The temporary-offset workaround (#7408, commit bd57cc8735) subtracts the
directrix centroid (`mean`) from the curve points before building the
sweep near the origin, then must add it back to restore the original
location. The restore negated the sign — `Move(-mean)` instead of
`Move(+mean)` — placing the swept solid at -mean (mirrored through the
origin) rather than its true position.
Only triggers for polyline directrixes (`is_polyhedron()`) whose centroid
is more than 100 m from the origin (`mean.norm() > 1e2`), so models
centered near the origin are unaffected. Models that keep absolute site
coordinates (e.g. many Revit/ODA IFC exports) render affected swept
solids — reinforcing bars, pipes — at a mirrored phantom location far
from the rest of the model.
PR3 shipped tool.Wall.path_connection_location_world; the local
_path_connection_location_world added in PR4 commit 70845e4dd
duplicated the same logic. The only caller in wall.py already uses
the tool method (line 3687 area), so the local helper has been
dead code since the migration in 7e5e7b8d6 routed _get_wall_geom_cached
to tool.Wall.read_geometry. Drop it.
Generated with the assistance of an AI coding tool.
Pre-existing architectural smell on v0.8.0: core/root.py.copy_class
called a module-level _has_material_styles helper that did
ifcopenshell.util.element.get_materials() directly, bypassing the
Prophecy mock seam that every other branch in copy_class flowed
through. Symptom: test/core/test_root.py::TestCopyClass::
test_AAAAAAAAAAAA passed mock strings into copy_class, the helper
called .is_a() on the string, AttributeError.
Move the check to tool.Root.has_material_styles (paired with
assign_body_styles — they're called in sequence as "is there a
material style? if not, assign body style"). core/root.py now
calls root.has_material_styles(new) like every other dependency,
fixing the test failure and dropping the ifcopenshell.util.element
import that was the only consumer of the ifcopenshell import at
module load in core/root.py.
* core/tool.py: add abstract has_material_styles to Root interface.
* tool/root.py: add concrete classmethod near assign_body_styles.
* core/root.py: replace _has_material_styles helper call site with
root.has_material_styles; drop the local helper and its import.
* test/core/test_root.py: add the new mock expectation
root.has_material_styles("element").will_return(False) before the
existing assign_body_styles expectation.
Generated with the assistance of an AI coding tool.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Three test files covering PR4's new surfaces — preview registry,
wall-gizmo poll behaviour, fillet operator registration. Every test
walks the live registry or class hierarchy instead of hard-coding
preview keys, operator names, or helper function names, so adding a
new preview / wall gizmo group / fillet operator exercises the same
invariants without test edits.
test_preview_base.py (6 tests):
* RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a
callable cancel operator on bpy.ops.bim.
* GetPreviewPropsTolerance: get_preview_props returns None for
contexts without a scene (regression guard for the SimpleNamespace
bug fixed in commit ee63137c6).
* ActivationCycle (registry-driven loop): any_preview_active toggles
with each registered preview's is_active flag;
discard_pending_previews clears every active flag across every
registered preview.
* SaveOnDiscardWired: locates the bim.save_project operator
dynamically and verifies its execute path references the discard
helper by its actual __name__.
test_wall_gizmo_poll_gate.py (4 tests):
* WallGizmoGroupsHideDuringPreview: walks the wall module for
bpy.types.GizmoGroup subclasses (skips preview-owner exceptions
whose bl_idname contains 'preview'), mocks any_preview_active to
True, and asserts every discovered gizmo's poll returns False.
* BaseParametricGizmoPollHidesDuringPreview: mirrors the test for
the cross-feature parametric framework base class.
test_fillet_operators.py (3 tests):
* FilletOperatorsRegistered: at-least-four-ops + every-discovered-op-
is-callable. Catches accidental deregistration.
* EnableRejectsIneligibleSelection: poll returns False without a
selection so the operator is greyed-out in menus.
State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are
deliberately omitted — the operator early-returns when context.screen
is unattached and prior tests in the model lane can leave the screen
in that state, making the dispatch path inherently flaky. Live testing
covers the behaviour.
Net: 13 tests pass cleanly in both single-file and full model lane.
Generated with the assistance of an AI coding tool.
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>
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>
deepdiff 9.1.0 added cachebox<6,>=5.2 as a direct runtime dep.
cachebox 5.2.3 only publishes macOS x86_64 wheels for macosx_10_12+,
incompatible with the macos py311 build's --platform macosx_10_10_x86_64.
The daily build's linux-wheel safeguard fires when the resulting
cachebox-*-manylinux_*.whl leaks into the macOS / windows wheels folder
(builds run on ubuntu-latest and cross-build via pip download --platform).
Pin deepdiff to <9.1 (resolves to 9.0.0, no cachebox transitive dep) as
the minimal hotfix. Long-term cleanup: bump the macos py311 platform tag
from 10_10 to 10_13 (matching py312/py313) and re-flag this line with the
standard \$(PYPI_PLATFORM) --only-binary=:all: pattern used by brickschema
and python-socketio.
Partly generated with the assistance of an AI coding tool.
Save-path:
* SaveProject._execute (project/operator.py) now calls
preview_base.discard_pending_previews(context.scene) right after
tool.Parametric.commit_pending_edits(). Previews are session-
transient — discard rather than commit. Sibling gizmo polls gate
on each preview's is_active flag; a stuck flag persisted through
the save would silently hide them on reload. Mirrors the pattern
already in gizmos-8088.
Preview-active gate hardening:
* preview_base.get_preview_props tolerates contexts without a
``scene`` attribute. Pre-existing tests use SimpleNamespace mocks
for the context; the previous getattr(context.scene, ...) raised
AttributeError before the inner default kicked in.
Test update:
* test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache
patches tool.Wall.read_geometry instead of the now-deleted local
wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site).
Generated with the assistance of an AI coding tool.
When override_mode_set_edit encounters an unsupported profile (Couldn't
import profile), deselect the object so Tab continues to cycle cleanly.
Also restores the selection that existed before entering aggregate mode
when finally tabbing out, via save/restore_previous_selection().
Following the pattern from 586f9be077, deselect the active object after
exiting item mode so Tab continues to cycle cleanly. Also deselects
parametric LAYER1/LAYER2 items that cannot be edited directly, avoiding
the need to manually deselect before Tab-cycling out of aggregate mode.
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>
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>
Two cohesive cleanups in one commit.
A. Migrate wall.py to PR3-absorbed tool methods (fixes bug 4: pen icon
missing on fillet corner walls):
PR3 shipped tool.Wall.read_geometry + tool.Wall.validate_for_parametric_edit
but wall.py kept local duplicates predating that work. The local
_read_wall_geometry guards on tool.Blender.Modifier.is_wall (LAYER2-only)
while the tool method guards on tool.Parametric.is_path_connectable_wall
(LAYER2 OR fillet corner). Consequence: _get_wall_geom_cached → local
_read_wall_geometry returned None for every fillet corner →
GizmoWallFilletReedit.position_gizmos hit `if geom is None: hide` →
pen icon was unreachable for every fillet corner the user created.
Three _read_wall_geometry callers migrated to tool.Wall.read_geometry
(_read_wall_state_into_props, _get_wall_geom_cached,
GizmoWallJoinIntersection.position_gizmos). Two
_validate_wall_for_parametric_edit callers migrated to
tool.Wall.validate_for_parametric_edit (_maybe_resync_wall_props_from_ifc,
EnableEditingWall._execute). Local helpers deleted; docstring references
updated.
B. Drop over-restrictive gizmo gates (fixes bug 1: join icons missing
when walls intersect away from endpoints):
GizmoWallJoinIntersection.position_gizmos no longer hides itself when
the projected intersection lands further than MAX_DISTANCE_TO_ENDPOINT_
FACTOR (0.75 wall lengths) from any endpoint. The remaining
PARALLEL_DOT_THRESHOLD (cos 2°) gate via project_axis_intersection
returns None for near-parallel walls and is the only correctness bound;
distance from endpoints is a UI concern, not a geometric one.
GizmoWallFilletReedit.poll drops the has_a / has_b ConnectedFrom +
ConnectedTo guard — the IsFilletCorner pset is the authoritative signal.
EnableWallFilletPreviewFromCorner.execute already separately validates
both neighbour connections and reports a user-facing error if either
side is disconnected.
Generated with the assistance of an AI coding tool.
Three live-session regressions surfaced after the fillet feature
landed.
Sister gizmos competed with the active preview:
* preview_base.any_preview_active(context): new helper iterates the
PREVIEW_CANCEL_OPS registry and returns True if any preview is open.
Future previews registered there automatically gate sister gizmos.
* BaseParametricGizmoGroup.poll (gizmos.py): short-circuits on
any_preview_active so every parametric gizmo (door/window/stair/
roof/railing/wall edition) hides during ANY preview.
* The 4 wall gizmo groups with explicit polls (GizmoWallAddOpening,
GizmoWallExtendVertically, GizmoWallJoinIntersection,
GizmoWallUnjoinSingle) + GizmoWallFilletReedit gain the same gate.
DRY: extract _wall_gizmo_poll_gate(context):
* 5 wall gizmo polls each duplicated the 2 pre-flight checks
(viewport-gizmos enabled + no preview active). The helper centralises
them — each poll becomes a single short-circuit line followed by its
per-feature selection inspection.
ESC cancels the active preview:
* try_cancel_active_preview already existed in preview_base since PR3
but had no caller. Hooked into OverrideEscape.execute (geometry/
operator.py) as a new elif branch — same keymap that already cancels
pen gizmo edit mode + item mode + edit mode + aggregate mode. Order
in the branch chain matters: try preview cancel before falling back
to try_canceling_editing_modifier_parameters_or_path so the in-
flight preview wins over a stale modifier-edit cancel attempt.
Generated with the assistance of an AI coding tool.
In BIM_PT_type_attributes and BIM_PT_object_attributes (when
the active object is a type), attribute value buttons now use
"type.<Attr>" as the selector key so the operator finds
matching occurrences via their relating type rather than the
occurrence's own (often unset) attributes.
Generated with the assistance of an AI coding tool.
After selecting objects in the same container, copy a `location="Name"`
filter query to the clipboard and report it — consistent with the same
behaviour in SelectSimilarType, SelectSimilarAggregate, SelectIfcClass,
and SelectSimilarMaterial.
Generated with the assistance of an AI coding tool.
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>
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>
When a `+`-separated filter group returns no results, `FacetTransformer.facet_list`
was skipping the reset of `has_additive_facet_in_current_list` because the reset
was inside the `if self.elements:` guard. The stale flag caused the next group's
`add_default_elements()` to bail out early, leaving its element set empty and
silently dropping every subsequent group from the result.
Move the flag reset outside the guard so it always fires regardless of whether
the group produced any results.
In the Placement panel, show Location and Rotation X/Y/Z
each on their own row beneath a header label. When the IFC
file uses imperial units, display a read-only feet-and-inches
label alongside each Location input field.
Generated with the assistance of an AI coding tool.
Display general and calculated stair parameters (Width,
Height, Tread Run, Tread Rise, Length, etc.) formatted
to the IFC file's configured length unit rather than
raw numeric values.
Generated with the assistance of an AI coding tool.
End-to-end fillet flow on top of the helpers + recreate_wall hook
(landed in the previous commit). Users select two LAYER2 walls, click
the fillet entry icon, drag the live radius widget, and validate to
replace the corner with a curved LAYER2 corner wall (banana body).
Operators (5):
* EnableWallFilletPreview: 2-wall selection → validates LAYER2 +
straight axis + zero-slope + intersect-or-joined state → seeds the
preview props with a default radius computed from the shorter
available leg.
* FinishWallFilletPreview: dispatches CreateWallFillet with the tuned
radius; clears preview state on FINISHED, preserves it on failure so
the user can re-tune without re-selecting.
* CancelWallFilletPreview: clears preview state, no IFC mutation.
* EnableWallFilletPreviewFromCorner: pen-icon re-edit on an existing
fillet corner — pre-fills the preview from the corner's BBIM_Wall
pset + walks the inverse graph to recover wall A and wall B.
* CreateWallFillet: deletes any prior corner + A↔B path connection,
shortens A and B to the tangent points, instantiates a corner wall
from A's type, unassigns the swept-layer material/type (the explicit
banana body MUST own its geometry), assigns the dominant material,
rebuilds the body, sets a straight 2-point chord axis, stores
BBIM_Wall.IsFilletCorner+FilletRadius, reconnects A and B to the
corner with NOTDEFINED on the corner's side.
Gizmo groups (2 new + entry icon on existing):
* GizmoWallFilletPreview: visible while a preview is active. Bundles
a radius_dim widget at the arc apex, a trim_dim widget along wall A
expressing the same DOF via the leg setback distance
(trim = |radius| * tan(sweep/2)), and validate / cancel icons
anchored above the apex in screen-up.
* GizmoWallFilletReedit: pen-icon entry on an existing fillet corner
wall (single-selection, BBIM_Wall.IsFilletCorner set, both neighbour
connections present). Mutually exclusive with an active preview.
* GizmoWallJoinIntersection now stacks a fillet entry icon
(VIEW3D_GT_fillet → bim.enable_wall_fillet_preview) above the
existing join/unjoin icon in the joined and intersect state branches.
Property + decorator infrastructure:
* prop.py: BIMWallFilletPreviewProperties (Scene-level draft) +
BIMPreviewProperties umbrella with only the wall_fillet pointer.
The umbrella is the seam preview_base.py (landed in PR3) already
reads via getattr(scene, "BIMPreviewProperties", None).
* decorator.py: _stroke_lines_alpha helper + WallFilletPreviewDecorator.
Polls is_active; renders leg projections + arc + arc-center
construction lines from tool.Wall.compute_wall_fillet_geometry.
* __init__.py: registers operators + gizmo groups + property groups +
wires Scene.BIMPreviewProperties.
* handler.py: WallFilletPreviewDecorator.install/uninstall in
_install_decorators — always installed, self-polls on is_active.
Drive-by: extract gizmo.get_screen_up(billboard_rot) helper —
the local +Y of a billboard rotation is the camera's screen-up world
direction. Replaces 4 inline `billboard_rot @ Vector((0.0, 1.0, 0.0))`
sites added across the fillet feature's gizmo groups.
Generated with the assistance of an AI coding tool.
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>
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>
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>
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>
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>
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>
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>
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>
DecorationsHandler now caches dissolved edges (mesh-keyed), world-space
draw payload, and GPUBatch objects with per-object epoch invalidation —
moving one wall doesn't wipe 50 opening caches. Object-mode dissolve
removes triangulation noise; 2-pass depth-test split dims occluded lines
instead of hiding them. Edit-mode behavior unchanged.
Also: disable viewport shadows for IfcFeatureElementSubtraction objects,
and wire DecorationsHandler.uninstall() into the model module's
unregister() so the new persistent handlers don't leak on addon disable.
Generated with the assistance of an AI coding tool.
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>