The scene core is already multi-model — models_gpu_ is a map, applyCachedModel
APPENDS, and per-model model_id / object_id rebasing / georef+transformation
are how the desktop federates today. The only web-specific gap was the byte
source: web had ONE global source (__ifcvFile/__ifcvUrl) and reset the scene on
every load, so it could show one file at a time. Desktop meanwhile carries a
per-model source (streaming_file_path).
Mirror that on web: give each model its own web_source_id into a JS source
registry (Module.__ifcvSources[id] = a picked File or a sized remote URL).
beginWebChunkLoad, the metadata bootstrap, and the on-demand deferred fetch all
read from the owning model's source, so several files stream concurrently into
one federated scene — reusing all the shared machinery (viewAll, picking, the
GUID fetch) untouched.
- webReadRangesAsync / ifcvReadRangeInto / ifcvSourceSize take a source id.
- loadSidecarMetadataWeb(source_id, …) appends (no resetScene); main_web
exposes load_sidecar_from_source_c(id) + clear_scene_c().
- URL size resolution moved to JS (shell.html registers + sizes sources via
HEAD/Range), retiring the C-side ifcvBeginUrlSource / ifcv_source_ready dance.
- shell.html: source registry + "Open" (replace) / "Add" (append) buttons,
multi-file selection; ?model= registers a URL source then loads.
Verified: two sidecars from two sources stream into one scene, both fully
resident, zero GPU errors. 111/111 unit + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First real consumer of the v15 deferred property block, and an end-to-end
demonstration that on-demand property loading works. On a left-click pick,
logSelectedObjectGuidWeb ensures the owning model's deferred block is loaded
(loadDeferredMetadataWeb — a network fetch the FIRST time, cached after) and
logs the picked object's GUID to the console.
Fix uncovered while wiring it: applyCachedModel rebases instance object_ids to
a per-model global base (object_id_base) to keep them unique across models,
but the deferred elements carry the sidecar's original local ids — so a lookup
by the picked (global) id missed. Store object_id_base on the model and rebase
the elements by it when the deferred block loads.
Verified: with a streamed model, the deferred block is fetched ONLY after the
first pick (not at load), and the pick logs a valid 22-char IFC GUID. 111/111
unit + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Users had no feedback during the seconds before/while a model streams. Add a
top progress strip + caption driven by the streaming state:
- "Loading model… N MB" while the critical metadata downloads (no chunks yet),
- "Loading geometry — R / T chunks · N MB" as chunks go resident,
- "Loaded — T chunks · N MB", then it hides.
ViewportCore::streamingProgress(resident, total) sums chunk residency across
models; main_web exports ifcv_chunks_resident_c / ifcv_chunks_total_c for
shell.html to poll each RAF. The EM_JS range reader accumulates
Module.__ifcvBytesLoaded so the caption can show MB downloaded. Shown only for
streamed loads (?model= URL / picked file), not the embedded sample.
6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First-paint over a network is metadata-bound: the whole post-index metadata
(~10 MB on a 118 MB model) had to download before any geometry. But ~25% of
it — elements + string_table, the IFC element tree (names/GUIDs/hierarchy) —
is used only for UI/picking, never for rendering (ViewportCore never touches
it).
v15 splits the post-index metadata into a render-CRITICAL block (meshes,
instances, georef, chunk TOC) preceded by its byte length, then a DEFERRED
block (elements + string_table). The web loader reads only the critical block
before painting; the deferred block sits at a known, self-describing offset
([critical end, EOF)) and is fetched on demand. Desktop reads both (local).
Web on-demand path is wired and complete (not yet called — no UI consumer):
loadDeferredMetadataWeb(model_id) range-fetches + parses the deferred block
into ModelGpuData.elements/string_table, at most once; the first consumer
will be "show the selected object's name" on pick. No background prefetch —
view-only sessions never download the property data (saves 2.64 MB on this
model).
parseSidecarTail split into parseSidecarCritical + parseSidecarDeferred (pure,
unit-tested); StreamingSidecar gains the critical-block locator. Measured
(118 MB model): critical metadata 10.35 -> 7.71 MB, deferred 2.64 MB off the
path; first paint 10.5 -> 9.6 s @ 24 Mbps. (Instances still dominate the
critical block — the next metadata lever.) Format -> v15, no back-compat;
regenerate sidecars. 111/111 unit + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes large-model streaming over a network actually good — fixing read
amplification, then first-paint latency — building on the byte-range work.
v14 layout + TOC (SidecarLayout, pure + unit-tested)
The loader chunks meshes by spatial Morton order, but the sidecar stored
geometry in mesh-id order, so a chunk's meshes were scattered through the
file: streaming one chunk meant either hundreds of tiny range requests or
reading (and discarding) everything between them — a 113 MB model fetched
~340 MB, a 531 MB model 2.25 GB (4.2x). Fix: at bake, reorder meshes into
the loader's chunk order and rebuild vertex/index(LOD0+LOD1)/instance
sections so each chunk is one CONTIGUOUS byte range, and bake a chunk TOC
({first_mesh, mesh_count}). The loader builds chunks straight from the TOC
rather than re-deriving the plan — the float Morton quantisation isn't
bit-identical across toolchains (x86 baker vs wasm loader), so a re-derived
plan scatters the chunks. Format bumped to v14 (regenerate sidecars). The
reorder buckets instances by per-instance mesh_id (the baker never sets
MeshInfo.first_instance — trusting it scrambled every transform → geometry
at the origin). Multiset-verified on a 28,900-instance model: every
instance's placement + geometry preserved. Result: 531 MB fetches 531 MB
(1.0x) in 72 requests (was 2036).
Progressive streaming (concurrency cap + small chunks)
Even at 1x, geometry appeared only after ~the whole model arrived: the
browser multiplexes every in-flight Range request over one HTTP/2 conn, so
unbounded concurrency (9 in flight) split the bandwidth and nothing finished
until the end (measured: first paint after 113 of 118 MB / 35 s @ 24 Mbps).
Cap concurrent chunk loads (kMaxWebInflightChunks=2): the priority-sorted
top chunks finish and paint first, then the next → first paint 9 s. Chunk
size dropped 16->4 MB (cheap now that each chunk is one read; matches Cesium
3D Tiles / xeokit / SVF2) for smoother progression. First-paint is now
metadata-bound (~10 MB tail) — the next lever.
111/111 unit (new test_sidecar_layout: geometry preserved, contiguous layout,
Morton-identity) + 6/6 web smoke pass; desktop bake (SceneLoader) reorders
before writeSidecar; embedded web sample regenerated to v14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets tooling read the wasm heap size (e.g. to verify a large sidecar
streams by byte range instead of loading whole, and to watch memory while
battle-testing big models over the network). Standard emscripten runtime
method, zero size cost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Battle-testing real sidecars over HTTP Range exposed severe thrash: a
531 MB model re-fetched 2.25 GB (4×) and never converged — viewAll puts
the whole model in frustum, so every chunk wants to be resident, and the
web async path made it worse two ways:
- A web load only consumes pool space when it COMPLETES (async), so the
per-frame issuance over-committed the pool; completions then failed
applyStreamedChunk on a full pool, the chunk re-candidated with no
cooldown, and re-fetched every frame.
- Pool growth is itself async on web (provisional sub-buffers validated
off the JS event loop), so even fetched chunks failed to alloc until
the pool caught up, and re-fetched.
Fix: gate web chunk issuance on VALIDATED free space + in-flight
reservation, and grow the pool BEFORE fetching:
- streaming_web_inflight_bytes_ reserves each in-flight load's footprint
so we never have more bytes in flight than the pool can place.
- When a visible chunk doesn't fit validated free, don't fetch — call
pool_.requestGrowth() (BufferPool: drives the async provisional grow
without allocating) and short-back-off; the chunk is fetched once,
after space exists. When the pool is saturated (model > GPU memory),
long-cooldown so a never-fitting chunk isn't re-fetched. Gating before
the evictor also kills phase-2 visible↔visible swap thrash.
- On async load failure, cool down (short if the pool can still grow,
long if saturated) instead of re-candidating next frame.
Result (manual battle tool, host.mjs + real files): 531 MB now loads
23/23 chunks, 322 MB loads 14/14 — resident climbs monotonically with
ZERO thrash warnings and a stable resident set, vs the old re-fetch loop.
The whole model resides on the GPU and stays. (Remaining ~3× ramp
over-fetch — per-chunk re-loads during the async-growth ramp + read
amplification from chunk byte-locality — is a separate efficiency
follow-up, not thrash.) 6/6 web smoke + 107/107 unit pass; desktop
unaffected (the gate is web-only; requestGrowth is a no-op wrapper there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
beginWebChunkLoad read the vertex ranges, then in the completion callback
read the index ranges, then applied — two serial round trips per chunk.
On a network that's the dominant per-chunk latency. Now both reads fire
at once and a small shared join (payloads + per-read done/ok flags) runs
the apply when the second lands, halving per-chunk RTT. Model re-lookup
still happens at apply time, so a resetScene mid-flight is dropped safely.
Per-chunk concurrency stacks with the existing across-chunk concurrency
(driveStreamingLoads issues several loads per frame); the browser caps
simultaneous connections per origin, so no explicit in-flight cap is
needed. 6/6 web smoke + 107/107 unit pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:
- ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
header (206). If a server ignores Range and returns 200, the requested
window is sliced out so it still works (without the bandwidth saving).
- ifcvFileSize: Blob size, or the URL's total length resolved up front.
- ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
- The metadata bootstrap is extracted into a source-agnostic
loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
thin entries. streaming_from_blob → streaming_from_web (now covers both).
main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).
Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: open a model in the web viewer, then launch the desktop
BonsaiViewer and open another model — the browser tab freezes, and a
fresh web tab then fails with "RequestDevice failed: Not enough memory
left". Root cause is GPU-memory contention: two heavy GPU clients on one
GPU, and the desktop app's allocations starve the browser's WebGPU
process, which reclaims our device.
We can't conjure GPU memory, but we were amplifying the symptom: with no
device-lost handler, render() kept driving a dead device —
wgpuSurfaceGetCurrentTexture returns Lost every frame and the
reconfigure + requestFrame retry becomes a tight per-RAF loop that hangs
the tab. Now the web device descriptor wires a device-lost callback that
latches device_lost_ (ignoring the intentional Destroyed reason from our
own shutdown); render() bails while set, so the loop goes idle instead of
spinning, and the console logs guidance to reload. The fresh-tab
RequestDevice OOM is genuine GPU exhaustion — surfaced as before, now with
a clearer message.
Verified the lost callback doesn't disturb Dawn-web's RequestDevice (all
5 web smoke tests still init + pass); desktop unaffected (device_lost_
stays false). The real contention path can't be reproduced in the headless
harness, so the loss handler itself is covered by review, not a test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fragment shader pre-decodes sRGB→linear to cancel the surface's
automatic linear→sRGB write encoding, so the final bytes match the GL
backend. That only holds when the render target is an sRGB format. On
desktop the surface's preferred format already is (e.g. BGRA8UnormSrgb),
but the browser canvas only offers plain BGRA8Unorm — so nothing
re-encoded and the whole image (background + models) rendered ~3× too
dark (authored bg 0.125,0.137,0.161 → ~32,35,41 collapsed to ~3,4,6).
Fix: when the surface format isn't sRGB, render through an sRGB *view* of
it — the standard WebGPU canvas pattern. surface_view_format_ is the sRGB
sibling of surface_format_ (unchanged when already sRGB, so desktop is a
no-op); configureSurface advertises it via viewFormats, the colour
pipelines (main, MSAA target, edge) target it, and render() creates the
surface view with it. The screenshot path still reads the base texture, so
its BGRA byte-order check stays on surface_format_.
Regression test: sample a 1x1 background pixel and assert it isn't crushed
dark (R,B > 20). Verified visually too — bg is now the correct dark
blue-gray and the cube is properly lit. 5/5 web smoke + 107/107 unit pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On first web load the sample stayed blank until a click/drag, then popped
in. Root cause: the main draw + cull run before driveStreamingLoads in
render(), so a chunk that becomes resident there is only painted a frame
later. On desktop the streaming thread keeps inFlightApprox() > 0 during a
load, so the render loop keeps ticking and the next frame paints it. On web
the sync MEMFS / Blob load finishes instantly (inFlightApprox stays 0), so
the single post-load requestFrame fired once and the on-demand loop went
idle before the geometry was ever drawn — until some input re-armed it.
Fix: arm a bounded settle burst (kStreamingSettleFrames) whenever there's
streaming activity — a load this frame, work still queued, or a visible
chunk not yet resident — and bleed it down over the next few frames, each
requesting one more. Covers the cull→display latency under an on-demand
loop and still quiesces at idle (no busy-rendering). General, not web-only.
Regression test: the sample must render with NO pointer input — a centred
patch (the framed cube) differs from a corner patch (background); a blank
stall leaves both as background. Verified empirically with a no-interaction
probe (canvas went from a static blank hash to a stable rendered one).
107/107 unit tests pass; 4/4 web smoke tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third Playwright case: click dead-centre on the framed sample, assert the
canvas changes (selection highlight rendered) with zero WebGPU errors. A
broken async pick would hang init or leave the canvas unchanged. All three
cases pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Desktop pick reads the pick staging buffer back with a blocking
`while(!done) waitTickInstance()` spin. On web that spin is a no-op
(Asyncify is off) and hangs the JS event loop, so click-to-select was
dead on web. This adds an async sibling that uses the spontaneous
map-callback pattern (AllowSpontaneous + the browser microtask loop)
already proven by the HiZ readback — no blocking.
- encodePickReadbackToStaging(x,y,want_normal): the pick-pass render +
copy-texel-to-staging, extracted from pickObjectAt verbatim and shared
by both readbacks (desktop sync path unchanged).
- pickObjectAtAsync(x,y,cb) [web]: encode, then map the staging buffer
with a spontaneous callback that delivers object_id to cb. One pick in
flight at a time (a pick issued mid-map is dropped → cb(0)).
- applyPickToSelection(id, add, remove): routes a pick result through the
selection state machine (replace / Shift-add / Ctrl-remove / empty-click
clear), mirroring the desktop ViewportWindow click semantics. selection_
marks dirty so the next render's uploadSelectionFlagsIfDirty flushes the
highlight.
main_web wires it: a left release under a 4px drag threshold (no orbit) is
a pick at down-position * devicePixelRatio, with Shift/Ctrl modifiers;
the result callback applies selection and requests a frame. Web + desktop
build clean; 107/107 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a second Playwright case that picks the sample sidecar through the
file input, waits for the C side to confirm the blob load (console),
then asserts an orbit drag changes the canvas with zero WebGPU errors.
This exercises the #88 path distinctly from the embedded MEMFS sample —
a broken metadata-head/tail or chunk range read renders blank and fails
the orbit-changed-canvas check. Both cases pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picked files are no longer copied whole into the wasm heap. The browser
File object stays in JS (Module.__ifcvFile) and is read lazily through
Blob.slice byte ranges, so a 200-500 MB sidecar never enters wasm linear
memory — only chunk-sized slices do.
Mechanism (web-only, #if __EMSCRIPTEN__):
- JS glue (EM_JS): ifcvFileSize + ifcvReadRangeInto — slice [off,off+n)
of the File and copy it into a caller-provided heap pointer, then call
back _ifcv_on_range_done. No malloc across the boundary; C pre-sizes
the destination from the read plan.
- webReadRangesAsync: reuses planSidecarReadRanges to coalesce a range
set into Blob.slice reads (1 MB gap — each slice is an async hop),
scatters them into a destination laid out in input order, and fires a
continuation when the whole set lands. An in-flight map keyed by id
survives unordered_map rehash (scratch buffers are heap-owned).
- loadSidecarFromBlobWeb: async metadata load — head (16 B) -> index
count -> tail-to-EOF -> parseSidecarHead/Tail -> applyCachedModel, then
tags the model streaming_from_blob and frames it.
- driveStreamingLoads: blob-sourced models route to beginWebChunkLoad
(async vertex+index range reads -> applyStreamedChunk in the callback),
holding is_loading until the bytes arrive. The embedded MEMFS sample
keeps the synchronous fopen path.
shell.html stashes the File and calls _load_sidecar_from_blob_c instead of
FS.writeFile'ing the whole thing; EXPORTED_RUNTIME_METHODS=['FS'] dropped.
Desktop is untouched (the new members + driveStreamingLoads branch are all
emscripten-guarded). Web links clean; desktop rebuilds; 107/107 unit tests
pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Splits the v13 metadata wire-format knowledge out of the FILE*-bound
streaming reader into pure, buffer-based functions so the web byte-range
path (#88) can reuse it without loading the whole sidecar into the wasm
heap:
- parseSidecarHead — validates the 16-byte head, yields num_vertex_bytes
- parseSidecarTail — parses meshes/instances/georef/elements/strings
from an in-memory tail buffer, bounds-checked
- planSidecarReadRanges + SidecarReadPlan — the range-coalescing /
scatter planner, promoted out of the anonymous namespace
readSidecarMetadataOnly and the range readers now call these; desktop
behaviour is unchanged (head + tail are small, the bulk is still skipped
via seek). The metadata tail is split from the head around the bulk
sections, so a blob-backed loader just slices those two regions and
hands the bytes to the same parsers.
Closes a coverage gap: StreamingLoader had no unit tests. Adds
test_streaming_loader.cpp (7 cases: metadata round-trip, corrupt/truncated
rejection, vertex+index range scatter, head validation, tail truncation,
read-plan coalescing). 107/107 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drives the built web page in a real Chrome (channel:'chrome', so no
`playwright install`): waits for wgpu init, then asserts an orbit drag
changes the composited canvas — one check that simultaneously proves the
scene rendered, mouse input is wired, and the log overlay isn't eating
events — and that zero uncaptured WebGPU errors were logged. Every web
bring-up bug so far (blank render, error-buffer cascade, overlay
swallowing input) is this shape; this would have caught them.
serve.mjs statically serves build-web; the config launches headed
against the real GPU (--use-angle=vulkan + --ignore-gpu-blocklist are
load-bearing for a non-null adapter on Linux Chrome). node_modules and
results are gitignored. See README.md to run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On web we skip the desktop error-scope spin-wait (it blocks the JS event
loop and hangs the page). The old web addSubBuffer then judged success by
`buf != nullptr` — but Dawn-web returns a NON-NULL error buffer on OOM,
so the pool committed an invalid sub-buffer, alloc handed out slices in
it, and every chunk_bind_group built against it failed ("BindGroup is
invalid" spam + a wgpuQueueSubmit panic). Loading a model larger than the
browser's WebGPU budget triggered exactly this.
Add the grown sub-buffer as *provisional* (alloc and the capacity/free
tallies skip it) and validate it through a non-blocking AllowSpontaneous
PopErrorScope. resolveProvisionalGrowth() clears the flag when it's good,
or drops the sub-buffer and latches growth_disabled_ on a real OOM — at
which point the streaming evictor bounds the working set to what fits
instead of cascading. Only one provisional grow is in flight at a time
(growth_pending_). Desktop keeps its synchronous halve-retry path
unchanged. All 100 unit tests still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status overlay was position:fixed top/left/right with max-height
80vh and pointer-events:auto — a near-fullscreen div that both hid the
model and swallowed mouse events, so orbit drags over most of the canvas
did nothing. Move it to a small bottom-left box, set pointer-events:none
so it never intercepts navigation, and collapse it to a few dimmed lines
once the app goes live (errors re-expand it). It still auto-scrolls to
the newest line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an "Open .ifcview…" button that opens the browser's native file
chooser. The picked file's bytes are written into MEMFS and a new
exported entry point, load_uploaded_model_c, reads them back via the
existing loadSidecarFromPath: it resetScene()s the current model
(replace, not append), loads the sidecar, and viewAll()s it. Geometry
becomes resident over subsequent frames through render()'s inline
driveStreamingLoads, same as the embedded sample.
Deliberately a file picker, not drag-drop: browser file drag-drop needs
an X11 drag source (a file manager) to drag *from*, which a minimal WM
(ratpoison) doesn't provide. The native chooser is WM-independent.
Exports _load_uploaded_model_c and the FS runtime method; URL/byte-range
fetch of remote models stays for #88.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop pool walked down from 4 GB looking for the largest single
buffer the runtime would grant, then used that as the pool's first
sub-buffer and growth increment. On a stack that advertises an
effectively unbounded maxBufferSize (1 TB observed on wgpu-native here)
the walk lands on 2 GB, so loading even a 1.2 MB model allocated a 2 GB
sub-buffer. That plus the depth/MSAA/HiZ/pick attachments exhausted
VRAM, and the next tiny allocation — the ~4 KB selection_flags buffer —
failed with "Not enough memory left", invalidating its bind group and
panicking wgpuQueueSubmit.
Drop the probe and size the pool to demand, mirroring the web path:
configure a modest initial sub-buffer and let BufferPool::addSubBuffer
grow it lazily (halve-retrying to its 64 MB floor on constrained
devices). A single chunk is capped at 16 MB, so the initial sub-buffer
only has to clear that. Web keeps its 64 MB initial (Chrome contends on
large first allocations); desktop uses 256 MB to keep sub-buffer count
low for big models. The two platforms now share one createPool() (was
probeAndCreatePool — it no longer probes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register emscripten HTML5 pointer/wheel handlers that translate raw
events into ViewportCore::orbitBy / panBy / dollyBy: left-drag orbits,
middle/right-drag pans, wheel zooms. mousedown binds to the canvas;
mousemove/up bind to the window so a drag keeps tracking off-canvas. A
contextmenu suppressor lets right-drag pan without the browser menu.
Pure callbacks — no Asyncify, no sync spin — so none of the web init
gotchas apply. Pick stays deferred until the async buffer-readback
rewrite. The embedded sample is now navigable on Chrome + Firefox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the inline orbit/pan/wheel math in ViewportWindow's mouse
handlers with calls to ViewportCore::orbitBy / panBy / dollyBy. The core
methods request the frame (via the host), so the now-redundant
requestUpdate() calls drop out; the pivot-indicator afterglow and 3px
drag-promotion stay in the Qt layer where they belong. Behaviour is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The orbit navigation math lived in the Qt ViewportWindow, operating on
ViewportCore's camera fields through references. That left the web host
with no way to drive the camera — orbit/pan/zoom were Qt-only.
Lift the three pixel-delta moves into ViewportCore as orbitBy / panBy /
dollyBy so every host (Qt desktop + web) shares one implementation and
the math can't drift between platforms. panBy takes the viewport height
as a parameter (the one Qt coupling: pan's world-units-per-pixel needs
it) so the core stays toolkit-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IFC2X3 representations have no HasShapeAspects inverse; opening the
Geometry & Materials subpanel on an IFC2X3 object raised AttributeError
and left the items list empty. Wrap the access with a getattr default
so pre-IFC4 schemas return an empty iterable, and pin the contract with
an AST forward-compat guard that scans bim/, tool/, and core/ for any
future direct .HasShapeAspects access.
Closes#8157
Generated with the assistance of an AI coding tool.
The check `len(bytedata) == n * 2` was wrong: float64 is 8 bytes per
element, not 2. Legacy float64 checksums fell through to the float32
reader and produced a (2n,)-shaped array, breaking is_moved() and
is_camera_moved() with `ValueError: operands could not be broadcast`
on .blend files saved by Blender <5.0.
Adds a parametrized regression test covering both n=3 (translation)
and n=9 (rotation) for both dtypes.
Generated with the assistance of an AI coding tool.
GizmoWallEdition.position_gizmos used props.anchor_x / props.length
for the in-range check (split icon visibility) and perpendicular
gizmo placement. Those props mirror IFC and are re-primed by
_maybe_resync_wall_props_from_ifc — any operator path that skips
the re-sync leaves the perpendicular gizmo clamped to the previous
wall extent, so the icon parks at the old wall end instead of the
cursor's orthogonal projection. Visible after a wall mutation as
the perpendicular icon landing way off the cursor in top-down view.
Switch to the mesh bbox along local X. recreate_wall rebuilds the
mesh to match the current IFC body on every wall mutation, so
bound_box is authoritative without an explicit props sync.
Generated with the assistance of an AI coding tool.
MEP elements imported as tessellation / brep (no IfcExtrudedAreaSolid
or IfcSweptDiskSolid in their body representation) can't be
parametrically edited — the gizmos offer affordances the geometry
kernel has no path to honour. tool.System.has_parametric_body
inspects the Model/Body/MODEL_VIEW representation and returns True
only when at least one item resolves to one of the two
profile-sweep primitives.
The gate is wired into:
- GizmoMEPActions.is_eligible_object (the action icon group)
- _active_is_flow_segment / _active_is_bend_fitting visibility
predicates the icon row consults per-icon
- GizmoPipeSegmentEdition / GizmoDuctSegmentEdition is_element_type
tool.Parametric.is_pipe_segment / is_duct_segment stay IFC-class-only
so their truth-table contract test keeps reading a single concern.
Generated with the assistance of an AI coding tool.
Pure-math parallelism check (value ≡ 0 mod π within VTX_PRECISION)
that lived as a module-private helper in mep.py belongs next to
tool.Cad.is_x — same comparator family, no MEP-specific knowledge.
Other features with rotation-difference checks (wall fillet, roof
slope, railing terminus) now have a sanctioned spelling.
Generated with the assistance of an AI coding tool.
Four standalone test files pinning contracts the production code
already honours:
- test_mep_actions_cache.py: GizmoMEPActions visibility-predicate
cache evicts on selection or generation change.
- test_mep_bend_preview_cache.py: bend decorator polyline cache
re-uses within a generation and rebuilds on generation bump.
- test_mep_distribution_fit_smoke.py: bim.fit_flow_segments
round-trips a 3-segment polyline without raising.
- test_preview_cancel_ops_forward_compat.py: AST scan ensures every
preview Enable* operator has a paired Cancel* operator with the
matching prop reset.
Generated with the assistance of an AI coding tool.
Two small refactors:
- apply_transform_modal_draw_gate(group, context) replaces the
three-line _is_transform_modal_active + _hide_all_non_modal_gizmos
pair that BillboardingGizmoGroupMixin, BaseParametricGizmoGroup
and BaseSchematicGizmoGroup all repeat in draw_prepare.
- decorator.py renames _stroke_lines_alpha to a public-scope
draw_polyline_segments and drops the no-longer-private companion
docstring reference; the function is now usable by sibling
decorators that draw polyline overlays.
Plus a few one-liner tweaks in tool/model.py and opening.py
following the helper rename.
Generated with the assistance of an AI coding tool.
Two hot paths the gizmo polls fire every viewport event memoise
their result against tool.Parametric.get_geom_generation():
- tool.Blender.Modifier.any_selected_array_child caches the
per-selection scan against the selection identity-set + the
IFC generation token so a stable selection during a drag
doesn't re-walk every selected object's BBIM_Array pset every
frame.
- bim/module/model/wall.py grows a pair-predicate + connection
cache that the wall topology gizmos hit; both keyed on
(pair_uids, predicate_kind, generation) so a wall split or
axis edit invalidates correctly via the generation bump.
Behavioural contract is unchanged — stale entries are evicted
on generation bump; cache miss returns the same value the
un-cached path returned.
Generated with the assistance of an AI coding tool.
bim/module/model/conftest.py exposes the autouse _require_real_bpy
skip-guard, four make_* factories (obj / element / context /
ifc_file), and a patched_tool context-manager factory that wires
the half-dozen tool.* boundary patches every gizmo + decorator
test was repeating.
Existing test files in the directory drop their local copies of
_require_real_bpy and adopt the patched_tool / make_* fixtures
where the call site simplifies — test_mep_port_operators.py is
the biggest beneficiary (−89 LOC).
No production behaviour change.
Generated with the assistance of an AI coding tool.
Three concerns bundled by file boundary (all in mep.py):
- Extract bend preview operators + GizmoBendPreview into a focused
mep_bend_preview.py module; preview_base.py grows the shared helper
set both bend and other previews now consume; classes tuple in
model/__init__.py updated to register the new module.
- Surface ERROR reports on five silent CANCELLED returns in
MEPUnjoinAtPort / MEPRemoveTerminalFitting / MEPUnjoinPair so a
degenerate IFC file ("fitting has no Blender object", "connected
port leads nowhere") shows up in the popup instead of looking like
a no-op.
- DRY: _resolve_active_mep_segment + _require_port_state factor the
segment-id-or-active-object resolve + port-state guard out of every
port operator's prologue; _wire_anchored_icon_targets pulls the
GizmoMEPActions setup() body into an exercise-without-MRO helper so
the wiring-contract tests can hit it without instantiating the
GizmoGroup.
Drops the now-unused preview_base import that the extraction left
behind.
Generated with the assistance of an AI coding tool.
The opening preview's outline used a single-batch two-pass scheme that
dimmed the occluded back pass via alpha=0.25. The visible front pass also
inherited the source decorator color's modest alpha, so the outline read
as subtle on both sides.
Replace with a CAD hidden-line convention: solid full-alpha front pass on
the visible side, world-space dashed back pass on the occluded side. Both
passes use POLYLINE_UNIFORM_COLOR so depth and line-weight paths match.
The dashed batch is built once per object epoch by a new pure helper
tool.Blender.build_dashed_line_segments (pre-segments edges into world-
space dash chunks), then cached via the existing batch-cache mechanism
under "<uid>_dashed".
The solid front pass is rendered at a slightly wider line width than the
dashed back pass so its halo overpowers Blender's WIRE-display overlay
bias at outline pixels — without the asymmetry the wire's anti-z-fight
forward bias makes the LESS_EQUAL comparison narrowly fail and the
dashed pass wins on visible edges too.
Generated with the assistance of an AI coding tool.
MEPConnectElements took obj1_name/obj2_name (Blender object names),
which break when objects are renamed or replicated by array
duplication. Switch to obj1_guid/obj2_guid resolved via
ifc_file.by_guid, with by_guid RuntimeError surfaced as an operator
error rather than a stack trace. DrawPolylineProfile (the sole
in-tree caller) updates to pass GlobalIds.
Generated with the assistance of an AI coding tool.
Pins the geometry contracts the hand-meshed bend body relies on
while IfcSweptDiskSolid round-trip is broken upstream (#8106):
- profile cross-section sampling: circle returns 16 evenly-spaced
points starting at (radius, 0); rectangle returns the four
canonical corners; anything else returns None so the rep swap
is skipped rather than meshed against the wrong section
- parallel-transport framing keeps the cross-section continuous
around L-shaped corners — pinned via start / end ring planes
- initial_basis override seeds the first ring with the source
segment's local +X / +Y axes, fixing the asymmetric-rectangle
twist the world-Z seed produces
Generated with the assistance of an AI coding tool.
Pins which IFC mutation each port operator commits and which
inputs each refuses with CANCELLED:
- MEPUnjoinAtPort removes the fitting + reconnects the two free
ports; refuses if the named port is free or terminal
- MEPRemoveTerminalFitting deletes the terminal element + leaves
the segment's port free; refuses on bridged fittings
- SelectMEPPathMembers walks IfcRelConnectsPorts in both
directions from the active segment and selects every fitting /
segment reachable through the port graph
Boundary mocks for tool.Ifc, tool.System and MEPGenerator stand
in for the IFC fixture; tests assert against the recorded
ifcopenshell.api.* calls.
Generated with the assistance of an AI coding tool.
Pins two regressions the live MEP gizmo group can hit:
- per-icon setup() must write `position` (and `mode` on open-lock
icons) onto every target_set_operator result; the test stands in
for the AttributeError on bim.mep_add_obstruction that surfaced
when a field was dropped from the operator declaration
- each visibility_condition lambda must stay total against None /
non-IFC inputs, since a single raising predicate silently disables
every sibling icon in the group
Generated with the assistance of an AI coding tool.
ProfileDecorator.draw_faces (used by the roof path-edit overlay) and
SystemDecorator.draw_faces called bmesh.ops.triangulate on the live
bmesh — both mutated the input and produced ear-clip fans that rendered
as visible streaks across n-gon roof faces at alpha 0.1. The opening
DecorationsHandler edit-mode branch had a separate bug: it computed
triangles from obj.data.calc_loop_triangles() while iterating the
edit-mode bmesh, so any topology added mid-edit desynced the indices.
Centralise the correct draw path on tool.Blender.draw_bmesh_face_tris
(wraps bm.calc_loop_triangles, non-mutating, beauty triangulator) and
route all three call-sites through it. A forward-compat AST guard walks
every *Decorator / DecorationsHandler class under bim/module/ and pins
the no-bmesh.ops.triangulate rule against future regressions.
Generated with the assistance of an AI coding tool.
A user clicking the pen icon on a typed-product occurrence whose body
representation is mapped from its type would silently mutate every
sibling occurrence's geometry. Add a confirmation dialog at the pen-icon
dispatcher (the single chokepoint every feature routes through) showing
the sibling count, with a session-scoped suppress checkbox.
The check is read-only: tool.Model.get_sibling_occurrence_count wraps
tool.Geometry.get_elements_by_representation against the resolved body
rep and subtracts self + type. A forward-compat AST guard pins the
dispatcher monopoly so any future feature that binds pen_gizmo directly
to a feature-specific enable op fails the test before merge.
Generated with the assistance of an AI coding tool.
Once a bend was created, the only way to retune start_length /
end_length / radius was to delete and recreate from scratch.
EnableBendPreviewFromBend re-opens the preview on an existing
parametric bend: it walks the bend's ports to resolve the two
connected segments, reads start / end length and radius from the
bend type's BBIM_Fitting pset, and sets editing_bend_id on the
preview props. MEPAddBend then deletes the old bend + its port
connections (single undo step) before the recreate path runs, so
finish replaces the bend in place and cancel discards the edit
without touching the original.
GizmoMEPActions surfaces a pen icon on single bend-fitting
selections via the new _active_is_bend_fitting predicate; the icon
dispatches the new operator. Mirror of the wall fillet re-edit
flow (EnableWallFilletPreviewFromCorner + editing_corner_id in
CreateWallFillet).
Test coverage: registration probe for the new operator, an attached
editing_bend_id field probe on the preview umbrella, and a
parametrized truth-table for the _is_bend_fitting predicate
(IfcFlowFitting with BEND PredefinedType, with other PredefinedType,
with no type, IfcFlowSegment, IfcWall, None).
Generated with the assistance of an AI coding tool.
Wall topology mutations (merge / join / extend-to-wall / unjoin /
fillet) applied to a Bonsai array child are silently overwritten by
the next ``regenerate_array``; merge also orphans a GUID listed in
the parent's ``BBIM_Array.Data``. Add a central
``tool.Blender.Modifier.any_selected_is_array_child`` predicate and
gate the five wall topology gizmo groups plus the six bound operators
behind it. Operator gating is defence in depth against keymap / F3
invocation paths that bypass the gizmo.
The base ``_wall_gizmo_poll_gate`` keeps its loose two-check shape
(viewport gizmos + no preview). A new
``_wall_topology_gizmo_poll_gate`` wraps it with the array-child
filter and is what the topology gizmos use. Host-opening gizmos
deliberately stay on the loose gate: openings authored on a child
are preserved through ``regenerate_array`` and track with the
replicated instance.
A forward-compat AST guard walks wall.py for ``GizmoGroup`` subclasses
and asserts each routes its poll through the tighter gate or the
central predicate, with an allow-list for the parametric-edit and
preview-owner exceptions. New wall topology gizmos inherit the
contract by construction.
Generated with the assistance of an AI coding tool.
Selecting a Bonsai-parametric IfcDoor now shows the swing arc(s)
without entering edit mode. A new viewport decorator polls on the
active object, reads the door's BBIM_Door pset, and draws the same
arcs the parametric door swing gizmo would draw — matching the
hinge / panel-width / x-mirror contract minus the is_editing gate.
A forward-compat test walks every door operation type and cross-
checks the readonly decorator's arc selection against the gizmo's
swing-arc config table, so future enum additions fail in both
surfaces simultaneously.
Also disables the inherited 8-pass dark halo on GizmoArc: an open
curve has no enclosed silhouette, so the offset passes read as
ghost arcs rather than a uniform outline. The arc's own cross-
section thickness keeps it legible without the halo.
Generated with the assistance of an AI coding tool.
The MEP bend feature's IfcSweptDiskSolid representation produces
geometrically correct output but fails to round-trip through the
OpenCascade geometry kernel (upstream issue #8106) — the body is
dropped on the next file load. Until upstream is fixed, MEPAddBend
captures the bend centerline in world space before the segments are
extended (otherwise the post-extension axes no longer reach the
original intersection and arc reconstruction is wrong), then after
the fitting is placed it hand-meshes the bend body and swaps the
type's swept-disk representation for an IfcTessellatedFaceSet via
tool.Geometry.export_mesh_to_tessellation + tool.Model.
replace_object_ifc_representation.
The centerline includes the straight start_length / end_length legs
in addition to the arc so the bend covers the full segment-to-
segment span. Sweep uses parallel-transport framing — each ring's
(right, up) basis is rotated by the minimum rotation that maps the
previous tangent to the current one, eliminating the twist a fixed
world-axis reference produces when the tangent crosses the
reference. Cross-section orientation seeds from the source segment's
matrix_world local +X / +Y so asymmetric IfcRectangleProfileDef
ducts land with XDim / YDim on the same axes the segment expects;
parallel transport then preserves that alignment around the arc.
Centerline radius is radius + profile_dim[lateral_axis] to match
MEPAddBend's ref_point_radius — without this offset, the bend legs
fall short of the extended segments by profile_dim * tan(angle/2).
Face winding is left to the caller to correct via
bmesh.ops.recalc_face_normals on the closed bend tube.
Two FIXME(#8106) markers (capture site + helper call site) so both
can be dropped once upstream lands a swept-disk round-trip fix.
Generated with the assistance of an AI coding tool.
GizmoWallEdition gains a fourth cursor-anchored icon that
spawns a perpendicular branch wall from the cursor's
orthogonal projection on the source wall axis. Click forms
a T-junction; shift+click forms an L-corner with the source
wall trimmed at the projection, keeping its longer portion.
The branch inherits the source's spatial container and
centerline baseline so its authored axis matches the source's
alignment rather than the type's default.
Also includes a floor-plane preview quad for the new gizmo,
a floor-Z cross line on the split preview for top-down
visibility, a small bump to QUAD_ALPHA for clearer preview
fills, and a stacking-offset helper that centralises the
cursor-row screen-up step across three call sites.
Generated with the assistance of an AI coding tool.
The MEP one-shot operators (join, unjoin variants, terminal removal,
path-select, obstruction add/remove) had no viewport surface. This
commit adds GizmoMEPActions — the icon-action gizmo group that
surfaces them as billboarded icons around selected MEP elements.
Three anchor regions: a horizontal row above the bbox top
(selection-cardinality icons), per-port endpoints for the three-state
lock / unjoin icons (open lock for PORT_FREE, closed for
PORT_TERMINAL, unjoin for PORT_JOINED — resolved per-frame from
port_connection_state), and the predicted join location
(compute_mep_join_location, shared with the bend preview) for the
join / unjoin_pair pair. Unjoin icons render at full
DEFAULT_BILLBOARD_SCALE with warning-red hover; endpoint lock icons
shrink so the lock row stays subordinate to the row icons. The
group hides itself entirely while a bend preview is active.
MEPAddObstruction grew a position enum (CURSOR / START / END) and a
mode enum (ADD / REMOVE / TOGGLE) so the gizmo can target a specific
port without touching the cursor and dispatch ADD or REMOVE based on
the click target — the lock_open icons drive ADD with position
pinned, the lock_closed icons drive bim.mep_remove_terminal_fitting.
Without the new fields the gizmo wiring (op_props.position = ...)
crashed at setup() with AttributeError on the obstruction operator.
validate_bend_preconditions extracts the type-match and profile-kind
checks MEPAddBend enforces so EnableBendPreview surfaces the
rejection immediately — the user no longer tunes a preview only to
learn at commit time that the segments use an unsupported profile
(e.g. IfcArbitraryClosedProfileDef).
Generated with the assistance of an AI coding tool.
Four discrete one-shot operators driven by the MEP segment's port
state. mep_unjoin_at_port deletes the IfcFlowFitting bridging a
segment's named port to a second element when the port is in the
JOINED state. mep_remove_terminal_fitting deletes the terminal
fitting at a port (closed-lock state) and dispatches by fitting
type — OBSTRUCTION fittings go through MEPGenerator.remove_obstruction
so the segment absorbs the freed length, other terminal fittings go
through the standard delete path. mep_unjoin_pair finds the single
fitting bridging two selected MEP segments and deletes it.
select_mep_path_members walks the connected MEP network from the
active element via IfcRelConnectsPorts and replaces the selection
with every reachable member. Foundation for the MEP Actions gizmo
group which surfaces these operators as icon affordances around
selected segments.
Generated with the assistance of an AI coding tool.