The coarse motion threshold (15 px vs the 3 px still floor) followed the
per-frame "did the camera move" test directly. During a slow drag on a
janky main thread — the 66-model web session at 20 fps, mouse events
coalesced — some frames see no camera change, so the cull alternated
between thresholds every few frames: 84% of the visible set vanishing
and reappearing (139k <-> 22k objects in the log), with a full
visible-set re-upload at each flip feeding the very jank that caused the
gaps. On screen it read as the model sporadically jumping and returning
while orbiting slowly, easing as streaming and caching settled — which
is exactly how it was reported.
The motion state now latches: any camera movement arms it, and it only
drops after 250 ms of stillness, with the render loop kept alive over
the hold so the fine-threshold re-cull actually runs in an on-demand
loop. A drag degrades once at its start and restores once shortly after
it ends. Measured with a deliberately gappy scripted drag: two
transitions for the whole drag where each 120 ms pause previously
flipped it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the single-threaded web build the CPU cull WAS the frame: 52-60 ms
of a 60 ms frame at 640k instances (desktop hides the same cost across
cores via std::async, which web cannot use without COOP/COEP+pthreads).
Two changes, both also helping desktop:
- ModelGpuData::CullInstance packs the six AABB floats and three ids the
cull reads into 40 contiguous bytes. InstanceInfo is 232 bytes with
the AABB 200 bytes away from the ids, so the walk paid two or three
cache lines per instance. Rebuilt by rebuildCullInstances at model
apply and inside uploadInstanceRecords, which every recompose,
transform and colour-override change already funnels through.
Measured on web: 94 ns/instance -> 36 ns/instance during a continuous
orbit (~2.6x).
- render() re-culls only when a cull input changed: the camera, a
cull-relevant setting (contribution px, LOD px, x-ray, HiZ on/off), a
fresh HiZ pyramid, or scene_epoch_ — bumped by chunk residency,
visibility, colours, transforms, model add/remove/hide/unload. A
frame requested for an overlay redraw, pick feedback, or a streaming
tick where nothing landed draws from the buffers the last cull
uploaded and skips the walk entirely. Benchmarks are exempt so bench
numbers keep measuring the real cull.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On web there is no device-memory query, so the budget sat at the wasm
heap cap while the pool grew until Chrome's GPU process refused
(observed at 1920 MB on a 66-model session). Nothing acted on that
refusal: the cache kept the last byte, and the next attachment
reallocation (orbit resize, 76 MB) had to fail first — a few frames of
invalid-TextureView errors — before pressure feedback carved out room.
The refusal IS the query-less platform's device report. render() now
answers the first one by lowering the budget by the required-tier
margin and shrinking the pool to it, so attachments and model buffers
find headroom without ever failing. Desktop gets the same fallback for
drivers GpuMemory cannot answer for.
Reproduced under Playwright with a native process squeezing the GPU:
Chrome refuses at 512 MB, the margin (256 MB) is released on the next
frame, and the session continues with zero uncaptured WebGPU errors —
previously the same squeeze produced invalid-view frames before
recovery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getObjects() is on every real host page's path — a click hands back an
object id, and resolving it to a GlobalId/name/type needs the element
tables (the JS layer also builds its GUID index from this call). The
implementation materialised a vector of ElementRef (three fresh
std::strings per element), serialised the entire scene into one JSON
string grown by +=, and UTF8ToString'd the whole thing — several
hundred MB simultaneously alive at ~600k elements. The wasm heap never
returns pages, so that transient became the session's permanent floor.
ViewportCore::visitModelElements hands out one model's elements as
slices into its string table (no per-element copies), and the export
serialises straight from those, one model per batch, reusing one string
whose capacity grows only to the largest model. The JS side accumulates
batches and resolves the same array as before — the page API is
unchanged. Peak is now one model's JSON instead of the scene's.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wasm heap grew past 2 GB on a 66-model session (surfacing first as
the setBindGroup 2 GB TypeError, fixed separately) because CPU memory
attached to loaded geometry never shrank while the GPU pool did:
- mesh_triangles_cache — the dequantised positions + LOD0 indices the
surface raycasts and measurement tools read — was filled once per mesh
on first residency (gated on mesh_local_volumes == 0) and never
released, converging over a session to the whole federation's geometry
on the heap: 12 B/vertex + 4 B/index, 400 MB - 1 GB at this scale. And
on web nothing reads it at all (no measurement tools yet).
- Every chunk's cull scratch was reserved at model load (20 B/instance
scene-wide) and the scratch + uploaded mirrors survived eviction.
- Cull ran the HiZ test and emitted VisibleDrawGpu entries — then
uploaded them — for non-resident chunks render() cannot draw.
Now the shadow follows GPU residency: a per-mesh resident-chunk refcount
(the spatial planner may duplicate a mesh into several chunks) is
counted up in applyStreamedChunk and down in unloadChunk, releasing the
mesh's entry at zero and refilling from the chunk bytes on the next
residency. mesh_local_volumes (8 B/mesh) is kept across eviction so the
Volume tool still covers evicted meshes. Hosts opt in via
ViewportHost::wantsCpuMeshTriangles(): Qt yes, web no until the tools
are ported — so on web the shadow costs nothing.
Cull stops at the streaming counters for non-resident chunks, the eager
scratch reserve is gone, and unloadChunk releases the scratch and
uploaded mirrors. Clearing the mirrors also fixes a real staleness bug
in unload/load: the model's cull buffers are recreated on load, and a
stale mirror would make the memcmp dirty-check skip the first upload
into the fresh (garbage) buffer.
The heartbeat log reports the shadow (cpuTris). Measured on a 3-model /
990 MB scene: shadow tracks residency (493 MB at a 530 MB resident set,
flat over minutes of streaming churn; previously monotonic), unload
drops it to zero, reload refills it (verified via readbackMeshTriangles
round trip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Emscripten's generated WebGPU shim implements the dynamic-offset path of
wgpuRenderPassEncoderSetBindGroup as
pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);
where HEAPU32 is the view over the entire wasm linear memory. Browsers
validate the byte length of that whole backing buffer, not the slice
actually read, and refuse anything over 2 GB. This build lets the heap
grow to 4 GB because large federations need it, so on a big enough
session (66 models) every dynamic-offset draw — the axis gizmo, section
gizmo and overlay lines, all drawn every frame — throws
TypeError: GPURenderPassEncoder.setBindGroup: Argument 3 can't be an
ArrayBuffer or an ArrayBufferView larger than 2 GB
on every frame for the life of the page.
ifcviewer::setBindGroupDynamic copies the handful of offsets into a
small Uint32Array on web (HEAPU32.slice, not subarray, which would alias
the heap again) and forwards straight through natively. The five
dynamic-offset call sites route through it. A Playwright spec spies on
setBindGroup and asserts the largest buffer it is ever handed is the
offsets themselves (4 bytes), where the shim previously passed the full
268 MB heap 35 times in three seconds of idle rendering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the geometry in view needs more GPU memory than the cache can
hold, the viewer keeps the largest on-screen chunks resident and streams
the rest as the camera moves. That is the right degradation, but it was
invisible: nothing told the user the scene did not fit, and the only
lever was removing or hiding models, neither of which is "keep it in the
federation but stop spending GPU memory on it".
Viewer core:
- ModelGpuData::unloaded, with drawable() = !hidden && !unloaded now the
test every cull / draw / pick / streaming pass uses. unloadModel evicts
every chunk and releases the model's own buffers; loadModel recreates
them from the CPU mirrors (no disk read) and lets chunks stream back.
Recompose keeps the CPU instances current while a model is unloaded so
a reload sees up-to-date transforms. The MeshGpu/InstanceGpu record
builders are factored out so load and reload share them.
- FrameStats reports the camera's working set: chunks wanted, how many
of those are not resident, and their bytes.
- modelVramBytes / isModelUnloaded accessors, forwarded by ViewportWindow.
BonsaiViewer:
- Models tree gains a memory column (name | MB | eye) refreshed once a
second and on load-state changes; unloaded models read "unloaded" in
italics. The viewport stays the single authority for the state;
SessionState only carries the modelLoadStateChanged notification.
- Context menu: "Unload Model" / "Load Model", distinct from hide and
remove, reporting the MB freed in the status bar.
- Status bar notice, independent of the perf-stats toggle, once the
shortfall has persisted for 3 s (a moment of missing chunks after any
camera move is normal): "GPU memory full: N of M visible chunks (X MB)
not loaded", with a tooltip pointing at Unload. The perf label also
shows "N/M chunks waiting".
Verified on the GPU: unloading a 497 MB model frees it immediately with
the others still rendering; reloading streams all 180 chunks back.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 66-model session oscillated with a ~4 s period — 298 releases in one
log: the pool grew to its ceiling, the next report read ~83 MB free, the
budget dropped and the pool shrank, the reading rebounded, the budget
rose and the pool re-grew, reloading the same chunks each time. Objects
flickered on and off continuously.
The report includes transients the viewer itself creates: the upload
staging behind a burst of chunk loads (~170 MB in that session) and a
released sub-buffer the driver has not yet reclaimed. A budget that
followed every reading fed those straight back into growth decisions.
GpuBudget::update now bounds the cache outright on the first device
report and afterwards moves only on sustained readings: lower when free
memory is below half the margin on two consecutive scheduled reports,
raise when it is above 1.5× the margin on two, and nothing in between.
Transients drain well within a poll interval, so a momentary low never
reaches the pool, while a process that really took memory still does a
second later. A refused allocation (onPressure) is never deferred.
Verified in the saturated regime (working set ~990 MB against a 683 MB
budget, continuous streaming): zero releases over 75 s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 66-model session showed the pool reach a 1938 MB ceiling, the next
poll lower the budget to 1756, and the shrink drop 402 MB (73+73+256)
for a 182 MB excess, which the pool then spent seconds re-growing. Two
causes.
The release granularity is whole sub-buffers but the shrink ran "until
capacity ≤ target", so the last 36 MB of excess cost a 256 MB
sub-buffer. shrinkToCapacity now never undershoots — it releases only
while doing so keeps capacity ≥ target, leaving a sub-buffer's worth of
excess for the margin to absorb — and the pressure path uses a separate
releaseAtLeast(bytes), whose contract is the opposite: free at least
what the failed allocation needs, whatever the granularity. Resident
geometry is also only evicted once the pool is over budget by half the
margin (GpuBudget::shrinkTarget), so report jitter does not trigger a
shrink-and-reload.
The ceiling was a second old when the pool grew into it, and the upload
staging that rides on growth had pushed device free memory to ~74 MB —
below the driver's observed refusal point — before the next scheduled
poll. pollDeviceMemory now re-derives the budget immediately after any
sub-buffer is added, so the next growth decision sees the device as it
is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The budget was derived once at init as device free minus a reserve sized
for attachments at 4K plus margin. On a 1440p surface that idled ~550 MB
of VRAM the user's hardware could have spent on geometry, and it never
followed the device as other processes came and went.
Now, on the same once-a-second device poll that feeds the status bar,
budget = cache capacity + device free - margin
is recomputed and applied: the pool's growth ceiling moves with it, and
the pool yields whole sub-buffers when the device has less to give than
the pool holds. The attachments are eager, so at any poll they are
already inside "used" at the actual surface size; a resize that no
longer fits is answered by the existing pressure path rather than by a
permanent reserve.
The margin is 256 MB for later required allocations plus a learned part:
drivers refuse while still reporting memory free (the original crash
refused 59 MB with 221 MB "free"), so a pressure event records how much
reported-free memory proved unusable and update() stops short of it from
then on, instead of growing straight back into the same refusal.
Web is unchanged: fixed heap cap plus pressure. On the test machine the
idle-device budget goes from 1609 MB to 2212 MB; with another process
holding 1 GB mid-session the budget follows it down and back up without
evicting geometry the device could still hold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loading enough models drove the chunk pool to the driver's refusal point,
after which the first click aborted: the pick attachments are allocated
lazily, wgpu-native reported their OOM as a validation error nobody
observed, and the invalid views reached wgpuQueueSubmit, which panics
across the FFI boundary. Two policy defects compounding: the cache was
allowed to take the last byte, and nothing but the pool's own growth was
treated as fallible.
GPU memory is now two tiers. Required allocations (per-pixel attachments,
a model's metadata buffers, readback staging) are eager, deterministic
and fallible; the chunk pool is an elastic cache that grows only to a
budget and yields whenever a required allocation fails.
- GpuBudget (pure, unit-tested): desktop derives the budget from the
driver's free-memory report minus a reserve for the attachments at 4K;
web keeps the wasm-heap cap; either lowers it on pressure. The budget's
source differs per platform, the mechanism does not.
- GpuAllocScope: the OOM/Validation error-scope dance in one place,
synchronous on wgpu-native, provisional on Dawn-web. BufferPool's
inline copy now uses it.
- BufferPool::shrinkToCapacity releases whole sub-buffers newest-first
after the owner empties them; growth clamps to the budget instead of
overshooting.
- ViewportCore::allocateRequired runs any required creation under a
scope and, on failure, lowers the budget, evicts and releases cache
sub-buffers, waits for the device to reclaim them, and retries until
it fits or the cache is at its floor. Pick attachments are created with
the other attachments in configureSurface; render() skips a frame
rather than submit invalid views; a model whose buffers cannot fit is
not loaded instead of aborting.
Verified on a 4 GB GeForce: the pool clamps itself at the derived budget
(256+256+67 MB for a 579 MB budget) and, in a standalone check against
the real device, a pool grown to the driver's refusal point observes a
failed required allocation, releases 320 MB and succeeds on retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FrameStats gains the geometry pool's used/capacity bytes and, on desktop,
the device-wide used/total reported by the driver (NVML via dlopen, or
amdgpu/i915 sysfs, matched to the wgpu adapter's vendor/device id so a
switchable-graphics laptop reports the card wgpu actually picked). The
device query is polled once a second, not per frame. Web has no VRAM
query, so the device figure is omitted there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A federated pick could be attributed to the wrong file. The model slot a
host sees — ElementRef::model_index, modelProgress's index — is a rank in
session_model_id order, and on web that id was minted at the END of the
sidecar read chain, after three network round trips. So the ranking was
the order the models' reads happened to finish in, not the order the host
added them. With ~40 similarly-sized models over HTTP, adjacent models
swapped and a click reported its neighbour's file; the host page then
asked for a GUID the file does not contain.
Mint the id at the top of loadSidecarMetadataWeb instead, which runs
synchronously from load_sidecar_from_source_c and therefore in the order
the host asked for its models. A load that fails partway just abandons
its id, and the ranks compact over the surviving models as before.
Positions are still positions, though: if one model fails to load, every
later index shifts down one and a host mapping index into its own list
silently drifts again. So also carry the source id — the handle the host
minted itself when it registered the file — through ElementRef into the
pick payload and getObjects rows, and document it as the way to attribute
an object to a file. ModelGpuData::web_source_id defaults to -1 now, since
0 is a real source id and cannot double as "none".
The test server grows a ?delay=<ms> knob so a test can force the losing
interleaving: georef-a is added first and served slowly, and its objects
must still come back as model 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop viewport draws an RGB triad in the bottom-left corner and a
second one at the orbit target while navigating; the web build drew
neither. Both lived in the Qt-coupled OverlayRenderer, which only
ViewportWindow drives — the web host no-ops the overlay hooks — so the
wasm build had no path to them at all.
Lift them into AxisIndicatorRenderer, a Qt-free renderer in
IfcViewerCore, and drive it from ViewportCore::render for desktop and
web alike. Same move SectionGizmoRenderer already made; the drawing code
is unchanged apart from swapping qDegreesToRadians for CameraMath's kPiF.
Pivot visibility moves to the core with it: it was a QTimer on
ViewportWindow, so the afterglow couldn't follow the gizmo across. It is
now a Stopwatch deadline next to the drawing, with render() requesting
frames until an armed afterglow expires. Hosts keep the same three
triggers (on for orbit/pan drags, off on release, 600 ms on wheel).
The web demo shell's log overlay sat exactly on top of the corner gizmo,
so it shifts right of the 110 px box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.ifcview has carried the model's CoordinateOperation since v11 and the
streaming reader has always parsed it, but applyCachedModel ignored it. The
matrix only ever reached the scene because BonsaiViewer pushes it after every
load via setModelCoordinateOperation. Nothing does that on web, so every model
rendered in its local coordinates and two federated models with differing map
conversions came out misaligned.
Seed the matrix and the unit scales from the sidecar, and recompose the model
afterwards. Seeding alone is not enough: the instance transforms in a sidecar
are baked with identity federation matrices, and applyCachedModel uploads them
as-is. The recompose also fixes a second case that had nothing to do with
georeferencing — a model loaded while a federated false origin was already in
force kept its unshifted transforms.
ModelGpuData gains the unit scales because composeModelTransformation needs
them to lift a transform's anchor point into metres, and on a sidecar-only load
there is no IFC to read them back from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The value types and compose helpers in Federation.h were already Qt-free —
Eigen and std::string — but sat in the Qt half of the viewer, so the web build
could not reach them. Split them into FederationMath and add it to
IfcViewerCore, which the Emscripten build links.
What stays behind is what genuinely needs the dependencies: computeModelGeoref
reads an ifcopenshell::file, and the Federation class is a QObject that
persists .ifcfed. Federation.h includes the new header, so no caller changes.
FederationMath needs convert() to resolve a federation unit name to metres and
x_axis_to_angle_deg() to read grid north off a coordinate operation, hence the
helpers_math dependency added in the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Queued chunk loads waited for the next render frame to start, so streaming
advanced at frame cadence rather than as fast as the in-flight cap allowed.
driveStreamingLoads now queues whatever it could not start and every load
completion drains that queue, decoupling fetching from the render loop.
pumpWebChunkLoads is deliberately defined outside the __EMSCRIPTEN__ block
that holds the rest of the byte-range streaming code: driveStreamingLoads
calls it unconditionally and ViewportCore.h declares it unconditionally, so
desktop needs a definition to link against. The body guards itself instead
and compiles to a no-op off the web, where loads are not asynchronous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Return independent geometry copies with unique ownership, preserve parent lifetimes, and teach the Python wrapper to own derived results. Keep serializer inputs non-owning and replace Collada's deferred object with copied triangulation elements.\n\nGenerated with the assistance of an AI coding tool.
Rename header-scope aliases, enums, and helper types while retaining descriptive names where dropping the suffix would create a collision.
Generated with the assistance of an AI coding tool.
Apply the rename manifest, normalize serializer filenames to the classes they define, and update includes and CMake source lists.
Generated with the assistance of an AI coding tool.
Background colour gains a meaningful alpha. Below 1 the viewport composites
over whatever the host has stacked behind it rather than painting a colour
of its own; at 0 it clears to nothing, so the model draws over another 3D
view, a map, or ordinary page content. There is no depth interaction -- the
layer behind is strictly behind and cannot occlude the model.
Honouring the alpha needs a premultiplied surface. configureSurface asks for
one only when the platform advertises it, the same way it picks a present
mode, and falls back to Auto otherwise -- this machine's Vulkan surface
offers opaque compositing only, so hardcoding premultiplied would have
requested a mode it does not support. The clear follows that choice: scaled
by alpha when premultiplied, held at 1 when not, since scaling would
otherwise just darken the colour for an alpha nobody reads.
ViewportWindow::setBackgroundColor now forwards to core rather than writing
through a reference proxy, dropping one more member from the friend
declaration it is meant to shrink. The screenshot QImage is tagged
premultiplied to match what the main pass leaves in the buffer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The renderer tints the selection blue, which says nothing about an object
that is already blue. Add a halo drawn just outside the selected objects
instead: a fixed colour against the background, so it reads whatever the
element is painted.
The mask pass reuses the main shader module and pipeline layout and shares
the main depth buffer read-only, so the halo follows the selection as
visible -- an occluded object contributes nothing. A separable dilation
widens the mask into inner and outer rings, composited after the edge pass
so the edge multiply does not darken it.
On by default; IfcViewer.setSelectionOutline(false) gets the tint alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Box select resolved hits by reading the depth-tested object_id MRT, so only
the front-most surface in each pixel could ever come back. In x-ray that is
wrong twice over: you can see the geometry behind, and you still cannot
select it.
Add a second box-pick path used only while x-ray is active. It runs the same
vs_pick geometry through fs_boxpick with depth compare Always, no depth write
and no colour targets, scissored to the marquee — so nothing culls a fragment
behind another and the pass's only output is an atomicOr of one bit per
object into a hit bitmask. Reading that back gives every object with geometry
inside the box, occluded or not.
The bitmask rides alongside sel_flags at group(0) binding 2, allocated and
bound by ensureSelectionFlagsBuffer so the two can never disagree about how
many object ids exist. The layout entry is FRAGMENT-visible only: WebGPU
forbids a read_write storage buffer in the vertex stage, and every pipeline
shares this layout. Back-face culling is off for the pass — a box landing
inside a closed solid would otherwise see none of its faces and miss it.
Outside x-ray the depth-tested read stands, so a plain marquee still takes
only what is visible. A failure to build the pipeline falls back to that path
rather than breaking box select.
Tests cover the three properties worth having: x-ray selects strictly more,
its result is a superset of the plain one (a bare count would wave through a
wrong scissor or an off-by-one in the bit decode), and turning x-ray off
restores front-most-only. They need a model with real self-occlusion, which
sidecar_bake cannot currently produce — it segfaults on any input, including
the pristine sample.ifc — so they skip with an explanation until a fixture is
supplied. See the note at the top of the spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SidecarBuilder::build (the one-shot bake used by the models-panel export
command in bonsaiviewer) never called reorderSidecarByMorton, unlike the
live streaming loader. The chunk table was therefore left empty, so the
exported .ifcview had its geometry laid out non-contiguously and only the
metadata blocks compressed. Reorder before writeSidecar to match the
loader so exported sidecars stream correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
driveStreamingLoads could deadlock: a chunk waiting on asynchronous
pool growth parks in a frame-counted backoff cooldown, but once the
render loop quiesced after the settle burst the frame index froze, so
the cooldown never expired and streaming stalled part-loaded until the
user moved the camera. Keep the loop alive while growth may still land
(growth_pending() || can_grow()), exposed via a new BufferPool accessor.
loadSidecarMetadataWeb put the model in the scene before reading the
element-metadata block header, leaving a window where the locator was
still zero. A getObjects() landing in that window could not distinguish
"locator not read yet" from "sidecar has no element block" and latched
the model as permanently empty. Read the 16-byte header first, then
apply; carry the locator through applyCachedModel so it is set before
any web element-metadata fetch can run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-clicking a model in the models panel now offers "View Selected Model",
which frames the camera on just that model's geometry — View All, scoped to
one model. With several models selected the action reads "View Selected
Models" and frames their union, matching how the panel's existing Move to
Group already treats a multi-selection.
The AABB fold behind viewAll moves into InstanceCompose, which exists so this
kind of logic is unit-testable without a Qt window or a wgpu device (populating
ViewportCore's model map needs a real GPU, so the fold was previously
untestable in place). It splits in two:
- sceneWorldAabb — every VISIBLE model, what viewAll frames.
- modelsWorldAabb — only the named models, hidden or not. A model the caller
named explicitly is framed even if hidden; second-guessing
that is worse than honouring it. Models with no loaded
geometry contribute nothing, and if none of them do the
camera is left alone rather than flying to the origin.
Both are covered by six new cases in test_instance_compose (131 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give host pages a real API over the web viewer, not just "embed it and listen
for picks": read/set the camera, read/set multi-selection, enumerate every
object with its IFC identity, drive per-object visibility, and override
object colours.
The wasm boundary keeps to object_ids (u32 arrays marshalled through the heap,
with an "ask twice" convention on the getters); web/ifcviewer.js layers IFC
GlobalId resolution on top, from the element table getObjects() fetches. Every
id-taking call accepts an objectId, a GlobalId, or an element object.
Colour override needed no new mechanism: color_override_rgba8 was already
plumbed through the sidecar, the instance SSBO, the WGSL shader and the
opaque/transparent cull classifier, but nothing ever wrote a non-zero value
into it. setObjectsColor is the missing writer, which is why an alpha below 255
correctly reclassifies the instance into the transparent pass.
Two bugs surfaced while wiring this up:
- wgpu_initialized_ was only ever set by the Qt desktop host, so on web every
upload guarded on it was a silent no-op — including the pre-existing
recomposeAndUploadModel that federation transforms depend on. The core now
latches it in its own web init.
- The demo pages were copied into the build dir by a POST_BUILD command on the
wasm target, so they only refreshed when the wasm itself relinked; editing a
page left a stale copy that the dev server (and the Playwright suite) kept
serving. Each page now has its own copy rule with a real dependency, and
sample.ifcview is a LINK_DEPENDS so regenerating it forces a relink.
applyCachedModel also now keeps the element metadata it already parses on the
path-based load (it was being dropped), so the embedded sample has GUIDs and
the demo works with no file to pick.
The sample model was three coincident cubes, which made per-object hide and
colour look like no-ops — whatever you hid was still drawn by the box behind
it. make_sample.py regenerates it as a slab, a wall and a beam in distinct
places, so the fixture is reproducible rather than an opaque blob.
Demoed by web/scripting.html (linked from the index; viewer is on
window.viewer) and covered by tests/scripting.spec.mjs — 6 cases against a real
GPU, asserting visibility and colour at the pixels, not just at the API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createWgpuSurface() calls wgpu_macos_attach_metal_layer() in the Q_OS_MAC
branch at the top of the file, but the only #include of MetalSurface_mac.h
sat ~450 lines below the call site, so macOS builds failed with 'use of
undeclared identifier'. The header self-guards on __APPLE__, so move the
include up into the early platform block next to <Windows.h>; the lone
call site is the sole consumer, so the late include was dead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a fresh .ifc streams geometry to the GPU, then bakes the .ifcview
cache. That bake — reorder + per-chunk zstd (level 19) — ran synchronously
in SceneLoader::onStreamerFinished, which is a QueuedConnection slot on the
main thread, so it froze the UI right as the progress bar hit 100% (≈15s of
zstd for a 130 MB-geometry model).
- Move the compress + writeSidecar onto a background thread. The geometry is
already resident and the sidecar is only a cache for the next open, so the
viewport is interactive the instant streaming finishes; the write is joined
before the next write and in the destructor.
- Parallelise the per-chunk zstd across hardware_concurrency threads (compress
all chunks, then write serially to keep contiguous offsets) so the
background write also finishes quickly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Backface Culling" checkbox persisted a value and reflected it, but
nothing consumed AppSettings::backfaceCulling — the opaque pipeline
hardcoded cullMode = Back, so toggling had no effect.
Build a second opaque pipeline (cullMode None) alongside the culled one and
pick between them per-frame from a backface_culling_ flag; setBackfaceCulling
flips the flag and requests a redraw (no rebuild). ViewportWindow forwards
it, and MainWindow applies the persisted value at startup and re-applies on
change — same wiring as the nav preset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the web viewer so the wasm is a reusable module and add a
second example that drives it from ordinary page DOM.
Build:
- Emit IfcViewerWeb.js (a `createIfcViewer` factory, MODULARIZE) + .wasm
instead of a single baked page (dropped --shell-file); copy the static
example pages next to it at build time.
- Unbreak the web build: CameraMath.h / ViewportCore.cpp used
boost::math::constants::pi just for pi, pulling all of boost/math into a
header shared with the Emscripten build (no Boost in its sysroot). Replace
with a constexpr kPiF — identical value, no dependency, desktop unaffected.
JS integration (web/ifcviewer.js):
- A small helper wraps the factory: boots the viewer on a canvas, runs the
RAF loop from onRuntimeInitialized (NOT a post-await .then, which stalls
Dawn-web's device callback and leaves the device half-initialised), and
exposes addFile/addUrl, clearScene, model list/progress, and onSelect(...).
- ViewportCore/main_web emit each pick to JS via Module.__ifcvOnSelect
(object id + IFC GlobalId + model index; empty on deselect); onSelect also
dispatches an 'ifcviewer:select' DOM event.
- Fix input coords for a non-fullscreen canvas: mousemove/mouseup are
window-targeted, so convert their coords to canvas-relative via the canvas
client-rect origin (marquee + box-pick were offset when embedded).
Examples:
- IfcViewerWeb.html: the fullscreen viewer (same DOM/behaviour as before,
now loading the module) — the Playwright smoke suite still targets it.
- embedded.html: a sized viewer with DOM outside it to add models (file or
URL), list loaded models with streaming progress, and show the model +
GlobalId of the clicked object. Starts empty (drops the wasm's embedded
sample, which the fullscreen page/tests still use).
- index.html links both.
Federation note: the web viewer already streams multiple models into one
scene (a byte-source per file/URL); it doesn't need the desktop Federation
document for this. Verified: 11/11 web smoke tests pass; embedded example
loads models, reports the picked model + GUID, and the marquee aligns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
initWgpu() runs on the first exposeEvent, after MainWindow has already
applied the nav preset saved in Settings. It then unconditionally
re-applied "blender" whenever WGPU_NAV_PRESET was unset, silently
overriding the user's saved choice — so the applied navigation didn't
match what Settings showed.
Only apply the preset from WGPU_NAV_PRESET when that env override is
actually set; otherwise leave the current preset (MainWindow's persisted
choice, or the blender default). The startup log now reports the effective
orbit/pan bindings rather than a hardcoded name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously Del always removed the most recently added section plane. Now a
plane can be picked and deleted individually:
- ViewportCore tracks a selected plane index, kept valid as planes are
added (the new one becomes selected), removed, or cleared.
- Clicking a gizmo with the section tool active selects that plane.
- The section gizmo geometry is baked white and coloured via its per-plane
tint, so the selected plane draws in a bright amber highlight while the
rest stay red (unchanged look).
- Del/Backspace removes the selected plane, falling back to the most recent
one when nothing is selected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The streaming settle burst re-armed the render loop whenever a
non-resident chunk was frustum-visible, but the enqueue only fetches
chunks that are contribution-visible (big enough on screen) and not in a
blocked cooldown. A chunk that is in the frustum but sub-pixel is never
loaded, so visible_pending stayed true forever and the loop spun at full
frame rate with no input.
Match visible_pending to the enqueue's eligibility test: a non-resident
chunk keeps the loop alive only if it's actively loading, or is
contribution-visible and past its cooldown. Sub-pixel / cooldown-blocked
chunks no longer prevent idle; they still stream in when a camera move or
eviction requests a frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the two overloaded model identifiers and make object_id
assignment single-authority, fixing a pick -> properties mismatch.
Identifiers:
- Per-model UUID fed_id -> model_id; the uint32 runtime handle
model_id -> session_model_id (SessionState accessors + mirror hashes
renamed to match). "fed_id" was a misnomer -- the federation is the
whole collection, not one model.
object_id assignment (fixes wrong class on click):
- Producers (GeometryStreamer, .ifcview sidecar) now stamp model-LOCAL
object_ids; ViewportCore::applyCachedModel is the sole authority that
assigns the session-global id (base + local). Removed
SceneLoader::next_object_id_, GeometryStreamer::lastObjectId(), and the
streamer's start_object_id parameter.
- The element table is stamped by the same base on both load paths
(applySidecarData and onStreamerFinished), so registry ids match the
ids pick returns. Previously the sidecar path double-rebased instances
vs the registry (click IfcSite -> showed IfcDoor); the live-stream path
had the same latent mismatch. Both closed.
Naming / cleanup:
- SceneLoader::addFiles -> queueModels; startStreamLoadFor ->
loadFromGeometryStreamer; readSidecarMetadataOnly -> readSidecarMetadata.
- Federation::addModel takes an explicit display_name (no QFileInfo
fallback); callers pass QFileInfo(path).fileName().
- Disambiguate cryptic short locals (d->sidecar, m->model, c->chunk, ...)
in SceneLoader, Federation, ViewportWindow, AreaMeasurement,
SectionGizmoRenderer, and the SidecarData/SidecarReadPlan spots in
ViewportCore.
Tests: 125/125 pass.
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>
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>
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>