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>
The memory work (cache budget, pressure handling, unloadModel) lives in
ViewportCore and so already ran in the wasm, but the page could not see
or use any of it: the web host had no onFrameStats, and there were no
bindings for residency.
- WebViewportHost latches the last FrameStats; ifcv_get_frame_stats_c
hands them to JS as doubles, and viewer.stats() returns {fps,
frameTimeMs, objects, triangles, drawCalls, vram{used, capacity,
budget}, workingSet{chunks, chunksMissing, missingBytes}} — the same
figures BonsaiViewer's status bar shows. Device-wide VRAM is omitted:
there is no query for it on web.
- viewer.unloadModel / loadModel / modelUnloaded / modelVramBytes, keyed
by source id like the other per-model calls.
- The demo page shows a GPU memory line that turns into a "full: N of M
visible chunks not loaded" notice once a shortfall persists for 3 s,
and each model's MB with an Unload/Load button.
- memory.spec.mjs covers stats() and the unload/load round trip.
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>
The readout showed used over budget, which reads as impossible once the
pool legitimately sits a sub-buffer above a lowered budget (releasing
it would undershoot). Show used/capacity, and the budget only when it
differs from capacity.
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>
- assert isinstance(res, list) was wrong because duplicate_task returns a tuple not a list
- removed overkill assertion anyway as the usecase is already typed.
- setting optional name or reuse planned schedule name
- usecase now returns created baseline work schedule
E.g. IfcAdvancedHouse:
```
/usr/bin/x86_64-linux-gnu-ld.bfd: CMakeFiles/IfcAdvancedHouse.dir/IfcAdvancedHouse.cpp.o: in function `main':
IfcAdvancedHouse.cpp:(.text.startup.main+0x137): undefined reference to `hierarchy_helper<Ifc4x3_add2>::addBuilding(Ifc4x3_add2::IfcSite, Ifc4x3_add2::IfcOwnerHistory)'
/usr/bin/x86_64-linux-gnu-ld.bfd: IfcAdvancedHouse.cpp:(.text.startup.main+0x7c7): undefined reference to `hierarchy_helper<Ifc4x3_add2>::getRepresentationContext(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)'
/usr/bin/x86_64-linux-gnu-ld.bfd: IfcAdvancedHouse.cpp:(.text.startup.main+0x931): undefined reference to `hierarchy_helper<Ifc4x3_add2>::getRepresentationContext(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)'
```
Noticed by addressing gcc warning gcc warning that attribute order is incorrect:
```
//src/ifcparse/hierarchy_helper.i:721:31: warning: attribute ignored in explicit instantiation ‘class hierarchy_helper<Ifc2x3>’ [-Wattributes]
721 | template IFC_SCHEMA_API class hierarchy_helper<IfcSchema>;
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~
//src/ifcparse/hierarchy_helper.i:721:31: note: no attribute can be applied to an explicit instantiation
```
Resolves the error below. This option was removed in clang 18.
```
.clang-tidy:4:1: error: unknown key 'AnalyzeTemporaryDtors'
AnalyzeTemporaryDtors: false
```