Commit Graph

21808 Commits

Author SHA1 Message Date
Dion Moult 66a21923b8 ifcviewer: move buildPipelines + selection-flags wiring into ViewportCore (#84-k)
Move the main render pipeline construction + the selection flags
buffer/bind group lifecycle. Both buildPipelines and the selection
flags methods produce/consume state ViewportCore already owns
(main_pipeline_, frame_bgl_, etc.) plus a handful of "frame
infrastructure" fields this commit also brings across.

State moved (7 fields):
  WGPUBuffer        frame_uniform_buffer_
  WGPUBindGroup     frame_bind_group_
  WGPUBuffer        selection_flags_buffer_
  uint32_t          selection_flags_capacity_
  std::vector<u32>  selection_flags_scratch_
  SelectionState    selection_
  VisibilityState   visibility_

Methods moved:
  buildPipelines              (~150 lines + 320-line MAIN_WGSL string)
  ensureSelectionFlagsBuffer  (~60 lines)
  uploadSelectionFlagsIfDirty (~10 lines)

Plus the MAIN_WGSL constant + the svFromCStr helper into
ViewportCore.cpp's anonymous namespace. ViewportWindow.cpp keeps its
own svFromCStr copy (still used by 50+ label fields in the not-yet-
moved pipeline builders + render encoders).

Shared constants extracted to ViewportCore.h:
  kMaxSectionPlanes (was OverlayRenderer::kMaxSectionPlanes — assert
                     in VW.cpp keeps them in sync)
  kViewportSampleCount (was SAMPLE_COUNT in VW; VW keeps a static
                        constexpr alias for the existing callsites)
  struct FrameUniforms (canonical layout for the per-frame UBO,
                        consumed by both core's buildPipelines and
                        VW's still-in-flight updateFrameUniforms)

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 16:39:01 +10:00
Dion Moult 8cf7d4346d ifcviewer: move volume readout helpers into ViewportCore (#84-j)
Tiny followup to #84-i — move the const-lookup volume helpers used by
bonsai's measurement HUD:

  double volumeOfObjects(const std::vector<uint32_t>&) const
  vector<pair<uint32_t, double>> volumesPerObject(
                              const std::vector<uint32_t>&) const

The det3OfPlacement static helper moves with them into ViewportCore.cpp's
anonymous namespace (the original kept its mirror in
ViewportWindow.cpp; ViewportWindow's own internal callers are gone now
since these methods moved).

Pure read of models_gpu_ + mesh_local_volumes — all in core already.
Trivial transplant.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 15:37:47 +10:00
Dion Moult 707bb8f5d4 ifcviewer: move camera mutators + AABB helpers into ViewportCore (#84-i)
Move the cluster of camera-state mutators + per-object AABB helpers
now that the camera fields all live in ViewportCore. CameraState
struct is canonical in core; ViewportWindow keeps a `using` alias
so bonsai's HomeView round-trip (Commands.cpp setHome / restoreHome)
compiles unchanged.

Moved:
  void viewAll()
  void setCamera(...) — pitch + distance clamping included
  void setStandardView(yaw, pitch) — bypasses clamp for ±90°
  void toggleProjection()
  std::string cameraString() const
  CameraState cameraState() const
  void frameAabb(mn, mx, padding)
  bool computeObjectAabb(id, float[3], float[3]) const
  bool computeObjectAabb(id, Eigen::Vector3f&, Eigen::Vector3f&) const

ViewportWindow keeps thin forwarders for the public ones (bonsai
calls them). setCamera additionally flips initial_view_applied_
on the VW side — the auto-viewAll suppression flag isn't in core
yet because the trigger for auto-viewAll lives in the still-in-VW
applyCachedModel path.

The isExposed()+requestUpdate() Qt pattern inside the moved bodies
becomes host_->requestFrame(); two viewAll/toggleProjection diagnostic
prints become fprintf since Log::info() doesn't reach into core.cpp
through the Qt logging surface.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 15:27:39 +10:00
Dion Moult 14e7c9fc42 ifcviewer: move camera math into ViewportCore (#84-h)
Move the three camera-math methods that compute view/projection
matrices, scene bounds, and per-chunk screen footprint for the
streaming priority signal:

  void  buildViewProj(Eigen::Matrix4f&, Eigen::Matrix4f&) const
  bool  computeSceneAabb(float[3], float[3]) const
  float chunkScreenAreaPx(const ModelGpuData::Chunk&,
                          const Eigen::Matrix4f&) const

Plus the orbitEye helper (anonymous namespace in ViewportCore.cpp;
the qDegreesToRadians dep got swapped for an inline M_PI/180 constant).

ViewportWindow.cpp's 9 internal callers (cull, streaming, pick,
render, debug) updated to use core_.buildViewProj() etc. The
buildViewProj forwarder stays out of ViewportWindow.h since no
external caller needs it — bonsai/minimal both go through
public API methods like viewAll which still wrap core_ access
on the VW side.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 14:43:25 +10:00
Dion Moult a0db182d2b ifcviewer: move camera + surface-geom state into ViewportCore (#84-g)
Move the camera/projection/clear-color fields that buildViewProj,
updateFrameUniforms, the cull screen-area projector, and the bonsai-
side cameraState/setCamera/viewAll surface depend on. Same alias
pattern; no method bodies move in this commit — the next one moves
the camera math methods now that all their state is in core.

State moved (12 fields):
  int   configured_w_, configured_h_
  float camera_target_[3], camera_distance_
  float camera_yaw_deg_, camera_pitch_deg_, camera_fov_y_deg_
  float camera_near_, camera_far_
  bool  projection_ortho_
  Eigen::Vector4f background_color_

ViewportWindow keeps reference aliases for each (including a proper
`float (&camera_target_)[3]` reference-to-array binding) so the
~150 call sites that touch camera state stay unchanged. Aliases
collapse when their owning methods migrate.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 14:24:49 +10:00
Dion Moult 8da0993457 ifcviewer: move scene mutators + releaseWgpuModelGpuData into ViewportCore (#84-f)
Move the eight scene-mutation methods that drive bonsai's load/unload
and georeference setters, plus the per-model GPU teardown helper.
All are mechanical transplants — no logic change — so behaviour stays
identical; only the owner has changed.

Methods moved (ViewportWindow public-API methods stay as forwarders
to keep the bonsai-side callers compiling):
  removeModel / resetScene / hideModel / showModel
  setFederatedFalseOrigin
  setModelCoordinateOperation
  setModelTransformation
  recomposeAndUploadModel

State moved:
  bool wgpu_initialized_   (storage → core_, alias kept in VW for
                            the initWgpu call site that still flips
                            it; goes when initWgpu moves)

Free function moved:
  releaseWgpuModelGpuData(ModelGpuData&, BufferPool&) → ViewportCore.cpp
  (must live in IfcViewerCore now that ViewportCore.cpp's
   removeModel / resetScene call it; ViewportWindow.cpp's remaining
   two call sites continue to resolve through ModelGpuData.h's
   declaration — same linker view, different definition TU)

The `if (isExposed()) requestUpdate()` Qt pattern inside the moved
bodies became `host_->requestFrame()` since ViewportCore can't see
QWindow; the desktop ViewportHost override at the bottom of
ViewportWindow.cpp continues to translate that into requestUpdate().

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 14:04:57 +10:00
Dion Moult b2fe9c4a71 ifcviewer: move const-lookup methods into ViewportCore (#84-e)
Move two pure-read methods (no GPU touch, no Qt) that the bonsai
measurement / federation-origin paths use:

  bool findInstance(uint32_t, InstanceLookup&)        const
  bool firstGeometryPointWorldM(uint32_t, Vector3d&)  const

ViewportWindow keeps both public-API method names — they now forward
to core_ for the implementation so existing callers in
bonsaiviewer/Measurement.cpp + Federation hooks don't have to change.
The InstanceLookup type also stays a `using` alias in ViewportWindow
(was added in #74).

Both methods were already de-Qt'd (`findInstance` delegates to
InstanceCompose; `firstGeometryPointWorldM` is pure Eigen). The move
is a straight transplant — no behaviour change.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 13:54:28 +10:00
Dion Moult ad6822ac85 ifcviewer: move composeInstanceFromPlacement into ViewportCore (#84-d)
First method-body migration. composeInstanceFromPlacement composes the
federated-false-origin × model-transformation × coordinate-operation ×
placement chain and re-derives the world AABB; it's a small,
self-contained method that only reads scene state and one matrix.

Moved:
  Eigen::Matrix4d federated_false_origin_meters_   (storage → core_)
  void composeInstanceFromPlacement(InstanceCpu&, ...) (body → core_)

ViewportWindow keeps:
  - alias reference to federated_false_origin_meters_ (existing
    setFederatedFalseOrigin call site still writes through it)
  - no method declaration — internal callers route through core_

Internal caller (recomposeAndUploadModel) now invokes
core_.composeInstanceFromPlacement; once recomposeAndUploadModel
itself moves into ViewportCore the call shortens back.

Pattern for the rest of #84: state moves, then method body moves,
then internal callers update. Each commit leaves desktop / bonsai /
web green and tests 100/100. This is one of many such steps.
2026-06-05 13:32:19 +10:00
Dion Moult 303f903a10 ifcviewer: move scene state into ViewportCore (#84-c)
Move the five scene-state fields that drive per-model GPU upload + the
streaming residency loop into ViewportCore:

  BufferPool      pool_              — vertex+index sub-allocator
  StreamingThread streaming_thread_  — background chunk reader
  std::unordered_map<uint32_t, ModelGpuData> models_gpu_
                                     — per-model state
  uint32_t        next_model_id_     — model-id allocator
  uint32_t        next_object_id_    — globally-unique object-id allocator

ViewportCore.h gains transitive includes for BufferPool / StreamingThread
/ ModelGpuData; ViewportWindow keeps the same names as reference aliases
so existing method bodies that touch them don't have to change.

Same risk profile as #84-a and #84-b: the storage moved but the values
are still set and consumed by the same code paths, so behaviour stays
identical.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 10:00:58 +10:00
Dion Moult d0be7b775a ifcviewer: move render pipelines into ViewportCore (#84-b)
Move the 15 pipeline + bind-group-layout + shader-module handles
that buildPipelines / buildEdgePipeline / buildPickPipeline write to.
Same pattern as #84-a: storage lives in ViewportCore, ViewportWindow
keeps reference aliases so existing builder-method bodies don't
have to acquire a `core_.` prefix at every touch point.

Moved fields:
  Main render group:
    main_shader_module_, frame_bgl_ (group 0), model_bgl_ (group 1),
    pipeline_layout_, main_pipeline_, main_pipeline_transparent_
  HiZ occlusion-cull group:
    hiz_shader_module_, hiz_bgl_, hiz_pipeline_layout_, hiz_pipeline_
  Edge silhouette group:
    edge_shader_module_, edge_bgl_, edge_pipeline_layout_, edge_pipeline_
  Pick pass:
    pick_pipeline_ (reuses pipeline_layout_ — same set of bindings)

ViewportWindow's constructor binds 16 new alias references after
the 7 lifecycle ones from #84-a; member-init order matches
declaration order so core_ is constructed before any alias binds.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 09:57:59 +10:00
Dion Moult e37a78f4f5 ifcviewer: move wgpu lifecycle state ownership into ViewportCore
First chunk of the #84 ViewportCore extraction. The seven wgpu lifecycle
handles (instance, adapter, device, queue, surface, surface_format,
surface_configured) now live as ViewportCore members; ViewportWindow
keeps reference aliases pointing at ViewportCore's storage so its
existing render-method bodies don't need a `core_.` prefix added at
every call site — 230+ touches deferred until each method moves
across.

Member init order in ViewportWindow's constructor:
  core_(this)               → constructs ViewportCore with host_=this
  instance_(core_.instance_) → binds the alias to core_'s field
  …                           same for adapter/device/queue/surface/…

Friend declaration on ViewportCore::ViewportWindow lets the references
bind to its private fields. The friend bond shrinks each commit as
render methods (and their `device_` / `queue_` references) migrate into
ViewportCore proper; the goal state is no friend and no aliases.

Next #84 chunks (separate commits) move pipelines, models_gpu_, pool_,
streaming_thread_, then the render/cull/encode methods. Each leaves
the desktop build green.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 09:39:57 +10:00
Dion Moult 1a17ba9e6d ifcviewer: de-Qt QColor/QPoint/QSet/QElapsedTimer in ViewportWindow
Last round of straight-swap Qt value types in ViewportWindow + its
overlay co-pilot.

  setBackgroundColor(const QColor&)   → (float r, float g, float b, float a)
  QColor   background_color_          → Eigen::Vector4f (linear, 0..1)
  QPoint   {nav_,box_select_,fps_,    } → Eigen::Vector2i
           {section_drag_start_mouse_}
  QSet<int> fps_keys_held_            → std::unordered_set<int>
  QElapsedTimer fps_last_tick_,       → Stopwatch (new header in
               fly_render_clock_,        IfcViewerCore — std::chrono-
               render_thread_local_      backed, exposes the existing
               timers in render()        QElapsedTimer .start/.restart/
                                         .elapsed/.nsecsElapsed surface)

Also propagates the QPoint → Eigen::Vector2i change through
OverlayRenderer::encodeMarquee since the marquee corner coords flow
through that interface.

API-level helpers:
  toV2i(QPoint)        — small inline in ViewportWindow.cpp, isolates
                         the QMouseEvent→Vector2i conversion at the
                         five mouse-event handlers
  Stopwatch.h          — new file, IfcViewerCore. Same call shape as
                         QElapsedTimer; backed by std::chrono::steady_clock.

QSet method swaps:
  .isEmpty() → .empty()
  .contains(k) → .count(k)   (C++17, no std contains() until C++20)
  .remove(k)   → .erase(k)

Eigen::Vector2i doesn't have .manhattanLength(); the box-select drag
threshold uses std::abs(diff.x()) + std::abs(diff.y()) inline.

Bonsai side: View.cpp's setBackgroundColor wrapper now decomposes the
QColor into floats at the call site (kept locally so the bonsai UI
keeps its QColor-driven theming).

Closes #81 + the QElapsedTimer half of #83. QTimer
(pivot_indicator_hide_timer_) still uses Qt — it needs the host's
scheduleOnce mechanism that lands with #85.

Builds: desktop / bonsai / web all green. Tests 100/100.
2026-06-05 09:26:53 +10:00
Dion Moult b62e14a06a ifcviewer: de-Qt ViewportWindow public API (QString → std::string)
Take QString out of ViewportWindow's outward-facing surface so it can
eventually move into a Qt-free ViewportCore:

  void     queueLoadSidecar(const QString&)    →  (const std::string&)
  uint32_t loadSidecar(const QString&)         →  (const std::string&)
  QString  cameraString() const                →  std::string …
  void     captureNextFrameToPng(const QString&, bool)
                                               →  (const std::string&, bool)
  void     setHudText(const QString&)          →  (const std::string&)

Internal members also moved off QString:
  std::deque<QString> pending_sidecars_     →  std::deque<std::string>
  QString             pending_screenshot_path_ →  std::string

Implementation strategy: convert at the boundary where ViewportWindow
still leans on Qt internals — `loadSidecar` bridges to QString once
for QFile/QDir/QFileInfo path handling; the screenshot save path
constructs a QString locally for QImage::save; the OverlayRenderer's
HUD setter still takes QString so setHudText converts before calling
through. Each of those bridges goes away when ViewportCore lands and
OverlayRenderer / SceneLoader / SidecarBuilder get their own de-Qt
sweeps. cameraString now produces its CSV via snprintf — no QString
ever instantiated.

Bonsai-side updates (compile-only):
  ifcviewer-minimal/main.cpp — queueLoadSidecar / captureNextFrameToPng
                              callers add .toStdString() on the QString
                              parser result
  modules/viewport/View.cpp  — setHudText callers add .toStdString() to
                              their `QString::arg(...)` formatter chains;
                              two `QString()` empty sentinels become
                              `std::string()`
  Measurement.cpp            — same pattern, two setHudText sites
  ifcviewer/LengthMeasurement.cpp — same, three sites

The cameraString string-streaming fix-up in ViewportWindow.cpp drops
the temporary .toUtf8().constData() bridge from #82 — Log::Stream's
std::string overload now handles it directly.

Builds: desktop / bonsai / web all green. Tests 100/100. Closes #80.
2026-06-05 09:14:19 +10:00
Dion Moult 6dd3558db9 ifcviewer: replace qInfo/qWarning with a Qt-free logger seam
Add Log.h (in IfcViewerCore) — a tiny stream-style logger that backs
fprintf(stderr,...), with overloads for the common primitives + char
strings. Mimics qInfo()/qWarning()'s syntax surface enough that
mass-replacing qInfo()→Log::info() and qWarning()→Log::warn() keeps
existing call sites parsing unchanged; .noquote() / .nospace() exist
as compat no-ops so chained qInfo().noquote()<<x<<y patterns survive.

QString streaming is a transitional concern — the QString → std::string
sweep (#80) hasn't landed yet, so ViewportWindow and friends still
construct QStrings for log payloads. LogQt.h (in IfcViewer, not Core)
adds the QString / QStringView operator<< overloads so those streaming
sites work without source changes during the in-flight Qt removal.
When #80 retires QString, LogQt.h drops out.

ViewportWindow.cpp: 132 qInfo/qWarning callsites converted. The two
printf-style qInfo("fmt %s", ...) callsites get fprintf with explicit
[info]/[warn] prefixes to keep the output discoverable.

Also de-Qt'd:
  AreaMeasurement.cpp  — 1 qInfo("fmt", …) → fprintf
  SceneLoader.cpp      — 4 qDebug + 1 qWarning printf-style → fprintf
  GeometryStreamer.cpp — 2 qDebug printf-style → fprintf
  ifcviewer-minimal/main.cpp — 2 qWarning << → Log::warn

Drops <QDebug> from each. Closes #82.

Builds: desktop / bonsai / web all green. Tests 100/100 pass.
2026-06-05 08:58:45 +10:00
Dion Moult 77cf535b45 ifcviewer: de-Qt math types (Eigen everywhere)
Replace Qt math wrappers with Eigen across ViewportWindow, OverlayRenderer,
Federation, and the bonsai-side viewport modules. Eigen was already the
canonical type for the actually-important matrix work (InstanceCompose,
ModelGpuData, federation matrices); QVector3D/QVector4D/QMatrix4x4 were
leftover from when Qt was the path of least resistance. They offered
nothing over Eigen for our use case beyond a few graphics helpers
(lookAt / perspective / ortho) which were 30 lines to write.

Substitutions:
  QMatrix4x4 → Eigen::Matrix4f
  QVector2D  → Eigen::Vector2f
  QVector3D  → Eigen::Vector3f
  QVector4D  → Eigen::Vector4f

API rewrites:
  .lengthSquared()         → .squaredNorm()
  .length()                → .norm()
  .isNull()                → .isZero()
  .setToIdentity()         → .setIdentity()
  .constData()             → .data()
  .toVector3D()            → .head<3>()
  .inverted(&ok)           → tryInvert4f(M, out)
  Q::dotProduct(a,b)       → a.dot(b)
  Q::crossProduct(a,b)     → a.cross(b)
  QMat4x4(... row-major)   → Eigen::Map<const Matrix4f>(col-major buf)
  QMat4x4().lookAt(...)    → lookAtRH(eye, target, up)
  QMat4x4().perspective(.) → perspectiveYFovGL(fovy, aspect, n, f)
  QMat4x4().ortho(...)     → orthoGL(l, r, b, t, n, f)

Default-init divergence handled explicitly (QMatrix4x4() = identity,
QVector3D() = zero; Eigen leaves both uninitialized). Public API
(CameraState, HomeView, ViewportWindow::computeObjectAabb, the
addSectionPlaneAtSurface / pickSurfaceAt / raycast signatures) follows
through to Eigen too; bonsai-side View.cpp and Commands.cpp updated to
match.

Camera helpers (lookAtRH, perspectiveYFovGL, orthoGL, tryInvert4f)
extracted to a new CameraMath.h so OverlayRenderer's gizmo MVP and
ViewportWindow's buildViewProj share the same definitions. Federation
drops its <QVector3D> include in favour of <Eigen/Dense> (already had
the latter for the georef matrices).

Builds: desktop IfcViewerMinimal ✓, BonsaiViewer ✓, web IfcViewerWeb ✓.
Tests: 100/100 pass. Closes #78 + #79; opens the door for #80-#83.
2026-06-05 08:33:22 +10:00
Thomas Krijnen 1f2b20fd86 Fix --convert-back-units on transformation object #8137 2026-06-04 22:15:35 +02:00
Thomas Krijnen 94fab271cd Check for empty result after BOPAlgo_MakerVolume and reset manifoldness state #8140 2026-06-04 21:45:27 +02:00
Thomas Krijnen 8583d0963f Make faceset duplicate loop detection respect inner/outer #8140 2026-06-04 21:45:27 +02:00
Thomas Krijnen 77a2284f8a Re-sew non-manifold operands; interior loop re-orientations affect edge identity #8140 2026-06-04 21:45:26 +02:00
Thomas Krijnen 4520a72152 Sane error messages for unsupported items in geometry libs #8106 2026-06-04 21:45:26 +02:00
Dion Moult c314dd3ca8 ifcviewer: scaffold ViewportHost + ViewportCore (Path A step 1)
Define the boundary the Path-A web-bring-up refactor will move things
across:

- ViewportHost.h is the embedder interface — surface creation,
  framebuffer geometry, frame scheduling, quit, and notification
  callbacks (onObjectPicked, onToolModeChanged, …). Desktop hosts
  forward notifications to Q_SIGNALS; the future web host pushes
  them to JS callbacks.

- ViewportCore.{h,cpp} is the platform-agnostic render-core target.
  Empty today — the body fills in across the #78-#86 sequence as
  each Qt subsystem (matrices, vectors, strings, timers, render
  path, input) gets de-Qt'd and moved over.

- ViewportWindow now multiply-inherits ViewportHost alongside QWindow
  and implements the host overrides as thin forwarders: createSurface
  returns the cached surface_, requestFrame -> requestUpdate, quit ->
  QCoreApplication::quit, onObjectPicked -> emit objectPicked.
  Renamed the DPR accessor `dpr()` (vs `devicePixelRatio`) to avoid
  the inherited-virtual clash with QWindow's qreal-returning version.

No method movement yet — this is purely the architectural scaffold so
subsequent commits have a destination.
2026-06-04 19:34:51 +10:00
Dion Moult e55a360aa2 web: scaffold IfcViewerWeb (Emscripten clear-color renderer)
First Emscripten target. main_web.cpp brings up a wgpu instance against
a <canvas id="viewer-canvas">, requests adapter+device asynchronously
via the standard webgpu.h callback chain, configures the surface, and
clears to the BonsaiViewer slate background on each RAF tick. No
sidecar load, no pipelines, no scene state yet — the goal is to end-
to-end verify the build + canvas + wgpu plumbing.

src/ifcviewer-web/ is a separate CMake root (not a subdir under the
desktop cmake/CMakeLists.txt) so the web build doesn't have to opt out
of Qt / OpenCASCADE / IfcGeom find_packages it can't satisfy. It adds
src/ifcviewer EXCLUDE_FROM_ALL and consumes only IfcViewerCore.

src/ifcviewer/CMakeLists.txt now gates the wgpu-native fetch + the
Qt-using IfcViewer target + install commands behind NOT EMSCRIPTEN.
The wgpu_native link target still resolves under Emscripten as an
INTERFACE library that activates --use-port=emdawnwebgpu (Dawn's
webgpu.h, replaces the legacy -sUSE_WEBGPU=1).

Build:
    source path/to/emsdk_env.sh
    emcmake cmake -S src/ifcviewer-web -B build-web -G Ninja
    ninja -C build-web
    python3 -m http.server --directory build-web 8080
    # open http://localhost:8080/IfcViewerWeb.html in a WebGPU-capable
    # browser (Chrome 113+, Edge 113+).

Phase B step 3 of #45.
2026-06-04 18:58:34 +10:00
Gorgious56 25651a1507 Fix demo preset crash + scope header refresh
bpy.ops.bim.new_project(preset='demo') crashed in
refresh_bim_tool_headers: the post-commit hook fired for every
nested bpy.ops.bim.append_library_element during template
loading, and the operator context Blender hands to
programmatically-invoked nested operators is stripped of the
view-layer attributes the refresh reads.

Two changes resolve it.

Gate the header refresh in tool.Parametric.refresh_post_commit
on operator.bl_idname being one of the EDIT_TYPES finish_op
idnames. Only validate-gizmo commits (bim.finish_editing_<name>)
now trigger the refresh; demo-loader and other non-edit
operators skip it. Querying the registry directly is the
canonical signal — string-prefix matching would silently drift
if ParametricObject.finish_op changes derivation.

Harden tool.Blender.get_active_object so its view_layer fallback
also uses getattr; the 150+ callers routed through it now
tolerate stripped contexts. _resolve_bim_tool_context applies
the same defensive pattern to mode / workspace.

Tests:
- test_handler_restricted_context covers get_active_object's
  defensive path and the BimTool-family whitelist (excludes
  annotation, spatial, structural).
- test_handler_forward_compat AST-pins that the gate consults
  EDIT_TYPES (not a string prefix).
- test_wall_header_refresh rewritten — three tests cover the
  gated-by-registry contract: counter bumps for every commit,
  finish_op operators refresh headers, others don't.

Hotkey-driven in-place edits (S_E / C_E) no longer trigger the
refresh — they were caught by the pre-refactor "every commit"
design. Left out of scope; the new skip-non-finish test pins
this as intentional.

Generated with the assistance of an AI coding tool.
2026-06-04 10:42:58 +02:00
Dion Moult c098146c35 Remove Autodesk viewer examples
Remove the bonsaiviewer-autodesk Cargo examples that were used for local UI and dialog experiments.

Generated with the assistance of an AI coding tool.
2026-06-04 18:34:46 +10:00
Dion Moult fe6a0452bf ifcviewer: split out IfcViewerCore static library
Pull the Qt-free / OpenCASCADE-free files out of the IfcViewer target
into a new IfcViewerCore static lib: BufferPool, ChunkPlanner,
InstanceCompose, SidecarCache, StreamingLoader, StreamingThread,
LodBuilder, plus the header-only InstancedGeometry / ModelGpuData /
VertexQuantization / Selection / Visibility headers. IfcViewer PUBLIC-
links IfcViewerCore so existing consumers see no change.

This is the boundary the Emscripten web target will link against —
keeps Qt, IfcGeom, OpenCASCADE, CGAL, and Boost out of the wasm build.
Explicit file list, not glob, because the boundary is the whole point.
2026-06-04 18:29:15 +10:00
Dion Moult cb19f22ee4 BufferPool: drop Qt log dependency
Replace qInfo() growth-event logging with fprintf(stderr,...) so
BufferPool.cpp has no Qt touchpoints. Lets the test target drop its
Qt6::Core link too. Prerequisite for the IfcViewerCore library boundary
the web target will link against.
2026-06-04 18:20:37 +10:00
Dion Moult 1fe4570860 ifcviewer: extract ChunkPlanner + InstanceCompose; add Tier-1 test trio
The chunk planner (Morton sort + greedy pack) and instance composition
(federation × placement matrix chain + world-AABB derive) were inline
helpers in ViewportWindow.cpp. Pulled both out as free-function modules
so the math + lookup logic can be exercised without a Qt window or a
wgpu device. ViewportWindow now delegates; InstanceLookup is a using-
alias to InstanceCompose::InstanceLookup.

Also added an addSubBufferForTesting / clearSubPoolsForTesting seam to
BufferPool so the sub-allocator invariants can be pinned with fake
WGPUBuffer handles. The fakes are never dereferenced; the guard drops
the sub-pools before destructor would call wgpuBufferRelease.

Three new test binaries under src/ifcviewer/tests/, 33 cases / 173
assertions: BufferPool first-fit + alignment + coalescing + multi-
sub-pool isolation; ChunkPlanner Morton split / interleave / stable
sort / greedy-pack monotonicity and single-mesh-oversize; InstanceCompose
identity / translation / order-of-multiplication / large-placement
cancellation against federation false origin / column-major writeback /
findInstance lookup paths.
2026-06-04 17:19:24 +10:00
Gorgious56 94faaa3160 Drop dead Geometry.has_material_styles + sanitation sweep
Two related cleanups bundled because each was too small on its own.

== Drop dead Geometry.has_material_styles duplicate ==

Two parallel has_material_styles implementations existed on HEAD:

* Geometry.has_material_styles (tool/geometry.py:853, added by
  3483683cb "Add tool.Geometry helpers for body representation +
  placement"): checks each material via tool.Material.get_style
  for an IfcSurfaceStyle. This is the implementation gizmos-8088
  uses — its core/root.py:58 calls geometry.has_material_styles.

* Root.has_material_styles (tool/root.py:75, added by e76455913
  "Route _has_material_styles through tool.Root.has_material_styles"):
  checks each material for a HasRepresentation inverse. Added to
  fix the test/core/test_root.py::TestCopyClass::test_AAAAAAAAAAAA
  failure by routing the check through a Prophecy-mockable seam.

HEAD's core/root.py:59 calls root.has_material_styles. The Geometry
version became orphaned by that migration — zero callers historically
(git log -S "Geometry.has_material_styles" returns nothing). The
Root placement is the right architectural home: has_material_styles
pairs with assign_body_styles in the copy_class flow as "is there
material-defined styling? if not, apply body styling" — both
decisions live on the same interface, called in sequence from the
same caller.

The semantic delta (HasRepresentation vs IfcSurfaceStyle) is a close
approximation in real IFC files where HasRepresentation almost always
indicates a styled material; if precision becomes necessary, the
Root impl can be tightened independently of this cleanup.

Drop the Geometry method + its abstract declaration in core/tool.py.

== Sanitation sweep per CLAUDE.md §4a ==

Eight rot-prone references in code we authored on this branch get
their first-draft mistakes cleaned up. The §4a rule (no sibling
symbol names, no test paths, no motivation history in docstrings)
got added during this branch, so older commits sometimes named their
siblings in prose; this is a focused cleanup of the worst offenders.

* bim/module/model/wall.py:201 — _CommitWallDraftsFirstMixin
  docstring carried motivation history ("...that every multi-wall
  operator … used to repeat at the top of _execute"). Rewrite to
  describe only the current contract.

* bim/module/model/wall.py:1910 — cycle_type_operator comment named
  two sibling methods. Rephrase to describe what happens at the slot.

* bim/module/model/wall.py:2025 — _active_instances ClassVar comment
  named WallGizmoPreviewDecorator. Rephrase to "the wall-gizmo
  preview decorator" (role, not class).

* bim/module/drawing/gizmos.py:3402 — GizmoFillet hit_uses_bbox
  comment named GizmoWallJoinIntersection. Rephrase to "the wall-join
  gizmo group".

* bim/module/drawing/gizmos.py:3887 — GizmoCountLabel docstring had
  a :meth:`set_count` cross-reference. Drop — reader sees the method
  next to the class.

* bim/module/model/host_add_opening_gizmo.py:201 — poll-exclusion
  comment named GizmoWallEdition + GizmoRoofEdition. Rephrase to
  describe why we skip ("walls and parametric roofs both render
  their own toggle in the pen row").

* bim/module/void/operator.py:45 — preserve_placement comment named
  FilledOpeningGenerator.generate. Rephrase to "the filling-opening
  generator gates its snap-to-wall-axis block on this flag".

* bim/parametric_lifecycle.py:64 — module docstring named the test
  file path (test/bim/test_parametric_registry.py). Rewrite to
  "enforced by the registry contract tests".

Sweep otherwise clean: no third-party software names in this-branch-
authored comments (upstream Revit / Tekla / ArchiCAD references are
legitimate external-constraint workarounds, §4a-allowed). No
PR/issue numbers we authored except the FIXME(PR5) in
tool/parametric.py:150, deliberately preserved until PR6's MEP slice
resolves it.

Generated with the assistance of an AI coding tool.
2026-06-04 09:08:47 +02:00
Dion Moult 749476d1a7 docs: rewrite stale GL-era docs (env-vars + viewport_architecture)
Two long-stale docs that described the deleted OpenGL backend are
replaced with current-state rewrites under `src/bonsaiviewer/docs/`
and wired into the toctree. The originals are removed.

## env-vars.rst (replaces src/ifcviewer/settings.rst)

The orphan `src/ifcviewer/settings.rst` was written for the OpenGL
backend (`IFC_*` prefix, MDI-specific knobs) and was never wired into
any Sphinx toctree — it sat as a one-off file in the C++ source tree,
undiscoverable from a normal docs build.

* **Dead — dropped entirely.** `IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`,
  `IFC_SUBDRAW_DIAG` were GL-only `glMultiDrawElementsIndirect`
  instrumentation. wgpu has no MDI. `IFC_FPS_HITCH_MS` no longer
  exists in source.
* **Renamed.** `IFC_HIZ_MOTION` → `WGPU_HIZ_MOTION`,
  `IFC_CULL_THREADS` → `WGPU_CULL_THREADS`.
* **New, previously undocumented.** Ten `WGPU_*` vars added during
  the port + bring-up (WGPU_HIZ, WGPU_HIZ_TRACE, WGPU_MIN_PX,
  WGPU_MIN_PX_MOTION, WGPU_FLY_DEBUG, WGPU_NAV_PRESET,
  WGPU_PRESENT_MODE, WGPU_STREAM_DEBUG, WGPU_STREAM_DEEP_DEBUG,
  WGPU_STREAM_EVICT_LOG). Descriptions written from each variable's
  use-site so wording matches actual behaviour.
* **LOD-build section kept verbatim.** IFC_LOD_ERROR, IFC_LOD_RATIO,
  IFC_LOD_MIN_SAVINGS, IFC_LOD_DEBUG — sidecar-bake knobs,
  backend-agnostic.
* **GUI-promoted "old IFC_* graveyard" section dropped.** The file
  is an env-var reference, not a record of historical spellings.

## viewport_architecture.rst (replaces src/ifcviewer/README.md)

The 994-line `src/ifcviewer/README.md` was an archive of the GL-era
phase-by-phase perf narrative. ~95% of it described deleted code:
OpenGL 4.5 Core, `glMultiDrawElementsIndirect`, VAO/VBO/EBO,
`GL_ARB_shader_draw_parameters`, BVH-per-model, sidecar v5/v7/v9
(current is v13), the now-non-existent `./IfcViewer` binary, Phase
3F "static batching next" plans superseded by the chunk-pool
architecture, Phase 3E "GPU compute culling removed" since re-added
as task #17 pending. Salvaging the ~50 lines of still-correct
content would have left a Frankenstein doc internally contradicting
itself.

Replaced with a focused architecture page covering current reality:
consumer split (BonsaiViewer shell vs IfcViewerMinimal standalone),
stack (wgpu-native v29, Qt6, IfcOpenShell, IfcUtil, Eigen3,
meshoptimizer), five core ideas (unique-mesh instancing, quantized
12 B vertex, chunked streaming on a probed VRAM pool, sidecar v13
fast path, event-driven rendering), per-frame pipeline (cull →
upload → streaming → opaque pass → transparent pass → edge → overlay
→ present), federation + false-origin compose, file map limited to
files that actually exist in `src/ifcviewer/` today, build/run via
`build_viewer.sh`, cross-refs to env-vars.rst, debug-output.rst,
and connectors/.

## Toctree

`src/bonsaiviewer/docs/index.rst` gains `env-vars` and
`viewport_architecture` entries alongside the existing
`connectors/index` and `debug-output`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 15:57:40 +10:00
Dion Moult ce7d2fa329 docs: split Autodesk connector docs into user + developer pages
`autodesk.rst` had grown to mix end-user concerns (where do my tokens
live, how do I install the bundle, why isn't sign-in working) with
developer concerns (cargo build, fmt/clippy/test, packaging script
flow, per-OS toolchain notes, CI). Reorganise into:

* **`autodesk.rst`** — Autodesk Connector. User-facing. Bonsai-Viewer-
  level intro (Forma/APS/Docs, "Add from cloud"); install-from-zip
  per OS; first-run setup (client ID, OAuth port, browser redirect);
  where settings / cache / OAuth tokens live; proxy / TLS guidance
  for corporate installs.

* **`autodesk_development.rst`** — Autodesk Connector Development.
  Developer-facing. Tech stack (FLTK, ureq, keyring, dirs, serde,
  chrono, webbrowser); `cargo build --release`; `cargo test
  --all-features` / clippy / fmt-check; protocol probing via stdio
  pipe; packaging via `packaging/build.py`; per-OS build / keychain
  / codesign notes; CI workflow overview. Absorbs the entirety of
  the old `autodesk_packaging.rst`, which is removed.

`connectors/index.rst` toctree updated: `autodesk_packaging` →
`autodesk_development`. `cloud_sync_protocol.rst` untouched —
language-agnostic protocol spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 15:57:40 +10:00
Dion Moult 9d9f4054f6 bonsaiviewer-autodesk: replace Python connector with the Rust impl
The Python implementation of the Autodesk Forma connector
(bonsaiviewer_autodesk/) is deprecated. The Rust port that's been
maturing under src/bonsaiviewer-autodesk-rs/ is now the connector
and takes over the original folder name.

## File operations

* `git rm -r src/bonsaiviewer-autodesk` — drop the 18 tracked Python
  source/test/packaging files. (~6.5k untracked build artefacts in
  venv/build/dist/egg-info are removed too, but those were never in
  the index.)
* `mv src/bonsaiviewer-autodesk-rs src/bonsaiviewer-autodesk` —
  the Rust impl takes over the canonical folder name.
* `rm -rf src/bonsaiviewer-autodesk-rs-egui` — abandoned egui-based
  experiment, never committed.
* `src/bonsaiviewer-autodesk/.gitignore` extended with `/dist` to
  keep packaging output out of the index alongside the existing
  `/target` rule.

The Rust binary in Cargo.toml already has `name = "bonsaiviewer-
autodesk"` and `connector.json`'s `exec` field already points at that
name — so the connector loader, build_viewer.sh symlink, and
win/build-all-win.py CONNECTOR_DIR all keep working without edits.

## Packaging shape preserved

`packaging/build.py` is rewritten to:

  * shell out to `cargo build --release` instead of pyinstaller,
  * copy the produced binary + connector.json into the same
    `dist/autodesk/` layout the PyInstaller flow produced,
  * zip into `dist/autodesk-<os>-<arch>.zip` with the same
    naming pattern (CI artifact uploads keep working).

The Rust binary statically links its deps, so unlike PyInstaller
there's no `_internal/` directory — single executable inside
`dist/autodesk/`. Everything downstream (`build_viewer.sh` symlink,
`win/build-all-win.py collect_connector_files`, the zip step in
`build_rocky.yml`) only cares that `dist/autodesk/` exists, so the
on-disk contract is preserved.

Verified locally: `python3 src/bonsaiviewer-autodesk/packaging/build.py`
produces `dist/autodesk/{bonsaiviewer-autodesk, connector.json}`
(3.9 MB stripped ELF) and `dist/autodesk-linux-x86_64.zip` (~1.5 MB
compressed).

## CI updates

* `.github/workflows/build_rocky.yml` and `build_rocky_arm.yml`:
  drop the `pip install ".[build]"` step — `packaging/build.py` is
  stdlib-only now, the cargo build wrapped inside it does the work.
* `.github/workflows/build_win.yml`: same — drop pip install,
  packaging script handles cargo internally.
* `.github/workflows/build-bonsaiviewer-autodesk.yml`: full rewrite
  of the dedicated connector test/build workflow. Replaces the
  Python {3.11, 3.13} test matrix with `cargo fmt --check`,
  `cargo clippy --all-targets -- -D warnings`, and `cargo test
  --all-features`. The OS/arch build matrix is unchanged
  (linux-x86_64, macos-arm64, macos-x86_64, windows-x86_64) but
  installs a Rust toolchain via dtolnay/rust-toolchain@stable and
  caches target/ via Swatinem/rust-cache.

`win/build-all-win.py` and `build_viewer.sh` are unchanged — they
only reference the `dist/autodesk/` path, which the new
`packaging/build.py` populates identically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:59:43 +10:00
Dion Moult b3fbcd6a66 refactor: extract src/ifcutil/ from src/ifcviewer/ (Unit, Geolocation, Placement)
Unit / Geolocation / Placement are schema-agnostic IFC helpers ported
from ifcopenshell.util.{unit,geolocation,placement}. Nothing about
them is viewer-specific: pure IfcParse + Eigen, no Qt, no IfcGeom, no
renderer. Living under src/ifcviewer/ implies an unwanted dependency
direction every time a non-viewer caller (test_federation, the bonsai
SettingsView georef readout, a future standalone IFC tool) wants to
use them.

Move them to a new `src/ifcutil/` static lib (IfcUtil). The lib has
PUBLIC `target_include_directories(${CMAKE_CURRENT_SOURCE_DIR})` so
callers that link IfcUtil can keep `#include "Unit.h"` etc. without
relative-path adjustments — the include dir propagates transitively
via IfcViewer's PUBLIC link.

## Changes

* `git mv src/ifcviewer/{Geolocation,Placement,Unit}.{h,cpp}
   → src/ifcutil/` (history follows the rename).
* `src/ifcutil/CMakeLists.txt`: IfcUtil static lib, PUBLIC links
  IfcParse + Eigen3::Eigen, PUBLIC include dir.
* `cmake/CMakeLists.txt`: `add_subdirectory(../src/ifcutil ifcutil)`
  before ifcviewer/ so the link target exists when IfcViewer's
  CMakeLists runs.
* `src/ifcviewer/CMakeLists.txt`: IfcUtil added to IfcViewer's PUBLIC
  link_libraries.
* `src/ifcviewer/tests/CMakeLists.txt`: test_federation drops the
  explicit `${IFCVIEWER_SRC}/{Unit,Geolocation,Placement}.cpp`
  source list and links `IfcUtil` instead (matches how production
  code resolves the symbols).
* `src/bonsaiviewer/modules/models/SettingsView.cpp`: the two
  explicit `#include "../../../ifcviewer/{Geolocation,Unit}.h"`
  paths swap to `../../../ifcutil/…`. All other callers use bare
  `#include "Unit.h"` style and continue to work via the propagated
  include dir.

## Verification

* `ninja -C build-viewer` builds clean: IfcUtil + IfcViewer +
  IfcViewerMinimal + BonsaiViewer + all four pre-existing
  ifcviewer tests + the two from-wgpu tests.
* `test_federation` runs green: 226 assertions in 22 test cases
  pass with IfcUtil linked instead of the explicit-source compile.
* `git log --follow` traces e.g. `Geolocation.cpp` back through the
  rename to its prior location in src/ifcviewer/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:25:28 +10:00
Dion Moult 32d9fd6c1c build-all: restore full PYTHON_VERSIONS list
d390911d75 ("build_osx: build BonsaiViewer on macOS via build-all.py",
2026-06-01) accidentally committed a local single-version pin
(`PYTHON_VERSIONS = ["3.11.8"]`) intended only for fast iteration
during macOS bring-up. With macOS / CI green and the Python wrapper
fix from 748b4e72a landed, restore the full multi-version list so
both Rocky and macOS CI publish wrappers for 3.10/3.11/3.12/3.13/3.14
again.

Cost is ~5 from-source Python builds per CI run; the cache-deps
plumbing in nix/cache_dependencies.py already memoises these so
repeated runs only pay it once per Python release bump.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:04:14 +10:00
Dion Moult b123ee69d6 viewer: two-pass alpha transparency + Alt+X global x-ray cap
## The bug

FZK-Haus windows rendered fully opaque despite every piece of the
data path carrying alpha correctly: vertex format is RGBA u8x4,
InstanceCpu/InstanceGpu carry color_override_rgba8 with its alpha
byte, fs_main returns vec4(rgb, in.color.a). Cause: the main render
pipeline's color target had `blend = nullptr`, which in wgpu disables
the blend stage entirely — fragment RGBA overwrites the back buffer
unmodified, alpha discarded.

## Why "just enable blend" isn't enough

Two failure modes that don't go away with a one-liner:

1. `depthWriteEnabled = True` on the main pipeline would make a
   transparent window-frame pane occlude geometry behind it in
   depth, so the wall behind the window then fails the depth test
   and never draws — you'd see the silhouette of the window with
   whatever colour was in the back buffer before, not the wall.
2. Order-dependent blending across transparent surfaces in arbitrary
   cull order — overlapping transparent surfaces would shift colours
   as the camera moves.

Standard fix for a BIM viewer is two-pass opaque-then-transparent.

## What this commit adds

### Per-mesh "has any alpha < 255" classifier
* `ModelGpuData::mesh_has_alpha` (uint8_t vector, parallel to meshes).
* Sized in `applyCachedModel`.
* Populated in `applyStreamedChunk` by scanning each in-chunk mesh's
  vertex bytes for a vertex's alpha byte < 255 (offset 11 within
  the 12-byte vertex record — the 4th byte of the third u32, which
  the shader reads as `w2 >> 24`). Single chunk-arrival site covers
  both sidecar streaming and the worker-result drain. First-load
  IFC-without-sidecar geometry still routes opaque until the sidecar
  bake completes; A-path scan is deferred.

### Per-chunk opaque/transparent partition during cull
* `Chunk::opaque_visible_vertices` / `opaque_visible_draws`
  (per-frame counts).
* Transient `visible_draws_scratch_transparent` +
  `transparent_per_draw_vertex_counts` filled alongside the existing
  opaque half during the cull walk. Post-walk concat appends
  transparent entries onto the opaque half and continues the
  cumulative prefix-sum sequence — single buffer, single bind
  group, no doubling.
* Classifier inside the cull lambda:
    `xray_active ? always_transparent
     : override_active ? (override.alpha < 255)
     : mesh_has_alpha[mesh_id]`

### Per-chunk uniform layout extension
From `[total_draws, total_verts, 0, 0]` to
`[total_draws, total_verts, opaque_verts, opaque_draws]`. The third
slot is what `render()` passes as `firstVertex` to the transparent-
pass draw call so the shader's vid lands in the transparent range of
the same visible_draws_scratch buffer.

### `main_pipeline_transparent_`
Copy of `main_pipeline_` with `color_target.blend = SrcAlpha /
OneMinusSrcAlpha`. depthWriteEnabled stays True (see below).

### Two-pass `render()`
Opaque pass (`main_pipeline_`, firstVertex=0,
vertexCount=opaque_visible_vertices) then transparent pass
(`main_pipeline_transparent_`, firstVertex=opaque_visible_vertices,
vertexCount=total - opaque). Each loop skips empty halves so an
opaque-only chunk costs one draw call, transparent-only one draw,
mixed chunks two.

### depth_transparent.depthWriteEnabled = True (NOT off)

Initially set False (standard "let further-back geometry paint
through transparent front faces" trick) but that broke the edge-
detect pass: edge detection reads the depth buffer to find
silhouette discontinuities, and windows-without-depth meant the
glass had no silhouette at all (panes looked like framed holes) and
the edges of opaque geometry behind the glass painted through at
full intensity. Keeping the write avoids that — trade-off is depth-
test occlusion between transparent surfaces (closer occludes
farther), which for BIM panes that don't overlap in screen space
is invisible. Real fix for the overlap case is OIT or sort-back-
to-front, not depth-write toggling.

## Alt+X global X-ray (drops in basically free)

* `xray_alpha_cap` field on FrameUniforms + WGSL counterpart, default
  1.0 (no effect). fs_main clamps `out.a = min(in.color.a, cap)`.
* `ViewportWindow::xray_alpha_cap_` member, default 1.0. Alt+X
  toggles between 1.0 and 0.3.
* Cull classifier sees `xray_alpha_cap_ < 1.0` and forces every
  instance into the transparent pass so the blend stage actually
  fires (an opaque-pass fragment with capped alpha would still
  overwrite the back buffer).
* No per-instance state mutation needed — toggle is a single float
  in a uniform plus a re-cull. Excluding objects from x-ray later
  would mean tagging them so the classifier skips the force-
  transparent branch for them, also small.

Stress-tested on FZK-Haus: window glass visibly translucent with
correct silhouette edges; Alt+X turns the whole scene to a tinted
ghost of itself and back without artefact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:58:50 +10:00
Dion Moult 748b4e72a9 macOS: re-enable Python wrapper + stage IfcViewerMinimal.app bundle
Three coupled fixes that close the macOS bring-up loop:

## 1. ifcwrap: fix INSTALL_RPATH on Apple

The ifcopenshell_wrapper Python module had `INSTALL_RPATH "$ORIGIN"` set
for "NOT WIN32 AND NOT WASM_BUILD" — but `$ORIGIN` is a Linux ld.so
placeholder, not a macOS dyld one. macOS dyld doesn't expand it; it
bakes the literal string `$ORIGIN` into LC_RPATH, which resolves to
nothing at runtime. The wrapper's hard-link `@rpath/ifcopenshell
.document.rdb.dylib` then fails to load even though INSTALL(TARGETS …
LIBRARY DESTINATION "${python_package_dir}/ifcopenshell") above had
already dropped the plug-in dylib right next to the wrapper.

Split the rpath assignment: `@loader_path` on Apple (the dyld
equivalent of `$ORIGIN`), `$ORIGIN` elsewhere.

This is what b0ef47819 (the build_osx IFCOS_BUILD_PYTHON_WRAPPER=off
gate) was working around. The gate is removed below.

## 2. ifcviewer-minimal: stage IfcOpenShell + wgpu_native into the .app

IfcViewerMinimal.app was building on macOS via the cmake
`BUILD_BONSAIVIEWER → BUILD_BONSAIVIEWER_WGPU` promotion, but had no
bundle staging — Contents/Frameworks/ only contained the Qt
frameworks macdeployqt deposited, so the .app would refuse to start
("Library not loaded: @rpath/libwgpu_native.dylib").

Mirror what src/bonsaiviewer/CMakeLists.txt does for BonsaiViewer.app:

* Set INSTALL_RPATH to `@executable_path/../Frameworks` so the exe
  knows where to look for @rpath/* deps.
* install(FILES) libwgpu_native.dylib into the bundle's Frameworks/
  (globbed from WGPU_NATIVE_LIB_DIR rather than hard-coded so it
  covers any future versioned name).
* install(CODE) staging block that copies every `*.dylib` from
  <prefix>/lib/ into the bundle's Frameworks/, excluding the
  geometry-writer plug-ins (same EXCLUDE regex as BonsaiViewer.app —
  viewer doesn't need OBJ/glTF/DAE/STP/IGS/SVG/TTL export converters).

Same long-form rationale + caveats apply (macdeployqt doesn't follow
non-Qt @rpath deps, lib-prefixed core libs vs ifcopenshell.* plug-in
naming split, Linux's equivalent lives in build_rocky.yml workflow
bash via patchelf + stage_runtime_payload). See src/bonsaiviewer/
CMakeLists.txt for the full version.

## 3. build_osx.yml: drop IFCOS_BUILD_PYTHON_WRAPPER=off

With (1) fixed, the Python wrapper smoke test should pass again. The
gate goes away; the comment block in build_osx.yml is replaced with a
short note pointing at the ifcwrap rpath fix as the underlying change
that re-enables this.

Together, (1)+(2)+(3) close the standalone IfcViewerMinimal-on-macOS
gap (task #43) and re-enable IfcOpenShell-Python on the macOS arm64 CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:31:45 +10:00
Dion Moult 8ab5c31e75 refactor: merge ifcviewer-wgpu into ifcviewer, drop Wgpu prefix
The GL backend is gone (task #53). The wgpu/non-wgpu folder split and
the Wgpu* class prefix were both disambiguation artefacts from the
overlap period — now pure dead weight.

## Folder + library merge

* `src/ifcviewer-wgpu/`  → folded into `src/ifcviewer/` (git mv tracks
  every file as a rename so blame/log history survives).
* `src/ifcviewer-wgpu-minimal/` → `src/ifcviewer-minimal/` (the exe was
  already named `IfcViewerMinimal`; this just brings the folder + CMake
  target name into line).
* `src/ifcviewer-wgpu/tests/test_wgpu_{selection,visibility}.cpp` →
  `src/ifcviewer/tests/test_{selection,visibility}.cpp`, folded into
  the existing `add_ifcviewer_unit_test(...)` helper.
* The `IfcViewerWgpu` static library is dissolved — its sources become
  part of the unified `IfcViewer` static library, which now bundles
  scene/loader + renderer in one target. The pre-merge circular
  dependency (IfcViewer linking IfcViewerWgpu just to get the
  ViewportWindow.h include path that SceneLoader.h needs) goes away.
* The wgpu-native FetchContent block, the Cocoa/QuartzCore link on
  Apple, the OBJCXX-enabled `.mm` source, and the wgpu-native runtime
  install all move into `src/ifcviewer/CMakeLists.txt` unchanged.

## Type renames (Wgpu prefix dropped from every Wgpu* identifier)

  WgpuAreaMeasurement   → AreaMeasurement
  WgpuBufferPool        → BufferPool
  WgpuLengthMeasurement → LengthMeasurement
  WgpuMetalSurface      → MetalSurface
  WgpuModelGpuData      → ModelGpuData
  WgpuOverlayFrame      → OverlayFrame
  WgpuOverlayRenderer   → OverlayRenderer
  WgpuSectionPlane      → SectionPlane
  WgpuSelectionState    → SelectionState
  WgpuStreamingLoader   → StreamingLoader
  WgpuStreamingThread   → StreamingThread
  WgpuViewportWindow    → ViewportWindow
  WgpuVisibilityState   → VisibilityState

  CMake target IfcViewerWgpuMinimal → IfcViewerMinimal (exe name was
  already this since wgpu shipped as default).

Deliberately kept: `onWgpuLog` (wgpu-native log callback — names a
binding to an external API, not one of *our* types), and the WGPU*
enum/struct prefixes from wgpu-native's own headers. `WgpuMemProbe`
lives in the separate `src/wgpu-mem-probe/` standalone diagnostic
project and isn't touched.

## Include-path updates

Every `#include "../ifcviewer-wgpu/Wgpu<X>.h"` → `"../ifcviewer/<X>.h"`,
every in-directory `#include "Wgpu<X>.h"` → `"<X>.h"`. Includes from
sibling subdirectories (modules/, etc.) are updated to point at
`../../../ifcviewer/` instead of `../../../ifcviewer-wgpu/`.

## cmake/CMakeLists.txt simplification

The redundant `add_subdirectory(ifcviewer-wgpu)` blocks (one inside
the BUILD_BONSAIVIEWER fan-in, one in the BONSAIVIEWER-less standalone
block) collapse into a single unconditional
`add_subdirectory(../src/ifcviewer ifcviewer)`. The standalone block
keeps only `wgpu-mem-probe` (the diagnostic tool, unrelated to the
viewer lib).

## Verification

* Full build green: `IfcViewer` static lib, `IfcViewerMinimal` exe,
  `BonsaiViewer` exe, all four pre-existing ifcviewer unit tests, and
  the two new-location tests (`test_selection`, `test_visibility`).
* No stray `Wgpu<X>` identifier remains across `src/ifcviewer/`,
  `src/bonsaiviewer/`, `src/ifcviewer-minimal/` (verified by grep).
* Renames tracked by git as `R` entries — `git log --follow` on
  ViewportWindow.cpp etc. continues to show history through the move.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:11:14 +10:00
Dion Moult 7f87408b78 bonsaiviewer: rework add-models false-origin guess + drop fly-mode input lag
Two independent threads that landed on this branch.

## 1. Federation false-origin guess: arm-on-add + frame-on-origin

The cc54237f5 fix moved the guess off the refresh() fan-in (terminating
the stack-overflow recursion on Holter Tower), but the gating was still
`modelIds().size() == 1` — broken for batch-add-into-empty-session
because the session registry is populated synchronously by all
addModel calls before any modelGeometryReady lands. Result: load 3
files at once → none of the per-model geometry-ready events ever
finds `size() == 1` → guess never fires → camera + federation origin
stay at surveyor coords.

Rework:

* **Arm/consume at the command boundary.** modules/models/Commands
  exposes `armFederatedFalseOriginGuess` / `consumeFederatedFalseOrigin
  Guess` (function pair — arming is a one-shot, raw-bool would let a
  peek-without-clear silently break the contract). `addModel` and the
  cloud-callback in `addModelFromCloud` arm if `modelIds().isEmpty()`
  at the moment they're about to register federation entries. The
  first geometry-ready then consumes the arm and runs the guess —
  batch add or single, works the same way.

* **Lazy first geometry point from mesh AABB centre.** Drop
  SceneLoader's `firstPlacement(mid)` / `first_placement` /
  `has_first_placement` and the two capture sites entirely. The
  viewport keeps CPU-side MeshInfo + InstanceCpu for picking /
  measurement; compute the anchor on demand via new const accessor
  `WgpuViewportWindow::firstGeometryPointWorldM(mid, out)` =
  instance0.placement × meshes[instance0.mesh_id].aabb_centre.
  This is more representative than the placement translation
  (placements often live far from the actual geometry due to long
  ObjectPlacement chains / intermediate local frames), and lighter
  storage-wise (lazy, vs. 128 B per model held just-in-case).

* **`guessFederatedFalseOrigin` math signature: Matrix4d → Vector3d.**
  The function only ever consumed `.block<3,1>(0,3)`; the Matrix4d
  API surface was a strictly-larger-than-necessary contract. Vector3d
  matches what the function actually needs.

* **`WgpuViewportWindow::frameOnFederatedOrigin(mid, max_distance_m)`**
  replaces the post-shift use of viewAll() that the ViewportView
  almost reached for. The federated false origin sits at (0,0,0) in
  post-shift space by construction, so the camera targets there
  directly; distance fits the model's post-shift AABB diagonal with
  viewAll's padding math, clamped to `max_distance_m` so a model
  with one crazy-coord outlier vertex can't pull the camera back so
  far the real geometry becomes a pixel. Called with 100 m cap from
  the guess. Unlike viewAll() this only iterates the one model the
  guess fired for — the "load 10 models, viewAll shows nothing"
  failure mode is structurally avoided.

* **ViewportView::tryGuessFirstModelFalseOrigin** renamed to
  `guessFederatedFalseOriginFromFirstModel` and the body restructured
  to consume the arm, look up the anchor via the viewport, mutate
  the federation origin (which propagates through SessionState's
  federatedFalseOriginChanged relay → refresh() → recompose all
  instance world AABBs), then `frameOnFederatedOrigin(mid, 100)`.

* **Internal guards preserved.** filePath skip (project files own
  the origin), current==defaults skip (don't clobber a user who
  set the origin manually then removed the model), placement /
  georef availability checks — defense-in-depth around the arm, not
  the primary gate. The arm-only flow means re-arming on add-into-
  empty-session is naturally re-firable: add → remove → add will
  retry if the previous guess returned defaults.

## 2. wgpu present-mode: prefer Immediate above FifoRelaxed

On Linux Vulkan stacks where the driver / compositor doesn't advertise
Mailbox (confirmed on the user's setup — capability log added in this
patch reports just `fifo, fifo_relaxed, immediate`), Fifo's 2–3 frame
queue doubles input-to-photon latency the moment WASD activates
(~16 ms render-body fully consumes the budget, so the queue is held
deep). On a 60 Hz display this reads as "less smooth than the 100 fps
HUD suggests" during fly-mode mouse-look-while-moving — confirmed by
WGPU_FLY_DEBUG dt traces (rock-solid 16-17 ms cadence, so it's not
frame pacing — it's latency).

Promote Immediate above FifoRelaxed in the preference order so that
when Mailbox is unavailable we pick the no-queue option (can tear
under fast motion, but tearing on architectural geometry is usually
invisible while the latency win is immediately felt). Also log the
full advertised capability list on first configure so future "why
isn't Mailbox available?" diagnostics don't need a code patch.

Mailbox remains first preference; Fifo remains the spec-guaranteed
final fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:02:53 +10:00
Gorgious56 0e922074b9 Adopt _CommitWallDraftsFirstMixin on 7 wall operators
The 7 multi-wall operators (UnjoinWalls, UnjoinWallPathConnection,
ExtendWallsToUnderside, ExtendWallsToWall, SplitWall, MergeWall,
JoinWallsIntersection) each opened their _execute with an identical
prologue:

    _commit_pending_wall_edits_for_selection(context)
    # ... operator-specific logic

— flushing any in-progress wall parametric drafts so the operator
acts on committed IFC state rather than the draft preview box.

Extract that prologue into _CommitWallDraftsFirstMixin: its _execute
calls the commit helper, then delegates to a subclass-supplied
_perform. Subclasses inherit the mixin first in their bases tuple so
the mixin's _execute resolves first via the MRO. The IFC transaction
opened by tool.Ifc.Operator.execute still wraps both the commit and
the perform.

Behaviour-equivalent — same call, same order, same selection scope.
Architectural cleanup only: a future multi-wall operator can no
longer forget the commit step. The named helper
_commit_pending_wall_edits_for_selection stays as the single
encapsulation of the names=("wall",) filter; its docstring loses
the stale "every multi-wall operator calls it at the top of
_execute" sentence and now just describes the filter contract.

Matches gizmos-8088's _CommitWallDraftsFirstMixin pattern.

Generated with the assistance of an AI coding tool.
2026-06-03 17:15:45 +02:00
Gorgious56 90ea256cc3 Shift-click add-opening preserves filling placement
The regular bim.add_opening click on the host-add-opening gizmo
(wall + door/window co-selected) routes through
FilledOpeningGenerator.generate, which snaps the filling to the
wall's reference-line axis, optionally rotates 180° when the
filling sits on the opposite side, and re-applies an rl1 / rl2
Z-elevation default. That is the right default for "drag a fresh
door onto a wall and let the model place it for me", but defeats
the workflow where the user has already positioned the filling
precisely (e.g. snapped to a window in an adjacent wall, copy-
pasted at an exact Z, aligned to a reference object).

Holding SHIFT while clicking the gizmo now opts into a
"preserve placement" mode: the filling stays at its current
matrix_world and the opening is created at the filling's existing
position. The opening / filling rels and representation work are
unchanged — only the snap-to-axis branch is skipped, so the IFC
graph is identical to the regular click; only the spatial
position of the filling differs (user-chosen vs auto-snapped).

Implementation:

* bim/module/void/operator.py: AddOpening gains a hidden
  preserve_placement BoolProperty + an invoke() that sets it from
  event.shift. The call into FilledOpeningGenerator.generate
  forwards the flag. bl_description documents the SHIFT modifier
  so it surfaces in F3 search / hover tooltip.

* bim/module/model/opening.py: FilledOpeningGenerator.generate
  accepts preserve_placement (default False — backwards-compatible
  with the other caller, tool.Model.add_filled_opening). The
  voided_obj.data-gated snap block (raycast + axis projection +
  rl-Z default + filling_obj.matrix_world write) skips entirely
  when the flag is True. The opening's matrix_world reads from
  filling_obj.matrix_world below the gate, so the opening lands
  at the filling's preserved position automatically.

Generated with the assistance of an AI coding tool.
2026-06-03 16:44:38 +02:00
Gorgious56 387bd51b4a Use menu pick gizmo for door / window / stair type
The door / window / stair edit-row's type-cycle icon advanced one
type per click (CycleDoorType / CycleWindowType / CycleStairType
bound to cycle_type_operator). DoorType has 8 IFC variants,
WindowType 9, StairType 3 — so cycling past the target was the norm.

Threshold rule for cycle-vs-menu: cycle is appropriate for exactly 2
values (advance-one-per-click stays predictable). Three or more
values warrants a popup menu. Door / window / stair all qualify;
roof (RoofGenerationMethod has 2 values) keeps cycle. Wall has no
type cycle. Array is unaffected.

Swap to the popup-menu pattern (PickTypeMixin already on HEAD at
bim/parametric_lifecycle.py:442): clicking the icon opens a menu
listing all type_literal values; selecting one applies it in a
single undo step. The hamburger icon (VIEW3D_GT_menu) is wired into
BaseParametricGizmoGroup.setup_editing_gizmos whenever
pick_type_operator is set (mutually exclusive with
cycle_type_operator). Matches gizmos-8088's pattern exactly.

Per-feature shape:

* door.py: PickDoorType replaces CycleDoorType.
  GizmoDoorEdition.cycle_type_operator → pick_type_operator.
* window.py: PickWindowType replaces CycleWindowType. Same swap.
* stair.py: PickStairType replaces CycleStairType (no
  tool.Ifc.Operator inheritance — stair-type changes
  BIMStairProperties only, no IFC mutation). Same swap.
* bim/module/model/__init__.py: registration entries renamed
  Cycle* → Pick*.
* bim/module/drawing/gizmos.py: drop the
  CycleTypeMixin / PickTypeMixin / TypeAccessorBase shim re-export —
  its own docstring already noted "PR5 cleanup drops these" and the
  three callers (door / window / stair Cycle*Type) it served are
  gone. Roof's CycleTypeMixin import was already direct from
  bim.parametric_lifecycle. Also update GizmoMenu docstring to
  reflect the 2-vs-3+ threshold.

Generated with the assistance of an AI coding tool.
2026-06-03 16:07:13 +02:00
Gorgious56 de4c394b50 Add host-wall offset gizmos for door/window edit
When entering parametric edit on a door or window that fills a
wall opening, four dimension gizmos now measure the distances
from the wall edges to the filling's jambs and from the wall's
base/top to the sill/header. Dragging any gizmo translates the
filling along the wall's local axis; 180°-flipped fillings and
slanted LAYER2 walls round-trip correctly. The has_host_wall
predicate hides all four when the filling → opening → wall
chain cannot be resolved.

Generated with the assistance of an AI coding tool.
2026-06-03 15:34:48 +02:00
Gorgious56 ab64b652ff Show wall cursor gizmos outside edit mode + axis previews
Four concerns that together make the cursor-anchored gizmos
(extend_x_gizmo, extend_z_gizmo, split_gizmo on GizmoWallEdition)
fully functional and visually informative without entering parametric
edit mode first:

* Drop the props.is_editing gate in _update_cursor_gizmos. The three
  bound operators (bim.extend_wall_to_cursor,
  bim.extend_wall_height_to_cursor, bim.split_wall_at_cursor) already
  poll on wall-selected and commit any pending wall edit before
  acting, so single-click without entering edit mode is now the
  canonical flow. Matches gizmos-8088's always-on behaviour.

* Register GizmoWallEdition instances in a per-region weakref map
  (_active_instances) populated at setup_element_specific_gizmos
  time. The WallGizmoPreviewDecorator dereferences this map to read
  live is_highlight state off the cursor icons. Without the
  registration its _cursor_icon_hovered always returned False and
  the hover-gated GPU previews silently never drew. Mirrors the
  same pattern already in place on GizmoWallJoinIntersection.

* Add post-operator resync to all three cursor operators
  (_maybe_resync_wall_props_from_ifc for the single-wall split /
  extend-height paths, _resync_walls_after_mutation for the
  selection-wide extend-X path). Without this, props.length /
  props.height stayed stale after the operator ran, so the
  orientation flips _apply_wall_extend_flips computes from
  cursor_local vs wall dimensions kept using the pre-extend values
  until the next selection change. Matches gizmos-8088's pattern.

* Hover-gated GPU previews per icon:

  - extend-X: filled Z=0 floor quads spanning the wall's offset to
    offset+thickness Y band, visible from plan view without side-
    view clutter. Grow case (cursor beyond either endpoint): one
    green decorator_color_selected quad over the extension. Shrink
    case (cursor inside extent): green quad for the portion that
    REMAINS + red decorator_color_error quad for the portion the
    operator REMOVES.

  - extend-Z: vertical lines at the cursor's projected X in the
    wall's y=0 reference-line plane. Grow case (cursor above wall
    top): one green segment from z=height to z=cursor.z. Shrink
    case: green from z=0 to z=cursor.z (REMAINS) + red from
    z=cursor.z to z=height (REMOVES).

  - split: one red vertical line at the cursor's projected X from
    base to wall top — the cut plane.

  Quads use QUAD_ALPHA=0.25 so the underlying wall body stays
  visible.

* New module-level _fill_quads_alpha helper next to
  _stroke_lines_alpha, plus a per-decorator _fill convenience method
  and a _wall_floor_quad corner builder.

Modal-active gizmo hiding (is_gizmo_hidden_by_modal) is preserved.

Generated with the assistance of an AI coding tool.
2026-06-03 13:57:39 +02:00
Gorgious56 99bb1e30ad Generalise opening gizmos + DRY toolbar plumbing
Add openings — GizmoWallAddOpening only fired when a wall was active +
co-selected with a non-host; slabs and roofs got no in-viewport handle.
GizmoHostAddOpening covers all three host types via is_supported_host,
dispatching walls to the axis-projection anchor and slabs/roofs to a
world-Z anchor lifted just above the host's top face (predictable
height regardless of the void's vertical position).

Show openings on hosts with their own parametric-edit toolbar —
GizmoRoofEdition gains an idle-row toggle_openings_gizmo parallel to
the wall's, parked at the cancel-slot X next to the pen. Visible only
when the host carries HasOpenings and the edit triad is idle. Roof
overrides get_element_height to return the mesh's world-AABB top in
object-local Z, so the WHOLE pen-row anchors visibly above sloped or
stepped roof bodies. The wall's idle-row toggle now also hides when
HasOpenings is empty.

Show openings on hosts WITHOUT a parametric-edit toolbar —
GizmoHostToggleOpenings scoped strictly to the fallback case: a single
host selected, HasOpenings non-empty, NOT a path-connectable wall, NOT
a parametric roof. Covers slabs today plus any foreign-authored IfcRoof
without BBIM_Roof. Anchored at object origin XY + world-AABB top Z.
When slab parametric-edit eventually lands, the slab predicate joins
the exclusion list and this gizmo's poll narrows automatically.

Operator move — ToggleWallOpenings was already host-agnostic; renamed
to ToggleHostOpenings in opening.py (bl_idname bim.toggle_host_openings).
Three callers (the wall idle-row binding, GizmoWallFilletToggleOpenings,
and workspace.py's hotkey_A_O for Alt+O) now route through the renamed
operator. The Alt+O binding is surfaced in the operator's
bl_description so it appears in F3 search and hover tooltips.

DRY refactors —
* GizmoWallAddOpening deleted (subsumed by GizmoHostAddOpening)
* tool.Blender.get_object_world_bounding_box added as the world-AABB
  sibling of the existing local helper; 3 inline call sites in
  tool/misc.py (set_object_origin_to_bottom, scale_object_to_height)
  and gizmos.py adopt it (2 other sites in drawing/operator.py and
  project/operator.py inherently need raw transformed corners for
  per-corner plane / NDC tests — not AABB candidates)
* BaseParametricGizmoGroup gains setup_pen_row_toggle_openings_icon +
  update_pen_row_toggle_openings_icon; wall + roof + any future host
  gizmo wire up the idle-row toggle with two one-line calls
* _resolve_active_host shared poll prologue between the two host
  gizmos (gate + selection count + active-in-selected + entity lookup
  + supported-host check)
* HasOpenings non-empty checks at 3 sites route through
  tool.Geometry.has_openings
* hotkey_A_O body collapsed to bpy.ops.bim.toggle_host_openings()

The forward-compat AST guard pinning "must accept fillet-corner walls"
retargets from GizmoWallAddOpening.poll to is_supported_host.

Generated with the assistance of an AI coding tool.
2026-06-03 12:39:17 +02:00
Dion Moult 2b91e41fc4 bonsaiviewer: stage all IfcOpenShell dylibs (core + plug-ins) on macOS
The .app bundle's Frameworks/ staging rule was only globbing
ifcopenshell.*.dylib (the dlopen-only plug-ins) on the assumption
that macdeployqt would follow BonsaiViewer's link-time @rpath deps
for the lib-prefixed core shared libs. In practice it doesn't —
non-Qt @rpath deps whose source path is outside the standard system
/ Qt prefixes get skipped silently.

In a static build this didn't matter: libifcopenshell.geometry,
libIfcParse, libIfcViewer, etc. were statically embedded in
BonsaiViewer.exe, so there was no runtime dep. With --shared (added
in ddee88bed for the bundle-size win) they're separate dylibs that
must physically live in Frameworks/, or the binary won't even start:

    dyld: Library not loaded: @rpath/libifcopenshell.geometry.dylib
      Reason: tried '…/BonsaiViewer.app/Contents/Frameworks/
              libifcopenshell.geometry.dylib' (no such file)

Broaden the install(CODE) glob to *.dylib so both flavours land:

  * lib-prefixed linked core libs (libifcopenshell.geometry.dylib,
    libIfcParse.dylib, libIfcViewer.dylib, lib<mapping/kernel>.dylib)
  * non-prefixed plug-ins (ifcopenshell.parse.schema.ifcXxX.dylib,
    ifcopenshell.geometry.mapping.ifcXxX.dylib, etc.)

<prefix>/lib/ is IfcOpenShell-exclusive (Qt / boost / eigen live in
their own brew / build prefixes), so the broad glob doesn't risk
sweeping in unrelated dylibs. The geometry-writer EXCLUDE regex is
preserved so the size win from the writer-skip side of ddee88bed
stays in place.

In a static build the lib/ directory simply has no *.dylib files
that match, so the rule no-ops cleanly — same code path is safe for
both --shared and the (unused but possible) default static config.

Linux didn't need a counterpart: build_rocky.yml already does the
equivalent in workflow bash (`patchelf --set-rpath '$ORIGIN'` +
`stage_runtime_payload`), and local Linux dev uses CMake's
BUILD_RPATH which auto-resolves to the build subdirectories. macOS
.app bundles are treated as opaque by the packaging step (no
stage_runtime_payload against Contents/), so the staging has to
happen at CMake install time when the bundle is being assembled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:29:59 +10:00
Dion Moult cc54237f51 viewport: move first-model false-origin guess out of refresh()
Loading a model whose first placement sits at the world origin
stack-overflowed BonsaiViewer instantly on Windows (and macOS).
WinDbg trace was a 5-frame Qt signal-slot cycle hitting the guard
page ~1400 levels deep; Linux escaped only because that machine's
iterator order put a non-origin instance first, which made the guess
return a non-default value and naturally terminated the recursion
after one step.

Root cause is the architecture, not the specific guard inside the
guess function. `ViewportView::refresh()` was connected to six
SessionState signals (projectReset, projectOpened, modelsChanged,
federationChanged, visibilityChanged, modelGeometryReady) and was
calling `maybeGuessFederatedFalseOrigin` on every model on every
fire. That helper called `session_state_->notifyFederationChanged()`
unconditionally after the mutation, which re-emitted
SessionState::federationChanged, which re-entered refresh(), which
re-entered the guess — a hidden emit-in-slot loop. The "current ==
defaults" guard at the top of the guess prevented further mutations
once the value moved off defaults, but on machines where the guess
itself returned defaults the guard never fired and the loop ran
forever.

Cleanup:

* refresh() is now terminal: it reads federation state, pushes it to
  the viewport, and returns. No mutations, no signal emissions.
  maybeGuessFederatedFalseOrigin is removed from its for-loop.

* The guess is renamed to `tryGuessFirstModelFalseOrigin(uint32_t)`
  and is now invoked only from the modelGeometryReady connection,
  not from refresh(). Conditions:
    1. modelIds().size() == 1 (the just-loaded model is the only
       model — i.e. this is the "first model added" edge)
    2. federation->federatedFalseOrigin() == defaults (nobody has
       set the origin yet — possibly because the previous attempt
       guessed defaults and no-op'd, in which case we deliberately
       want to retry next time a model lands)
  No one-shot flag: add→remove→add cycles re-attempt the guess
  precisely while the origin is still default, which is the right
  semantics.

* SessionState now relays Federation::federatedFalseOriginChanged
  onto its own bus via notifyFederationChanged. This replaces the
  manual `session_state_->notifyFederationChanged()` call the old
  guess made post-mutation. With the relay in place, any future
  mutation site (commands, settings dialog, project load) will
  propagate to views automatically — the emit point lives at the
  data change, not at every caller. Views still subscribe to
  SessionState only; Federation stays a back-end detail.

Reproduced with ISSUE_053_20181220Holter_Tower_10.ifcview on
Windows (build 21d3945, WinDbg `kn30` showed the recurring cycle
explicitly). Diagnosis confirmed on Linux by adding tracing prints
in refresh() and the guess body: same model, same code, but the
iterator's first-placement happened to be non-origin so the loop
terminated after one step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:19:17 +10:00
Dion Moult ddee88bed3 build_osx: --shared + skip geometry-writer plug-ins (~3x bundle shrink)
Mirrors the Rocky workflow's two-part size reduction (27249770e
"Reduce Rocky package size") on macOS:

1. Pass `--shared` to nix/build-all.py. The default builds
   IfcOpenShell as static libs, which means every plug-in dylib
   (schemas × 8, kernels × 3, mappings × 8, writers × 8, document
   serializers × ~4, linework processing) statically embeds a full
   copy of libIfcParse + libIfcGeom. With --shared the plug-ins
   reference @rpath/libIfcParse.dylib + @rpath/libIfcGeom.dylib and
   the per-plug-in dylib drops from ~30-50 MB to a few MB each.
   Dominant size win.

2. Filter `ifcopenshell.geometry.writer.*.dylib` out of BonsaiViewer's
   plug-in staging step in src/bonsaiviewer/CMakeLists.txt. These are
   the per-schema OBJ / glTF / DAE / STP / IGS / SVG / TTL export
   converters — heavy because each one inlines the full schema, and
   BonsaiViewer is a viewer, never an exporter, so they're pure
   deadweight inside the bundle. Additive on top of --shared.

Bundle went 300 MB → expected ~100 MB, in line with Linux (~100 MB)
and Windows (~80 MB).

The IFCOPENSHELL_BUILD_PYTHON_WRAPPER=off gate is unchanged for now
— once we confirm BonsaiViewer.app size + functionality look sane,
we can ungate the Python wrapper and see if shared-builds-on-macOS
shake out its install issues too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:06:42 +10:00
Gorgious56 1707c36bd8 Stack cursor-anchored wall gizmos along screen-up in top view
The extend-X / extend-Z / split icons share the cursor's projected X
on the wall axis, separated only by world Z (floor / cursor / wall
top). World Z collapses to a single screen point in plan view, so
every icon piled onto extend-X's hit target and only the topmost was
clickable.

Two refinements ported from gizmos-8088:

* When ``tool.Blender.is_view_top_down(context)`` reports the camera
  is near plan-view, swap world-Z stacking for screen-up stacking:
  anchor all icons at the floor world position and offset each by
  ``index * CURSOR_STACK_OFFSET`` along ``tool.Blender.get_screen_up_world(context)``.
  Each icon lands in its own screen-space slot regardless of view
  rotation.
* In the same top-down branch, drop ``extend_z_gizmo`` entirely. A
  vertical-intent gizmo has no readable cue when looking down +Z —
  clicking it would mutate the wall in a direction the user can't
  see change.
* Bonus: split's local Z now goes through
  ``core.extrusion_depth_from_vertical_height(props.height, props.x_angle)``
  so the icon lands on the slanted top edge of sloped walls (x_angle
  != 0) instead of the vertical-height target the wall isn't at.

All three helpers (``is_view_top_down``, ``get_screen_up_world``,
``extrusion_depth_from_vertical_height``) already on HEAD from PR2/PR3.
Non-top views unchanged — same world-Z stacking + cascading bumps as
before.

Generated with the assistance of an AI coding tool.
2026-06-02 19:22:38 +02:00
Gorgious56 44f5ee028f Port WallGizmoPreviewDecorator from gizmos-8088
Hover-gated viewport preview lines that show where a wall-join /
extend / split operator would land before the user clicks. Four
preview paths, each gated on a specific icon's ``is_highlight`` state:

* **Join intersection** — two LAYER2 walls selected in the ``intersect``
  state (non-joined, non-collinear, non-parallel). Draws four lines:
  each wall's axis at both base and top Z, extending from the wall's
  nearer endpoint to the projected XY intersection. The pair of lines
  per wall communicates the full plane the join welds at, not just
  the floor edge.
* **Cursor extend** — single LAYER2 wall, hover on ``extend_x_gizmo``.
  One line from the wall's nearer X endpoint to the cursor's projected
  X on the wall axis.
* **Cursor extend-Z** — hover on ``extend_z_gizmo``. Vertical line at
  the cursor's projected X from wall base to cursor Z (the new total
  height).
* **Cursor split** — hover on ``split_gizmo``. Vertical line at the
  cursor's projected X from wall base to wall top — the cut plane.
  Warning-red colour matches the icon's destructive-action signal.

Hover colour rules for the join preview:

* **Join or Fillet hover** → all four lines highlight in
  ``decorator_color_selected``. Both icons commit a symmetric corner
  meet, so every line is part of the operation.
* **Extend-to-Wall hover** → only the non-active wall's two lines
  (base + top) highlight. The default-direction extend operator
  moves the non-active wall into the active one's axis; only that
  wall's preview should signal motion.
* No hover → all four lines in ``decorations_colour``.

Three coordinated changes:

* ``bim/module/model/wall.py`` gains the ``_classify_wall_join_state``
  wrapper over ``core.classify_wall_join_state`` (feeds the
  ``_are_walls_joined`` flag the core helper expects) AND a
  ``_active_instances`` per-region weakref ClassVar on
  ``GizmoWallJoinIntersection`` populated in ``setup()``. Without the
  weakref registration, the decorator's ``_lookup_active_instance``
  call returns None every frame and the hover gates silently
  evaluate False — the symptom would be preview lines that never
  switch colour. Both pieces ported from gizmos-8088.
* ``bim/module/model/decorator.py`` gains
  ``WallGizmoPreviewDecorator`` (~280 LOC across the four preview
  paths + shared helpers ``_stroke`` /
  ``_active_layer2_wall_for_gizmo_preview`` /
  ``_join_group_hover_state`` / ``_extended_wall_index``). All
  cross-file dependencies (``core.classify_wall_join_state``,
  ``core.wall_join_preview_lines``, ``_stroke_lines_alpha``,
  ``_cursor_icon_hovered``, ``_lookup_active_instance``,
  ``tool.Parametric.is_path_connectable_wall``,
  ``_wall_axis_world_segment_from_geom``) already on HEAD.
* ``bim/handler.py`` wires ``WallGizmoPreviewDecorator.install()`` /
  ``.uninstall()`` alongside the other always-on preview decorators.
  The decorator self-polls every frame; cost is one selection-count
  check + one ``is_highlight`` read when no eligible state is active.

Verified: headless smoke green, ruff + black clean. Live testing
confirms the four preview paths fire correctly when hovering each
icon.

Generated with the assistance of an AI coding tool.
2026-06-02 17:30:13 +02:00
Gorgious56 ed7b2fc233 Stack wall-join trio along screen-up + L/T glyphs
GizmoWallJoinIntersection used to place its icons at state-specific
world points: join at floor Z, extend-to-wall at the active wall's
top Z, fillet stacked screen-up above join. Same XY at different Z
collapses to a single screen pixel in plan / top view, so two icons
became one hit target — invisible from above.

* position_gizmos now always-stacks along screen-up at a wall-top
  anchor in both the joined (unjoin + fillet) and the intersecting
  (extend + join + fillet) states. Order bottom-up is
  extend / L / fillet. Collinear-merge keeps its single boundary
  icon (no stack needed).
* New _stack_anchor_z picks the active wall's top Z (or the taller
  of the two on mid-selection-transition frames). New _stack_at
  lays a tuple of icons along screen-up at the resolved anchor.
* Glyph swap: join_icon -> VIEW3D_GT_wall_corner (L), extend_to_wall_icon
  -> VIEW3D_GT_wall_tee (T). Both classes already existed in
  bim/module/drawing/gizmos.py from an earlier commit; only the
  setup() bl_idname strings changed. The previous arrow-merge /
  arrow-extend pair read as the same direction once stacked.

Forward-compat AST contracts in test_wall_gizmos_forward_compat.py
pin the new invariants: the L and T bl_idnames must appear in
setup(), and position_gizmos must route through _stack_at so a
regression that reintroduces a direct billboarded_at write for any
state-specific icon fails CI before it flattens the stack again.

Also folds in a one-line typo fix in core/spatial.py:
assign_container's per-element can_contain check iterated `e` but
predicate-tested `root_element` (the outer for-loop variable), so
every element in the comprehension was tested against the same
container/element pair. Switch the argument to `e`.

Generated with the assistance of an AI coding tool.
2026-06-02 15:35:31 +02:00
Gorgious56 2c18155d98 Merge ifcopenshell/v0.8.0 into parametric-framework-pt2
Bring in 13 commits from upstream v0.8.0 (tip f158ae737):

- Add regenerate_wall_to_underside operator + has_underside_connection
  Model interface (closes #7943)
- Extend/regenerate walls to multiple undersides
- Fix duplicate booleans in extend_walls_to_underside
- Fix extend_walls_to_underside ridge artifact
- Regenerate connected walls when recalculating a slab
- Fix validate_type corruption; remove debug prints
- Lazy BVH tree construction in SnapObj + early-terminate solid raycasts
  in non-xray mode + optimize 2D projection in ray_cast_by_proximity_2d
- Fix crash in update_bim_tool_props when selected type isn't a valid
  ifc_class
- Fix assign_container in spatial.py (#8079)
- Fix sign of temporary offset restore in sweep_along_curve

Auto-merge resolved all overlap files cleanly:
- bim/handler.py: work branch's update_bim_tool_props refactor and
  upstream's try/except hardening converged on identical try/except
  around props.ifc_class assignment (no net change).
- bim/module/model/__init__.py: upstream's wall.RegenerateWallToUnderside
  entry and work branch's roof gizmo entries occupy disjoint sections.
- bim/module/model/wall.py: upstream's RegenerateWallToUnderside operator
  and work branch's GizmoWallEdition/IconSlot refactors occupy disjoint
  sections.
- core/tool.py: upstream's four new Model stubs and work branch's
  Root.has_material_styles stub occupy different classes.

Partly generated with the assistance of an AI coding tool.
2026-06-02 13:31:53 +02:00