Commit Graph

20984 Commits

Author SHA1 Message Date
Dion Moult b0ef47819f ci: IFCOS_BUILD_PYTHON_WRAPPER env-gate (off for bonsai macOS CI)
# Why this exists

The bonsai macOS CI (`build_osx.yml`, arm64) currently fails the
`IfcOpenShell-Python` smoke test with:

    ImportError: dlopen(.../_ifcopenshell_wrapper.cpython-311-darwin.so,
                       0x0002):
      Library not loaded: @rpath/ifcopenshell.document.rdb.dylib
      Reason: tried: '$ORIGIN/ifcopenshell.document.rdb.dylib'
                     (no such file)

`_ifcopenshell_wrapper.cpython-311-darwin.so` has a hard `LC_LOAD_DYLIB`
of `@rpath/ifcopenshell.document.rdb.dylib` and its only `LC_RPATH` is
`$ORIGIN` (= `site-packages/ifcopenshell/`). The plug-in dylib is not
present at that path on macOS, so the wrapper fails to load and the
build smoke test (`build-all.py: compile_python_wrapper`) errors out.

BonsaiViewer.app builds, installs, and macdeployqt-deploys cleanly
before this point — the failure is downstream and unrelated to wgpu,
BonsaiViewer, or anything else on this branch.

# Where the regression came from

Two commits on the branch line that became `ifcviewer-wgpu`:

  b599ee10 "More work on isolating into plug-ins"   (2026-04-18, Thomas Krijnen)
  b022ca7e7 "Some plug-in work"                     (2026-04-21, Thomas Krijnen)

`b599ee10` added a hard link dep:

    target_link_libraries(ifcopenshell_wrapper PRIVATE document_serializer_rdb)

which bakes `@rpath/ifcopenshell.document.rdb.dylib` into the wrapper's
`LC_LOAD_DYLIB`. `b022ca7e7` added a `if(CREATE_BUNDLE) ...
install(TARGETS ${_ifcopenshell_python_runtime_targets}
LIBRARY DESTINATION "${python_package_dir}/ifcopenshell" ...)` block
that was *intended* to satisfy that link dep by copying plug-ins next
to the wrapper. On macOS arm64 the install rule does not actually
deposit `ifcopenshell.document.rdb.dylib` into
`site-packages/ifcopenshell/`, so the runtime dlopen fails.

# Why 227d85d worked

`227d85d` (2026-05-15) is on the `v0.8.0` line, not on the
`datamodel-v1.0 -> ifcviewer -> ifcviewer-wgpu` line. The merge-base
of `227d85d` and `ifcviewer-wgpu` is `e6258ab4` (2026-04-13). Both
b599ee10 and b022ca7e7 live on the wgpu side of that fork and are not
ancestors of `227d85d`:

    $ git merge-base --is-ancestor b599ee10 227d85d
    [exit 1 — NOT an ancestor]
    $ git merge-base --is-ancestor b599ee10 v0.8.0
    [exit 1 — NOT an ancestor]

So the macOS Python wheel built fine on `v0.8.0` because that branch
never had the plug-in refactor; it has been broken on our branch line
since 2026-04-21. Nobody noticed because nobody had been firing
`build_osx.yml` against this branch line until this week's bonsai CI
work.

# What this commit does

Adds an `IFCOS_BUILD_PYTHON_WRAPPER` env var to `nix/build-all.py`.
Defaulting to `on` preserves existing behaviour everywhere; setting
it to `off` (or `0`/`false`/`no`) drops `IfcOpenShell-Python` from
the target set so `build-all.py` skips the wrapper build + smoke
test entirely.

`build_osx.yml` sets `IFCOS_BUILD_PYTHON_WRAPPER=off` so the bonsai
macOS CI can complete and upload `BonsaiViewer.app` while the plug-in
install rule is broken.

# What Thomas should do

Once the install rule in `src/ifcwrap/CMakeLists.txt` (the
`if(CREATE_BUNDLE) ... install(TARGETS ${_ifcopenshell_python_runtime_targets}
LIBRARY DESTINATION "${python_package_dir}/ifcopenshell" ...)` block,
added in b022ca7e7) is fixed to actually drop
`ifcopenshell.document.rdb.dylib` next to the wrapper in
site-packages on macOS — this commit can be reverted in its entirety:
the env-gate in `build-all.py` AND the `IFCOS_BUILD_PYTHON_WRAPPER=off`
in `build_osx.yml`. The bonsai macOS workflow will then build the
Python wrapper too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:06:28 +10:00
Dion Moult 938dda80d0 ci: GCC 11 portability + BUNDLE DESTINATION "." on macOS
Linux (Rocky manylinux, GCC 11):

- WgpuAreaMeasurement.h: include <cstddef> directly. GCC 11 does not
  transitively pull `size_t` through <vector>, so triangleCount()'s
  return type fails to parse.

- WgpuViewportWindow.cpp:meshLocalToGlobal: use static_cast<double>(...)
  instead of double(mesh_local[N]) when constructing the Eigen::Vector4d.
  The latter triggers GCC 11's most-vexing-parse: it reads
  `Vector4d local(double(mesh_local[0]), double(mesh_local[1]), ...)`
  as a function declaration of `local` taking parameters
  `double mesh_local[0]` etc., colliding with the outer `mesh_local`
  parameter and failing with "redefinition of double* mesh_local".

macOS arm64:

- IfcViewerWgpuMinimal + BonsaiViewer install rules: change
  `BUNDLE DESTINATION bin` → `BUNDLE DESTINATION .`. Qt's deploy
  generator emits `macdeployqt <Target>.app` with no path prefix, which
  only resolves when the bundle sits at the install-prefix root.
  `BUNDLE DESTINATION bin` put it at `<prefix>/bin/Target.app` and the
  install/strip step failed with "Could not find app bundle".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:53:57 +10:00
Dion Moult 45e9f763c8 ci: fix bonsai cross-platform build on Linux, macOS arm64, Windows x64
Bundles four portability fixes uncovered by manually firing the platform
workflows against this branch:

- WgpuOverlayRenderer.cpp: GCC 11 (Rocky manylinux runner) does not parse
  a multi-line raw string inside `#define`. Converted
  THICK_LINE_HELPERS_WGSL from a `#define` to a `static const char*` and
  switched AXIS_WGSL / SECTION_WGSL / MARQUEE_WGSL to `std::string` so
  they can concatenate at static-init time. Three call sites now pass
  `.c_str()` to svFromCStr.

- bonsaiviewer/CMakeLists.txt: added BUNDLE DESTINATION to the install
  rule (same fix already applied to IfcViewerWgpuMinimal). MACOSX_BUNDLE
  targets fail at configure on macOS without it even when nobody runs
  `make install`.

- build_osx.yml: dropped the x64 (Intel cross-compile) matrix row. The
  runner is arm64 so `brew --prefix qt` returns the arm64 prefix; we'd
  need a separate x86_64 Qt install under /usr/local to cross-build
  BonsaiViewer. Revisit if Intel-Mac demand resurfaces.

- build_win.yml: dropped the ARM64 matrix row. wgpu-native does not ship
  a Windows-ARM64 binary, so IfcViewerWgpu's link step fails with ~60
  unresolved wgpu* externs. Re-enable when upstream publishes that
  target.

Cherry-pick this commit to v0.8.0 so the workflow_dispatch buttons see
the dropped rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:13:59 +10:00
Dion Moult d390911d75 build_osx: build BonsaiViewer on macOS via build-all.py
Linux (build_rocky.yml) and Windows (win/build-all-win.py) already pass
BUILD_BONSAIVIEWER=ON when building from source; macOS was the odd one
out. Three small changes to bring it up to parity:

