Compare commits

...

344 Commits

Author SHA1 Message Date
Dion Moult dd902bf0f8 Use fixed overlay text font
Use Qt's system fixed font for viewer overlay text instead of the generic monospace family, avoiding the Windows font-resolution delay seen during measurement overlays.

Generated with the assistance of an AI coding tool.
2026-05-26 13:36:03 +10:00
Dion Moult 371aabfef6 Disable Autodesk connector UPX
Build the PyInstaller Autodesk connector without UPX compression. UPX-packed launchers are more likely to trigger enterprise Windows security scanning, and the connector is distributed as a fresh unsigned artifact for each build.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 1c22fa0669 Use bound overlay uploads
Update the overlay renderer's dynamic VBO uploads to bind the buffer and use glBufferData/glBufferSubData instead of direct-state glNamedBufferData/glNamedBufferSubData.

This avoids Windows/NVIDIA driver corruption seen with overlay axes, pick markers, HUD rects, and marquee rectangles while keeping the same overlay geometry and draw paths.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult a91b1da28b Reduce Rocky package size
Build Rocky artifacts with shared IfcOpenShell libraries and keep geometry writer plugins out of executable packages while preserving them for Python packages.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 89cb551dbb Run Autodesk upload/download on a worker thread
The progress dialog was the only connector window not driven by a Tk
event loop: the handler created it, then blocked inline in httpx I/O.
On Windows CTkToplevel withdraws itself at construction and re-shows via
a delayed after() callback, which never fires without a running loop, so
the progress window stayed invisible for the whole transfer.

Add run_with_progress(): the blocking work runs on a daemon thread while
the main thread pumps the Tk loop and shows the dialog. Progress reports
are coalesced and marshalled back to the UI thread via _ProgressBridge,
and worker exceptions are re-raised on the main thread, preserving the
JSON-RPC error path. All eight upload/download handlers converted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 102ac551b3 Add VisibilityState/SelectionState tests; test real quantization helpers
test_instanced_geometry previously re-implemented vertex quantization
inline, with a stale comment claiming the helpers still lived in
ViewportWindow.cpp. They now live in VertexQuantization.h, so route the
test through the real quantizeVertex/octEncodeNormal and add coverage
for the degenerate-axis path, octahedral normal round-trip, the i8
normal error bound (~0.78 deg worst observed), and color passthrough.

Add test_visibility and test_selection: Tier-1 coverage of the two
per-object viewport state machines. Both are QObjects for their
changed() signal but touch no GL on the construction/mutation path, so
the tests exercise the pure CPU logic without a context.

Suite goes from 39 to 61 cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 137a890256 Add test suite for the Bonsai Viewer Autodesk connector
Introduce pytest coverage for the previously untested connector — rpc,
cache, settings, autodesk (auth + APS client) and connector handlers —
94 tests, runnable via the new `test` optional-dependency extra.

To make HTTP, time and the OAuth redirect testable without a network or
real sockets, add dependency-injection seams to autodesk.py:
AuthSessionService and ApsClient accept an optional httpx transport;
AuthSessionService accepts an injectable clock and callback_waiter; and
_wait_for_callback is extracted to the module-level wait_for_oauth_callback.
All seams default to the previous behaviour.

Remove the APS_CLIENT_ID environment-variable override: the client id now
comes solely from settings.json, collapsing settings.load_client_id and
simplifying the settings dialog.

CI: the build-bonsaiviewer-autodesk workflow gains a `test` job
(Python 3.11 + 3.13) that gates the build matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult de7520418b Build the Bonsai Viewer in CI with the Autodesk connector bundled
Compile the Bonsai Viewer as part of the Linux and Windows binary builds,
and ship the Autodesk connector alongside the viewer executable.

Qt6 dependencies:
- The viewer links Qt6::Svg for runtime icon tinting. Svg is a separate
  base-Qt archive, so aqt now installs "qtbase qtsvg" (plus icu on Linux)
  rather than qtbase alone, on both Linux and Windows.
- Qt6::CorePrivate is exposed differently across Qt versions: Qt 6.8 ships
  the target inside Qt6Core, while Qt 6.10 provides it only as a separate
  CorePrivate config package. The viewer CMakeLists requests it via
  OPTIONAL_COMPONENTS so it resolves on both.
- When cross-compiling Windows ARM64, windeployqt runs from the host x64
  Qt, so qtsvg is installed into the host Qt as well.

Windows build:
- build-all-win.py passed -DBUILD_IFCVIEWER, a flag since renamed to
  BUILD_BONSAIVIEWER, so the Windows build compiled no viewer at all. It
  now passes -DBUILD_BONSAIVIEWER.
- The Autodesk connector is bundled under connectors/ next to
  BonsaiViewer.exe in the packaged archive, mirroring the Linux builds.
- The Windows workflow builds the connector (PyInstaller) before the main
  build so it is available to bundle.

Connector bundling:
- The Linux rocky workflows build the connector and bundle it into the
  BonsaiViewer archive; the Windows build now does the same.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult e8a93846dd Bundle connectors next to the Bonsai Viewer executable
Connector discovery scanned a per-user data directory
(QStandardPaths::GenericDataLocation -> ~/.local/share/IfcOpenShell/
BonsaiViewer/connectors and the macOS/Windows equivalents). Connectors
are now meant to ship with the application, so there is no reason to
look outside the install tree.

Replace userConnectorsDir() with bundledConnectorsDir(), which returns
QCoreApplication::applicationDirPath() + "/connectors". discoverConnectors()
scans only that path; its first-wins / malformed-manifest handling is
unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 96941463c0 Simplify Models panel and dialog layout
Models panel: replace the manual resizeEvent column-sizing hack with
QHeaderView Stretch/Fixed modes, re-applied via sectionCountChanged so
they survive the model rebuilds that QHeaderView resets them on.

Dialog: only wrap the body in a QScrollArea when scrollable, mirroring
Panel. The scroll area caps its sizeHint at 36x24 cells, which turned
wide fixed-size dialog content into spurious scrollbars.

Add Model dialog: reserve a stable, font-metrics-measured height for the
hover description so longer text never reflows the buttons; regroup the
buttons into LOCAL / CLOUD / TOOLS.

Buttons: move the trailing-separator decision out of makeButtonGroup
into a new addButtonGroups row builder, so the last group in a row
never draws a dangling divider.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 19bff92a47 Fix data races when parsing IFC files on concurrent threads
Loading a federated project (.ifcfed) with several models segfaults
non-deterministically on a fresh start. The viewer's SceneLoader spawns
one background std::thread per model in startDataSourceLoad() to
construct an ifcopenshell::file; with cached sidecars all models reach
that point near-simultaneously, so multiple threads parse different IFC
files at once. Parsing touches the process-wide schema singleton, which
was not thread-safe in two places.

Race 1 — concurrent schema population
-------------------------------------
schema_registry::get() lazily runs the schema's get_() function (e.g.
Ifc4::get_schema() -> IFC4_populate_schema()) and mutates entries_ with
no lock. Two threads calling schema_by_name("IFC4") at once both run
IFC4_populate_schema() concurrently, which fills global arrays
(IFC4_types[], strings[]). One thread reads a slot the other is still
writing.

Core-dump evidence (gdb thread apply all bt):

  Thread 1  SIGSEGV in IFC4_populate_schema   Ifc4-schema.cpp:1989
            <- Ifc4::get_schema
            <- schema_registry::get           schema.cpp:241
            <- schema_by_name("IFC4")
            <- ifcopenshell::file::file (NWCH-PIR-SS...ifc)
            <- SceneLoader::startDataSourceLoad lambda  SceneLoader.cpp:315

  Thread 3  also in IFC4_populate_schema (entity ctor for
            "IfcMaterialProfileSetUsageTapering")
            <- Ifc4::get_schema
            <- schema_registry::get           schema.cpp:241
            <- ifcopenshell::file::file (NWCH-PIR-PT...ifc)
            <- SceneLoader::startDataSourceLoad lambda

Two threads inside IFC4_populate_schema() at the same time is the race.

Fix: guard schema_registry's bind()/get()/names()/clear() with a
recursive_mutex (recursive because get() re-enters bind() via
load_schema_plugin(), and a freshly populated schema registers itself
through register_schema()). get() is serialized, so only the first
thread populates the schema; the rest block briefly and then observe
the finished result. Returned schema pointers are stable for the
process lifetime, so holding the lock only across get() is sufficient.

Race 2 — lazy all_attributes_ cache filled during parsing
---------------------------------------------------------
entity::all_attributes() lazily fills a `mutable` optional cache on the
shared schema entity the first time it is accessed — and that first
access happens during parsing (parse_context::construct), not during
schema population. With race 1 fixed, two parser threads still raced
here: both saw the cache empty, both did all_attributes_.emplace() and
std::copy() into it, corrupting the vector.

Core-dump evidence after the race-1 fix:

  Thread 1  SIGSEGV in attribute::type_of_attribute (this=0xe130...55c)
            <- std::transform(first=0x4, last=0xb0d1...)   <-- garbage
               iterators into a corrupt std::vector
            <- parse_context::construct over
               decl->as_entity()->all_attributes()        file.cpp:249
            <- instance_streamer::read_instance
            <- ifcopenshell::file::file (NWCH-PIR-PT...ifc)
            <- SceneLoader::startDataSourceLoad lambda

The begin pointer 0x4 is a half-written vector being read mid-resize by
another thread.

Fix: force every entity's all_attributes_ cache in the
schema_definition constructor, while construction is still
single-threaded. The schema is then genuinely immutable after
construction, so concurrent parsing needs no hot-path lock.

Both crashes reproduce reliably on a fresh start at native speed but
vanish under gdb (which serializes thread scheduling) — the classic
signature of a data race. With both fixes the federated load completes
cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 66f3593de1 Add Bonsai Viewer docs
Create a standalone Sphinx docs tree for Bonsai Viewer and migrate the Autodesk connector Markdown documentation into RST.\n\nGenerated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult bb17cfbc40 Rename Bonsai Viewer build option
Replace the old IFC viewer build switch with BUILD_BONSAIVIEWER in CMake, the Linux workflows, and the nix build script.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 4913b84af7 Add recent projects to Bonsai Viewer
Replace the "Open Recent coming soon" placeholder with a working
most-recently-used project list. RecentProjects persists .ifcfed paths
via QSettings, capped and pruned to existing files. The Open Recent
ribbon button now shows a popup menu of recent projects; every
successful open or save (local or cloud) records an entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 8734b419e5 Improve Autodesk connector browsing and progress UI
Sort hubs, projects, folders and files alphabetically. Allow
multi-select when adding models so several can be pulled at once.
Rework the progress dialog into a fixed-shape two-line layout that
shows percent and byte counts, middle-eliding long filenames so the
window never reflows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 49f6f64e5c Add Autodesk callback port setting
Persist the OAuth callback port in connector settings, expose it in the settings dialog, and use it when constructing the localhost callback URL.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult dc82171013 Document output formats
Rename the user-facing serialisers page to formats and document .rdbview as a Bonsai Viewer package.\n\nGenerated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 084c87ea06 Rename ifcviewer-autodesk connector to bonsaiviewer-autodesk
Follows the host viewer's rename to Bonsai Viewer: directory, Python
package, entry point, PyInstaller spec, keyring service, and on-disk
config/cache paths all use the bonsaiviewer-autodesk name. CI workflow
filename and path filters updated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 59e5b2b1b8 Rename IfcViewerFull to Bonsai Viewer
Directory src/ifcviewer-full -> src/bonsaiviewer, CMake target
IfcViewerFull -> BonsaiViewer, namespace ifcviewerfull -> bonsaiviewer,
QApplication / window titles / connector path now use the new brand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult fcae3d90af Add CI workflow for Autodesk connector builds
Build the ifcviewer-autodesk connector bundle on push/PR/dispatch for the four supported targets: linux-x86_64 (ubuntu-22.04, oldest reasonable glibc), macos-arm64, macos-x86_64, and windows-x86_64. Each job runs packaging/build.py and uploads the resulting autodesk-<os>-<arch>.zip as an artifact.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 486b858d56 Wire IfcViewer to cloud sync connectors
Implements the viewer side of CLOUD_SYNC_PROTOCOL.md: connector
discovery, JSON-RPC stdio host, and Open/Save/Sync/Add cloud workflows
wired through the ribbon, Models panel right-click, and Settings tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:19 +10:00
Dion Moult 95c62cc70e Add Autodesk cloud sync connector
Initial implementation of the ifcviewer-autodesk connector — a separate process that bridges the IfcViewer to Autodesk APS (BIM 360 / ACC). Speaks JSON-RPC 2.0 over stdio per CLOUD_SYNC_PROTOCOL.md (also added). PKCE OAuth with keyring-backed token storage, customtkinter browse/picker UI, and PyInstaller packaging.

Implements both interactive and non-interactive variants of each push/pull (pull_ifcfed[_interactive], pull_models[_interactive], push_ifcfed[_interactive], push_model[_interactive]) so the viewer can offer both "Save"/"Open from Cloud" and "Save As"/"Add Model from Cloud" entry points. File transfers report progress through a dialog with per-byte updates; pull_models shows "(i/N)" for batches.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult 6ca38f8bf6 Derive map units from IFC scale
Use IfcMapConversion.Scale as the source of truth for converting map coordinates to metres, instead of deriving that scale from IfcProjectedCRS.MapUnit. Bump the sidecar version because cached georef matrices and unit scales may differ under the new interpretation.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:19 +10:00
Dion Moult e0a504417c Preserve precise viewer placements
Keep placement transformations in double precision through streaming, sidecar caching, and viewport recomposition so large coordinates can be cancelled before the final GPU float upload.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 8b8fafa698 Unify sidecar production via SidecarBuilder
Renamed HeadlessSidecarBuilder to SidecarBuilder and reused it for live
loads. SceneLoader now constructs one per stream load, forwards meshReady
/instanceReady chunks alongside the viewport upload, and finalizes +
writes the sidecar at onStreamerFinished — no more GPU readback path
via ViewportWindow::snapshotModel (removed). Same code path now produces
sidecars for both live loads and the .rdbview offline export.

Sidecar use is opt-in per direction via SceneLoader::setShouldReadSidecar
and setShouldWriteSidecar; both default off so embedders that don't want
caching get a pure-streaming loader. ifcviewer-full and ifcviewer-minimal
opt in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 5f467cd8fa Interface mockup 19 2026-05-25 16:34:18 +10:00
Dion Moult 8242efac97 Mutex bug fixed so remove hack 2026-05-25 16:34:18 +10:00
Dion Moult 22f25f0098 Interface mockup 18 2026-05-25 16:34:18 +10:00
Dion Moult fed739847e Add geometry database (.rdbview) export to IfcViewerFull
Wire a new "Export Geometry Database" tool button in AddModelDialog,
adjacent to "Convert IFC File to Database", to produce a zipped
read-only artifact combining a lossy RDB (with IfcRepresentationItem
stripped) and a .ifcview geometry sidecar. Intended for cloud
coordination workflows where parametric geometry editing is not needed.

Pipeline changes to support this:

- document_serializer_context gains a `skip_supertypes` field; the
  rdb plugin forwards it to RocksDbSerializer so the same registry
  path produces full or lossy RDBs.

- Vertex quantization helpers (octEncodeNormal + quantizeVertex) move
  out of ViewportWindow.cpp into a shared header so the sidecar's
  byte layout stays identical regardless of whether it came from a
  GPU readback or a CPU pipeline.

- New HeadlessSidecarBuilder runs a GeometryStreamer on the calling
  thread, captures MeshChunk/InstanceChunk into a SidecarData on the
  CPU, then computes georef + packed elements + LODs and writes the
  .ifcview — no ViewportWindow or GL context required.

The Controller's export flow runs RDB conversion + sidecar build +
QZipWriter packaging on a background QThread, writing through
`<dest>.tmp` then renaming for atomic appearance in cloud-sync
folders. ifcviewer-full now links Qt6::CorePrivate for QZipWriter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 56273de443 Add IFC to RDB conversion in IfcViewerFull
Wire the AddModelDialog "Convert IFC File to Database" button to a new
ConvertToDatabase source mode handled by ModelsPanelController, which
prompts for an .ifc input and .rdb output then runs the existing
document_serializer_registry "rdb" plugin on a background QThread with
a modal progress dialog.

Build the src/serializers subdir for BUILD_IFCVIEWER so the rdb plugin
is produced, and align serializer plugin runtime output with the
kernel/mapping plugins by writing them into $<TARGET_FILE_DIR:IfcGeom>
so default plugin discovery finds them in both dev and install layouts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 6901533368 Clean up IfcViewerFull naming
Rename leftover interface-era namespaces, settings, and resource identifiers inside the IfcViewerFull source tree without changing the public target name.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 4a91e6a87d Swap interface into IfcViewerFull
Replace the old IfcViewerFull application tree with the interface-based viewer while preserving the IfcViewerFull target and build workflow.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 03b8b9c1bd Refactor model settings georef view
Move model georeferencing state and rendering into a dedicated settings view, and show live IFC coordinate operation and unit data in the dialog.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 8f937052b8 Show ENH for first length pick
Style hidden interface models with disabled text and move the first length-tool pick coordinates to the HUD as ENH in the global georeferenced frame.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult b1bad23a64 Add interface load progress bar
Show a real status-bar progress bar for interface model loads by wiring the shell window to SceneLoader progress and completion signals.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult d18c05d4bd Add interface model group reparenting
Add group rename and reparenting, model-to-group moves, drag-and-drop reassignment, and clearer group creation actions in the interface models panel.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult cee2dbcc85 Add interface sidecar writeback
Port the streamed-model sidecar writeback path into the interface, including packed element metadata, georef persistence, LOD generation, and viewport LOD application.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult a755e96c13 Gate LOD test on meshoptimizer
Keep the IfcViewer test CMake in sync with the optional meshoptimizer dependency so test_lod_builder is only added when the package is enabled.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 9c1a10190d Interface mockup 17 2026-05-25 16:34:18 +10:00
Dion Moult a4f7db45cd Interface mockup 16 2026-05-25 16:34:18 +10:00
Dion Moult 6df9aeda2c Interface mockup 15 2026-05-25 16:34:18 +10:00
Dion Moult 1c598a981e ifcviewer: per-element visibility (H / Shift+H / Alt+H)
Adds VisibilityState, a CPU-only sibling to SelectionState.  It owns
the canonical hidden-id set plus a flat per-object_id byte vector that
the cull's hot path queries inline (bounds check + byte load + compare
per surviving instance).  Hidden elements never reach the visible[]
SSBO so they don't draw or pick — matching Blender/CAD convention.

ViewportWindow registers every streamed and sidecar-cached object_id
with the new state, resets it on clearScene, and connects the changed
signal to invalidate cached cull state.  Three convenience verbs:
hideSelectedElements (union into hidden), isolateSelectedElements
(replace hidden with live-object_ids minus selection, skipping
model-hidden models so element-hide doesn't pile on top of model-hide),
and showAllElements (clears the override; model-hidden models stay
hidden, per the user's spec).

Bound in the View menu: H hide, Shift+H isolate, Alt+H show all.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 742561c56d Interface mockup 14 2026-05-25 16:34:18 +10:00
Dion Moult 05a15e84a6 Configurable navigation bindings 2026-05-25 16:34:18 +10:00
Dion Moult a900f2c437 ifcviewer: promote runtime perf knobs, drop always-on settings
Promotes five env-var-driven knobs to AppSettings + the settings dialog
(min pixel radius, motion min pixel radius, LOD1 pixel threshold, HiZ
resolution, HiZ on/off).  Defaults: motion min pixel radius is now 10
(was 0/disabled) and IFC_HIZ_MOTION is on by default — the strict
view-projection gate reverts via env var =0 when chasing HiZ
correctness bugs.  ViewportWindow connects each *Changed signal so
changes invalidate cached cull state and take effect on the next
frame.

Removes "Load Property Data Source" and "Apply Coordinate Operation"
from the settings dialog: both are now hardcoded on.  The basic-info
property fallback (used when there's no live IFC source for an object,
e.g. .ifcview without a sibling) now triggers organically when
ElementRegistry::findEntity returns null instead of being gated on a
user toggle.  Federation::guessFederatedFalseOrigin lost its
apply_coordinate_operation parameter and now uses
georef.has_coordinate_operation directly.

src/ifcviewer/settings.rst documents the remaining diagnostic env vars
(IFC_HIZ_MOTION, IFC_CULL_THREADS, IFC_SKIP_MDI, IFC_MAX_SUBDRAWS,
IFC_FPS_HITCH_MS, IFC_SUBDRAW_DIAG, IFC_LOD_*) plus a cross-walk from
the old promoted-knob env-var names to their new QSettings keys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 0a297babea ifcviewer-full: volume tool with HUD + per-object labels
Mirrors the Area tool's display: HUD shows total volume + object count,
each selected object gets a label at its world-AABB centroid showing
its individual volume.  Gated behind ToolMode::Volume (Ctrl+Shift+V) so
it stays out of the way until invoked.

Volume is a passive tool — selection works as in None (multi-select,
modifier toggle, box-select all keep working).  Area / Length still
intercept clicks through surfacePickedInTool.

Adds volumesPerObject() reusing the same mesh-cached readback path as
volumeOfObjects, so the per-object split costs no extra GL readbacks.
computeObjectAabb is promoted to public for the centroid lookup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult bba2f30816 ifcviewer: multi-selection + box-select with active highlight
SelectionState (new) owns the multi-set, the "active" id (last single-
clicked), and a per-object_id flags SSBO bound at binding=3.  Main
shader reads sel_flags[v_object_id] for the in-set tint and a separate
u_active_id uniform for a stronger tint on the active.

Click semantics: plain replaces, Shift/Ctrl toggles.  LMB-drag past 5px
boxes the rect through a pick-pass readback — plain replaces, Shift
adds, Ctrl removes; box-select preserves the active.  Drag promotes
regardless of start point so a press on geometry doesn't disqualify it.

Sidecar fast-path bulk-loads instances, so noteObjectId is also called
from the apply path — without it the flags buffer was sized to 1 slot
while object_ids were in the 100k+ range and the in-set bit was lost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult d0c8c84e7b Add model coordinates settings
Add the federation/model settings dialog and related interface wiring for model coordinate configuration.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 7f153375b2 Rename interface modules
Move interface features from panels into modules, move AddModelDialog into the models module, and rename module Widget surfaces to Panel.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult d25c2583da ifcviewer: fix progress bar capping at ~1/n during streaming
The streamer carved [0,100] evenly across N prioritised contexts (and
again across the net/gross passes).  In practice nearly every element
yields from the first (Body) context, so smooth progress only ever
filled range/n of the bar — typically ~20% — before snapping forward.
Drive progress directly from yielded element count over total instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult 027e2406be Interface mockup 13 2026-05-25 16:34:18 +10:00
Dion Moult e864a961ee ifcviewer-full: per-patch area labels + skip redundant 2-pt perpendicular
Area mode now drops a label at every connected coplanar patch — a
BFS sweep over selected_ restricted to each mesh's edge adjacency
identifies the components, then each component gets one label at
its area-weighted centroid in world space.  Two clicks on
different walls now show two distinct numbers; a single BFS-grown
wall face stays one number across all its triangles.

The 2-pt length perpendicular line is now omitted when |perp|
matches any of ΔX/ΔY/ΔZ within 1mm — the surface-aligned-with-
axis case where the perpendicular is already shown by one of the
RGB legs.  Avoids redundant double-readout on axis-aligned walls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult d8f37b2376 ifcviewer-full: 1-pt laser, 2-pt XYZ + perpendicular, sharper visuals
Length tool's 1-pt laser is now hybrid:
  - On any surface, a coplanar BFS finds the connected face patch
    around the click and projects its vertices into the surface
    tangent basis to get an exact bounding-box extent.  Stops at
    the face edge by construction — no overshoot into adjacent
    geometry like the previous tangent-raycast did.
  - On near-horizontal surfaces (|n.z| > 0.85, i.e. floors and
    ceilings) it additionally fires one raycast in +n to the
    opposing surface — so a single floor click reports X extent +
    Y extent + ceiling height.
  - Bars are labelled by their dominant world axis (X/Y/Z) instead
    of "vertical/horizontal", which reads cleanly on either kind
    of surface.

The 2-pt readout now draws the world-space XYZ stair-step (red ΔX,
green ΔY, blue ΔZ) with each leg labelled, and a dashed
perpendicular line whenever the two picks landed on near-parallel
surfaces — useful for measuring across walls.

To support multiple line styles per frame, OverlayRenderer's
setOverlayLines takes std::vector<LineGroup> instead of a single
inline style; each group has its own color/halo/width and an
optional dash period.  The line shader gained v_along_px +
u_dash_period uniforms (screen-space dashes), and both line and
point shaders now use a sharp step() for the inner→stroke
transition with AA only on the outer halo edge — much crisper than
the previous soft band.  Default visual style trimmed: 1.5px lines
(0.5px halo), 6px dots (1px halo), opaque black halo.

Also adds ViewportWindow::raycast(origin, dir, RaycastHit&) — CPU
ray traversal of each model's per-instance BVH followed by
Möller-Trumbore against the candidate meshes' triangles (lazily
read back, cached per call).  Used by the floor/ceiling laser path
today and reusable for any future raycast-based feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Dion Moult acfcf6e14e Fix sidecar source loading
Treat .ifcview sources as geometry-only cache inputs, stop guessing sibling data paths, and only start data-source loading for real model sources after a sidecar hit.

Generated with the assistance of an AI coding tool.
2026-05-25 16:34:18 +10:00
Dion Moult 5bc7e4b98b ifcviewer-full: length tool (2/3/4+ point distance, angle, polygon area)
ViewportWindow trades the area_tool_active_ bool for an enum ToolMode
{None, Area, Length}; the existing surfacePickedInTool signal carries
both, the app dispatches on toolMode().  Esc exits any active tool;
Backspace/Delete in length mode emits toolBackspacePressed which the
length tool uses to remove the last point.

LengthMeasurement collects clicked world-space points and adapts the
readout: 2pt → distance + axis-aligned ΔX/ΔY/ΔZ, 3pt → angle at the
middle vertex + triangle area, 4+pt → best-fit-plane PCA + shoelace
when planar (RMS plane distance / bbox diag < 1e-3) else fan
triangulation, with the chosen method labelled in the readout.  Per-
segment lengths float at each midpoint.

OverlayRenderer grows three new pipelines to support this:
  - point sprite shader: gl_PointCoord-based outlined disc with
    fwidth-smoothed inner/stroke bands, a single draw call.
  - line shader: CPU-expand each segment to 6 verts carrying both
    endpoints + (side, along) corner index; vertex shader computes
    the screen-space perpendicular and offsets accordingly.  Real
    outlined lines independent of the driver's glLineWidth clamp.
  - screen-space rect shader: HUD + label backgrounds drawn as raw
    GL quads in NDC.  QPainter::fillRect on QOpenGLPaintDevice was
    silently dropping fills across drivers; bypassing it entirely
    via this shader makes backgrounds reliable.  Cull-face is also
    explicitly disabled here — GL_TRIANGLES respects it but the
    line/point primitives don't, so this was the one path needing
    the fix.

setOverlayLines / setOverlayPoints take an inner color, an outline
color, and an extra-pixels-per-side stroke amount.  Lines + points
draw with GL_ALWAYS so measurement annotations stay visible through
geometry; highlight tris stay depth-aware (GL_LEQUAL) so area
shading still tints the surface in place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:34:18 +10:00
Thomas Krijnen c8d39cc481 At least make sure that hybrid-cgal-simple-opencascade processes the elements correctly #8052 2026-05-21 14:17:17 +02:00
Thomas Krijnen 1cf9373000 Add test_shape_stats #8054 2026-05-18 19:25:13 +02:00
Thomas Krijnen d8c9d1a47b submodule 2026-05-18 19:24:25 +02:00
Thomas Krijnen 0993c1b0f1 Submodule 2026-05-18 18:56:37 +02:00
Thomas Krijnen 22384e136f Fix manifold kernel halfspace direction #8054 2026-05-18 18:18:05 +02:00
Thomas Krijnen 6dea7a5110 Calculate normals in manifold kernel 2026-05-18 18:16:49 +02:00
Thomas Krijnen 424e70ac86 Remove svgfill test in pyodide wheel test 2026-05-10 20:57:46 +02:00
Thomas Krijnen fceb8911f6 Updates to workflow 2026-05-10 19:16:07 +02:00
Thomas Krijnen 94f4dbcf31 Add missing file 2026-05-10 17:01:11 +02:00
Thomas Krijnen 391f8363bb zip .so links (untested) 2026-05-10 16:54:50 +02:00
Thomas Krijnen 210861eda1 Strip to try and get back some file size increase 2026-05-10 16:54:30 +02:00
Thomas Krijnen a2012ace2e Proper svgfill isolation 2026-05-10 14:33:07 +02:00
Thomas Krijnen 05e328339f Try again with Qt install on Rocky 2026-05-10 13:44:58 +02:00
Thomas Krijnen cd612dc988 Add missing files 2026-05-09 22:00:46 +02:00
Thomas Krijnen 44ba6e8963 Untested build script updates for qt and viewer app 2026-05-09 21:59:58 +02:00
Thomas Krijnen c3a10694ba draw.py use settings instead of direct member methods 2026-05-09 21:19:55 +02:00
Thomas Krijnen da5ebf172e Delete 7za.exe from repo 2026-05-09 21:05:08 +02:00
Thomas Krijnen c1173dfc78 Additional plug-in host to try and fix arm64 build 2026-05-09 21:04:32 +02:00
Thomas Krijnen fde502daa1 svgfill as plug-in 2026-05-09 21:03:55 +02:00
Thomas Krijnen 2eb2d65710 Allow passing buffer to serializers that support it 2026-05-09 21:03:15 +02:00
Thomas Krijnen 0d8e9f0edc Explicit cast to make clang 21 happy 2026-05-09 20:28:27 +02:00
Thomas Krijnen 3c773d71d3 If it's an EMSCRIPTEN build, we're not done 2026-05-09 20:08:28 +02:00
Thomas Krijnen 43f5a79f99 Try with manifold on again 2026-05-08 20:47:16 +02:00
Thomas Krijnen 12935c83de Silent extraction 2026-05-08 20:31:29 +02:00
Thomas Krijnen e7249fe934 BUILD_IFCVIEWER=ON 2026-05-08 20:10:19 +02:00
Thomas Krijnen cf257aa20a check_installation for qt6 - was not aware of this bit 2026-05-08 19:08:08 +02:00
Thomas Krijnen 7d0b6f6fd8 Examples=Off for now 2026-05-08 17:59:38 +02:00
Thomas Krijnen 554c7174e3 Backspace everything regarding HDF5 2026-05-08 16:20:26 +02:00
Thomas Krijnen bd436765bf Win packaging changes 2026-05-08 14:05:17 +02:00
Thomas Krijnen 5270318570 Tweak Qt install handling 2026-05-08 13:40:21 +02:00
Thomas Krijnen 8fcaa18171 Tweak output names 2026-05-08 13:39:52 +02:00
Thomas Krijnen 8d0a6ecc16 Respect unicode setting for plugin debug info 2026-05-08 13:39:36 +02:00
Thomas Krijnen bfea57c617 Wire up serializer plug-ins in python 2026-05-08 10:58:09 +02:00
Thomas Krijnen 18b79a4360 Rocksdb streaming serializer connect to IfcConvert 2026-05-08 10:57:58 +02:00
Thomas Krijnen 05cd19592d Qt install (not working) 2026-05-08 10:56:41 +02:00
Thomas Krijnen a1efdccb4b Add back mutex 2026-05-08 10:07:34 +02:00
Thomas Krijnen 6ad5fbb27e Add qt6 to win build scripts using an 3rd party install script 2026-05-07 22:32:53 +02:00
Thomas Krijnen 884e7ba326 Make meshoptim optional 2026-05-07 22:31:15 +02:00
Thomas Krijnen 82f188fbe6 IfcViewer needs to be static because it does not export anything 2026-05-07 22:31:00 +02:00
Thomas Krijnen 31ddc0ecdb Respect plus-sign in versions in split_pyodide___.py 2026-05-07 22:09:00 +02:00
Thomas Krijnen 02198e3f56 Update wasm demo app for modular wheels 2026-05-07 22:08:19 +02:00
Thomas Krijnen b1899b1a8d Expand schema_plugin with schema name to eliminate symbol collisions 2026-05-07 21:10:35 +02:00
Thomas Krijnen 39d583a26a Merge remote-tracking branch 'origin/ifcviewer' into datamodel-v1.0 2026-05-07 21:01:56 +02:00
Thomas Krijnen 609b6959bc set_plugin_search_paths() inside test as well 2026-05-07 20:58:45 +02:00
Thomas Krijnen 2c7d25cffc Reorder .so files in wheel so that symbol dependencies do not trip up loading 2026-05-07 16:31:13 +02:00
Thomas Krijnen e893552f24 Fixes to plug-in loading in and outside of pyodide 2026-05-07 14:43:26 +02:00
Dion Moult bae1eddda9 Interface mockup 12 2026-05-07 17:10:04 +10:00
Dion Moult ad62dd6d91 ifcviewer: viewport overlay subsystem (highlight tris + HUD text)
New OverlayRenderer module owns every client-supplied overlay primitive
drawn after the main pass: tinted, depth-aware highlight triangles via
its own GL shader, and top-left HUD text via QPainter on a
QOpenGLPaintDevice.  Public surface on ViewportWindow is just two
forwarders (setHighlightTriangles, setHudText).

ViewportWindow's MeshLocalPick now exposes the instance's composed
transform so consumers can map mesh-local geometry back to world
space without re-querying.  AreaMeasurement uses both: its selection
key is now (object_id, tri) so per-instance highlighting works for
two distinct walls sharing a mesh, and on every pick it rebuilds the
world-space tri list and the HUD readout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:43:38 +10:00
Dion Moult d565dc3ff3 Interface mockup 11 2026-05-07 14:45:23 +10:00
Dion Moult a340e6cf9a Interface mockup 10 2026-05-07 14:17:06 +10:00
Dion Moult 359693c562 Interface mockup 9 2026-05-07 12:21:55 +10:00
Dion Moult 016c278748 ifcviewer-full: console-print accumulating coplanar-patch area tool
Adds a click-to-measure area mode triggered by Ctrl+Shift+A.  Each LMB
click expands the picked triangle into its connected coplanar patch
(BFS over shared edges, dot(normal, seed) > 0.9999); re-clicking
removes that patch; Alt+LMB skips expansion for a single triangle.
Picks across different meshes accumulate as separate patches.

ViewportWindow gains pickMeshLocalAt (screen pick → mesh-local hit
via inverse composed transform) and a tool-mode pattern mirroring
the section tool (toggleAreaTool, surfacePickedInTool signal,
areaToolToggled signal, Esc to exit).  Per-mesh adjacency is built
lazily on first pick of each mesh and dropped on tool toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:03:22 +10:00
Dion Moult f2b655fcf6 ifcviewer-full: print object volume on click via lazy GPU readback
Adds neutral primitives on ViewportWindow (readbackMeshTriangles,
findInstance) so consumers can compute per-object geometry queries
without the library retaining a CPU triangle copy. Measurement.cpp in
ifcviewer-full uses them to sum signed-tetrahedra in mesh-local space,
weighted by |det(placement_3x3)| per instance for mapped-item scaling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 11:25:30 +10:00
Dion Moult 0bb6df6a6b ifcparse: skip flush+compact for read-only RocksDB on destruction
Read-only handles reject Flush/CompactRange, so the destructor's status
assertion always fired on shutdown when the streamer's sidecar was
opened with read_only=true. Track the flag and skip the write path; also
guard against a null db when the initial open failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 10:16:15 +10:00
Thomas Krijnen 2257d7930a For schema plugins also wasm-opt -O1 2026-05-06 21:38:08 +02:00
Thomas Krijnen 98ff457fd6 Continue work on plug-in and tests 2026-05-06 21:17:57 +02:00
Dion Moult f07afda09c Interface mockup 8 2026-05-06 21:35:12 +10:00
Thomas Krijnen 4670715ef3 Work a bit on failing tests 2026-05-06 11:41:43 +02:00
Dion Moult 5b2721c1c4 Interface mockup 7 2026-05-06 13:13:37 +10:00
Dion Moult 0d5fa0c20c Interface mockup 6 2026-05-06 12:36:28 +10:00
Dion Moult 802ddf8ff9 Interface mockup 5 2026-05-06 10:47:03 +10:00
Thomas Krijnen ea4747ccb1 SIDE_MODULE=2 for plug-ins 2026-05-05 21:43:58 +02:00
Dion Moult e20cea221b Interface mockup 4 2026-05-05 19:59:51 +10:00
Dion Moult ae66550cae Interface mockup 3 2026-05-05 18:02:02 +10:00
Dion Moult 5ccb655e10 Interface mockup 2 2026-05-05 09:40:58 +10:00
Dion Moult 278c1e5068 Interface mockup 2026-05-05 07:29:32 +10:00
Dion Moult 095e4a1677 ifcviewer: nested groups in federation, with cascading visibility
Federation gains a nested Group tree (id, display_name, visible,
children); models reference a single group via Model::group_id.
Visibility cascades: a model is effectively visible only when its own
flag is on and every ancestor group is visible.  Persistence nests
groups directly in the JSON — no parent_id field.

ifcviewer-full surfaces this in the element tree with right-click
menus to create / rename / move / remove groups, move models between
groups, and toggle group visibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:39:28 +10:00
Dion Moult de5eb9641f ifcviewer-full: hide and remove model actions
Right-click a model root in the Elements tree to get Hide/Show and
Remove.  Hide flips the federation's per-model visible flag (already
round-tripped to .ifcfed), pushes ViewportWindow::hideModel/showModel,
and italicises + greys the tree root as a visual cue.  Remove drops
the model from the viewport, the SceneLoader (streamer + caches), the
MainWindow UI maps and tree, and the Federation — disabled while the
model is the active load.

Visibility is reapplied on each model's load completion (sidecar or
stream), so a federation saved with hidden models opens with them
hidden.  clearScene() now also drops SceneLoader state so streamers
no longer leak across federation transitions.

API additions:
- Federation::setModelVisible + modelVisibilityChanged signal
- SceneLoader::removeModel + isLoadingModel

Tests cover the setter (dirty + signal + idempotence + unknown id);
extends the existing round-trip test to actually exercise the
visibility load/save it always claimed to.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 12:22:14 +10:00
Dion Moult 4597be7fda ifcviewer: link Placement.cpp into test_federation
Commit f7add7f4 split getAxis2Placement out of an anonymous helper in
Geolocation.cpp into a shared Placement.{h,cpp}, but the test_federation
target's source list wasn't updated.  The test binary failed to link
with `undefined reference to getAxis2Placement(express::Base const&)`
from Geolocation::getWcs.  Add Placement.cpp to the explicit-source
list — it has no Qt dependency, only ifcparse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 12:21:50 +10:00
Dion Moult ca2e866c45 serializers: skip_supertypes filter in RocksDbSerializer streaming write
Plumbed through to the Python convert_path_to_rocksdb wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:28:12 +10:00
Dion Moult e84767c22b ifcviewer-full: multi-select directories in Add Database dialog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:14:52 +10:00
Dion Moult f7add7f412 ifcviewer: auto-guess FederatedFalseOrigin on first model added
When the user adds a model into a fresh, untitled federation that still
has the default (0,0,0, no rotation) FederatedFalseOrigin, derive an
origin from the first instance's placement_transformation (lifted
through CoordinateOperation when enabled) and the helmert grid-north
baked into ModelGeoref::coordinate_operation_meters.  Multi-file batches
naturally settle: whichever load finishes first anchors the federation,
the rest see a non-default origin and skip.  Saved .ifcfeds keep their
authoritative origin.

Adds Placement.{h,cpp} (port of util/placement.py — a2p,
get_axis2placement, get_local_placement) so Geolocation no longer needs
its own anonymous getAxis2Placement, and xaxis2angleDeg in Geolocation
mirroring util/geolocation.xaxis2angle.

SceneLoader captures the first instance's placement_transformation from
either the sidecar's InstanceCpu[0] or the streamer's first
InstanceChunk, so the guess works on both load paths without re-reading
the IFC.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:04:39 +10:00
Dion Moult 8ffdb8f0b9 ifcviewer: cache CoordinateOperation in sidecar (v10 -> v11)
Previously, applyCoordinateOperationToViewport — which pushes both
CoordinateOperation and ModelTransformation — was only called on
paths that required the IFC source to be loaded
(onLoadedFromStream and onDataSourceReady).  Sidecar-only loads
(loadDataSource off, or no .ifc/.rdb sibling) silently lost both
stages.

Cache the per-model georef + unit scales in the sidecar itself so
the IFC source isn't needed to apply them:

  SidecarData gains
    coordinate_operation_meters[16]  // column-major
    project_length_to_meters
    map_unit_to_meters
    has_coordinate_operation

  148 B fixed block written/read between instances and elements.
  SIDECAR_VERSION 10 -> 11; existing sidecars rebuild on next load.

  MainWindow::writeSidecarForModel populates the block from
  loader_->modelGeoref(mid) before writeSidecar.

  SceneLoader::applySidecarData restores it into the model's
  ModelGeoref + sets has_georef = true, so subsequent
  loader_->modelGeoref(mid) calls return the cached data without
  needing the IFC.

  MainWindow::onLoadedFromSidecar now calls
  applyCoordinateOperationToViewport(mid) directly — both
  CoordinateOperation and ModelTransformation land at sidecar-load
  time, no longer waiting on a possibly-never-arriving data source.

Edits to the IFC's IfcMapConversion don't invalidate the cache —
delete the .ifcview manually if the source's georef changes.  This
matches the existing cache-invalidation contract.

Tests: round-trip the new fields through the existing sidecar
fixture; assert SIDECAR_VERSION == 11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:08:45 +10:00
Dion Moult e0e143bab5 ifcviewer: F to frame selection from tree, debug coord dump
Tree -> viewport selection was already wired (onTreeSelectionChanged
calls setSelectedObjectId), but pressing F afterwards routed to the
focused tree widget rather than the viewport, so framing didn't fire.
Add a window-level View > Frame Selected QAction with Qt::Key_F that
delegates to ViewportWindow::focusOnSelectedObject — works regardless
of which child widget has focus.  The viewport's own F handler stays
in place for when the viewport itself owns focus.

For debugging coordinate problems, add View > Print Selected Coords
(Ctrl+Shift+P) -> ViewportWindow::printSelectedObjectCoords, which
qInfo's:
  - a sample vertex (first vertex of the selected mesh, decoded on
    demand from the quantised VBO so no extra CPU storage is needed);
  - placement_transformation (the per-instance matrix that maps the
    sample vertex from mesh-local into the model's pre-georef frame);
  - global = CoordinateOperation . placement_transformation (where
    the IFC's own IfcCoordinateOperation has been folded in);
  - the sample vertex transformed through both matrices.

The print is a no-op when nothing is selected or GL hasn't initialised.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:01:50 +10:00
Dion Moult 8f3eaa35d7 ifcviewer-full: per-model ModelTransformation editor
ModelTransformationDialog edits one federation model at a time.  Top
combobox picks the model; below it the form covers the four pieces
of authoring intent:

  - AFrame radio: ModelLocal vs ModelGlobal
  - Point A: 3 doubles, label switches between "model project length
    unit" and "model map unit" with the radio
  - Point B: 3 doubles in federation units (label reflects current
    FederationConfig.unit_*)
  - Rotation: rx/ry/rz in degrees, intrinsic XYZ
  - Pivot: 3 doubles in federation units

Switching models discards unsaved form edits — Ok saves the
currently-visible model, Cancel discards.  On Ok calls
Federation::setModelTransformation, which fires
modelTransformationChanged → MainWindow recomposes that model in
the viewport.

Reachable from File > Model Transformations.

End-to-end is now editable: open a federation, edit federation unit /
false origin from one dialog, edit any model's transformation from
the other, watch the viewport recompose live.  Visual verification
on a real model still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 08:06:25 +10:00
Dion Moult 81b1efdfd6 ifcviewer-full: federation settings dialog (unit + false origin)
New FederationSettingsDialog edits the federation-wide unit and the
FederatedFalseOrigin (XYZ + Z-rotation in that unit).  On Ok it calls
Federation::setConfig + setFederatedFalseOrigin, which fire the
granular Federation signals MainWindow listens to → viewport
recomposes immediately.

Reachable from File > Federation Settings.  Unit picker is a fixed
combobox of common length units (metres / mm / cm / km / ft / in /
yd / mi); each item carries (prefix, name) in itemData so saving
round-trips correctly.  Per-model ModelTransformation editor still
to come — that's a per-model dialog reachable from the model entry,
not the federation-wide settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 08:02:59 +10:00
Dion Moult 4a9af13626 ifcviewer: wire FederatedFalseOrigin / ModelTransformation to viewport
Federation grows three granular signals so consumers can recompose only
what's affected:
  - configChanged()                        — federation unit changed
  - federatedFalseOriginChanged()          — stage 3 changed
  - modelTransformationChanged(fed_id)     — stage 4 changed for one model

Emitted from setConfig / setFederatedFalseOrigin / setModelTransformation
in addition to the existing dirtyChanged.

MainWindow gains applyFederatedFalseOriginToViewport and
applyModelTransformationToViewport helpers.  Each composes the matrix
from the current federation state (using composeFederatedFalseOrigin /
composeModelTransformation, which already exist on Federation.h) and
pushes to the viewport's setFederatedFalseOrigin /
setModelTransformation.  ModelTransformation reads ModelUnits and the
active CoordinateOperation matrix from SceneLoader::modelGeoref so
ModelLocal-frame `a` lifts correctly through stage 2 when authored.

Wiring:
  - federation.federatedFalseOriginChanged -> applyFederatedFalseOriginToViewport
  - federation.configChanged               -> stage 3 + walk all models for stage 4
  - federation.modelTransformationChanged  -> stage 4 for that one model
  - applyCoordinateOperationToViewport now also re-pushes stage 4 (the
    compose result depends on the active stage 2 when a_frame is ModelLocal)
  - openFederation() pushes the loaded FederatedFalseOrigin once load
    completes; per-model stage 4 falls out of the existing
    onLoadedFromStream / onDataSourceReady path.

End-to-end pipeline is now active under the AppSettings toggle: edit
the federation in memory and the viewport recomposes immediately.  UI
for editing (form-based dialog) still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 07:58:26 +10:00
Dion Moult e9d577890e ifcviewer: gate CoordinateOperation on a settings toggle
AppSettings.applyCoordinateOperation (default false, persisted via
QSettings) controls whether each loaded model's IfcCoordinateOperation
is applied at upload time.  Off keeps models in their local engineering
frame (current behaviour).  On lifts each model into map coordinates
via the stage-2 georef matrix cached on SceneLoader.

MainWindow:
  - applyCoordinateOperationToViewport(mid) reads the toggle, fetches
    the model's ModelGeoref, and pushes either the
    coordinate_operation_meters matrix or identity to the viewport.
  - Called from onLoadedFromStream (streamer path) and onDataSourceReady
    (sidecar-hit path, where the IFC arrives asynchronously).
  - Subscribed to AppSettings::applyCoordinateOperationChanged: a
    runtime toggle walks every loaded model and re-applies, so users
    can flip georef on/off without reloading.

SettingsWindow gains a "Apply Coordinate Operation" checkbox alongside
the existing per-load toggles.

Default-off so the change is opt-in — users with georeferenced models
(UTM coords etc.) can flip the toggle to see them in their map frame
once they're ready.  Visual verification on a real georeferenced
model still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 07:45:40 +10:00
Dion Moult 7f29850022 ifcviewer: compose federation pipeline at SSBO upload
InstanceCpu now carries both placement_transformation (raw streamer
output, the iterator's per-shape transform with vertex-rebasing offset
folded in) and transform (the composed FederatedFalseOrigin ·
ModelTransformation · CoordinateOperation · placement_transformation
result that lands in the SSBO).  World AABBs are recomputed from the
composed transform — frustum/BVH culling sees the actual rendered
position regardless of stage state.

ViewportWindow gains:
  - ModelGpuData::coordinate_operation_meters / model_transformation_meters
  - federated_false_origin_meters_ (federation-wide member)
  - composeInstanceFromPlacement / recomposeAndUploadModel helpers
  - public setFederatedFalseOrigin / setModelCoordinateOperation /
    setModelTransformation

Each setter rewrites the affected model's SSBO, refreshes the
reflection flags, and rebuilds the BVH.  Defaults are identity, so
behaviour is unchanged until something wires a setter up — that's
the next commit (MainWindow listening to Federation::dirtyChanged
and SceneLoader::modelGeoref ready signals).

Sidecar bumped 9 -> 10: InstanceCpu grew 104 B -> 168 B.  Existing
sidecars rebuild on next load.  v10 sidecars store
placement_transformation, so they remain reusable across .ifcfeds —
the composed transform on disk is overwritten with the right one
on load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:41:25 +10:00
Dion Moult 92c3b4c308 ifcviewer: rename stage1/2/3/4 to their proper IFC-mapped names
Replace the placeholder "stage1/2/3/4" terminology with names that
mirror the IFC concepts each step represents:

  stage 1 -> PlacementTransformation
            (per-instance, derived from IfcObjectPlacement)
  stage 2 -> CoordinateOperation
            (per-model, IfcCoordinateOperation / IfcMapConversion)
  stage 3 -> FederatedFalseOrigin
            (federation-wide, user-nominated)
  stage 4 -> ModelTransformation
            (per-model, user-authored within the federation)

API renames:
  FederationOrigin            -> FederatedFalseOrigin
  ModelTransform              -> ModelTransformation
  composeFederationOrigin     -> composeFederatedFalseOrigin
  composeModelTransform       -> composeModelTransformation
  Federation::setOrigin       -> Federation::setFederatedFalseOrigin
  Federation::setModelTransform -> Federation::setModelTransformation
  Federation::origin()        -> Federation::federatedFalseOrigin()
  Federation::Model::transform_intent -> ::model_transformation
  ModelGeoref::stage2_meters  -> ::coordinate_operation_meters
  ModelGeoref::has_stage2     -> ::has_coordinate_operation

JSON keys in .ifcfed renamed in lockstep:
  origin                -> federated_false_origin
  transform_intent      -> model_transformation

The streamer's per-mesh "stage 1 vertex rebasing" comment is reframed:
the rebase isn't its own stage — it's a precision optimisation applied
inside the PlacementTransformation step.

All 36 ctest cases pass under the new names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:06:06 +10:00
Dion Moult 600d3a3460 ifcviewer: stage 1 mesh-vertex rebasing in the streamer
Per-mesh, when the iterator's first source vertex is more than 1 km
from origin (matching bonsai's distance_limit default), pick that
vertex as a rebase offset.  buildMeshChunk subtracts the offset from
every emitted vertex (in double precision, narrowed to float at the
end), and each instance's placement matrix is post-multiplied by
T(+offset) so world position is preserved by construction:

    T(+offset) · (verts - offset)   ≡   T · verts

The offset is stored on the per-mesh MeshAabb so all instances of the
same mesh apply the same compensation.  When the mesh's first vert is
near origin (the common case), offset is zero and the work is a no-op
beyond a couple of FP ops per vertex.

Improves float32 precision in the vertex buffer for georeferenced
models (UTM coords etc.) where verts would otherwise have to encode
million-metre magnitudes directly — at 1e6 m, float32 resolves about
6 cm, ruining sub-millimetre detail in the buildings themselves.

Visual verification on a real UTM-coords model still pending — the
math preserves world position by construction but precision claims
warrant a hand-test in the viewer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:39:13 +10:00
Dion Moult bea4e38e65 ifcviewer: cache per-model georef in SceneLoader
Adds ModelGeoref { ModelUnits units; Eigen::Matrix4d stage2_meters; bool
has_stage2; } and computeModelGeoref(file*) in Federation.{h,cpp}.  The
helper reads the project length unit, IfcProjectedCRS.MapUnit, helmert
parameters and WCS, and reduces them to a metres-in/metres-out stage 2
matrix using the existing Geolocation + Unit primitives.  When the model
has no IfcMapConversion it returns an identity stage_2 with has_stage2
== false, so the upload pipeline can branch cheaply.

SceneLoader::Model gains a cached ModelGeoref; SceneLoader::modelGeoref
(uint32_t mid) computes lazily on first call (returns nullptr when the
IFC file isn't available yet — happens on the sidecar-hit path before
the data-source thread populates the streamer) and serves from cache
afterwards.

Not yet consumed by the upload pipeline; that's the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:31:08 +10:00
Dion Moult 5386c9ec69 ifcviewer: add stage 3+4 data model and compose helpers to Federation
Adds the structs that were briefly in src/ifcviewer/Federation.{h,cpp}
two commits ago, now folded into the merged Federation alongside the
file persistence layer:

  - FederationConfig: federation-wide unit ({prefix, name}).  Default
    METRE; one-of an IfcSIUnit name with optional prefix or an
    IfcConversionBasedUnit name.
  - FederationOrigin: stage 3 — XYZ in federation unit + Z-rot.
    Composes to R_z · T(-xyz_meters), nominating a point as origin.
  - AFrame + ModelTransform: stage 4 intent — A (model project or
    map unit, per a_frame), B and pivot (federation unit), full
    intrinsic-XYZ Euler rotation in degrees.
  - ModelUnits: per-model project_length_to_meters / map_unit_to_meters
    cached at load time.

Free functions composeFederationOrigin and composeModelTransform
return Eigen::Matrix4d in metres.  composeModelTransform takes the
model's stage-2 georef matrix so it can lift `a` into metres when
authored in ModelLocal.

Federation gains config_, origin_ members + setters that emit
dirtyChanged.  Each Model carries a transform_intent.  JSON I/O
emits config / origin always; transform_intent only when non-default.
Schema stays "ifcfed/1" — additive, optional, sane defaults.

Five new tests: round-trip of the new fields, default-omission
behaviour, two compose smoke tests for FederationOrigin, and one
verifying the "pivot at B preserves A→B" invariant of
composeModelTransform.  All 36 ctest cases pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 15:15:37 +10:00
Dion Moult ecf0a5a4e1 ifcviewer: merge Federation classes into the lib
Move src/ifcviewer-full/Federation.{h,cpp} (and its tests) into
src/ifcviewer/ so the lib stays the single source of truth for the
federation data model.  Restores the original "agnostic lib usable
from ifcviewer-full and ifcviewer-minimal alike" framing.

Drop the unused per-model transform[16] / has_transform field — it
was round-trip-only with no UI to author it, and is being replaced
by an intent-based ModelTransform in the next commit.  No real
.ifcfed in the wild populated this field; old files still load
(unknown JSON keys ignored), they just lose the unused transform.

Replaces the pure-data-model Federation.{h,cpp} that was added a
few commits earlier — that file's structs and compose helpers
return as part of the merged Federation in commit 6.

ifcviewer-full's per-app tests dir is removed (test_federation was
the only one); BUILD_IFCVIEWER_TESTS now wires test_federation in
under src/ifcviewer/tests/, with the Qt6::Core/Gui/Test dependency
declared inline since unlike the other Tier-1 tests it has to pull
Qt in.  All 31 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:41:41 +10:00
Dion Moult 540f3acf52 ifcviewer: add Federation data model in Federation.{h,cpp}
FederationConfig holds the federation-wide display unit (defaults to
METRE; on load the first model's MapUnit becomes the default).
FederationOrigin captures stage 3 — XYZ in federation unit + Z-rot —
and composes to R_z · T(-xyz_meters), nominating a point as the new
origin and rotating around it.  ModelTransform captures stage 4 —
A in model project or map unit (per AFrame), B and pivot in
federation unit, full intrinsic-XYZ Euler rotation — and composes to
T(B - R_pivot · A) · R_pivot, rotating first then translating so the
rotated A lands at B.

ModelUnits caches per-model project/map unit-to-metres scales so the
compose helpers don't need to re-read the IFC each call.

All composed matrices are in metres; user-typed numbers are stored
in source units to round-trip without precision loss, and converted
on compose via Unit.h.

Not yet wired into the streamer or .ifcfed I/O — pure data model and
maths, integrated in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:56:23 +10:00
Dion Moult b3d29c4081 ifcviewer: add helmertMetersFromParameters and getMapUnit
helmertMetersFromParameters builds the helmert transformation as a
meter-input/meter-output 4x4 directly from parsed parameters, bypassing
autoLocal2Global's normalisation step.  This preserves
IfcMapConversionScaled.FactorX/Y/Z in the rotation block so the factor
applies to placement translations when the matrix is precomputed
per-model and composed with placements at upload time.  For ordinary
IfcMapConversion (factor = 1) this is bit-identical to
autoLocal2Global; only diverges on rare surveyed models with non-unit
factors, where it is the only correct behaviour.

getMapUnit returns IfcCoordinateOperation.TargetCRS.MapUnit so callers
can resolve the unit-to-metres scale via Unit.h's siScaleFromNamedUnit.

autoLocal2Global is unchanged — kept as a clean port of the python
ifcopenshell.util.geolocation reference impl for one-shot
project-units-in / map-units-out callers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:43:27 +10:00
Dion Moult 0d3849737c ifcviewer: port unit utilities to C++ in Unit.{h,cpp}
Mirrors selected helpers from ifcopenshell.util.unit: SI prefix
multipliers, the conversion-based-unit table (foot/inch/etc -> SI
metres), siScaleFromNamedUnit (walks IfcConversionBasedUnit chains
down to IfcSIUnit), getUnitAssignment / getProjectUnit /
calculateUnitScale, and convert / convertUnit.  Lives in
src/ifcviewer/ for now alongside Geolocation; will move out when
ifcopenshell.util is ported to C++.

Needed by upcoming Geolocation fix (e/n/h on IfcMapConversion are
in MapUnit, must be converted to metres for the meter-by-default
iterator output) and by the federation module (display-unit
conversion when the user changes the federation unit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:12:29 +10:00
Dion Moult a2db0a68a4 ifcviewer: port auto_local2global to C++ in Geolocation.{h,cpp}
Mirrors ifcopenshell.util.geolocation: HelmertTransformation parameters
(IfcMapConversion / IfcMapConversionScaled / IfcRigidOperation, plus
IFC2X3 ePSet_MapConversion), get_wcs from IfcGeometricRepresentationContext,
local2global, and auto_local2global.  Lives in src/ifcviewer/ for now;
will move out when ifcopenshell.util is ported to C++.

Not yet wired into the streamer.  A subsequent commit fixes the
unit handling for the iterator's meter-by-default output.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 11:58:36 +10:00
Dion Moult 3e2869b6aa ifcviewer: re-enable contribution culling in ortho mode
The previous projection-toggle commit short-circuited contribution
culling when projection_ortho_ was set — the formula
r_px = focal_px * r / dist looks like it depends on per-instance
distance, which doesn't apply in ortho.  Result: every frustum-
visible object drew, including sub-pixel ones, and FPS tanked on
top-down plan views.

In ortho the projected pixel size of a bounding sphere is constant:
r_px = pixels_per_world * r, where pixels_per_world equals the
existing focal_px / camera_distance_ (the ortho box was sized to
match perspective at the pivot's distance).  So the same formula
gives the right answer if we replace per-instance dist with
camera_distance_.

cullModelCpu now does that substitution for both contributionPasses
and pixelRadius (the latter feeds LOD1 selection too — sub-pixel
objects pick LOD1 in ortho the same way they do in perspective).
The "camera inside AABB" early-return is kept; it only fires in
perspective where dist→0 would otherwise blow up r_px, and is
harmless in ortho.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:05:32 +10:00
Dion Moult db1a2705a3 ifcviewer: edge enhancement post-pass
Adds a per-frame depth-laplacian pass that darkens pixels at sharp
depth discontinuities — silhouettes, overlapping-surface boundaries,
section-cut edges.  Catches the wall-against-wall and slab-against-
ceiling cases that the cavity hint in the lighting shader misses.

Implementation:

- New edge_depth_fbo_ / edge_depth_tex_ — single-sample D24S8 the
  size of the window.  After the main draw, blit the default FB
  depth into it (handles MSAA resolve in the same call).
- Fullscreen triangle generated from gl_VertexID, samples four
  cardinal neighbours, computes |4c - n - s - e - w| on linearized
  depth.  Linearization branches between perspective and ortho via
  u_is_ortho.  Threshold scales with depth so distant edges still
  register.
- Output is multiplicatively blended (GL_DST_COLOR, GL_ZERO) so
  colours just darken; no separate composite step.
- Runs before the pivot/section/axis gizmos so they aren't outlined
  themselves.  HiZ pyramid build still runs after, unchanged.

Per-frame cost is one MSAA depth blit + one fullscreen pass with
five depth samples.  Sub-millisecond at 1080p on a mid GPU.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 14:34:38 +10:00
Dion Moult e7787b6aad ifcviewer: hemisphere ambient + fill light + cavity hint
Replaces the flat 0.25 ambient + single-Lambert key with three cheap
shape-readability tricks, all in the fragment shader:

- Hemisphere ambient (sky/ground tint mixed by n.z) so floors,
  ceilings, and walls get visibly different ambient colour even when
  shadowed.  +Z is world-up.
- Secondary fill light at 35% intensity from roughly the opposite
  horizontal direction so backs of objects are not pitch black.
- Cavity hint: clamp(length(fwidth(n)) * 1.5, 0, 0.35) darkens
  fragments where adjacent normals diverge sharply.  Catches
  wall-floor seams, column-slab joints, and stair edges as faint
  dark lines without any post-process.

Total cost: ~8 extra ALU ops per fragment, no extra passes, no extra
buffers.  No change to cull/HiZ/MDI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 14:22:08 +10:00
Dion Moult 6f0327d4b5 ifcviewer: orthographic toggle and standard axis-aligned views
- P toggles ortho/perspective.  The ortho box is sized so the
  visible rectangle at the pivot's distance matches what the
  perspective camera would show — toggling at any zoom keeps the
  framing identical, and the wheel keeps working by rescaling the
  box.  Contribution culling is disabled in ortho since its
  r_px = focal_px * r / dist formula assumes perspective; frustum
  and HiZ culling still run.
- X / Y / Z snap the camera to look from +X / +Y / +Z; Shift+X /
  Y / Z snap to the negative side.  Yaw and pitch are set
  directly so top/bottom land on exactly ±90°.
- updateCamera() picks the lookAt up vector dynamically: world +Z
  except within 1° of the pole, where it switches to world +Y.
  That keeps lookAt well-conditioned at the poles and gives top
  views the architectural "Y as north" screen orientation.
- Pan now derives screen-right / screen-up from the real camera
  basis instead of from yaw/pitch alone — the old derivation
  assumed up = world +Z and silently inverted at top/bottom.
- Standard views preserve target and distance — rotate only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 13:30:18 +10:00
Dion Moult 6341d7dd31 ifcviewer: bind Shift+K to clear all section planes
Convenient escape hatch when the user has stacked several cuts
and wants to start over without exiting the tool first.  Also
resets the selection and drag state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 12:37:37 +10:00
Dion Moult 51971dd493 ifcviewer: section tool — gizmo, drag, K shortcut
Wires up the user-facing section-cut tool on top of the clipping
plumbing landed in the previous commit.

- K toggles the tool.
- LMB while the tool is active:
    * On an existing plane's arrow gizmo (screen-space line-segment
      hit test, 12 px grab radius) → select + start drag.
    * Otherwise on geometry → pickSurfaceAt + addSectionPlaneAt-
      Surface, select the new plane.
    * Otherwise → deselect.
- LMB drag updates the plane's origin by projecting the cursor
  delta onto the screen-space normal axis and converting back to
  metres.  d is rederived from the new origin each frame.
- Delete removes the selected plane; Esc exits the tool.
- Each plane renders a 2x2 m quad outline plus a yellow arrow
  along +n at its origin.  Selected plane draws cyan and
  thicker.

LMB object-pick is suppressed while the tool is active so plane
creation does not also change selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 12:32:49 +10:00
Dion Moult 309e20009c ifcviewer: add section-plane clipping plumbing
Adds a clip-plane pipeline used by the upcoming section tool:

- Up to 8 SectionPlane{n, d} entries, AND-combined as
  fragment-shader discard against world position.  Main and pick
  fragment shaders both honour the planes, so cut areas are
  neither drawn nor selectable.
- Main vertex shader now passes v_world_pos through.
- Pick FBO grows two attachments (RGB32F world position, RGB16F
  world normal) and the pick shader writes both alongside the
  object id.  pickSurfaceAt() does a single readback of all
  three.  Existing pickObjectAt() still works unchanged for
  callers that just want the id.
- addSectionPlaneAtSurface(point, normal) auto-flips the normal
  toward the camera so the first click immediately cuts the
  camera-facing half.

No UI yet — that's the next commit (gizmo, drag, K shortcut).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 12:13:17 +10:00
Dion Moult a06e5ecdf5 ifcviewer: add Focus-on-Object and View-All camera shortcuts
F (no modifier) re-aims the orbit camera at the selected object's
world AABB centroid and dollies camera_distance_ so the bounding
sphere fits the current viewport.  Home does the same for the union
of all finalized models.  Both preserve yaw/pitch so the user keeps
their orientation; both no-op in FPS mode.

Scene AABB prefers the per-model BVH root when available and falls
back to walking InstanceCpu world AABBs.  Object AABB unions every
matching instance.  Distance accounts for portrait windows by using
the tighter of the horizontal and vertical FOV constraints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 11:34:35 +10:00
Dion Moult 1ca2e12f92 ifcviewer: draw 3D pivot indicator during navigation
A small RGB axis cross is rendered at camera_target_ while the user is
orbiting, panning, or has just zoomed.  Visibility toggles on
middle-mouse press/release; the wheel arms a single-shot QTimer that
hides it 750 ms after the last notch.

Drawn in two passes: GL_GREATER at 30% alpha for the occluded portion
(X-ray cue) and GL_LEQUAL at full alpha for the visible portion.  Arm
length is computed from camera_distance_, fovy, and viewport height so
the cross stays ~30 px on screen across zoom levels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 10:21:23 +10:00
Dion Moult b9e5739088 ifcviewer: stream geometry per prioritised context
Port get_prioritised_contexts from ifcopenshell.util.representation to
C++ and have GeometryStreamer iterate one context at a time, mirroring
bonsai's create_generic_element loop.  Each pass sets context-ids to a
single context id; elements that yield geometry are dropped from the
include set so lower-priority contexts only pick up leftovers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:00:33 +10:00
Dion Moult 30cdffc27a ifcviewer: include settings for deflection tolerances 2026-04-29 22:37:55 +10:00
Dion Moult 3607fbb762 ifcviewer: include spatial elements in iterator filter
Match bonsai's process_element_filter for the no-filter branch:
IfcSpatialStructureElement on IFC2X3, IfcSpatialElement otherwise.
They flow through the same net/gross split as IfcElement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:20:20 +10:00
Dion Moult b73cdd1a0e ifcviewer: filter iterator to net IfcElements, void-limit setting
Mirror bonsai's IfcImporter.process_element_filter so the streamer
walks only IfcElement (plus IfcProxy on IFC2X3/IFC4), drops
IfcFeatureElement except IfcSurfaceFeature, and routes elements
with more openings than the configurable void limit through a
second iterator pass with disable-opening-subtractions=true.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:54:35 +10:00
Dion Moult e21bd1ac96 ifcviewer: add tier-1 unit tests (Catch2 + CTest)
Covers the pure-logic modules with no Qt event loop or GL context: BVH
build, LOD decimation, sidecar round-trip, instanced-geometry layout
constants, and Federation save/load + relative-path policy. Each test
binary compiles only the production source(s) under test, so the unit
tier doesn't pull Qt/OpenCASCADE/IfcGeom into the test build.

Gated behind BUILD_IFCVIEWER_TESTS=OFF; default builds remain offline.
Catch2 v3.5.4 is fetched on demand via FetchContent.
2026-04-28 21:49:44 +10:00
Dion Moult 633c613da2 ifcviewer: drop unused SidecarHeader reserved field, bump v8 -> v9
The reserved uint32_t was always written as 0 and never inspected on
read.  Removing it shrinks the header from 16 to 12 bytes; the version
bump makes pre-existing sidecars fail the version check cleanly rather
than misreading by 4 bytes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:40:00 +10:00
Dion Moult 8282f691e8 ifcviewer: rename SceneLoader::Entry to Model
The struct holds per-model bookkeeping; Model describes its contents
rather than its container relationship.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:31:48 +10:00
Dion Moult 7bc64bef1a ifcviewer-full: add .ifcfed federation save/load
Federation (JSON) tracks an ordered list of model sources plus an
optional home-view camera state. Sources are stored relative when
under the federation file's directory, absolute otherwise.

File menu now exposes New / Open / Save / Save As; Add Files moves
to Ctrl+Shift+O. View menu gains Set/Go to Home View. Window title
binds to dirty state via setWindowModified, and the close-window
prompt offers Save/Discard/Cancel.

Per-model transform (4x4 column-major) and visible round-trip
through load/save but are not yet applied at the viewport — the
georeferencing work uses them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:31:59 +10:00
Dion Moult 81a7c5b50e ifcviewer: add Shift+F fly-mode camera
WASD strafe, Q/E down/up, mouse-look (cursor hidden + recentered),
Shift to sprint, scrollwheel scales speed, click or Esc returns to
orbit.  Exiting drops back to the same viewpoint because rotation
re-pins camera_target_ to keep camera_eye_ stationary.

Movement integrates wall-clock dt inside render() and the next frame
self-schedules via requestUpdate() while any key is held.  A QTimer
would fight Qt's event loop during long swapBuffers blocks and produce
"camera pauses one frame" stalls; render-driven integration keeps
movement phase-locked to vsync and absorbs slow frames in a single
catch-up step.

IFC_FPS_HITCH_MS=<n> logs frames slower than n ms while in fly mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 20:12:52 +10:00
Thomas Krijnen 3b3ed47e89 Reduce concurrency to see if we can get the rc 143 to go away 2026-04-25 14:34:33 +02:00
Thomas Krijnen f54c917ded Try to disable wasm-opt 2026-04-25 14:09:09 +02:00
Thomas Krijnen 5c6444d5ea [tmp] disable manifodl 2026-04-25 11:41:43 +02:00
Thomas Krijnen 6282634a70 Try with manual paths 2026-04-25 11:34:31 +02:00
Thomas Krijnen 40267aa068 Revert some tmp changes 2026-04-25 11:13:56 +02:00
Thomas Krijnen 1efedfd3cc pin pyodide versions 2026-04-25 11:13:48 +02:00
Thomas Krijnen ddfe3bce20 Fixes for WASM build (some temporary) 2026-04-24 13:36:06 +02:00
Dion Moult 8f7c8dc1d2 ifcviewer: load rdb/ifc as property data source on sidecar hit
Sidecar hits skipped opening the underlying .rdb/.ifc, so ifcFile() was
null and the property panel only showed cached name/type/guid. Now, after
a sidecar hit, a background thread opens <stem>.rdb (preferred) or
<stem>.ifc and hands the file to GeometryStreamer via setIfcFile(), with
a dataSourceReady signal so the UI refreshes the current selection.

Gated behind a new AppSettings::loadDataSource toggle (default on) so
users can opt into geometry-only viewing; when off, the sidecar-hit
thread is skipped and the stream-path ifc_file_ is released after
the sidecar write completes.

Also adds *.ifcview to the Add Files dialog filter so a cache can be
opened directly without its source file present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:45:04 +10:00
Dion Moult eea2398e07 ifcopenshell-python: fix broken imports after upstream refactors
Two upstream commits on this branch landed without updating all their
callers, leaving `import ifcopenshell.geom` unusable:

  89c66f62b "Python import fixes: import from wrapper now which
  inherits from mixins" moved the `file` class out of
  ifcopenshell/file.py into ifcopenshell_wrapper, but missed
  geom/main.py and stream.py which still did `from ..file import file`.

  b022ca7e7 "Some plug-in work" dropped the SWIG exports for
  `serialise`, `tesselate`, `XmlSerializer` (and other serializers)
  with a `// @todo bring back serialization` marker, but left
  geom/main.py referencing them at module-load time.

Fix the `file` imports to come from ifcopenshell_wrapper, and guard
the removed-serializer references behind `hasattr`, matching the
pattern already in use for the other optional serializers (gltf, hdf5,
collada, json, ttl). Revert once upstream fixes this.
2026-04-24 07:33:13 +10:00
Dion Moult 35be7f4190 ifcviewer: load RocksDB-backed IFC models
The viewer can now open a .rdb directory (as produced by
RocksDbSerializer / convert_path_to_rocksdb) anywhere it accepts an
.ifc file. The full GUI gets an "Add Database..." File menu entry
that opens a directory chooser; the streamer lets the file
constructor autodetect the format and opens the store read-only so
multiple viewers can share a database without taking the exclusive
RocksDB lock.

Parallel mapping on RocksDB-backed files still produces
non-deterministic shape counts (the race is outside the instance
cache), so force num_threads=1 for the iterator when the storage is
RocksDB. Serial RocksDB (~2.6s) and parallel SPF (~0.7s) both
produce 107 shapes on AC20-FZK-Haus; @todo in-source points at the
remaining thread-safety work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult 4f929e90a7 ifcviewer: key sidecar on path stem, drop staleness check
Previously readSidecar/writeSidecar were keyed on (path, file_size) with
staleness rejected at read time.  Switch to pure path-stem keying: foo.ifc
and foo.ifcdb/ both resolve to foo.ifcview, so the same cache serves either
source format.  Staleness is user-managed (delete the sidecar to force a
rebuild), which also lets sidecars be copied or moved independently of the
source.

v8 header drops the source_file_size field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult f898089b27 Queue viewer ops before GL init
Buffer viewport model mutations until the OpenGL context is initialized so loads that start before first exposure do not silently drop geometry or model state.

Generated with the assistance of an AI coding tool.
2026-04-23 21:32:25 +10:00
Dion Moult 4e4553201b Fix viewer load termination
Handle streamer success, failure, and cancellation as distinct terminal states so failed or cancelled loads do not finalize as successful models. Clean up partial model/UI state in the full and minimal viewer apps when a load is cancelled or fails.

Generated with the assistance of an AI coding tool.
2026-04-23 21:32:25 +10:00
Dion Moult 29f5132510 ifcviewer: extract SceneLoader, remove duplicated load orchestration
MainWindow and MinimalWindow each carried ~150 lines of mirrored
load-queue, sidecar-thread, streamer-wiring, and ID-rebase code. Lift
all of it into a SceneLoader QObject in the library; both apps now
consume it via signals. Sidecar writes stay on the full-app side since
they need the consumer's element metadata strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult ae938d425b ifcviewer: split into shared library and two app executables
Turn src/ifcviewer into libIfcViewer.so holding the rendering engine +
geometry pipeline (ViewportWindow, GeometryStreamer, BvhAccel,
InstancedGeometry, SidecarCache, LodBuilder, AppSettings).  Move the
existing UI shell (MainWindow, SettingsWindow, main.cpp) into
src/ifcviewer-full as the IfcViewerFull executable.  Add a new
src/ifcviewer-minimal target with a MinimalWindow that hosts only the
viewport and reuses the sidecar fast-path for benchmark/debug runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult 5161b0a3f8 ifcviewer: remove meshopt_simplify path, keep only simplifySloppy
Edge-collapse decimation (meshopt_simplify) returns BIM meshes unchanged
due to per-triangle vertex duplication and non-manifold topology. The
sloppy voxel-clustering decimator is faster, needs no shadow index
welding, and produces good results at the sub-30px LOD1 threshold.
Remove the non-sloppy branch, shadow buffer, IFC_LOD_SLOPPY and
IFC_LOD_LOCK_BORDER env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult 3015f758ba ifcviewer: shrink vertex format from 16 to 12 bytes (oct i8x2 normals)
Replace i16x2 octahedral normals with i8x2, filling the 2-byte padding
after position and saving 4 bytes per vertex. int8 gives ~1.4 deg
worst-case angular error — invisible for BIM geometry which is
overwhelmingly axis-aligned. 25% VBO reduction; sidecar files shrink
~15% overall (5.4 GB -> 4.6 GB on a 111-model test scene). Bumps
sidecar format to v7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult dbe68b48f2 Fix kernel/mapping plugin output dir and IfcViewer link dependencies
Use $<TARGET_FILE_DIR:IfcGeom> instead of hardcoded
${CMAKE_BINARY_DIR}/ifcgeom/$<CONFIG> for plugin runtime dirs — the
old path was wrong on non-MSVC generators where $<CONFIG> expands
empty. Add explicit add_dependencies for kernel/mapping plugins so
IfcViewer waits for them to build, and drop the redundant direct link
against ${kernel_libraries}.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult 094d96c735 ifcviewer: remove GPU compute cull (IFC_GPU_CULL)
Benchmarks showed negligible gain (52 vs 51 fps) — the CPU BVH path
already culls efficiently, and the GPU path still read back to CPU for
LOD/winding/HiZ. Removes ~570 lines of dead weight: compute shader,
async readback, one-frame-late consume, per-model AABB SSBOs, and
profiling counters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult ed6e8d831e ifcviewer: benchmark CLI, settle recull fix, and Phase 3G documentation
Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for
reproducible performance measurement.  The benchmark orbits the camera
(0.5°/frame yaw) for N frames after a 5-frame warmup, prints
avg/median/p1/p99 frame times, then exits.  Press C during interactive
use to print the current camera as a --camera argument.

Fix settle recull to fire after ANY camera motion (not just when
IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames
are always cleared when the camera stops.

Document Phase 3G (motion-adaptive culling + HiZ during motion) in
README with benchmark results from 1.06M-instance scene:
  - Baseline:                    16.3 fps
  - IFC_MIN_PX_MOTION=10:       26.5 fps (1.6x)
  - IFC_HIZ_MOTION=1:           46.6 fps (2.9x)
  - Both combined:              51.0 fps (3.1x)
  - + GPU_CULL:                 52.0 fps (3.2x, negligible gain)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:25 +10:00
Dion Moult 930678e3d2 ifcviewer: motion-adaptive contribution culling + sub-draw diagnostics
During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION)
to aggressively cull small objects, dramatically reducing sub_draws and
improving orbit fps (e.g. 29→67 fps on 1M-instance scene).  When the camera
stops, automatically re-cull at the base threshold to restore full detail.

Key behaviors:
- IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled)
- Settle recull fires on the first still frame after motion
- HiZ pyramid invalidated on settle (stale from sparse motion frame)
- GPU cull results skipped on settle (dispatched at motion threshold)
- requestUpdate() ensures the settle frame actually runs

Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis
and documents Phase 3E/3F experiment results in README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 4e3cc63de1 ifcviewer: fix HiZ depth blit and make occlusion test conservative
The HiZ pipeline had two bugs causing false occlusions:

1. The scaling depth blit (glBlitFramebuffer from window-size to HiZ-size)
   produced GL_INVALID_VALUE on some drivers. Replace with a fullscreen-
   triangle shader that samples the resolved depth and writes gl_FragDepth.

2. The resolve texture used GL_DEPTH_COMPONENT24 but Qt's default FBO uses
   D24S8 (depth+stencil). Mismatched formats cause the MSAA resolve blit
   to fail. Fix by using GL_DEPTH24_STENCIL8 for the resolve texture.

Additionally, the occlusion test was too aggressive for scenes with
compressed depth ranges (entire scene in 0.99-1.0). Change from
"max over coarse mip texels" to "reject only if ALL fine-mip texels
agree the AABB is behind them", with early-out on first non-occluding
texel and a 64-sample cap.

Also fix IFC_HIZ_MOTION=0 being treated as enabled (checked env var
existence, not value).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 7b64dd338b ifcviewer: dirty-mesh tracking + consume sub-phase profiling for GPU cull
Only clear and emit mesh buckets that received survivors in the previous
frame, converting both phases from O(total_meshes) to O(active_meshes).
Adds per-sub-phase timing (bin/clr/class/emit) to the stats line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 71612e0780 ifcviewer: hybrid GPU frustum+contribution cull with async readback
Replace the CPU BVH traversal + frustum + contribution stages with a
GPU compute path (IFC_GPU_CULL=1).  A single scene-wide dispatch tests
all instances against frustum planes and screen-space contribution
threshold, compacting survivors into a flat uint32 buffer via atomicAdd.

Uses one-frame-late async readback: frame N dispatches and fences,
frame N+1 polls the fence (non-blocking) and reads the persistent-
mapped result buffer with zero GPU sync cost.  CPU still handles HiZ,
LOD selection, winding bucketing, and indirect command generation from
the compact survivor list; draw path is unchanged.

On a 1M-instance / 111-model scene (GTX 1650):
  GPU dispatch:  0.70 ms  (frustum + contribution, brute-force)
  Readback:      0.00 ms  (fence already signaled, persistent map)
  CPU consume:   5.7–6.7 ms  (parallel emit across models)
  Cull wall:     5.8–6.9 ms  (vs 9.6–15.2 ms CPU-only path)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 9aae8f0329 Revert "ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1"
This reverts commit 4fe32b54105ca2c5c00290603db17164837211e1.
2026-04-23 21:32:24 +10:00
Dion Moult 175efcfffe Revert "ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b)"
This reverts commit 7defbe982464536e34e80aa85d2cd7eaafbb62ee.
2026-04-23 21:32:24 +10:00
Dion Moult 643a2e1c1f Revert "ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c)"
This reverts commit 77cac3ec170b622db6977829f66b62603266a047.
2026-04-23 21:32:24 +10:00
Dion Moult 3c5c8e44cb Revert "ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d)"
This reverts commit 9a7a48944f4b62f9ca431149139eb846229f6114.
2026-04-23 21:32:24 +10:00
Dion Moult fc89ffeb19 Revert "ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount"
This reverts commit d5b7b87ba17c90008cf0673c838ce8431ad85e36.
2026-04-23 21:32:24 +10:00
Dion Moult 4bedb40d8a ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount
Pack compute shader compacts non-empty indirect commands into
contiguous fwd/rev ranges, eliminating ~690k empty sub-draws that
dominated command-processor overhead.  GL 4.6 entrypoint loaded via
getProcAddress with ARB fallback; graceful degradation to uncompacted
MDI when unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult a34c36d22e ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d)
Two-phase compute-cull dispatch when IFC_GPU_CULL=1:

  Phase 1  frustum + contribution + LOD, no HiZ  → survivors
  Depth    render survivors depth-only into half-viewport FBO
  Build    GPU compute max-reduce depth → R32F mip pyramid
  Phase 2  same cull + HiZ test                  → final survivors
  Color    render final survivors

The compact shader's new hizOccluded() projects 8 AABB corners to
screen space, picks the mip level where the covered rect fits in ≤2×2
texels, and rejects when the AABB's near-depth exceeds the pyramid's
max depth.

New GPU resources (per-window):
  hiz_gpu_fbo_ / hiz_gpu_depth_tex_  — depth-only FBO at half viewport
  hiz_gpu_pyramid_tex_                — R32F mipmapped pyramid
  hiz_gpu_copy_prog_                  — compute: depth → pyramid L0
  hiz_gpu_reduce_prog_                — compute: max-reduce L(n-1)→L(n)
  hiz_gpu_depth_prog_                 — vertex + trivial fragment

On a dense 18-model BIM dataset:
  survivors:  140k → 65k  (HiZ rejects ~50%)
  triangles:  22M  → 13M
  gpu_cull:   0.06ms → 22.5ms  (depth pre-pass CP overhead)

The depth pre-pass suffers the same empty-sub-draws CP overhead as the
color pass (690k commands, most with instanceCount=0).  Once MDI
compaction lands, both passes will be fast.  For now, net FPS is flat
(savings on color ≈ cost of depth pre-pass).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult e5ed7b53d4 ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c)
The compact shader now computes per-instance pixel radius and routes
survivors to LOD1 buckets when the projected sphere falls below the
LOD1 threshold (default 30 px, same as CPU path, tunable via
IFC_LOD1_PX).

Layout expanded from 2 to 4 buckets per mesh:
  [0..M)   fwd_lod0   [M..2M)   fwd_lod1
  [2M..3M) rev_lod0   [3M..4M)  rev_lod1

Two MDIs per model: CCW for [0..2M), CW for [2M..4M).  Per-mesh
has_lod1 flags live in a new gpu_mesh_flags_ssbo (binding 4).

Contribution cull refactored: the compact shader now computes
pixelRadius() once and uses it for both the min_pixel_radius rejection
and LOD routing, matching the CPU path's logic.

Visible-buffer worst case is 2 × total_instances (each LOD bucket
reserves the full fwd/rev capacity per mesh, since LOD selection is
dynamic).

Tri count drops ~60% on the test dataset (53M → 22M) thanks to LOD1
decimated meshes.  FPS recovers from 16 to 36 despite 690k sub_draws
(4M layout).  MDI compaction remains the final perf fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 069ef20c46 ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b)
Extend the GPU-cull indirect buffer from M to 2M commands: the first M
are the forward (non-reflected, CCW) bucket, the second M are the
reverse (reflected, CW) bucket.  The compact shader reads flags bit 0
from the AABB SSBO and routes each survivor to the appropriate bucket
via bucket = reflected ? mesh_id + M : mesh_id.

uploadGpuCullStaticBuffers() now precomputes exact per-mesh fwd/rev
instance counts so each bucket reserves only the slots it needs
(total visible_ssbo size unchanged — sum of fwd + rev = total).

Draw loop issues two MDIs per model under IFC_GPU_CULL: first M
commands CCW, next M commands CW.

Sub-draws doubled (172k → 345k) which further regresses FPS due to
command-processor overhead from zero-instance sub-draws — the same
issue noted in 3a.  MDI compaction remains the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 0b122ae1f5 ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1
Promote the compute cull from a validation shader to the actual draw
driver.  With the gate on, the CPU cull fan-out is skipped and MDI
consumes gpu_indirect_buffer / gpu_visible_ssbo directly.

- uploadGpuCullStaticBuffers() pre-fills per-mesh DrawElementsIndirect
  commands and a mesh_base prefix sum so the compact shader can scatter
  survivors into a fixed per-mesh range.  Instance count for each
  command is zeroed by a tiny reset dispatch, then the compact shader
  atomically writes survivors and increments instanceCount.
- Draw loop branches on the gate: single CCW MDI with all mesh
  commands.  Fwd/rev winding split, LOD selection, and HiZ are still
  CPU-path-only; reflected instances render with wrong winding under
  this gate (step 3b).
- Once-per-second readback of each model's indirect buffer populates
  the survivor / visible-object / visible-triangle stats so the
  [frame] line reflects what the GPU actually drew.

Known regression: sub_draws is the full mesh count per model (~172k on
the test dataset) vs the handful of non-empty commands the CPU path
produces.  Command-processor overhead from zero-instance sub-draws is
what drives the FPS drop, not the cull itself (0.05 ms).  Compacting
non-empty commands requires glMultiDrawElementsIndirectCount, a GL 4.6
entrypoint not exposed by Qt's QOpenGLFunctions_4_5_Core; deferring to
3a-followup so we don't bolt a getProcAddress loader into the renderer
mid-restructure.

IFC_GPU_CULL is off by default, so this does not affect normal runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult a0cc4b874b ifcviewer: add GPU frustum-cull validation shader (IFC_GPU_CULL=1)
First Phase 3E milestone: a compute shader that reads the per-instance
world-AABB SSBO added in the last commit, tests each instance against
the 6 frustum planes, and atomicAdds a global counter.  No visible list
or indirect-buffer writes yet — the output is just a survivor count,
cross-checked each frame against the CPU cull's numbers in the stats
line (`gpu_cull[Xms in=A surv=B]`) so we can verify the plumbing end-
to-end before we hand the GPU responsibility for the actual render data.

Dispatched from render() after the CPU cull completes, only when
IFC_GPU_CULL=1 and the camera moved (the skipped-cull still-frame path
doesn't re-check either).  The readback is synchronous — that's fine
for a validation path; it'll go away once the GPU writes indirect
commands directly.

Expected invariant: gpu_cull.surv >= cpu_cull.visible_objects, since
the GPU path does frustum-only and CPU adds contribution + HiZ cuts on
top.  A large mismatch (orders of magnitude, or surv < visible) means
the SSBO upload or shader logic is wrong.

No shader/buffer bindings overlap with the draw path (compute uses
bindings 0/1, restored before drawing; draw programs rebind 0/1/2).
2026-04-23 21:32:24 +10:00
Dion Moult 2f88778c9f ifcviewer: upload per-instance world AABBs to a GPU SSBO
Scaffolding for Phase 3E (GPU compute cull).  After finalizeModel /
applyCachedModel, pack each InstanceCpu's world AABB + mesh_id +
reflection bit into a std430-friendly 32 B record and push it to a
per-model aabb_ssbo.  No consumer yet — the CPU cull still drives
rendering — but the next commits will point a compute shader at this
buffer and have it produce the visible list + indirect commands
directly on the GPU.

Cost: 32 B per instance, ~18 MB for the 569 k-instance test scene.
One-shot upload at finalize time; streaming-time appends aren't
mirrored (the CPU cull doesn't need the SSBO, and finalizeModel
rebuilds the whole thing in one go).
2026-04-23 21:32:24 +10:00
Dion Moult 0a752e09eb ifcviewer: README — document HiZ disabled during camera motion
The 'Known caveats' bullet still described the old 1-frame-stale
behavior.  Since 6b496d802 the cull compares hiz_vp_ to the current VP
and drops HiZ rejection whenever they differ, so HiZ only helps on
still frames — orbiting gets no benefit.  Call out the tradeoff and
the planned same-frame-depth-pre-pass fix slated for Phase 3E.
2026-04-23 21:32:24 +10:00
Dion Moult 03662d2016 ifcviewer: fix pick-pass cull corruption and cached-model ID collisions
Two stability bugs:

1. Clicking an object left the scene with wrong shading until the camera
   moved.  The pick pass re-culls every model with its own parameters
   (min_pixel_radius=0, no HiZ) and overwrites each model's visible_ssbo
   and indirect buffer.  The next render() saw an unchanged camera,
   skipped the cull via the have_cached_cull_ shortcut, and drew the
   stale pick-pass buffers.  Fix: invalidate have_cached_cull_ at the
   end of pickObjectAt().

2. Loading two sidecar-cached models made the second model's picked
   properties resolve to the first model's elements.  Sidecars store raw
   object_id / model_id values from the session that wrote them, and
   both files start at object_id=1, so element_map_ entries collided.
   Fix: on load, rebase every PackedElementInfo and InstanceCpu by
   (next_object_id_ - min_id_in_sidecar) and overwrite model_id with
   the freshly-assigned handle before the elements hit element_map_.

Also document both in the README — the pick-pass note under 3A
contribution culling, the sidecar rebase under the sidecar format
section.
2026-04-23 21:32:24 +10:00
Dion Moult 99f409280a ifcviewer: disable HiZ cull when camera has moved
HiZ from last frame encodes depth from last frame's viewpoint. When
the camera moves, projecting a current-frame AABB through the stored
VP answers 'was this occluded last frame?' rather than 'is it occluded
now?' — a self-reinforcing feedback loop where objects culled in
prior frames never appear in any depth buffer and stay permanently
hidden at certain camera angles.

Fix: require hiz_vp_ == current VP for the HiZ test to apply. HiZ
still helps static views (kicks in one frame after camera stops) but
no longer produces false occlusions during orbit. The correct fix for
orbit coverage is a depth pre-pass feeding fresh HiZ — planned as
part of Phase 3E GPU compute cull.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 0c9d3ea6d7 ifcviewer: README — document parallel per-model cull (Phase 3D)
Add the parallel cull bullet to the feature list, a Phase 3D section
explaining the fan-out / scratch-ownership design + measured 4x
speedup, and renumber the planned GPU compute cull to Phase 3E so it
can cite 3D as the CPU algorithm being ported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 37fa4e9076 ifcviewer: parallel per-model CPU cull
Split cullAndUploadVisible into cullModelCpu (CPU-only, thread-safe) and
uploadCullResults (GL-only, main thread). render() fans the per-model
culls out via std::async and joins before the serial upload pass.

The cull scratch (vis_fwd/rev_lod0/1, visible_flat, indirect_scratch)
moved onto ModelGpuData so each worker owns its output buffers. Phase
timers and hiz_reject_count_ are atomic since workers fetch_add into
them. A new wall-clock timer around the dispatch block reports the
actual frame-time contribution; the existing clr/trv/emt counters are
now documented as per-thread sums.

Measured on the 18-model / 569k-instance test scene: wall-clock cull
dropped from ~25 ms to ~5 ms while the aggregate CPU work (trv) stayed
~30 ms. Frame time 34 ms -> 19 ms. IFC_CULL_THREADS=0 forces the
single-threaded fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 1ec273f508 ifcviewer: README — document event-driven rendering and VBO quantization
Add the event-driven rendering bullet (zero idle cost, in-render frame
timing) and roadmap entries for VBO quantization and event-driven
rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 574bcfa6c5 ifcviewer: quantize VBO to 16 B/vertex (sidecar v6)
Position now u16x3 normalized against each mesh's local AABB; normal
oct-encoded to i16x2; RGBA8 colour unchanged. Per-mesh dequant basis
lives in a new MeshGpu SSBO at binding 2; both main and pick shaders
mix() against it before applying the instance transform.

Drops VBO and sidecar size by ~43 % (28 -> 16 B/vert), which matters
mostly for warm-load downloads of precomputed sidecars and steady-state
VRAM. LodBuilder dequantizes positions into a scratch buffer before
calling meshopt, since meshoptimizer needs float positions.

Also fixes a streaming-time crash in cullAndUploadVisible: bvh_items
was only populated at finalize, but the linear fallback indexes it
during streaming. Mirror BvhItem appends in uploadInstanceChunk so the
hot path stays valid before the BVH is built.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 09ffdd2028 ifcviewer: event-driven rendering, idle scenes cost zero CPU
Replaced the 16ms QTimer with QEvent::UpdateRequest delivered via
requestUpdate(), posted from every state mutator (mouse/wheel, model
lifecycle, selection, visibility, resize).  A static BIM scene — the
common case for a viewer — now does no work at all between user actions.

FPS is now measured as time spent inside render() rather than wall-clock
gap between frames, so idle gaps don't pollute the 1-second window and
the headline number reflects real render throughput.  Headline fps still
caps at vsync; sub-vsync profiling lives in the cull[...] phase timers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult d0c5bd5e85 Cull: skip cullAndUploadVisible + HiZ on still frames
render() was re-running the full cull every 16 ms timer tick even when
nothing had changed — the camera matrices, scene state, and therefore
visible set were all identical to the previous frame's.  The GPU was
still happy to redraw from the cached indirect buffer, but the CPU was
burning 21 ms/frame rebuilding the same visible list.

Detect the no-op case by comparing view/proj against last_cull_view_ /
last_cull_proj_ and checking a scene-dirty flag (have_cached_cull_)
that every mutator on models_gpu_ invalidates — finalizeModel,
applyCachedModel, applyLodExtension, hide/show/remove/reset, and
uploadInstanceChunk.  When the check passes we skip both
cullAndUploadVisible and buildHizPyramid (the depth buffer is
bit-identical, so re-reading it produces the same pyramid).

Per-model visible_objects / visible_triangles stats now live on
ModelGpuData so the stats line reports correct numbers on skipped
frames instead of reading from a stale indirect_scratch_.

Measured on a 569k-object overview: still frames go 22 fps → 62 fps;
orbiting goes 23 fps → ~30-50 fps depending on how hard you move the
mouse (the cull only pays its full cost on the ~25 % of frames where
the camera actually moved).  The stats line gains a "skipped N/M"
field so you can see the ratio live.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult c03a7fe117 Cull: read AABBs from compact bvh_items in the hot path
cullAndUploadVisible was reading each instance's AABB through
m.instances[idx] — a 104-byte InstanceCpu struct — for the frustum /
contribution / HiZ tests.  Only 24 of those bytes (the two float[3]
AABBs) are actually used by the tests; the rest (4×4 transform +
header) is pure cache-line waste, and with 569k instances the array
is 59 MB, well past any cache.

bvh_items[idx] already stores a 1:1 compact 28-byte record with the
same AABB, built unconditionally in buildBvhForModel().  Switch the
hot test path to read from it, and only touch InstanceCpu once an
instance has passed all three tests (for mesh_id).  Modest ~20 %
drop in cull-traverse time on a 569k-object overview (26 ms → 21 ms).

Also add four cull-phase timers (clr / trv / emt / upl) to the
per-second stats line so future optimisation work has concrete
numbers to chase.  Confirmed via these timers that bucket clears,
emit and GPU upload are all <1 ms combined; traversal is where the
remaining CPU cost lives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 8596d53a4a Phase 3C: Hierarchical-Z occlusion culling (CPU-side v1)
After the main draw, blit the MSAA default-framebuffer depth to a
single-sample 256×128 depth texture, read it back, and build a CPU
max-reduced mip pyramid.  Next frame's cullAndUploadVisible projects
each BVH node / instance AABB through the previous frame's VP and
compares the AABB's nearest depth against the pyramid's deepest value
at the matching mip level; strictly-beyond AABBs are rejected.

Conservative direction (aabb_near > hiz_max) — never wrongly rejects a
visible instance, so no flicker.  BVH subtree-level test lets a single
8-corner projection reject up to a leaf's worth of instances.

Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width.
New stats counter hiz_rej shows rejects/frame.

Measured: big win on interior views (GPU-bound), roughly zero net
effect on exterior overviews (CPU-bound on cull traversal, so the
saved GPU work is masked).  Tried a 3-deep PBO ring for async readback
and reverted — the extra frame of staleness produced visible flicker
on fast orbit, and the synchronous readback wasn't actually a measured
bottleneck at 256×128.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult c78e16eafb Phase 3B: per-instance LOD via meshoptimizer simplifySloppy
Decimate each unique mesh once at sidecar-build time and swap to the
reduced index slice per-instance per-frame when projected sphere radius
drops below IFC_LOD1_PX (default 30).  Same VBO, same SSBO, just a
different firstIndex/count in the indirect command.

Extends MeshInfo (48→56 B) with lod1_ebo_byte_offset + lod1_index_count
and bumps the sidecar to v5.  buildLods() runs inside
onStreamingFinished, appends decimated indices to sd.indices,
applyLodExtension pushes the EBO suffix to the live GPU state, and the
sidecar is written with LOD1 baked in.

simplifySloppy (voxel clustering) is used instead of the default
edge-collapse meshopt_simplify because BIM brep output is per-triangle-
unwelded and non-manifold after welding — simplify returned the input
unchanged for every mesh tested.  Sloppy ignores topology.  Knobs
(IFC_LOD_SLOPPY, IFC_LOD_ERROR, IFC_LOD_RATIO, IFC_LOD_MIN_SAVINGS,
IFC_LOD_LOCK_BORDER, IFC_LOD_DEBUG) are available for A/B tuning.

Result on the 128M-tri 10-model test scene (GTX 1650, 2px contribution
cull): 20.2 → 43.2 fps, 40M → 14M visible triangles, no change in
object count.  LOD build adds 100–600 ms per model on first open,
cached thereafter.

README Phase 3B section is now a full writeup of pipeline, selection,
decimator-choice rationale, env vars, and measured numbers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 68fea7bd45 README: mark Phase 3A done with measured numbers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 90366f8236 Phase 3A: screen-space contribution culling
Reject frustum-visible objects whose bounding sphere projects below a
pixel-radius threshold.  Applied at both BVH-node level (whole subtrees
pruned) and per-instance level; short-circuits when the camera is
inside the AABB so nothing-you're-standing-next-to is ever lost.
Pick pass passes threshold 0 so sub-pixel objects stay clickable.

Threshold defaults to 2 px (radius), overridable via IFC_MIN_PX env
var.  Measured on the 128 M-tri test scene (GTX 1650):

  0 px (off):   6.7 fps, 128 M tris
  2 px:        20.2 fps,  40 M tris (31%)
  4 px:        30.3 fps,  15 M tris (12%)

The metric is sphere-based (cheap: one sqrt per test) rather than
AABB-corner projection; loses a little precision on very elongated
bounds but costs ~5x less per test and the BVH-node pre-cull means
the long-tail-of-small-things case is already handled by subtree
pruning before we touch individual instances.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult d3c21d7a81 Pivot Phase 3: diagnose as draw-bound, not upload-bound
Earlier probes pointed at per-frame glNamedBufferSubData uploads as the
bottleneck (60 fps when those two calls were commented out).  That was a
false reading — zeroing the uploads also emptied the indirect buffer, so
MDI drew nothing.  "No upload" and "no draw" were indistinguishable.

Two new diagnostic env vars in render() isolate the real costs:

  IFC_SKIP_MDI=1       keep cull + upload + binds, skip only the MDI
                       draws.  Gives 62 fps with everything else running,
                       confirming the non-draw path fits in ~16 ms.
  IFC_MAX_SUBDRAWS=N   cap each MDI's drawcount.  67k -> 30k sub-draws
                       saves 0 ms, confirming sub-draw count itself is
                       not the bottleneck; the long tail of sub-draws
                       carries ~no triangles.

On a GTX 1650 with 128 M triangles in view, nvidia-smi sits at 95 %
GPU util and FPS scales with triangle work, not sub-draw count.  The
card is simply rasterising at ~850 M tri/s.  No CPU-side or upload
trick recovers it.

Revised Phase 3 is therefore shedding triangles, not bytes:
  3A screen-space contribution culling (next)
  3B LOD
  3C HiZ occlusion
  3D GPU-side compute culling

README Phase 3 section rewritten around the diagnosis, including the
false lead, so future work doesn't re-tread the upload path.  The
aborted staging+resident ring-buffer implementation was reverted (the
uncommitted working tree is gone — pure glNamedBufferSubData retained
for the visible + indirect buffers, which we now know is fine).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult cd77c557e9 Rewrite README for instancing pipeline and refocus Phase 3
The previous README described a pre-instancing world (32-byte world-
coord vertices with per-vertex object_id, ObjectDrawInfo structs, EBO
reordering after BVH build, and a Phase 3 plan built around moving
draw submission to the GPU).  Most of that is either gone or already
solved:

  - Vertices are now 28 B local-coord; per-instance transforms live
    in an SSBO read through a visible-index SSBO and gl_BaseInstanceARB.
  - ObjectDrawInfo is replaced by MeshInfo + InstanceCpu + InstanceGpu.
  - No EBO reorder on BVH build — the BVH is over instance AABBs and
    the mesh/EBO layout is orthogonal.
  - Draw-call submission is already one glMultiDrawElementsIndirect
    per model; the old Phase 3 goal is met.

New content worth keeping:

  - GPU instancing section documents the mesh/instance/visible/indirect
    buffer contract the whole renderer hangs off of.
  - Reflection-aware two-pass draw is documented (det<0 placements,
    forward/reverse slice split, glFrontFace toggle).
  - reorient-shells and backface culling are called out as correctness
    + perf levers with their tradeoffs.
  - Phase 3 is rewritten around the actual bottleneck surfaced by
    profiling: per-frame glNamedBufferSubData stalls on the visible
    and indirect buffers.  Includes the diagnostic methodology (empty-
    screen jump to 60 fps, window/MSAA invariance, upload-comment-out
    experiment) so future-me remembers why this is the next step.
  - 3A (persistent mapped ring buffers, near-term) and 3B (GPU-side
    compute cull, longer-term) split out with scope estimates.
  - Roadmap updated: instancing / MDI / reflections / reorient-shells
    / backface cull all ticked; 3A surfaced as the next open item.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 3110c98429 Backface culling with reflection-aware two-pass MDI
Enables GL_CULL_FACE by default (user-toggleable in Settings) so
closed solids skip shading their back halves.  The catch is that
IFC placements can contain reflections (mat4 with det<0 — mirrored
families, symmetric instances).  Naively culling would make every
mirrored instance vanish because the rasterizer sees its screen-space
winding as backwards.

Fix: detect reflections at upload time via determinant sign, bucket
visible instances into forward (det>=0) and reverse (det<0) per mesh
during culling, and issue two glMultiDrawElementsIndirect calls per
model with glFrontFace toggled CCW/CW between them.  The indirect
buffer is still one buffer — just split into a forward slice followed
by a reverse slice, with m.indirect_forward_count recording the split.

Vertex shader flips the normal when the transform has negative
determinant, keeping lighting correct on mirrored instances.  The
fragment shader keeps the gl_FrontFacing fallback as a safety net
when culling is disabled (e.g. for files with open shells).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 0e2a62d3b7 Enable reorient-shells in geometry iterator
IFC files routinely have IfcConnectedFaceSets whose faces point
inconsistently within the same shell — the result under per-vertex
normals is dark inside-out patches, and under GL_CULL_FACE it's
swiss-cheese.  reorient-shells fixes the face winding at geometry
generation time, which is the only place it can be fixed correctly;
no shader trick can recover from a mesh whose triangles disagree
among themselves.

Off by default in IfcOpenShell because it adds iterator time, but
we cache the result in the sidecar so it's a one-shot cost per file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult f532624dc1 Two-sided lighting, rename misleading draw-count stat
Two bugs conflated as "weird colors":

1. Two-sided lighting.  IFC placements often embed reflection
   matrices (mirrored families).  Transforming a_normal by
   mat3(inst.transform) produces a normal pointing the wrong way
   on those instances, and max(n·L, 0) then clamps the surface to
   pure ambient — reads as dark / washed out.  Use gl_FrontFacing
   to flip n in the fragment shader so both winding orientations
   shade correctly.  The proper fix (ship an inverse-transpose
   normal matrix or a det-sign bit per instance) is still owed;
   that would unlock re-enabling GL_CULL_FACE for a big fragment-
   work win on closed solids.

2. Stats label "inst_draws" was counting indirect sub-draws, not
   actual GL draw calls — misleading since MDI collapses N sub-
   draws into one glMultiDrawElementsIndirect.  Split into
   gl_draw_calls (real GL calls, = drawn-model count) and
   indirect_sub_draws (packed sub-commands).  For a BIM model
   with 47k unique meshes at full view this now correctly reads
   "1 gl_draws (47092 sub)" rather than suggesting 47k driver
   dispatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult f0e3056d0a Collapse per-mesh draws into glMultiDrawElementsIndirect
Each visible model now issues a single glMultiDrawElementsIndirect
call instead of one glDrawElementsInstancedBaseVertex per mesh.  The
CPU BVH cull populates an array of DrawElementsIndirectCommand
records plus the flat visible-instance list, uploads both, and draws
the whole model in one GL call.

Vertex shaders switch from a uniform u_instance_offset to
gl_BaseInstanceARB (ARB_shader_draw_parameters), so per-draw offset
comes from the indirect command's baseInstance field.

Draw-call counts for BIM scenes with hundreds of unique meshes drop
from hundreds-per-frame to one-per-model, cutting driver overhead.
This also sets up the plumbing for the follow-up compute-shader cull
that will populate the indirect buffer entirely on-GPU.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 298eca0ab6 Progressive rendering during streaming
Pre-allocate the instance SSBO on model creation (4 MB, grow-on-demand)
and append each arriving InstanceChunk directly to the GPU-side
InstanceGpu array in uploadInstanceChunk.  This makes a model drawable
as soon as its first mesh + first instance chunk land, rather than
waiting for finalizeModel.

The visible-list architecture already decouples SSBO order from the
draw path, so appending in insertion order is correct — no sorting
required.  finalizeModel collapses to:
  - compute per-mesh instance counts (for stats + sidecar round-trip)
  - build the per-model BVH over instance world AABBs

Render / pick loops now gate on ssbo_instance_count > 0 rather than
the finalized flag.  Stats include in-progress models in totals
(excluding only hidden).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 1f17d73f3e BVH frustum culling over instances
Re-wires the BVH acceleration structure on top of the new instanced
renderer.  Per model, build a BVH over per-instance world AABBs at
finalize (and on sidecar apply).  Each frame, traverse the BVH against
the camera frustum to produce a visible-instance index list, bucket by
mesh_id, and upload to a per-model SSBO at binding=1.  The main and
pick vertex shaders do a double-indirection
`instances[visible[u_offset + gl_InstanceID]]` so draws only touch
instances that passed the frustum test.

Models with fewer than BVH_MIN_OBJECTS instances skip the BVH build
and fall back to a linear per-instance frustum test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult ababb49ae7 Sidecar v4: persist instanced geometry + metadata
Commit B of the instancing migration.  The sidecar on-disk format is
reintroduced at version 4 with MeshInfo + InstanceCpu sections in place
of v3's flat per-object draw-info array.

After streaming finishes, MainWindow asks the viewport for a post-
finalise snapshot (VBO + EBO are read back from the GPU, meshes and
instances come from the CPU-side arrays) and writes it alongside
PackedElementInfo + the string table.  On a subsequent load,
readSidecar rehydrates the whole struct and ViewportWindow::
applyCachedModel uploads VBO/EBO/SSBO in a single step, bypassing the
iterator entirely.

Staleness check is still by source file size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 07a5c59359 GPU instancing: streamer, viewport, shaders rewritten
Commit A of the instancing migration (Phase 3a).  The streamer now runs
the iterator with use-world-coords=false and dedupes by the geometry's
representation id, emitting a MeshChunk once per unique geometry and an
InstanceChunk per placement.  The viewport keeps geometry in local
coordinates (28 B/vertex, down from 32) and applies the per-instance
transform in the vertex shader via an std430 SSBO indexed by
gl_InstanceID + a per-draw uniform offset.  After streaming finishes
finalizeModel() stable-sorts instances by mesh_id, assigns each mesh a
contiguous range, and uploads the SSBO; render then issues one
glDrawElementsInstancedBaseVertex per mesh.

BvhAccel is reshaped to operate on a generic BvhItem (world AABB +
model_id) so it can drive instance-level culling, but the path is not
wired in yet -- every instance is drawn every frame in this commit.
Progressive-during-streaming rendering is likewise disabled: a model
appears when its SSBO is uploaded, not incrementally.  Sidecar cache
is stubbed (reads miss, writes are no-ops); the v4 on-disk format with
MeshInfo + InstanceGpu sections lands in Commit B.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 8c8ef5c32b Leaf-batched BVH draw commands
When a BVH leaf passes the frustum test, emit a single glMultiDrawElements
record covering the leaf's entire index range instead of one per object.
Leaves are contiguous in the EBO after reorderEbo, so the range is just
[first_object.index_offset, sum(index_count)]. Cuts draw calls by ~8x
(BVH_MAX_LEAF_SIZE) and shifts the bottleneck from CPU/driver per-draw
overhead toward GPU vertex throughput.

Per-object features (selection highlight, per-vertex color, object_id
picking) are unchanged — they operate on vertex attributes, not draw
state. Future per-object hide/override will use SSBO lookups sampled
by object_id in the fragment shader.

Slight overdraw from skipping per-object frustum tests within a leaf is
negligible given median-split BVH tightness and spare tri throughput.

Also adds visible_objects_ counter so stats still report true object
counts (not leaf counts), plus leaf_draws/model_draws breakdown in the
per-second frame log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 36fa53122a Add profiling for VRAM, FPS ratios, and instancing analysis
Per-second frame log reports fps/ms, visible/total object & triangle
ratios, VRAM breakdown (VBO+EBO), model count, and pending uploads.

Upload-complete log includes per-model VBO/EBO MB and scene total VRAM.

Streamer runs an instancing analysis keyed on geom.id(): total shapes,
unique representations, dedup ratio, theoretical VBO/EBO/SSBO sizes if
instanced, potential savings, and top-5 most-duplicated representations.
Used to validate whether GPU instancing is worth the architectural
rewrite for a given dataset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 1dace18d26 BVH frustum culling, sidecar cache, per-model buffers, progressive upload
Phase 2 performance: BVH acceleration with median-split build, per-model
trees, and EBO re-sorting for GPU cache coherence. Raw binary .ifcview
sidecar stores full geometry + BVH for instant subsequent loads (skip
tessellation entirely).

Per-model GPU buffers (VAO/VBO/EBO per model) eliminate cross-model buffer
copies on growth. Sidecar reads happen on a background thread. Bulk GPU
uploads are progressive (48 MB/frame chunks) so the viewport stays
interactive while multi-GB models stream in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 5b4c1089cf Update README for multi-model support and frustum culling
Reflect current architecture: per-model streamers, glMultiDrawElements
with frustum culling, 32-byte vertex format with color, multiselect
file picker, settings/stats files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 83a3131276 Multi-model project support with sequential loading
Introduce ModelHandle and per-model GeometryStreamers so multiple IFC
files can be loaded simultaneously. Object IDs are globally unique
(monotonically increasing across models). File picker is now multiselect.
Each model gets a top-level tree node. Property lookup uses the correct
model's ifcopenshell::file. ViewportWindow supports hide/show/remove
per model via model_id filtering in the frustum cull pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 6f6bebf387 Add performance stats overlay in status bar
Show FPS, frame time, visible/total objects, and visible/total
triangles in the status bar. Toggled via Settings > Show Performance
Stats, persisted in app settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult bfac12dbe7 Per-object frustum culling with glMultiDrawElements
Track per-object AABB and index range during upload. Each frame,
extract frustum planes from the view-projection matrix and cull
objects whose AABB is entirely outside any plane. Draw only visible
objects via glMultiDrawElements. Document the three-phase rendering
performance strategy in README.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult d33055bb72 Plan out performance strategy 2026-04-23 21:32:24 +10:00
Dion Moult d08a4e0706 Update ifcviewer to compile with datamodel refactor 2026-04-23 21:32:24 +10:00
Dion Moult 06eca938d7 Dump of hello world ifc viewer code 2026-04-23 21:32:24 +10:00
Dion Moult 543d9f8588 Local hacks to compile and monkey patch issues in the Python world
All AI generated slop. Do NOT trust these "fixes". It's just to get it
working on my machine.
2026-04-23 21:32:24 +10:00
Thomas Krijnen b40e4378b3 update workflows 2026-04-23 10:58:57 +02:00
Thomas Krijnen 1089de14f0 quotes 2026-04-23 10:51:52 +02:00
Thomas Krijnen 1b35533917 Update workflow for .so copying 2026-04-22 21:40:05 +02:00
Thomas Krijnen c42a7f32d0 The proper id / identity fix for rocksdb 2026-04-22 18:11:01 +02:00
Thomas Krijnen 14e9846e35 identity_ for types; id_ for instances 2026-04-22 12:04:24 +02:00
Thomas Krijnen 37c6aea092 forgot to set goosd 2026-04-22 12:04:09 +02:00
Thomas Krijnen 9b13dc8dd6 Get rid of parse context pool 2026-04-22 12:03:58 +02:00
Thomas Krijnen 89c66f62bf Python import fixes: import from wrapper now which inherits from mixins 2026-04-21 21:59:02 +02:00
Thomas Krijnen 18363c0d19 No soname for plugin.so in CREATE_BUNDLE mode 2026-04-21 21:52:04 +02:00
Thomas Krijnen 782aa4f88f Copy more so to python module 2026-04-21 21:51:21 +02:00
Thomas Krijnen 13c71d7a8a symbol visibility 2026-04-21 21:48:54 +02:00
Thomas Krijnen cb7e7331e6 CREATE_BUNDLE=On to copy .so 2026-04-21 21:48:15 +02:00
Thomas Krijnen 4850e2a07c add manifold to build-all.py 2026-04-21 21:47:38 +02:00
Thomas Krijnen fd980abcc2 Reverse subdir order so that svgfill is a proper target 2026-04-21 21:46:56 +02:00
Thomas Krijnen b022ca7e70 Some plug-in work 2026-04-21 16:18:59 +02:00
Thomas Krijnen 325db2e57f Export templates 2026-04-21 11:55:04 +02:00
Thomas Krijnen 046ceb452a Tighten scope of cmake vars and dirs 2026-04-19 12:32:18 +02:00
Thomas Krijnen 9e19735275 IfcConvert Plug-in discovery for info print 2026-04-19 12:32:10 +02:00
Thomas Krijnen 6ee05b646b swig ignore Base::Base(std::nullopt_t); 2026-04-19 12:24:36 +02:00
Thomas Krijnen e62921171c Remove duplicated attr in wrapper 2026-04-19 11:42:24 +02:00
Thomas Krijnen 6c47123781 Remove C++ references to ifcxml 2026-04-19 10:35:04 +02:00
Thomas Krijnen d448fa95e3 build-all.py use single build dir 2026-04-19 10:21:23 +02:00
Thomas Krijnen 57de09d236 Pointer issue 2026-04-19 09:31:49 +02:00
Thomas Krijnen a25f522cc3 typo 2026-04-19 08:33:48 +02:00
Thomas Krijnen e067c2b834 build-all BUILD_SHARED_LIBS tweak 2026-04-18 21:43:36 +02:00
Thomas Krijnen e2905d6f0e BUILD_SHARED_LIBS=On 2026-04-18 21:32:32 +02:00
Thomas Krijnen ead061a98a svgfill dll link related 2026-04-18 21:13:24 +02:00
Thomas Krijnen fae85e63cc std::optional 2026-04-18 21:06:39 +02:00
Thomas Krijnen bccea6d932 Examples and update virtual bases for new codegen 2026-04-18 21:04:42 +02:00
Thomas Krijnen 1cc93784cd Rerun codegen 2026-04-18 21:03:02 +02:00
Thomas Krijnen f1e93581ec Reuse constructors and return *this from initialize() 2026-04-18 21:01:21 +02:00
Thomas Krijnen 91ae631c7d Merge remote-tracking branch 'origin/v0.8.0' into datamodel-v1.0 2026-04-18 20:15:28 +02:00
Thomas Krijnen b599ee1040 More work on isolating into plug-ins 2026-04-18 15:46:21 +02:00
Thomas Krijnen d2cc66fdf0 tree and document plug-ins 2026-04-17 11:24:09 +02:00
Thomas Krijnen 3824e7b449 First start plug-in architecture 2026-04-15 18:07:28 +02:00
Thomas Krijnen aa10784154 First slice plug-in refactor 2026-04-14 13:51:09 +02:00
Thomas Krijnen 2018bcb3c1 This needs some serious scrutiny 2026-04-13 21:28:55 +02:00
Thomas Krijnen 11006f0ef5 deque 2026-04-13 21:18:58 +02:00
Thomas Krijnen a44f72287b pasta errors 2026-04-13 21:18:49 +02:00
Thomas Krijnen c6849073d9 Reset weights 2026-04-13 21:18:41 +02:00
Thomas Krijnen b2fc0c00cc Hierarchical index for inverses 2026-04-10 14:54:47 +02:00
Thomas Krijnen 2d5883f966 Dilation of 2nd operands as a poor mans fuzziness 2026-04-10 14:46:46 +02:00
Thomas Krijnen 4621fc9269 Less useless logging 2026-04-10 09:54:28 +02:00
Thomas Krijnen 1840e3d1a8 Add passthrough kernel 2026-04-09 17:17:53 +02:00
Thomas Krijnen becd38c77d Compilation fixes 2026-04-09 16:18:53 +02:00
Thomas Krijnen 7d6c6bd523 Fix iteration: prevent inserting nullptr equivalents into a set 2026-04-09 11:53:28 +02:00
Thomas Krijnen a425bb6da0 Cache some hotpath inverses regarding context handling 2026-04-07 16:13:01 +02:00
Thomas Krijnen a19d398c78 Vibe code an implementation that uses manifold 2026-04-07 15:47:58 +02:00
Thomas Krijnen f616c4049c Add Codex-generated wrappergen 2026-03-31 20:51:46 +02:00
Thomas Krijnen 20ccf2b455 Parameter naming 2026-03-31 18:23:13 +02:00
Thomas Krijnen a07f56db6f Restructure and rename 2026-03-31 15:32:36 +02:00
Thomas Krijnen 724cdb446e Move schemas into schemas/ subfolder 2026-03-31 10:02:00 +02:00
Thomas Krijnen 95b3f7dac4 Don't include dot as special when doing float runs 2026-03-27 21:04:48 +01:00
Thomas Krijnen 603cedc487 Try some things: (a) fewer allocations - parse context pool; lexer string pool (b) SWAR process multiple chars at once in keywords/enums/strs/stc. 2026-03-27 20:45:13 +01:00
Thomas Krijnen fe6e9d86ae Xml serializer proper specialization 2026-03-26 15:49:54 +01:00
Thomas Krijnen 8e42f35db3 Rework variable length token storage to use string pool; eliminate need for rereads 2026-03-26 15:49:28 +01:00
Thomas Krijnen 9d69a712ac Does SWIG prefer std::conditional_t over auto return type? 2026-03-26 11:33:00 +01:00
Thomas Krijnen dc127471c5 Rerun codegen 2026-03-26 10:52:12 +01:00
Thomas Krijnen 95a094d596 Fix some schema generation issues 2026-03-26 10:39:29 +01:00
Thomas Krijnen 4ec643d4e1 schema entity initialize() method, optional argument forwarding in file::create, populate_derived in InstanceData 2026-01-15 19:24:55 +01:00
Thomas Krijnen c089c11cd6 Hack a bit to make stream tests run 2026-01-15 16:22:18 +01:00
Thomas Krijnen 8afba9a665 Hack a bit to make sql tests run 2026-01-15 16:12:25 +01:00
Thomas Krijnen 5c4046e054 declaration as property 2026-01-15 16:11:58 +01:00
Thomas Krijnen d82e1da907 Don't leak parent id into type decl instances 2026-01-15 15:38:49 +01:00
Thomas Krijnen 8c2e1226c9 declaration property 2026-01-15 15:38:32 +01:00
Thomas Krijnen ed8821486d Broaden __eq__ for type decl instances 2026-01-15 14:23:49 +01:00
Thomas Krijnen 742bfac144 _remove, schema_identifier and test_file 2026-01-15 14:13:29 +01:00
Thomas Krijnen 1f4c4204d0 Re-enable setting logical with UNKNOWN in python 2026-01-15 13:13:51 +01:00
Thomas Krijnen 43f78605f2 Small tweak to invocation of test/test_rules.py 2026-01-15 13:00:48 +01:00
Thomas Krijnen 6cd1375cf9 Rerun rule compilation for exists() on indeterminate 2026-01-15 13:00:37 +01:00
Thomas Krijnen 2d1250c18f Global file for rule and derived attributes 2026-01-15 12:39:32 +01:00
Thomas Krijnen bee61cba11 Accept exact schema_identifier in file() 2026-01-15 12:38:36 +01:00
Thomas Krijnen 33f072e0a7 Account for removed instance factory 2026-01-15 12:37:53 +01:00
Thomas Krijnen 490fa92dc0 Rework equality and get_info_2 2026-01-15 12:37:12 +01:00
Thomas Krijnen cf3393b6a0 black 2026-01-14 14:09:40 +01:00
Thomas Krijnen fbbc92c5d7 Hashing solely based on identity 2026-01-14 14:03:55 +01:00
Thomas Krijnen 574827016a Initialization of header and file 2026-01-14 14:03:28 +01:00
Thomas Krijnen 18c9140700 Data types 2026-01-14 14:03:03 +01:00
Thomas Krijnen 9e165319e6 Fix schema passing to file creation 2026-01-14 14:02:48 +01:00
Thomas Krijnen 64bf807baa Fix add entity with id 2026-01-14 14:02:10 +01:00
Thomas Krijnen 136befde86 Fix write() call 2026-01-14 14:01:39 +01:00
Thomas Krijnen 1a4d750ecd Consistency of get_inverse calls 2026-01-14 14:00:50 +01:00
Thomas Krijnen 0a5dd78774 Fix add entity with id 2026-01-14 14:00:00 +01:00
Thomas Krijnen 94427ebcb3 Allow setting of history and future 2026-01-14 13:59:26 +01:00
Thomas Krijnen 9147938c36 Aggregate data types 2026-01-14 13:59:07 +01:00
Thomas Krijnen ffcc02fb99 Remove global create_entity() call 2026-01-14 13:57:26 +01:00
Thomas Krijnen e4e4f31d20 Defer deletion so that traversal still works 2026-01-13 08:45:04 +01:00
Thomas Krijnen 2dd002bcd8 Properly constuct type decl instances 2026-01-13 08:44:44 +01:00
Thomas Krijnen 7e5248da29 Fix create_shape() overloads because SWIG does not map None for us anymore 2026-01-13 08:44:30 +01:00
Thomas Krijnen 07a01bcf54 Process derived attributes that are not redeclared (C++ has no knowledge of them) 2026-01-13 08:43:52 +01:00
Thomas Krijnen 0a9e29ce45 black 2026-01-10 11:52:08 +01:00
Thomas Krijnen 0604db06e9 typename 2026-01-10 11:52:05 +01:00
Thomas Krijnen 991556fbe3 template as 2026-01-10 11:03:26 +01:00
Thomas Krijnen b66b04b001 Remove usage of .wrapped_item and some other fixes 2026-01-10 11:01:18 +01:00
Thomas Krijnen 5c9213426f Hacks and fixes to get python code back in reasonable state 2026-01-10 10:21:09 +01:00
Thomas Krijnen 69a4ad35a1 Remaining cpp changes 2026-01-10 10:20:34 +01:00
Thomas Krijnen 7f4d9e31c3 Add test about deletion 2026-01-08 11:52:04 +01:00
Thomas Krijnen ae79996eb6 Fix running of test/tests.py 2026-01-08 11:49:29 +01:00
Thomas Krijnen f5b2358c2e Make Base::data() private, file::add(..., id) 2026-01-08 10:35:46 +01:00
Thomas Krijnen f9c791de1b Fix examples 2026-01-08 09:44:43 +01:00
Thomas Krijnen 5b85a2c38b Fix examples 2026-01-08 09:44:00 +01:00
Thomas Krijnen bc46a8f85b Fixes 2026-01-08 09:38:46 +01:00
Thomas Krijnen c57209fbce Fixes 2026-01-07 20:37:10 +01:00
Thomas Krijnen 088a6b7204 Reinstate IfcAlignment example 2026-01-07 18:15:23 +01:00
Thomas Krijnen f2f4d626a5 Fix examples mostly 2026-01-07 13:51:48 +01:00
Thomas Krijnen 572a5655cf Fixes for gcc 2026-01-06 13:23:48 +01:00
Thomas Krijnen ccd91c0ff0 No more messing around with specific sfinae as<>() in Select implementations 2026-01-06 11:27:29 +01:00
Thomas Krijnen a1d2c902f3 Add enum to forward declarations for Ifc2x3::IfcNullStyle 2026-01-06 09:56:36 +01:00
Thomas Krijnen 3ae361bb3b Rerun codegen 2026-01-06 09:49:28 +01:00
Thomas Krijnen 2d7521ed88 Move template down to .cpp to prevent use of incomplete type 2026-01-06 09:41:20 +01:00
Thomas Krijnen 7098beb819 Work towards v1.0 data model with encapsulated weak_ptr as basis for instances 2026-01-05 21:42:01 +01:00
Thomas Krijnen f09ca658f1 Initial investigation into lofting open profile with tags 2025-12-02 16:34:24 +01:00
776 changed files with 422921 additions and 364826 deletions
@@ -0,0 +1,101 @@
name: Build Bonsai Viewer Autodesk Connector
on:
workflow_dispatch:
push:
paths:
- 'src/bonsaiviewer-autodesk/**'
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
pull_request:
paths:
- 'src/bonsaiviewer-autodesk/**'
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
jobs:
test:
name: test-py${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Exercise the floor and a current version of the supported range
# (pyproject requires-python = ">=3.11").
python-version: ['3.11', '3.13']
defaults:
run:
working-directory: src/bonsaiviewer-autodesk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
# The connector imports tkinter/customtkinter (via the test suite's
# connector coverage), so fail loudly here if Tk is missing.
- name: Verify tkinter is available
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
- name: Install package and test deps
run: python -m pip install ".[test]"
- name: Run pytest
run: python -m pytest -q
build:
name: ${{ matrix.os_label }}-${{ matrix.arch }}
needs: test
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
# Linux: build on the oldest reasonable glibc (Ubuntu 22.04 / glibc
# 2.35) so the bundle runs on older distributions.
- os_label: linux
arch: x86_64
runner: ubuntu-22.04
# macOS Apple Silicon.
- os_label: macos
arch: arm64
runner: macos-14
# macOS Intel. macos-13 is the last Intel runner GitHub provides.
- os_label: macos
arch: x86_64
runner: macos-13
# Windows x86_64.
- os_label: windows
arch: x86_64
runner: windows-latest
defaults:
run:
working-directory: src/bonsaiviewer-autodesk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
# actions/setup-python ships Python with tkinter on all three OSes via
# python-build-standalone, but verify so a missing-tk regression fails
# the build loudly instead of inside PyInstaller.
- name: Verify tkinter is available
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
- name: Install package and build deps
run: python -m pip install ".[build]"
- name: Build connector bundle
run: python packaging/build.py
- name: Upload connector zip
uses: actions/upload-artifact@v7
with:
name: autodesk-${{ matrix.os_label }}-${{ matrix.arch }}
path: src/bonsaiviewer-autodesk/dist/autodesk-${{ matrix.os_label }}-${{ matrix.arch }}.zip
if-no-files-found: error
+28 -8
View File
@@ -110,7 +110,20 @@ jobs:
run: |
VERSION=v`cat VERSION`
cd ./build/`uname`/*/10.15/install/ifcopenshell
mkdir ~/output
mkdir -p ~/output
install_root="$PWD"
stage_runtime_payload() {
dest="$1"
while IFS= read -r runtime_file; do
cp -L "$runtime_file" "$dest/"
done < <(
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
[ -d "$runtime_dir" ] || continue
find "$runtime_dir" -type f \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
done
)
}
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
@@ -125,18 +138,25 @@ jobs:
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip ifcopenshell/*
stage_runtime_payload ifcopenshell
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip ifcopenshell
mv *.zip ~/output
popd > /dev/null
done
cd bin
rm *.zip || true
ls | while read exe; do
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip $exe
rm -f "$install_root"/bin/*.zip
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
rm -rf "$package_dir"
mkdir -p "$package_dir"
cp "$exe_path" "$package_dir/"
stage_runtime_payload "$package_dir"
pushd "$package_dir" > /dev/null
zip -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip" .
popd > /dev/null
rm -rf "$package_dir"
done
mv *.zip ~/output
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
+13
View File
@@ -40,6 +40,18 @@ jobs:
NEW_FILE=`echo $FILE | sed "s/-/+${GITHUB_SHA:0:7}-/2"`
mv $FILE $NEW_FILE
- name: Order wheel shared objects
run: |
python ./IfcOpenShell/pyodide/order_pyodide_wheel_shared_objects.py dist/ifcopenshell-*.whl
- name: Split packages
run: |
VERSION=v`cat ./IfcOpenShell/VERSION`
mkdir -p dist-modular
python ./IfcOpenShell/pyodide/split_pyodide_ifcopenshell_wheel.py dist/ifcopenshell-*.whl ./dist-modular
cd dist-modular
zip -r -qq ifcopenshell-modular-${VERSION}-${GITHUB_SHA:0:7}-pyodide.zip *.whl
- name: Upload Build Logs
if: always()
uses: actions/upload-artifact@v7
@@ -86,3 +98,4 @@ jobs:
- name: Upload .zip archives to S3
run: |
aws s3 cp dist s3://ifcopenshell-builds/ --recursive --exclude "*" --include "*.whl"
aws s3 cp dist-modular s3://ifcopenshell-builds/ --recursive --exclude "*" --include "*.zip"
+125 -11
View File
@@ -12,13 +12,16 @@ jobs:
- name: Install Dependencies
run: |
dnf update -y
dnf install -y epel-release
dnf install -y gcc gcc-c++ git autoconf automake bison make zip cmake python3 python3-pip \
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
python3 -m pip install typing_extensions
findutils xz byacc patchelf libxkbcommon-devel \
python3.11 python3.11-pip python3.11-tkinter
python3 -m pip install typing_extensions aqtinstall
git config --global --add safe.directory '*'
python3.11 -c "import tkinter; print('Tk', tkinter.TkVersion)"
- name: Install aws cli
run: |
@@ -56,7 +59,7 @@ jobs:
shell: bash
run: |
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -83,10 +86,107 @@ jobs:
git push || true
- name: Package .zip archives
shell: bash
run: |
VERSION=v`cat VERSION`
python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
test -d "$autodesk_connector_dir"
cd ./build/`uname`/*/install/ifcopenshell
mkdir ~/output
mkdir -p ~/output
install_root="$PWD"
QT6_VERSION="${QT6_VERSION:-6.8.3}"
if [ -z "${QT_DIR:-}" ]; then
for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do
if [ -d "$qt_candidate/lib" ]; then
QT_DIR="$qt_candidate"
break
fi
done
fi
ensure_soname_links() {
dest="$1"
find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do
soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1)
[ -n "$soname" ] || continue
[ -e "$dest/$soname" ] && continue
ln -s "$(basename "$shared_object")" "$dest/$soname"
done
}
stage_runtime_payload() {
dest="$1"
include_geometry_writers="${2:-1}"
while IFS= read -r runtime_file; do
if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then
continue
fi
cp -P "$runtime_file" "$dest/"
done < <(
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
[ -d "$runtime_dir" ] || continue
find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
done
)
ensure_soname_links "$dest"
}
stage_qt_runtime_payload() {
exe_path="$1"
dest="$2"
[ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0
if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then
return 0
fi
find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \;
ensure_soname_links "$dest"
if [ -d "$QT_DIR/plugins" ]; then
pushd "$QT_DIR/plugins" > /dev/null
find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do
mkdir -p "$dest/plugins/$(dirname "$plugin_file")"
cp -P "$plugin_file" "$dest/plugins/$plugin_file"
done
popd > /dev/null
if [ -d "$dest/plugins" ]; then
find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \;
fi
fi
find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;
printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf"
}
check_runtime_dependencies() {
package_dir="$1"
missing=0
while IFS= read -r binary_file; do
readelf -h "$binary_file" >/dev/null 2>&1 || continue
if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then
echo "ldd failed for $binary_file"
cat "$package_dir/.ldd.out"
missing=1
continue
fi
if grep -q "not found" "$package_dir/.ldd.out"; then
echo "Missing runtime dependencies for $binary_file"
grep "not found" "$package_dir/.ldd.out"
missing=1
fi
done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \))
rm -f "$package_dir/.ldd.out"
if [ "$missing" -ne 0 ]; then
echo "Runtime dependency check found issues; continuing packaging."
fi
return 0
}
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
@@ -101,18 +201,32 @@ jobs:
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell/*
stage_runtime_payload ifcopenshell
zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell
mv *.zip ~/output
popd > /dev/null
done
cd bin
rm *.zip || true
ls | while read exe; do
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip $exe
rm -f "$install_root"/bin/*.zip
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
rm -rf "$package_dir"
mkdir -p "$package_dir"
cp "$exe_path" "$package_dir/"
patchelf --set-rpath '$ORIGIN' "$package_dir/$exe"
stage_runtime_payload "$package_dir" 0
stage_qt_runtime_payload "$exe_path" "$package_dir"
if [ "$exe" = "BonsaiViewer" ]; then
mkdir -p "$package_dir/connectors"
cp -a "$autodesk_connector_dir" "$package_dir/connectors/"
fi
check_runtime_dependencies "$package_dir"
pushd "$package_dir" > /dev/null
zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip" .
popd > /dev/null
rm -rf "$package_dir"
done
mv *.zip ~/output
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
+125 -11
View File
@@ -12,13 +12,16 @@ jobs:
- name: Install Dependencies
run: |
dnf update -y
dnf install -y epel-release
dnf install -y gcc gcc-c++ git autoconf automake bison make zip cmake python3 python3-pip \
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
findutils xz byacc
python3 -m pip install typing_extensions
findutils xz byacc patchelf libxkbcommon-devel \
python3.11 python3.11-pip python3.11-tkinter
python3 -m pip install typing_extensions aqtinstall
git config --global --add safe.directory '*'
python3.11 -c "import tkinter; print('Tk', tkinter.TkVersion)"
- name: Install aws cli
run: |
@@ -56,7 +59,7 @@ jobs:
shell: bash
run: |
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log
- name: Upload Build Logs
if: always()
@@ -83,10 +86,107 @@ jobs:
git push || true
- name: Package .zip archives
shell: bash
run: |
VERSION=v`cat VERSION`
python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
test -d "$autodesk_connector_dir"
cd ./build/`uname`/*/install/ifcopenshell
mkdir ~/output
mkdir -p ~/output
install_root="$PWD"
QT6_VERSION="${QT6_VERSION:-6.8.3}"
if [ -z "${QT_DIR:-}" ]; then
for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do
if [ -d "$qt_candidate/lib" ]; then
QT_DIR="$qt_candidate"
break
fi
done
fi
ensure_soname_links() {
dest="$1"
find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do
soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1)
[ -n "$soname" ] || continue
[ -e "$dest/$soname" ] && continue
ln -s "$(basename "$shared_object")" "$dest/$soname"
done
}
stage_runtime_payload() {
dest="$1"
include_geometry_writers="${2:-1}"
while IFS= read -r runtime_file; do
if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then
continue
fi
cp -P "$runtime_file" "$dest/"
done < <(
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
[ -d "$runtime_dir" ] || continue
find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
done
)
ensure_soname_links "$dest"
}
stage_qt_runtime_payload() {
exe_path="$1"
dest="$2"
[ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0
if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then
return 0
fi
find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \;
ensure_soname_links "$dest"
if [ -d "$QT_DIR/plugins" ]; then
pushd "$QT_DIR/plugins" > /dev/null
find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do
mkdir -p "$dest/plugins/$(dirname "$plugin_file")"
cp -P "$plugin_file" "$dest/plugins/$plugin_file"
done
popd > /dev/null
if [ -d "$dest/plugins" ]; then
find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \;
fi
fi
find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;
printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf"
}
check_runtime_dependencies() {
package_dir="$1"
missing=0
while IFS= read -r binary_file; do
readelf -h "$binary_file" >/dev/null 2>&1 || continue
if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then
echo "ldd failed for $binary_file"
cat "$package_dir/.ldd.out"
missing=1
continue
fi
if grep -q "not found" "$package_dir/.ldd.out"; then
echo "Missing runtime dependencies for $binary_file"
grep "not found" "$package_dir/.ldd.out"
missing=1
fi
done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \))
rm -f "$package_dir/.ldd.out"
if [ "$missing" -ne 0 ]; then
echo "Runtime dependency check found issues; continuing packaging."
fi
return 0
}
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
@@ -101,18 +201,32 @@ jobs:
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip ifcopenshell/*
stage_runtime_payload ifcopenshell
zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip ifcopenshell
mv *.zip ~/output
popd > /dev/null
done
cd bin
rm *.zip || true
ls | while read exe; do
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip $exe
rm -f "$install_root"/bin/*.zip
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
exe=`basename "$exe_path"`
package_dir="$install_root/.package-${exe}"
rm -rf "$package_dir"
mkdir -p "$package_dir"
cp "$exe_path" "$package_dir/"
patchelf --set-rpath '$ORIGIN' "$package_dir/$exe"
stage_runtime_payload "$package_dir" 0
stage_qt_runtime_payload "$exe_path" "$package_dir"
if [ "$exe" = "BonsaiViewer" ]; then
mkdir -p "$package_dir/connectors"
cp -a "$autodesk_connector_dir" "$package_dir/connectors/"
fi
check_runtime_dependencies "$package_dir"
pushd "$package_dir" > /dev/null
zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip" .
popd > /dev/null
rm -rf "$package_dir"
done
mv *.zip ~/output
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
+23 -1
View File
@@ -48,7 +48,11 @@ jobs:
run: |
cd ${{ matrix.deps_dir }}
Get-ChildItem -Path . -Filter 'cache-*.zip' | ForEach-Object {
7z x $_.FullName
Write-Host "Extracting $($_.Name)"
7z x -bso0 -bsp0 $_.FullName
if ($LASTEXITCODE -ne 0) {
throw "Failed to extract $($_.Name) with 7z exit code $LASTEXITCODE."
}
}
- name: ccache
@@ -59,6 +63,24 @@ jobs:
# and with default 500MB some cache gets deleted, leading to misses.
max-size: 5000MB
- name: Set up Python for connector build
uses: actions/setup-python@v6
with:
python-version: '3.12'
# The connector's PyInstaller bundle embeds a tkinter GUI; verify Tk is
# present so a missing-tk regression fails here, not inside the build.
- name: Verify tkinter is available
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
# Build the Autodesk connector before the C++ build: build-all-win.py
# bundles it next to BonsaiViewer.exe while archiving the executables.
- name: Build Autodesk connector
working-directory: src/bonsaiviewer-autodesk
run: |
python -m pip install ".[build]"
python packaging/build.py
- name: Run Build Script And Pack .zip Archives
shell: cmd
env:
+1 -2
View File
@@ -31,7 +31,7 @@ jobs:
sudo apt-get install --no-install-recommends \
git cmake gcc g++ libboost-all-dev python3-all-dev swig libpcre3-dev libxml2-dev \
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
libhdf5-dev libcgal-dev nlohmann-json3-dev libeigen3-dev
libcgal-dev nlohmann-json3-dev libeigen3-dev
-
name: ccache
@@ -60,7 +60,6 @@ jobs:
-DMPFR_INCLUDE_DIR=/usr/include \
-DGMP_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
-DMPFR_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
-DHDF5_INCLUDE_DIR=/usr/include/hdf5/serial \
-DGLTF_SUPPORT=On \
-DJSON_INCLUDE_DIR=/usr/include \
-DEIGEN_DIR=/usr/include/eigen3 \
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
libtbb-dev nlohmann-json3-dev \
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
${OCCT_CMAKE_DEPS} \
libhdf5-dev libcgal-dev libeigen3-dev
libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
+1 -2
View File
@@ -36,7 +36,7 @@ jobs:
swig libpcre3-dev libxml2-dev \
libtbb-dev nlohmann-json3-dev \
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
libhdf5-dev libcgal-dev opencollada-dev
libcgal-dev opencollada-dev
- name: Build
env:
@@ -64,7 +64,6 @@ jobs:
-DMPFR_INCLUDE_DIR=/usr/include \
-DGMP_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
-DMPFR_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
-DHDF5_INCLUDE_DIR=/usr/include/hdf5/serial \
-DOPENCOLLADA_INCLUDE_DIR=/usr/include/opencollada \
-DOPENCOLLADA_LIBRARY_DIR=/usr/lib/opencollada/ \
../cmake
+4
View File
@@ -128,3 +128,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
*.claude
*.py.tmp*
*.json.tmp*
# bonsaiviewer-autodesk connector build artifacts
/src/bonsaiviewer-autodesk/build/
/src/bonsaiviewer-autodesk/dist/
+1 -1
View File
@@ -27,7 +27,7 @@ RUN echo "deb http://archive.ubuntu.com/ubuntu focal-proposed main restricted" |
libboost-all-dev \
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev \
libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
libhdf5-serial-dev python3-pytest ; \
python3-pytest ; \
rm -rf /var/lib/apt/lists/* ;
COPY . /home/IfcOpenShell/
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -e
mkdir -p build && cd build
cmake ../cmake \
-G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DPython_EXECUTABLE=/home/dion/Projects/env/bin/python3.11 \
-DPython_INCLUDE_DIR=/usr/include/python3.11 \
-DBUILD_IFCPYTHON=ON \
-DBUILD_IFCGEOM=ON \
-DBUILD_CONVERT=ON \
-DBUILD_GEOMSERVER=OFF \
-DBUILD_EXAMPLES=OFF \
-DWITH_OPENCASCADE=ON \
-DWITH_CGAL=ON \
-DWITH_MANIFOLD=ON \
-DGLTF_SUPPORT=ON \
-DIFCXML_SUPPORT=OFF \
-DCOLLADA_SUPPORT=OFF \
-DSCHEMA_VERSIONS="2x3;4;4x3_add2" \
-DOCC_INCLUDE_DIR=/usr/include/opencascade \
-DOCC_LIBRARY_DIR=/usr/lib64/opencascade
ninja
cp ifcwrap/_ifcopenshell_wrapper*.so ifcwrap/ifcopenshell_wrapper.py \
../src/ifcopenshell-python/ifcopenshell/
+208 -214
View File
@@ -18,28 +18,19 @@
################################################################################
cmake_minimum_required(VERSION 3.21)
if(NOT DEFINED CMAKE_CXX_STANDARD)
if (NOT DEFINED CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 17)
endif()
if(CMAKE_CXX_STANDARD LESS 17)
if (CMAKE_CXX_STANDARD LESS 17)
message(FATAL_ERROR "C++17 or newer is required.")
endif()
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
if(POLICY CMP0141) # 3.25+
# Has to be set before `project` to take effect.
cmake_policy(SET CMP0141 NEW) # Support for `CMAKE_MSVC_DEBUG_INFORMATION_FORMAT`.
cmake_policy(SET CMP0141 NEW) # Support for `CMAKE_MSVC_DEBUG_INFORMATION_FORMAT`.
endif()
if(POLICY CMP0144) # 3.27
cmake_policy(SET CMP0144 NEW) # find_package() uses upper-case <PACKAGENAME>_ROOT variables.
@@ -48,8 +39,6 @@ if(POLICY CMP0167) # 3.30
cmake_policy(SET CMP0167 OLD)
endif()
project(IfcOpenShell VERSION ${RELEASE_VERSION})
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE "Release")
endif()
@@ -65,64 +54,69 @@ endif()
option(MINIMAL_BUILD "The build is to make a minimal version of IFC converter from OCCT into IFC." OFF)
option(WASM_BUILD "Build a WebAssembly binary." OFF)
option(
ENABLE_BUILD_OPTIMIZATIONS
"Enable certain compiler and linker optimizations on RelWithDebInfo and Release builds."
OFF
)
option(BUILD_SHARED_LIBS "Build IfcParse and IfcGeom as shared libs (SO/DLL)." OFF)
option(ENABLE_BUILD_OPTIMIZATIONS "Enable certain compiler and linker optimizations on RelWithDebInfo and Release builds." OFF)
option(BUILD_SHARED_LIBS "Build IfcOpenShell as shared libraries (required)." ON)
option(MSVC_PARALLEL_BUILD "Multi-threaded compilation in Microsoft Visual Studio (/MP)" OFF)
option(USE_VLD "Use Visual Leak Detector for debugging memory leaks, MSVC-only." OFF)
option(USE_MMAP "Adds a command line options to parse IFC files from memory mapped files using Boost.Iostreams" OFF)
option(NO_WARN "Disable all warnings" OFF)
option(CREATE_BUNDLE "Copy .so files and don't create RPATHS or SOVERSION symlinks" )
option(BUILD_IFCGEOM "Build IfcGeom." ON)
option(BUILD_IFCPYTHON "Build IfcPython." ON)
option(BUILD_IFCPARSE_EXPERIMENTAL_WRAPPER "Build the experimental Clang-generated ifcparse Python wrapper." OFF)
option(BUILD_CONVERT "Build IfcConvert executable." ON)
option(BUILD_DOCUMENTATION "Build IfcOpenShell Documentation." OFF)
option(BUILD_EXAMPLES "Build example applications." OFF)
option(BUILD_EXAMPLES "Build example applications." ON)
option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is required)." ON)
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
option(BUILD_BONSAIVIEWER "Build Bonsai Viewer" OFF) # Requires Qt6 + OpenGL 4.5
option(BUILD_BONSAIVIEWER_TESTS "Build unit tests for Bonsai Viewer core (fetches Catch2 v3)" OFF)
option(BUILD_PACKAGE "" OFF)
# Most users probably need just common schemas,
# but we're keeping it `OFF` by default to avoid disruption
# (e.g. all Python distribution would need to adapt this option to be set).
option(
BUILD_ONLY_COMMON_SCHEMAS
"Build only common IFC schemas (2x3, 4, 4x3_add2). By default all schemas will be built."
IFCOPENSHELL_DEPLOY_QT_RUNTIME
"Deploy Qt runtime dependencies for installed Qt applications."
ON
)
option(
IFCOPENSHELL_DEPLOY_QT_TRANSLATIONS
"Deploy Qt translation catalogs with installed Qt applications."
OFF
)
option(SCHEMA_VERSIONS "Explicitly specify schemas to build." "")
option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON)
option(WITH_CGAL "Enable geometry interpretation using CGAL" ON)
option(WITH_MANIFOLD "Enable geometry interpretation using Manifold" OFF)
option(COLLADA_SUPPORT "Build IfcConvert with COLLADA support (requires OpenCOLLADA)." ON)
option(GLTF_SUPPORT "Build IfcConvert with glTF support (requires json.hpp)." OFF)
option(HDF5_SUPPORT "Enable HDF5 support (requires HDF5, zlib)" ON)
option(WITH_PROJ "Enable output of Earth-Centered Earth-Fixed glTF output using the PROJ library" OFF)
option(IFCXML_SUPPORT "Build IfcParse with ifcXML support (requires libxml2)." ON)
option(USD_SUPPORT "Build IfcConvert with USD support (requires pixar's USD library)." OFF)
option(WITH_RELATIONSHIP_VALIDATION "Build IfcConvert with option to validate geometrical relationships." OFF)
option(WITH_ROCKSDB "Support a RocksDB key-value store as a file backend in IfcOpenShell" OFF)
option(WITH_ZSTD "Use Zstd compression in RocksDB writes" OFF)
option(USERSPACE_PYTHON_PREFIX "Installs IfcPython for the current user only instead of system-wide." OFF)
option(USE_DEBUG_PYTHON "Use debug binaries when building Debug IfcPython on Windows." OFF)
option(ADD_COMMIT_SHA "Add commit sha and branch in version number, requires git" OFF)
option(
VERSION_OVERRIDE
"Override the version defined in buildinfo.cpp with the file VERSION in the repository root"
OFF
)
option(USE_CCACHE "Enable use of ccache if it's available from PATH." ON)
option(VERSION_OVERRIDE "Override the version defined in buildinfo.cpp with the file VERSION in the repository root" OFF)
set(PYTHON_MODULE_INSTALL_DIR
""
CACHE PATH
set(
PYTHON_MODULE_INSTALL_DIR
"" CACHE PATH
"Directory to install IfcPython package to. By default package is installed in found Python's site-packages."
)
if (VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
project(IfcOpenShell VERSION ${RELEASE_VERSION})
# Make sure CMake modules in this project are found first
list(PREPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR})
@@ -133,18 +127,16 @@ if(MINIMAL_BUILD)
set(WITH_CGAL OFF)
set(COLLADA_SUPPORT OFF)
set(GLTF_SUPPORT OFF)
set(HDF5_SUPPORT OFF)
set(IFCXML_SUPPORT OFF)
set(USD_SUPPORT OFF)
endif()
if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND (NOT BUILD_IFCGEOM))
if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND(NOT BUILD_IFCGEOM))
message(STATUS "'IfcGeom' is required with current outputs")
set(BUILD_IFCGEOM ON)
endif()
find_program(CCACHE_FOUND ccache)
if(USE_CCACHE AND CCACHE_FOUND)
if(CCACHE_FOUND)
message(STATUS "`ccache` is found, using it as a compiler launcher.")
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_FOUND}")
if(MSVC)
@@ -153,15 +145,17 @@ if(USE_CCACHE AND CCACHE_FOUND)
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$<CONFIG:Debug,RelWithDebInfo>:Embedded>")
# Not needed for Ninja.
if(CMAKE_GENERATOR MATCHES "Visual Studio")
file(COPY_FILE ${CCACHE_FOUND} ${CMAKE_BINARY_DIR}/cl.exe ONLY_IF_DIFFERENT)
set(CMAKE_VS_GLOBALS "CLToolExe=cl.exe" "CLToolPath=${CMAKE_BINARY_DIR}" "UseMultiToolTask=true")
file(COPY_FILE
${CCACHE_FOUND} ${CMAKE_BINARY_DIR}/cl.exe
ONLY_IF_DIFFERENT)
set(CMAKE_VS_GLOBALS
"CLToolExe=cl.exe"
"CLToolPath=${CMAKE_BINARY_DIR}"
"UseMultiToolTask=true"
)
endif()
endif()
endif()
mark_as_advanced(CCACHE_FOUND)
# Variable to accumulate swig definitions from various submodules.
set(SWIG_DEFINES "")
if(MSVC AND MSVC_PARALLEL_BUILD)
add_definitions("/MP")
@@ -177,28 +171,23 @@ endif()
include(GNUInstallDirs)
set(IFCOPENSHELL_EXPORT_TARGETS "${PROJECT_NAME}Targets")
# On Windows Release and Debug binaries are not compatible.
# So we add a postfix to avoid issues and allow release and debug installations to coexist.
if(WIN32)
set(CMAKE_DEBUG_POSTFIX "_d")
if(NOT INCLUDEDIR)
set(INCLUDEDIR include)
endif()
if(NOT IS_ABSOLUTE ${INCLUDEDIR})
set(INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR})
endif()
message(STATUS "INCLUDEDIR: ${INCLUDEDIR}")
if(BUILD_SHARED_LIBS)
add_definitions(-DIFC_SHARED_BUILD)
if(MSVC)
message(
WARNING
"Building DLLs against the static VC run-time. This is not recommended if the DLLs are to be redistributed."
)
# C4521: 'identifier' : class 'type' needs to have dll-interface to be used by clients of class 'type2'
# There will be couple hundreds of these so suppress them away, https://msdn.microsoft.com/en-us/library/esew7y1w.aspx
add_definitions(-wd4251)
endif()
if(MSVC)
message(WARNING "Building DLLs against the static VC run-time. This is not recommended if the DLLs are to be redistributed.")
# C4521: 'identifier' : class 'type' needs to have dll-interface to be used by clients of class 'type2'
# There will be couple hundreds of these so suppress them away, https://msdn.microsoft.com/en-us/library/esew7y1w.aspx
add_definitions(-wd4251)
endif()
UNIFY_ENVVARS_AND_CACHE(BOOST_ROOT)
UNIFY_ENVVARS_AND_CACHE(BOOST_LIBRARYDIR)
if(NOT MINIMAL_BUILD)
UNIFY_ENVVARS_AND_CACHE(PYTHON_INCLUDE_DIR)
@@ -214,71 +203,75 @@ foreach(option_flag IN LISTS option_flags)
convert_env_var_to_bool("${option_flag}")
endforeach()
if(WITH_CGAL)
find_package(CGAL REQUIRED)
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_CGAL)
list(APPEND GEOMETRY_KERNELS cgal)
endif()
if(BUILD_IFCGEOM AND WITH_OPENCASCADE)
find_package(OpenCASCADE REQUIRED)
add_definitions(-DIFOPSH_WITH_OPENCASCADE)
# Map OpenCASCADE_LIBRARIES variable from OpenCASCADEConfig.cmake to OpenCASCADE_LIBRARIES used by kernel generic cmake file
set(OpenCASCADE_LIBRARIES ${OpenCASCADE_LIBRARIES})
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_OPENCASCADE)
list(APPEND GEOMETRY_KERNELS opencascade)
endif()
set(GLTF_LIBRARIES "")
message(STATUS "BUILD_IFCGEOM WITH_MANIFOLD: ${BUILD_IFCGEOM} ${WITH_MANIFOLD}")
if(BUILD_IFCGEOM AND WITH_MANIFOLD)
find_package(manifold CONFIG REQUIRED)
if(TARGET manifold::manifold)
set(MANIFOLD_LIBRARIES manifold::manifold)
elseif(TARGET manifold)
set(MANIFOLD_LIBRARIES manifold)
else()
message(FATAL_ERROR "Unable to determine manifold target")
endif()
list(APPEND GEOMETRY_KERNELS manifold)
endif()
if(BUILD_IFCGEOM)
list(APPEND GEOMETRY_KERNELS passthrough)
endif()
if(GLTF_SUPPORT)
find_package(nlohmann_json REQUIRED)
set(GLTF_LIBRARIES nlohmann_json::nlohmann_json)
add_definitions(-DWITH_GLTF)
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_GLTF)
endif()
# Add USD support to serializers
set(USD_LIBRARIES "")
if(USD_SUPPORT)
find_package(USD REQUIRED)
set(USD_LIBRARIES pxr::USD)
endif(USD_SUPPORT)
set(ROCKSDB_LIBRARIES "")
if(WITH_ROCKSDB)
if (WITH_ROCKSDB)
# Temporaily mess with CMAKE_FIND_PACKAGE_PREFER_CONFIG to help RocksDB
# find it's zstd dependency on Windows.
# Only do it on Windows, otherwise it might create problems as
# findzstd and zstd-config target names do not match.
# https://github.com/facebook/rocksdb/pull/13975
if(WIN32)
set(TEMP CMAKE_FIND_PACKAGE_PREFER_CONFIG)
set(CMAKE_FIND_PACKAGE_PREFER_CONFIG TRUE)
endif()
find_package(RocksDB CONFIG REQUIRED)
mark_as_advanced(RocksDB_DIR)
if(WIN32)
set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ${TEMP})
endif()
message(STATUS "RocksDB: found at '${RocksDB_DIR}'.")
add_library(IFCOPENSHELL_RocksDB INTERFACE)
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
# So rocksdb supported only as a static library.
# See https://github.com/facebook/rocksdb/issues/981.
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
if(WITH_ZSTD)
if (WITH_ZSTD)
# @todo do we actually need the zstd include dir or rather just pass
# the libzstd.a along with the rocksdb library when needed and feature
# detect based on rocksdb API?
find_package(zstd CONFIG REQUIRED)
mark_as_advanced(zstd_DIR)
message(STATUS "zstd: found at '${zstd_DIR}'.")
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE zstd::libzstd_static)
endif()
install(TARGETS IFCOPENSHELL_RocksDB EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
endif()
# Find Boost: On win32 the (hardcoded) default is to use static libraries and
# runtime, when doing running conda-build we pick what conda prepared for us.
if(WIN32 AND NOT DEFINED ENV{CONDA_BUILD})
if(WIN32 AND("$ENV{CONDA_BUILD}" STREQUAL ""))
set(Boost_USE_STATIC_LIBS ON)
set(Boost_USE_STATIC_RUNTIME OFF)
set(Boost_USE_MULTITHREADED ON)
@@ -309,14 +302,8 @@ if(WASM_BUILD)
else()
# @todo review this, shouldn't this be all possible header-only now?
# ... or rewritten using C++17 features?
set(BOOST_COMPONENTS
system
program_options
regex
thread
date_time
iostreams
)
# set(BOOST_COMPONENTS system program_options regex thread date_time iostreams)
set(BOOST_COMPONENTS program_options regex thread date_time iostreams)
endif()
if(USE_MMAP)
@@ -326,17 +313,6 @@ if(USE_MMAP)
else()
set(BOOST_COMPONENTS ${BOOST_COMPONENTS} iostreams)
endif()
add_definitions(-DUSE_MMAP)
endif()
# Handle CGAL after Boost settings are set, since CGAL will use them too.
# Do `find_package(Boost)` only after this, to make sure `FindBoost` finds correct components.
# Otherwise it will find components needed for CGAL and we might some libraries.
if(WITH_CGAL)
find_package(CGAL REQUIRED)
set(CGAL_LIBRARIES IFCOPENSHELL_CGAL)
list(APPEND GEOMETRY_KERNELS cgal)
endif()
find_package(Boost REQUIRED COMPONENTS ${BOOST_COMPONENTS})
@@ -345,17 +321,8 @@ message(STATUS "Boost libraries found in ${Boost_LIBRARY_DIRS}")
if(COLLADA_SUPPORT)
find_package(OpenCOLLADA REQUIRED)
add_definitions(-DWITH_OPENCOLLADA)
endif()
if(HDF5_SUPPORT)
find_package(HDF5 REQUIRED COMPONENTS C CXX)
set(IFCOPENSHELL_LIBRARIES ${IFCOPENSHELL_LIBRARIES} hdf5::hdf5_cpp)
add_definitions(-DWITH_HDF5)
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_HDF5)
endif(HDF5_SUPPORT)
if(ENABLE_BUILD_OPTIMIZATIONS)
if(MSVC)
# NOTE: RelWithDebInfo and Release use O2 (= /Ox /Gl /Gy/ = Og /Oi /Ot /Oy /Ob2 /Gs /GF /Gy) by default,
@@ -368,20 +335,12 @@ if(ENABLE_BUILD_OPTIMIZATIONS)
# Linker
# /OPT:REF enables also /OPT:ICF and disables INCREMENTAL
set(LINKER_FLAGS_RELEASE "/LTCG /OPT:REF")
# /OPT:NOICF is recommended when /DEBUG is used (http://msdn.microsoft.com/en-us/library/xe4t6fc1.aspx)
set(LINKER_FLAGS_RELWITHDEBINFO "/DEBUG /OPT:NOICF")
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} /LTCG /OPT:REF")
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
set(CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO
"${CMAKE_SHARED_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}"
)
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_EXE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}")
set(CMAKE_MODULE_LINKER_FLAGS_RELEASE "${CMAKE_MODULE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
set(CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO
"${CMAKE_MODULE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}"
)
# /OPT:NOICF is recommended when /DEBUG is used (http://msdn.microsoft.com/en-us/library/xe4t6fc1.aspx)
set(CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} /DEBUG /OPT:NOICF")
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /LTCG /OPT:REF")
set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:NOICF")
else()
# GCC-like: Release should use O3 but RelWithDebInfo 02 so enforce 03. Anything other useful that could be added here?
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3")
@@ -430,7 +389,7 @@ if(MSVC)
endif()
# Enforce standards-conformance on VS > 2015, older Boost versions fail to compile with this
if(MSVC_VERSION GREATER 1900 AND (Boost_MAJOR_VERSION GREATER 1 OR Boost_MINOR_VERSION GREATER 66))
if(MSVC_VERSION GREATER 1900 AND(Boost_MAJOR_VERSION GREATER 1 OR Boost_MINOR_VERSION GREATER 66))
add_definitions(-permissive-)
endif()
@@ -449,11 +408,11 @@ if(MSVC)
# endforeach()
# endif()
add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE)
# See #5158.
if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40)
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
endif()
add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE)
# See #5158.
if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40)
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
endif()
else()
add_definitions(-Wall -Wextra)
@@ -463,10 +422,7 @@ else()
add_definitions(-Wno-maybe-uninitialized)
endif()
if(
CMAKE_CXX_COMPILER_ID MATCHES "GNU"
AND (CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 9.0 OR CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL 9.0)
)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 9.0 OR CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL 9.0))
# OpenCascade spews a lot of deprecated-copy warnings
add_definitions(-Wno-deprecated-copy)
endif()
@@ -477,45 +433,28 @@ else()
endif()
endif(MSVC)
include_directories(${OPENCOLLADA_INCLUDE_DIRS} ${Boost_INCLUDE_DIRS} ${HDF5_INCLUDE_DIR})
include_directories(${INCLUDE_DIRECTORIES}
${Boost_INCLUDE_DIRS}
${CGAL_INCLUDE_DIR} ${GMP_INCLUDE_DIR} ${MPFR_INCLUDE_DIR}
)
if(NOT SCHEMA_VERSIONS)
# `WASM_BUILD` - super arbitrarily try to keep size down at least a little bit
if(BUILD_ONLY_COMMON_SCHEMAS OR WASM_BUILD)
if(WASM_BUILD)
# super arbitrarily try to keep size down at least a little bit
set(SCHEMA_VERSIONS "2x3" "4" "4x3_add2")
else()
set(SCHEMA_VERSIONS
"2x3"
"4"
"4x1"
"4x2"
"4x3"
"4x3_tc1"
"4x3_add1"
"4x3_add2"
)
set(SCHEMA_VERSIONS "2x3" "4" "4x1" "4x2" "4x3" "4x3_tc1" "4x3_add1" "4x3_add2")
endif()
endif()
message(STATUS "IFC SCHEMA_VERSIONS that will be used for the build: ${SCHEMA_VERSIONS}.")
set(SCHEMA_DEFINITIONS "")
foreach(schema ${SCHEMA_VERSIONS})
list(APPEND SCHEMA_DEFINITIONS "-DHAS_SCHEMA_${schema}")
endforeach()
string(REPLACE ";" ")(" schema_version_seq "(${SCHEMA_VERSIONS})")
list(APPEND SCHEMA_DEFINITIONS "-DSCHEMA_SEQ=${schema_version_seq}")
if(COMPILE_SCHEMA)
# @todo, this appears to be untested at the moment
find_package(PythonInterp)
if(NOT PYTHONINTERP_FOUND)
message(
FATAL_ERROR
"A Python interpreter is necessary when COMPILE_SCHEMA is enabled. Disable COMPILE_SCHEMA or fix Python paths to proceed."
)
message(FATAL_ERROR "A Python interpreter is necessary when COMPILE_SCHEMA is enabled. Disable COMPILE_SCHEMA or fix Python paths to proceed.")
endif()
set(IFC_RELEASE_NOT_USED ${SCHEMA_VERSIONS})
@@ -535,10 +474,7 @@ if(COMPILE_SCHEMA)
if("${PYPARSING_FOUND}" STREQUAL "-1")
message(STATUS "Installing pyparsing")
execute_process(
COMMAND ${PYTHON_EXECUTABLE} -m pip "install" --user pyparsing
RESULT_VARIABLE SUCCESS
)
execute_process(COMMAND ${PYTHON_EXECUTABLE} -m pip "install" --user pyparsing RESULT_VARIABLE SUCCESS)
if(NOT "${SUCCESS}" STREQUAL "0")
execute_process(COMMAND pip "install" --user pyparsing RESULT_VARIABLE SUCCESS)
@@ -553,23 +489,19 @@ if(COMPILE_SCHEMA)
# Bootstrap the parser
message(STATUS "Compiling schema, this will take a while...")
execute_process(
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
execute_process(COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
WORKING_DIRECTORY ../src/ifcexpressparser
OUTPUT_FILE express_parser.py
RESULT_VARIABLE SUCCESS
)
RESULT_VARIABLE SUCCESS)
if(NOT "${SUCCESS}" STREQUAL "0")
message(FATAL_ERROR "Failed to bootstrap parser. Make sure pyparsing is installed")
endif()
# Generate code
execute_process(
COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
execute_process(COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
WORKING_DIRECTORY ../src/ifcparse
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME
)
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME)
# Prevent the schema that had just been compiled from being excluded
foreach(schema ${SCHEMA_VERSIONS})
@@ -584,17 +516,40 @@ if(NOT Boost_VERSION LESS 105800)
add_definitions(-DBOOST_OPTIONAL_USE_OLD_DEFINITION_OF_NONE)
endif()
add_subdirectory(../src/plugin plugin)
add_subdirectory(../src/ifcparse ifcparse)
set(IFCOPENSHELL_LIBRARIES IfcParse)
if(BUILD_IFCPARSE_EXPERIMENTAL_WRAPPER)
add_subdirectory(../src/wrappergen wrappergen)
endif()
if(BUILD_IFCGEOM)
# CGAL::CGAL target already has dependencies resolved.
if(WITH_CGAL AND CGAL_DIR)
set(CGAL_LIBRARIES CGAL::CGAL)
message(STATUS "Using found CGAL package at '${CGAL_DIR}'")
elseif(WITH_CGAL AND NOT CGAL_DIR)
find_library(libGMP NAMES gmp mpir PATHS ${GMP_LIBRARY_DIR} NO_DEFAULT_PATH)
find_library(libMPFR NAMES mpfr PATHS ${MPFR_LIBRARY_DIR} NO_DEFAULT_PATH)
if(NOT libGMP)
message(FATAL_ERROR "Unable to find GMP library files, aborting")
endif()
if(NOT libMPFR)
message(FATAL_ERROR "Unable to find MPFR library files, aborting")
endif()
list(APPEND CGAL_LIBRARIES "${libMPFR}")
list(APPEND CGAL_LIBRARIES "${libGMP}")
endif()
add_subdirectory(../src/ifcgeom ifcgeom)
endif(BUILD_IFCGEOM)
if(BUILD_CONVERT OR BUILD_IFCPYTHON)
if(BUILD_CONVERT OR BUILD_IFCPYTHON OR BUILD_BONSAIVIEWER)
add_subdirectory(../src/serializers serializers)
set(IFCOPENSHELL_LIBRARIES ${IFCOPENSHELL_LIBRARIES} ${SERIALIZER_SCHEMA_LIBRARIES})
endif(BUILD_CONVERT OR BUILD_IFCPYTHON)
endif(BUILD_CONVERT OR BUILD_IFCPYTHON OR BUILD_BONSAIVIEWER)
if(BUILD_CONVERT)
add_subdirectory(../src/ifcconvert ifcconvert)
@@ -613,8 +568,8 @@ if(ADD_COMMIT_SHA)
endif()
if(GIT_FOUND)
if(VERSION_OVERRIDE)
set(git_branch ${RELEASE_VERSION})
if (VERSION_OVERRIDE)
set (git_branch ${RELEASE_VERSION})
else()
message("git found: ${GIT_EXECUTABLE} with version ${GIT_VERSION_STRING}")
execute_process(
@@ -626,8 +581,8 @@ if(ADD_COMMIT_SHA)
string(REPLACE "\n" ";" git_branch_list "${git_branches}")
foreach(git_branch_candidate IN ITEMS ${git_branch_list})
string(REPLACE "*" "" git_branch_candidate_temp "${git_branch_candidate}")
string(STRIP "${git_branch_candidate_temp}" git_branch_candidate_2)
string(REPLACE "*" "" git_branch_candidate_temp "${git_branch_candidate}")
string(STRIP "${git_branch_candidate_temp}" git_branch_candidate_2)
if(NOT git_branch_candidate_2 MATCHES "^HEAD$")
string(REPLACE "/" ";" git_branch_candidate_2_list "${git_branch_candidate_2}")
list(GET git_branch_candidate_2_list -1 git_branch)
@@ -645,13 +600,13 @@ if(ADD_COMMIT_SHA)
message(STATUS "IfcOpenShell branch: \"${git_branch}\"")
message(STATUS "IfcOpenShell commit: \"${git_sha}\"")
if("${git_branch}" STREQUAL "" OR "${git_sha}" STREQUAL "")
if ("${git_branch}" STREQUAL "" OR "${git_sha}" STREQUAL "")
message(FATAL_ERROR "Unable to determine commit sha and/or branch")
endif()
target_compile_definitions(
IfcParse
PRIVATE -DIFCOPENSHELL_BRANCH=${git_branch} -DIFCOPENSHELL_COMMIT=${git_sha}
target_compile_definitions(IfcParse PRIVATE
-DIFCOPENSHELL_BRANCH=${git_branch}
-DIFCOPENSHELL_COMMIT=${git_sha}
)
endif()
endif(ADD_COMMIT_SHA)
@@ -669,10 +624,6 @@ if(BUILD_DOCUMENTATION)
add_subdirectory(../docs docs)
endif()
if(BUILD_IFCPYTHON)
add_subdirectory(../src/ifcwrap ifcwrap)
endif()
if(BUILD_EXAMPLES)
add_subdirectory(../src/examples examples)
endif()
@@ -685,32 +636,82 @@ if(BUILD_IFCPYTHON AND WITH_CGAL)
add_subdirectory(../src/svgfill svgfill)
endif()
if(BUILD_IFCPYTHON)
add_subdirectory(../src/ifcwrap ifcwrap)
endif()
if(BUILD_QTVIEWER)
add_subdirectory(../src/qtviewer qtviewer)
endif()
if(BUILD_IFCGEOM)
# install(FILES ${IFCGEOM_H_FILES}
# DESTINATION ${INCLUDEDIR}/ifcgeom
# )
install(FILES ${SCHEMA_AGNOSTIC_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcgeom
)
file(GLOB SERIALIZATION_H_FILES ../src/ifcgeom/serialization/*.h)
install(FILES ${SERIALIZATION_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcgeom/serialization
)
foreach(kernel ${GEOMETRY_KERNELS})
file(GLOB IFCGEOM_H_FILES ../src/ifcgeom/kernels/${kernel}/*.h)
install(FILES ${IFCGEOM_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcgeom/kernels/${kernel}
)
endforeach()
install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom)
endif(BUILD_IFCGEOM)
if(BUILD_BONSAIVIEWER)
if(BUILD_BONSAIVIEWER_TESTS)
# Catch2 v3 — fetched on demand. Test option is OFF by default so the
# default build remains offline-capable.
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.5.4
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(Catch2)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
enable_testing()
endif()
add_subdirectory(../src/ifcviewer ifcviewer)
add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal)
add_subdirectory(../src/bonsaiviewer bonsaiviewer)
endif()
# Cmake uninstall target
if(NOT TARGET uninstall)
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake_uninstall.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake"
IMMEDIATE
@ONLY
)
IMMEDIATE @ONLY)
add_custom_target(uninstall COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
add_custom_target(uninstall
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
endif()
# Packaging
list(APPEND CPACK_SOURCE_IGNORE_FILES "/\\\\.git" "/build/" "/.pytest_cache/" "/__pycache__/")
list(APPEND CPACK_SOURCE_IGNORE_FILES
"/\\\\.git"
"/build/"
"/.pytest_cache/"
"/__pycache__/"
)
set(CPACK_SOURCE_INSTALLED_DIRECTORIES "${CMAKE_SOURCE_DIR}/..;/")
set(CPACK_PACKAGE_NAME
"${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}"
)
set(CPACK_PACKAGE_NAME "${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}")
set(CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}${EXTRA_VERSION}")
set(CPACK_PACKAGE_FILE_NAME
"${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}-${CMAKE_SYSTEM_NAME}"
)
SET(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}-${CMAKE_SYSTEM_NAME}")
set(CPACK_PACKAGE_DIRECTORY "${PROJECT_BINARY_DIR}/assets")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "IfcOpenShell")
set(CPACK_PACKAGE_DESCRIPTION "IfcOpenShell.")
@@ -723,7 +724,6 @@ set(CPACK_PACKAGE_VERSION_PATCH "${PROJECT_VERSION_PATCH}")
set(CPACK_GENERATOR "TGZ;DEB")
set(CPACK_SOURCE_GENERATOR "TGZ")
set(BOOST_DEPS "")
foreach(COMPONENT IN ITEMS ${BOOST_COMPONENTS})
string(REPLACE "_" "-" COMP ${COMPONENT})
set(BOOST_DEPS "${BOOST_DEPS}, libboost-${COMP}-dev")
@@ -731,19 +731,13 @@ endforeach(COMPONENT)
set(CPACK_DEBIAN_PACKAGE_NAME "${PROJECT_NAME}")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "${CPACK_PACKAGE_CONTACT}")
set(CPACK_DEBIAN_PACKAGE_DEPENDS
"python3, libxml2, libocct-foundation-dev, libocct-modeling-algorithms-dev, libocct-modeling-data-dev, libocct-ocaf-dev, libocct-visualization-dev, libocct-data-exchange-dev, libhdf5-serial-dev, libpython3-dev, python3-pytest ${BOOST_DEPS}"
)
set(CPACK_DEBIAN_PACKAGE_DEPENDS "python3, libxml2, libocct-foundation-dev, libocct-modeling-algorithms-dev, libocct-modeling-data-dev, libocct-ocaf-dev, libocct-visualization-dev, libocct-data-exchange-dev, libpython3-dev, python3-pytest ${BOOST_DEPS}")
set(CPACK_DEBIAN_PACKAGE_DESCRIPTION_SUMMARY "${CPACK_PACKAGE_DESCRIPTION_SUMMARY}")
set(CPACK_DEBIAN_PACKAGE_DESCRIPTION "${CPACK_PACKAGE_DESCRIPTION}")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_SECTION "science")
set(CPACK_DEBIAN_PACKAGE_VERSION
"${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPACK_PACKAGE_VERSION_PATCH}${EXTRA_VERSION}"
)
set(CPACK_DEBIAN_PACKAGE_VERSION "${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPACK_PACKAGE_VERSION_PATCH}${EXTRA_VERSION}")
set(CPACK_DEBIAN_ARCHITECTURE "${CMAKE_SYSTEM_PROCESSOR}")
# set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_SOURCE_DIR}/cmake/debian/postinst")
include(CPack)
include(package_export.cmake)
+1 -4
View File
@@ -15,7 +15,6 @@
"BUILD_CONVERT": "ON",
"BUILD_IFCMAX": "OFF",
"IFCXML_SUPPORT": "ON",
"HDF5_SUPPORT": "ON",
"SCHEMA_VERSIONS": "4x3_add2",
"CMAKE_GENERATOR_PLATFORM": "",
"CMAKE_GENERATOR_TOOLSET": ""
@@ -50,8 +49,6 @@
"MPFR_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
"Boost_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
"Boost_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include",
"HDF5_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include",
"HDF5_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
"ZLIB_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include"
}
},
@@ -110,4 +107,4 @@
}
}
]
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
# - `GMP_LIBRARY_DIR`
# - `MPFR_INCLUDE_DIR`
# - `MPFR_LIBRARY_DIR`
# If input variables are not specified, try to find HDF5 config.
# If input variables are not specified, try to find CGAL config.
# Input variables could also be provided as environment variables.
#
# Output targets:
-109
View File
@@ -1,109 +0,0 @@
#
# Input variables:
# - `HDF5_INCLUDE_DIR`
# - `HDF5_LIBRARY_DIR`
# - `HDF5_LIBRARIES`
# If input variables are not specified, try to find HDF5 config.
# Input variables could also be provided as environment variables.
#
# Output variables:
# - `HDF5_INCLUDE_DIR`
# - `HDF5_LIBRARY_DIR`
# - `HDF5_LIBRARIES`
#
UNIFY_ENVVARS_AND_CACHE(HDF5_INCLUDE_DIR)
UNIFY_ENVVARS_AND_CACHE(HDF5_LIBRARY_DIR)
UNIFY_ENVVARS_AND_CACHE(HDF5_LIBRARIES)
# To avoid cyclic calls to this file
list(REMOVE_ITEM CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
if(NOT HDF5_INCLUDE_DIR)
message(STATUS "No HDF5 include directory specified")
else()
set(HDF5_INCLUDE_DIR "${HDF5_INCLUDE_DIR}" CACHE FILEPATH "HDF5 header files")
endif()
if(NOT HDF5_LIBRARY_DIR)
message(STATUS "No HDF5 library directory specified")
else()
set(HDF5_LIBRARY_DIR "${HDF5_LIBRARY_DIR}" CACHE FILEPATH "HDF5 library files")
endif()
if(HDF5_LIBRARY_DIR)
# result of the HDF5 ctest package
# Find zlib using cmake find_library. How should this be implemented?
# FIND_LIBRARY(NAMES z libz libz_debug PATHS ... NO_DEFAULT_PATH)
if(NOT DEFINED ENV{CONDA_BUILD})
# result of the HDF5 ctest package
if(WIN32)
set(zlib_post lib)
set(lib_ext lib)
else()
set(lib_ext a)
endif()
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(debug_postfix "_debug")
endif()
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libz${zlib_post}${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libsz${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libaec${debug_postfix}.${lib_ext}"
)
else()
message(STATUS "Packaging hdf5 and zlib for conda distribution")
if(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
# macOS
set(zlib_post libz)
set(lib_ext dylib)
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5.${lib_ext}"
"${HDF5_LIBRARY_DIR}/${zlib_post}.${lib_ext}"
)
else()
# linux and windows
# Find HDF5 package
find_package(HDF5 REQUIRED COMPONENTS C CXX)
# Find ZLIB package
find_package(ZLIB REQUIRED)
# Include directories
include_directories(${HDF5_INCLUDE_DIRS} ${ZLIB_INCLUDE_DIRS})
# Link libraries
set(HDF5_LIBRARIES ${HDF5_LIBRARIES} ${ZLIB_LIBRARIES})
message(STATUS "HDF5 libraries: ${HDF5_LIBRARIES}")
endif()
endif()
endif()
if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
# First try to find it as a config.
find_package(HDF5 CONFIG)
mark_as_advanced(HDF5_DIR)
if(HDF5_DIR)
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
set(HDF5_LIBRARIES hdf5_cpp-static)
else()
# If it failed, still try to find as a module.
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
# Will automatically fill HDF5_LIBRARIES and HDF5_INCLUDE_DIR.
find_package(HDF5 COMPONENTS CXX)
if(NOT HDF5_INCLUDE_DIR)
message(
FATAL_ERROR
"HDF5_INCLUDE_DIR is not provided (current value: '${HDF5_INCLUDE_DIR}'). "
"HDF5_LIBRARY_DIR is not provided (current value: '${HDF5_LIBRARY_DIR}'). "
"Also could not find HDF5 package (neither module or config)."
)
endif()
endif()
endif()
# Restore module path.
list(PREPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
+4 -3
View File
@@ -139,7 +139,8 @@ if(NOT OpenCOLLADA_DIR)
endif()
endif(NOT OpenCOLLADA_DIR)
if(OPENCOLLADA_FOUND)
add_definitions(-DWITH_OPENCOLLADA)
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_OPENCOLLADA)
if(OPENCOLLADA_FOUND AND NOT TARGET OpenCOLLADA::OpenCOLLADA)
add_library(OpenCOLLADA::OpenCOLLADA INTERFACE IMPORTED)
target_include_directories(OpenCOLLADA::OpenCOLLADA INTERFACE ${OPENCOLLADA_INCLUDE_DIRS})
target_link_libraries(OpenCOLLADA::OpenCOLLADA INTERFACE ${OPENCOLLADA_LIBRARIES})
endif()
+12 -6
View File
@@ -6,7 +6,7 @@
# Input variables could also be provided as environment variables.
#
# Output targets:
# - `PROJ::proj`
# - `proj::proj`
#
# To avoid cyclic calls to this file
@@ -34,10 +34,12 @@ if((NOT PROJ_INCLUDE_DIR AND NOT PROJ_LIBRARIES))
message(FATAL_ERROR "Unable to find PROJ libraries in: ${PROJ_LIBRARY_DIR}")
endif()
add_library(PROJ::proj INTERFACE IMPORTED)
target_include_directories(PROJ::proj INTERFACE "${PROJ_INCLUDE_DIR}")
target_link_libraries(PROJ::proj INTERFACE ${PROJ_LIBRARIES})
target_link_directories(PROJ::proj INTERFACE "${PROJ_LIBRARY}")
if(NOT TARGET proj::proj)
add_library(proj::proj INTERFACE IMPORTED)
target_include_directories(proj::proj INTERFACE "${PROJ_INCLUDE_DIR}")
target_link_libraries(proj::proj INTERFACE ${PROJ_LIBRARIES})
target_link_directories(proj::proj INTERFACE "${PROJ_LIBRARY}")
endif()
endif()
else()
find_library(PROJ_LIBRARY NAMES proj PATHS ${PROJ_LIBRARY_DIR})
@@ -50,7 +52,11 @@ else()
set(PROJ_INCLUDE_DIR ${PROJ_INCLUDE_DIR} CACHE FILEPATH "PROJ header files")
message(STATUS "Looking for PROJ include files in: ${PROJ_INCLUDE_DIR}")
include_directories(${PROJ_INCLUDE_DIR})
if(NOT TARGET proj::proj)
add_library(proj::proj INTERFACE IMPORTED)
target_include_directories(proj::proj INTERFACE "${PROJ_INCLUDE_DIR}")
target_link_libraries(proj::proj INTERFACE ${PROJ_LIBRARIES})
endif()
endif()
list(PREPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
-3
View File
@@ -64,7 +64,6 @@ set(USD_LIBRARIES
find_library(USD_LIBRARY NAMES ${USD_LIBRARIES} PATHS ${USD_LIBRARY_DIR})
if(USD_LIBRARY)
message(STATUS "USD libraries ${USD_LIBRARIES} found in: ${USD_LIBRARY_DIR}")
link_directories(${USD_LIBRARY_DIR})
else()
message(FATAL_ERROR "Unable to find USD libraries in: ${USD_LIBRARY_DIR}")
endif()
@@ -82,5 +81,3 @@ if(MSVC)
endif()
target_compile_definitions(pxr::USD INTERFACE PXR_STATIC WITH_USD)
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_USD)
+102
View File
@@ -41,6 +41,108 @@ macro(SET_INSTALL_RPATHS _target _paths)
set_target_properties(${_target} PROPERTIES INSTALL_RPATH "${${_target}_rpaths}")
endmacro()
function(ifcopenshell_plugin_target TARGET)
set_target_properties(${TARGET} PROPERTIES PREFIX "")
endfunction()
function(ifcopenshell_wasm_plugin_link_options TARGET REGISTRATION_SYMBOL)
ifcopenshell_plugin_target(${TARGET})
if(NOT WASM_BUILD)
return()
endif()
cmake_parse_arguments(PLUGIN "" "OPTIMIZATION" "" ${ARGN})
if(NOT PLUGIN_OPTIMIZATION)
set(PLUGIN_OPTIMIZATION -O1)
endif()
set(plugin_symbols
ifcopenshell_plugin_abi_v1
ifcopenshell_plugin_metadata_v1
${REGISTRATION_SYMBOL}
)
target_link_options(${TARGET} PRIVATE "SHELL:-s SIDE_MODULE=2" ${PLUGIN_OPTIMIZATION})
foreach(symbol IN LISTS plugin_symbols)
target_link_options(${TARGET} PRIVATE "LINKER:--export=${symbol}")
endforeach()
endfunction()
function(ifcopenshell_deploy_qt_runtime TARGET)
if(NOT IFCOPENSHELL_DEPLOY_QT_RUNTIME)
return()
endif()
if(NOT TARGET ${TARGET})
message(FATAL_ERROR "Cannot deploy Qt runtime for unknown target '${TARGET}'.")
endif()
get_target_property(target_type ${TARGET} TYPE)
if(NOT target_type STREQUAL "EXECUTABLE")
message(FATAL_ERROR "Qt runtime deployment target '${TARGET}' is not an executable.")
endif()
if(NOT DEFINED QT_DEFAULT_MAJOR_VERSION)
if(DEFINED QT_VERSION)
set(QT_DEFAULT_MAJOR_VERSION ${QT_VERSION})
else()
set(QT_DEFAULT_MAJOR_VERSION 6)
endif()
endif()
if(NOT TARGET Qt${QT_DEFAULT_MAJOR_VERSION}::Core)
set(qt_find_args Qt${QT_DEFAULT_MAJOR_VERSION} COMPONENTS Core REQUIRED)
if(DEFINED QT_DIR AND NOT QT_DIR STREQUAL "")
list(APPEND qt_find_args PATHS ${QT_DIR})
endif()
find_package(${qt_find_args})
endif()
if(COMMAND _qt_internal_setup_deploy_support)
if(NOT DEFINED QT_CMAKE_EXPORT_NAMESPACE AND TARGET Qt${QT_DEFAULT_MAJOR_VERSION}::Core)
set(QT_CMAKE_EXPORT_NAMESPACE Qt${QT_DEFAULT_MAJOR_VERSION})
endif()
if(QT_DEFAULT_MAJOR_VERSION EQUAL 6 AND TARGET Qt6::Core)
get_target_property(qt_core_type Qt6::Core TYPE)
if(qt_core_type STREQUAL "SHARED_LIBRARY")
set(QT6_IS_SHARED_LIBS_BUILD ON)
else()
set(QT6_IS_SHARED_LIBS_BUILD OFF)
endif()
endif()
_qt_internal_setup_deploy_support()
endif()
set(deploy_args
TARGET ${TARGET}
OUTPUT_SCRIPT deploy_script
NO_UNSUPPORTED_PLATFORM_ERROR
)
if(NOT IFCOPENSHELL_DEPLOY_QT_TRANSLATIONS)
list(APPEND deploy_args NO_TRANSLATIONS)
endif()
list(APPEND deploy_args ${ARGN})
if(COMMAND qt_generate_deploy_app_script)
qt_generate_deploy_app_script(${deploy_args})
elseif(COMMAND qt6_generate_deploy_app_script)
qt6_generate_deploy_app_script(${deploy_args})
else()
message(WARNING
"Qt runtime deployment requested for '${TARGET}', but this Qt version "
"does not provide qt_generate_deploy_app_script()."
)
return()
endif()
install(SCRIPT ${deploy_script})
endfunction()
# Get a list of all OPTION flags from the CMakeLists.txt and store in an output LIST
function(get_all_option_flags output_list)
# Read the contents of the CMakeLists.txt
-4
View File
@@ -22,9 +22,6 @@ cmake -G "Ninja" ^
-D GMP_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
-D MPFR_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
-D COLLADA_SUPPORT=OFF ^
-D HDF5_SUPPORT=ON ^
-D HDF5_INCLUDE_DIR="%LIBRARY_PREFIX%\include" ^
-D HDF5_LIBRARY_DIR="%LIBRARY_PREFIX%\lib" ^
-D JSON_INCLUDE_DIR="%LIBRARY_PREFIX%\include" ^
-D PYTHON_INCLUDE_DIR=%PREFIX%\include ^
-D PYTHON_EXECUTABLE:FILEPATH=%PREFIX%\python.exe ^
@@ -37,7 +34,6 @@ cmake -G "Ninja" ^
-D GLTF_SUPPORT:BOOL=ON ^
-D BUILD_CONVERT:BOOL=ON ^
-D BUILD_IFCMAX:BOOL=OFF ^
-D IFCXML_SUPPORT:BOOL=ON ^
-D Boost_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
-D Boost_INCLUDE_DIR:FILEPATH="%LIBRARY_PREFIX%\include" ^
-D Boost_USE_STATIC_LIBS:BOOL=OFF ^
+1 -5
View File
@@ -24,9 +24,6 @@ cmake ${CMAKE_ARGS} -G Ninja \
-DMPFR_LIBRARY_DIR=$PREFIX/lib \
-DOCC_INCLUDE_DIR=$PREFIX/include/opencascade \
-DOCC_LIBRARY_DIR=$PREFIX/lib \
-DHDF5_SUPPORT:BOOL=ON \
-DHDF5_INCLUDE_DIR=$PREFIX/include \
-DHDF5_LIBRARY_DIR=$PREFIX/lib \
-DJSON_INCLUDE_DIR=$PREFIX/include \
-DCGAL_INCLUDE_DIR=$PREFIX/include \
-DLIBXML2_INCLUDE_DIR=$PREFIX/include/libxml2 \
@@ -34,7 +31,6 @@ cmake ${CMAKE_ARGS} -G Ninja \
-DEIGEN_DIR:FILEPATH=$PREFIX/include/eigen3 \
-DCOLLADA_SUPPORT:BOOL=OFF \
-DBUILD_EXAMPLES:BOOL=OFF \
-DIFCXML_SUPPORT:BOOL=ON \
-DGLTF_SUPPORT:BOOL=ON \
-DBUILD_CONVERT:BOOL=ON \
-DBUILD_IFCPYTHON:BOOL=ON \
@@ -47,4 +43,4 @@ ninja
ninja install -j 1
python "${RECIPE_DIR}/update_version_init.py" "${PKG_VERSION}" "${SP_DIR}/ifcopenshell/__init__.py"
python "${RECIPE_DIR}/update_version_init.py" "${PKG_VERSION}" "${SP_DIR}/ifcopenshell/__init__.py"
-2
View File
@@ -26,8 +26,6 @@ c_stdlib_version:
- 2.17 # [linux]
- 10.13 # [osx and x86_64]
- 11.0 # [osx and arm64]
hdf5:
- 1.14.6
libboost_devel:
- '1.86'
libxml2:
-1
View File
@@ -33,7 +33,6 @@ requirements:
- occt
- libxml2
- cgal-cpp
- hdf5
- eigen
- mpfr
- nlohmann_json
+317
View File
@@ -0,0 +1,317 @@
# Build fix: remove `boost_system` from CMake components
`Boost.System` became header-only in Boost 1.69. Boost 1.90.0 no longer ships a compiled library or CMake config for it, so `find_package(Boost REQUIRED COMPONENTS system ...)` fails.
## Fix
`cmake/CMakeLists.txt`:
```diff
- set(BOOST_COMPONENTS system program_options regex thread date_time iostreams)
+ set(BOOST_COMPONENTS program_options regex thread date_time iostreams)
```
The headers are still available; no linking is needed.
# Build fix: add `template` keyword for dependent template member calls
Calling a template member function through a dependent expression (e.g. `storage->has_attribute_value<T>(...)` where `storage`'s type depends on a template parameter) requires the `template` keyword to disambiguate from a less-than comparison.
## Error
```
src/ifcparse/IfcParse.cpp:1856:67: error: expected primary-expression before '>' token
1856 | if (storage->has_attribute_value<express::Base>(attr_index)) {
| ^
```
Six identical errors at lines 1856, 1865, 1896, 1905, 1934, 1943.
## Fix
`src/ifcparse/IfcParse.cpp`:
```diff
-storage->has_attribute_value<express::Base>(attr_index)
+storage->template has_attribute_value<express::Base>(attr_index)
-storage->has_attribute_value<Blank>(attr_index)
+storage->template has_attribute_value<Blank>(attr_index)
```
Applied at all six call sites in `in_memory_file_storage::read_from_stream`.
# Linker fix: missing explicit template instantiations for `InstanceStreamer`
`InstanceStreamer` is a class template with methods defined in `IfcParse.cpp`, not the header. Without explicit instantiations, the linker can't find the symbols when the SWIG wrapper loads.
## Error
```
ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPS3_PNS_7IfcFileE
(IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, IfcFile*))
```
## Fix
Cannot use `template class InstanceStreamer<...>` because some constructors have `static_assert` guards that reject certain reader types. Instead, instantiate each member function individually per reader type, only including the constructors valid for that type.
`src/ifcparse/IfcParse.cpp` (after the last `InstanceStreamer` method definition):
```cpp
// FullBufferImpl
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(const std::string&, bool, IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(void*, int, IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, IfcParse::IfcFile*);
// ... plus ensure_header, initialize_header, hasSemicolon, semicolonCount,
// pushPage, bypassTypes, readInstance
// PushedSequentialImpl — same pattern, different valid constructors
// MMapFileReader (ifdef USE_MMAP) — same pattern
```
# Linker fix: `FullBufferImpl` missing buffer constructor
SWIG's `stream_from_string` calls `InstanceStreamer<FileReader<FullBufferImpl>>(void*, int, IfcFile*)`, but the `(void*, int)` constructor previously hit a `static_assert` for `FullBufferImpl` — it only allowed `PushedSequentialImpl`.
## Error
```
ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPviPNS_7IfcFileE
(InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(void*, int, IfcFile*))
```
## Fix
Three changes to make `FullBufferImpl` support buffer-based and default construction:
`src/ifcparse/FileReader.h` — add buffer constructor to `FullBufferImpl`:
```diff
class IFC_PARSE_API FullBufferImpl {
public:
explicit FullBufferImpl(const std::string& fn);
+ FullBufferImpl(void* data, size_t length);
```
`src/ifcparse/FileReader.h` — add `FileReader(void*, size_t)` forwarding constructor:
```diff
+ FileReader(void* data, size_t length)
+ : cursor_(0) {
+ if constexpr (std::is_same_v<Impl, FullBufferImpl>) {
+ impl_ = std::make_shared<Impl>(data, length);
+ } else {
+ static_assert(...);
+ }
+ }
```
`src/ifcparse/FileReader.cpp` — implement the constructor:
```cpp
FullBufferImpl::FullBufferImpl(void* data, size_t length)
: buf_(static_cast<char*>(data), static_cast<char*>(data) + length)
, size_(length) {
}
```
`src/ifcparse/IfcParse.cpp` — extend the two `InstanceStreamer` constructors to accept `FullBufferImpl`:
```diff
// InstanceStreamer(IfcFile*):
+ } else if constexpr (std::is_same_v<Reader, FileReader<FullBufferImpl>>) {
+ owned_stream_ = std::make_unique<Reader>(nullptr, (size_t)0);
// InstanceStreamer(void*, int, IfcFile*):
+ } else if constexpr (std::is_same_v<Reader, FileReader<FullBufferImpl>>) {
+ owned_stream_ = std::make_unique<Reader>(data, (size_t)length);
```
# Runtime fix: segfault in `parse_context::push()` due to vector reallocation
`parse_context_pool` stores nodes in a `std::vector<parse_context>`. During parsing, `load()` takes a `parse_context&` parameter and calls `context.push()`, which calls `pool_->make()`. If the pool's vector reallocates (via `emplace_back`), all existing references into the vector — including the `context` reference held by the caller — become dangling. Subsequent access through the dangling reference causes a segfault.
Triggered by larger IFC files (e.g. `ISSUE_159_kleine_Wohnung_R22.ifc`, 9.5 MB) that cause enough pool growth to trigger reallocation.
## Error
```
Thread 1 received signal SIGSEGV, Segmentation fault.
0x... in IfcParse::parse_context::push()
#1 in_memory_file_storage::load(...) // context& is dangling after reallocation
#2 in_memory_file_storage::load(...) // parent call
#3 InstanceStreamer::readInstance()
```
## Fix
`src/ifcparse/storage.h` — change the pool container from `std::vector` to `std::deque`, which does not invalidate references on `push_back`/`emplace_back`:
```diff
+#include <deque>
struct parse_context_pool {
- std::vector<parse_context> nodes_;
+ std::deque<parse_context> nodes_;
```
# Runtime fix: `express::Base` comparison operators throw on null/expired instances
`express::Base::operator<` and `operator==` called `data()`, which throws `std::runtime_error("Trying to access deleted instance reference")` when the internal `weak_ptr` is expired. A default-constructed `express::Base` (the value-type equivalent of a null pointer) always has an expired `weak_ptr`.
## Why this model triggers it
The bug requires two conditions to coincide:
1. A representation is shared by **more than one product** (via `IfcRepresentationMap` / `IfcMappedItem`).
2. At least one of those products has **no material association**, so `get_single_material_association()` returns `express::Base{}` (the null equivalent).
In `advanced_model.ifc`, Body representations like `#449` (Body/Brep) have a single `IfcRepresentationMap` (`#453`) with 13 `IfcMappedItem` usages, meaning 13 products share the geometry. Some of those products (e.g. `IfcFlowTerminal` instances) have no `IfcRelAssociatesMaterial`, so `get_single_material_association` returns `express::Base{}`.
Smaller or simpler models don't hit this because either:
- Every representation maps to only 1 product → `reuse_ok_` short-circuits at `products.size() == 1` before reaching the material check.
- Every product has a material association → no null `express::Base` is ever inserted into the set.
## Exact call sequence
```
Iterator::initialize()
try {
mapping::get_representations(reps, filters_)
addRepresentationsFromDefaultContexts(representations)
→ collects reps from subcontexts in order:
Axis (#115): 143 reps
Body (#117): 7550 reps
FootPrint (#119): 12 reps
for (auto representation : representations):
── Axis reps (indices 0142) ──────────────────────────
products_represented_by(rep, rmap)
→ OfProductRepresentation: 1 product each
filter_products(products, filters) → 1 product
reuse_ok_(ifcproducts)
→ products.size() == 1 → return true ← SHORT-CIRCUIT, no material check
representation_mapped_to(rep) → null (no MappedItem)
→ task created. 143 tasks accumulated.
── First Body rep #449 (Body/Brep) ────────────────────
products_represented_by(#449, rmap)
→ OfProductRepresentation: empty
→ RepresentationMap: 1 map (#453)
→ MapUsage: 13 MappedItems → traces through to 13 IfcProducts
filter_products(products, filters) → 13 products
reuse_ok_(ifcproducts) ← CRASH HERE
→ products.size() == 1? NO (13 products)
→ for each product:
find_openings(product) → OK
get_single_material_association(product)
→ some products have no IfcRelAssociatesMaterial
→ returns express::Base{} (expired weak_ptr)
associated_single_materials.insert(result)
→ std::set::insert calls operator<
→ operator< calls data()
→ data() calls data_.lock() → expired → THROWS
"Trying to access deleted instance reference"
} catch (const std::exception& e) {
Logger::Error(e) ← exception caught here, get_representations aborted
}
→ reps contains only the 143 Axis tasks created before the throw
→ all 143 Axis reps have Curve2D geometry → map(representation) returns null
→ no valid elements produced → initialize() returns false
```
In the old pointer-based code, `reuse_ok_` used `std::set<const IfcUtil::IfcBaseEntity*>` and `get_single_material_association` returned `nullptr`. Inserting `nullptr` into a `std::set<T*>` is a plain pointer comparison — no dereference, no throw. The refactoring to `std::set<express::Base>` changed the comparison from pointer comparison to `express::Base::operator<`, which unconditionally dereferences through `data()`.
## Error
```
[Error] Trying to access deleted instance reference
[Notice] Created 143 tasks for 143 products ← only Axis reps; all Body reps lost
initialize() returned: False
```
## Fix
`src/ifcparse/express.h` — use `weak_ptr::lock().get()` instead of `data()` so that expired pointers compare as `nullptr` (matching old raw-pointer semantics):
```diff
bool operator<(const Base& other) const {
- return data() < other.data();
+ auto a = data_.lock();
+ auto b = other.data_.lock();
+ return a.get() < b.get();
}
bool operator==(const Base& other) const {
- return data() == other.data();
+ auto a = data_.lock();
+ auto b = other.data_.lock();
+ return a.get() == b.get();
}
```
# Runtime fix: `entity_instance` missing `get_inverse` due to SWIG `%rename` collision
Accessing inverse attributes (e.g. `element.IsDecomposedBy`) on any entity raises `AttributeError: entity instance of type 'IFC2X3.IfcProject' has no attribute 'get_inverse'`.
## Why
`entity_instance_mixin.__getattr__` (line 106 of `entity_instance.py`) calls `self.get_inverse(name)` when it detects an inverse attribute. Since the mixin inherits into the SWIG-generated `entity_instance` class (via the `object = custom_base` hack in `IfcParseWrapper.i:936`), `self.get_inverse` must resolve to a method on the SWIG class.
However, `IfcParseWrapper.i:70` has a global rename:
```
%rename("get_inverses_by_declaration") get_inverse;
```
This was intended for `ifcopenshell::file::get_inverse` (which takes an entity + declaration and returns instances by reference), but SWIG `%rename` is global — it also renames the `%extend express::Base` method `get_inverse(const std::string& a)` at line 551. So the Python-side `entity_instance` class exposes the method as `get_inverses_by_declaration`, not `get_inverse`.
The old code (`v0.8.0`) didn't hit this because `__getattr__` called `self.wrapped_data.get_inverse(name)` on an inner `ifcopenshell_wrapper.entity_instance` object — but in that old layout, the inner object was constructed differently and the rename didn't apply the same way (or the method had a different path). In the new mixin approach, `self` **is** the SWIG object, so the rename is directly visible.
## Fix
`src/ifcwrap/IfcParseWrapper.i` — override the global rename specifically for `express::Base::get_inverse`, restoring the original name on entity instances:
```diff
+%rename("get_inverse") express::Base::get_inverse;
%rename("get_inverses_by_declaration") get_inverse;
```
Add this line **before** the global rename (or anywhere before the `%extend express::Base` block). This scoped rename takes precedence for `express::Base`, so:
- `entity_instance.get_inverse(name)` works as the mixin expects
- `file.get_inverses_by_declaration(...)` keeps its intended name
## Python-side workaround
`entity_instance.py:106` — call the method by its SWIG-renamed name:
```diff
- vs = self.get_inverse(name)
+ vs = self.get_inverses_by_declaration(name)
```
# Runtime fix: `entity_instance` class no longer importable from `entity_instance` module
The class rename from `entity_instance` to `entity_instance_mixin` broke external code that does `from ifcopenshell.entity_instance import entity_instance`.
## Error
```
ImportError: cannot import name 'entity_instance' from 'ifcopenshell.entity_instance'
```
Triggered at import time via `ifcopenshell.util.pset` (and likely other modules).
## Fix
`src/ifcopenshell-python/ifcopenshell/entity_instance.py` — add a backwards-compatible alias at the bottom of the module:
```python
entity_instance = entity_instance_mixin
```
+176 -113
View File
@@ -107,7 +107,6 @@ import logging
import multiprocessing
import os
import platform
import re
import shutil
# @todo temporary for expired mpfr.org certificate on 2023-04-08
@@ -142,6 +141,7 @@ logger.addHandler(ch)
PROJECT_NAME = "IfcOpenShell"
USE_CURRENT_PYTHON_VERSION = os.getenv("USE_CURRENT_PYTHON_VERSION")
ADD_COMMIT_SHA = os.getenv("ADD_COMMIT_SHA")
BUILD_BONSAIVIEWER = os.getenv("BUILD_BONSAIVIEWER", "").lower() in {"1", "on", "true", "yes"}
PYTHON_VERSIONS = ["3.10.3", "3.11.8", "3.12.1", "3.13.6", "3.14.0"]
JSON_VERSION = "3.11.3"
@@ -153,7 +153,6 @@ PCRE_VERSION = "8.41"
LIBXML2_VERSION = "2.13.8"
SWIG_VERSION = "4.2.1"
OPENCOLLADA_VERSION = "v1.6.68"
HDF5_VERSION = "1.13.1"
GMP_VERSION = "6.3.0"
MPFR_VERSION = "3.1.6" # latest is 4.1.0
@@ -162,6 +161,9 @@ USD_VERSION = "23.05"
TBB_VERSION = "2021.9.0"
ROCKSDB_VERSION = "9.11.2"
ZSTD_VERSION = "1.5.7"
MANIFOLD_VERSION = "3.2.1"
QT6_VERSION = os.getenv("QT6_VERSION", "6.8.3")
# binaries
cp = "cp"
bash = "bash"
@@ -329,12 +331,13 @@ cecho(""" - IFC Schemas to compile. If not provided, fallback to default provide
""")
dependency_tree: "dict[str, tuple[str, ...]]" = {
"IfcParse": ("boost", "libxml2", "hdf5", "rocksdb"),
"IfcGeom": ("IfcParse", "occ", "json", "cgal", "eigen", "OpenCOLLADA"),
"IfcParse": ("boost", "libxml2", "rocksdb"),
"IfcGeom": ("IfcParse", "occ", "manifold", "json", "cgal", "eigen", "OpenCOLLADA"),
"IfcConvert": ("IfcGeom",),
"OpenCOLLADA": ("libxml2", "pcre"),
"IfcGeomServer": ("IfcGeom",),
"IfcOpenShell-Python": ("python", "swig", "IfcGeom"),
"BonsaiViewer": ("IfcGeom", "qt6"),
"swig": (),
"boost": (),
"libxml2": (),
@@ -342,11 +345,12 @@ dependency_tree: "dict[str, tuple[str, ...]]" = {
"occ": (),
"pcre": (),
"json": (),
"hdf5": (),
"cgal": (),
"eigen": (),
"rocksdb": ("zstd",),
"zstd": (),
"manifold": (),
"qt6": (),
# 'usd': ('boost', 'oneTBB')
}
@@ -400,9 +404,12 @@ else:
targets = set(dependency_tree.keys())
targets = set(t for t in targets if "without-%s" % t.lower() not in flags)
if not explicit_targets and not BUILD_BONSAIVIEWER:
targets.difference_update({"BonsaiViewer", "qt6"})
if BUILD_BONSAIVIEWER:
targets.update(gather_dependencies("BonsaiViewer"))
if WASM:
SKIP_TARGETS_FOR_WASM = {
"hdf5",
"rocksdb",
"opencollada",
"swig",
@@ -410,6 +417,8 @@ if WASM:
"IfcGeom",
"IfcConvert",
"IfcGeomServer",
"BonsaiViewer",
"qt6",
}
SKIP_TARGETS_FOR_WASM = {t.lower() for t in SKIP_TARGETS_FOR_WASM}
skip_targets = {t for t in targets if t.lower() in SKIP_TARGETS_FOR_WASM}
@@ -583,6 +592,11 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
]
)
if not any("BUILD_SHARED_LIBS" in f for f in cmake_args):
cmake_flags.append(
f"-DBUILD_SHARED_LIBS={OFF_ON[not BUILD_STATIC]}",
)
run(
[
*wasm,
@@ -591,7 +605,6 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
*cmake_flags,
*cmake_args,
f"-DCMAKE_BUILD_TYPE={BUILD_CFG}",
f"-DBUILD_SHARED_LIBS={OFF_ON[not BUILD_STATIC]}",
f"-DCMAKE_SHARED_LINKER_FLAGS={os.environ['LDFLAGS']}",
],
cwd=cwd,
@@ -626,7 +639,6 @@ def build_dependency(
mode: Literal[
"cmake",
"autoconf",
"ctest",
"bjam",
],
build_tool_args: "list[str]",
@@ -733,23 +745,7 @@ def build_dependency(
if shell is not None:
sp.run(shell, shell=True, check=True, cwd=extract_dir)
if mode == "ctest":
try:
run(
["ctest", "-S", "HDF5config.cmake,BUILD_GENERATOR=Unix", "-C", BUILD_CFG, "-V", "-O", "hdf5.log"],
cwd=extract_dir,
)
except Exception as e:
print("-" * 70)
print(open(os.path.join(extract_dir, "hdf5.log")))
print("-" * 70)
raise e
run([tar, "-xf", kwargs["ctest_result"] + ".tar.gz"], cwd=os.path.join(extract_dir, "build"))
shutil.copytree(
os.path.join(extract_dir, "build", kwargs["ctest_result"], kwargs["ctest_result_path"]),
os.path.join(DEPS_DIR, "install", name),
)
elif mode != "bjam":
if mode != "bjam":
extract_build_dir = os.path.join(extract_dir, *([cmake_dir] if cmake_dir else []), "build")
if os.path.exists(extract_build_dir):
shutil.rmtree(extract_build_dir)
@@ -788,6 +784,61 @@ def build_dependency(
shutil.rmtree(build_dir, ignore_errors=True)
def get_qt6_aqt_config() -> "tuple[str, str, str]":
if platform.system() != "Linux":
raise ValueError("Automatic Qt6 installation with aqtinstall is only configured for Linux builds.")
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "linux", "linux_gcc_64", "gcc_64"
if machine in {"aarch64", "arm64"}:
return "linux_arm64", "linux_gcc_arm64", "gcc_arm64"
raise ValueError(f"Automatic Qt6 installation is not configured for architecture '{platform.machine()}'.")
def install_qt6() -> str:
host, qt_arch, install_suffix = get_qt6_aqt_config()
qt_install_root = INSTALL_DIR / f"qt6-{QT6_VERSION}-{install_suffix}"
qt_dir = qt_install_root / QT6_VERSION / install_suffix
os.environ["QT_DIR"] = str(qt_dir)
qt_config = qt_dir / "lib" / "cmake" / "Qt6" / "Qt6Config.cmake"
qt_core = qt_dir / "lib" / "libQt6Core.so.6"
qt_svg = qt_dir / "lib" / "cmake" / "Qt6Svg" / "Qt6SvgConfig.cmake"
if qt_config.exists() and qt_core.exists() and qt_svg.exists():
logger.info(f"Found existing Qt6 at {qt_dir}, skipping")
return str(qt_dir)
os.makedirs(qt_install_root, exist_ok=True)
run(
[
sys.executable,
"-m",
"aqt",
"install-qt",
host,
"desktop",
QT6_VERSION,
qt_arch,
"-O",
str(qt_install_root),
# Keep the install lean by filtering archives: qtbase provides
# Core/Gui/Widgets (and the Qt6::CorePrivate target), qtsvg provides
# Qt6::Svg. Both are base-Qt archives, not add-on modules.
"--archives",
"icu",
"qtbase",
"qtsvg",
]
)
if not (qt_config.exists() and qt_core.exists() and qt_svg.exists()):
raise RuntimeError(f"Qt6 installation did not produce a usable Qt at {qt_dir}.")
return str(qt_dir)
cecho("Collecting dependencies:", GREEN)
# Set compiler flags for 32bit builds on 64bit system
@@ -845,37 +896,6 @@ os.environ["LDFLAGS"] = LDFLAGS
# @tfk: this is no longer needed
# build_dependency(name="cmake-%s" % (CMAKE_VERSION,), mode="autoconf", build_tool_args=[], download_url="https://cmake.org/files/v%s" % (CMAKE_VERSION_2,), download_name="cmake-%s.tar.gz" % (CMAKE_VERSION,))
if "hdf5" in targets:
# not supported
orig = [os.environ[f] for f in compiler_flags]
for f in compiler_flags:
os.environ[f] = re.sub(r"-flto(=\w+)?", "", os.environ[f])
HDF5_UNDERSCORE = "_".join(HDF5_VERSION.split("."))
HDF5_MAJOR = ".".join(HDF5_VERSION.split(".")[:-1])
dependency_name = f"hdf5-{HDF5_VERSION}"
build_dependency(
name=dependency_name,
mode="cmake",
build_tool_args=[
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/{dependency_name}",
"-DHDF5_ENABLE_Z_LIB_SUPPORT=OFF",
"-DBUILD_TESTING=OFF",
"-DHDF5_BUILD_TOOLS=OFF",
"-DHDF5_BUILD_EXAMPLES=OFF",
"-DBUILD_SHARED_LIBS=OFF",
"-DHDF5_BUILD_UTILS=OFF",
"-DHDF5_BUILD_CPP_LIB=ON",
*MAC_CROSS_COMPILE_INTEL_ARGS,
],
download_url=f"https://github.com/HDFGroup/hdf5/archive/refs/tags/",
download_name=f"hdf5-{HDF5_UNDERSCORE}.tar.gz",
)
for f, o in zip(compiler_flags, orig):
os.environ[f] = o
if "json" in targets:
dependency_name = f"json-{JSON_VERSION}"
build_dependency(
@@ -994,6 +1014,33 @@ elif "occ" in targets:
download_name=f"OCE-{OCE_VERSION}.tar.gz",
)
if "manifold" in targets:
dependency_name = f"manifold-{MANIFOLD_VERSION}"
patches = []
if WASM:
patches.append("./patches/manifold/install-metadata-for-emscripten.patch")
build_dependency(
name=dependency_name,
mode="cmake",
build_tool_args=[
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/{dependency_name}",
"-DMANIFOLD_PAR=OFF",
"-DMANIFOLD_CROSS_SECTION=OFF",
"-DMANIFOLD_PYBIND=OFF",
"-DMANIFOLD_JSBIND=OFF",
"-DMANIFOLD_CBIND=OFF",
"-DMANIFOLD_TEST=OFF",
"-DMANIFOLD_EXPORT=OFF",
"-DMANIFOLD_DOWNLOADS=OFF",
*MAC_CROSS_COMPILE_INTEL_ARGS,
],
download_url="https://github.com/elalish/manifold.git",
download_name="manifold",
download_tool=download_tool_git,
revision=f"v{MANIFOLD_VERSION}",
patch=patches,
)
if "libxml2" in targets:
OLD_CC = ""
if MAC_CROSS_COMPILE_INTEL:
@@ -1297,6 +1344,9 @@ if "rocksdb" in targets:
revision=f"v{ROCKSDB_VERSION}",
)
if "qt6" in targets:
install_qt6()
cecho("Building IfcOpenShell:", GREEN)
IFCOS_DIR = os.path.join(DEPS_DIR, "build", "ifcopenshell")
@@ -1304,8 +1354,8 @@ if os.environ.get("NO_CLEAN", "").lower() not in {"1", "on", "true"}:
if os.path.exists(IFCOS_DIR):
shutil.rmtree(IFCOS_DIR)
os.makedirs(IFCOS_DIR, exist_ok=True)
executables_dir = os.path.join(IFCOS_DIR, "executables")
os.makedirs(executables_dir, exist_ok=True)
ifcos_build_dir = os.path.join(IFCOS_DIR, "build")
os.makedirs(ifcos_build_dir, exist_ok=True)
cmake_args = [
@@ -1314,6 +1364,7 @@ cmake_args = [
"-DBUILD_SHARED_LIBS=" + OFF_ON[not BUILD_STATIC],
"-DGLTF_SUPPORT=ON",
"-DBoost_NO_BOOST_CMAKE=On",
"-DCREATE_BUNDLE=On",
"-DADD_COMMIT_SHA=" + ("On" if ADD_COMMIT_SHA else "Off"),
"-DVERSION_OVERRIDE=" + ("On" if ADD_COMMIT_SHA else "Off"),
*MAC_CROSS_COMPILE_INTEL_ARGS,
@@ -1363,6 +1414,10 @@ elif "occ" in targets:
occ_library_dir = f"{DEPS_DIR}/install/oce-{OCE_VERSION}/lib"
cmake_args.extend(["-DOCC_INCLUDE_DIR=" + occ_include_dir, "-DOCC_LIBRARY_DIR=" + occ_library_dir])
if "manifold" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/manifold-{MANIFOLD_VERSION}")
cmake_args.append("-DWITH_MANIFOLD=On")
if "OpenCOLLADA" in targets:
# pcre is a dependency of OpenCOLLADA, but since we `find_package`,
# we don't need to add it explicitly here as cmake will find it from the config.
@@ -1377,11 +1432,6 @@ else:
if "libxml2" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/libxml2-{LIBXML2_VERSION}")
if "hdf5" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/hdf5-{HDF5_VERSION}")
else:
cmake_args.append("-DHDF5_SUPPORT=Off")
if "usd" in targets:
cmake_args.append("-DUSD_SUPPORT=ON")
cmake_args_prefix_path.extend(
@@ -1408,40 +1458,44 @@ if "rocksdb" in targets:
if "swig" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/swig-{SWIG_VERSION}")
if not WASM and (not explicit_targets or {"IfcGeom", "IfcConvert", "IfcGeomServer"} & set(explicit_targets)):
if os.environ.get("QT_DIR"):
cmake_args_prefix_path.append(os.environ["QT_DIR"])
cmake_args.append(f"-DQT_DIR={os.environ['QT_DIR']}")
build_bonsaiviewer = BUILD_BONSAIVIEWER or "BonsaiViewer" in targets
ifcos_build_args = [
f"-DBUILD_IFCGEOM={OFF_ON['IfcGeom' in targets]}",
f"-DBUILD_GEOMSERVER={OFF_ON['IfcGeomServer' in targets]}",
f"-DBUILD_CONVERT={OFF_ON['IfcConvert' in targets]}",
f"-DBUILD_BONSAIVIEWER={OFF_ON[build_bonsaiviewer]}",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell",
]
if not WASM and (
build_bonsaiviewer
or not explicit_targets
or {"IfcGeom", "IfcConvert", "IfcGeomServer", "BonsaiViewer"} & set(explicit_targets)
):
logger.info("\rConfiguring executables...")
exec_args = [
f"-DBUILD_IFCGEOM={OFF_ON['IfcGeom' in targets]}",
f"-DBUILD_GEOMSERVER={OFF_ON['IfcGeomServer' in targets]}",
f"-DBUILD_CONVERT={OFF_ON['IfcConvert' in targets]}",
*ifcos_build_args,
f"-DBUILD_IFCPYTHON=OFF",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell",
]
run_cmake("", exec_args + cmake_args + get_cmake_args_prefix_path(), cmake_dir=CMAKE_DIR, cwd=executables_dir)
run_cmake("", exec_args + cmake_args + get_cmake_args_prefix_path(), cmake_dir=CMAKE_DIR, cwd=ifcos_build_dir)
logger.info("\rBuilding executables... ")
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=executables_dir)
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=executables_dir)
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=ifcos_build_dir)
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=ifcos_build_dir)
if "IfcOpenShell-Python" in targets:
# On OSX the actual Python library is not linked against.
ADDITIONAL_ARGS = ""
wrapper_ldflags = ""
if platform.system() == "Darwin":
ADDITIONAL_ARGS = "-Wl,-undefined,dynamic_lookup"
# NOTE: We don't use `CXXFLAGS` for wrappers, so wrapper is compiled with different flags
# (e.g. ` -fdata-sections` is missing, which is set by default for executables)
# So cache doesn't match and running build-all.py builds most of ifcopenshell libraries twice.
os.environ["CPPFLAGS"] = f"{CXXFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
os.environ["CXXFLAGS"] = f"{CXXFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
os.environ["CFLAGS"] = f"{CFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
os.environ["LDFLAGS"] = f"{LDFLAGS} {ADDITIONAL_ARGS}"
python_dir = os.path.join(IFCOS_DIR, "pythonwrapper")
os.makedirs(python_dir, exist_ok=True)
# On OSX the actual Python library is not linked against.
wrapper_ldflags = "-Wl,-undefined,dynamic_lookup"
def compile_python_wrapper(
python_version: str,
@@ -1456,10 +1510,6 @@ if "IfcOpenShell-Python" in targets:
logger.info(f"\rConfiguring python {python_version} wrapper...")
cache_path = os.path.join(python_dir, "CMakeCache.txt")
if os.path.exists(cache_path):
os.remove(cache_path)
if python_path:
# We couldn't just prefix PATH and have to provide all variables explicitly,
# see ifcwrap/cmake for the details.
@@ -1473,27 +1523,38 @@ if "IfcOpenShell-Python" in targets:
)
assert python_include
run_cmake(
"",
cmake_args
+ get_cmake_args_prefix_path()
+ [
*([f"-DPYTHON_EXECUTABLE={python_executable}"] if python_executable else []),
# Needed because pyodide is expecting setup.py to be in the root.
*([f"-DPYTHON_MODULE_INSTALL_DIR={REPO_PATH}"] * WASM),
f"-DPYTHON_INCLUDE_DIR={python_include}",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell/tmp",
"-DUSERSPACE_PYTHON_PREFIX="
+ ["Off", "On"][os.environ.get("PYTHON_USER_SITE", "").lower() in {"1", "on", "true"}],
],
cmake_dir=CMAKE_DIR,
cwd=python_dir,
)
old_ldflags = os.environ["LDFLAGS"]
if wrapper_ldflags:
os.environ["LDFLAGS"] = f"{old_ldflags} {wrapper_ldflags}"
try:
run_cmake(
"",
ifcos_build_args
+ [
"-DBUILD_IFCPYTHON=ON",
]
+ cmake_args
+ get_cmake_args_prefix_path()
+ [
*([f"-DPYTHON_EXECUTABLE={python_executable}"] if python_executable else []),
# Needed because pyodide is expecting setup.py to be in the root.
*([f"-DPYTHON_MODULE_INSTALL_DIR={REPO_PATH}"] * WASM),
f"-DPYTHON_INCLUDE_DIR={python_include}",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell/tmp",
"-DUSERSPACE_PYTHON_PREFIX="
+ ["Off", "On"][os.environ.get("PYTHON_USER_SITE", "").lower() in {"1", "on", "true"}],
],
cmake_dir=CMAKE_DIR,
cwd=ifcos_build_dir,
)
finally:
os.environ["LDFLAGS"] = old_ldflags
logger.info(f"\rBuilding python {python_version} wrapper... ")
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper", "VERBOSE=1"], cwd=python_dir)
run([make, "install/local"], cwd=os.path.join(python_dir, "ifcwrap"))
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper", "VERBOSE=1"], cwd=ifcos_build_dir)
run([make, "install/local"], cwd=os.path.join(ifcos_build_dir, "ifcwrap"))
if python_executable:
run([python_executable, "-m", "ensurepip"])
@@ -1508,12 +1569,14 @@ if "IfcOpenShell-Python" in targets:
if platform.system() != "Darwin":
if BUILD_CFG == "Release":
# TODO: This symbol name depends on the Python version?
so = glob.glob(os.path.join(module_dir, "_ifcopenshell_wrapper*.so"))[0]
if "wasm" in flags:
run(["wasm-strip", so, "-k", "dylink.0"])
else:
run([strip, "-s", "-K", "PyInit__ifcopenshell_wrapper", so], cwd=module_dir)
for so in glob.glob(os.path.join(module_dir, "*.so")):
if "wasm" in flags:
run(["wasm-strip", so, "-k", "dylink.0"])
elif os.path.basename(so).startswith("_ifcopenshell_wrapper"):
# TODO: This symbol name depends on the Python version?
run([strip, "-s", "-K", "PyInit__ifcopenshell_wrapper", so], cwd=module_dir)
else:
run([strip, "--strip-unneeded", so], cwd=module_dir)
return module_dir
@@ -0,0 +1,17 @@
# This file was generated with the assistance of an AI coding tool.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 42e403b..764562f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -249,11 +249,6 @@ set_source_files_properties(
PROPERTIES GENERATED TRUE
)
-# If it's an EMSCRIPTEN build, we're done
-if(EMSCRIPTEN)
- return()
-endif()
-
# CMake exports
configure_file(
cmake/manifoldConfig.cmake.in
-112
View File
@@ -34,7 +34,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
@@ -149,7 +148,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
@@ -243,7 +241,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
@@ -349,7 +346,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
@@ -464,7 +460,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
@@ -558,7 +553,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
@@ -743,7 +737,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
@@ -858,7 +851,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
@@ -952,7 +944,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
@@ -1068,7 +1059,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-he663afc_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-ha7acb78_11.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
@@ -1239,7 +1229,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-64/geos-3.13.1-h502464c_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc8237f9_103.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
@@ -1380,7 +1369,6 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/geos-3.13.1-h9ea8674_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_he30205f_103.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda
@@ -2990,106 +2978,6 @@ packages:
- pkg:pypi/h2?source=compressed-mapping
size: 95967
timestamp: 1756364871835
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
sha256: 93d2bfc672f3ee0988d277ce463330a467f3686d3f7ee37812a3d8ca11776d77
md5: d76fff0092b6389a12134ddebc0929bd
depends:
- __glibc >=2.17,<3.0.a0
- libaec >=1.1.3,<2.0a0
- libcurl >=8.10.1,<9.0a0
- libgcc >=13
- libgfortran
- libgfortran5 >=13.3.0
- libstdcxx >=13
- libzlib >=1.3.1,<2.0a0
- openssl >=3.4.0,<4.0a0
license: BSD-3-Clause
license_family: BSD
size: 3950601
timestamp: 1733003331788
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda
sha256: 4f173af9e2299de7eee1af3d79e851bca28ee71e7426b377e841648b51d48614
md5: c74d83614aec66227ae5199d98852aaf
depends:
- __glibc >=2.17,<3.0.a0
- libaec >=1.1.4,<2.0a0
- libcurl >=8.14.1,<9.0a0
- libgcc >=14
- libgfortran
- libgfortran5 >=14.3.0
- libstdcxx >=14
- libzlib >=1.3.1,<2.0a0
- openssl >=3.5.1,<4.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
size: 3710057
timestamp: 1753357500665
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
sha256: 56500937894b1ca917e1ae1bea64b873a9eec57d581173579189d0b1f590db26
md5: 12ebafc40b10d4bf519e4c2074c52aef
depends:
- __osx >=10.13
- libaec >=1.1.3,<2.0a0
- libcurl >=8.10.1,<9.0a0
- libcxx >=18
- libgfortran 5.*
- libgfortran5 >=13.2.0
- libzlib >=1.3.1,<2.0a0
- openssl >=3.4.0,<4.0a0
license: BSD-3-Clause
license_family: BSD
size: 3732340
timestamp: 1733003702265
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc8237f9_103.conda
sha256: e41d22f672b1fbe713d22cf69630abffaee68bdb38a500a708fc70e6f639357f
md5: 3f1df98f96e0c369d94232712c9b87d0
depends:
- __osx >=10.13
- libaec >=1.1.4,<2.0a0
- libcurl >=8.14.1,<9.0a0
- libcxx >=19
- libgfortran
- libgfortran5 >=14.3.0
- libgfortran5 >=15.1.0
- libzlib >=1.3.1,<2.0a0
- openssl >=3.5.1,<4.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
size: 3522832
timestamp: 1753358062940
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
sha256: e8ced65c604a3b9e4803758a25149d71d8096f186fe876817a0d1d97190550c0
md5: 4381be33460283890c34341ecfa42d97
depends:
- libaec >=1.1.3,<2.0a0
- libcurl >=8.10.1,<9.0a0
- libzlib >=1.3.1,<2.0a0
- openssl >=3.4.0,<4.0a0
- ucrt >=10.0.20348.0
- vc >=14.2,<15
- vc14_runtime >=14.29.30139
license: BSD-3-Clause
license_family: BSD
size: 2048450
timestamp: 1733003052575
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_he30205f_103.conda
sha256: 0a90263b97e9860cec6c2540160ff1a1fff2a609b3d96452f8716ae63489dac5
md5: f1f7aaf642cefd2190582550eaca4658
depends:
- libaec >=1.1.4,<2.0a0
- libcurl >=8.14.1,<9.0a0
- libzlib >=1.3.1,<2.0a0
- openssl >=3.5.1,<4.0a0
- ucrt >=10.0.20348.0
- vc >=14.3,<15
- vc14_runtime >=14.44.35208
license: BSD-3-Clause
license_family: BSD
purls: []
size: 2031491
timestamp: 1753357255237
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba
md5: 0a802cb9888dd14eeefc611f05c40b6e
-1
View File
@@ -31,7 +31,6 @@ occt = { version = "*", build = "*novtk*" }
cgal-cpp = "*"
numpy = "*"
lark = "*"
hdf5 = "*"
eigen = "*"
mpfr = "*"
gmp = "*"
+16 -4
View File
@@ -1,6 +1,11 @@
#!/usr/bin/bash
set -ex
PYODIDE_VERSION=0.29.3
PYODIDE_BUILD_VERSION=0.33.0
PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
# Script is assuming that it will be possible to execute it multiple times
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
@@ -11,14 +16,15 @@ source .venv/bin/activate
# Install pyodide cross build environment.
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
uv pip install pyodide-build
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
# `uv run` is required, so xbuildenv would skip using `pip`.
uv run pyodide xbuildenv install
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
uv run pyodide xbuildenv install-emscripten
EMSDK_ROOT=$(pyodide config get emscripten_dir)
source ${EMSDK_ROOT}/emsdk_env.sh
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
source "${EMSDK_ROOT}/emsdk_env.sh"
which emcc
emcc --version
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
@@ -29,6 +35,12 @@ sed -i s/0.8.0/$VERSION/g packages/ifcopenshell/meta.yaml
# Otherwise pyodide build path typically includes package version, so cached cmake configs might break.
export BUILD_DIR=`readlink -f ifcopenshell_build`
# Sat, 25 Apr 2026 12:11:39 GMT 2026-04-25 12:11:39,173 - DEBUG - running
# command `make -j5 ifcopenshell_wrapper VERBOSE=1` in directory
# '/home/runner/work/IfcOpenShell/IfcOpenShell/ifcopenshell_build/Linux/wasm/build/ifcopenshell/build'
# Sat, 25 Apr 2026 12:18:01 GMT Error: Process completed with exit code 143.
export IFCOS_NUM_BUILD_PROCS=1
# Use build-recipes-no-deps first, so logs would be printed to stdout.
pyodide build-recipes-no-deps ifcopenshell
pyodide build-recipes ifcopenshell --install
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# This file was generated with the assistance of an AI coding tool.
"""Order Pyodide wheel shared objects so wasm side modules load safely."""
from __future__ import annotations
import argparse
import os
import re
import tempfile
import zipfile
from pathlib import Path
SCHEMA_ORDER = {
"ifc2x3": 0,
"ifc4": 1,
"ifc4x1": 2,
"ifc4x2": 3,
"ifc4x3": 4,
"ifc4x3_add1": 5,
"ifc4x3_add2": 6,
}
MAIN_SHARED_OBJECT_RE = re.compile(r"^_ifcopenshell_wrapper(?:\.|$)")
SCHEMA_PLUGIN_RE = re.compile(r"^ifcopenshell\.parse\.schema\.([^.]+)\.so$")
MAPPING_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.mapping\.([^.]+)\.so$")
DOCUMENT_PLUGIN_RE = re.compile(r"^ifcopenshell\.document\.[^.]+\.([^.]+)\.so$")
GEOMETRY_SERIALIZATION_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.serialization\.([^.]+)\.so$")
def schema_key(schema: str) -> tuple[int, str]:
schema = schema.lower()
return SCHEMA_ORDER.get(schema, len(SCHEMA_ORDER)), schema
def shared_object_sort_key(filename: str, index: int) -> tuple[int, tuple[int, str], str, int]:
basename = Path(filename).name
if MAIN_SHARED_OBJECT_RE.match(basename):
return 0, schema_key(""), basename, index
if match := SCHEMA_PLUGIN_RE.match(basename):
return 1, schema_key(match.group(1)), basename, index
if match := MAPPING_PLUGIN_RE.match(basename):
return 2, schema_key(match.group(1)), basename, index
if match := DOCUMENT_PLUGIN_RE.match(basename):
return 3, schema_key(match.group(1)), basename, index
if match := GEOMETRY_SERIALIZATION_PLUGIN_RE.match(basename):
return 4, schema_key(match.group(1)), basename, index
return 5, schema_key(""), basename, index
def ordered_infos(infos: list[zipfile.ZipInfo]) -> list[zipfile.ZipInfo]:
shared_infos = [(index, info) for index, info in enumerate(infos) if info.filename.endswith(".so")]
ordered_shared_infos = [
info for index, info in sorted(shared_infos, key=lambda item: shared_object_sort_key(item[1].filename, item[0]))
]
ordered_shared_iter = iter(ordered_shared_infos)
return [next(ordered_shared_iter) if info.filename.endswith(".so") else info for info in infos]
def zip_info_for_write(source: zipfile.ZipInfo) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(source.filename)
info.date_time = source.date_time
info.compress_type = source.compress_type
info.comment = source.comment
info.create_system = source.create_system
info.external_attr = source.external_attr
info.extra = source.extra
return info
def shared_object_names(infos: list[zipfile.ZipInfo]) -> list[str]:
return [info.filename for info in infos if info.filename.endswith(".so")]
def rewrite_wheel(wheel: Path, ordered: list[zipfile.ZipInfo]) -> None:
fd, temp_name = tempfile.mkstemp(prefix=f".{wheel.name}.", suffix=".tmp", dir=wheel.parent)
os.close(fd)
temp_path = Path(temp_name)
try:
with zipfile.ZipFile(wheel) as zin, zipfile.ZipFile(
temp_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
) as zout:
for info in ordered:
zout.writestr(zip_info_for_write(info), zin.read(info))
os.replace(temp_path, wheel)
finally:
if temp_path.exists():
temp_path.unlink()
def order_wheel(wheel: Path, check: bool) -> bool:
wheel = wheel.resolve()
if wheel.suffix != ".whl":
raise ValueError(f"not a wheel: {wheel}")
with zipfile.ZipFile(wheel) as zf:
infos = zf.infolist()
ordered = ordered_infos(infos)
changed = shared_object_names(infos) != shared_object_names(ordered)
if check:
if changed:
print(f"{wheel}: shared object order needs updating")
return False
print(f"{wheel}: shared object order is already valid")
return True
if changed:
rewrite_wheel(wheel, ordered)
print(f"{wheel}: reordered shared objects")
else:
print(f"{wheel}: shared object order is already valid")
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wheel", type=Path, help="Wheel to rewrite in place")
parser.add_argument("--check", action="store_true", help="Only validate the current shared object order")
args = parser.parse_args()
return 0 if order_wheel(args.wheel, args.check) else 1
if __name__ == "__main__":
raise SystemExit(main())
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Split optional IfcOpenShell Pyodide payloads into separate wheels."""
from __future__ import annotations
import argparse
import base64
import csv
import hashlib
import io
import os
import re
import sys
import time
import zipfile
from email.parser import Parser
from pathlib import Path
MAIN_SHARED_OBJECT_RE = re.compile(r"(^|/)_ifcopenshell_wrapper(?:\.|$)")
PURE_PYTHON_PACKAGE_NAME = "ifcopenshell-pure-python"
PURE_PYTHON_PREFIXES = (
"ifcopenshell/api/",
"ifcopenshell/express/",
"ifcopenshell/mvd/",
"ifcopenshell/simple_spf/",
)
def wheel_parts(path: Path) -> tuple[str, str, str, str, str]:
if path.suffix != ".whl":
raise ValueError(f"not a wheel: {path}")
stem = path.name[:-4]
left, py_tag, abi_tag, platform_tag = stem.rsplit("-", 3)
dist, version = left.rsplit("-", 1)
return dist, version, py_tag, abi_tag, platform_tag
def safe_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower().strip("-")
def wheel_escape(value: str) -> str:
return re.sub(r"[^\w\d.]+", "_", value, flags=re.UNICODE)
def wheel_version_escape(value: str) -> str:
return re.sub(r"[^\w\d.+]+", "_", value, flags=re.UNICODE)
def dist_info_dir(name: str, version: str) -> str:
return f"{wheel_escape(name)}-{wheel_version_escape(version)}.dist-info"
def sha256_record_value(data: bytes) -> str:
digest = hashlib.sha256(data).digest()
return "sha256=" + base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def make_info(name: str, *, source: zipfile.ZipInfo | None = None, mode: int | None = None) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(name)
if source is not None:
info.date_time = source.date_time
info.external_attr = source.external_attr
info.comment = source.comment
info.create_system = source.create_system
else:
info.date_time = time.localtime(time.time())[:6]
info.external_attr = ((mode if mode is not None else 0o644) & 0xFFFF) << 16
info.create_system = 3
info.compress_type = zipfile.ZIP_DEFLATED
return info
def write_record(zf: zipfile.ZipFile, entries: dict[str, bytes | None], record_name: str) -> None:
rows: list[list[str]] = []
for name in sorted(entries):
data = entries[name]
if name == record_name:
rows.append([name, "", ""])
elif data is None:
raise ValueError(f"missing bytes for RECORD entry {name}")
else:
rows.append([name, sha256_record_value(data), str(len(data))])
buf = io.StringIO(newline="")
writer = csv.writer(buf, lineterminator="\n")
writer.writerows(rows)
zf.writestr(make_info(record_name), buf.getvalue().encode("utf-8"))
def read_original_metadata(zf: zipfile.ZipFile) -> tuple[str, str, str]:
metadata_names = [n for n in zf.namelist() if n.endswith(".dist-info/METADATA")]
wheel_names = [n for n in zf.namelist() if n.endswith(".dist-info/WHEEL")]
record_names = [n for n in zf.namelist() if n.endswith(".dist-info/RECORD")]
if len(metadata_names) != 1 or len(wheel_names) != 1 or len(record_names) != 1:
raise ValueError("expected exactly one METADATA, WHEEL, and RECORD in the source wheel")
return metadata_names[0], wheel_names[0], record_names[0]
def shared_package_name(so_path: str) -> str:
stem = Path(so_path).name.removesuffix(".so")
stem = re.sub(r"[^A-Za-z0-9]+", "-", stem).strip("-")
return safe_name(stem)
def is_pure_python_split_path(path: str) -> bool:
return any(path.startswith(prefix) for prefix in PURE_PYTHON_PREFIXES)
def build_wheel(
output_dir: Path,
package_name: str,
version: str,
tag: str,
root_is_purelib: bool,
summary: str,
payloads: list[tuple[zipfile.ZipInfo, bytes]],
license_files: dict[str, bytes],
) -> Path:
di = dist_info_dir(package_name, version)
wheel_name = f"{wheel_escape(package_name)}-{wheel_version_escape(version)}-{tag}.whl"
out = output_dir / wheel_name
record_name = f"{di}/RECORD"
entries: dict[str, bytes | None] = {}
metadata = (
"Metadata-Version: 2.4\n"
f"Name: {package_name}\n"
f"Version: {version}\n"
f"Summary: {summary}\n"
"License-File: COPYING\n"
"License-File: COPYING.LESSER\n"
"\n"
).encode("utf-8")
wheel = (
"Wheel-Version: 1.0\n"
"Generator: split_pyodide_ifcopenshell_wheel.py\n"
f"Root-Is-Purelib: {str(root_is_purelib).lower()}\n"
f"Tag: {tag}\n"
"\n"
).encode("utf-8")
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for info, data in payloads:
zf.writestr(make_info(info.filename, source=info), data)
entries[info.filename] = data
metadata_name = f"{di}/METADATA"
wheel_meta_name = f"{di}/WHEEL"
zf.writestr(make_info(metadata_name), metadata)
zf.writestr(make_info(wheel_meta_name), wheel)
entries[metadata_name] = metadata
entries[wheel_meta_name] = wheel
for basename, data in license_files.items():
name = f"{di}/licenses/{basename}"
zf.writestr(make_info(name), data)
entries[name] = data
entries[record_name] = None
write_record(zf, entries, record_name)
return out
def rewrite_main_wheel(source: Path, target: Path, split_paths: set[str]) -> None:
with zipfile.ZipFile(source) as zin, zipfile.ZipFile(
target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
) as zout:
_, _, record_name = read_original_metadata(zin)
entries: dict[str, bytes | None] = {}
for info in zin.infolist():
if info.filename in split_paths or info.filename == record_name:
continue
data = zin.read(info.filename)
zout.writestr(make_info(info.filename, source=info), data)
entries[info.filename] = data
entries[record_name] = None
write_record(zout, entries, record_name)
def verify_wheel(path: Path) -> None:
with zipfile.ZipFile(path) as zf:
zf.testzip()
metadata_name, wheel_name, record_name = read_original_metadata(zf)
Parser().parsestr(zf.read(metadata_name).decode("utf-8"))
wheel_text = zf.read(wheel_name).decode("utf-8")
if "Wheel-Version:" not in wheel_text or "Tag:" not in wheel_text:
raise ValueError(f"invalid WHEEL metadata in {path}")
record_rows = list(csv.reader(io.StringIO(zf.read(record_name).decode("utf-8"))))
names = {row[0] for row in record_rows}
missing = set(zf.namelist()) - names
if missing:
raise ValueError(f"{path} RECORD is missing entries: {sorted(missing)[:5]}")
for name, digest, size in record_rows:
if name == record_name:
continue
data = zf.read(name)
if digest != sha256_record_value(data) or size != str(len(data)):
raise ValueError(f"{path} RECORD mismatch for {name}")
def split_wheel(wheel_path: Path, output_dir: Path) -> None:
wheel_path = wheel_path.expanduser().resolve()
if not wheel_path.exists():
raise FileNotFoundError(wheel_path)
output_dir = output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
main_wheel_path = output_dir / wheel_path.name
if main_wheel_path.resolve(strict=False) == wheel_path:
raise ValueError("output directory must not point to the input wheel location")
_, version, py_tag, abi_tag, platform_tag = wheel_parts(wheel_path)
binary_tag = f"{py_tag}-{abi_tag}-{platform_tag}"
pure_tag = "py3-none-any"
with zipfile.ZipFile(wheel_path) as zf:
file_infos = [info for info in zf.infolist() if not info.is_dir()]
so_infos = [info for info in file_infos if info.filename.endswith(".so")]
split_so_infos = [info for info in so_infos if not MAIN_SHARED_OBJECT_RE.search(Path(info.filename).name)]
pure_python_infos = [info for info in file_infos if is_pure_python_split_path(info.filename)]
if not split_so_infos and not pure_python_infos:
raise RuntimeError("no secondary .so files or pure Python subpackages found to split")
license_files = {
Path(info.filename).name: zf.read(info.filename)
for info in file_infos
if ".dist-info/licenses/" in info.filename
}
split_so_payloads = [(info, zf.read(info.filename)) for info in split_so_infos]
pure_python_payloads = [(info, zf.read(info.filename)) for info in pure_python_infos]
created_wheels: list[Path] = []
for info, data in split_so_payloads:
package_name = shared_package_name(info.filename)
created_wheels.append(
build_wheel(
output_dir,
package_name,
version,
binary_tag,
False,
f"Pyodide shared library split from IfcOpenShell ({Path(info.filename).name}).",
[(info, data)],
license_files,
)
)
if pure_python_payloads:
created_wheels.append(
build_wheel(
output_dir,
PURE_PYTHON_PACKAGE_NAME,
version,
pure_tag,
True,
"Pure Python subpackages split from IfcOpenShell.",
pure_python_payloads,
license_files,
)
)
temp_main_wheel = output_dir / f".{wheel_path.name}.tmp"
try:
rewrite_main_wheel(
wheel_path,
temp_main_wheel,
{info.filename for info, _ in split_so_payloads + pure_python_payloads},
)
verify_wheel(temp_main_wheel)
for created in created_wheels:
verify_wheel(created)
os.replace(temp_main_wheel, main_wheel_path)
finally:
if temp_main_wheel.exists():
temp_main_wheel.unlink()
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Extract optional IfcOpenShell Pyodide payloads into separate wheel artifacts."
)
parser.add_argument("wheel", help="IfcOpenShell Pyodide wheel to split")
parser.add_argument("output_dir", help="Directory for generated wheels")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
split_wheel(Path(args.wheel), Path(args.output_dir))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -14,6 +14,8 @@ def test_ifcopenshell_import(selenium):
import micropip
await micropip.install(f"./{WHEEL_FILENAME}")
import ifcopenshell
from pathlib import Path
ifcopenshell.set_plugin_search_paths([str(Path(ifcopenshell.__file__).parent)])
ifc_file = ifcopenshell.file()
wall = ifc_file.create_entity("IfcWall")
wall1 = ifc_file.by_type("IfcWall")[0]
+1 -1
View File
@@ -316,7 +316,7 @@ def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo:
Returns:
The BCF viewpoint definition.
"""
ifc_file = element.wrapped_data.file
ifc_file = element.file
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
elem_placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
elem_placement[:3, 3] *= unit_scale
+1 -1
View File
@@ -316,7 +316,7 @@ def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo:
Returns:
The BCF viewpoint definition.
"""
ifc_file = element.wrapped_data.file
ifc_file = element.file
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
elem_placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
elem_placement[:3, 3] *= unit_scale
-1
View File
@@ -1 +0,0 @@
This cache folder contains .h5 files. These files cache IFC geometry for performance only. You may safely clear the contents of this cache folder without losing data.
-1
View File
@@ -43,7 +43,6 @@ class IfcExporter:
def export(self):
self.file = tool.Ifc.get()
self.set_header()
IfcStore.update_cache()
self.sync_all_objects()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip":
+1 -1
View File
@@ -187,7 +187,7 @@ def import_attributes(
info = {a.name(): None for a in attributes}
info["type"] = element
else:
assert (entity := element.wrapped_data.declaration().as_entity())
assert (entity := element.declaration().as_entity())
attributes = entity.all_attributes()
info = element.get_info()
for attribute in attributes:
-75
View File
@@ -18,9 +18,7 @@
from __future__ import annotations
import hashlib
import os
import shutil
import tempfile
import traceback
import uuid
@@ -31,7 +29,6 @@ from typing import Literal, NotRequired, Optional, TypedDict, Union
import bpy
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper
from ifcopenshell.file import UndoSystemError
@@ -72,8 +69,6 @@ class IfcStore:
"""Should be set only using ``tool.Ifc.set``."""
schema: Optional[ifcopenshell.ifcopenshell_wrapper.schema_definition] = None
cache: Optional[ifcopenshell.ifcopenshell_wrapper.HdfSerializer] = None
cache_path: Optional[str] = None
id_map: dict[int, IFC_CONNECTED_TYPE] = {}
guid_map: dict[str, IFC_CONNECTED_TYPE] = {}
edited_objs: set[bpy.types.Object] = set()
@@ -95,8 +90,6 @@ class IfcStore:
IfcStore.path = ""
IfcStore.file = None
IfcStore.schema = None
IfcStore.cache = None
IfcStore.cache_path = None
IfcStore.id_map = {}
IfcStore.guid_map = {}
IfcStore.edited_objs = set()
@@ -130,74 +123,6 @@ class IfcStore:
if IfcStore.path and not os.path.isabs(IfcStore.path):
IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path))
@staticmethod
def generate_cache_path() -> str:
"""Generate cache path based on the active file and it's path."""
assert IfcStore.file
ifc_key = IfcStore.path + IfcStore.file.header.file_name.time_stamp
ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest()
prefs = tool.Blender.get_addon_preferences()
cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
return cache_path
@staticmethod
def get_cache() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Get existing cache for the current file or create a new one.
.h5 cache name reflects IFC filepath and it's current header's timestamp.
"""
if IfcStore.cache is None and IfcStore.path:
cache_path = IfcStore.generate_cache_path()
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
IfcStore.cache_path = cache_path
cache_path = Path(IfcStore.cache_path)
cache_settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
cache_preexists = cache_path.exists()
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
if cache_preexists:
print(f"Successfully loaded existing cache: {cache_path.name}.")
else:
print("New cache was created.")
except Exception as e:
if cache_preexists:
print(f"Failed to create a cache from existing file '{cache_path.name}': {str(e)}.")
else:
print(f"Failed to create a cache: {str(e)}.")
# No point to trying again the same operation.
return
os.remove(IfcStore.cache_path)
try:
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
IfcStore.cache_path, cache_settings, serializer_settings
)
print("New cache was created.")
except Exception as e:
print(f"Failed to create a cache: {str(e)}.")
return
return IfcStore.cache
@staticmethod
def update_cache() -> None:
"""Update cache filename after timestamp was updated."""
if not IfcStore.cache:
return
assert IfcStore.cache_path
new_cache_path = IfcStore.generate_cache_path()
IfcStore.cache = None
try:
shutil.move(IfcStore.cache_path, new_cache_path)
except PermissionError:
try:
shutil.copy2(IfcStore.cache_path, new_cache_path)
except PermissionError:
pass # Well we tried. No cache for you!
IfcStore.get_cache()
@staticmethod
def load_file(path: str) -> None:
if not os.path.isfile(path):
-7
View File
@@ -721,10 +721,6 @@ class IfcImporter:
iterator = ifcopenshell.geom.iterator(
settings, self.file, include=products, geometry_library=self.ifc_import_settings.geometry_library
)
if self.ifc_import_settings.should_cache:
cache = IfcStore.get_cache()
if cache:
iterator.set_cache(cache)
valid_file = iterator.initialize()
if not valid_file:
return results
@@ -1267,7 +1263,6 @@ class IfcImportSettings:
self.should_merge_materials_by_colour = False
self.should_load_geometry = True
self.should_clean_mesh = False
self.should_cache = True
self.deflection_tolerance = 0.05 # Default is 0.001, but I find this to be more practical
self.angular_tolerance = 0.5
self.void_limit = 30
@@ -1295,7 +1290,6 @@ class IfcImportSettings:
context=None, input_file: Optional[str] = None, logger: Optional[logging.Logger] = None
) -> IfcImportSettings:
scene_diff = tool.Blender.get_diff_props()
prefs = tool.Blender.get_addon_preferences()
props = tool.Project.get_project_props()
settings = IfcImportSettings()
settings.input_file = input_file
@@ -1308,7 +1302,6 @@ class IfcImportSettings:
settings.should_merge_materials_by_colour = props.should_merge_materials_by_colour
settings.should_load_geometry = props.should_load_geometry
settings.should_clean_mesh = props.should_clean_mesh
settings.should_cache = prefs.should_always_cache or props.should_cache
settings.deflection_tolerance = props.deflection_tolerance
settings.angular_tolerance = props.angular_tolerance
settings.void_limit = props.void_limit
+1 -1
View File
@@ -206,7 +206,7 @@ class CostSchedulesData:
data["UnitSymbol"] = ifcopenshell.util.unit.get_unit_symbol(unit)
if quantity.is_a("IfcPhysicalSimpleQuantity"):
measure_class = (
quantity.wrapped_data.declaration()
quantity.declaration()
.as_entity()
.attribute_by_index(3)
.type_of_attribute()
@@ -37,7 +37,6 @@ classes = (
operator.PrintObjectPlacement,
operator.PrintUnusedElementStats,
operator.ProfileImportIFC,
operator.PurgeHdf5Cache,
operator.PurgeUnusedElementsByClass,
operator.PurgeUnusedObjects,
operator.RestartBlender,
+1 -12
View File
@@ -90,7 +90,7 @@ class PrintIfcFile(bpy.types.Operator):
return tool.Ifc.get()
def execute(self, context):
print(tool.Ifc.get().wrapped_data.to_string())
print(tool.Ifc.get().to_string())
return {"FINISHED"}
@@ -570,17 +570,6 @@ class SelectExpressFile(bpy.types.Operator, ImportHelper):
return {"FINISHED"}
class PurgeHdf5Cache(bpy.types.Operator):
bl_idname = "bim.purge_hdf5_cache"
bl_label = "Purge HDF5 Cache"
bl_description = "Clean up HDF5 cache files except the ones that currently loaded"
def execute(self, context):
core.purge_hdf5_cache(tool.Debug)
self.report({"INFO"}, "HDF5 cache purged.")
return {"FINISHED"}
class OverrideDisplayType(bpy.types.Operator):
bl_idname = "bim.override_display_type"
bl_label = "Override Display Type"
-3
View File
@@ -64,9 +64,6 @@ class BIM_PT_debug(Panel):
row = layout.row()
row.operator("bim.copy_debug_information")
row = layout.row()
row.operator("bim.purge_hdf5_cache")
row = layout.row()
row.operator("bim.update_representation", text="Manually Save Representation")
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import json
import logging
import multiprocessing
@@ -901,10 +900,7 @@ class CreateDrawing(bpy.types.Operator):
# All very hackish whilst prototyping
exporter = bonsai.bim.export_ifc.IfcExporter(None)
exporter.file = tool.Ifc.get()
invalidated_elements = exporter.sync_all_objects()
invalidated_guids = [e.GlobalId for e in invalidated_elements if hasattr(e, "GlobalId")]
if cache := IfcStore.get_cache():
[cache.remove(guid) for guid in invalidated_guids]
exporter.sync_all_objects()
# If we have already calculated it in the SVG in the past, don't recalculate
edited_guids = set()
@@ -922,7 +918,6 @@ class CreateDrawing(bpy.types.Operator):
cached_linework -= edited_guids
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
files = {bim_props.ifc_file: tool.Ifc.get()}
props = tool.Project.get_project_props()
@@ -935,12 +930,8 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
for ifc_path, ifc in files.items():
for ifc in files.values():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
ifc_cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
@@ -576,9 +576,6 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
return
if not product.is_a("IfcGridAxis"):
tool.Geometry.clear_cache(product)
if product.is_a("IfcGridAxis"):
# Grid geometry does not follow the "representation" paradigm and needs to be treated specially
tool.Model.create_axis_curve(obj, product)
@@ -790,7 +787,7 @@ def lock_error_message(name: str) -> str:
def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context) -> bool:
total_elements = len(tool.Ifc.get().wrapped_data.entity_names())
total_elements = len(tool.Ifc.get().entity_names())
total_polygons = sum([len(o.data.polygons) for o in context.selected_objects if o.type == "MESH"])
# These numbers are a bit arbitrary, but basically batching is only
# really necessary on large models and large geometry removals.
@@ -1488,7 +1488,6 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
@@ -345,14 +345,6 @@ class BIMProjectProperties(PropertyGroup):
),
default=False,
)
should_cache: BoolProperty(
name="Cache",
description=(
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
"for faster imports and geometry reloads."
),
default=False,
)
deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.05)
angular_tolerance: FloatProperty(name="Angular Tolerance", default=0.5)
void_limit: IntProperty(
@@ -521,7 +513,6 @@ class BIMProjectProperties(PropertyGroup):
should_merge_materials_by_colour: bool
should_load_geometry: bool
should_clean_mesh: bool
should_cache: bool
deflection_tolerance: float
angular_tolerance: float
void_limit: int
@@ -207,8 +207,6 @@ class BIM_PT_project(Panel):
row = self.layout.row()
row.prop(pprops, "should_clean_mesh")
row = self.layout.row()
row.prop(pprops, "should_cache")
row = self.layout.row()
row.prop(pprops, "should_load_geometry")
row = self.layout.row()
row.prop(pprops, "should_merge_materials_by_colour")
+2 -2
View File
@@ -95,7 +95,7 @@ class ColourByPropertyData:
element = tool.Ifc.get_entity(obj)
if not element:
return default
keys = [a.name() for a in element.wrapped_data.declaration().as_entity().all_attributes()]
keys = [a.name() for a in element.declaration().as_entity().all_attributes()]
psets = ifcopenshell.util.element.get_psets(element)
for pset, properties in psets.items():
if pset.endswith("Common"):
@@ -126,7 +126,7 @@ class SelectSimilarData:
element = tool.Ifc.get_entity(obj)
if not element:
return []
keys = [a.name() for a in element.wrapped_data.declaration().as_entity().all_attributes()]
keys = [a.name() for a in element.declaration().as_entity().all_attributes()]
psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
for pset, properties in psets.items():
if pset.endswith("Common"):
@@ -207,7 +207,6 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator):
representation=representation,
)
tool.Geometry.unlock_scale_object_with_openings(obj)
tool.Geometry.clear_cache(element)
return {"FINISHED"}
-6
View File
@@ -665,10 +665,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty(
name="Always Cache Geometry",
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
)
occurrence_name_style: bpy.props.EnumProperty(
items=[("CLASS", "By Class", ""), ("TYPE", "By Type", ""), ("CUSTOM", "Custom", "")],
name="Occurrence Name Style",
@@ -777,7 +773,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_baseurl: str
should_disable_undo_on_save: bool
should_stream: bool
should_always_cache: bool
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
occurrence_name_function: str
gizmos: GizmoPreferences
@@ -976,7 +971,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.prop(self, "opening_focus_opacity")
layout.prop(self, "should_disable_undo_on_save")
layout.prop(self, "should_stream")
layout.prop(self, "should_always_cache")
layout.label(text="bSDD:")
layout.prop(self, "bsdd_load_preview_dictionaries")
layout.prop(self, "bsdd_load_inactive_dictionaries")
-4
View File
@@ -30,10 +30,6 @@ def parse_express(debug: type[tool.Debug], filename: str) -> None:
debug.add_schema_identifier(debug.load_express(filename))
def purge_hdf5_cache(debug: type[tool.Debug]) -> None:
debug.purge_hdf5_cache()
def purge_unused_elements(ifc: type[tool.Ifc], debug: type[tool.Debug], ifc_class: str) -> int:
ifc_file = ifc.get()
unused_elements = [i for i in ifc_file.by_type(ifc_class) if ifc_file.get_total_inverses(i) == 0]
-2
View File
@@ -44,7 +44,6 @@ def edit_object_placement(
element = ifc.get_entity(obj)
if not element:
return
geometry.clear_cache(element)
if apply_scale:
geometry.clear_scale(obj)
geometry.get_blender_offset_type(obj)
@@ -125,7 +124,6 @@ def switch_representation(
element = ifc.get_entity(obj)
assert element
geometry.clear_cache(element)
geometry.reimport_element_representations(obj, representation, apply_openings=apply_openings)
-2
View File
@@ -283,7 +283,6 @@ class Cost:
class Debug:
def add_schema_identifier(cls, schema): pass
def load_express(cls, filename): pass
def purge_hdf5_cache(cls): pass
@interface
@@ -422,7 +421,6 @@ class Feature:
@interface
class Geometry:
def change_object_data(cls, obj, data, is_global=False): pass
def clear_cache(cls, element): pass
def clear_modifiers(cls, obj): pass
def clear_scale(cls, obj): pass
def copy_data_links(cls, data, copied_entities) -> None: pass
-11
View File
@@ -59,17 +59,6 @@ class Debug(bonsai.core.tool.Debug):
ifcopenshell.register_schema(schema)
return schema.schema
@classmethod
def purge_hdf5_cache(cls) -> None:
prefs = tool.Blender.get_addon_preferences()
cache_dir = prefs.cache_dir
filelist = [f for f in os.listdir(cache_dir) if f.endswith(".h5")]
for f in filelist:
try:
os.remove(os.path.join(cache_dir, f))
except PermissionError:
pass
@classmethod
def debug_bmesh(cls, bm: bmesh.types.BMesh, name: str = "Debug") -> bpy.types.Object:
mesh = bpy.data.meshes.new("Debug")
+1 -13
View File
@@ -73,7 +73,6 @@ import bonsai.core.style
import bonsai.core.system
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
if TYPE_CHECKING:
from bonsai.bim.module.geometry.prop import (
@@ -109,16 +108,6 @@ class Geometry(bonsai.core.tool.Geometry):
raise Exception("user_remap is not supported for meshes in EDIT mode")
old_data.user_remap(new_data)
@classmethod
def get_cache(cls) -> Union[ifcopenshell.geom.serializers.hdf5, None]:
return IfcStore.get_cache()
@classmethod
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
cache = IfcStore.get_cache()
if cache and hasattr(element, "GlobalId"):
cache.remove(element.GlobalId)
@classmethod
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
for modifier in obj.modifiers:
@@ -818,7 +807,6 @@ class Geometry(bonsai.core.tool.Geometry):
if not cls.has_data_users(old_data):
cls.delete_data(old_data)
cls.clear_modifiers(obj)
cls.clear_cache(element)
# Import swept disk solids as Blender curves if possible.
elements_without_openings = {e for e in elements if not getattr(e, "HasOpenings", False)}
@@ -1809,7 +1797,7 @@ class Geometry(bonsai.core.tool.Geometry):
item = tool.Ifc.get().by_id(props.ifc_definition_id)
allowed_attributes = [
a.name()
for a in item.wrapped_data.declaration().as_entity().all_attributes()
for a in item.declaration().as_entity().all_attributes()
if a.type_of_attribute()._is("IfcLengthMeasure")
]
-3
View File
@@ -22,9 +22,6 @@ props = tool.Georeference.get_georeference_props()
props = tool.Project.get_project_props()
# Generally recommended to disable caching for stability right now
props.should_cache = False
# If you are not authoring, it is recommended to enable this.
# When enabled, types, openings, and non geometric elements are not loaded.
props.is_coordinating = True
@@ -9,7 +9,6 @@ class BlenderImporter:
def __init__(self):
self.file = ifcopenshell.open("/home/dion/untitled.ifc")
self.cache_path = "cache.h5"
self.should_use_cpu_multiprocessing = True
self.deflection_tolerance = 0.001
self.angular_tolerance = 0.5
@@ -82,9 +81,6 @@ class BlenderImporter:
iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count(), include=products)
else:
iterator = ifcopenshell.geom.iterator(settings, self.file, include=products)
cache = self.get_cache()
if cache:
iterator.set_cache(cache)
valid_file = iterator.initialize()
if not valid_file:
return results
@@ -113,13 +109,4 @@ class BlenderImporter:
print("Done creating geometry")
return results
def get_cache(self):
cache_settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
try:
return ifcopenshell.geom.serializers.hdf5(self.cache_path, cache_settings, serializer_settings)
except:
return
BlenderImporter().execute()
-6
View File
@@ -25,9 +25,3 @@ class TestParseExpress:
debug.load_express("filename").should_be_called().will_return("schema")
debug.add_schema_identifier("schema").should_be_called()
subject.parse_express(debug, "filename")
class TestPurgeHdf5Cache:
def test_run(self, debug):
debug.purge_hdf5_cache().should_be_called()
subject.purge_hdf5_cache(debug)
-2
View File
@@ -24,7 +24,6 @@ from test.core.bootstrap import geometry, ifc, style, surveyor
class TestEditObjectPlacement:
def predict(self, ifc, geometry, surveyor):
ifc.get_entity("obj").should_be_called().will_return("element")
geometry.clear_cache("element").should_be_called()
geometry.clear_scale("obj").should_be_called()
geometry.get_blender_offset_type("obj").should_be_called()
surveyor.get_absolute_matrix("obj").should_be_called().will_return("matrix")
@@ -197,7 +196,6 @@ class TestSwitchRepresentation:
def test_switching_to_a_representation(self, ifc, geometry):
geometry.get_object_data("obj").should_be_called().will_return("current_obj_data")
ifc.get_entity("obj").should_be_called().will_return("element")
geometry.clear_cache("element").should_be_called()
geometry.reimport_element_representations("obj", "mapped_rep", apply_openings=True).should_be_called()
subject.switch_representation(
ifc,
-20
View File
@@ -17,8 +17,6 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import os
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.api.style
@@ -55,24 +53,6 @@ class TestLoadExpress(NewFile):
os.remove(schema_path + ".cache.dat")
class TestPurgeHdf5Cache(NewFile):
def test_run(self):
prefs = tool.Blender.get_addon_preferences()
cache_dir = Path(prefs.cache_dir)
test_file = cache_dir / "test.h5"
test_file.parent.mkdir(parents=True, exist_ok=True)
test_file.touch()
# Ensure it can skip currently loaded cache.
loaded_file_path = test_file.with_stem("test_loaded")
loaded_file = open(loaded_file_path, "w")
subject.purge_hdf5_cache()
# On Unix loaded files are not locked.
paths = [loaded_file_path] if os.name == "nt" else []
assert [f for f in cache_dir.iterdir() if f.suffix == ".h5"] == paths
class TestMergeIdenticalObject(NewFile):
def test_merge_identical_styles(self):
tool.Ifc.set(ifc := ifcopenshell.file())
@@ -0,0 +1,18 @@
from __future__ import annotations
import sys
from bonsaiviewer_autodesk.connector import AutodeskConnector
from bonsaiviewer_autodesk.rpc import JsonRpcHost
from bonsaiviewer_autodesk.ui import ensure_tk_app
def main() -> int:
ensure_tk_app()
connector = AutodeskConnector()
host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
return host.run()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,739 @@
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import json
import math
import secrets
import urllib.parse
import webbrowser
from dataclasses import asdict, dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable
import httpx
import keyring
import keyring.errors
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
Progress = Callable[[str, str, "int | None"], None]
# (host, port, path, expected_state) -> authorization code
CallbackWaiter = Callable[[str, int, str, str], str]
def _utcnow() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def _no_keyring_error() -> RpcError:
return RpcError(
JSONRPC_INTERNAL_ERROR,
"No secure keyring backend is available. On macOS, use Keychain; "
"on Windows, use Credential Manager; on Linux, install a Secret Service "
"backend such as gnome-keyring or KWallet.",
)
class KeyringTokenStore:
def __init__(self, *, service_name: str, username: str) -> None:
self.service_name = service_name
self.username = username
def load(self) -> dict[str, Any] | None:
try:
raw = keyring.get_password(self.service_name, self.username)
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
return json.loads(raw) if raw else None
def save(self, value: dict[str, Any]) -> None:
try:
keyring.set_password(self.service_name, self.username, json.dumps(value))
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
def delete(self) -> None:
try:
keyring.delete_password(self.service_name, self.username)
except keyring.errors.PasswordDeleteError:
pass
except keyring.errors.NoKeyringError as exc:
raise _no_keyring_error() from exc
def _base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def generate_code_verifier() -> str:
return _base64url(secrets.token_bytes(48))
def generate_code_challenge(verifier: str) -> str:
return _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
@dataclass
class StoredToken:
client_id: str
access_token: str
refresh_token: str
access_token_expires_at_utc: str
refresh_token_expires_at_utc: str
scope: str
@property
def access_token_expires_at(self) -> dt.datetime:
return dt.datetime.fromisoformat(self.access_token_expires_at_utc)
@property
def refresh_token_expires_at(self) -> dt.datetime:
return dt.datetime.fromisoformat(self.refresh_token_expires_at_utc)
def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> None:
return
def wait_for_oauth_callback(host: str, port: int, path: str, expected_state: str) -> str:
"""Block on a single OAuth redirect to ``http://host:port/path`` and return
the authorization code. Raises ``RpcError`` on an OAuth error, a ``state``
mismatch, or a missing code. This is the default ``callback_waiter`` for
:class:`AuthSessionService`; tests inject a stub instead."""
result: dict[str, str] = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != path:
self.send_response(404)
self.end_headers()
return
query = urllib.parse.parse_qs(parsed.query)
result["state"] = query.get("state", [""])[0]
result["code"] = query.get("code", [""])[0]
result["error"] = query.get("error", [""])[0]
body = b"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
server = HTTPServer((host, port), Handler)
server.handle_request()
server.server_close()
if result.get("error"):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
if result.get("state") != expected_state:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
code = result.get("code", "")
if not code:
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
return code
class AuthSessionService:
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
def __init__(
self,
*,
client_id: str,
callback_url: str,
scope: str,
token_store: KeyringTokenStore,
transport: httpx.BaseTransport | None = None,
now: Callable[[], dt.datetime] | None = None,
callback_waiter: CallbackWaiter | None = None,
) -> None:
self.client_id = client_id
self.callback_url = callback_url
self.scope = scope
self.token_store = token_store
self.http = httpx.Client(timeout=60, transport=transport)
self._now = now or _utcnow
self._callback_waiter = callback_waiter or wait_for_oauth_callback
def get_token(self) -> StoredToken | None:
raw = self.token_store.load()
return StoredToken(**raw) if raw else None
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
token = self.get_token()
now = self._now()
if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
return token.access_token
if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
return self._refresh(token, progress).access_token
return self.login_interactive(progress).access_token
def login_interactive(self, progress: Progress = _noop_progress) -> StoredToken:
progress("auth", "Preparing Autodesk sign-in", None)
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)
state = secrets.token_hex(16)
callback = urllib.parse.urlparse(self.callback_url)
if callback.scheme != "http" or callback.hostname not in {"127.0.0.1", "localhost"}:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Callback URL must be http://localhost or http://127.0.0.1.")
query = urllib.parse.urlencode(
{
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.callback_url,
"scope": self.scope,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
)
authorize_url = f"{self.authorize_endpoint}?{query}"
progress("auth", "Opening browser for Autodesk sign-in", None)
webbrowser.open(authorize_url)
code = self._callback_waiter(
callback.hostname or "127.0.0.1",
callback.port or 80,
callback.path or "/",
state,
)
progress("auth", "Exchanging authorization code for token", None)
response = self.http.post(
self.token_endpoint,
data={
"client_id": self.client_id,
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
"redirect_uri": self.callback_url,
},
)
if response.is_error:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token exchange failed: {response.text}")
token = self._token_from_payload(response.json())
self.token_store.save(asdict(token))
progress("auth", "Signed in to Autodesk", 100)
return token
def _refresh(self, token: StoredToken, progress: Progress) -> StoredToken:
progress("auth", "Refreshing Autodesk session", None)
response = self.http.post(
self.token_endpoint,
data={
"client_id": self.client_id,
"grant_type": "refresh_token",
"refresh_token": token.refresh_token,
"scope": self.scope,
},
)
if response.is_error:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token refresh failed: {response.text}")
refreshed = self._token_from_payload(response.json())
self.token_store.save(asdict(refreshed))
progress("auth", "Session refreshed", 100)
return refreshed
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
now = self._now()
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
return StoredToken(
client_id=self.client_id,
access_token=payload["access_token"],
refresh_token=payload["refresh_token"],
access_token_expires_at_utc=(now + dt.timedelta(seconds=int(payload["expires_in"]) - 30)).isoformat(),
refresh_token_expires_at_utc=(now + dt.timedelta(seconds=refresh_ttl - 30)).isoformat(),
scope=self.scope,
)
class ApsClient:
def __init__(self, auth: AuthSessionService, *, transport: httpx.BaseTransport | None = None) -> None:
self.auth = auth
self.http = httpx.Client(timeout=120, transport=transport)
# Browsing -----------------------------------------------------------------
def list_hubs(self) -> list[dict[str, Any]]:
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
hubs = [
{
"id": item["id"],
"name": item["attributes"]["name"],
"extension_type": item["attributes"]["extension"]["type"],
}
for item in payload.get("data", [])
]
hubs.sort(key=lambda h: (h["name"] or "").casefold())
return hubs
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
projects: list[dict[str, Any]] = []
while url:
payload = self._get_json(url)
for item in payload.get("data", []):
projects.append(
{
"id": item["id"],
"name": item["attributes"]["name"],
"extension_type": item["attributes"]["extension"]["type"],
"root_folder_id": item["relationships"]["rootFolder"]["data"]["id"],
}
)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
projects.sort(key=lambda p: (p["name"] or "").casefold())
return projects
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
payload = self._get_json(
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
)
folders = [self._entry(item) for item in payload.get("data", [])]
folders.sort(key=lambda e: (e.get("display_name") or "").casefold())
return folders
def list_folder_contents(
self,
project_id: str,
folder_id: str,
*,
object_types: list[str] | None = None,
extension_filter: Callable[[dict[str, Any]], bool] | None = None,
) -> list[dict[str, Any]]:
url = f"https://developer.api.autodesk.com/data/v1/projects/{project_id}/folders/{folder_id}/contents"
if object_types:
query = [("filter[type]", value) for value in object_types]
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
entries: list[dict[str, Any]] = []
while url:
payload = self._get_json(url)
for item in payload.get("data", []):
entry = self._entry(item)
if extension_filter and entry["type"] == "items" and not extension_filter(entry):
continue
entries.append(entry)
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
return entries
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
"""Return the item plus its current tip in a single request.
``hidden`` reflects the item's soft-delete state (BIM 360 / ACC mark
deleted items as ``hidden: true``; the storage URL may still resolve
to a stale copy, so callers must check this before downloading).
"""
payload = self._get_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}"
f"/items/{urllib.parse.quote(item_id, safe='')}?include=tip"
)
item = payload["data"]
item_attributes = item.get("attributes", {})
parent_folder_id = self._relationship_id(item, "parent")
tip_id = self._relationship_id(item, "tip")
tip: dict[str, Any] | None = None
for included in payload.get("included", []):
if included.get("type") == "versions" and included.get("id") == tip_id:
tip = included
break
if tip is None:
return {
"id": item["id"],
"display_name": item_attributes.get("displayName")
or item_attributes.get("name")
or item["id"],
"hidden": True,
"version_id": None,
"storage_id": None,
"version_number": None,
"last_modified_time_utc": None,
"last_modified_user_name": None,
"parent_folder_id": parent_folder_id,
}
tip_attributes = tip.get("attributes", {})
return {
"id": item["id"],
"display_name": tip_attributes.get("displayName")
or tip_attributes.get("name")
or item_attributes.get("displayName")
or item["id"],
"hidden": bool(item_attributes.get("hidden", False)),
"version_id": tip["id"],
"storage_id": self._relationship_id(tip, "storage"),
"version_number": tip_attributes.get("versionNumber"),
"last_modified_time_utc": tip_attributes.get("lastModifiedTime"),
"last_modified_user_name": tip_attributes.get("lastModifiedUserName"),
"parent_folder_id": parent_folder_id,
}
# Download / upload --------------------------------------------------------
def download_storage_to_file(
self,
storage_id: str,
destination_path: Path,
*,
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
) -> None:
bucket_key, object_key = self._parse_storage_id(storage_id)
signed_url = self._get_signed_download_url(bucket_key, object_key)
self._download_to_file(signed_url, destination_path, progress)
def upload_file_to_folder(
self,
project_id: str,
folder_id: str,
local_path: Path,
*,
display_name: str | None = None,
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
) -> dict[str, Any]:
if not local_path.exists():
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
file_name = display_name or local_path.name
storage_id = self._create_storage(project_id, folder_id, file_name)
bucket_key, object_key = self._parse_storage_id(storage_id)
self._upload_local_file_to_oss(bucket_key, object_key, local_path, progress)
existing_item = self._find_item_in_folder(project_id, folder_id, file_name)
if existing_item is not None:
return self._create_version(project_id, existing_item["id"], file_name, storage_id)
return self._create_item(project_id, folder_id, file_name, storage_id)
# HTTP helpers -------------------------------------------------------------
def _get_json(self, url: str) -> dict[str, Any]:
token = self.auth.ensure_access_token()
try:
response = self.http.get(url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
token = self.auth.ensure_access_token()
try:
response = self.http.post(
url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
"Accept": "application/vnd.api+json",
},
json=payload,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _get_signed_download_url(self, bucket_key: str, object_key: str) -> str:
payload = self._get_json(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3download"
)
url = payload.get("url")
if not isinstance(url, str) or not url:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Signed download URL response did not contain a URL.")
return url
def _download_to_file(
self,
url: str,
destination_path: Path,
progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None:
try:
with self.http.stream("GET", url) as response:
response.raise_for_status()
total_bytes: int | None = None
header_value = response.headers.get("Content-Length")
if header_value and header_value.isdigit():
total_bytes = int(header_value)
downloaded_bytes = 0
with open(destination_path, "wb") as handle:
for chunk in response.iter_bytes():
handle.write(chunk)
downloaded_bytes += len(chunk)
if progress and total_bytes:
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
progress(destination_path.name, percent, downloaded_bytes, total_bytes)
elif progress:
progress(destination_path.name, None, downloaded_bytes, total_bytes)
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _create_storage(self, project_id: str, folder_id: str, file_name: str) -> str:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/storage",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "objects",
"attributes": {"name": file_name},
"relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
},
},
)
storage_id = payload.get("data", {}).get("id")
if not isinstance(storage_id, str) or not storage_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Storage creation did not return an object id.")
return storage_id
def _upload_local_file_to_oss(
self,
bucket_key: str,
object_key: str,
local_path: Path,
progress: Callable[[str, int | None, int | None, int | None], None] | None,
) -> None:
file_size = local_path.stat().st_size
chunk_size = 5 * 1024 * 1024
total_parts = max(1, math.ceil(file_size / chunk_size))
upload_key: str | None = None
parts_uploaded = 0
bytes_uploaded = 0
with open(local_path, "rb") as handle:
while parts_uploaded < total_parts:
parts_to_request = min(total_parts - parts_uploaded, 5)
first_part = parts_uploaded + 1
signed = self._get_signed_upload_urls(
bucket_key,
object_key,
upload_key=upload_key,
first_part=first_part,
parts=parts_to_request,
)
if upload_key is None:
upload_key = signed.get("uploadKey")
urls = signed.get("urls", [])
if not isinstance(urls, list) or not urls:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload URL response did not contain upload URLs.")
for url in urls:
if parts_uploaded >= total_parts:
break
chunk = handle.read(chunk_size)
if not chunk:
break
self._put_bytes(str(url), chunk)
parts_uploaded += 1
bytes_uploaded += len(chunk)
if progress:
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
progress(local_path.name, percent, bytes_uploaded, file_size)
if not upload_key:
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
self._complete_signed_upload(bucket_key, object_key, upload_key)
def _get_signed_upload_urls(
self,
bucket_key: str,
object_key: str,
*,
upload_key: str | None,
first_part: int,
parts: int,
) -> dict[str, Any]:
token = self.auth.ensure_access_token()
params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts}
if upload_key:
params["uploadKey"] = upload_key
try:
response = self.http.get(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
headers={"Authorization": f"Bearer {token}"},
params=params,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None:
token = self.auth.ensure_access_token()
try:
response = self.http.post(
"https://developer.api.autodesk.com/oss/v2/buckets/"
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"uploadKey": upload_key},
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _put_bytes(self, url: str, content: bytes) -> None:
try:
response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = exc.response.text.strip()
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None:
children = self.list_folder_contents(project_id, folder_id, object_types=["items"])
return next(
(
child for child in children
if self._entry_name_matches(child, file_name)
),
None,
)
def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "versions",
"attributes": {
"name": file_name,
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {
"item": {"data": {"type": "items", "id": item_id}},
"storage": {"data": {"type": "objects", "id": storage_id}},
},
},
},
)
version = payload["data"]
attributes = version.get("attributes", {})
return {
"item_id": item_id,
"version_id": version["id"],
"display_name": file_name,
"version_number": attributes.get("versionNumber"),
"last_modified_time_utc": attributes.get("lastModifiedTime"),
"last_modified_user_name": attributes.get("lastModifiedUserName"),
}
def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
payload = self._post_json(
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items",
{
"jsonapi": {"version": "1.0"},
"data": {
"type": "items",
"attributes": {
"displayName": file_name,
"extension": {"type": "items:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {
"tip": {"data": {"type": "versions", "id": "1"}},
"parent": {"data": {"type": "folders", "id": folder_id}},
},
},
"included": [
{
"type": "versions",
"id": "1",
"attributes": {
"name": file_name,
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
},
"relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}},
}
],
},
)
item = payload["data"]
version_id = "1"
version_number: Any = 1
last_modified_time: Any = None
last_modified_user: Any = None
for included in payload.get("included", []):
if included.get("type") == "versions":
version_id = included.get("id") or version_id
attributes = included.get("attributes", {})
version_number = attributes.get("versionNumber", version_number)
last_modified_time = attributes.get("lastModifiedTime")
last_modified_user = attributes.get("lastModifiedUserName")
break
return {
"item_id": item["id"],
"version_id": version_id,
"display_name": file_name,
"version_number": version_number,
"last_modified_time_utc": last_modified_time,
"last_modified_user_name": last_modified_user,
}
# Static helpers -----------------------------------------------------------
@staticmethod
def _parse_storage_id(storage_id: str) -> tuple[str, str]:
marker = "urn:adsk.objects:os.object:"
if not storage_id.startswith(marker):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.")
path = storage_id[len(marker):]
slash = path.find("/")
if slash <= 0 or slash == len(path) - 1:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.")
return path[:slash], path[slash + 1 :]
@staticmethod
def _entry(item: dict[str, Any]) -> dict[str, Any]:
attributes = item["attributes"]
return {
"id": item["id"],
"type": item["type"],
"display_name": attributes.get("displayName") or attributes.get("name") or "",
"name": attributes.get("name"),
"extension_type": attributes.get("extension", {}).get("type", ""),
}
@staticmethod
def _relationship_id(data: dict[str, Any], name: str) -> str | None:
rel_data = data.get("relationships", {}).get(name, {}).get("data")
if isinstance(rel_data, dict):
rel_id = rel_data.get("id")
return rel_id if isinstance(rel_id, str) and rel_id else None
if isinstance(rel_data, list) and rel_data:
rel_id = rel_data[0].get("id")
return rel_id if isinstance(rel_id, str) and rel_id else None
return None
@staticmethod
def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool:
display_name = str(entry.get("display_name") or "").lower()
raw_name = str(entry.get("name") or "").lower()
expected = expected_name.lower()
return display_name == expected or raw_name == expected
@@ -0,0 +1,62 @@
from __future__ import annotations
import hashlib
import json
import os
import platform
import shutil
from pathlib import Path
from typing import Any
def cache_root() -> Path:
system = platform.system()
if system == "Windows":
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
root = Path(base) / "bonsaiviewer-autodesk" / "Cache"
elif system == "Darwin":
root = Path.home() / "Library" / "Caches" / "bonsaiviewer-autodesk"
else:
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
root = Path(base) / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
return root
def _short_hash(*parts: str) -> str:
joined = "\x1f".join(parts)
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
def ifcfed_dir(project_id: str, item_id: str) -> Path:
"""Stable directory for an .ifcfed. Re-downloads overwrite in place so the
viewer's open path remains valid across sync operations."""
return cache_root() / "ifcfeds" / _short_hash(project_id, item_id)
def model_dir(project_id: str, item_id: str, version_id: str) -> Path:
"""Per-version directory for a model. A new resolved version → a new
directory, satisfying the spec's invariant that sidecars regenerate when
the model file changes."""
return cache_root() / "models" / _short_hash(project_id, item_id, version_id)
def prepare_sole_child_dir(directory: Path) -> Path:
"""Clear the directory so the file we write is the only child."""
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True, exist_ok=True)
return directory
def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path:
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return manifest_path
def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None:
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
if not manifest_path.exists():
return None
return json.loads(manifest_path.read_text(encoding="utf-8"))
@@ -0,0 +1,563 @@
from __future__ import annotations
import sys
import traceback
from pathlib import Path
from typing import Any, Callable
from bonsaiviewer_autodesk import cache, settings
from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError
from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, prompt_for_filename, run_with_progress
ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
Report = Callable[[str, str, "int | None", "str | None"], None]
def _format_bytes(value: int) -> str:
"""Render a byte count as a short human-readable string (e.g. '3.4 MB')."""
if value < 1024:
return f"{value} B"
scaled = float(value)
for unit in ("KB", "MB", "GB", "TB"):
scaled /= 1024.0
if scaled < 1024 or unit == "TB":
return f"{scaled:.1f} {unit}"
return f"{value} B"
def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str:
"""Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'."""
parts: list[str] = []
if percent is not None:
parts.append(f"{percent}%")
if done is not None and total:
parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}")
elif done is not None:
parts.append(_format_bytes(done))
return ", ".join(parts)
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
"""Adapt ProgressDialog.report to the APS download callback.
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
single-file downloads to omit the suffix.
"""
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
suffix = f" ({index}/{total})" if total else ""
detail = _progress_detail(percent, bytes_done, bytes_total)
report("download", f"Downloading {name}{suffix}", percent, detail)
return cb
def _upload_callback(report: Report) -> ApsProgress:
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
detail = _progress_detail(percent, bytes_done, bytes_total)
report("upload", f"Uploading {name}", percent, detail)
return cb
CONNECTOR_ID = "autodesk"
KEYRING_SERVICE = "bonsaiviewer-autodesk"
DEFAULT_SCOPE = "data:read data:write data:create"
class AutodeskConnector:
def __init__(self) -> None:
self.auth: AuthSessionService | None = None
self.aps: ApsClient | None = None
self.reload_credentials()
def reload_credentials(self) -> None:
"""Rebuild auth + APS from current settings. Safe to call any time."""
client_id = settings.load_client_id()
if not client_id:
self.auth = None
self.aps = None
return
token_store = KeyringTokenStore(service_name=KEYRING_SERVICE, username=client_id)
callback_url = f"http://localhost:{settings.stored_callback_port()}/"
self.auth = AuthSessionService(
client_id=client_id,
callback_url=callback_url,
scope=DEFAULT_SCOPE,
token_store=token_store,
)
self.aps = ApsClient(self.auth)
def _require_aps(self) -> tuple[AuthSessionService, ApsClient]:
if self.auth is None or self.aps is None:
raise RpcError(
JSONRPC_INTERNAL_ERROR,
"Autodesk client id is not configured. Open the connector settings to set it.",
)
return self.auth, self.aps
def handlers(self) -> dict[str, Any]:
return {
"pull_ifcfed_interactive": self.pull_ifcfed_interactive,
"pull_ifcfed": self.pull_ifcfed,
"pull_models": self.pull_models,
"pull_models_interactive": self.pull_models_interactive,
"push_ifcfed_interactive": self.push_ifcfed_interactive,
"push_ifcfed": self.push_ifcfed,
"push_model_interactive": self.push_model_interactive,
"push_model": self.push_model,
"open_settings": self.open_settings,
}
# ---- open_settings ------------------------------------------------------
def open_settings(self, _params: Any) -> dict[str, Any]:
SettingsDialog(connector=self).run()
return {}
# ---- pull_ifcfed_interactive --------------------------------------------
def pull_ifcfed_interactive(self, _params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
hub = chosen["hub"]
project = chosen["project"]
entry = chosen["entries"][0]
path = run_with_progress(
"Downloading project",
lambda report: self._download_ifcfed(
aps=aps,
hub_id=hub["id"],
project_id=project["id"],
item_id=entry["id"],
display_name=entry["display_name"],
progress=_download_callback(report),
),
)
return {"path": str(path)}
# ---- pull_ifcfed --------------------------------------------------------
def pull_ifcfed(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
manifest = _require_object(params, "params")
hub_id = _require_string(manifest, "hub_id")
project_id = _require_string(manifest, "project_id")
item_id = _require_string(manifest, "item_id")
display_name = manifest.get("display_name") or item_id
path = run_with_progress(
"Downloading project",
lambda report: self._download_ifcfed(
aps=aps,
hub_id=hub_id,
project_id=project_id,
item_id=item_id,
display_name=display_name,
progress=_download_callback(report),
),
)
return {"path": str(path)}
def _download_ifcfed(
self,
*,
aps: ApsClient,
hub_id: str,
project_id: str,
item_id: str,
display_name: str,
progress: ApsProgress | None = None,
) -> Path:
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
file_name = item["display_name"] or display_name or item_id
if not file_name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Item '{file_name}' is not an .ifcfed file.")
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, item_id))
ifcfed_path = directory / file_name
aps.download_storage_to_file(storage_id, ifcfed_path, progress=progress)
cache.write_manifest(
ifcfed_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": item_id,
"display_name": file_name,
},
)
return ifcfed_path
# ---- pull_models --------------------------------------------------------
def pull_models(self, params: Any) -> list[dict[str, Any] | None]:
_, aps = self._require_aps()
models = _require_array(params, "params")
total = len(models)
def work(report: Report) -> list[dict[str, Any] | None]:
results: list[dict[str, Any] | None] = []
for index, model in enumerate(models):
callback = _download_callback(report, index=index + 1, total=total)
try:
results.append(self._resolve_model(aps, model, progress=callback))
except RpcError as exc:
print(f"pull_models[{index}] skipped: {exc.message}", file=sys.stderr)
results.append(None)
except Exception as exc:
print(f"pull_models[{index}] skipped: {exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
results.append(None)
return results
return run_with_progress("Downloading models", work)
def _resolve_model(
self,
aps: ApsClient,
model: Any,
*,
progress: ApsProgress | None = None,
) -> dict[str, Any] | None:
if not isinstance(model, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must be an object.")
source = model.get("source")
if not isinstance(source, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must have a 'source' object.")
if source.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
project_id = _require_string(source, "project_id")
item_id = _require_string(source, "item_id")
display_name_hint = model.get("display_name") or item_id
item = aps.get_item(project_id, item_id)
if item["hidden"]:
print(f"Autodesk item '{item_id}' is hidden/deleted; returning null.", file=sys.stderr)
return None
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
file_name = item["display_name"] or display_name_hint
version_id = item["version_id"]
directory = cache.model_dir(project_id, item_id, version_id)
model_path = directory / file_name
if not model_path.exists():
cache.prepare_sole_child_dir(directory)
aps.download_storage_to_file(storage_id, model_path, progress=progress)
return {
"path": str(model_path),
"metadata": _build_metadata(item),
}
# ---- pull_models_interactive --------------------------------------------
def pull_models_interactive(self, _params: Any) -> list[dict[str, Any]]:
auth, aps = self._require_aps()
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
hub = chosen["hub"]
project = chosen["project"]
entries = chosen["entries"]
total = len(entries)
def work(report: Report) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for index, entry in enumerate(entries):
callback = _download_callback(report, index=index + 1, total=total)
try:
result = self._download_picked_model(aps, hub, project, entry, callback)
except RpcError as exc:
print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr)
continue
except Exception as exc:
print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
continue
if result is not None:
results.append(result)
return results
return run_with_progress("Downloading models", work)
def _download_picked_model(
self,
aps: ApsClient,
hub: dict[str, Any],
project: dict[str, Any],
entry: dict[str, Any],
progress: ApsProgress,
) -> dict[str, Any] | None:
item = aps.get_item(project["id"], entry["id"])
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.")
storage_id = item["storage_id"]
if not isinstance(storage_id, str):
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
file_name = item["display_name"] or entry["display_name"] or entry["id"]
version_id = item["version_id"]
directory = cache.model_dir(project["id"], entry["id"], version_id)
model_path = directory / file_name
if not model_path.exists():
cache.prepare_sole_child_dir(directory)
aps.download_storage_to_file(storage_id, model_path, progress=progress)
return {
"display_name": file_name,
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": entry["id"],
},
"path": str(model_path),
"metadata": _build_metadata(item),
}
# ---- push_ifcfed_interactive --------------------------------------------
def push_ifcfed_interactive(self, params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
if not local_path.name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed_interactive expects an .ifcfed file.")
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entries"][0]
file_name = prompt_for_filename(
title="Save Project",
label="Save .ifcfed as:",
default=local_path.name,
)
if not file_name:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
if not file_name.lower().endswith(".ifcfed"):
file_name = file_name + ".ifcfed"
uploaded = run_with_progress(
"Uploading project",
lambda report: aps.upload_file_to_folder(
project["id"],
folder["id"],
local_path,
display_name=file_name,
progress=_upload_callback(report),
),
)
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project["id"], uploaded["item_id"]))
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
cache.write_manifest(
cached_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": uploaded["item_id"],
"display_name": file_name,
},
)
return {"path": str(cached_path)}
# ---- push_ifcfed --------------------------------------------------------
def push_ifcfed(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
if not local_path.name.lower().endswith(".ifcfed"):
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed expects an .ifcfed file.")
manifest = params_obj.get("manifest")
if not isinstance(manifest, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "'manifest' must be a JSON object.")
if manifest.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Manifest connector is not '{CONNECTOR_ID}'.")
hub_id = _require_string(manifest, "hub_id")
project_id = _require_string(manifest, "project_id")
item_id = _require_string(manifest, "item_id")
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
folder_id = item.get("parent_folder_id")
if not isinstance(folder_id, str) or not folder_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
file_name = manifest.get("display_name") or item.get("display_name") or local_path.name
uploaded = run_with_progress(
"Uploading project",
lambda report: aps.upload_file_to_folder(
project_id,
folder_id,
local_path,
display_name=file_name,
progress=_upload_callback(report),
),
)
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, uploaded["item_id"]))
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
cache.write_manifest(
cached_path,
{
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": uploaded["item_id"],
"display_name": file_name,
},
)
return {"path": str(cached_path)}
# ---- push_model_interactive ---------------------------------------------
def push_model_interactive(self, params: Any) -> dict[str, Any]:
auth, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
hub = chosen["hub"]
project = chosen["project"]
folder = chosen["entries"][0]
file_name = prompt_for_filename(
title="Save Model",
label="Save model as:",
default=local_path.name,
)
if not file_name:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
uploaded = run_with_progress(
"Uploading model",
lambda report: aps.upload_file_to_folder(
project["id"],
folder["id"],
local_path,
display_name=file_name,
progress=_upload_callback(report),
),
)
directory = cache.model_dir(project["id"], uploaded["item_id"], uploaded["version_id"])
cache.prepare_sole_child_dir(directory)
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
return {
"display_name": file_name,
"path": str(cached_path),
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub["id"],
"project_id": project["id"],
"item_id": uploaded["item_id"],
},
"metadata": _build_metadata(uploaded),
}
# ---- push_model ---------------------------------------------------------
def push_model(self, params: Any) -> dict[str, Any]:
_, aps = self._require_aps()
params_obj = _require_object(params, "params")
local_path = Path(_require_string(params_obj, "path"))
if not local_path.exists():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
source = params_obj.get("source")
if not isinstance(source, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, "'source' must be a JSON object.")
if source.get("connector") != CONNECTOR_ID:
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
hub_id = _require_string(source, "hub_id")
project_id = _require_string(source, "project_id")
item_id = _require_string(source, "item_id")
item = aps.get_item(project_id, item_id)
if item["hidden"]:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
folder_id = item.get("parent_folder_id")
if not isinstance(folder_id, str) or not folder_id:
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
file_name = item.get("display_name") or local_path.name
uploaded = run_with_progress(
"Uploading model",
lambda report: aps.upload_file_to_folder(
project_id,
folder_id,
local_path,
display_name=file_name,
progress=_upload_callback(report),
),
)
directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"])
cache.prepare_sole_child_dir(directory)
cached_path = directory / file_name
cached_path.write_bytes(local_path.read_bytes())
return {
"source": {
"connector": CONNECTOR_ID,
"hub_id": hub_id,
"project_id": project_id,
"item_id": uploaded["item_id"],
},
"metadata": _build_metadata(uploaded),
}
def _require_object(params: Any, name: str) -> dict[str, Any]:
if not isinstance(params, dict):
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.")
return params
def _require_array(params: Any, name: str) -> list[Any]:
if not isinstance(params, list):
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.")
return params
def _require_string(obj: dict[str, Any], key: str) -> str:
value = obj.get(key)
if not isinstance(value, str) or not value.strip():
raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.")
return value
def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]:
metadata: dict[str, Any] = {}
version_number = version_info.get("version_number")
if version_number is not None:
metadata["revision"] = f"v{version_number}"
last_modified = version_info.get("last_modified_time_utc")
if isinstance(last_modified, str) and last_modified:
metadata["date"] = last_modified
author = version_info.get("last_modified_user_name")
if isinstance(author, str) and author:
metadata["author"] = author
return metadata
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import sys
import traceback
from typing import Any, Callable, TextIO
JSONRPC_PARSE_ERROR = -32700
JSONRPC_INVALID_REQUEST = -32600
JSONRPC_METHOD_NOT_FOUND = -32601
JSONRPC_INVALID_PARAMS = -32602
JSONRPC_INTERNAL_ERROR = -32603
class RpcError(Exception):
def __init__(self, code: int, message: str, data: Any | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.data = data
Handler = Callable[[Any], Any]
class JsonRpcHost:
def __init__(
self,
handlers: dict[str, Handler],
*,
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout,
stderr: TextIO = sys.stderr,
) -> None:
self.handlers = handlers
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
def run(self) -> int:
for line in self.stdin:
line = line.strip()
if not line:
continue
self._handle_line(line)
return 0
def _handle_line(self, line: str) -> None:
message_id: Any = None
try:
try:
message = json.loads(line)
except json.JSONDecodeError as exc:
self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}")
return
if not isinstance(message, dict):
self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object")
return
if message.get("jsonrpc") != "2.0":
self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version")
return
message_id = message.get("id")
method = message.get("method")
if not isinstance(method, str):
self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string")
return
params = message.get("params", None)
if params is not None and not isinstance(params, (dict, list)):
self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array")
return
handler = self.handlers.get(method)
if handler is None:
self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'")
return
try:
result = handler(params)
except RpcError as exc:
self._respond_error(message_id, exc.code, exc.message, exc.data)
return
except Exception as exc:
print(f"Handler '{method}' raised: {exc}", file=self.stderr)
traceback.print_exc(file=self.stderr)
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
return
if message_id is not None:
self._respond_result(message_id, result)
except Exception as exc:
print(f"Unhandled host error: {exc}", file=self.stderr)
traceback.print_exc(file=self.stderr)
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
def _respond_result(self, message_id: Any, result: Any) -> None:
self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
self._write({"jsonrpc": "2.0", "id": message_id, "error": error})
def _write(self, payload: dict[str, Any]) -> None:
line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
self.stdout.write(line + "\n")
self.stdout.flush()
@@ -0,0 +1,71 @@
from __future__ import annotations
import json
import os
import platform
from pathlib import Path
from typing import Any
DEFAULT_CALLBACK_PORT = 8080
def config_root() -> Path:
system = platform.system()
if system == "Windows":
base = os.environ.get("APPDATA") or os.path.expanduser("~")
root = Path(base) / "bonsaiviewer-autodesk"
elif system == "Darwin":
root = Path.home() / "Library" / "Application Support" / "bonsaiviewer-autodesk"
else:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
root = Path(base) / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
return root
def _settings_path() -> Path:
return config_root() / "settings.json"
def _read() -> dict[str, Any]:
path = _settings_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return data if isinstance(data, dict) else {}
def _write(data: dict[str, Any]) -> None:
_settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
def load_client_id() -> str:
"""The client id persisted in settings.json, or "" if none is set."""
return str(_read().get("client_id", "")).strip()
def save_client_id(client_id: str) -> None:
data = _read()
data["client_id"] = client_id.strip()
_write(data)
def stored_callback_port() -> int:
value = _read().get("callback_port", DEFAULT_CALLBACK_PORT)
try:
port = int(value)
except (TypeError, ValueError):
return DEFAULT_CALLBACK_PORT
return port if 1 <= port <= 65535 else DEFAULT_CALLBACK_PORT
def save_callback_port(port: int) -> None:
if not 1 <= port <= 65535:
raise ValueError("Callback port must be between 1 and 65535.")
data = _read()
data["callback_port"] = port
_write(data)
@@ -0,0 +1,810 @@
from __future__ import annotations
import threading
import time
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
import customtkinter as ctk
from bonsaiviewer_autodesk import settings
from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
if TYPE_CHECKING:
from bonsaiviewer_autodesk.connector import AutodeskConnector
MODEL_EXTENSIONS = (".ifc", ".ifcview", ".rdb", ".rdbview")
Mode = Literal["ifcfed", "model", "destination"]
# --- root + Treeview style ---------------------------------------------------
# Tk's default ttk.Treeview looks like Windows 95 in any theme; force-style it
# to match the surrounding CTk dark theme. Every other widget uses CTk defaults.
_root: ctk.CTk | None = None
def ensure_tk_app() -> ctk.CTk:
global _root
if _root is None:
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
_root = ctk.CTk()
_root.withdraw()
_apply_treeview_style()
return _root
def _apply_treeview_style() -> None:
style = ttk.Style()
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(
"Treeview",
background="#2b2b2b",
foreground="#dce4ee",
fieldbackground="#2b2b2b",
borderwidth=0,
rowheight=26,
)
style.map(
"Treeview",
background=[("selected", "#1f6aa5")],
foreground=[("selected", "#ffffff")],
)
style.layout("Treeview", [("Treeview.treearea", {"sticky": "nswe"})])
# --- base modal --------------------------------------------------------------
class _BaseDialog(ctk.CTkToplevel):
def __init__(self, title: str, *, size: tuple[int, int], resizable: bool = True) -> None:
super().__init__(ensure_tk_app())
self.title(title)
self.geometry(f"{size[0]}x{size[1]}")
if not resizable:
self.resizable(False, False)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.result: Any = None
self.withdraw()
def _on_close(self) -> None:
try:
self.grab_release()
except tk.TclError:
pass
self.destroy()
def _center_on_screen(self) -> None:
self.update_idletasks()
w = self.winfo_width()
h = self.winfo_height()
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"+{x}+{y}")
def run(self) -> Any:
root = ensure_tk_app()
self._center_on_screen()
self.deiconify()
self.lift()
self.focus_force()
try:
self.grab_set()
except tk.TclError:
pass
self.wait_window()
try:
root.update()
root.update_idletasks()
except tk.TclError:
pass
return self.result
# --- progress ----------------------------------------------------------------
class ProgressDialog(_BaseDialog):
"""Fixed-size progress dialog: a title line, a stats line, and a bar.
Both text lines are single-line and middle-elided with '' so a long
filename can never reflow the layout or resize the window.
"""
_WIDTH = 560
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
super().__init__(title, size=(self._WIDTH, 160), resizable=False)
self._text_font = ctk.CTkFont()
body = ctk.CTkFrame(self)
body.pack(fill="both", expand=True, padx=20, pady=20)
self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
self.title_label.pack(fill="x", padx=16, pady=(16, 0))
# Initialised with a space so the line reserves its height before the
# first report(); a single-line label never grows taller than this.
self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font)
self.detail_label.pack(fill="x", padx=16, pady=(2, 0))
self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
self.bar.pack(fill="x", padx=16, pady=(14, 16))
self.bar.start()
self._determinate = False
# Derive the height from the laid-out content (font-driven) rather than
# hardcoding it, then lock it. Single-line labels keep it stable no
# matter how long the text is.
self.update_idletasks()
self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}")
self._center_on_screen()
self.deiconify()
self.lift()
self.update()
def report(
self,
_phase: str,
message: str,
percent: int | None = None,
detail: str | None = None,
) -> None:
try:
width = self._text_area_width()
self.title_label.configure(text=self._elide_middle(message, width))
self.detail_label.configure(text=self._elide_middle(detail or " ", width))
if percent is None:
if self._determinate:
self.bar.configure(mode="indeterminate")
self.bar.start()
self._determinate = False
else:
if not self._determinate:
self.bar.stop()
self.bar.configure(mode="determinate")
self._determinate = True
self.bar.set(max(0.0, min(1.0, percent / 100.0)))
self.update()
except tk.TclError:
pass
def _text_area_width(self) -> int:
"""Pixels available for label text, in the same scaled space as the font."""
width = self.title_label.winfo_width()
if width <= 1: # not laid out yet
return self._WIDTH - 2 * 20 - 2 * 16
return max(40, width - 6)
def _elide_middle(self, text: str, max_width: int) -> str:
"""Middle-truncate text with '' so it fits max_width without wrapping."""
font = self._text_font
if font.measure(text) <= max_width:
return text
ellipsis = ""
keep = len(text) - 1
while keep > 0:
head = (keep + 1) // 2
tail = keep - head
candidate = text[:head] + ellipsis + (text[-tail:] if tail else "")
if font.measure(candidate) <= max_width:
return candidate
keep -= 1
return ellipsis
class _ProgressContext:
def __init__(self, parent: tk.Misc | None, message: str) -> None:
self.parent = parent
self.message = message
self.dialog: ProgressDialog | None = None
def __enter__(self) -> Callable[..., None]:
self.dialog = ProgressDialog(self.message, self.parent)
return self.dialog.report
def __exit__(self, *_exc: object) -> None:
if self.dialog is not None:
try:
self.dialog.withdraw()
self.dialog.destroy()
except tk.TclError:
pass
self.dialog = None
try:
root = ensure_tk_app()
root.update_idletasks()
root.update()
except tk.TclError:
pass
T = TypeVar("T")
# A progress sink: (phase, message, percent, detail) -> None.
Report = Callable[[str, str, "int | None", "str | None"], None]
class _ProgressBridge:
"""Thread-safe hand-off of the latest progress report to the UI thread.
``work`` runs on a worker thread and must never touch Tk; it calls
:meth:`report`, which only stashes the most recent update. The main thread
drains it via :meth:`take` and applies it to the dialog. Intermediate
updates are coalesced only the latest matters for a progress bar.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._pending: tuple[str, str, int | None, str | None] | None = None
def report(
self,
phase: str,
message: str,
percent: int | None = None,
detail: str | None = None,
) -> None:
with self._lock:
self._pending = (phase, message, percent, detail)
def take(self) -> tuple[str, str, int | None, str | None] | None:
with self._lock:
pending, self._pending = self._pending, None
return pending
def run_with_progress(
message: str,
work: Callable[[Report], T],
*,
parent: tk.Misc | None = None,
) -> T:
"""Show a ProgressDialog and run ``work`` on a worker thread.
Tkinter is single-threaded and only repaints while its event loop runs, so
a long upload/download executed inline would freeze the dialog. Worse, on
Windows ``CTkToplevel`` withdraws itself at construction and re-shows via a
delayed ``after()`` callback without a running loop that callback never
fires and the window stays invisible for the whole transfer.
So the blocking ``work`` runs on a background thread while the main thread
pumps the Tk event loop here. ``work`` receives a thread-safe ``report``
callback; its updates are marshalled back to the UI thread. Returns work's
result, or re-raises (on the main thread) whatever exception it raised.
"""
root = ensure_tk_app()
dialog = ProgressDialog(message, parent)
bridge = _ProgressBridge()
outcome: dict[str, Any] = {}
def runner() -> None:
try:
outcome["value"] = work(bridge.report)
except BaseException as exc: # noqa: BLE001 - re-raised on the main thread
outcome["error"] = exc
thread = threading.Thread(target=runner, name="autodesk-progress", daemon=True)
thread.start()
try:
while thread.is_alive():
pending = bridge.take()
try:
if pending is not None:
dialog.report(*pending)
else:
root.update()
except tk.TclError:
break
time.sleep(0.03)
thread.join()
final = bridge.take()
if final is not None:
try:
dialog.report(*final)
except tk.TclError:
pass
finally:
try:
dialog.withdraw()
dialog.destroy()
except tk.TclError:
pass
try:
root.update_idletasks()
root.update()
except tk.TclError:
pass
if "error" in outcome:
raise outcome["error"]
return outcome["value"]
# --- browse ------------------------------------------------------------------
class BrowseDialog(_BaseDialog):
"""Hub → project → folder tree → file/folder picker."""
def __init__(self, *, auth: AuthSessionService, aps: ApsClient, mode: Mode) -> None:
titles = {
"ifcfed": ("Open Project From Autodesk", "Open"),
"model": ("Add Model From Autodesk", "Add"),
"destination": ("Choose Autodesk Destination", "Select"),
}
title, action_label = titles[mode]
super().__init__(title, size=(920, 620))
self.auth = auth
self.aps = aps
self.mode: Mode = mode
self.multi_select = mode == "model"
self.selected_hub: dict[str, Any] | None = None
self.selected_project: dict[str, Any] | None = None
self.selected_entries: list[dict[str, Any]] = []
self._tree_entries: dict[str, dict[str, Any]] = {}
self._project_entries: dict[str, dict[str, Any]] = {}
self._build_ui(action_label)
def _build_ui(self, action_label: str) -> None:
root = ctk.CTkFrame(self, fg_color="transparent")
root.pack(fill="both", expand=True, padx=16, pady=16)
root.grid_rowconfigure(1, weight=1)
root.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(root, fg_color="transparent")
top.grid(row=0, column=0, sticky="ew", pady=(0, 12))
top.grid_columnconfigure(1, weight=1)
self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in)
self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w")
self.hub_combo = ctk.CTkOptionMenu(
top,
values=["Select hub"],
command=self._hub_changed,
anchor="w",
)
self.hub_combo.grid(row=0, column=1, sticky="ew")
self.hub_combo.configure(state="disabled")
split = ctk.CTkFrame(root, fg_color="transparent")
split.grid(row=1, column=0, sticky="nsew")
split.grid_rowconfigure(0, weight=1)
split.grid_columnconfigure(0, weight=3, uniform="col")
split.grid_columnconfigure(1, weight=7, uniform="col")
self.projects_frame = ctk.CTkFrame(split)
self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self.tree_frame = ctk.CTkFrame(split)
self.tree_frame.grid(row=0, column=1, sticky="nsew")
self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
tree_selectmode = "extended" if self.multi_select else "browse"
self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode)
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
actions = ctk.CTkFrame(root, fg_color="transparent")
actions.grid(row=3, column=0, sticky="ew")
actions.grid_columnconfigure(0, weight=1)
self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
self.cancel_button.grid(row=0, column=1, padx=(0, 8))
self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
self.action_button.grid(row=0, column=2)
self.action_button.configure(state="disabled")
def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview:
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
body = ctk.CTkFrame(parent, fg_color="transparent")
body.pack(fill="both", expand=True, padx=8, pady=8)
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=1)
tree = ttk.Treeview(body, show="tree", selectmode=selectmode)
tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
tree.configure(yscrollcommand=scrollbar.set)
return tree
# --- sign-in & population ------------------------------------------------
def run(self) -> dict[str, Any]:
if self.auth.get_token() is not None:
self._populate_hubs_silently()
outcome = super().run()
if outcome is None:
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled the Autodesk picker.")
return outcome
def _populate_hubs_silently(self) -> None:
try:
hubs = self.aps.list_hubs()
self._fill_hubs(hubs)
self.status.configure(text="Signed in. Select a hub.")
except Exception:
pass
def _sign_in(self) -> None:
with self._with_progress("Signing in to Autodesk") as report:
try:
self.auth.login_interactive(report)
hubs = self.aps.list_hubs()
self._fill_hubs(hubs)
self.status.configure(text="Signed in. Select a hub.")
except Exception as exc:
show_error(title="Sign In Failed", message=str(exc))
def _fill_hubs(self, hubs: list[dict[str, Any]]) -> None:
self._hubs_by_name = {hub["name"]: hub for hub in hubs}
values = ["Select hub"] + list(self._hubs_by_name.keys())
self.hub_combo.configure(values=values, state="normal")
self.hub_combo.set("Select hub")
def _hub_changed(self, label: str) -> None:
if label == "Select hub":
return
hub = getattr(self, "_hubs_by_name", {}).get(label)
if not isinstance(hub, dict):
return
self.selected_hub = hub
self.selected_project = None
self.selected_entry = None
self._clear_projects()
self._clear_tree()
self._refresh_action_button()
with self._with_progress("Loading Autodesk projects"):
try:
projects = self.aps.list_projects(hub["id"])
self._project_entries = {}
for project in projects:
iid = self.projects.insert("", "end", text=project["name"])
self._project_entries[iid] = project
self.status.configure(text=f"Hub: {hub['name']}. Select a project.")
except Exception as exc:
show_error(title="Load Projects Failed", message=str(exc))
def _project_changed(self) -> None:
selection = self.projects.selection()
if not selection or self.selected_hub is None:
return
project = self._project_entries.get(selection[0])
if not isinstance(project, dict):
return
self.selected_project = project
self.selected_entries = []
self._refresh_action_button()
self._clear_tree()
with self._with_progress("Loading top folders"):
try:
top_folders = self.aps.list_top_folders(self.selected_hub["id"], project["id"])
for entry in top_folders:
self._insert_tree_entry("", entry)
if self.mode == "destination":
self.status.configure(text=f"Project: {project['name']}. Browse folders and choose a destination.")
else:
self.status.configure(text=f"Project: {project['name']}. Browse folders and pick a file.")
except Exception as exc:
show_error(title="Load Project Failed", message=str(exc))
def _insert_tree_entry(self, parent_iid: str, entry: dict[str, Any]) -> str:
label = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
iid = self.tree.insert(parent_iid, "end", text=label)
self._tree_entries[iid] = entry
if entry.get("type") == "folders":
placeholder = self.tree.insert(iid, "end", text="Loading…")
self._tree_entries[placeholder] = {"__placeholder__": True}
return iid
def _on_tree_open(self, _event: tk.Event) -> None:
selection = self.tree.focus()
if not selection:
return
entry = self._tree_entries.get(selection)
if not isinstance(entry, dict) or entry.get("type") != "folders" or self.selected_project is None:
return
children = self.tree.get_children(selection)
if len(children) != 1:
return
only = self._tree_entries.get(children[0])
if not (isinstance(only, dict) and only.get("__placeholder__")):
return
self.tree.delete(children[0])
self._tree_entries.pop(children[0], None)
with self._with_progress("Loading folder contents"):
try:
object_types = ["folders"] if self.mode == "destination" else ["folders", "items"]
contents = self.aps.list_folder_contents(
self.selected_project["id"],
entry["id"],
object_types=object_types,
extension_filter=self._extension_filter(),
)
for child in contents:
self._insert_tree_entry(selection, child)
except Exception as exc:
show_error(title="Load Folder Failed", message=str(exc))
def _extension_filter(self) -> Callable[[dict[str, Any]], bool] | None:
if self.mode == "ifcfed":
return lambda entry: (entry.get("display_name") or "").lower().endswith(".ifcfed")
if self.mode == "model":
return lambda entry: (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
return None
def _tree_selection_changed(self) -> None:
selection = self.tree.selection()
entries: list[dict[str, Any]] = []
for iid in selection:
entry = self._tree_entries.get(iid)
if isinstance(entry, dict) and not entry.get("__placeholder__"):
entries.append(entry)
self.selected_entries = entries
if not entries:
pass
elif len(entries) == 1:
entry = entries[0]
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
kind = entry.get("type", "entry")
self.status.configure(text=f"Selected {kind}: {name}")
else:
valid_count = sum(1 for e in entries if self._is_valid_selection(e))
self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.")
self._refresh_action_button()
def _is_valid_selection(self, entry: dict[str, Any]) -> bool:
if self.mode == "destination":
return entry.get("type") == "folders"
if self.mode == "ifcfed":
return (
entry.get("type") == "items"
and (entry.get("display_name") or "").lower().endswith(".ifcfed")
)
return (
entry.get("type") == "items"
and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
)
def _valid_entries(self) -> list[dict[str, Any]]:
return [e for e in self.selected_entries if self._is_valid_selection(e)]
def _refresh_action_button(self) -> None:
enabled = self.selected_project is not None and bool(self._valid_entries())
self.action_button.configure(state="normal" if enabled else "disabled")
def _confirm(self) -> None:
valid = self._valid_entries()
if not self.selected_hub or not self.selected_project or not valid:
return
self.result = {
"hub": self.selected_hub,
"project": self.selected_project,
"entries": valid,
}
self._on_close()
def _clear_projects(self) -> None:
for iid in self.projects.get_children():
self.projects.delete(iid)
self._project_entries.clear()
def _clear_tree(self) -> None:
for iid in self.tree.get_children():
self.tree.delete(iid)
self._tree_entries.clear()
def _with_progress(self, message: str) -> _ProgressContext:
return _ProgressContext(self, message)
# --- filename prompt ---------------------------------------------------------
class _FilenamePrompt(_BaseDialog):
def __init__(self, *, title: str, label: str, default: str) -> None:
super().__init__(title, size=(440, 170), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x")
self.entry = ctk.CTkEntry(body)
self.entry.pack(fill="x", pady=(8, 16))
self.entry.insert(0, default)
self.entry.select_range(0, "end")
self.entry.focus_set()
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2)
self.bind("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", lambda _e: self._on_close())
def _confirm(self) -> None:
value = self.entry.get().strip()
self.result = value or None
self._on_close()
def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
return _FilenamePrompt(title=title, label=label, default=default).run()
# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
class _ConfirmDialog(_BaseDialog):
def __init__(self, *, title: str, message: str) -> None:
super().__init__(title, size=(440, 180), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
self.bind("<Return>", lambda _e: self._confirm())
self.bind("<Escape>", lambda _e: self._on_close())
def _confirm(self) -> None:
self.result = True
self._on_close()
class _AlertDialog(_BaseDialog):
def __init__(self, *, title: str, message: str) -> None:
super().__init__(title, size=(440, 180), resizable=False)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=20, pady=20)
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(0, weight=1)
ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
self.bind("<Return>", lambda _e: self._on_close())
self.bind("<Escape>", lambda _e: self._on_close())
def confirm(*, title: str, message: str) -> bool:
return bool(_ConfirmDialog(title=title, message=message).run())
def show_error(*, title: str, message: str) -> None:
_AlertDialog(title=title, message=message).run()
# --- settings ----------------------------------------------------------------
class SettingsDialog(_BaseDialog):
"""Edit the APS client id and sign out."""
def __init__(self, *, connector: "AutodeskConnector") -> None:
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
self.connector = connector
client_id = settings.load_client_id()
callback_port = settings.stored_callback_port()
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=24, pady=24)
ctk.CTkLabel(
body,
text="Autodesk Platform Services",
anchor="w",
font=ctk.CTkFont(size=14, weight="bold"),
).pack(fill="x")
ctk.CTkLabel(
body,
text="The connector signs in to Autodesk using a PKCE flow. The client id below comes from your APS application.",
anchor="w",
wraplength=460,
justify="left",
).pack(fill="x", pady=(2, 16))
ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
self.client_id_entry.pack(fill="x", pady=(6, 8))
self.client_id_entry.insert(0, client_id)
ctk.CTkLabel(body, text="OAuth callback port", anchor="w").pack(fill="x")
self.callback_port_entry = ctk.CTkEntry(body, placeholder_text=str(settings.DEFAULT_CALLBACK_PORT))
self.callback_port_entry.pack(fill="x", pady=(6, 8))
self.callback_port_entry.insert(0, str(callback_port))
self.status_label = ctk.CTkLabel(
body,
text=f"Signed in as {client_id}" if client_id else "No client id configured.",
anchor="w",
wraplength=460,
justify="left",
)
self.status_label.pack(fill="x", pady=(0, 16))
buttons = ctk.CTkFrame(body, fg_color="transparent")
buttons.pack(fill="x")
buttons.grid_columnconfigure(1, weight=1)
self.signout_button = ctk.CTkButton(
buttons,
text="Sign Out",
command=self._sign_out,
fg_color="transparent",
border_width=1,
)
self.signout_button.grid(row=0, column=0, sticky="w")
if not client_id:
self.signout_button.configure(state="disabled")
ctk.CTkButton(
buttons,
text="Close",
command=self._on_close,
fg_color="transparent",
border_width=1,
).grid(row=0, column=2, padx=(0, 8))
ctk.CTkButton(buttons, text="Save", command=self._save).grid(row=0, column=3)
def _save(self) -> None:
new_id = self.client_id_entry.get().strip()
try:
callback_port = int(self.callback_port_entry.get().strip())
settings.save_callback_port(callback_port)
except ValueError:
show_error(title="Invalid Callback Port", message="Callback port must be a number between 1 and 65535.")
return
settings.save_client_id(new_id)
try:
self.connector.reload_credentials()
except Exception as exc:
show_error(title="Reload Failed", message=str(exc))
return
self._on_close()
def _sign_out(self) -> None:
client_id = settings.load_client_id()
if not client_id:
return
if not confirm(
title="Sign Out",
message=f"Forget the stored Autodesk session for {client_id}?",
):
return
try:
KeyringTokenStore(service_name="bonsaiviewer-autodesk", username=client_id).delete()
except RpcError as exc:
show_error(title="Sign Out Failed", message=exc.message)
return
self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.")
self.signout_button.configure(state="disabled")
+6
View File
@@ -0,0 +1,6 @@
{
"id": "autodesk",
"name": "Autodesk Forma",
"version": "0.1.0",
"exec": "bonsaiviewer-autodesk"
}
@@ -0,0 +1,81 @@
# PyInstaller spec for the Bonsai Viewer Autodesk connector (Tk + CustomTkinter).
#
# The connector talks JSON-RPC over stdio, so `console=True` is required to
# attach stdin/stdout on Windows. The Bonsai Viewer is expected to spawn the
# connector with the OS's "hide console window" flag on Windows
# (CREATE_NO_WINDOW) so end users never see a console pop up.
from pathlib import Path
PROJECT_ROOT = Path(SPECPATH).resolve().parent
# keyring uses entry points for backends — PyInstaller can't trace them
# without hints. Bundle every backend; the right one is picked at runtime
# per OS.
HIDDEN_IMPORTS = [
"keyring.backends.SecretService",
"keyring.backends.macOS",
"keyring.backends.Windows",
"keyring.backends.fail",
"keyring.backends.chainer",
]
a = Analysis(
[str(PROJECT_ROOT / "bonsaiviewer_autodesk" / "__main__.py")],
pathex=[str(PROJECT_ROOT)],
binaries=[],
datas=[],
hiddenimports=HIDDEN_IMPORTS,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Test / docs.
"test", "unittest", "pydoc_data",
# Protocols / formats we never touch. (email, html, http.cookies and
# http.cookiejar are required by http.server / httpx and must stay.)
"xmlrpc", "sqlite3", "ftplib", "imaplib", "poplib", "nntplib",
"smtplib", "telnetlib", "wsgiref",
# Concurrency we never use (asyncio is needed by httpx → anyio).
"multiprocessing", "concurrent.futures.process",
# Build / packaging tools.
"setuptools", "pip", "distutils", "ensurepip", "lib2to3",
# Heavy stdlib bits with no callers.
"decimal", "_decimal",
# tkinter test modules.
"tkinter.test", "test.test_tk",
],
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="bonsaiviewer-autodesk",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="bonsaiviewer-autodesk",
)
@@ -0,0 +1,120 @@
"""Build the Autodesk connector bundle for the current OS.
Each OS builds on itself (PyInstaller does not cross-compile). The output is a
single zip ready to drop into the Bonsai Viewer connectors directory:
dist/autodesk-<os>-<arch>.zip
autodesk/
connector.json
bonsaiviewer-autodesk[.exe]
_internal/...
Usage:
pip install -e ".[build]"
python packaging/build.py
"""
from __future__ import annotations
import json
import platform
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PACKAGING_DIR = PROJECT_ROOT / "packaging"
SPEC_FILE = PACKAGING_DIR / "bonsaiviewer-autodesk.spec"
DIST_DIR = PROJECT_ROOT / "dist"
BUILD_DIR = PROJECT_ROOT / "build"
CONNECTOR_FOLDER_NAME = "autodesk"
PYINSTALLER_OUTPUT_NAME = "bonsaiviewer-autodesk"
def _platform_tag() -> str:
system = platform.system()
if system == "Darwin":
os_name = "macos"
elif system == "Windows":
os_name = "windows"
else:
os_name = system.lower()
machine = platform.machine().lower()
if machine in {"amd64", "x86_64"}:
arch = "x86_64"
elif machine in {"arm64", "aarch64"}:
arch = "arm64"
else:
arch = machine
return f"{os_name}-{arch}"
def _clean() -> None:
for path in (DIST_DIR, BUILD_DIR):
if path.exists():
shutil.rmtree(path)
def _run_pyinstaller() -> Path:
subprocess.check_call(
[
sys.executable,
"-m",
"PyInstaller",
str(SPEC_FILE),
"--noconfirm",
"--distpath",
str(DIST_DIR),
"--workpath",
str(BUILD_DIR),
],
cwd=PROJECT_ROOT,
)
produced = DIST_DIR / PYINSTALLER_OUTPUT_NAME
if not produced.is_dir():
raise SystemExit(f"PyInstaller did not produce expected folder: {produced}")
return produced
def _assemble_connector_folder(pyinstaller_output: Path) -> Path:
connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
if connector_dir.exists():
shutil.rmtree(connector_dir)
pyinstaller_output.rename(connector_dir)
# The source-controlled connector.json uses the bare entry-point name so
# `pip install -e .` works for development. For the bundled folder, the
# binary lives next to connector.json, so rewrite `exec` to a relative path.
manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8"))
binary_name = "bonsaiviewer-autodesk.exe" if platform.system() == "Windows" else "bonsaiviewer-autodesk"
manifest["exec"] = f"./{binary_name}"
(connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return connector_dir
def _zip_connector_folder(tag: str) -> Path:
archive_base = DIST_DIR / f"{CONNECTOR_FOLDER_NAME}-{tag}"
return Path(shutil.make_archive(str(archive_base), "zip", DIST_DIR, CONNECTOR_FOLDER_NAME))
def main() -> int:
tag = _platform_tag()
print(f"Building Autodesk connector for {tag}")
_clean()
pyinstaller_output = _run_pyinstaller()
connector_dir = _assemble_connector_folder(pyinstaller_output)
archive = _zip_connector_folder(tag)
print(f"Connector folder: {connector_dir}")
print(f"Distribution zip: {archive}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "bonsaiviewer-autodesk"
version = "0.1.0"
description = "Autodesk cloud connector for Bonsai Viewer"
readme = { text = "Autodesk cloud connector for Bonsai Viewer. See src/bonsaiviewer/docs/connectors/autodesk.rst in the IfcOpenShell repository.", content-type = "text/x-rst" }
requires-python = ">=3.11"
dependencies = [
"customtkinter>=5.2",
"httpx>=0.27",
"keyring>=25.2"
]
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
test = ["pytest>=8"]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
include = ["bonsaiviewer_autodesk*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[project.scripts]
bonsaiviewer-autodesk = "bonsaiviewer_autodesk.__main__:main"
@@ -0,0 +1,72 @@
"""Shared fixtures for the bonsaiviewer-autodesk test suite."""
from __future__ import annotations
import keyring
import keyring.backend
import keyring.errors
import pytest
from bonsaiviewer_autodesk import cache as cache_module
from bonsaiviewer_autodesk import settings as settings_module
@pytest.fixture
def cache_dir(tmp_path, monkeypatch):
"""Redirect ``bonsaiviewer_autodesk.cache`` at an isolated tmp directory.
Patches ``cache_root`` itself rather than ``XDG_CACHE_HOME`` so the
redirect holds on every platform on macOS/Windows ``cache_root`` ignores
the XDG variables. Returns the directory ``cache_root()`` now resolves to.
"""
root = tmp_path / "cache" / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(cache_module, "cache_root", lambda: root)
return root
@pytest.fixture
def config_dir(tmp_path, monkeypatch):
"""Redirect ``bonsaiviewer_autodesk.settings`` at an isolated tmp directory.
Patches ``config_root`` directly (platform-independent). Returns the
directory ``config_root()`` now resolves to.
"""
root = tmp_path / "config" / "bonsaiviewer-autodesk"
root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(settings_module, "config_root", lambda: root)
return root
class InMemoryKeyring(keyring.backend.KeyringBackend):
"""A keyring backend that keeps secrets in a dict — never touches the OS."""
priority = 1 # type: ignore[assignment]
def __init__(self) -> None:
super().__init__()
self._store: dict[tuple[str, str], str] = {}
def get_password(self, service: str, username: str) -> str | None:
return self._store.get((service, username))
def set_password(self, service: str, username: str, password: str) -> None:
self._store[(service, username)] = password
def delete_password(self, service: str, username: str) -> None:
try:
del self._store[(service, username)]
except KeyError as exc:
raise keyring.errors.PasswordDeleteError("not found") from exc
@pytest.fixture
def memory_keyring():
"""Swap in the in-memory keyring backend for the duration of a test."""
backend = InMemoryKeyring()
previous = keyring.get_keyring()
keyring.set_keyring(backend)
try:
yield backend
finally:
keyring.set_keyring(previous)
@@ -0,0 +1,676 @@
"""Tests for the Autodesk auth + APS client (``bonsaiviewer_autodesk.autodesk``).
HTTP is mocked through the injectable ``transport`` seam using
``httpx.MockTransport``; time through the injectable ``now`` clock; and the
OAuth redirect through the injectable ``callback_waiter``.
"""
from __future__ import annotations
import datetime as dt
import socket
import threading
import time
from collections import deque
import httpx
import keyring.errors
import pytest
from bonsaiviewer_autodesk import autodesk
from bonsaiviewer_autodesk.rpc import RpcError
# --- HTTP routing ------------------------------------------------------------
def _build_response(spec: dict) -> httpx.Response:
kwargs = {key: spec[key] for key in ("json", "text", "content", "headers") if key in spec}
return httpx.Response(spec.get("status", 200), **kwargs)
class Router:
"""A tiny httpx.MockTransport router.
Routes match on HTTP method plus a substring of the request URL, in
declaration order. Pass multiple specs to a route to return them in turn
(the last one repeats); every request is recorded on ``requests``.
"""
def __init__(self) -> None:
self._routes: list[tuple[str, str, deque]] = []
self.requests: list[httpx.Request] = []
def add(self, method: str, contains: str, *specs: dict) -> "Router":
self._routes.append((method, contains, deque(specs or ({},))))
return self
def _handle(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
for method, contains, specs in self._routes:
if request.method == method and contains in str(request.url):
spec = specs[0] if len(specs) == 1 else specs.popleft()
return _build_response(spec)
return httpx.Response(404, text=f"unrouted {request.method} {request.url}")
@property
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self._handle)
def count(self, method: str, contains: str) -> int:
return sum(
1 for r in self.requests if r.method == method and contains in str(r.url)
)
# --- fakes -------------------------------------------------------------------
class FakeTokenStore:
"""In-memory stand-in for KeyringTokenStore."""
def __init__(self, initial: dict | None = None) -> None:
self.value = initial
def load(self) -> dict | None:
return self.value
def save(self, value: dict) -> None:
self.value = value
def delete(self) -> None:
self.value = None
class FakeAuth:
"""Minimal AuthSessionService stand-in for ApsClient tests."""
def ensure_access_token(self) -> str:
return "fake-token"
FIXED_NOW = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
def iso(offset_seconds: int) -> str:
return (FIXED_NOW + dt.timedelta(seconds=offset_seconds)).isoformat()
def stored_token_dict(*, access_offset: int, refresh_offset: int) -> dict:
return {
"client_id": "cid",
"access_token": "current-access",
"refresh_token": "current-refresh",
"access_token_expires_at_utc": iso(access_offset),
"refresh_token_expires_at_utc": iso(refresh_offset),
"scope": "data:read",
}
def make_auth(
*,
router: Router | None = None,
token_store: FakeTokenStore | None = None,
callback_waiter=None,
callback_url: str = "http://localhost:8080/",
) -> autodesk.AuthSessionService:
return autodesk.AuthSessionService(
client_id="cid",
callback_url=callback_url,
scope="data:read",
token_store=token_store or FakeTokenStore(),
transport=(router or Router()).transport,
now=lambda: FIXED_NOW,
callback_waiter=callback_waiter,
)
def make_client(router: Router) -> autodesk.ApsClient:
return autodesk.ApsClient(FakeAuth(), transport=router.transport)
# --- pure helpers ------------------------------------------------------------
def test_base64url_strips_padding():
assert autodesk._base64url(b"\x00") == "AA"
assert "=" not in autodesk._base64url(b"\x00\x00")
def test_generate_code_verifier_is_url_safe_and_unique():
verifier = autodesk.generate_code_verifier()
assert not set(verifier) & set("=+/")
assert autodesk.generate_code_verifier() != autodesk.generate_code_verifier()
def test_generate_code_challenge_matches_rfc7636_vector():
# RFC 7636 Appendix B test vector.
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
assert (
autodesk.generate_code_challenge(verifier)
== "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
)
def test_parse_storage_id_splits_bucket_and_object():
bucket, obj = autodesk.ApsClient._parse_storage_id(
"urn:adsk.objects:os.object:wip.dm.prod/abc-123.ifc"
)
assert bucket == "wip.dm.prod"
assert obj == "abc-123.ifc"
@pytest.mark.parametrize(
"bad",
[
"not-a-urn",
"urn:adsk.objects:os.object:bucketonly",
"urn:adsk.objects:os.object:/object",
"urn:adsk.objects:os.object:bucket/",
],
)
def test_parse_storage_id_rejects_malformed(bad):
with pytest.raises(RpcError):
autodesk.ApsClient._parse_storage_id(bad)
def test_entry_extracts_fields():
item = {
"id": "x",
"type": "items",
"attributes": {
"displayName": "Model.ifc",
"extension": {"type": "items:autodesk.bim360:File"},
},
}
assert autodesk.ApsClient._entry(item) == {
"id": "x",
"type": "items",
"display_name": "Model.ifc",
"name": None,
"extension_type": "items:autodesk.bim360:File",
}
def test_relationship_id_handles_dict_list_and_missing():
data = {
"relationships": {
"parent": {"data": {"id": "p1"}},
"files": {"data": [{"id": "f1"}, {"id": "f2"}]},
}
}
assert autodesk.ApsClient._relationship_id(data, "parent") == "p1"
assert autodesk.ApsClient._relationship_id(data, "files") == "f1"
assert autodesk.ApsClient._relationship_id(data, "missing") is None
def test_entry_name_matches_is_case_insensitive():
assert autodesk.ApsClient._entry_name_matches({"display_name": "Model.IFC"}, "model.ifc")
assert autodesk.ApsClient._entry_name_matches({"name": "Model.IFC"}, "model.ifc")
assert not autodesk.ApsClient._entry_name_matches({"display_name": "other.ifc"}, "model.ifc")
# --- KeyringTokenStore -------------------------------------------------------
def test_keyring_token_store_round_trip(memory_keyring):
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
assert store.load() is None
store.save({"access_token": "abc"})
assert store.load() == {"access_token": "abc"}
store.delete()
assert store.load() is None
def test_keyring_token_store_delete_missing_is_noop(memory_keyring):
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
store.delete() # no entry to delete — must not raise
def test_keyring_missing_backend_surfaces_friendly_error(monkeypatch):
def boom(*_args, **_kwargs):
raise keyring.errors.NoKeyringError("no backend")
monkeypatch.setattr(autodesk.keyring, "get_password", boom)
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
with pytest.raises(RpcError) as excinfo:
store.load()
assert "keyring" in excinfo.value.message.lower()
# --- token lifecycle / injected clock ---------------------------------------
def test_ensure_access_token_returns_unexpired_token_without_http():
store = FakeTokenStore(stored_token_dict(access_offset=3600, refresh_offset=100_000))
router = Router()
auth = make_auth(router=router, token_store=store)
assert auth.ensure_access_token() == "current-access"
assert router.requests == []
def test_ensure_access_token_refreshes_when_access_expired():
store = FakeTokenStore(stored_token_dict(access_offset=-100, refresh_offset=100_000))
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "refreshed-access",
"refresh_token": "refreshed-refresh",
"expires_in": 3600,
"refresh_token_expires_in": 200_000,
}
},
)
auth = make_auth(router=router, token_store=store)
assert auth.ensure_access_token() == "refreshed-access"
assert store.value["access_token"] == "refreshed-access"
assert router.count("POST", "/authentication/v2/token") == 1
def test_ensure_access_token_logs_in_when_both_tokens_expired(monkeypatch):
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
store = FakeTokenStore() # nothing stored at all
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "logged-in-access",
"refresh_token": "logged-in-refresh",
"expires_in": 3600,
}
},
)
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "auth-code")
assert auth.ensure_access_token() == "logged-in-access"
assert store.value["access_token"] == "logged-in-access"
def test_token_from_payload_uses_injected_clock():
auth = make_auth()
token = auth._token_from_payload(
{"access_token": "a", "refresh_token": "r", "expires_in": 3600}
)
assert token.access_token_expires_at_utc == iso(3600 - 30)
# Default refresh TTL is 15 days, minus the same 30s safety margin.
assert token.refresh_token_expires_at_utc == iso(15 * 24 * 60 * 60 - 30)
def test_login_interactive_rejects_non_local_callback():
auth = make_auth(callback_url="https://example.com/callback")
with pytest.raises(RpcError, match="Callback URL"):
auth.login_interactive()
def test_login_interactive_exchanges_code_for_token(monkeypatch):
opened: list[str] = []
monkeypatch.setattr(autodesk.webbrowser, "open", lambda url: opened.append(url))
store = FakeTokenStore()
router = Router().add(
"POST",
"/authentication/v2/token",
{
"json": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"expires_in": 3600,
}
},
)
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "the-code")
token = auth.login_interactive()
assert token.access_token == "fresh-access"
assert store.value["access_token"] == "fresh-access"
assert opened and opened[0].startswith(autodesk.AuthSessionService.authorize_endpoint)
def test_token_exchange_failure_raises_rpc_error(monkeypatch):
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
router = Router().add(
"POST", "/authentication/v2/token", {"status": 400, "text": "invalid_grant"}
)
auth = make_auth(router=router, callback_waiter=lambda *_a: "the-code")
with pytest.raises(RpcError, match="invalid_grant"):
auth.login_interactive()
# --- wait_for_oauth_callback (real loopback socket) --------------------------
def _free_port() -> int:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _get_with_retry(url: str, params: dict, timeout: float = 5.0) -> None:
"""Fire one GET, retrying only while the server has not yet bound."""
deadline = time.time() + timeout
while True:
try:
httpx.get(url, params=params)
return
except httpx.ConnectError:
if time.time() > deadline:
raise
time.sleep(0.02)
def drive_callback(expected_state: str, query: dict, path: str = "/cb") -> dict:
"""Run ``wait_for_oauth_callback`` in a thread and fire one redirect at it."""
port = _free_port()
outcome: dict = {}
def server() -> None:
try:
outcome["code"] = autodesk.wait_for_oauth_callback(
"127.0.0.1", port, path, expected_state
)
except BaseException as exc: # noqa: BLE001 - re-raised to the test
outcome["error"] = exc
thread = threading.Thread(target=server, daemon=True)
thread.start()
_get_with_retry(f"http://127.0.0.1:{port}{path}", query)
thread.join(timeout=5)
return outcome
def test_wait_for_oauth_callback_returns_authorization_code():
outcome = drive_callback("state-123", {"state": "state-123", "code": "the-code"})
assert outcome.get("code") == "the-code"
def test_wait_for_oauth_callback_rejects_state_mismatch():
outcome = drive_callback("expected-state", {"state": "tampered", "code": "c"})
assert isinstance(outcome.get("error"), RpcError)
assert "state mismatch" in outcome["error"].message.lower()
def test_wait_for_oauth_callback_reports_oauth_error():
outcome = drive_callback("state-123", {"state": "state-123", "error": "access_denied"})
assert isinstance(outcome.get("error"), RpcError)
assert "access_denied" in outcome["error"].message
def test_wait_for_oauth_callback_requires_a_code():
outcome = drive_callback("state-123", {"state": "state-123"})
assert isinstance(outcome.get("error"), RpcError)
assert "authorization code" in outcome["error"].message.lower()
# --- ApsClient browsing ------------------------------------------------------
def _hub(hub_id: str, name: str) -> dict:
return {
"id": hub_id,
"attributes": {"name": name, "extension": {"type": "hubs:autodesk.core:Hub"}},
}
def _project(project_id: str, name: str) -> dict:
return {
"id": project_id,
"attributes": {
"name": name,
"extension": {"type": "projects:autodesk.bim360:Project"},
},
"relationships": {"rootFolder": {"data": {"id": f"root-{project_id}"}}},
}
def test_list_hubs_sorts_case_insensitively():
router = Router().add(
"GET",
"/project/v1/hubs",
{"json": {"data": [_hub("h2", "Beta"), _hub("h1", "alpha")]}},
)
hubs = make_client(router).list_hubs()
assert [h["name"] for h in hubs] == ["alpha", "Beta"]
assert hubs[0]["id"] == "h1"
def test_list_projects_follows_pagination():
router = Router()
router.add(
"GET",
"page=2",
{"json": {"data": [_project("p2", "Zeta")], "links": {}}},
)
router.add(
"GET",
"/hubs/h/projects",
{
"json": {
"data": [_project("p1", "Alpha")],
"links": {
"next": {
"href": "https://developer.api.autodesk.com/project/v1/hubs/h/projects?page=2"
}
},
}
},
)
projects = make_client(router).list_projects("h")
assert [p["id"] for p in projects] == ["p1", "p2"]
assert projects[0]["root_folder_id"] == "root-p1"
def test_get_item_returns_tip_details():
router = Router().add(
"GET",
"/items/",
{
"json": {
"data": {
"id": "item-1",
"attributes": {"displayName": "Model.ifcfed", "hidden": False},
"relationships": {
"parent": {"data": {"id": "folder-1"}},
"tip": {"data": {"id": "v3"}},
},
},
"included": [
{
"type": "versions",
"id": "v3",
"attributes": {
"versionNumber": 3,
"lastModifiedTime": "2026-01-01T00:00:00Z",
"lastModifiedUserName": "Dion",
},
"relationships": {
"storage": {
"data": {"id": "urn:adsk.objects:os.object:b/o"}
}
},
}
],
}
},
)
item = make_client(router).get_item("proj-1", "item-1")
assert item["hidden"] is False
assert item["version_id"] == "v3"
assert item["storage_id"] == "urn:adsk.objects:os.object:b/o"
assert item["version_number"] == 3
assert item["parent_folder_id"] == "folder-1"
assert item["last_modified_user_name"] == "Dion"
def test_get_item_without_tip_is_treated_as_hidden():
router = Router().add(
"GET",
"/items/",
{"json": {"data": {"id": "item-1", "attributes": {}, "relationships": {}}}},
)
item = make_client(router).get_item("proj-1", "item-1")
assert item["hidden"] is True
assert item["storage_id"] is None
assert item["version_id"] is None
def test_list_folder_contents_applies_extension_filter():
router = Router().add(
"GET",
"/contents",
{
"json": {
"data": [
{
"id": "f1",
"type": "folders",
"attributes": {"name": "Sub", "extension": {"type": "t"}},
},
{
"id": "i1",
"type": "items",
"attributes": {"displayName": "keep.ifcfed", "extension": {}},
},
{
"id": "i2",
"type": "items",
"attributes": {"displayName": "skip.txt", "extension": {}},
},
],
"links": {},
}
},
)
entries = make_client(router).list_folder_contents(
"proj",
"folder",
extension_filter=lambda e: (e["display_name"] or "").endswith(".ifcfed"),
)
ids = {e["id"] for e in entries}
assert ids == {"f1", "i1"} # folders kept, non-.ifcfed item dropped
def test_get_json_maps_http_error_to_rpc_error():
router = Router().add(
"GET", "/project/v1/hubs", {"status": 403, "text": "Forbidden: bad token"}
)
with pytest.raises(RpcError, match="Forbidden"):
make_client(router).list_hubs()
# --- download / upload -------------------------------------------------------
def test_download_storage_to_file_writes_content_and_reports_progress(tmp_path):
router = Router()
router.add(
"GET",
"/signeds3download",
{"json": {"url": "https://signed.example/blob"}},
)
router.add("GET", "signed.example/blob", {"content": b"hello world"})
dest = tmp_path / "out.bin"
seen: list = []
make_client(router).download_storage_to_file(
"urn:adsk.objects:os.object:bucket/object",
dest,
progress=lambda name, pct, done, total: seen.append((name, pct, done, total)),
)
assert dest.read_bytes() == b"hello world"
assert seen[-1][1] == 100 # final progress callback reports 100%
def test_signed_download_url_missing_raises():
router = Router().add("GET", "/signeds3download", {"json": {}})
with pytest.raises(RpcError, match="URL"):
make_client(router).download_storage_to_file(
"urn:adsk.objects:os.object:bucket/object", "/tmp/ignored"
)
def _upload_router() -> Router:
router = Router()
router.add(
"POST", "/storage", {"json": {"data": {"id": "urn:adsk.objects:os.object:bk/obj"}}}
)
router.add(
"GET",
"/signeds3upload",
{"json": {"uploadKey": "ukey", "urls": ["https://up.example/part1"]}},
)
router.add("PUT", "up.example/part1", {"status": 200})
router.add("POST", "/signeds3upload", {"status": 200, "json": {}})
return router
def test_upload_file_creates_new_item_when_folder_is_empty(tmp_path):
local = tmp_path / "model.ifc"
local.write_bytes(b"x" * 1024)
router = _upload_router()
router.add("GET", "/contents", {"json": {"data": [], "links": {}}})
router.add(
"POST",
"/items",
{
"json": {
"data": {"id": "new-item"},
"included": [
{
"type": "versions",
"id": "v1",
"attributes": {
"versionNumber": 1,
"lastModifiedTime": "2026-01-01T00:00:00Z",
"lastModifiedUserName": "Dion",
},
}
],
}
},
)
result = make_client(router).upload_file_to_folder(
"proj", "folder", local, display_name="model.ifc"
)
assert result["item_id"] == "new-item"
assert result["version_id"] == "v1"
assert result["version_number"] == 1
assert router.count("PUT", "up.example/part1") == 1
def test_upload_file_creates_a_version_when_item_exists(tmp_path):
local = tmp_path / "model.ifc"
local.write_bytes(b"x" * 1024)
router = _upload_router()
router.add(
"GET",
"/contents",
{
"json": {
"data": [
{
"id": "existing-item",
"type": "items",
"attributes": {"displayName": "model.ifc", "extension": {}},
}
],
"links": {},
}
},
)
router.add(
"POST",
"/versions",
{"json": {"data": {"id": "v7", "attributes": {"versionNumber": 7}}}},
)
result = make_client(router).upload_file_to_folder(
"proj", "folder", local, display_name="model.ifc"
)
assert result["item_id"] == "existing-item"
assert result["version_id"] == "v7"
assert result["version_number"] == 7
def test_upload_rejects_missing_local_file(tmp_path):
with pytest.raises(RpcError, match="does not exist"):
make_client(Router()).upload_file_to_folder(
"proj", "folder", tmp_path / "missing.ifc"
)
@@ -0,0 +1,83 @@
"""Tests for the on-disk cache layout (``bonsaiviewer_autodesk.cache``)."""
from __future__ import annotations
from bonsaiviewer_autodesk import cache
def test_cache_root_uses_xdg_on_linux(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Linux")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
root = cache.cache_root()
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_cache_root_uses_localappdata_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Windows")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata"))
root = cache.cache_root()
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" / "Cache"
assert root.is_dir()
def test_cache_root_uses_library_caches_on_macos(tmp_path, monkeypatch):
monkeypatch.setattr(cache.platform, "system", lambda: "Darwin")
monkeypatch.setattr(cache.Path, "home", lambda: tmp_path / "home")
root = cache.cache_root()
assert root == tmp_path / "home" / "Library" / "Caches" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_ifcfed_dir_is_deterministic(cache_dir):
first = cache.ifcfed_dir("project-1", "item-1")
second = cache.ifcfed_dir("project-1", "item-1")
assert first == second
def test_ifcfed_dir_varies_with_inputs(cache_dir):
assert cache.ifcfed_dir("p", "item-1") != cache.ifcfed_dir("p", "item-2")
assert cache.ifcfed_dir("p1", "item") != cache.ifcfed_dir("p2", "item")
def test_model_dir_is_per_version(cache_dir):
v1 = cache.model_dir("p", "item", "v1")
v2 = cache.model_dir("p", "item", "v2")
assert v1 != v2
assert cache.model_dir("p", "item", "v1") == v1
def test_prepare_sole_child_dir_wipes_existing_contents(cache_dir):
directory = cache.ifcfed_dir("p", "item")
directory.mkdir(parents=True)
(directory / "stale.txt").write_text("old")
result = cache.prepare_sole_child_dir(directory)
assert result == directory
assert directory.is_dir()
assert list(directory.iterdir()) == []
def test_prepare_sole_child_dir_creates_when_absent(cache_dir):
directory = cache.model_dir("p", "item", "v1")
assert not directory.exists()
cache.prepare_sole_child_dir(directory)
assert directory.is_dir()
def test_manifest_round_trips(cache_dir):
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
ifcfed_path = directory / "model.ifcfed"
ifcfed_path.write_text("data")
manifest = {"connector": "autodesk", "item_id": "item", "hub_id": "h"}
manifest_path = cache.write_manifest(ifcfed_path, manifest)
assert manifest_path.name == "model.ifcfed.manifest"
assert cache.read_manifest(ifcfed_path) == manifest
def test_read_manifest_returns_none_when_absent(cache_dir):
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
assert cache.read_manifest(directory / "model.ifcfed") is None
@@ -0,0 +1,304 @@
"""Tests for the RPC handlers (``bonsaiviewer_autodesk.connector``).
The non-interactive handlers are exercised against a fake ``ApsClient`` so no
network or GUI is touched; ``run_with_progress`` is stubbed out.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from bonsaiviewer_autodesk import cache, connector, settings
from bonsaiviewer_autodesk.rpc import RpcError
# --- fakes / fixtures --------------------------------------------------------
def _fake_run_with_progress(_message, work, *, parent=None):
"""Run ``work`` inline with a no-op report — no worker thread, no GUI."""
return work(lambda *_args, **_kwargs: None)
class FakeAps:
"""Stand-in for ApsClient covering only what the handlers call."""
def __init__(self, *, item: dict | None = None, items: dict | None = None) -> None:
self._item = item
self._items = items or {}
self.downloaded: list[tuple] = []
self.uploaded: list[tuple] = []
def get_item(self, _project_id: str, item_id: str) -> dict:
if item_id in self._items:
return dict(self._items[item_id])
assert self._item is not None, f"no canned item for {item_id!r}"
return dict(self._item)
def download_storage_to_file(self, storage_id, destination_path, *, progress=None) -> None:
Path(destination_path).write_bytes(b"<ifc data>")
self.downloaded.append((storage_id, Path(destination_path)))
def upload_file_to_folder(
self, project_id, folder_id, local_path, *, display_name=None, progress=None
) -> dict:
self.uploaded.append((project_id, folder_id, Path(local_path), display_name))
return {
"item_id": "uploaded-item",
"version_id": "v1",
"version_number": 1,
"last_modified_time_utc": "2026-01-01T00:00:00Z",
"last_modified_user_name": "Dion",
}
@pytest.fixture
def make_connector(config_dir, cache_dir, monkeypatch):
"""Build an AutodeskConnector wired to a fake ApsClient."""
monkeypatch.setattr(connector, "run_with_progress", _fake_run_with_progress)
def _make(aps: FakeAps) -> connector.AutodeskConnector:
conn = connector.AutodeskConnector()
conn.aps = aps
conn.auth = object() # only identity matters to _require_aps
return conn
return _make
def _ifcfed_item(**overrides) -> dict:
item = {
"id": "item-1",
"hidden": False,
"storage_id": "urn:adsk.objects:os.object:bucket/object",
"display_name": "Project.ifcfed",
"version_id": "v1",
"version_number": 1,
"last_modified_time_utc": None,
"last_modified_user_name": None,
"parent_folder_id": "folder-1",
}
item.update(overrides)
return item
# --- pure helpers ------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
(0, "0 B"),
(512, "512 B"),
(1024, "1.0 KB"),
(1536, "1.5 KB"),
(5 * 1024 * 1024, "5.0 MB"),
],
)
def test_format_bytes(value, expected):
assert connector._format_bytes(value) == expected
def test_progress_detail_combines_percent_and_bytes():
detail = connector._progress_detail(45, 4_500_000, 10_000_000)
assert detail.startswith("45%, ")
assert " / " in detail
def test_progress_detail_percent_only():
assert connector._progress_detail(50, None, None) == "50%"
def test_progress_detail_bytes_without_total():
assert connector._progress_detail(None, 2048, None) == "2.0 KB"
def test_progress_detail_empty_when_nothing_known():
assert connector._progress_detail(None, None, None) == ""
def test_build_metadata_maps_all_fields():
metadata = connector._build_metadata(
{
"version_number": 3,
"last_modified_time_utc": "2026-01-01T00:00:00Z",
"last_modified_user_name": "Dion",
}
)
assert metadata == {
"revision": "v3",
"date": "2026-01-01T00:00:00Z",
"author": "Dion",
}
def test_build_metadata_omits_missing_fields():
assert connector._build_metadata({}) == {}
assert connector._build_metadata({"version_number": None}) == {}
def test_require_string_rejects_missing_or_blank():
assert connector._require_string({"k": "v"}, "k") == "v"
with pytest.raises(RpcError):
connector._require_string({"k": " "}, "k")
with pytest.raises(RpcError):
connector._require_string({}, "k")
def test_require_object_and_array_type_checks():
assert connector._require_object({"a": 1}, "p") == {"a": 1}
assert connector._require_array([1, 2], "p") == [1, 2]
with pytest.raises(RpcError):
connector._require_object([], "p")
with pytest.raises(RpcError):
connector._require_array({}, "p")
# --- credential wiring -------------------------------------------------------
def test_reload_credentials_without_client_id_leaves_aps_unset(config_dir):
conn = connector.AutodeskConnector()
assert conn.aps is None
assert conn.auth is None
with pytest.raises(RpcError, match="client id"):
conn._require_aps()
def test_reload_credentials_with_client_id_builds_aps(config_dir):
settings.save_client_id("my-client-id")
conn = connector.AutodeskConnector()
assert conn.aps is not None
assert conn.auth is not None
# --- pull_ifcfed -------------------------------------------------------------
def test_pull_ifcfed_downloads_and_writes_manifest(make_connector):
aps = FakeAps(item=_ifcfed_item())
conn = make_connector(aps)
result = conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
path = Path(result["path"])
assert path.exists()
assert path.name == "Project.ifcfed"
assert aps.downloaded # the fake actually got asked to download
manifest = cache.read_manifest(path)
assert manifest["connector"] == "autodesk"
assert manifest["item_id"] == "item-1"
assert manifest["hub_id"] == "h"
def test_pull_ifcfed_rejects_non_ifcfed_file(make_connector):
aps = FakeAps(item=_ifcfed_item(display_name="model.ifc"))
conn = make_connector(aps)
with pytest.raises(RpcError, match="ifcfed"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
def test_pull_ifcfed_rejects_deleted_item(make_connector):
aps = FakeAps(item=_ifcfed_item(hidden=True))
conn = make_connector(aps)
with pytest.raises(RpcError, match="deleted"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
def test_pull_ifcfed_requires_string_fields(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="item_id"):
conn.pull_ifcfed({"hub_id": "h", "project_id": "p"})
# --- pull_models -------------------------------------------------------------
def test_pull_models_skips_failures_with_none(make_connector):
aps = FakeAps(
items={
"good": _ifcfed_item(id="good", display_name="good.ifc"),
"gone": _ifcfed_item(id="gone", hidden=True),
}
)
conn = make_connector(aps)
models = [
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "good"}},
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "gone"}},
{"source": {"connector": "other", "project_id": "p", "item_id": "good"}},
]
results = conn.pull_models(models)
assert len(results) == 3
assert results[0] is not None and Path(results[0]["path"]).exists()
assert results[1] is None # hidden/deleted item
assert results[2] is None # wrong connector -> RpcError, swallowed
def test_pull_models_requires_a_json_array(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="array"):
conn.pull_models({"not": "an array"})
# --- push_ifcfed -------------------------------------------------------------
def test_push_ifcfed_uploads_and_caches_with_manifest(make_connector, tmp_path):
local = tmp_path / "local.ifcfed"
local.write_bytes(b"ifcfed bytes")
aps = FakeAps(item=_ifcfed_item())
conn = make_connector(aps)
result = conn.push_ifcfed(
{
"path": str(local),
"manifest": {
"connector": "autodesk",
"hub_id": "h",
"project_id": "p",
"item_id": "item-1",
},
}
)
assert aps.uploaded
cached = Path(result["path"])
assert cached.exists()
assert cache.read_manifest(cached)["item_id"] == "uploaded-item"
def test_push_ifcfed_rejects_missing_local_file(make_connector):
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="does not exist"):
conn.push_ifcfed(
{
"path": "/no/such/file.ifcfed",
"manifest": {
"connector": "autodesk",
"hub_id": "h",
"project_id": "p",
"item_id": "item-1",
},
}
)
def test_push_ifcfed_rejects_non_ifcfed_extension(make_connector, tmp_path):
local = tmp_path / "local.ifc"
local.write_bytes(b"data")
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="ifcfed"):
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "autodesk"}})
def test_push_ifcfed_rejects_foreign_connector_manifest(make_connector, tmp_path):
local = tmp_path / "local.ifcfed"
local.write_bytes(b"data")
conn = make_connector(FakeAps(item=_ifcfed_item()))
with pytest.raises(RpcError, match="connector"):
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "other"}})
+123
View File
@@ -0,0 +1,123 @@
"""Tests for the JSON-RPC host (``bonsaiviewer_autodesk.rpc``)."""
from __future__ import annotations
import io
import json
from bonsaiviewer_autodesk.rpc import (
JSONRPC_INTERNAL_ERROR,
JSONRPC_INVALID_PARAMS,
JSONRPC_INVALID_REQUEST,
JSONRPC_METHOD_NOT_FOUND,
JSONRPC_PARSE_ERROR,
JsonRpcHost,
RpcError,
)
def run(line: str, handlers: dict | None = None) -> tuple[list[dict], str]:
"""Drive a ``JsonRpcHost`` over in-memory streams.
Returns ``(responses, stderr_text)`` where ``responses`` is the parsed
JSON written to stdout, one element per line.
"""
out = io.StringIO()
err = io.StringIO()
JsonRpcHost(
handlers or {},
stdin=io.StringIO(line),
stdout=out,
stderr=err,
).run()
responses = [json.loads(piece) for piece in out.getvalue().splitlines() if piece]
return responses, err.getvalue()
def test_parse_error_for_invalid_json():
responses, _ = run("this is not json\n")
assert responses[0]["error"]["code"] == JSONRPC_PARSE_ERROR
assert responses[0]["id"] is None
def test_request_must_be_a_json_object():
responses, _ = run("[1, 2, 3]\n")
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
def test_wrong_jsonrpc_version_keeps_id():
responses, _ = run('{"jsonrpc": "1.0", "id": 7, "method": "go"}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
assert responses[0]["id"] == 7
def test_missing_method_string():
responses, _ = run('{"jsonrpc": "2.0", "id": 1}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
def test_params_must_be_object_or_array():
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "go", "params": 5}\n')
assert responses[0]["error"]["code"] == JSONRPC_INVALID_PARAMS
def test_unknown_method():
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "nope"}\n')
assert responses[0]["error"]["code"] == JSONRPC_METHOD_NOT_FOUND
def test_successful_result_round_trip():
responses, _ = run(
'{"jsonrpc": "2.0", "id": 42, "method": "echo", "params": {"x": 1}}\n',
{"echo": lambda params: params},
)
assert responses == [{"jsonrpc": "2.0", "id": 42, "result": {"x": 1}}]
def test_rpc_error_is_forwarded_with_code_and_data():
def handler(_params):
raise RpcError(JSONRPC_INTERNAL_ERROR, "boom", data={"detail": "x"})
responses, _ = run(
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
{"go": handler},
)
assert responses[0]["error"] == {
"code": JSONRPC_INTERNAL_ERROR,
"message": "boom",
"data": {"detail": "x"},
}
def test_unexpected_exception_becomes_internal_error():
def handler(_params):
raise ValueError("kaboom")
responses, stderr = run(
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
{"go": handler},
)
assert responses[0]["error"]["code"] == JSONRPC_INTERNAL_ERROR
assert responses[0]["error"]["message"] == "kaboom"
assert "Traceback" in stderr
def test_notification_runs_handler_but_writes_no_response():
calls: list[int] = []
responses, _ = run(
'{"jsonrpc": "2.0", "method": "go"}\n',
{"go": lambda _params: calls.append(1)},
)
assert calls == [1]
assert responses == []
def test_blank_lines_skipped_and_requests_processed_in_order():
line = (
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n'
"\n"
" \n"
'{"jsonrpc": "2.0", "id": 2, "method": "go"}\n'
)
responses, _ = run(line, {"go": lambda _params: "ok"})
assert [r["id"] for r in responses] == [1, 2]
@@ -0,0 +1,85 @@
"""Tests for settings persistence (``bonsaiviewer_autodesk.settings``)."""
from __future__ import annotations
import json
import pytest
from bonsaiviewer_autodesk import settings
def _write_settings_json(config_dir, data: dict) -> None:
(config_dir / "settings.json").write_text(json.dumps(data), encoding="utf-8")
def test_config_root_uses_xdg_on_linux(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Linux")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
root = settings.config_root()
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_config_root_uses_appdata_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Windows")
monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
root = settings.config_root()
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_config_root_uses_application_support_on_macos(tmp_path, monkeypatch):
monkeypatch.setattr(settings.platform, "system", lambda: "Darwin")
monkeypatch.setattr(settings.Path, "home", lambda: tmp_path / "home")
root = settings.config_root()
assert root == tmp_path / "home" / "Library" / "Application Support" / "bonsaiviewer-autodesk"
assert root.is_dir()
def test_save_and_load_client_id_strips_whitespace(config_dir):
settings.save_client_id(" abc123 ")
assert settings.load_client_id() == "abc123"
def test_load_client_id_empty_when_nothing_configured(config_dir):
assert settings.load_client_id() == ""
def test_callback_port_round_trips(config_dir):
settings.save_callback_port(9001)
assert settings.stored_callback_port() == 9001
def test_callback_port_defaults_when_unset(config_dir):
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_callback_port_defaults_on_non_numeric_value(config_dir):
_write_settings_json(config_dir, {"callback_port": "not-a-number"})
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_callback_port_defaults_on_out_of_range_value(config_dir):
_write_settings_json(config_dir, {"callback_port": 70000})
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_save_callback_port_rejects_out_of_range(config_dir):
with pytest.raises(ValueError):
settings.save_callback_port(0)
with pytest.raises(ValueError):
settings.save_callback_port(70000)
def test_corrupt_settings_file_is_treated_as_empty(config_dir):
(config_dir / "settings.json").write_text("{ not valid json", encoding="utf-8")
assert settings.load_client_id() == ""
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
def test_save_client_id_preserves_other_keys(config_dir):
settings.save_callback_port(9001)
settings.save_client_id("abc")
assert settings.stored_callback_port() == 9001
assert settings.load_client_id() == "abc"
+133
View File
@@ -0,0 +1,133 @@
# This file was generated with the assistance of an AI coding tool.
################################################################################
# #
# This file is part of IfcOpenShell. #
# #
# IfcOpenShell is free software: you can redistribute it and/or modify #
# it under the terms of the Lesser GNU General Public License as published by #
# the Free Software Foundation, either version 3.0 of the License, or #
# (at your option) any later version. #
# #
# IfcOpenShell is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# Lesser GNU General Public License for more details. #
# #
# You should have received a copy of the Lesser GNU General Public License #
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
# #
################################################################################
message("Running CMakeLists.txt in /src/bonsaiviewer")
set(QT_VERSION 6 CACHE STRING "Qt version")
# Qt6::CorePrivate is exposed differently across Qt versions: Qt 6.8 ships the
# target inside Qt6Core (no CorePrivate config package), while Qt 6.10 provides
# it only as a separate CorePrivate package. OPTIONAL_COMPONENTS finds that
# package where it exists without failing where it does not; the Qt6::CorePrivate
# target ends up available either way for the link step below.
find_package(Qt${QT_VERSION} REQUIRED
COMPONENTS Core Gui Widgets Svg
OPTIONAL_COMPONENTS CorePrivate
PATHS ${QT_DIR})
set(BONSAIVIEWER_FILES
${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.h
${CMAKE_CURRENT_SOURCE_DIR}/ViewerSettings.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ViewerSettings.h
${CMAKE_CURRENT_SOURCE_DIR}/SessionState.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SessionState.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/Measurement.cpp
${CMAKE_CURRENT_SOURCE_DIR}/Measurement.h
${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Style.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Style.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.h
${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsView.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Types.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/View.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/View.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/todo/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/todo/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Types.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/View.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/View.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Commands.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Commands.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/View.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/View.h
${CMAKE_CURRENT_SOURCE_DIR}/bonsaiviewer_resources.qrc
)
add_executable(BonsaiViewer ${BONSAIVIEWER_FILES})
set_target_properties(BonsaiViewer PROPERTIES
AUTOMOC ON
AUTORCC ON
WIN32_EXECUTABLE ON
MACOSX_BUNDLE ON
)
target_link_libraries(BonsaiViewer PRIVATE
IfcViewer
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::CorePrivate
Qt${QT_VERSION}::Gui
Qt${QT_VERSION}::Svg
Qt${QT_VERSION}::Widgets
)
install(TARGETS BonsaiViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
ifcopenshell_deploy_qt_runtime(BonsaiViewer)
+126
View File
@@ -0,0 +1,126 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "ElementRegistry.h"
#include "../ifcviewer/GeometryStreamer.h"
#include "../ifcviewer/SceneLoader.h"
#include "../ifcviewer/SidecarCache.h"
namespace bonsaiviewer {
ElementRegistry::ElementRegistry(QObject* parent)
: QObject(parent)
{
}
void ElementRegistry::bindLoader(SceneLoader* loader) {
loader_ = loader;
connect(loader, &SceneLoader::sidecarElementsReady,
this, &ElementRegistry::onSidecarElementsReady);
connect(loader, &SceneLoader::streamedElementsReady,
this, &ElementRegistry::onStreamedElementsReady);
}
void ElementRegistry::clear() {
elements_.clear();
}
void ElementRegistry::removeModel(uint32_t model_id) {
for (auto it = elements_.begin(); it != elements_.end();) {
if (it->second.model_id == model_id) {
it = elements_.erase(it);
} else {
++it;
}
}
}
std::vector<BasicElementInfo> ElementRegistry::basicElementInfoForModel(uint32_t model_id) const {
std::vector<BasicElementInfo> result;
result.reserve(elements_.size());
for (const auto& [object_id, info] : elements_) {
(void)object_id;
if (info.model_id != model_id) continue;
result.push_back(info);
}
return result;
}
std::optional<BasicElementInfo> ElementRegistry::findBasicElementInfo(uint32_t object_id) const {
auto it = elements_.find(object_id);
if (it == elements_.end()) return std::nullopt;
return it->second;
}
std::optional<express::Base> ElementRegistry::findEntity(uint32_t object_id) const {
if (!loader_) return std::nullopt;
auto info = findBasicElementInfo(object_id);
if (!info) return std::nullopt;
auto* file = loader_->ifcFile(info->model_id);
if (!file) return std::nullopt;
try {
auto instance = file->instance_by_id(info->ifc_id);
if (!instance) return std::nullopt;
return instance;
} catch (...) {
return std::nullopt;
}
}
void ElementRegistry::onSidecarElementsReady(uint32_t /*mid*/,
std::vector<PackedElementInfo> elements,
std::string string_table) {
auto str = [&](uint32_t offset, uint32_t length) -> QString {
if (length == 0 || offset + length > string_table.size()) return {};
return QString::fromStdString(string_table.substr(offset, length));
};
for (const auto& pe : elements) {
BasicElementInfo info;
info.object_id = pe.object_id;
info.model_id = pe.model_id;
info.ifc_id = pe.ifc_id;
info.parent_id = pe.parent_id;
info.guid = str(pe.guid_offset, pe.guid_length);
info.name = str(pe.name_offset, pe.name_length);
info.type = str(pe.type_offset, pe.type_length);
elements_[info.object_id] = info;
}
}
void ElementRegistry::onStreamedElementsReady(uint32_t /*mid*/, std::vector<ElementInfo> elements) {
for (const auto& e : elements) {
BasicElementInfo info;
info.object_id = e.object_id;
info.model_id = e.model_id;
info.ifc_id = e.ifc_id;
info.parent_id = e.parent_id;
info.guid = QString::fromStdString(e.guid);
info.name = QString::fromStdString(e.name);
info.type = QString::fromStdString(e.type);
elements_[info.object_id] = info;
}
}
} // namespace bonsaiviewer
+72
View File
@@ -0,0 +1,72 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_ELEMENTREGISTRY_H
#define IFCINTERFACE_ELEMENTREGISTRY_H
#include <QObject>
#include <QString>
#include "../ifcparse/express.h"
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
class SceneLoader;
struct PackedElementInfo;
struct ElementInfo;
namespace bonsaiviewer {
struct BasicElementInfo {
uint32_t object_id = 0;
uint32_t model_id = 0;
int ifc_id = 0;
int parent_id = 0;
QString guid;
QString name;
QString type;
};
class ElementRegistry : public QObject {
Q_OBJECT
public:
explicit ElementRegistry(QObject* parent = nullptr);
void bindLoader(SceneLoader* loader);
void clear();
void removeModel(uint32_t model_id);
std::vector<BasicElementInfo> basicElementInfoForModel(uint32_t model_id) const;
std::optional<BasicElementInfo> findBasicElementInfo(uint32_t object_id) const;
std::optional<express::Base> findEntity(uint32_t object_id) const;
private:
void onSidecarElementsReady(uint32_t mid,
std::vector<PackedElementInfo> elements,
std::string string_table);
void onStreamedElementsReady(uint32_t mid, std::vector<ElementInfo> elements);
SceneLoader* loader_ = nullptr;
std::unordered_map<uint32_t, BasicElementInfo> elements_;
};
} // namespace bonsaiviewer
#endif
+564
View File
@@ -0,0 +1,564 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "MainWindow.h"
#include "../ifcviewer/AppSettings.h"
#include "../ifcviewer/Federation.h"
#include "../ifcviewer/ViewportWindow.h"
#include "SessionState.h"
#include "components/Buttons.h"
#include "components/Panel.h"
#include "components/Style.h"
#include "components/Tabs.h"
#include "modules/models/Commands.h"
#include "modules/todo/Panel.h"
#include "modules/models/View.h"
#include "modules/models/Panel.h"
#include "modules/project/Commands.h"
#include "modules/project/RecentProjects.h"
#include "modules/properties/View.h"
#include "modules/properties/Panel.h"
#include "modules/settings/Dialog.h"
#include "modules/spatial_hierarchy/View.h"
#include "modules/spatial_hierarchy/Panel.h"
#include "modules/viewport/Commands.h"
#include "modules/viewport/Panel.h"
#include "modules/viewport/View.h"
#include <QAction>
#include <QDockWidget>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QShortcut>
#include <QSignalBlocker>
#include <QStackedWidget>
#include <QStatusBar>
#include <QProgressBar>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::shell {
MainWindow::MainWindow(QWidget* parent)
: QMainWindow(parent)
{
session_state_ = new bonsaiviewer::SessionState(this);
auto on_mutated = [this]() {
setWindowModified(true);
updateWindowTitle();
};
auto on_clean_state = [this]() {
setWindowModified(false);
updateWindowTitle();
};
connect(session_state_, &bonsaiviewer::SessionState::federationChanged, this, on_mutated);
connect(session_state_, &bonsaiviewer::SessionState::modelsChanged, this, on_mutated);
connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, on_clean_state);
connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, on_clean_state);
connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, on_clean_state);
// Every successful open/save (local or cloud) feeds the recent list.
auto remember_recent = [](const QString& path) {
modules::project::RecentProjects::add(path);
};
connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, remember_recent);
connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, remember_recent);
setupChrome();
setupViewport();
setupPanels();
setupStatus();
setupLoader();
setupRibbon();
resize(1720, 980);
}
void MainWindow::setupChrome() {
setObjectName("appWindow");
setDockOptions(QMainWindow::AllowNestedDocks |
QMainWindow::AllowTabbedDocks |
QMainWindow::GroupedDragging);
auto bind_shortcut = [this](const QKeySequence& sequence, auto fn) {
auto* shortcut = new QShortcut(sequence, this);
shortcut->setContext(Qt::WindowShortcut);
connect(shortcut, &QShortcut::activated, this, fn);
};
bind_shortcut(QKeySequence("Ctrl+Shift+L"), [this]() {
modules::viewport::commands::toggleDistance(*viewport_widget_->viewport());
});
bind_shortcut(QKeySequence("Ctrl+Shift+A"), [this]() {
modules::viewport::commands::toggleArea(*viewport_widget_->viewport());
});
bind_shortcut(QKeySequence("Ctrl+Shift+V"), [this]() {
modules::viewport::commands::toggleVolume(*viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_F), [this]() {
modules::viewport::commands::fly(*session_state_, *viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::Key_K), [this]() {
modules::viewport::commands::toggleSection(*session_state_, *viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_K), [this]() {
modules::viewport::commands::clearSection(*session_state_, *viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::Key_H), [this]() {
modules::viewport::commands::hideSelected(*viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_H), [this]() {
modules::viewport::commands::isolateSelected(*viewport_widget_->viewport());
});
bind_shortcut(QKeySequence(Qt::ALT | Qt::Key_H), [this]() {
modules::viewport::commands::showAll(*viewport_widget_->viewport());
});
updateWindowTitle();
}
QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) {
auto* button = components::buttons::makeButton(text, ":/icons/sidebar-expand.svg", this);
button->setCheckable(true);
button->setChecked(dock->isVisible());
connect(button, &QToolButton::toggled, dock, [dock](bool checked) {
dock->setVisible(checked);
if (checked) dock->raise();
});
connect(dock, &QDockWidget::visibilityChanged, button, [button](bool visible) {
const QSignalBlocker blocker(button);
button->setChecked(visible);
});
return button;
}
void MainWindow::populateRecentMenu(QMenu* menu) {
menu->clear();
const QStringList recent = modules::project::RecentProjects::list();
if (recent.isEmpty()) {
QAction* empty = menu->addAction("No Recent Projects");
empty->setEnabled(false);
return;
}
for (const QString& path : recent) {
QAction* action = menu->addAction(QFileInfo(path).fileName());
action->setToolTip(path);
connect(action, &QAction::triggered, this, [this, path]() {
const bool opened = modules::project::commands::openProjectPath(
*session_state_, *this, *viewport_widget_->viewport(), path);
// A recent entry that no longer loads is dropped so it stops
// cluttering the menu (e.g. moved/deleted file).
if (!opened && !QFileInfo::exists(path)) {
modules::project::RecentProjects::remove(path);
}
});
}
menu->addSeparator();
QAction* clear = menu->addAction("Clear Recent Projects");
connect(clear, &QAction::triggered, this, []() {
modules::project::RecentProjects::clear();
});
}
QWidget* MainWindow::buildHomeRibbonPage() {
auto* page = new QFrame(this);
page->setObjectName("ribbonPage");
auto* row = new QHBoxLayout(page);
row->setContentsMargins(6, 4, 6, 4);
row->setSpacing(0);
auto* new_project = components::buttons::makeButton("New Project", ":/icons/plus-square.svg", this);
connect(new_project, &QToolButton::clicked, this, [this]() {
modules::project::commands::newProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* open_project = components::buttons::makeButton("Open Project", ":/icons/download-square.svg", this);
connect(open_project, &QToolButton::clicked, this, [this]() {
modules::project::commands::openProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this);
connect(open_cloud, &QToolButton::clicked, this, [this]() {
modules::project::commands::openCloudProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* open_recent = components::buttons::makeButton("Open Recent", ":/icons/clock-rotate-right.svg", this);
auto* recent_menu = new QMenu(open_recent);
recent_menu->setToolTipsVisible(true);
open_recent->setMenu(recent_menu);
open_recent->setPopupMode(QToolButton::InstantPopup);
connect(recent_menu, &QMenu::aboutToShow, this, [this, recent_menu]() {
populateRecentMenu(recent_menu);
});
auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this);
connect(save_project, &QToolButton::clicked, this, [this]() {
modules::project::commands::saveProjectDialog(*session_state_, *this);
});
auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this);
connect(add_model, &QToolButton::clicked, this, [this]() {
modules::models::commands::addModel(*session_state_, *this);
});
auto* sync_from_cloud = components::buttons::makeButton("Sync From Cloud", ":/icons/refresh-double.svg", this);
connect(sync_from_cloud, &QToolButton::clicked, this, [this]() {
modules::project::commands::syncCloudProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* settings_button = components::buttons::makeButton("Settings", ":/icons/settings.svg", this);
connect(settings_button, &QToolButton::clicked, this, [this]() {
modules::settings::SettingsDialog dialog(session_state_, this);
dialog.exec();
});
components::buttons::addButtonGroups(row, {
components::buttons::makeButtonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project}, this),
components::buttons::makeButtonGroup("MODELS", {add_model, sync_from_cloud}, this),
components::buttons::makeButtonGroup("SETTINGS", {settings_button}, this),
});
row->addStretch(1);
return page;
}
QWidget* MainWindow::buildNavigateRibbonPage() {
auto* page = new QFrame(this);
page->setObjectName("ribbonPage");
auto* row = new QHBoxLayout(page);
row->setContentsMargins(2, 4, 2, 4);
row->setSpacing(0);
auto* set_home = components::buttons::makeButton("Set Home", ":/icons/home.svg", this);
connect(set_home, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::setHome(*session_state_, *viewport_widget_->viewport());
});
auto* go_home = components::buttons::makeButton("Go Home", ":/icons/home-alt.svg", this);
connect(go_home, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::goHome(*session_state_, *viewport_widget_->viewport());
});
auto* view_all = components::buttons::makeButton("View All", ":/icons/cube-scan.svg", this);
connect(view_all, &QToolButton::clicked, this, [this]() {
if (viewport_widget_) viewport_widget_->viewport()->viewAll();
});
auto* view_selected = components::buttons::makeButton("View Selected", ":/icons/cube-scan-solid.svg", this);
connect(view_selected, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::viewSelected(*viewport_widget_->viewport());
});
auto* plan_view = components::buttons::makeButton("Plan", ":/icons/planimetry.svg", this);
connect(plan_view, &QToolButton::clicked, this, [this]() {
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 90.0f);
});
auto* front_view = components::buttons::makeButton("Front", ":/icons/city.svg", this);
connect(front_view, &QToolButton::clicked, this, [this]() {
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(0.0f, 0.0f);
});
auto* side_view = components::buttons::makeButton("Side", ":/icons/building.svg", this);
connect(side_view, &QToolButton::clicked, this, [this]() {
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 0.0f);
});
auto* align_object = components::buttons::makeButton("Align Object", ":/icons/cellar.svg", this);
connect(align_object, &QToolButton::clicked, this, [this]() {
session_state_->setStatusMessage("Orientation", "Align to object coming soon");
});
auto* projection_button = components::buttons::makeButton("Perspective", ":/icons/perspective-view.svg", this);
connect(projection_button, &QToolButton::clicked, this, [this, projection_button]() {
if (!viewport_widget_) return;
viewport_widget_->viewport()->toggleProjection();
projection_button->setText(
viewport_widget_->viewport()->projectionOrtho() ? "Ortho" : "Perspective");
});
auto* fly_mode = components::buttons::makeButton("Fly", ":/icons/drone.svg", this);
connect(fly_mode, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::fly(*session_state_, *viewport_widget_->viewport());
});
auto* section_mode = components::buttons::makeButton("Section", ":/icons/cube-cut-with-curve.svg", this);
connect(section_mode, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::toggleSection(*session_state_, *viewport_widget_->viewport());
});
components::buttons::addButtonGroups(row, {
components::buttons::makeButtonGroup("CAMERA", {set_home, go_home, view_all, view_selected}, this),
components::buttons::makeButtonGroup("ORIENTATION", {plan_view, front_view, side_view, align_object, projection_button}, this),
components::buttons::makeButtonGroup("MODE", {fly_mode, section_mode}, this),
});
row->addStretch(1);
return page;
}
QWidget* MainWindow::buildInspectRibbonPage() {
auto* page = new QFrame(this);
page->setObjectName("ribbonPage");
auto* row = new QHBoxLayout(page);
row->setContentsMargins(2, 4, 2, 4);
row->setSpacing(0);
auto* hide_selected = components::buttons::makeButton("Hide", ":/icons/eye-closed.svg", this);
connect(hide_selected, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::hideSelected(*viewport_widget_->viewport());
});
auto* isolate_selected = components::buttons::makeButton("Isolate", ":/icons/eye-solid.svg", this);
connect(isolate_selected, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::isolateSelected(*viewport_widget_->viewport());
});
auto* show_all = components::buttons::makeButton("Show All", ":/icons/eye.svg", this);
connect(show_all, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::showAll(*viewport_widget_->viewport());
});
auto* invert_selection = components::buttons::makeButton("Invert", ":/icons/intersect.svg", this);
connect(invert_selection, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::invertVisibility(*viewport_widget_->viewport());
});
auto* distance = components::buttons::makeButton("Distance", ":/icons/select-edge3d.svg", this);
connect(distance, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::toggleDistance(*viewport_widget_->viewport());
});
auto* area = components::buttons::makeButton("Area", ":/icons/select-face3d.svg", this);
connect(area, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::toggleArea(*viewport_widget_->viewport());
});
auto* volume = components::buttons::makeButton("Volume", ":/icons/select-point3d.svg", this);
connect(volume, &QToolButton::clicked, this, [this]() {
modules::viewport::commands::toggleVolume(*viewport_widget_->viewport());
});
components::buttons::addButtonGroups(row, {
components::buttons::makeButtonGroup("SELECTION", {hide_selected, isolate_selected, show_all, invert_selection}, this),
components::buttons::makeButtonGroup("MEASURE", {distance, area, volume}, this),
});
row->addStretch(1);
return page;
}
QWidget* MainWindow::buildPanelsRibbonPage() {
auto* page = new QFrame(this);
page->setObjectName("ribbonPage");
auto* row = new QHBoxLayout(page);
row->setContentsMargins(2, 4, 2, 4);
row->setSpacing(0);
components::buttons::addButtonGroups(row, {
components::buttons::makeButtonGroup("DATA", {
makePanelToggle("Models", models_panel_),
makePanelToggle("Spatial", spatial_panel_),
makePanelToggle("Layers", layers_panel_),
makePanelToggle("Properties", properties_panel_)
}, this),
components::buttons::makeButtonGroup("QUERY", {
makePanelToggle("Views", stored_views_panel_),
makePanelToggle("Search", search_panel_),
makePanelToggle("Sheets", spreadsheet_panel_),
makePanelToggle("Audit", audit_panel_)
}, this),
components::buttons::makeButtonGroup("COLLABORATE", {
makePanelToggle("Clash", clash_panel_),
makePanelToggle("Issues", issues_panel_)
}, this),
});
row->addStretch(1);
return page;
}
void MainWindow::setupRibbon() {
auto* shell = new QFrame(this);
auto* shell_layout = new QVBoxLayout(shell);
shell_layout->setContentsMargins(0, 0, 0, 0);
shell_layout->setSpacing(0);
ribbon_tabs_ = new components::TabBar(shell);
ribbon_tabs_->addTab("Home");
ribbon_tabs_->addTab("Navigate");
ribbon_tabs_->addTab("Inspect");
ribbon_tabs_->addTab("Panels");
ribbon_tabs_->setCurrentIndex(0);
auto* ribbon_band = new QFrame(shell);
ribbon_band->setObjectName("ribbonBand");
auto* band_layout = new QVBoxLayout(ribbon_band);
band_layout->setContentsMargins(0, 0, 0, 0);
band_layout->setSpacing(0);
ribbon_pages_ = new QStackedWidget(ribbon_band);
ribbon_pages_->addWidget(buildHomeRibbonPage());
ribbon_pages_->addWidget(buildNavigateRibbonPage());
ribbon_pages_->addWidget(buildInspectRibbonPage());
ribbon_pages_->addWidget(buildPanelsRibbonPage());
band_layout->addWidget(ribbon_pages_);
shell_layout->addWidget(ribbon_tabs_);
shell_layout->addWidget(ribbon_band);
connect(ribbon_tabs_, &components::TabBar::currentChanged,
ribbon_pages_, &QStackedWidget::setCurrentIndex);
setMenuWidget(shell);
}
void MainWindow::setupViewport() {
viewport_widget_ = new modules::viewport::ViewportPanel(this);
setCentralWidget(viewport_widget_);
}
void MainWindow::setupPanels() {
models_panel_ = new modules::models::ModelsPanel(
session_state_, viewport_widget_->viewport(), this);
spatial_panel_ = new modules::spatial_hierarchy::SpatialHierarchyPanel(this);
properties_panel_ = new modules::properties::PropertiesPanel(this);
models_view_ = new modules::models::ModelsPanelView(models_panel_, session_state_, this);
spatial_view_ = new modules::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, session_state_, this);
properties_view_ = new modules::properties::PropertiesPanelView(properties_panel_, session_state_, this);
layers_panel_ = new components::Panel("Layers", new modules::todo::TodoPanel("Layers", this), this);
stored_views_panel_ = new components::Panel(
"Stored Views", new modules::todo::TodoPanel("Stored Views", this), this);
search_panel_ = new components::Panel(
"Search and Query", new modules::todo::TodoPanel("Search and Query", this), this);
spreadsheet_panel_ = new components::Panel(
"Spreadsheet", new modules::todo::TodoPanel("Spreadsheet", this), this);
audit_panel_ = new components::Panel("Audit", new modules::todo::TodoPanel("Audit", this), this);
clash_panel_ = new components::Panel("Clash", new modules::todo::TodoPanel("Clash", this), this);
issues_panel_ = new components::Panel("Issues", new modules::todo::TodoPanel("Issues", this), this);
addDockWidget(Qt::LeftDockWidgetArea, models_panel_);
addDockWidget(Qt::LeftDockWidgetArea, spatial_panel_);
splitDockWidget(models_panel_, spatial_panel_, Qt::Vertical);
addDockWidget(Qt::RightDockWidgetArea, properties_panel_);
addDockWidget(Qt::RightDockWidgetArea, layers_panel_);
addDockWidget(Qt::RightDockWidgetArea, stored_views_panel_);
addDockWidget(Qt::RightDockWidgetArea, search_panel_);
addDockWidget(Qt::RightDockWidgetArea, spreadsheet_panel_);
addDockWidget(Qt::RightDockWidgetArea, audit_panel_);
addDockWidget(Qt::RightDockWidgetArea, clash_panel_);
addDockWidget(Qt::RightDockWidgetArea, issues_panel_);
tabifyDockWidget(properties_panel_, layers_panel_);
tabifyDockWidget(layers_panel_, stored_views_panel_);
tabifyDockWidget(stored_views_panel_, search_panel_);
tabifyDockWidget(search_panel_, spreadsheet_panel_);
tabifyDockWidget(spreadsheet_panel_, audit_panel_);
tabifyDockWidget(audit_panel_, clash_panel_);
tabifyDockWidget(clash_panel_, issues_panel_);
properties_panel_->raise();
layers_panel_->hide();
stored_views_panel_->hide();
search_panel_->hide();
spreadsheet_panel_->hide();
audit_panel_->hide();
clash_panel_->hide();
issues_panel_->hide();
resizeDocks({models_panel_, properties_panel_}, {290, 330}, Qt::Horizontal);
resizeDocks({models_panel_, spatial_panel_}, {280, 240}, Qt::Vertical);
}
void MainWindow::setupStatus() {
status_mode_label_ = new QLabel("Ready", this);
status_selection_label_ = new QLabel("No selection", this);
status_perf_label_ = new QLabel(this);
status_progress_bar_ = new QProgressBar(this);
status_perf_label_->setVisible(AppSettings::instance().showStats());
status_progress_bar_->setMaximumWidth(200);
status_progress_bar_->setVisible(false);
statusBar()->setSizeGripEnabled(false);
statusBar()->addWidget(status_mode_label_);
statusBar()->addWidget(status_selection_label_, 1);
statusBar()->addPermanentWidget(status_perf_label_);
statusBar()->addPermanentWidget(status_progress_bar_);
connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) {
status_perf_label_->setVisible(show);
if (!show) status_perf_label_->clear();
});
connect(session_state_, &bonsaiviewer::SessionState::statusMessageChanged,
this, [this](const QString& mode, const QString& detail) {
status_mode_label_->setText(mode);
status_selection_label_->setText(detail);
});
connect(session_state_, &bonsaiviewer::SessionState::progressBegan, this, [this](const QString&) {
// Start in indeterminate mode (spinning bar). The first concrete
// setProgress(...) call below switches it to a determinate 0-100 bar.
status_progress_bar_->setRange(0, 0);
status_progress_bar_->setVisible(true);
});
connect(session_state_, &bonsaiviewer::SessionState::progressChanged, this, [this](int percent) {
if (status_progress_bar_->maximum() == 0) status_progress_bar_->setRange(0, 100);
status_progress_bar_->setValue(percent);
});
connect(session_state_, &bonsaiviewer::SessionState::progressEnded, this, [this]() {
status_progress_bar_->setVisible(false);
});
session_state_->setStatusMessage("Ready", "No selection");
}
void MainWindow::setupLoader() {
session_state_->createLoader(viewport_widget_->viewport());
viewport_view_ = new modules::viewport::ViewportView(
session_state_, viewport_widget_->viewport(), this);
// Load errors surface through SessionState as a session-level signal; the
// status text + progress are already cleared there, we only show the modal.
connect(session_state_, &bonsaiviewer::SessionState::loadError, this,
[this](const QString& message) {
QMessageBox::warning(this, "Bonsai Viewer", message);
});
connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this,
[this](const ViewportWindow::FrameStats& s) {
if (!status_perf_label_->isVisible()) return;
status_perf_label_->setText(
QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws")
.arg(s.fps, 0, 'f', 1)
.arg(s.frame_time_ms, 0, 'f', 1)
.arg(s.visible_objects)
.arg(s.total_objects)
.arg(s.visible_triangles)
.arg(s.total_triangles)
.arg(s.gl_draw_calls));
});
connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked,
this, [this](uint32_t object_id) {
session_state_->setSelectedObjectId(object_id);
session_state_->notifySelectionChanged();
});
}
void MainWindow::updateWindowTitle() {
auto* federation = session_state_->federation();
const QString project_path = federation->filePath();
if (project_path.isEmpty() && federation->models().empty()) {
setWindowTitle("Bonsai Viewer");
} else if (project_path.isEmpty()) {
setWindowTitle("untitled[*] - Bonsai Viewer");
} else {
setWindowTitle(QFileInfo(project_path).fileName() + "[*] - Bonsai Viewer");
}
}
} // namespace bonsaiviewer::shell
+96
View File
@@ -0,0 +1,96 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_SHELL_MAINWINDOW_H
#define IFCINTERFACE_SHELL_MAINWINDOW_H
#include <QHash>
#include <QMainWindow>
#include <QStringList>
class QLabel;
class QDockWidget;
class QMenu;
class QProgressBar;
class QStackedWidget;
class QToolButton;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::components { class TabBar; }
namespace bonsaiviewer::modules::models { class ModelsPanel; }
namespace bonsaiviewer::modules::models { class ModelsPanelView; }
namespace bonsaiviewer::modules::spatial_hierarchy { class SpatialHierarchyPanel; }
namespace bonsaiviewer::modules::spatial_hierarchy { class SpatialHierarchyPanelView; }
namespace bonsaiviewer::modules::properties { class PropertiesPanel; }
namespace bonsaiviewer::modules::properties { class PropertiesPanelView; }
namespace bonsaiviewer::modules::viewport { class ViewportView; }
namespace bonsaiviewer::modules::viewport { class ViewportPanel; }
namespace bonsaiviewer::shell {
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
private:
void setupChrome();
void setupRibbon();
void setupViewport();
void setupPanels();
void setupStatus();
void setupLoader();
QWidget* buildHomeRibbonPage();
QWidget* buildNavigateRibbonPage();
QWidget* buildInspectRibbonPage();
QWidget* buildPanelsRibbonPage();
void updateWindowTitle();
QToolButton* makePanelToggle(const QString& text, QDockWidget* dock);
// Rebuilds `menu` from the persisted recent-projects list. Wired to the
// menu's aboutToShow so it always reflects the current MRU state.
void populateRecentMenu(QMenu* menu);
private:
QLabel* status_mode_label_ = nullptr;
QLabel* status_selection_label_ = nullptr;
QLabel* status_perf_label_ = nullptr;
QProgressBar* status_progress_bar_ = nullptr;
bonsaiviewer::components::TabBar* ribbon_tabs_ = nullptr;
QStackedWidget* ribbon_pages_ = nullptr;
bonsaiviewer::modules::viewport::ViewportPanel* viewport_widget_ = nullptr;
bonsaiviewer::modules::viewport::ViewportView* viewport_view_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
bonsaiviewer::modules::models::ModelsPanel* models_panel_ = nullptr;
bonsaiviewer::modules::spatial_hierarchy::SpatialHierarchyPanel* spatial_panel_ = nullptr;
QDockWidget* layers_panel_ = nullptr;
bonsaiviewer::modules::properties::PropertiesPanel* properties_panel_ = nullptr;
QDockWidget* stored_views_panel_ = nullptr;
QDockWidget* search_panel_ = nullptr;
QDockWidget* spreadsheet_panel_ = nullptr;
QDockWidget* audit_panel_ = nullptr;
QDockWidget* clash_panel_ = nullptr;
QDockWidget* issues_panel_ = nullptr;
bonsaiviewer::modules::models::ModelsPanelView* models_view_ = nullptr;
bonsaiviewer::modules::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr;
bonsaiviewer::modules::properties::PropertiesPanelView* properties_view_ = nullptr;
};
} // namespace bonsaiviewer::shell
#endif
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MEASUREMENT_H
#define IFCINTERFACE_MEASUREMENT_H
#include <cstddef>
#include "ViewportWindow.h"
#include <QString>
#include <array>
#include <cstdint>
#include <unordered_map>
#include <vector>
// Sum of mesh-local volumes (m³) of every instance whose object_id is in
// `object_ids`. Groups by (model, mesh) so each unique mesh is read back
// from the GPU at most once per call; instances of the same mesh are scaled
// by |det(placement_3x3)| to pick up mapped-item scale/mirror. Volume is
// taken as the absolute value of the signed-tetrahedra sum, so winding
// convention does not matter. Returns 0.0 for empty input or when nothing
// resolves. Recomputes from scratch on every call — no cache.
double volumeOfObjects(ViewportWindow& vp,
const std::vector<uint32_t>& object_ids);
// Per-object volumes (m³). Same algorithm as volumeOfObjects but
// attributed per id rather than summed. Skips ids that don't resolve
// to a live instance, so the result may be shorter than the input.
// Used by MainWindow's volume readout to drive both the total HUD and
// the per-object overlay labels.
std::vector<std::pair<uint32_t, double>>
volumesPerObject(ViewportWindow& vp,
const std::vector<uint32_t>& object_ids);
// Click-to-accumulate area measurement. Each pick resolves the screen
// click to a (instance, triangle) using ViewportWindow's primitives,
// expands it into the connected coplanar patch (BFS over shared edges,
// dot(normal, seed_normal) > 0.9999), then either adds or removes that
// patch from the running set depending on whether the seed triangle was
// already in. Alt-click skips the BFS expansion (single-triangle).
// Picks on different instances (even of the same mesh) are kept as
// separate patches and their areas are summed.
//
// On every pick the world-space triangles of the running set are pushed
// to ViewportWindow::setHighlightTriangles for in-viewport shading.
// State is cleared on construction, on clear(), and is expected to be
// reset by the host (e.g. when the viewport's area tool toggles off).
class AreaMeasurement {
public:
AreaMeasurement();
// Main entry point: handle one click in area-tool mode. alt = true
// suppresses BFS expansion. Logs the per-click delta and running total
// via qInfo. Misses are silent.
void onPick(ViewportWindow& vp, int x, int y, bool alt);
// Wipe all accumulated triangles, per-mesh adjacency caches, and the
// viewport overlay.
void clear(ViewportWindow& vp);
double totalArea() const { return total_area_m2_; }
size_t triangleCount() const { return selected_.size(); }
private:
// Cached per-mesh data: triangles + edge→triangles adjacency. Keyed
// by (model_id << 32) | mesh_id. Filled lazily on first pick of that
// mesh, dropped on clear().
struct MeshCache {
std::vector<float> positions; // 3 * N_verts
std::vector<uint32_t> indices; // 3 * N_tris
std::vector<float> tri_normals; // 3 * N_tris (unit, mesh-local)
std::vector<float> tri_areas; // N_tris
// edge_key (min<<32 | max) → list of triangle indices touching it.
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
};
MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id);
// Per-selected-triangle record. The composed transform is captured at
// pick time so the overlay rebuild doesn't have to re-query the
// viewport for it (and so the overlay keeps working if the picked
// instance later goes hidden).
struct SelectedTri {
uint32_t model_id;
uint32_t mesh_id;
uint32_t tri;
float composed_transform[16];
};
// Selection key: object_id (high 32) | tri index (low 32). Packing
// by object_id rather than mesh_id means two distinct instances of
// the same mesh contribute independently, as the user spec'd.
static uint64_t triKey(uint32_t object_id, uint32_t tri) {
return (uint64_t(object_id) << 32) | uint64_t(tri);
}
void rebuildHighlight(ViewportWindow& vp);
std::unordered_map<uint64_t, MeshCache> mesh_cache_;
std::unordered_map<uint64_t, SelectedTri> selected_;
double total_area_m2_ = 0.0;
};
// Click-to-place length / angle / area measurement. Each pick appends a
// world-space point. The readout adapts to the point count:
//
// 1 point → "laser-measure" mode: 6 rays (±surface-normal, ±tangent₁,
// ±tangent₂ in the surface's own basis) trace into the scene.
// On a wall this gives thickness + floor-to-ceiling height +
// length-along-wall in one click. Tangent₁ is world up
// projected onto the surface plane (Gram-Schmidt against the
// normal); tangent₂ = normal × tangent₁.
// 2 points → straight-line distance plus axis-aligned ΔX/ΔY/ΔZ
// 3 points → angle at the middle vertex plus the triangle's area
// 4+ → polygon area: best-fit-plane shoelace if the points are
// near-coplanar (RMS plane distance < 1e-3 of the bounding
// box), else fan-triangulated from the first point
//
// Clicked points are pushed to the viewport overlay as small dots and
// the connecting polyline (or the laser rays for 1-point); readouts
// go to the multi-line HUD.
class LengthMeasurement {
public:
LengthMeasurement();
void onPick(ViewportWindow& vp, int x, int y, bool alt);
void removeLastPoint(ViewportWindow& vp);
void clear(ViewportWindow& vp);
size_t pointCount() const { return points_.size(); }
private:
void rebuildOverlay(ViewportWindow& vp);
void rebuildLaserOverlay(ViewportWindow& vp);
QString formatReadout() const;
std::vector<std::array<float, 3>> points_;
std::vector<std::array<float, 3>> normals_; // surface normal at each pick
// Captured at the very first pick of a fresh sequence and never
// updated afterwards. Used by the 1-pt laser BFS to re-locate the
// mesh-local position of points_[0] without re-picking. Stays valid
// while points_[0] does (pop_back never touches the first element).
ViewportWindow::MeshLocalPick first_pick_{};
};
#endif // IFCINTERFACE_MEASUREMENT_H
+186
View File
@@ -0,0 +1,186 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SessionState.h"
#include "ElementRegistry.h"
#include "modules/connectors/Registry.h"
#include "../ifcviewer/Federation.h"
#include "../ifcviewer/SceneLoader.h"
namespace bonsaiviewer {
SessionState::SessionState(QObject* parent)
: QObject(parent)
, federation_(new Federation(this))
, element_registry_(new ElementRegistry(this))
, connector_registry_(new modules::connectors::ConnectorRegistry(this))
{
}
void SessionState::createLoader(ViewportWindow* viewport) {
Q_ASSERT(!loader_);
loader_ = new SceneLoader(viewport, this);
loader_->setShouldReadSidecar(true);
loader_->setShouldWriteSidecar(true);
element_registry_->bindLoader(loader_);
auto format_elapsed = [](qint64 ms) {
return (ms >= 1000)
? QString::number(ms / 1000.0, 'f', 2) + " s"
: QString::number(ms) + " ms";
};
// Translate low-level loader events into session-level signals, status
// text, and progress so views don't need to subscribe to the loader.
connect(loader_, &SceneLoader::loadStarted, this,
[this](uint32_t, const QString& display_name) {
setStatusMessage("Loading", display_name);
beginProgress(display_name);
});
connect(loader_, &SceneLoader::progressChanged, this, &SessionState::setProgress);
connect(loader_, &SceneLoader::loadedFromSidecar, this,
[this, format_elapsed](uint32_t mid, qint64 elapsed_ms) {
setStatusMessage("Loaded",
QString("%1 from cache in %2")
.arg(loader_->displayName(mid))
.arg(format_elapsed(elapsed_ms)));
endProgress();
emit modelGeometryReady(mid);
});
connect(loader_, &SceneLoader::loadedFromStream, this,
[this, format_elapsed](uint32_t mid, qint64 elapsed_ms) {
setStatusMessage("Loaded",
QString("%1 streamed in %2")
.arg(loader_->displayName(mid))
.arg(format_elapsed(elapsed_ms)));
endProgress();
emit modelGeometryReady(mid);
});
connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t mid) {
setStatusMessage("Cancelled", loader_->displayName(mid));
endProgress();
});
connect(loader_, &SceneLoader::loadError, this,
[this](uint32_t, const QString& message) {
setStatusMessage("Error", message);
endProgress();
emit loadError(message);
});
connect(loader_, &SceneLoader::allLoadsFinished, this, [this]() {
setStatusMessage("Loaded", QString("%1 model(s)").arg(loader_->modelCount()));
});
}
void SessionState::setSelectedObjectId(uint32_t object_id) {
selected_object_id_ = object_id;
}
void SessionState::setStatusMessage(const QString& mode, const QString& detail) {
status_mode_ = mode;
status_detail_ = detail;
emit statusMessageChanged(status_mode_, status_detail_);
}
void SessionState::beginProgress(const QString& label) {
emit progressBegan(label);
}
void SessionState::setProgress(int percent) {
emit progressChanged(percent);
}
void SessionState::endProgress() {
emit progressEnded();
}
void SessionState::setModelMapping(const QString& fed_id, uint32_t model_id) {
fed_id_to_model_id_[fed_id] = model_id;
model_id_to_fed_id_[model_id] = fed_id;
}
void SessionState::removeModelMappingByFedId(const QString& fed_id) {
cloud_metadata_.remove(fed_id);
auto it = fed_id_to_model_id_.find(fed_id);
if (it == fed_id_to_model_id_.end()) return;
model_id_to_fed_id_.remove(it.value());
fed_id_to_model_id_.erase(it);
}
void SessionState::clearModelMappings() {
fed_id_to_model_id_.clear();
model_id_to_fed_id_.clear();
cloud_metadata_.clear();
}
void SessionState::setCloudMetadata(const QString& fed_id, const QVariantMap& metadata) {
if (metadata.isEmpty()) cloud_metadata_.remove(fed_id);
else cloud_metadata_.insert(fed_id, metadata);
}
QVariantMap SessionState::cloudMetadata(const QString& fed_id) const {
return cloud_metadata_.value(fed_id);
}
uint32_t SessionState::modelIdForFedId(const QString& fed_id) const {
return fed_id_to_model_id_.value(fed_id, 0);
}
QString SessionState::fedIdForModelId(uint32_t model_id) const {
return model_id_to_fed_id_.value(model_id);
}
QList<uint32_t> SessionState::modelIds() const {
return model_id_to_fed_id_.keys();
}
void SessionState::notifySelectionChanged() {
emit selectionChanged(selected_object_id_);
}
void SessionState::notifyModelsChanged() {
emit modelsChanged();
}
void SessionState::notifyFederationChanged() {
emit federationChanged();
}
void SessionState::notifyVisibilityChanged() {
emit visibilityChanged();
}
void SessionState::notifyModelGeometryReady(uint32_t model_id) {
emit modelGeometryReady(model_id);
}
void SessionState::notifyProjectOpened(const QString& path) {
emit projectOpened(path);
}
void SessionState::notifyProjectSaved(const QString& path) {
emit projectSaved(path);
}
void SessionState::notifyProjectReset() {
emit projectReset();
}
} // namespace bonsaiviewer
+129
View File
@@ -0,0 +1,129 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_SESSIONSTATE_H
#define IFCINTERFACE_SESSIONSTATE_H
#include <QHash>
#include <QObject>
#include <QString>
#include <QVariantMap>
class Federation;
class SceneLoader;
class ViewportWindow;
namespace bonsaiviewer {
class ElementRegistry;
namespace modules::connectors { class ConnectorRegistry; }
class SessionState : public QObject {
Q_OBJECT
public:
explicit SessionState(QObject* parent = nullptr);
// Owned by SessionState once it can be tied to a viewport. Wires
// loader → element registry signals internally. Call exactly once.
void createLoader(ViewportWindow* viewport);
Federation* federation() const { return federation_; }
SceneLoader* loader() const { return loader_; }
ElementRegistry* elementRegistry() const { return element_registry_; }
modules::connectors::ConnectorRegistry* connectorRegistry() const { return connector_registry_; }
QString statusMode() const { return status_mode_; }
QString statusDetail() const { return status_detail_; }
void setSelectedObjectId(uint32_t object_id);
uint32_t selectedObjectId() const { return selected_object_id_; }
void setStatusMessage(const QString& mode, const QString& detail);
// Generic progress reporting for any long-running operation (load,
// convert, export, ...). Subscribers (the status bar) react to the
// signals; they do not need to know which operation is running.
void beginProgress(const QString& label);
void setProgress(int percent);
void endProgress();
void setModelMapping(const QString& fed_id, uint32_t model_id);
void removeModelMappingByFedId(const QString& fed_id);
void clearModelMappings();
// Per-session cloud metadata returned by connectors (revision/date/
// author/...). Not persisted to the .ifcfed; display only. Lifetime
// is tied to the fed_id — removeModelMappingByFedId and
// clearModelMappings drop the matching entries.
void setCloudMetadata(const QString& fed_id, const QVariantMap& metadata);
QVariantMap cloudMetadata(const QString& fed_id) const;
uint32_t modelIdForFedId(const QString& fed_id) const;
QString fedIdForModelId(uint32_t model_id) const;
QList<uint32_t> modelIds() const;
void notifySelectionChanged();
void notifyModelsChanged();
void notifyFederationChanged();
void notifyVisibilityChanged();
void notifyModelGeometryReady(uint32_t model_id);
void notifyProjectOpened(const QString& path);
void notifyProjectSaved(const QString& path);
void notifyProjectReset();
signals:
void projectOpened(const QString& path);
void projectSaved(const QString& path);
void projectReset();
void modelsChanged();
// Fires whenever a command has mutated the federation (groups, transforms,
// origin, config). Always implies the project is now dirty; callers do not
// emit this on save/load/reset — projectSaved/Opened/Reset cover those.
void federationChanged();
void visibilityChanged();
// Fires when a model's geometry has been pushed to the viewport. Fires
// for both sidecar-cache and stream loads; subscribers that just need to
// re-derive view state (e.g. ViewportView::refresh) listen to this.
void modelGeometryReady(uint32_t model_id);
// Fires when SceneLoader reports a load failure. SessionState turns the
// raw loader signal into a session-level one so views (e.g. the MessageBox)
// can subscribe without touching the loader directly.
void loadError(const QString& message);
void selectionChanged(uint32_t object_id);
void statusMessageChanged(const QString& mode, const QString& detail);
void progressBegan(const QString& label);
void progressChanged(int percent);
void progressEnded();
private:
Federation* federation_ = nullptr;
SceneLoader* loader_ = nullptr;
ElementRegistry* element_registry_ = nullptr;
modules::connectors::ConnectorRegistry* connector_registry_ = nullptr;
uint32_t selected_object_id_ = 0;
QString status_mode_;
QString status_detail_;
QHash<QString, uint32_t> fed_id_to_model_id_;
QHash<uint32_t, QString> model_id_to_fed_id_;
QHash<QString, QVariantMap> cloud_metadata_;
};
} // namespace bonsaiviewer
#endif
+189
View File
@@ -0,0 +1,189 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "ViewerSettings.h"
#include "components/Style.h"
#include <QColor>
#include <QSettings>
namespace {
constexpr const char* kThemeModeKey = "interface/theme/mode";
constexpr const char* kThemeColorPrefix = "interface/theme/colors/";
using Spec = bonsaiviewer::ViewerSettings::ThemeColorSpec;
const std::vector<Spec> kThemeColorSpecs = {
{"app_background", "App Background", bonsaiviewer::components::style::palette::app_background, "#eef1f5"},
{"border", "Border", bonsaiviewer::components::style::palette::border, "#c9d1dc"},
{"selection_background", "Selection Background", bonsaiviewer::components::style::palette::selection_background,
"#2f9e44"},
{"tab_bar_background", "Tab Bar Background", bonsaiviewer::components::style::palette::tab_bar_background,
"#eef1f5"},
{"tab_background", "Tab Background", bonsaiviewer::components::style::palette::tab_background, "#e3e8ef"},
{"ribbon_background", "Ribbon Background", bonsaiviewer::components::style::palette::ribbon_background,
"#e3e8ef"},
{"ribbon_button_hover", "Ribbon Button Hover", bonsaiviewer::components::style::palette::ribbon_button_hover,
"#d6dde7"},
{"ribbon_button_pressed", "Ribbon Button Pressed", bonsaiviewer::components::style::palette::ribbon_button_pressed,
"#cad3df"},
{"viewport_shell_background", "Viewport Shell Background",
bonsaiviewer::components::style::palette::viewport_shell_background, "#dbe1e8"},
{"viewport_background", "Viewport Background", bonsaiviewer::components::style::palette::viewport_background,
"#eef2f6"},
{"panel_background", "Panel Background", bonsaiviewer::components::style::palette::panel_background, "#ffffff"},
{"control_background", "Control Background", bonsaiviewer::components::style::palette::control_background,
"#f5f7fa"},
{"control_border_focus", "Control Border Focus", bonsaiviewer::components::style::palette::control_border_focus,
"#7c8ca3"},
{"box_background", "Box Background", bonsaiviewer::components::style::palette::box_background, "#f5f7fa"},
{"scroll_handle", "Scroll Handle", bonsaiviewer::components::style::palette::scroll_handle, "#b1bac8"},
{"scroll_handle_hover", "Scroll Handle Hover", bonsaiviewer::components::style::palette::scroll_handle_hover,
"#929daf"},
{"status_background", "Status Background", bonsaiviewer::components::style::palette::status_background,
"#edf1f5"},
{"section_header_background", "Section Header Background",
bonsaiviewer::components::style::palette::section_header_background, "#eef2f6"},
{"primary_text", "Primary Text", bonsaiviewer::components::style::palette::primary_text, "#1f2937"},
{"secondary_text", "Secondary Text", bonsaiviewer::components::style::palette::secondary_text, "#5b6676"},
{"disabled_text", "Disabled Text", bonsaiviewer::components::style::palette::disabled_text, "#8b95a3"},
{"warning_text", "Warning Text", bonsaiviewer::components::style::palette::warning_text, "#b26b00"},
{"selection_text", "Selection Text", bonsaiviewer::components::style::palette::selection_text, "#ffffff"},
{"hover_text", "Hover Text", bonsaiviewer::components::style::palette::hover_text, "#0f172a"},
{"icon_color", "Icon Color", "#e7ebf2", "#445066"},
{"icon_active_color", "Icon Active Color", "#ffffff", "#101828"},
{"icon_disabled_color", "Icon Disabled Color", "#6f7988", "#98a2b3"},
{"icon_accent_color", "Accent Icon Color", "#39b54a", "#2f9e44"},
{"icon_accent_active_color", "Accent Icon Active Color", "#53c763", "#267e37"},
};
int colorIndexForKey(const QString& key) {
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
if (QString::fromUtf8(kThemeColorSpecs[i].key) == key) {
return static_cast<int>(i);
}
}
return -1;
}
QString normalizedColor(const QString& value, const QString& fallback) {
const QString trimmed = value.trimmed();
if (!QColor::isValidColorName(trimmed)) return fallback;
return QColor(trimmed).name(QColor::HexRgb);
}
} // namespace
namespace bonsaiviewer {
ViewerSettings& ViewerSettings::instance() {
static ViewerSettings inst;
return inst;
}
const std::vector<ViewerSettings::ThemeColorSpec>& ViewerSettings::themeColorSpecs() {
return kThemeColorSpecs;
}
ViewerSettings::ViewerSettings() {
custom_colors_.resize(kThemeColorSpecs.size());
load();
}
ViewerSettings::ThemeMode ViewerSettings::themeMode() const {
return theme_mode_;
}
void ViewerSettings::setThemeMode(ThemeMode mode) {
if (theme_mode_ == mode) return;
theme_mode_ = mode;
persist();
emit themeModeChanged(mode);
emit themeChanged();
}
QString ViewerSettings::color(const QString& key) const {
const int index = colorIndexForKey(key);
if (index < 0) return {};
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
switch (theme_mode_) {
case ThemeMode::Dark:
return QString::fromUtf8(spec.dark_default);
case ThemeMode::Light:
return QString::fromUtf8(spec.light_default);
case ThemeMode::Custom:
return customColor(key);
}
return QString::fromUtf8(spec.dark_default);
}
QString ViewerSettings::customColor(const QString& key) const {
const int index = colorIndexForKey(key);
if (index < 0) return {};
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
const QString& stored = custom_colors_[static_cast<size_t>(index)];
return stored.isEmpty() ? QString::fromUtf8(spec.dark_default) : stored;
}
void ViewerSettings::setCustomColor(const QString& key, const QString& value) {
const int index = colorIndexForKey(key);
if (index < 0) return;
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
const QString normalized = normalizedColor(value, QString::fromUtf8(spec.dark_default));
QString& stored = custom_colors_[static_cast<size_t>(index)];
if (stored == normalized) return;
stored = normalized;
persist();
if (theme_mode_ == ThemeMode::Custom) {
emit themeChanged();
}
}
void ViewerSettings::load() {
QSettings settings;
int raw_mode = settings.value(kThemeModeKey, static_cast<int>(ThemeMode::Dark)).toInt();
if (raw_mode < static_cast<int>(ThemeMode::Dark) || raw_mode > static_cast<int>(ThemeMode::Custom)) {
raw_mode = static_cast<int>(ThemeMode::Dark);
}
theme_mode_ = static_cast<ThemeMode>(raw_mode);
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
const auto& spec = kThemeColorSpecs[i];
const QString stored = settings.value(QString::fromUtf8(kThemeColorPrefix) + QString::fromUtf8(spec.key),
QString::fromUtf8(spec.dark_default))
.toString();
custom_colors_[i] = normalizedColor(stored, QString::fromUtf8(spec.dark_default));
}
}
void ViewerSettings::persist() const {
QSettings settings;
settings.setValue(kThemeModeKey, static_cast<int>(theme_mode_));
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
settings.setValue(QString::fromUtf8(kThemeColorPrefix) + QString::fromUtf8(kThemeColorSpecs[i].key),
custom_colors_[i]);
}
}
} // namespace bonsaiviewer
+72
View File
@@ -0,0 +1,72 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_INTERFACESETTINGS_H
#define IFCINTERFACE_INTERFACESETTINGS_H
#include <QObject>
#include <QString>
#include <vector>
namespace bonsaiviewer {
class ViewerSettings : public QObject {
Q_OBJECT
public:
enum class ThemeMode {
Dark = 0,
Light = 1,
Custom = 2,
};
Q_ENUM(ThemeMode)
struct ThemeColorSpec {
const char* key;
const char* label;
const char* dark_default;
const char* light_default;
};
static ViewerSettings& instance();
static const std::vector<ThemeColorSpec>& themeColorSpecs();
ThemeMode themeMode() const;
void setThemeMode(ThemeMode mode);
QString color(const QString& key) const;
QString customColor(const QString& key) const;
void setCustomColor(const QString& key, const QString& value);
signals:
void themeModeChanged(ThemeMode mode);
void themeChanged();
private:
ViewerSettings();
void load();
void persist() const;
ThemeMode theme_mode_ = ThemeMode::Dark;
std::vector<QString> custom_colors_;
};
} // namespace bonsaiviewer
#endif
@@ -0,0 +1,50 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
<RCC>
<qresource prefix="/fonts">
<file alias="DMSans-VariableFont_opsz,wght.ttf">../ifctester/webapp/public/fonts/dmsans/DMSans-VariableFont_opsz,wght.ttf</file>
</qresource>
<qresource prefix="/icons">
<file alias="plus-square.svg">icons/plus-square.svg</file>
<file alias="download-square.svg">icons/download-square.svg</file>
<file alias="cloud-square.svg">icons/cloud-square.svg</file>
<file alias="clock-rotate-right.svg">icons/clock-rotate-right.svg</file>
<file alias="floppy-disk.svg">icons/floppy-disk.svg</file>
<file alias="floppy-disk-arrow-in.svg">icons/floppy-disk-arrow-in.svg</file>
<file alias="cube.svg">icons/cube.svg</file>
<file alias="refresh-double.svg">icons/refresh-double.svg</file>
<file alias="settings.svg">icons/settings.svg</file>
<file alias="home.svg">icons/home.svg</file>
<file alias="home-alt.svg">icons/home-alt.svg</file>
<file alias="cube-scan.svg">icons/cube-scan.svg</file>
<file alias="cube-scan-solid.svg">icons/cube-scan-solid.svg</file>
<file alias="planimetry.svg">icons/planimetry.svg</file>
<file alias="city.svg">icons/city.svg</file>
<file alias="building.svg">icons/building.svg</file>
<file alias="cellar.svg">icons/cellar.svg</file>
<file alias="perspective-view.svg">icons/perspective-view.svg</file>
<file alias="drone.svg">icons/drone.svg</file>
<file alias="cube-cut-with-curve.svg">icons/cube-cut-with-curve.svg</file>
<file alias="folder-plus.svg">icons/folder-plus.svg</file>
<file alias="folder-minus.svg">icons/folder-minus.svg</file>
<file alias="folder.svg">icons/folder.svg</file>
<file alias="minus-square.svg">icons/minus-square.svg</file>
<file alias="eye-closed.svg">icons/eye-closed.svg</file>
<file alias="eye.svg">icons/eye.svg</file>
<file alias="eye-solid.svg">icons/eye-solid.svg</file>
<file alias="intersect.svg">icons/intersect.svg</file>
<file alias="select-edge3d.svg">icons/select-edge3d.svg</file>
<file alias="select-face3d.svg">icons/select-face3d.svg</file>
<file alias="select-point3d.svg">icons/select-point3d.svg</file>
<file alias="frame-alt.svg">icons/frame-alt.svg</file>
<file alias="square3d-from-center.svg">icons/square3d-from-center.svg</file>
<file alias="filter.svg">icons/filter.svg</file>
<file alias="cube-dots.svg">icons/cube-dots.svg</file>
<file alias="cursor-pointer.svg">icons/cursor-pointer.svg</file>
<file alias="sidebar-expand.svg">icons/sidebar-expand.svg</file>
<file alias="check.svg">icons/check.svg</file>
<file alias="xmark-circle.svg">icons/xmark-circle.svg</file>
<file alias="database.svg">icons/database.svg</file>
<file alias="database-restore.svg">icons/database-restore.svg</file>
<file alias="cube-bandage.svg">icons/cube-bandage.svg</file>
</qresource>
</RCC>
+85
View File
@@ -0,0 +1,85 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Buttons.h"
#include "SvgIcon.h"
#include <QBoxLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::components::buttons {
QToolButton* makeButton(const QString& text,
const QString& icon_path,
QWidget* parent) {
auto* button = new QToolButton(parent);
button->setObjectName("ribbonButton");
button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
button->setIcon(components::icons::makeAccentSvgIcon(icon_path));
button->setIconSize(QSize(20, 20));
button->setText(text);
button->setMinimumSize(QSize(90, 68));
button->setAutoRaise(false);
return button;
}
QWidget* makeButtonGroup(const QString& title,
const QList<QToolButton*>& buttons,
QWidget* parent,
int vertical_spacing) {
auto* group = new QFrame(parent);
group->setObjectName("ribbonGroup");
auto* group_layout = new QVBoxLayout(group);
group_layout->setContentsMargins(8, 6, 8, 4);
group_layout->setSpacing(vertical_spacing);
auto* button_row = new QHBoxLayout();
button_row->setContentsMargins(0, 0, 0, 0);
button_row->setSpacing(4);
for (auto* button : buttons) {
button_row->addWidget(button);
}
auto* label = new QLabel(title, group);
label->setObjectName("ribbonGroupLabel");
label->setProperty("textRole", "secondary");
label->setAlignment(Qt::AlignCenter);
group_layout->addLayout(button_row);
group_layout->addWidget(label);
return group;
}
void addButtonGroups(QBoxLayout* row, const QList<QWidget*>& groups) {
for (int i = 0; i < groups.size(); ++i) {
// The divider belongs *between* groups; the last group never draws a
// trailing one. The stylesheet keys off this dynamic property.
groups[i]->setProperty("separator", i + 1 < groups.size());
row->addWidget(groups[i]);
}
}
} // namespace bonsaiviewer::components::buttons
+48
View File
@@ -0,0 +1,48 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_BUTTONS_H
#define IFCINTERFACE_COMPONENTS_BUTTONS_H
#include <QList>
class QBoxLayout;
class QToolButton;
class QWidget;
namespace bonsaiviewer::components::buttons {
QToolButton* makeButton(const QString& text,
const QString& icon_path,
QWidget* parent);
QWidget* makeButtonGroup(const QString& title,
const QList<QToolButton*>& buttons,
QWidget* parent,
int vertical_spacing = 4);
// Adds button groups to a ribbon row, drawing a vertical divider between
// adjacent groups but never after the last one. Centralising the decision
// here means a row can't end up with a dangling trailing separator.
void addButtonGroups(QBoxLayout* row, const QList<QWidget*>& groups);
} // namespace bonsaiviewer::components::buttons
#endif
+151
View File
@@ -0,0 +1,151 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Dialog.h"
#include "Style.h"
#include "Tabs.h"
#include <QDialog>
#include <QFrame>
#include <QScrollArea>
#include <QVBoxLayout>
namespace bonsaiviewer::components {
Dialog::Dialog(QWidget* parent, bool scrollable)
: QDialog(parent)
{
auto* outer_layout = new QVBoxLayout(this);
outer_layout->setContentsMargins(style::metrics::padding,
style::metrics::padding,
style::metrics::padding,
style::metrics::padding);
outer_layout->setSpacing(0);
auto* frame = new QFrame(this);
frame->setObjectName("panel");
auto* frame_layout = new QVBoxLayout(frame);
frame_layout->setContentsMargins(0,
style::metrics::section_body_padding,
0,
style::metrics::section_body_padding);
frame_layout->setSpacing(0);
// A non-scrollable dialog must not be wrapped in a QScrollArea. The scroll
// area caps its own sizeHint at 36x24 character cells, and dialogs size
// themselves with QLayout::SetFixedSize — so any content wider/taller than
// that cap is turned into scrollbars instead of growing the dialog. Only
// use a scroll area when scrolling is actually wanted (mirrors Panel).
auto* body = new QWidget(frame);
body->setObjectName("panelScrollBody");
body_layout_ = new QVBoxLayout(body);
body_layout_->setContentsMargins(0, 0, 0, 0);
body_layout_->setSpacing(style::metrics::section_body_padding);
body_layout_->setAlignment(Qt::AlignTop);
if (scrollable) {
auto* scroll = new QScrollArea(frame);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
scroll->setWidget(body);
frame_layout->addWidget(scroll, 1);
} else {
frame_layout->addWidget(body);
}
auto* footer = new QWidget(frame);
footer_layout_ = new QVBoxLayout(footer);
footer_layout_->setContentsMargins(style::metrics::section_body_padding,
style::metrics::section_body_padding,
style::metrics::section_body_padding,
0);
footer_layout_->setSpacing(style::metrics::section_body_padding);
footer_layout_->setAlignment(Qt::AlignTop);
frame_layout->addWidget(footer);
outer_layout->addWidget(frame);
}
void Dialog::addBodyWidget(QWidget* widget) {
body_layout_->addWidget(widget);
}
void Dialog::addFooterWidget(QWidget* widget) {
footer_layout_->addWidget(widget);
}
TabbedDialog::TabbedDialog(QWidget* parent)
: QDialog(parent)
{
auto* outer_layout = new QVBoxLayout(this);
outer_layout->setContentsMargins(style::metrics::padding,
style::metrics::padding,
style::metrics::padding,
style::metrics::padding);
outer_layout->setSpacing(0);
auto* frame = new QFrame(this);
frame->setObjectName("panel");
auto* frame_layout = new QVBoxLayout(frame);
frame_layout->setContentsMargins(0,
style::metrics::section_body_padding,
0,
style::metrics::section_body_padding);
frame_layout->setSpacing(0);
tabs_ = new TabWidget(frame);
frame_layout->addWidget(tabs_, 1);
auto* footer = new QWidget(frame);
footer_layout_ = new QVBoxLayout(footer);
footer_layout_->setContentsMargins(style::metrics::section_body_padding,
style::metrics::section_body_padding,
style::metrics::section_body_padding,
0);
footer_layout_->setSpacing(style::metrics::section_body_padding);
footer_layout_->setAlignment(Qt::AlignTop);
frame_layout->addWidget(footer);
outer_layout->addWidget(frame);
}
void TabbedDialog::addTab(const QString& title, QWidget* widget) {
auto* scroll = new QScrollArea(tabs_);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* scroll_body = new QWidget(scroll);
scroll_body->setObjectName("panelScrollBody");
auto* layout = new QVBoxLayout(scroll_body);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(style::metrics::section_body_padding);
layout->setAlignment(Qt::AlignTop);
layout->addWidget(widget);
scroll->setWidget(scroll_body);
tabs_->addTab(scroll, title);
}
void TabbedDialog::addFooterWidget(QWidget* widget) {
footer_layout_->addWidget(widget);
}
} // namespace bonsaiviewer::components
+63
View File
@@ -0,0 +1,63 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_DIALOG_H
#define IFCINTERFACE_COMPONENTS_DIALOG_H
#include <QDialog>
class QVBoxLayout;
class QWidget;
class QTabWidget;
namespace bonsaiviewer::components {
class Dialog : public QDialog {
Q_OBJECT
public:
explicit Dialog(QWidget* parent = nullptr,
bool scrollable = false);
void addBodyWidget(QWidget* widget);
void addFooterWidget(QWidget* widget);
private:
QVBoxLayout* body_layout_ = nullptr;
QVBoxLayout* footer_layout_ = nullptr;
};
class TabbedDialog : public QDialog {
Q_OBJECT
public:
explicit TabbedDialog(QWidget* parent = nullptr);
void addTab(const QString& title, QWidget* widget);
void addFooterWidget(QWidget* widget);
private:
QTabWidget* tabs_ = nullptr;
QVBoxLayout* footer_layout_ = nullptr;
};
} // namespace bonsaiviewer::components
#endif
@@ -0,0 +1,72 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "KeyValueTable.h"
#include "SvgIcon.h"
#include <QGridLayout>
#include <QLabel>
namespace bonsaiviewer::components {
KeyValueTable::KeyValueTable(const QList<KeyValueTableRow>& rows, QWidget* parent)
: QWidget(parent)
{
setObjectName("keyValueTable");
auto* layout = new QGridLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setHorizontalSpacing(12);
layout->setVerticalSpacing(6);
layout->setColumnStretch(1, 1);
int row_index = 0;
for (const auto& row_data : rows) {
auto* key = new QLabel(row_data.key, this);
key->setProperty("textRole", "secondary");
if (row_data.key_minimum_width > 0) {
key->setMinimumWidth(row_data.key_minimum_width);
}
auto* value = new QLabel(row_data.value, this);
value->setObjectName(row_data.value_object_name.isEmpty()
? "keyValueValueLabel"
: row_data.value_object_name);
value->setWordWrap(true);
value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
layout->addWidget(key, row_index, 0, Qt::AlignLeft | Qt::AlignTop);
layout->addWidget(value, row_index, 1);
if (!row_data.trailing_icon_path.isEmpty()) {
auto* icon = new QLabel(this);
icon->setObjectName(row_data.trailing_icon_object_name.isEmpty()
? "keyValueTrailingIconLabel"
: row_data.trailing_icon_object_name);
icon->setPixmap(icons::makeSvgPixmap(row_data.trailing_icon_path, QSize(14, 14)));
icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
layout->addWidget(icon, row_index, 2, Qt::AlignRight | Qt::AlignTop);
}
++row_index;
}
}
} // namespace bonsaiviewer::components
@@ -0,0 +1,47 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
#define IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
#include <QList>
#include <QString>
#include <QWidget>
namespace bonsaiviewer::components {
struct KeyValueTableRow {
QString key;
QString value;
QString value_object_name = "keyValueValueLabel";
QString trailing_icon_path;
QString trailing_icon_object_name;
int key_minimum_width = 0;
};
class KeyValueTable : public QWidget {
Q_OBJECT
public:
explicit KeyValueTable(const QList<KeyValueTableRow>& rows, QWidget* parent = nullptr);
};
} // namespace bonsaiviewer::components
#endif
+140
View File
@@ -0,0 +1,140 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "Style.h"
#include "SvgIcon.h"
#include <QDockWidget>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QScrollArea>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::components {
namespace {
class DockTitleBar : public QWidget {
public:
explicit DockTitleBar(const QString& title,
bool has_settings = false,
std::function<void()> on_settings = {},
QWidget* parent = nullptr)
: QWidget(parent)
{
auto* layout = new QHBoxLayout(this);
layout->setContentsMargins(10, 6, 6, 6);
layout->setSpacing(6);
auto* text = new QLabel(title.toUpper(), this);
text->setObjectName("panelTitleText");
layout->addWidget(text);
layout->addStretch(1);
if (has_settings) {
auto* settings = new QToolButton(this);
settings->setIcon(icons::makeSvgIcon(":/icons/settings.svg"));
settings->setAutoRaise(true);
settings->setCursor(Qt::ArrowCursor);
settings->setFixedSize(18, 18);
settings->setObjectName("panelTitleButton");
settings->setToolTip(QString("%1 settings").arg(title));
connect(settings, &QToolButton::clicked, this, [on_settings = std::move(on_settings)]() {
if (on_settings) on_settings();
});
layout->addWidget(settings);
}
}
};
} // namespace
Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings, bool scrollable)
: QDockWidget(title, parent)
{
auto* outer = new QFrame();
auto* outer_layout = new QVBoxLayout(outer);
outer_layout->setContentsMargins(style::metrics::padding,
style::metrics::padding,
style::metrics::padding,
style::metrics::padding);
outer_layout->setSpacing(0);
auto* frame = new QFrame(outer);
frame->setObjectName("panel");
auto* frame_layout = new QVBoxLayout(frame);
frame_layout->setContentsMargins(0, style::metrics::section_body_padding, 0, style::metrics::section_body_padding);
frame_layout->setSpacing(0);
if (scrollable) {
auto* scroll = new QScrollArea(frame);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* scroll_body = new QWidget(scroll);
scroll_body->setObjectName("panelScrollBody");
body_layout_ = new QVBoxLayout(scroll_body);
body_layout_->setContentsMargins(0, 0, 0, 0);
body_layout_->setSpacing(style::metrics::section_body_padding);
body_layout_->setAlignment(Qt::AlignTop);
scroll->setWidget(scroll_body);
frame_layout->addWidget(scroll);
} else {
auto* body = new QWidget(frame);
body_layout_ = new QVBoxLayout(body);
body_layout_->setContentsMargins(0, 0, 0, 0);
body_layout_->setSpacing(style::metrics::section_body_padding);
body_layout_->setAlignment(Qt::AlignTop);
frame_layout->addWidget(body);
}
if (content) {
addBodyWidget(content);
}
outer_layout->addWidget(frame);
setObjectName(title);
setFeatures(QDockWidget::DockWidgetMovable |
QDockWidget::DockWidgetFloatable |
QDockWidget::DockWidgetClosable);
setTitleBarWidget(new DockTitleBar(title, has_settings, [this]() {
emit settingsRequested();
}, this));
setWidget(outer);
}
void Panel::addBodyWidget(QWidget* widget) {
body_layout_->addWidget(widget);
}
void Panel::clearBodyWidgets() {
while (auto* item = body_layout_->takeAt(0)) {
if (auto* widget = item->widget()) widget->deleteLater();
delete item;
}
}
} // namespace bonsaiviewer::components
+53
View File
@@ -0,0 +1,53 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
#define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
#include <QDockWidget>
class QWidget;
class QVBoxLayout;
namespace bonsaiviewer::components {
class Panel : public QDockWidget {
Q_OBJECT
public:
explicit Panel(const QString& title,
QWidget* content = nullptr,
QWidget* parent = nullptr,
bool has_settings = false,
bool scrollable = false);
void addBodyWidget(QWidget* widget);
void clearBodyWidgets();
signals:
void settingsRequested();
private:
QVBoxLayout* body_layout_ = nullptr;
};
} // namespace bonsaiviewer::components
#endif

Some files were not shown because too many files have changed in this diff Show More