Rewrite the BonsaiViewer viewport architecture page to match the current
renderer (updated type names, streaming/sidecar flow). Add a dedicated
.ifcview sidecar format reference page and link it from the ifcopenshell
formats toctree, and polish the Bonsai intro copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the streamer/sidecar transfer and record types to describe what
they are rather than how they move:
MeshChunk -> StreamedMesh
InstanceChunk -> StreamedInstance
InstanceCpu -> InstanceInfo
PackedElementInfo -> ElementTableRecord
uploadMeshChunk -> uploadStreamedMesh
uploadInstanceChunk -> uploadStreamedInstance
buildMeshChunk -> buildStreamedMesh
and the two post-index sidecar metadata blocks:
"critical" metadata -> "geometry" metadata (meshes/instances/georef/TOC)
"deferred" metadata -> "element" metadata (elements + string table)
parseSidecarCritical -> parseSidecarGeometryMetadata
parseSidecarDeferred -> parseSidecarElementMetadata
The one behavioural change: the element hierarchy (parent_id) was
carried through ElementInfo, ElementTableRecord, and the sidecar element
table but never consumed, so drop it and bump SIDECAR_VERSION 16 -> 17.
No back-compat: regenerate sidecars. sample.ifcview is regenerated at v17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full section tool for the web viewport, with the gizmo + interaction shared with
desktop from one codebase.
- True-face surface pick. pickSurfaceAt had always ray-cast the instance AABB (to
skip a depth readback), so cuts sat in front of the real surface. The pick
fragment already computes the exact world_pos (it clips sections with it); now
it OUTPUTS it to a 3rd pick MRT (RGBA32F) that every pick path renders, and
pickSurfaceAt / pickSurfaceAtAsync read it back (decodeMappedPickPosition;
ray-AABB kept only as a fallback). The web async pick chains id -> normal ->
position spontaneous staging maps.
- Web tool: LMB drops a cut at the picked surface (LMB drag still orbits), K
toggles, Shift+K clears; oriented to the real MRT surface normal. Exports + a
Section / Clear cuts toolbar pair.
- Shared gizmo: lifted the section-gizmo renderer (SECTION_WGSL + thick-line AA +
quad+arrow VBO + pack + screen-space hit-test) out of the Qt-coupled
OverlayRenderer into a Qt-free SectionGizmoRenderer that ViewportCore::render
draws for BOTH desktop and web (both already render via render()). One identical
gizmo; OverlayRenderer's now-dead section code removed. Fixed 1 m size (matches
the desktop constant).
- Interaction (shared): hitTestSectionGizmo (SectionGizmoRenderer::hitTest) +
beginSectionDrag / updateSectionDrag / endSectionDrag live in ViewportCore.
Drag a gizmo arrow to slide the plane along its normal; Del/Backspace removes
the most recent cut. Desktop's ViewportWindow dropped its duplicate hit-test /
drag math + state and delegates to the core; web wires the same calls.
Tests: sectionPlaneCount add/clear/cap (Catch2, 125); web smoke "click a surface
cuts geometry, clear restores" exercises the shared gizmo + 3-MRT pick (11/11).
Desktop object-pick / marquee unaffected; BonsaiViewer builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add IfcOpenShell-Python and IfcViewer test-running documentation, including desktop CTest targets and web Playwright smoke tests. Move the web test README content into the Sphinx docs.\n\nGenerated with the assistance of an AI coding tool.
Update Bonsai viewer headers to identify Bonsai and GPL licensing, add Bonsai Viewer documentation, and document debug output capture.
Generated with the assistance of an AI coding tool.
Bring rubber-band box-select to the web on the Web preset's select button (RMB).
- Core: factor the pick-pass encode + rect copy out of picksInRect into
encodeBoxPickToStaging (mirroring how single-pick shares
encodePickReadbackToStaging), shared by the sync picksInRect (desktop) and a
new async picksInRectAsync (web) — the latter maps the staging buffer via a
spontaneous callback because the sync spin-map hangs the JS loop. New
applyMarqueeToSelection (plain replace / Shift add / Ctrl remove).
- Web main_web: a select-button drag past the click threshold draws a marquee
rubber-band (a plain DOM <div> positioned in CSS px — no GPU overlay pass,
which the web lib lacks) and on release box-picks the rect (device px) and
applies it to the selection. A click (no drag) still single-picks.
- Web shell.html: the #marquee div + styling, and — the reported bug — a
contextmenu preventDefault on the canvas so RMB (now the select button) doesn't
pop the browser menu. (Firefox still forces its native menu on Shift+RightClick;
that's a browser escape hatch pages can't override.)
Tests: applyMarqueeToSelection replace/add/remove + id-0 (Catch2, 124 total);
web smoke marquee drag → rubber-band shown → selection changes → hidden (10/10).
Desktop picksInRect unchanged in behaviour; BonsaiViewer builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the obsolete diagnostic macOS WGPU workflow that referenced removed standalone viewer paths and targets.\n\nGenerated with the assistance of an AI coding tool.
Rename short local variables and parameters in the viewer loading, sidecar, and BonsaiViewer command paths to make their responsibilities clearer.\n\nGenerated with the assistance of an AI coding tool.
Make orbit/pan/select mouse bindings pure data owned by ViewportCore so both
hosts and every preset share one source of truth, and add a "Web" preset. This
rounds out the matrix: the desktop gains a web-style scheme and the web inherits
all presets, with no per-platform hardcoding.
- Core: NavBindings { orbit, pan, select button + modifier } + setNavPreset
("blender" default | "rhino" | "revit" | "web") + navBindings(). Select is
preset-driven too (was hardcoded LMB) so "web" moves it to RMB. web = orbit
LMB, pan MMB, select RMB (LMB drag orbits with no click/drag ambiguity; RMB
click-selects / drag-marquees). NavMod uses "Plain" not "None" (X11 #defines
None to 0L).
- Desktop ViewportWindow: applyNavPreset sources the core table (mapped to Qt);
marquee-arm / single-pick dispatch keys off select_button_. Default stays
blender → no behaviour change.
- Desktop config: AppSettings::NavPreset gains Web + navPresetName(); the
Settings dialog lists it. This also FIXES a pre-existing gap — the preset combo
was persisted but never applied (only WGPU_NAV_PRESET env worked). MainWindow
now applies the persisted preset at startup (env override still wins) and live
on navPresetChanged, so all four presets actually work from the dialog.
- Web main_web: classifyPress routes the pressed button through navBindings()
(orbit/pan/select), defaulting to the "web" preset; context menu already
suppressed so RMB is free.
Tests: setNavPreset table (Catch2, 123 total); web smoke select tests use RMB.
BonsaiViewer builds; 9/9 web smoke.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bpy.ops.bim.reload_link(link_index=...) from a script skips invoke(),
so self.query stayed at its empty default and execute() overwrote the
link's stored query, reloading everything. Only update link.query when
the property was explicitly set (dialog or script argument), and reload
using the stored query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store the selector query used at Link IFC time on the Link
PropertyGroup, restore it from the sidecar cache JSON on host
IFC reopen, and forward it through LoadLink and ReloadLink so
subsequent reloads replay the original filter instead of loading
every element. ReloadLink now opens a small dialog pre-populated
with the current query, allowing the user to edit it in place
without unlink-and-relink.
Also swap TestCalculateLinkMatrix off NamedTemporaryFile(delete=True)
which held an exclusive Windows handle and blocked the
code-under-test from reopening the sidecar path.
Closes#8219
Generated with the assistance of an AI coding tool.
Fold two related cleanups from post-PR review into one commit:
Shared filling predicate — the gizmo poll and AddOpening._add_openings
both need to decide whether an IFC entity is a Bonsai-supported filling
(IfcDoor / IfcWindow, the classes the opening generator can derive
geometry from). Centralise the check in bim.module.model.opening as
is_filling_supported so a schema-broadening tomorrow only edits one
predicate. The gizmo's own predicate is renamed
is_supported_filling_or_opening to reflect its wider domain (also
accepts None for raw meshes and IfcOpeningElement for reassignment).
Aggregate-host guard — regenerate_filling_opening_body returns the
voided host Blender object so callers can recut it. Aggregates have
no mesh data; returning them made callers hit switch_representation
against a None data-block. Guard on voided_obj.data is None and
return None so callers can skip cleanly.
Adds a direct position_gizmos test asserting host-at-index-1 (filling
active) still anchors on the slab — pins the class-based dispatch's
selection-order independence.
Generated with the assistance of an AI coding tool.
Both files interleaved bpy.types imports with ifcopenshell.util
imports, which ruff's I001 rejects for standard-library / third-party
ordering. Running ruff check --fix on the two files reorders them into
the isort-canonical shape with no behaviour change.
Generated with the assistance of an AI coding tool.
EditOpenings.edit_openings unconditionally walked sibling wall sets
twice on every processed opening — once by mapped source id via
get_similar_openings_building_objs, once by filling type via
get_all_building_objects_of_similar_openings — and unioned both into
the building_objs recut set. reload_body_representation then hit every
one of those walls with a switch_representation call, even for the
show/hide toggle path where nothing about the opening changed.
Move both sibling-wall unions inside the is_edited / is_moved branch.
Pure show/hide (no shape edit, no move) now touches only the wall(s)
directly hosting the toggled openings. The edit and move paths still
refresh siblings the same as before, since a mapped-source rewrite
propagates the new shape to every sharing wall and each one needs a
recut.
Generated with the assistance of an AI coding tool.
recalculate_walls commits the wall's own placement to IFC before
recreating its geometry but did not touch its fillings. A door moved
along the wall's reference line therefore stayed cut at its old
position when the user pressed SHIFT+G on the wall, because the wall
recut ran against the still-stale opening placement in IFC.
Walk each wall's HasOpenings and, for every filling whose Blender
matrix_world differs from its committed IFC placement (tool.Ifc.is_moved),
commit the filling's placement and propagate the new matrix to the
enclosing opening via ifcopenshell.api.geometry.edit_object_placement.
The subsequent recreate_wall pass then sees the fresh opening positions
and cuts at the right spot.
Generated with the assistance of an AI coding tool.
Commit 82dd1d94d switched RecalculateFill from
bonsai.core.geometry.switch_representation to the surgical
tool.Geometry.recut_host to speed up batched host recuts. The trade-off
was intentional for that scope but dropped the implicit opening body
refresh that switch_representation used to provide: SHIFT+G on a door
whose parametric dimensions had drifted from its opening no longer
resized the opening, so the wall recut still hit a stale mapped source.
Extract a targeted single-source helper on tool.Model
(regenerate_filling_opening_body) that regenerates one filling's
mapped opening body via the existing FilledOpeningGenerator and
inverse-substitutes the new representation across every filling that
shares the mapped source. Refactor the family-wide caller
(update_simple_openings, used by the parametric-edit finish path) to
delegate to the same helper, deduped by source id so fragmented type
families where multiple mapped sources coexist all get refreshed.
Call the targeted helper at the top of RecalculateFill._recalculate_fills
for each distinct source among the selected fillings. All body-
representation lookups go through tool.Geometry.get_body_representation
rather than inlining the ("Model", "Body", "MODEL_VIEW") triple. An AST
forward-compat guard pins the call site.
Generated with the assistance of an AI coding tool.
The + gizmo previously appeared whenever a fillable host and any
non-host object were selected, so clicking it against an IfcCovering
crashed the geometry kernel when the opening generator tried to derive
a shape it couldn't build (AttributeError on 'NoneType.wrapped_data').
Tighten the gizmo poll to require the secondary selection to be a
class the operator can dispatch on: IfcDoor, IfcWindow,
IfcOpeningElement, or a non-IFC mesh. Make the poll selection-order-
independent so either click order activates it. Validate the same
class set at the operator boundary so keymap or scripted invocations
report a clear warning instead of crashing.
The narrower Door/Window support in the opening generator is a Bonsai
implementation limit, not an IFC schema restriction —
IfcRelFillsElement.RelatedBuildingElement is typed as IfcElement and
the schema permits any subtype. The tooltip and inline comment on the
validation branch note this so a future reader knows the gate is
future-work, not schema-mandated.
Rewrite the operator's bl_description to end-user-friendly wording that
drops the internal terms matrix_world and rl1/rl2.
Fixes#8215.
Generated with the assistance of an AI coding tool.
Coverage had lagged the recent feature work. Add both layers:
- test_viewport_camera (Catch2, headless): constructs ViewportCore with a mock
ViewportHost — construction/teardown touch no GPU (the wgpu teardown lives in
releaseWgpuModelGpuData, only reached with models loaded), and the camera ops
are pure — so it unit-tests the SHARED fly math fast and deterministically:
flyMove (forward step, 5x boost, opposing-key cancel, dt clamp, QE along +Z,
degenerate-pitch stays finite), flyLook (turn-in-place pins the eye, pitch
clamp), flyAdjustSpeed (x1.25/notch, [0.05,1000] clamp), toggleXray, and
hideSelected/showAll. Links the built IfcViewerCore (ViewportCore.cpp is the
monster TU that can't compile standalone). +9 cases → 122 desktop.
- smoke.spec.mjs (Playwright): fly (enter → W moves the camera → Esc exits),
x-ray (toggle translucency on/off), and hide-after-pick, driving the exported
C hooks end-to-end. +3 cases → 9 web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lift the visibility + X-ray ops into ViewportCore so desktop and web share them.
The cull already reads visibility_ (hidden objects skipped) and the frame uniform
reads xray_alpha_cap_, both per frame, so each op just mutates state + schedules
a frame — no GPU buffers to rebuild, no rendering work:
- hideSelected (hide the selection, then deselect), isolateSelected (hide every
non-selected object in a visible model), showAll (clear the hidden set),
toggleXray (flip the alpha cap 1.0<->0.3; cull routes all to the transparent
pass), xrayActive().
- Desktop ViewportWindow: the H / Shift+H / Alt+H / Alt+X keys and the menu-action
wrappers now call the core; the four inline/duplicated implementations are gone.
- Web main_web: same keys (H hide · Shift+H isolate · Alt+H show all · Alt+X
x-ray) + toolbar buttons (Hide / Isolate / Show all / X-ray, the last reflecting
active state).
Verified on web: x-ray toggles and visibly translucent-izes the scene, hide after
a pick removes geometry, 0 GPU errors. 113/113 desktop + 6/6 web smoke; desktop
app builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
applyCachedModel already does a one-time viewAll gated by initial_view_applied_
(desktop + web) — it frames the FIRST model loaded and never re-frames as more
arrive. Two web-only divergences from the desktop viewer, fixed here:
- loadSidecarMetadataWeb called viewAll() AGAIN, unconditionally, per model, so
every federated model that streamed in yanked the camera back to fit the whole
scene. Drop the redundant call; the shared gate handles first-model framing.
- Nothing ever cleared initial_view_applied_. On web the embedded sample sets it
at startup, so after clear_scene_c (a ?model= federation load) the gate was
already tripped and the loaded models never got framed — the camera stayed on
the prior view. (The redundant per-model viewAll above masked this until it was
removed.) Clear the flag in resetScene: a fresh scene should auto-frame its
first model — correct for desktop fresh-opens too.
113/113 desktop + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DRY the fly camera into ViewportCore so desktop and web share identical math:
- flyMove(fwd,back,right,left,up,down,boost,dt): WASD in the view plane, QE
along world +Z, forward = eye→target (no snap after orbiting), Shift = 5x,
dt clamped to 0.1s. flyLook(dx,dy): turn in place (yaw/pitch, eye pinned,
0.2 deg/px, pitch ±89.9). flyAdjustSpeed(notches): wheel scales speed x1.25.
fly_move_speed_ + orbitEye now live in the core (orbitEye's duplicate removed
from ViewportWindow).
- Desktop ViewportWindow: fpsIntegrate / mouse-look / wheel-speed call the core
methods; behaviour unchanged, BonsaiViewer builds clean.
- Web main_web: Shift+F (or the Fly toolbar button) enters and pointer-locks the
canvas; W/A/S/D/Q/E + Shift are held-tracked (keydown/keyup) and integrated
each RAF frame with wall-clock dt; pointer-lock mouse deltas drive flyLook;
wheel tunes speed. Exit on Esc OR a canvas click (matches desktop) — a
pointerlockchange handler catches the browser eating the first Esc to release
the lock (so a single Esc exits), guarded by fly_locked so a denied lock on
entry doesn't insta-exit. Fly button reflects/ syncs active state.
Verified: web enter→WASD moves→click/Esc exits, 0 GPU errors; 113/113 desktop
core + 6/6 web smoke; desktop app builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .ifcview data is hugely redundant (repeated double instance matrices,
patterned indices) — measured 12x zstd whole-file. Server Content-Encoding
can't be used (it breaks HTTP Range), so compress PER-CHUNK into the format.
Format (v16): geometry becomes per-chunk zstd(vertices)+zstd(indices) frames —
each independently Range-fetchable, so streaming is intact — and the critical +
deferred metadata blocks are single zstd frames. SidecarChunk carries the
compressed blob offsets/sizes; applyStreamedChunk (render/upload) is UNCHANGED —
decompression slots into the fetch. Full readSidecar (test/tooling) reconstructs
by decompress+scatter. zstd: desktop links libzstd (also compresses at bake);
the web build (Emscripten has no zstd port) FetchContent's the pinned zstd
source and compiles its decompress-only subset for wasm — no vendored blob,
same version as desktop. New SidecarCompress wraps it (compress guarded off
under Emscripten). Both stream paths — desktop StreamingThread worker + sync
fallback (readChunkGeometryCompressed) and web beginWebChunkLoad — decompress;
readSidecarMetadataOnly / the web bootstrap / loadDeferredMetadataWeb decompress
the metadata blocks. streamingByteProgress reports COMPRESSED bytes. MEASURED: a
752 MB v15 federation → 75 MB v16 (10x; per-file 6.7-15.3x); PP-PLP 118→15 MB,
loads 13/13 chunks on web, 0 errors.
Three fixes found while testing big federations on a real server:
- Web-streamed race: streaming_from_web was set in the deferred-header callback
(a round-trip after the model+chunks exist), so driveStreamingLoads could take
the sync fopen path meanwhile → "failed to read/decompress chunk 0". Now set
immediately after applyCachedModel.
- OOM abort on 18 models: the pool grew unbounded until an alloc failed, but on
web that's an uncatchable bad_alloc abort. Cap total pool capacity
(setMaxTotalCapacity, 3 GB) so it stops before the heap ceiling, and raise
MAXIMUM_MEMORY 2→4 GB (wasm32 max) for headroom.
- Web never evicted (grow-or-block only). At the hard budget, fall through to the
LRU/priority evictor so a big federation stays navigable (highest-contribution
chunks win) instead of freezing with holes.
113/113 desktop + 6/6 web smoke pass. No back-compat: regenerate sidecars
(desktop bakes v16; scratch conv tool migrates v15→v16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>