1. .github/workflows/build_osx.yml — \`brew install qt\` (Qt6 with Svg)
   in the Install Dependencies step, then set QT_DIR=\$(brew --prefix
   qt) and BUILD_BONSAIVIEWER=ON in the Run Build Script env.

2. nix/build-all.py — install_qt6() now honours a pre-set QT_DIR.
   Before this change get_qt6_aqt_config() raised on non-Linux,
   blocking bonsai builds on macOS/Windows from ever using a
   system-provided Qt6. We now validate that QT_DIR points at a real
   Qt6 install (probes lib/cmake/Qt6/Qt6Config.cmake) and skip the aqt
   download path if so. Linux flow is unchanged: when QT_DIR is unset
   the function falls through to the existing aqtinstall path.

build_osx.yml stays workflow_dispatch-only — slow run (~1h with
ccache, longer cold), so manual fire when wanted. Cherry-pick this
file + nix/build-all.py to v0.8.0 to make the workflow dispatchable
against the ifcviewer-wgpu branch before the squash lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:41:39 +10:00
Dion Moult 57a94095e8 ci: wgpu mac — drop the bare \-j\ flag
Ninja rejects \`-j\` without a numeric argument (unlike make, which
treats bare \`-j\` as unlimited parallelism). Build step exited with
\`ninja: fatal: invalid -j parameter\`. Drop the \`-- -j\` tail
entirely; Ninja already parallelises across available cores by
default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:26:29 +10:00
Dion Moult 025e60e635 ci: wgpu mac — cover ifcviewer-wgpu-minimal + wgpu-mem-probe in paths filter
The first run after BUNDLE DESTINATION fix didn't trigger because that
commit only touched src/ifcviewer-wgpu-minimal/CMakeLists.txt, which
the workflow's paths: list didn't cover. Add the two sibling
subprojects (-minimal and wgpu-mem-probe) since they participate in
the same configure pass.

(Manual workflow_dispatch is still unavailable until this workflow
lands on the default branch — GitHub gates the "Run workflow" button
on the default branch's copy of the file.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:20:30 +10:00
Dion Moult 07825b7442 ifcviewer-wgpu-minimal: add BUNDLE DESTINATION for macOS install rule
The target has MACOSX_BUNDLE ON, which on Darwin requires
install(TARGETS) to specify a BUNDLE DESTINATION — CMake validates
that at configure time, even when nobody runs `make install`. The
macOS CI configure step failed with:

    install TARGETS given no BUNDLE DESTINATION for MACOSX_BUNDLE
    executable target "IfcViewerWgpuMinimal".

Set BUNDLE DESTINATION bin alongside RUNTIME DESTINATION bin so both
platforms install into the same spot. No behavioural change on Linux
(no MACOSX_BUNDLE) — verified locally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:16:19 +10:00
Dion Moult 317dbaa440 ci: wgpu mac — install Boost on the runner
Top-level cmake/CMakeLists.txt:328 does an unconditional
find_package(Boost REQUIRED COMPONENTS program_options regex thread
date_time iostreams) before the BUILD_BONSAIVIEWER gate, so we have
to install it on the runner even though IfcViewerWgpu itself doesn't
touch Boost. Removed via task #12 (extract ifcviewer-core) later.

Other unconditional finds in the top-level CMake (manifold,
nlohmann_json, USD, RocksDB, zstd) are already gated on flags that
default to OFF — no action needed for those.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:10:24 +10:00
Dion Moult b103563c8d ci: wgpu mac — disable IfcGeom/CGAL/OCCT; drop ctest -R filter
Two iterations on the first CI run:

1. The top-level cmake/CMakeLists.txt unconditionally find_package's
   CGAL (line 217) and OpenCASCADE (222) before our BUILD_BONSAIVIEWER
   gate kicks in, so configure failed with "Could NOT find CGAL". Pass
   BUILD_IFCGEOM=OFF + BUILD_IFCPYTHON=OFF + BUILD_CONVERT=OFF +
   BUILD_EXAMPLES=OFF + BUILD_GEOMSERVER=OFF + WITH_OPENCASCADE=OFF +
   WITH_CGAL=OFF + COLLADA_SUPPORT=OFF so all the heavy deps stay out
   of the configure step. Verified locally on Linux.

2. The ctest -R "wgpu" filter was case-sensitive and the Catch2 test
   names begin with capital "Wgpu" (e.g. "WgpuSelectionState starts
   empty..."), so it matched zero tests and ctest exited with "No
   tests were found". The standalone wgpu config only builds the
   wgpu tests anyway, so the filter is unnecessary — drop it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:03:40 +10:00
Dion Moult 53e9240702 ci: wgpu sanity check on macOS
Lightweight workflow that compiles IfcViewerWgpu (the static lib) on
macOS arm64 + runs the wgpu state tests. Skips IfcViewerWgpuMinimal
because createSurface has no Metal path yet (task #32); the platform
surface blocks in WgpuViewportWindow.cpp are wrapped in
#if defined(Q_OS_LINUX) so the lib itself compiles cleanly on macOS.

Goal: catch portability regressions in the wgpu source on Apple
Silicon without paying for build_osx.yml's full IfcGeom + OCCT +
Python wheel pipeline. ~5 min vs hours.

Triggers on push/PR that touches src/ifcviewer-wgpu, the shared
headers it depends on, the top-level CMake, or this workflow itself.
Also workflow_dispatch for manual runs.

Hoists the Catch2 fetch in cmake/CMakeLists.txt out of the
BUILD_BONSAIVIEWER gate so the standalone wgpu config
(BUILD_BONSAIVIEWER=OFF + BUILD_BONSAIVIEWER_WGPU=ON +
BUILD_BONSAIVIEWER_TESTS=ON) can build tests without dragging the
whole bonsai/IfcGeom tree in. Default remains OFF, so default builds
stay offline-capable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:53:41 +10:00
Dion Moult 42ab97b134 tests: pass WITH_MESH_OPTIMIZER into test_lod_builder
LodBuilder.cpp guards its real body behind #ifdef WITH_MESH_OPTIMIZER
(the stub is `return;`). The IfcViewer static lib propagates the
define via target_compile_definitions, but test_lod_builder compiles
LodBuilder.cpp standalone (it doesn't link IfcViewer), so the test
silently exercised the no-op path. summariseLods and buildLods cases
asserted on the post-build state and saw zero LOD1 output.

Pre-existing regression since 884e7ba32 ("Make meshoptim optional");
adds the define to the test target directly so the real build path
runs. 5/5 LOD cases pass after.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:40:55 +10:00
Dion Moult 379f913f65 wgpu tests: port selection + visibility state coverage
Two Tier-1 unit binaries under src/ifcviewer-wgpu/tests/ — same Catch2
+ CTest harness as the surviving GL-side tests, gated by
BUILD_BONSAIVIEWER_TESTS.

- test_wgpu_selection: 17 cases / 71 assertions covering replace, add,
  remove, toggle, clear, contains, count, selectionIds, fillFlagsArray,
  active-id semantics, dirty-bit, and id == 0 sentinel handling.
- test_wgpu_visibility: 8 cases / 27 assertions covering hide, show,
  clear, isHidden, hiddenIds, idempotence, and the 0 sentinel.

The wgpu state classes have a deliberately simpler shape than the GL
ones (no Q_OBJECT, no signals — replaced by a dirty bit; no bulk
set/add/remove methods — bulk behaviour lives in the viewport verbs).
One *intentional* behavioural difference is documented in the test:
add(id) steals active in the wgpu API, where GL's addToSelection kept
the prior active. Each pick should drive the properties panel to the
most recently touched object.

Bulk hide/isolate/show-all semantics live in WgpuViewportWindow, which
composes WgpuVisibilityState + the model instance lists; those are
integration-level, not Tier-1, so they're not covered here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:39:34 +10:00
Dion Moult 2981500b3b Route bonsai through wgpu; delete the GL backend
Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.

SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
  the StreamingSidecar off to the new applyCachedModel. Field accesses
  inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
  finalizeModel). The applyLodExtension call is dropped — wgpu has no
  live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
  next open.

Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
  SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
  116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
  rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
  ../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
  dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
  duration of the migration; the GL-side IfcViewer also publicly links
  IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.

GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
  Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
  viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
  removed. The first has no replacement (wgpu doesn't use a per-instance
  BVH); the latter two are ported separately. test_lod_builder,
  test_sidecar_cache, test_instanced_geometry, test_federation remain
  (backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
  of the surviving translation units reach for them.

Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
  SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
  IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:38:33 +10:00
Dion Moult 9c067d1d0e wgpu: bonsai-ready API surface + direct-IFC ingestion + streaming-always
Make the wgpu viewport ready for bonsai's verb actions, federation
refresh, and tool routing — i.e. callable from an outside host, not
just from the minimal viewer's own hotkeys.

Surface additions on WgpuViewportWindow:
- Qt signals: objectPicked, frameStatsUpdated, surfacePickedInTool,
  toolModeChanged, toolBackspacePressed.
- FrameStats struct + rolling 60-sample frame-time window for the
  fps field; emit at end of render() so external listeners see fresh
  numbers in the same tick.
- InstanceLookup struct + findInstance(object_id, ...) const for the
  measurement tools' O(1) object → (model, mesh, placement) resolve.
- Federation hooks (setFederatedFalseOrigin / setModelCoordinateOperation
  / setModelTransformation) + per-model coordinate_operation_meters /
  model_transformation_meters fields on WgpuModelGpuData. Implement
  composeInstanceFromPlacement + recomposeAndUploadModel so each setter
  actually applies — model recompose runs in double, casts to float for
  the GPU upload, and refreshes per-chunk world AABBs. meshLocalToGlobal
  now composes coordinate_operation · placement properly.
- showModel / hideModel for per-model visibility, plus element-level
  verbs (hideSelectedElements / isolateSelectedElements / showAllElements
  / invertElementVisibility) and setSelectedObjectId / cameraState() /
  projectionOrtho() / toggle{Area,Length,Volume}Tool wrappers.
- Section-cutting methods (toggleSectionTool / clearSectionPlanes /
  sectionToolActive) moved to public so bonsai's Commands.cpp can call.
- QVector3D overload of computeObjectAabb to match the GL signature.
- ToolMode::None → ToolMode::NoTool (X11 macro collision avoidance).

Direct-IFC ingestion (A-path), mirrors the GL streaming push API:
- uploadMeshChunk / uploadInstanceChunk stage into pending_direct_loads_
  using the same vertex quantisation as SidecarBuilder so direct-load
  and sidecar-load produce byte-identical buffers.
- finalizeModel wraps the staged data in a file-less StreamingSidecar,
  routes through the existing applyCachedModel chunk planner, then
  gathers per-chunk vertex+index bytes from memory and feeds
  applyStreamedChunk synchronously. Every chunk lands is_resident=true
  immediately (no disk I/O to defer).

Streaming collapse:
- Delete the applyCachedModel(SidecarData) full-load path entirely.
- Rename applyCachedModelStreaming → applyCachedModel; loadSidecar
  always uses the metadata-only reader. Drop the --streaming CLI flag
  from IfcViewerWgpuMinimal and the streaming_enabled_ field.

WgpuSelectionState::ids() → selectionIds() so bonsai's
`viewport_->selection().selectionIds()` compiles unchanged.

Eigen3 added as a public dep of IfcViewerWgpu for the federation
matrices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:36:02 +10:00
Dion Moult e80897886d wgpu: per-chunk OOM cooldown + continue past blocked candidates
Fixes two coupled streaming pathologies on working-set > pool scenes:

1) The candidate loop used `break` when a candidate couldn't fit even
after eviction. Comment justified it with "sorted by priority, lower
candidates can't beat it either" — true for *priority* eviction, but
the failure is *size-based fitting*. A 31 MB candidate that doesn't
fit in 24 MB largest-free was starving the entire per-frame budget,
including smaller candidates that would have fit happily. Replaced
with `continue`.

2) Same blocked candidate re-entered the candidate list every frame
forever, spamming `[blocked]` and (worse, on web) paying for the same
byte-range fetch over and over when apply-time OOM happened. Added
`blocked_cooldown_until_frame_idx` on the chunk: when OOM strikes at
enqueue *or* apply, the chunk is skipped from candidate gathering for
~3s. Web-friendly cap of one wasted fetch per 3s per chronic chunk
instead of per-frame. Cooldown expires naturally; if the pool layout
changes within the window (other chunks evicted, fragmentation
coalesces) the chunk re-enters automatically.

Also added eviction-attribution + chunk thrash detection (gated behind
WGPU_STREAM_EVICT_LOG=1) to confirm A→B→A 2-cycles vs simple
sacrificial-victim cycles. Quietened the steady-state stream debug
dump — moved the verbose multi-line "missing/resident/bottom" snapshot
behind WGPU_STREAM_DEEP_DEBUG=1 with a wider 300-frame interval, and
added a single-line `[stream]` health summary every ~5s in interactive
mode. Removed the hardcoded one-off "brace.ifc bracing all
chunks" dump that was investigation scaffolding for a now-closed bug.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:01:10 +10:00
Dion Moult 699f22b502 wgpu: length measurement tool (L hotkey, adaptive 1/2/3/4+ point readout)
Ports Bonsai's LengthMeasurement onto WgpuViewportWindow as a new
WgpuLengthMeasurement class. Each LMB appends a world-space pick point
and the readout adapts to the running count:

  1 pt   → laser-measure: coplanar-patch BFS on the click's surface
           projects every patch vertex into the surface's own tangent
           basis to get face extents (X/Y/Z bars dashed in world space),
           plus ENH coords for the picked point, plus a vertical
           raycast for floor/ceiling distance on horizontal surfaces.
  2 pts  → distance A→B + axis-coloured ΔX/ΔY/ΔZ stair-step + dashed
           perpendicular projection when both picks landed on
           near-parallel surfaces.
  3 pts  → angle at middle vertex + triangle area + perimeter.
  4+ pts → polygon area via best-fit-plane shoelace (Jacobi-3x3
           eigendecomp inline; no Eigen dep) or fan-triangulated
           fallback for non-planar loops, plus closed-loop perimeter.

Backspace / Del removes the last point; Esc / L again exits.

Dependencies layered in:

- pickMeshLocalAt now refines the AABB-coarse pickSurfaceAt hit into a
  real triangle hit via Möller-Trumbore against the picked instance's
  CPU mesh shadow. Without this the BFS seeds with whatever triangle
  is closest to the bounding-box corner — producing patches and
  extents shaped like the AABB instead of the surface.
- meshLocalToGlobal: applies the instance's placement_transformation
  only (no per-model CoordinateOperation in wgpu yet). ENH equals
  IFC-world for non-federated loads, which is what the minimal viewer
  handles.
- raycast: brute-force world-AABB cull + Möller-Trumbore over the CPU
  mesh shadow. Used by the laser-measure ceiling/floor distance.
- ToolMode gains Length; click handler routes plain/Alt LMB through
  onLengthPick, Backspace through onLengthBackspace. Marquee-arm is
  gated off in Length mode.

Volume HUD now shows "Volume: 0.0000 m³  (0 objects)" the moment V is
pressed, matching how A primes "Area: 0.0000 m²" — gives the user a
visible cue the tool is active before any selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:44:12 +10:00
Dion Moult 8761f9ac46 wgpu: area measurement tool (A hotkey, BFS coplanar patch + cyan highlight)
Ports Bonsai's AreaMeasurement onto WgpuViewportWindow as a new
WgpuAreaMeasurement class. Each LMB pick resolves to (instance,
triangle), BFS-expands the coplanar patch (dot(normal, seed_normal)
> 0.9999, ~0.81° tolerance), and toggles it in/out of the running set.
Alt+LMB skips BFS for single-triangle accumulate. Connected-components
sweep over the selected set produces one "X.XXXX m²" label per patch
at its area-weighted centroid in world space; HUD shows the running
total + triangle count.

Dependencies layered in:

- WgpuOverlayRenderer.setHighlightTriangles / encodeHighlightTriangles:
  translucent world-space triangle list (cyan @ 0.45 alpha), depth-
  tested but depth-write off so the corner gizmo + labels still sit
  on top.
- WgpuViewportWindow.pickMeshLocalAt: reuses pickSurfaceAt for the
  world hit, then inverts the instance's composed transform to express
  it in mesh-local space — what the BFS needs. Uses the live map key
  (`mid`) rather than InstanceCpu.model_id, which is whatever the GL
  streamer wrote at sidecar-write time and goes stale across sessions.
- WgpuViewportWindow.readbackMeshTriangles: CPU mesh shadow lookup.
  The shadow itself is populated during the same dequant pass that
  computes mesh-local volume — applyCachedModel for full loads and
  applyStreamedChunk for streaming, so the BFS has data the moment
  the user can pick it.

WgpuModelGpuData gains a MeshTriangles vector indexed by mesh_id;
doubles per-vertex CPU memory (12 B/vert) but skips wgpu mapAsync
plumbing for now. Bounds-check at pick time gracefully no-ops when a
stale sidecar field is out of range.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
Dion Moult 0774398d4e wgpu: volume measurement tool (V hotkey, selection-driven HUD + per-object labels)
Ports Bonsai's volumeOfObjects + volumesPerObject onto WgpuViewportWindow.
Mesh-local volumes are precomputed at applyCachedModel via signed-
tetrahedra-from-origin (dequantising positions from the 12 B/vertex GPU
layout); per-instance volume is just the cached local × |det(placement)|.
No GPU readback — measurement is O(K) in the selection size.

Streaming path computes volumes per-chunk as they arrive — fills any
mesh whose chunk just delivered, then re-runs updateVolumeReadout if
the user is staring at a Volume readout while the geometry pages in.

UX matches GL: V toggles, Esc exits, selection-driven (LMB pick / marquee
/ Shift/Ctrl set ops all funnel into updateVolumeReadout). HUD shows
total + count; one overlay label per object at its AABB centre, capped
at 200 to keep the label-texture cache bounded on large marquees.

Side fixes layered on the label overlay:
- O(1) AABB lookup via object_id_to_instance instead of linear-scanning
  every model's instance list per selected object.
- Label texture cache evicts entries not touched this frame, so churning
  through "X.XXXX m³" strings doesn't pin GPU memory.
- DrawRec stores the WGPUBindGroup handle by value rather than a
  LabelTexture* pointer into the QHash — getOrCreateLabelTexture can
  rehash the table and invalidate every captured pointer, which crashed
  large marquee selections with BindGroup-no-longer-alive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:57:44 +10:00
Dion Moult 3ac7a79b7a wgpu: overlay labels + HUD text (QPainter rasterise, content-cached)
Ports GL OverlayRenderer's setOverlayLabels + setHudText to wgpu.
Each unique string is rasterised via QPainter into a QImage (dark-grey
rounded background + white antialiased text) and uploaded as an RGBA8
texture; the cache is keyed by content + font size so identical
strings across frames are texture-free. Per-frame work is projection,
vertex assembly, and one draw per visible label.

Drawn last in the frame on the resolved surface so labels sit on top
of every other overlay (no depth-test). HUD uses pt 11 at top-left
matching GL; world-anchored labels use pt 9 centred at the projected
screen position.

WebGPU has no QOpenGLPaintDevice equivalent — the GL backend's two-
stage GL-rect + QPainter pass becomes one textured quad per item here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:40:54 +10:00
Dion Moult 716dba2244 wgpu: overlay points (sprite-style, quad-expanded with stroke halo)
Ports GL OverlayRenderer's setOverlayPoints API to WgpuOverlayRenderer.
Each point becomes a 6-vertex screen-space quad sized to inner_diameter
+ 2*stroke_extra; the fragment reads its per-vertex corner varying
instead of gl_PointCoord (WebGPU has no sized-point primitive). Sharp
inner/stroke transition + AA on the outer edge only, matching GL.

Single uniform slot per set — colors are global to the call, not per
point. Vertex buffer regrows 1.5× on demand so steady-state sets don't
re-allocate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:23:51 +10:00
Dion Moult 1d26e5fb92 wgpu: overlay-line groups (stroke + dash, per-group dynamic uniform offset)
Ports GL OverlayRenderer's LineGroup API to WgpuOverlayRenderer. Each
group's segments are CPU-expanded into screen-space quads; the WGSL
fragment reproduces the GL pixel-distance stroke pick + arc-length
dash logic. One uniform slot per group, bound via dynamic offset so a
single bind-group services up to N groups.

No caller yet — sets up the API the wgpu measure tools (task #29) will
use. WgpuViewportWindow.setOverlayLines mirrors the GL viewport's
signature so the bonsai Measurement code can target either backend
through one interface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:38:59 +10:00
Dion Moult 814ae8304f wgpu: extract overlays (axis, pivot, section, marquee) into WgpuOverlayRenderer
WgpuViewportWindow.cpp had ~1100 lines of pipeline/shader/buffer plumbing
for the axis indicator, pivot gizmo, section visualizer, and marquee
drag rect. Mirroring the GL backend's split, that lives in its own class
now; the viewport keeps the camera/cull/draw loop and hands the renderer
a per-frame WgpuOverlayFrame snapshot for each encode call.

No behavioural change — pixel-identical screenshot on basic.ifcview.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:58:59 +10:00
Dion Moult 07913e2832 wgpu: marquee box-select (drag rect + Shift/Ctrl set ops)
Drag LMB in empty space to select every visible object whose pick-pixel
falls inside the rect. Mirrors GL ViewportWindow's marquee.

UI flow

  - LMB press in non-tool-consuming context arms the marquee. The
    cursor must move past kBoxSelectThresholdPx (5 logical) for it to
    become active — until then a release falls through to single-pick,
    so an unintentional micro-drag still picks under the cursor.
  - Press-time modifiers decide the set op so a mid-drag Shift release
    doesn't flip behaviour:
      plain  → selection.clear() then add every picked id
      Shift  → add to current selection
      Ctrl   → remove from current selection
  - Section tool intercepts plain LMB first (already wired); the
    marquee is mutually exclusive with it.

Rectangle pick

picksInRect(x, y, w, h):
  - Render the existing pick pass (R32UInt object_id + RGBA16F normal
    MRT) into the persistent pick attachments.
  - copyTextureToBuffer the rect region of pick_color_texture_ into
    box_pick_staging_buffer_ (regrown 2× on demand to fit the
    largest rect we've seen). R32UInt is a color format so partial
    sub-rect copies are allowed (unlike Depth32Float).
  - Iterate the mapped staging buffer, accumulate unique non-zero ids
    into an unordered_set, return.

Visual rect

A new marquee overlay pipeline draws the drag rect on the resolved
surface after the corner gizmo. Two passes per active frame share one
uniform buffer / bind group:

  fill    — 6-vert unit quad, vs_fill maps (0,1)² to NDC via
            rect_min/rect_max, fs_fill outputs color × fill_alpha
  outline — 24-vert thick-line quad (4 segs × 6 verts), uses the
            shared thick_line_clip + fs_main from THICK_LINE_HELPERS_WGSL
            so the rect outline has analytical AA without MSAA

Colour: Bonsai decorator_color_special (0.157, 0.565, 1.000) for the
axis-blue parity the user requested; outline alpha 0.95, fill alpha
0.20 of that so geometry behind the rect still reads.

Closes #41 and #61.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:46:30 +10:00
Dion Moult 0ed355154a wgpu: shared thick-line shader, Bonsai decorator palette, fatter section gizmo
Consolidation

THICK_LINE_HELPERS_WGSL — a macro that both AXIS_WGSL and SECTION_WGSL
prefix via adjacent string-literal pasting — holds:

  - VsOut: clip_pos + rgba colour + side_t for AA
  - thick_line_clip(p_start, p_end, t, side, viewport, line_width):
    the screen-space quad expansion with consistent perpendicular so
    the quad never collapses into a bowtie
  - fs_main: |side_t| + fwidth() coverage smoothstep — analytical
    1-pixel AA regardless of MSAA

Each gizmo shader now only declares its uniform struct + a 10-line
vertex shader. C++ side gains thickLineVertexLayout(attribs[5]) so
both call sites set up the 5-attribute layout in one call instead of
20+ lines each. Net diff is -28 lines on this commit and roughly -60
relative to the unconsolidated section commit; the next thick-line
gizmo (measure tool, selection outline, …) starts from ~30 lines of
WGSL + a vertex buffer.

Bonsai decorator palette

All overlay colours now come from src/bonsai/bonsai/bim/ui.py's
decorator_color_* defaults so they match Bonsai's Blender add-on:

  decorator_color_error    = (1.000, 0.200, 0.322)  red   → +X axis, section gizmo
  decorator_color_selected = (0.545, 0.863, 0.000)  green → +Y axis
  decorator_color_special  = (0.157, 0.565, 1.000)  blue  → +Z axis

Section gizmo polish

  - Entire gizmo (quad outline + arrow shaft + arrow head) goes red.
    GL's white quad + yellow arrow disappeared against light surfaces;
    one saturated red reads against any background and identifies the
    geometry as a tool overlay.
  - Line width bumped to 5 logical px and the per-vertex tint dropped
    to (1, 1, 1, 1) so the tint multiplier stays available for a future
    "selected" state without changing the base colour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:00:09 +10:00
Dion Moult 8173074050 wgpu: section cutting tool — K hotkey, click-to-add, drag arrow, Esc/Del
Mirrors GL ViewportWindow's section tool end-to-end.

Hotkeys

  K            toggle the tool active
  Shift+K      clearSectionPlanes
  Esc          deactivate the tool
  Del/Bksp     remove the most recently added plane (tool-active only)
  LMB click    pickSurfaceAt → addSectionPlaneAtSurface (no modifier)
  LMB drag    on the arrow gizmo: slide the plane along its normal

State

FrameUniforms grows by clip_count (i32) + clip_planes[6] (vec4). The
six-plane cap matches GL's MaxSectionPlanes. WGSL pads via three
scalar i32s instead of a vec3<i32> so the array starts at offset 144
to match the tightly-packed C++ struct (240 B) — vec3 would have
forced clip_planes to 160 and broken the binding-size match.

is_section_clipped(world) in WGSL evaluates all active planes and
returns true if any signals "on the positive side". Both main and
pick fragments discard with it so cuts are visible AND selection is
consistent — you can't pick something the user can't see.

Surface pick

Pick pass now emits 2 color targets: R32UInt object_id at @location(0)
and RGBA16F packed world-space normal at @location(1). Normal is
packed × 0.5 + 0.5 so unsigned-ish halfs keep the sign. pickSurfaceAt
reads both via 1×1 texel copies (RGBA16F is a color format with no
full-mip restriction, unlike Depth32Float). World position comes from
ray-AABB intersection against the picked instance's AABB — equally
accurate for "drop a plane where I clicked" and dodges the Depth32Float
copy-extent rule entirely. The pick normal is decoded into the
per-fragment surface normal so the plane lands perpendicular to the
actual triangle (not the AABB face).

Plane gizmo

Identical geometry to GL's renderSectionPlanes: 2×2 m quad outline
(white) + 1 m arrow shaft along +n (yellow-orange) + 4 arrow-head
diagonals. Drawn inside the main MSAA pass with depth LessEqual + no
depth write. Lines are rendered as screen-space-expanded thick quads
with fwidth-based AA, same technique the axis indicator uses, so the
gizmo reads against busy BIM geometry rather than disappearing as
1-px hairlines.

Drag

mousePressEvent claims a plain-LMB press if it hits an arrow gizmo
(12 logical-px grab radius, distance to the (origin, origin+n)
screen-space segment). The drag handler projects the cursor delta
onto the screen-space axis and converts to metres via
delta·axis / |axis|² — same formula GL uses. Mid-drag camera moves
keep working because the projection re-runs every frame against the
press-time origin.

Closes the click-to-add + drag halves of #30 / #60.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:18:34 +10:00
Dion Moult e6c6df905a wgpu: corner axis gizmo + orbit pivot indicator (shared geometry)
Both overlays draw the same three positive-axis rays (origin → +X / +Y
/ +Z) — one screen-space-thick-line shader, one vertex buffer, one
bind group layout. The vertex stage transforms each vertex as
`mvp * (origin + position * arm)` so the same primitive serves both
modes:

  corner: viewport set to a 110×110 px box in the bottom-left,
          camera-orientation ortho MVP, origin=0, arm=1.
  pivot : full viewport, main view-proj, origin=camera_target,
          arm = 30 logical px in world units.

Pipelines

  axis_pivot_pipeline_      — MSAA + depth LessEqual   (α=1)
  axis_pivot_xray_pipeline_ — MSAA + depth GreaterEqual (α=0.30)
  axis_corner_pipeline_     — resolved surface, no depth, sampleCount=1

Pivot renders inside the main MSAA pass after geometry (depth
interaction); corner renders on the resolved surface after the edge
silhouette pass so the laplacian can't darken its lines. The pivot's
two passes — x-ray first then visible — give an occluded-side hint
matching GL's renderPivotIndicator.

Screen-space thick lines

WebGPU has no lineWidth, so each axis is a 2-triangle quad expanded
by `line_width / 2` pixels along the screen-space perpendicular in
the vertex shader. Every vertex carries BOTH endpoints (start, end)
plus `t ∈ {0,1}` and `side ∈ {-1,+1}` so the direction is computed
consistently as `s_end - s_start` regardless of which end the vertex
sits at — an earlier "this vertex vs the other end" formulation
flipped sign at the end vertex and produced a bowtie.

Analytical AA

|side_t| ∈ [0,1] is the perpendicular distance from the line centre.
`smoothstep(1-fwidth, 1, |side_t|)` gives a 1-pixel coverage falloff
at the long edges — gizmos read cleanly even on the resolved-surface
corner pass which has no MSAA.

Pivot visibility

  - orbit / pan drag press → on, release → off
  - wheel zoom            → on with 600 ms afterglow via QTimer

Pole fallback for the corner gizmo's lookAt mirrors buildViewProj's
identical fix (swap Y-up when |pitch| ≥ 89°), so top/bottom standard
views don't degenerate.

Tracked under task #59.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:04:08 +10:00
Dion Moult d4169693c9 wgpu: drop dormant spatial-bucket prototype
Removed the WGPU_SPATIAL_BUCKETS=1 octree-style instance planner
(planSpatialChunks, SpatialPlan, the env-var pair, the field, the
load-time branch). Was an opt-in prototype kept in tree as a possible
acceleration for "find the right instance chunk", but:

- Benchmarked slower than mesh-keyed (~24-26 ms vs ~19.7 ms) — the
  higher chunk count's per-chunk bind-group + draw overhead more than
  ate the tight-AABB win on this dataset.
- Not load-bearing for the brace correctness fix — that turned out to
  be the AABB-projected screen-rect priority metric (commit 6200ab9fa),
  which works on either chunk topology.
- The "find the right chunk" hypothesis is moot: instance_chunk_idx[]
  is precomputed at load time, so cull has nothing to look up.

Git log preserves the implementation if it's ever revisited.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:17:14 +10:00
Dion Moult 72af067c9a wgpu: streaming + HiZ correctness fixes
Three correctness bugs found and fixed, plus an unrelated fly-mode
deadlock surfaced along the way.

HiZ false-rejection at the bottom of the screen
------------------------------------------------
The mip-pyramid sizing floored when halving — for a 256×70 mip 0 the
level-3 mip is 32×8, but mip-0 row 69 maps to ly = 69>>3 = 8, which is
out of bounds for an 8-row mip. ly1 then clamps down to 7 while ly0
stays at 8, the sampling loop runs zero times, max_d retains its
initial 0.0, and `min_z > 0` rejects every AABB whose projected y
range touches the bottom row. Same class for the right edge on very
wide viewports.

Fix: ceil rather than floor when halving mip dimensions so every
parent row has a covering child texel, plus std::clamp on both lookup
endpoints as belt-and-suspenders for any future mip-sizing change.

Surfaced after the user added more sidecars and saw "anything near the
bottom of the screen, no matter close or far" disappear ~0.5 s after
camera stops — that delay was the strict-VP gate + readback latency
opening the HiZ window. Found via WGPU_HIZ_TRACE rejection logs that
showed every rejection had `max_d=0` and `ly0 > ly1`.

Streaming priority lets the ocean starve out the bracing
---------------------------------------------------------
Per-instance projected screen footprint was estimated as bounding-
sphere radius squared. BIM geometry is overwhelmingly thin-in-one-axis
(slabs, pipes, columns, windows) and a flat ocean plane viewed nearly
edge-on gets a sphere projection ~250× larger than its actual screen
rect. Its chunk dominated the priority ranking and evicted the brace
chunks despite the braces being one of the closest visible things.

Fix: per-instance priority is now the screen-space AABB rectangle area
(world AABB extents projected onto the camera right/up basis vectors,
divided by view-z). Sphere radius is retained for the contribution
cull and LOD pick because conservative-over is the right failure mode
there.

Stale-VP HiZ gate
-----------------
HiZ resolves into an async ping-pong of staging buffers, so the
pyramid resident at cull time was typically captured one or two
frames ago. During camera motion the captured VP differs from
vp_this_frame and AABBs end up sampling depth taken for what was at
slightly-different screen positions in the old view. Strict by
default now: HiZ engages only when hiz_vp_ == vp_this_frame.
WGPU_HIZ_MOTION=1 trusts the stale pyramid (matches GL's default).

Fly mode Shift+Q deadlock
--------------------------
keyPressEvent requested a redraw only on the first key of a new held
set (was_empty). Pressing Shift first then Q never satisfied that
condition because Shift had already populated the set, so the render
loop never ticked. Now every relevant keypress calls requestUpdate
unconditionally.

HiZ stays opt-in behind WGPU_HIZ=1 for one release while the fix
bakes; WGPU_HIZ_TRACE=1 keeps the per-rejection diagnostic available
for future bugs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:06:56 +10:00
Dion Moult 126d2d4c06 wgpu: spatial instance bucketing for streaming (env-gated prototype)
WGPU_SPATIAL_BUCKETS=1 swaps the applyCachedModelStreaming planner from
mesh-keyed Morton+greedy to octree-style instance bucketing. Default
behaviour unchanged (env var unset → mesh-keyed planner runs).

Phase 1 of #55 / #56. The mesh-keyed planner produces chunks whose
AABBs are the union of all instances of the chunk's meshes — for
heavily-deduplicated IFC meshes (a "standard floor tile" used 800
times across a federation) the mesh's "centroid" is a mean of scattered
instance positions and the chunk's AABB ends up spanning the entire
model. Symptom: chunk-level frustum cull rarely fires (AABB always
intersects view), and the screen-area priority metric under-rates
big-AABB chunks because their corners straddle the near plane. Visible
objects pop in/out as the camera tilts, even though they're fully on
screen.

The spatial planner bucketises INSTANCES directly. Each leaf bucket
contains its instance list + the unique mesh data those instances
reference. A mesh whose instances scatter into multiple buckets gets
its vertex/index data uploaded into multiple pool slices — duplication
is the cost for tight bucket AABBs. For IFC this is acceptable:
heavily-shared meshes tend to be small (fittings, fasteners), so
per-bucket duplication adds tens-of-MB not GB.

Octree implementation (planSpatialChunks):
  - work-stack subdivision: for each (instance subset, AABB), split into
    8 octants around centre and recurse
  - stop conditions: bucket fits WGPU_CHUNK_VERTEX_BYTES_LIMIT for
    union vertex bytes AND ≤ spatial_max_instances_ instances; OR single
    instance left; OR every instance falls into the same octant
    (pathological — emit as leaf rather than infinite recurse)
  - spatial_max_instances_ default 5000, overridable via
    WGPU_SPATIAL_BUCKET_MAX_INSTS env var so the prototype can be
    swept without rebuilding

Data-model adjustment beyond what dc2927997 prepared:
  - Per-chunk per-mesh chunk-local offset table (chunk_mesh_offsets)
    built during the chunk-construction loop. The mesh-keyed per-mesh
    global arrays (mesh_chunk_idx etc.) still get populated for
    legacy reads, but under spatial bucketing they're overwritten when
    the same mesh appears in multiple chunks — harmless because cull
    reads the per-instance arrays exclusively (per dc2927997).
  - Post-construction, per-instance arrays are populated from
    chunk_mesh_offsets via (instance_to_chunk[i], inst.mesh_id) lookup.
    Mesh-keyed planner derives identical values to before
    (pixel-identical); spatial planner now writes the correct
    per-bucket offsets even when the mesh appears in multiple chunks.

basic.ifc parity on all three paths confirmed (non-streaming
mesh-keyed, streaming mesh-keyed, streaming spatial all produce 0
pixel diff vs the reference). Spatial planner produced 1 bucket on
basic.ifc (3 instances, well under thresholds) as expected.

Non-streaming applyCachedModel left unchanged — the prototype targets
the streaming path which is where the federation-scale missing-objects
issue lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:19:56 +10:00
Dion Moult 1f8f6bffc4 wgpu cull: refactor per-mesh chunk lookups to per-instance
Preparatory refactor for spatial instance bucketing (#55). Cull
previously routed through per-mesh tables (mesh_chunk_idx,
mesh_chunk_local_base_vertex, _ebo_first_u32, _lod1_first_u32) to
find the chunk and chunk-local offsets for each instance. That
assumes a mesh lives in EXACTLY ONE chunk — the assumption holds
under the current mesh-keyed planner but breaks under spatial
bucketing, where the same mesh can be duplicated across multiple
buckets if its instances are scattered.

Adds four per-instance arrays (instance_chunk_idx,
instance_base_vertex, instance_ebo_first_u32, instance_lod1_first_u32)
populated at planning time. The current mesh-keyed planner derives
them by translation:
    instance_chunk_idx[i] = mesh_chunk_idx[instances[i].mesh_id]
The spatial-bucket planner (next commit) will populate them directly,
allowing the same mesh_id to map to different chunks for different
instances.

cullModelCpuCompute now reads the per-instance arrays:
- frustum_visible_count uses chunks[instance_chunk_idx[i]]
- VisibleDrawGpu uses instance_base_vertex / instance_ebo_first_u32
  / instance_lod1_first_u32
- LOD-select branch and use_lod1 logic unchanged.

Per-mesh tables stay (used by makeChunkRequest, debug logs, the
planner itself). Memory cost: 16 bytes × N instances ≈ 16 MB on a
1M-instance scene. Pixel-identical on basic.ifc in both non-streaming
and streaming modes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:06:58 +10:00
Dion Moult 3eaa35d748 wgpu: input parity, fly mode, diagnostic instrumentation, chunk-priority fix
Brings the wgpu viewport's keyboard + mouse into line with GL ViewportWindow
+ Bonsai's MainWindow shortcut table, lands fly-mode, swaps in three
diagnostic env vars, and fixes a chunk-priority bug exposed by the
diagnostics.

Keyboard parity with GL + Bonsai:
  P             — toggle perspective / ortho projection
  X / Shift+X   — front / back view (eye on ±X, pitch 0)
  Y / Shift+Y   — right / left view (eye on ±Y, pitch 0)
  Z / Shift+Z   — top / bottom view (pitch ±90°)
  F             — focus camera on currently selected object
  Home          — frame entire scene
  C             — print --camera CLI args for current view
  H             — hide selected
  Shift+H       — isolate selected (hide everything not in selection)
  Alt+H         — show all (clear hidden set)
  Shift+F       — enter fly mode (matches BonsaiViewer)
  Escape (fly)  — exit fly mode
  WASD/QE/Shift — fly movement (when in fly mode)

The previous H/Shift+H/I assignments were wrong vs Bonsai (Shift+H went
to show-all, I to isolate); both are fixed.

Fly mode:
  - GL-style absolute m/s base speed (default 5.0), Shift = 5×, scrollwheel
    adjusts ×1.25/×0.8 per notch (Blender convention). Scrollwheel does
    NOT zoom in fly mode; that interfered with speed when speed was
    distance-scaled (it was, briefly; replaced with absolute m/s).
  - Mouse-look pins eye: yaw/pitch update first, then target is re-derived
    so orbitEye(target, dist, new_yaw, new_pitch) == old eye. Result:
    camera rotates in place (FPS) rather than orbiting the pivot.
  - dt ceiling clamp at 100ms (matches GL fps_move_speed_) so a stall
    doesn't warp the camera.
  - Pitch sign matches non-inverted FPS convention (mouse-up = look up).

Mouse-nav presets (WGPU_NAV_PRESET=blender|rhino|revit, default blender):
  Blender — Orbit MMB,        Pan Shift+MMB
  Rhino   — Orbit RMB,        Pan Shift+RMB
  Revit   — Orbit Shift+MMB,  Pan MMB
LMB stays free for selection in every preset. Nav-drag kind is captured
at press time so a mid-drag Shift release doesn't flip orbit↔pan. The
pan up-vector switches to world-Y at near-vertical pitch so panning
still works in top/bottom view (would otherwise NaN at pitch=±90°).

Camera-math refactor:
  buildViewProj(view, proj) centralises perspective↔ortho selection and
  the near-vertical up-vector switch. Four open-coded copies of the
  view/proj build (cull, debug, streaming priority, render uniforms) now
  call it instead, ensuring projection mode + up-vector switch land
  identically everywhere. basic.ifc pixel-diff is 0 (refactor confirmed
  output-equivalent on the path with no ortho / no near-vertical pitch).

WGPU_PRESENT_MODE=fifo|fifo_relaxed|mailbox|immediate (default fifo):
  Diagnostic toggle for stutter analysis. fifo_relaxed gave the tightest
  per-frame dt distribution on a federated bench scene; immediate gave
  uncapped throughput at the cost of tearing. Mailbox not supported on
  Vulkan + NVIDIA Linux but kept as an option for other backends.

WGPU_FLY_DEBUG=1: per-frame [fly] log printing dt, render gap, key
count, speed, position delta. Confirmed render_gap == dt to four
decimal places — fpsIntegrate runs exactly once per render, no
double-tick. Cull cost (~14-20 ms) is the dominant frame variance and
the eventual fix is task #49 (sub-model parallel cull) — fly-mode
stutter on slow scenes is a downstream symptom of cull cost, not a
fly-mode bug.

WGPU_STREAM_DEBUG=1: per-frame [stream-debug] log with cands / enq /
drained / ev_lru / ev_pri / blocked / resident / cycled / max_load.
Confirms thrash / pool-bound / load-budget cases on big scenes.

Pick-and-track diagnostic: clicking an object enumerates every chunk
holding instances of that object (an IFC object can split across
representations / chunks), printing each chunk's AABB + instance AABB +
residency. If any tracked chunk's is_resident flips true → false in
driveStreamingLoads, an EVICTED dump prints with the chunk's AABB,
priority, pool state, this-frame eviction counts, and the top-5
candidates that displaced it. Surfaces exactly why an object disappeared.

chunkScreenAreaPx fix (uses diagnostic to confirm the bug):
  A chunk's AABB is the union of every instance's world AABB in the
  chunk. On a federated IFC the camera commonly sits INSIDE that AABB
  (e.g. inside a 263×30×15 m floor-area bounding box). Previously the
  8-corner projection silently dropped corners with clip.w <= 1e-3
  (behind near plane), so the projected bbox of the surviving in-front
  corners was a tiny fraction of the chunk's true on-screen footprint.
  Result: big-AABB chunks lost every eviction fight, visible objects
  popped out as the camera tilted. Fix: short-circuit to full-viewport
  area when (a) eye is inside the chunk AABB (mirrors GL's
  contributionPasses camera-inside short-circuit), or (b) any AABB
  corner sits behind the near plane (AABB straddles → 8 corners cannot
  honestly measure footprint; conservatively over-prioritise).

The fix is a workaround for the deeper chunking issue — chunks are
mesh-keyed (group of meshes), and a mesh's AABB used in chunking is
the mean of its instances' positions, which is meaningless for
heavily-deduplicated meshes scattered across the scene. The root fix
is task #55 (spatial instance bucketing, runtime prototype) + #56
(sidecar v15 instance-keyed format). chunkScreenAreaPx fix unblocks
the user-visible "missing objects" issue while those land.

Extracted chunkScreenAreaPx from a driveStreamingLoads-local lambda
to a private member so the disappear-diagnostic and any future call
sites can use it consistently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:23:39 +10:00
Dion Moult a63bc63439 wgpu: diagnostic instrumentation for cull/streaming perf
Adds three knobs and three new heartbeat numbers to support the
ongoing perf-parity work. None affect behaviour in default runs.

cull[wall|compute|upload] split timer
  The existing cull_timer wrapped both the parallel std::async dispatch
  and the sequential cullModelCpuUpload loop (queueWriteBuffer × 3 per
  resident chunk × ~120 chunks ≈ 360 wgpu calls per frame). Splitting
  them ruled upload out as the bottleneck on a 51-model federation
  scene: compute ≈ 16-17 ms, upload ≈ 1 ms.

WGPU_CULL_THREADS=0 — force sequential cull
  std::async-per-model was already in place; this env var disables it
  so we can compare wall time vs sequential and confirm parallelism is
  working. On the federation scene with 52 models: sequential 74 ms vs
  parallel 17 ms = 4.4× speedup. Confirmed; the 17 ms floor is not a
  parallelism failure, it's the cost of culling the largest single
  model (model 43, 114k instances) ÷ no parallelism within that model.

WGPU_STREAM_DEBUG=1 — per-frame [stream-debug] log
  Surfaces cands/enq/drained/ev_lru/ev_pri/blocked/resident/cycled/
  max_load each frame from driveStreamingLoads. The "cycled" /
  "max_load" pair makes thrash vs eviction-churn vs just-loading
  distinguishable. Off by default; opt-in via the env var.

Bench-warm timeout dump
  When [bench warm] times out (600 frames without 0-loads streak),
  prints a structured summary: resident/missing/total chunks,
  cycled count, pool usage, largest free run, avg missing chunk
  size, and an auto-classifier diagnosis (POOL FRAGMENTED vs
  WORKING SET > POOL vs FEW-CHUNK CYCLE vs still-loading). Caught
  a real fragmentation pattern (18 MB largest free run vs ~100 MB
  typical chunk) on a 51-model run where the dumb classifier
  would have called it a load-budget problem.

LOD1 firing counter
  "lod1 X/Y (saved Z tris, N no-lod1)" suffix on the [frame] log.
  X = LOD1-selected this frame, Y = LOD1-eligible, Z = tris not
  drawn vs always-LOD0, N = visible instances with no baked LOD1
  (mesh below IFC_LOD_MIN_TRIS). Confirmed LOD1 path is genuinely
  firing post the per-chunk LOD1-storage commit, and exposed that
  ~90% of instances in real scenes are no-lod1 meshes — relevant
  to the future LOD-tier-residency design.

Chunk.load_count + Chunk.lod0/1 layout bookkeeping
  Per-chunk reload counter for the thrash detector. lod0/1
  layout_count fields prep the data model for distance-tiered
  residency (Phase B of #31) but aren't acted on yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:17:03 +10:00
Dion Moult 6a3dd4a0eb wgpu cull: contribution-cull defaults 2/10 → 3/15, env-var overrides
wgpu computes projected_px from view-Z distance (forward·(centre-eye)),
which is the perspective-divide-correct denominator: an instance's
on-screen radius really is world_radius * focal / z_view. GL computes
the same value but with euclidean distance (sqrt(dx²+dy²+dz²)) — for
off-axis instances euclidean > z_view, so GL underestimates screen
size and culls more aggressively at the same numeric threshold.

Concretely on the federation scene at the test camera, wgpu was
drawing ~3× the instances GL drew despite identical 2/10 thresholds:
wgpu obj 11067, hiz_rej 16532 vs GL obj 2310, hiz_rej 6823. Same
fps (vsync-pinned), but ~30% more cull work for raster output that
the user already wasn't seeing because GL had been quietly dropping it.

Keeping wgpu's view-Z formula (more physically correct) and bumping
the thresholds to 3.0 / 15.0 to match GL's effective drop rate. On
the federation scene this lands obj/tri counts within ~10% of GL
across the orbit, and shaves cull from 3.44 ms to 2.61 ms avg.
basic.ifc parity is unchanged (its 3 instances clear 3 px easily).

WGPU_MIN_PX / WGPU_MIN_PX_MOTION env vars added so the thresholds
can be swept without rebuilding — needed while we visually
confirm the new defaults across more scenes / cameras.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:40:18 +10:00
Dion Moult 10f8fc88e8 wgpu chunks: pack LOD1 indices alongside LOD0; cull picks per-instance
Sidecar LOD1 is per-mesh, index-only — meshoptimizer-baked decimated
index slices that share the LOD0 VBO. Before this change the wgpu
chunk path force-disabled LOD1 (effective_lod1 = false; tagged in a
comment as "until per-chunk LOD1 storage lands"), so it had to walk
every visible instance at full LOD0 even when the per-instance LOD
selection said the projected radius was below the LOD1 threshold.

Per-chunk layout: append LOD1 indices for the chunk's meshes after
all LOD0 indices in the same pool slice. A new
mesh_chunk_local_lod1_first_u32 array records each mesh's LOD1
starting offset (in u32s) within the chunk's index slice; LOD0
offsets stay where they were. The cull's emit then sets
VisibleDrawGpu.ebo_first_u32 to whichever side matches the per-
instance use_lod1 decision the prior #8 commit already computed.
Vertex pulling is oblivious to the LOD split — it just reads the
indices the cull pointed it at.

c.index_count is repurposed as the LOD0+LOD1 total so the pool
allocation, eviction's pool-fit check, and the VRAM accounting all
scale automatically. c.lod1_index_count exposes the LOD1 portion
for stats.

makeChunkRequest appends LOD1 byte ranges to req.i_ranges in the
same per-mesh order; the streaming worker concatenates ranges in
order, so the assembled idx blob lands LOD0-first / LOD1-second
which matches the chunk-local packing.

On a 10-model regen with meshoptimizer-baked sidecars, the bench
[frame] heartbeat shows lod1 firing on ~80-90% of LOD1-eligible
instances and saving 10-30M tris per frame versus the prior
LOD0-only ceiling. Same camera/scene on basic.ifc is still
pixel-identical (no mesh in basic.ifc is large enough to bake a
LOD1, so the cull just follows the LOD0 path it always did).

Temporary debug counters (lod1_dbg_count_ et al.) print
"lod1 X/Y (saved Z tris, N no-lod1)" in both interactive and
benchmark [frame] heartbeats — kept on while LOD1 correctness gets
confirmed across more scenes, will come out once trust is built.

The non-streaming applyCachedModel path runs the same LOD1 plumbing
but no longer fits the full federation scene in pool (extra index
bytes push past the 2 GB single-buffer cap); that mode was
already streaming-only on that scene before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:02:27 +10:00
Dion Moult c7fba1abb8 wgpu streaming: screen-space AABB priority + grace period + interactive heartbeat
The chunk priority metric is now the 2D projected pixel area of the
chunk's AABB on screen — 8 corners projected through view-projection,
2D axis-aligned bbox of the projected points, clamped to viewport.
This replaces the prior bounding-sphere-radius² metric, which was a
3D approximation: it treated a 322 × 55 × 5 m slab as a 163 m sphere,
giving it the same huge priority face-on or edge-on. The new metric
genuinely answers "what would this chunk's AABB cover if rendered
solid given the current camera and viewport."

Newly-loaded chunks get a 30-frame grace period at full priority
(visibility_history floor temporarily forced to 1.0). Without it,
just-loaded chunks crashed to history=0 → effective priority = pri ×
0.05 → immediately reverse-swapped by the chunk they displaced.
Cycle starved the per-frame load budget so candidates ranked below
the cyclers never got attempted. 30 frames = HISTORY_ALPHA's time
constant — enough for visibility_history to develop meaningfully.

EVICT_PRIORITY_RATIO bumped 1.21 → 2.0 to suppress more swap noise
between similar-priority chunks.

Interactive heartbeat log added: every render in non-bench mode prints
[frame] with fps, ms, obj, sub_draws, hiz_rej, cull, stream, chunks
breakdown (resident/frustum/total + missing count), VRAM, model count.
Every 30 frames when something's missing, also dumps:
- top 8 models by missing chunk count
- top 20 missing chunks by priority (with AABBs)
- bottom 5 residents by effective priority
- all chunks of brace.ifc (one-off diagnostic, hardcoded
  for the brace-visibility investigation)

The heartbeat made the streaming bug visible: a brace model that
isolation-loads correctly is missing in the full set because slabs
covering more pixels win the priority contest. Per-model fairness or
manual pinning are the remaining options if pixel-area + grace +
hysteresis isn't enough — left for follow-up so the user can decide
based on real testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:19:24 +10:00
Dion Moult 3d368e0079 wgpu chunks: 3D Morton-code spatial sort (tight voxel chunks)
The previous chunk-plan sorted meshes by lexicographic (z, y, x)
centroid — effectively a 1D Z-slab traversal. On a typical IFC
building (50 × 50 × 100 m), a 16-MB chunk's 80-ish meshes spanned
roughly 50 × 50 × 0.5 m. On a city federation it was much worse:
the first chunk grouped ground-floor stuff from every building,
spanning the entire scene horizontally. Per-chunk AABBs that wide
make frustum / contribution / HiZ rejection useless (every chunk
"overlaps the frustum" by virtue of spanning the whole scene).

3D Morton (Z-order) interleaves bits of quantised (x, y, z)
centroids, so consecutive items in the sorted order cluster in all
3 axes — chunks become tight 3D voxels of the model. Prerequisite
for the contribution-aware eviction priority (task #25) to actually
discriminate near and far chunks.

21 bits per axis = ~2 M bins per axis, sub-millimetre precision on
a kilometre-scale scene. Both apply paths (streaming and non-
streaming) share the same sortMeshIdsByMorton helper.

Benchmark unchanged (~47 fps avg, 20 ms cull, 0.3 ms stream) — the
distance-based evictor still keys on chunk centres, which moved
slightly under Morton but not enough to materially shift residency.
The user-visible win comes from the next commit, which switches
priority to screen-space contribution × HiZ history — both of which
need today's tight AABBs to mean anything.

Pixel-identical to non-streaming on basic.ifc on both paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:04:05 +10:00
Dion Moult 0ec72482c2 wgpu pool: halve-on-failure in addSubBuffer extracts +35% VRAM
Many Vulkan drivers cap a single VkDeviceMemory allocation at exactly
maxStorageBufferBindingSize (NVIDIA: 2 GB on consumer GeForce) or
refuse big contiguous allocations once heap is fragmented. The old
addSubBuffer gave up at the first refusal, latching growth_disabled_
— so on a 4 GB GeForce we extracted 2 GB and called it done.

The wgpu-mem-probe tool (feab05650) showed the driver actually grants
~3 GB total across multiple sub-buffers — invariant under allocation
pattern (2+1+small, 3×1 GB, 6×512 MB, 12×256 MB all land at 3 GB).
The cap is the hardware/desktop, not the request size.

addSubBuffer now starts at last_growth_size_ (initially
per_sub_buffer_capacity_, decays as the driver refuses larger sizes)
and halves on failure inside a single call. Stops at a 64 MB floor;
below that the per-sub-buffer bookkeeping cost (free list, bind
groups) isn't worth it. growth_disabled_ now latches only when even
64 MB is refused — a true hardware ceiling, not just "the first
attempt didn't fit."

pool_can_fit gains a next_growth_size_bytes() accessor to stay
honest about how big a future sub-buffer can be after the driver
has refused larger sizes.

Measured (big federation, --streaming, close camera):
  pool capacity:  2048 MB → 2688 MB (2 GB + 512 MB + 128 MB)
  VRAM resident:  2155 MB → 2800 MB (whole scene fits, no eviction)
  avg fps:        42 → 53
  stream time:    2.5 ms → 0.1 ms (no churn — working set is stable)

On larger GPUs (8 / 16 / 24 GB workstations) the same code extracts
proportionally more (e.g. 4 × 2 GB on a 10 GB+ card).

The GL backend's higher "4+ GB resident" claim is overcommit into
host RAM — explicit Vulkan/wgpu memory management deliberately
doesn't paper over that, and the wgpu-mem-probe data confirms it
isn't recoverable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:31:31 +10:00
Dion Moult feab05650d wgpu-mem-probe: standalone tool to investigate driver VRAM ceilings
New headless wgpu probe app — no Qt, no surface, just initializes a
device and stress-tests buffer allocations. Reports:
1. Adapter + device limits (maxBufferSize, maxStorageBufferBindingSize).
2. Single-allocation probe: descending sizes, each released, finds
   the largest single buffer the driver will grant.
3. Cumulative probe: halve-on-failure, finds total VRAM the runtime
   will let us park behind one device across multiple sub-buffers.
4. Fixed-size cumulative probe: 1 GB / 512 MB / 256 MB uniform sizes,
   to detect whether the "big-first" strategy leaves VRAM on the table.

Findings on a GTX 1650 (4 GB physical) + wgpu-native + Vulkan:
- maxStorageBufferBindingSize = 2 GB (driver cap, not wgpu-native).
- Any single storage buffer > 2 GB is REFUSED.
- Total available across N sub-buffers = ~3 GB, INVARIANT under
  allocation pattern (2+1+0.06, 3×1 GB, 6×512 MB, 12×256 MB all
  reach 3.00 GB). Driver hands out a fixed VRAM slice; pattern
  doesn't matter.
- Remaining ~1 GB is held by the desktop compositor + OS.
- GL's higher "4 GB+ resident" claim is overcommit into host RAM,
  which wgpu/Vulkan don't do.

The +50% (2 → 3 GB) improvement is real and worth chasing — a
follow-up halve-on-failure addSubBuffer in WgpuBufferPool will
extract that on this card. On 8/16/24 GB GPUs the same code gets
us proportionally more.

Build: ninja -C build-viewer-wgpu WgpuMemProbe
Run:   ./build-viewer-wgpu/wgpu-mem-probe/WgpuMemProbe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:22:57 +10:00
Dion Moult dcc2bf1c01 wgpu streaming: background-thread chunk I/O kills render-thread stutters
The sync chunk-read on the render thread was causing 100-300 ms spikes
during orbit whenever a new chunk needed to scatter-gather its mesh
bytes from disk. p99 was 326 ms on the close-camera benchmark.

New WgpuStreamingThread: one worker thread with a condvar-protected
request/result queue. driveStreamingLoads becomes drain-then-enqueue:
1. Drain any results the worker pushed since last frame. For each,
   pool-allocate slices + queueWriteBuffer + build the chunk bind
   group (still main-thread because wgpu queue ops aren't thread-safe).
2. Walk visible non-resident chunks (sorted by distance), evict to
   make pool room, and enqueue the request. Chunk gains is_loading
   flag to prevent re-enqueueing while in flight.

loadChunkBytesAndUploadGpu becomes the sync fallback path, used only
when a screenshot is pending — the deferred-capture wait would
otherwise let the window manager re-layout the window between frames
and the test framework would capture at the wrong size. Normal
streaming always goes through the worker.

Bench warm-gate / requestUpdate gating updated to consider
streaming_thread_.inFlightApprox() so we don't declare "converged"
while a worker read is still in flight, and the render loop stays
alive until the worker queue is empty.

Refactored loadChunkBytesAndUploadGpu into two helpers:
- makeChunkRequest: builds the worker request from chunk metadata
- applyStreamedChunk: pool.alloc + queueWriteBuffer + bind group
Both the sync and async paths share applyStreamedChunk.

Benchmark (big federation, --streaming):
  close camera:    avg 24 fps p99 47 ms (was 27/326)
  default camera:  avg 24 fps p99 46 ms (was 31/186)
  stream time:     ~2 ms (was 8-12)
  cull is now the bottleneck (20 ms median) — task #17 (GPU compute
  cull) is the next frontier.

Pixel-identical to non-streaming on basic.ifc on both paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:37:18 +10:00
Dion Moult 6f66d08bee wgpu cull: chunk-level frustum cull replaces BVH walk
cullModelCpuCompute previously had two paths: a flat linear scan over
all instances (default), or a BVH-stack walk (--bvh, gated off because
it regressed on dense scenes — the BVH built per instance but its
interior-node AABBs spanned huge chunks of model so most subtrees
straddled the frustum and the walk overhead beat the rejection win).

With spatial chunk planning (commit 4d3617420) chunks ARE already a
one-level spatial partition of the model, with tight per-chunk AABBs.
So the same wholesale-reject behaviour falls out of just walking
m.chunks: frustum-test each chunk's AABB once, and on hit, iterate
its (new) instance_ids list. No per-node traversal overhead, no
dependency on rebuilding a BVH alongside the chunk plan.

Changes:
- Chunk gains an instance_ids vector, populated in both apply paths
  alongside the per-chunk AABB accumulation.
- cullModelCpuCompute drops the if-bvh / else-linear-scan dichotomy
  in favour of `for chunk: frustum-test then iterate c.instance_ids`.
- Per-model ModelBvh field, buildModelBvhOne call sites, BvhAccel.cpp
  in CMakeLists, bvh_enabled_ field, and --bvh CLI flag all removed —
  dead code now that chunk-cull subsumes them.
- BvhAccel.{h,cpp} stay in src/ifcviewer for the GL backend's use.

Benchmark (big federation, --streaming, close camera): avg 37 fps
(was 36) / median 53 (was 53). Same order on the metric — the
parallelism across models was already amortising frustum-check cost,
so the per-chunk early-out saves only fragments of cull wall time.
Real cull-perf win will come from chunk-level HiZ (potentially) or
GPU compute cull (task #17). What this commit really delivers is
architectural simplification + removal of a dead-but-not-dropped
code path.

Pixel-identical to non-streaming on basic.ifc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:20:30 +10:00
Dion Moult 4d36174200 wgpu streaming: spatial chunk planning + coalesced multi-range reads
Chunks are now grouped by world-space centroid instead of mesh-id
range, so each chunk's AABB tightly bounds its geometry instead of
spanning the whole model. Distance-based eviction can finally
distinguish the near corner of a skyscraper from the far corner.

Algorithm:
1. Compute each mesh's centroid = mean of its instances' world AABB
   centres.
2. Sort mesh indices lexicographically by (z, y, x) centroid. Stable
   sort keeps mesh-id order as tiebreaker for instanced repeats.
3. Greedy-pack sorted meshes into chunks ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT.
4. Each Chunk stores its mesh_ids list; the per-mesh layout (chunk_local
   base_vertex / ebo_first_u32) is computed by walking the list at plan
   time.

Loader: chunk vertex/index bytes are no longer file-contiguous, so
streaming uses new multi-range read paths
(readSidecarVertexRanges / readSidecarIndexRanges). Each range list
is sorted by file offset and adjacent ranges coalesced with a 64 KB
gap tolerance — on the close-camera benchmark this brings the
per-chunk seek count back down to ~mesh-id-grouping levels, so the
spatial sort costs ~nothing on I/O while delivering tighter AABBs.

Non-streaming applyCachedModel mirrors the spatial plan but gathers
from in-memory data.vertices / data.indices via per-mesh
queueWriteBuffer calls at chunk-local offsets.

Chunk struct drops vertex_byte_offset and index_first_u32 (no longer
meaningful — each chunk is N scattered ranges). vertex_byte_size and
index_count stay as aggregates for pool sizing + eviction math.

Tuning: kept WGPU_CHUNK_VERTEX_BYTES_LIMIT at 128 MB. Tried 8 MB and
32 MB; both gave tighter AABBs but the scatter-gather I/O cost blew
up because the per-frame load count grows linearly as chunks shrink
(orbit shifts the working set faster across finer chunks). 128 MB +
coalescing is the empirical sweet spot pre-v14. Once sidecar v14
re-orders bytes on disk to match spatial chunks, we can drop the
limit to ~8 MB for sharp eviction without re-paying the seek cost.

Benchmarks (big federation, --streaming):
  close camera:     avg 36 fps median 53 (was 35/49) — parity
  default camera:   avg 33 fps median 47 (was 40/49) — small regression
                    likely from increased coalesce overhead on more-
                    scattered orbit traversals; will resolve with v14.

Pixel-identical to non-streaming on basic.ifc on both paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:45:15 +10:00
Dion Moult c3a55d7f7b wgpu streaming: multi-pool growth, frustum-only residency, sorted convergence
Five interlocking fixes that take --streaming on the big federation
scene from "5 fps + endless flicker + infinite cold-load" to a
stable 35-49 fps with a converged working set.

1. Multi-sub-buffer WgpuBufferPool. Pool now grows lazily by adding
   sub-buffers of per_sub_buffer_capacity_ when alloc demand exceeds
   existing free runs. Each Slice carries (buffer, offset, size,
   sub_idx). On driver refusal of addSubBuffer, growth_disabled_
   latches so subsequent allocs don't keep retrying and log-spamming.
   pool_can_fit consults can_grow() to know when growth could rescue
   a candidate vs when eviction is the only path.

2. Split cull / stream benchmark timers. The previous "cull[wall]"
   metric was actually cull + driveStreamingLoads, blaming the wrong
   subsystem (~170 ms of "cull" was synchronous disk I/O).

3. frustum_visible_count on Chunk, populated in cullModelCpuCompute
   right after the per-instance aabbInFrustum check. driveStreamingLoads
   now keys residency on this instead of total_visible_draws (which
   includes contribution + HiZ). HiZ visibility flips frame-to-frame
   as occluders shift; using it for residency caused chunks to be
   evicted then immediately re-loaded, every frame, even with a
   stationary camera — both the perf cliff and the visible flicker.

4. Distance-sorted candidates in driveStreamingLoads. Walk the
   non-resident frustum-visible chunks in distance order (closest
   first). With sorted processing, evict_farthest_than converges
   monotonically: each swap replaces a far resident with a closer
   candidate; once the next candidate is farther than every
   remaining resident, the loop exits. Without sorting the loader
   visited candidates in model/chunk-id order, swapping random
   chunks every frame without ever converging.

5. 10% eviction hysteresis (EVICT_DIST2_RATIO = 1.21). On scenes
   where many chunks are clustered at similar distance from the
   camera (e.g. several chunks all ~370 m away), naive
   "evict any resident strictly farther than candidate" triggers
   sub-meter swaps every frame, never resting. Requiring the victim
   to be 10% farther in linear distance kills these cycles while
   still allowing genuine "much closer" candidates to evict.

Plus: latched bench_warm_done_ on the cold-load gate, with a
5-frames-of-zero-loads convergence test (default-camera big scene
converges in 20 frames) and a 600-frame timeout fallback that prints
exactly once.

Measured on the test federation (111 sidecars, ~3 GB raw, 1 M
instances) with the user's close-in camera:
- avg 35 fps (was 5), median 49 fps (was 7)
- cull 19 ms (now the bottleneck), stream 5-8 ms (was 172)
- p99 184 ms — occasional big-chunk load on the render thread;
  background-thread I/O would smooth that out as a follow-up.

With the default wide camera:
- avg 40 fps, converges in 20 frames, residency grows naturally
  from 59 → 76 chunks as orbit shifts the frustum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:08:16 +10:00
Dion Moult 502c29fbc2 wgpu: probed-size pool replaces per-chunk createBuffer
Drops the per-machine "guess the OOM ceiling" budget knob in favour of
a single buffer pool whose capacity is *probed* at device-init time.
The runtime answers the question: descend from min(maxBufferSize, 4 GB)
through OOM error scopes, accept the largest size that allocates
cleanly. On a desktop wgpu-native v29 box this lands at 2 GB; on
browser-class platforms it'll land at 256 MB – 1 GB depending on the
implementation. Same code path either way.

Architecture:
- WgpuBufferPool (new): single WGPUBuffer + free-list sub-allocator
  with adjacent-range coalescing and first-fit. 256 B alignment for
  storage-binding offsets.
- Chunks now hold (pool_vertex_offset, pool_vertex_size) and
  (pool_index_offset, pool_index_size) instead of per-chunk WGPUBuffer
  handles. Load = pool.alloc + queueWriteBuffer. Unload = pool.free.
- Bind groups bind pool_.buffer() at the chunk's specific (offset, size)
  for both the vertex and index storage bindings.
- Eviction queries pool.largest_free_run_bytes() instead of a tracked
  budget; the two-phase LRU/distance evictor's policy is unchanged.

What this fixes:
- No more gpu-alloc-rs fragmentation OOM: one VkDeviceMemory block
  instead of N per-chunk blocks with rounding overhead. On the test
  dataset (~3 GB on disk, 562 k visible instances) the wgpu backend
  now runs through to render without OOM at any point.
- No --streaming-vram-mb knob, no hardcoded budget constant, no
  per-machine calibration. The pool size adapts to whatever the
  runtime grants.

Notes:
- Error scope probing: wgpu-native v29 classifies "Not enough memory
  left" as WGPUErrorType_Validation, not OutOfMemory. We push both
  filters (nested) and treat either firing as probe failure.
- The 4 GB probe cap is principled, not magic: above that, wgpu-native's
  advertised maxBufferSize is sometimes a sentinel (1 TB) that just
  forces wasteful halving steps. 4 GB is the largest buffer any
  realistic WebGPU implementation will grant a single allocation today.
- Pool destroy()/release happens after model release in shutdown() so
  the underlying buffer outlives every bind group that references it.

Follow-ups: spatial chunking (task #22) for finer eviction granularity;
cull perf needs work at 100+ models / 1M+ instances (separate from
streaming concerns).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:21:52 +10:00
Dion Moult 71e61dd8a5 wgpu streaming (5/4): per-chunk indices + LRU/distance eviction (stopgap)
Defers index buffers per-chunk (alongside vertex bytes) so streaming
fully delivers on its "don't load until visible" contract — the previous
per-model index buffer was upfront-loaded and tipped scenes >~1.5 GB into
allocator OOM at frame 1.

Adds residency tracking + a two-phase evictor: (1) drop LRU non-visible
chunks first, (2) if everything resident is visible-this-frame, drop the
farthest-from-eye chunk only when the candidate to load is closer. This
gives monotonic convergence to "closest visible chunks fit the budget"
instead of "first 4 win, rest never load."

Default budget set to 1 GB — explicitly a stopgap, documented inline.
The per-machine OOM ceiling on wgpu-native (caused by allocator
fragmentation from one VkDeviceMemory per createBuffer call) cannot be
solved by tuning this knob. The proper fix is a probed single-pool
buffer with sub-allocation, tracked under task #16.

Caveat: LOD1 indices are now force-disabled when chunking — per-chunk
buffers only carry LOD0. Re-enabling needs LOD1 to participate in the
chunk plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:15:11 +10:00
Dion Moult 5a3e5167df wgpu streaming (4/4): per-frame chunk-on-visible loader
The OOM fix for vertex storage. With --streaming, chunks now load on
demand:

  - After cull determines which chunks have visible draws, driveStreamingLoads
    walks non-resident chunks with total_visible_draws > 0 and brings up
    to MAX_STREAMING_LOADS_PER_FRAME (currently 4) into residency.
  - Each load: readSidecarVertexChunk → createBufferWithData →
    buildChunkBindGroup → is_resident = true. Same frame's draw loop
    picks up the newly-built bind_group and renders the chunk.
  - If more non-resident-but-visible chunks remain, requestUpdate is
    called so the load loop keeps running until the visible set is fully
    resident.

Per-chunk bind group construction refactored out of buildModelBindGroup
into a buildChunkBindGroup(m, chunk_idx) helper so the streaming loader
can build one chunk at a time as it arrives.

4 chunks/frame × 60 fps = 240 chunks/sec ingestion. A 200-chunk scene
fully resides in ~1 second of motion. Off-screen chunks never become
resident, never pay vertex-storage VRAM — that's where most of the OOM
fix lands.

Verified on basic.ifc: pixel-identical to non-streaming. On the user's
real 111-model / 1M-instance scene: all metadata loads succeed (was
OOM before), then loader runs but **indices are still loaded upfront
(1.5 GB!) so OOM still hits when vertex chunks start adding on top.**
Per-chunk index deferral is the next commit.

Eager-no-evict policy (per the design conversation): chunks stay
resident once loaded. LRU eviction lands in a follow-up if a workload
proves it necessary.

This completes the 4-commit stage-1 series for task #16. Stage-2:
defer indices, deferred mesh/instance storage if needed, async worker
thread.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:23:25 +10:00
Dion Moult f6d888d42b wgpu streaming (3/4): --streaming scaffold + applyCachedModelStreaming
Wires the metadata-only reader (commit 1) through a parallel streaming
load path. With --streaming on:

  - loadSidecar routes through readSidecarMetadataOnly: reads header +
    mesh dict + instance dict + georef + elements upfront. Skips
    vertex bytes entirely.
  - applyCachedModelStreaming computes the same chunk plan as the
    non-streaming path, allocates the small per-chunk buffers
    (visible_draws + prefix_sums + per_chunk_uniform), allocates the
    model-shared mesh + instance + index buffers, but leaves each
    chunk's vertex_storage NULL and is_resident=false.
  - Stores streaming_file_path + vertex_section_offset on the model so
    the per-frame loader can range-read chunks later.
  - Computes per-chunk world AABB by walking instances → mesh → chunk;
    used by both cull (chunk-level frustum reject, future) and the
    streaming loader (proximity-prioritised fetch, future).

Index buffer is still loaded upfront in stage 1 (small relative to
vertex data: ~1/2 of vertex bytes on real scenes). Stage 2 may defer
it too if measurements suggest it's worth the extra plumbing.

Render + pick already gate on c.bind_group (null when non-resident),
so the existing guards correctly skip non-resident chunks without
further changes.

With this commit alone, --streaming mode shows an EMPTY scene (just
background colour) because no chunk ever becomes resident. Commit 4
adds the per-frame loader that triggers chunk load when cull marks
them visible — that's the commit where rendering kicks in and the
OOM fix actually lands.

Default behaviour (no --streaming): legacy synchronous full-load.
Pixel-identical to the prior commit on basic.ifc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:15:53 +10:00
Dion Moult d368ee449d wgpu streaming (2/4): per-chunk residency fields on WgpuModelGpuData
Foundation for streaming. Adds to each Chunk:
  - is_resident (default true; streaming flips false initially)
  - vertex_byte_offset / vertex_byte_size in the sidecar file
  - aabb_min / aabb_max world-space chunk bounds (used by future cull
    and streaming priority)

Plus on the model:
  - streaming_file_path (non-empty = streaming path was used)
  - streaming_vertex_section_offset (where the chunks live in the file)

All fields default to backward-compatible values: is_resident=true,
streaming_file_path empty. The existing non-streaming applyCachedModel
sets up a Chunk with is_resident=true (implicit) and ignores the
streaming fields, so no behaviour changes yet.

Commit 3/4 wires the metadata-only reader from (1/4) through a new
applyCachedModelStreaming path that flips is_resident=false initially;
commit 4/4 adds the per-frame loader that brings chunks resident on
demand. This commit is verified pixel-identical to the previous render
on basic.ifc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:10:53 +10:00
Dion Moult a06d920fc6 wgpu streaming (1/4): metadata-only sidecar reader
First foundational piece for task #16. WgpuStreamingLoader exposes:

  - readSidecarMetadataOnly(path): reads v13 header + mesh dict + instance
    dict + georef + elements + string table from disk. Skips the bulky
    vertex and index byte sections, recording their on-disk offsets so
    they can be range-read later (per-chunk, on demand). The file handle
    is closed before return.

  - readSidecarVertexChunk / readSidecarIndexChunk: open + fseek + fread
    for a byte range. Synchronous; intended to be called from a worker
    thread for true async streaming or the main thread for stage-1
    on-demand load.

No format change yet — operates on existing v13 sidecars. v14 with an
explicit per-chunk TOC arrives in a follow-up; this layer abstracts
the chunk boundaries so the upgrade stays internal.

No integration with existing applyCachedModel — that's commit 3/4.
Build verifies the API compiles and links into IfcViewerWgpu.

Commits in this series:
  1/4: metadata-only reader (THIS)
  2/4: per-chunk residency state on WgpuModelGpuData
  3/4: --streaming opt-in path through applyCachedModel
  4/4: per-frame chunk-on-visible loader (the OOM fix)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:07:33 +10:00
Dion Moult a1693259b8 wgpu backend: BVH cull (opt-in via --bvh, default off)
Stage 15 implementation lands but doesn't pay off as default-on. On a
562k-instance / 18-model scene with a centred camera, the BVH walk
adds ~10 ms of cull cost without rejecting enough subtrees to
compensate — every interior node's AABB straddles the frustum, so
descents go all the way to leaves anyway. Linear scan beats it by
that 10 ms.

GL's BVH works better mainly because they do full cull (frustum + HiZ
+ contribution) at every node — their per-test cost is lower (likely
SIMD-vectorised) and they get more subtree rejections. My current
impl does frustum-only at interior nodes (HiZ there cost more than
it saved on the smaller dataset).

For now, gate the whole BVH walk behind --bvh, default off. The
infrastructure (BvhAccel build at applyCachedModel, walk in cull,
release) stays in place so it's a one-flag toggle to measure either
side. Real default-on requires further tuning — see updated task #15.

Measured on 562k-instance scene:
  --bvh on  → 25.9ms total (cull 25.4ms)
  --bvh off → 15.4ms total (cull 14.5ms)   ← default

For comparison, GL on the same scene + camera:
  GL → 18.2ms total (cull 8.5ms wall, multi-threaded BVH)

Net: wgpu beats GL by ~3ms total despite slower cull, because the
GPU side (no edge-pass cost, async HiZ readback, lean main pipeline)
gives back more than the cull deficit.

Also added task #17 (GPU compute-shader cull) as the asymptotic
answer — both backends hit CPU cull as the ceiling on ≥500k scenes;
moving it to a compute shader drops it to sub-ms regardless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:04:44 +10:00
Dion Moult 7dc13eb104 wgpu backend: chunk vertex storage to fit browser limits + settle frame
Two pieces:

1. Per-chunk vertex storage (stage 13)
   WebGPU mandates maxStorageBufferBindingSize ≥ 128 MB. Real BIM models
   routinely exceed that (one of yours is 139 MB vertex). Without
   chunking, every browser load would fail with
   "exceeds max_storage_buffer_binding_size".

   Strategy: each model's vertex data is split into ≤ 128 MB chunks at
   applyCachedModel time. Each chunk gets its own vertex_storage buffer,
   visible_draws / prefix_sums buffers, per_chunk_uniform, and bind group.
   Index buffer, instance storage, and mesh storage stay single-per-model
   (they fit well under the cap on every scene we've seen). Mesh-to-chunk
   assignment is bake-time-deterministic (walks meshes in order, opens a
   new chunk when adding the next would overflow).

   Cull buckets visible instances by their mesh's chunk; render issues
   one drawcall per non-empty chunk per model. WGSL is unchanged — the
   binary-search vertex pulling works identically per chunk because
   base_vertex is now CHUNK-LOCAL (the chunk's bind group binds its own
   vertex_storage).

   Single code path: chunking is ALWAYS on at 128 MB regardless of
   target. Cost on desktop is a handful of extra drawcalls per frame
   (1 per non-empty chunk; typical models = 1-3 chunks). Negligible.

   A mesh whose vertex range is itself > 128 MB can't fit in any chunk
   and would need splitting — typical IFC meshes are nowhere near that
   (hundreds of verts), and applyCachedModel warns loudly if one ever
   appears.

   --web-limits CLI flag requests the WebGPU mandatory floor limits
   (128 MB max storage binding, 256 MB max buffer) instead of the
   adapter's actual max. Used to verify chunking actually fits through
   browser constraints — turns "trust me, web will work" into a hard
   test. The 139 MB scene loads cleanly with --web-limits.

2. Settle frame after motion (bug fix)
   Reported regression: after orbiting, sub-pixel instances dropped by
   motion-mode contribution culling stayed missing after the camera
   stopped. Event-driven rendering means no frame is scheduled after
   mouse-up, so the cull never re-ran at the still threshold.

   Fix: track last_cull_was_motion_. If this frame used the motion
   threshold, requestUpdate() after present to schedule one settle
   frame. Next frame: camera_moved = false → still threshold → small
   instances reappear. Matches GL's last_cull_was_motion_ behaviour.

Verified pixel-identical on basic.ifc; loads the user's dense scene
successfully under --web-limits (chunks=2 on the 139 MB model,
chunks=1 on the others).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:05:38 +10:00