Just to use consistent patches between the builds. It was previously guarded by `WASM`, but it was a dead code - `OpenCOLLADA` is skipped on wasm, so it was never exercised.
Regarding the "specializing std::hash outside of the std:: namespace" issue on gcc - it was caused by patch missing fixes for `COLLADABU_HASH_NAMESPACE_OPEN` and `COLLADABU_HASH_NAMESPACE_CLOSE`. So in theory it should have also result in an error in clang or in an invalid code/ub. Either way, now it's fixed.
See logs below for example issue I've met locally when I had just `RocksDBTargets-relwithdebinfo.cmake`.
Providing a list of configs makes it try to use matching config first and only then try `Release` as a fallback, otherwise it was now requiring `Release` builds.
```
CMake Error in CMakeLists.txt:
IMPORTED_LOCATION not set for imported target "RocksDB::rocksdb"
configuration "RelWithDebInfo".
```
* Modifications to build C++ with Visual Studio 2026 and the v145 toolset.
* Fixes linker settings for rocksdb for Debug and Release builds
* module is a C++ 20 keyword. Explicitly stating namespace allows cpp20 projects to build against the library
* Fixes crash when initializing an object with the initialize function when some of the attributes are empty, {}, or omitted, std::nullopt
* cleanup for vs2026 v145 toolset per @aothms review
* Fixes bug, IfcCurveSegment.setStartLength was setSegmentLength in alignment_helper.cpp
* Bumps boost to 1.92
Noticed after 5b00c8b45 - build started to break. `--py313` is not needed here, since wasm build doesn't really depend on the provided Python versions and just builds the version it picks up from `pyodide`.
The imperial list ran the architectural scales from 1'=1'-0" down to
1/128"=1'-0", then restarted at 1"=10' for the engineering scales. Merge
both groups into a single sequence ordered by ratio, largest scale first.
The metric list was already ordered by ratio and is unchanged.
Also fix the enum cache invalidation, which compared the cached list's
length against hardcoded 13/31 while the imperial list has 32 entries, so
switching a scene from imperial back to metric kept showing imperial
scales. Track the unit system the cache was built for instead.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The bare clone fetched the default branch, so a v0.9.0 daily would be
smoke-tested and pytested against v0.8.0 scripts and tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First v0.9.0alpha0 binary set, so the version prefix moves with it.
The bump trackers had drifted (bonsai's OLD pointed at 3e7b739 while
ifcopenshell-python pinned e333c1c), so this was done by hand;
'make bump' works again from here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream binary builds no longer produce macos64 zips (build_osx builds
arm64 only since wgpu Qt), and Blender dropped Intel Mac support in 5.0,
so there is nothing left to package for that platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Filling only from the viewer's reads meant the cache converged on the
bytes the camera had needed — a user had to orbit every model into view
(unloading others to get there) before an entry could finish. Now a
filling entry fetches its uncovered spans in order, 8 MB at a time,
whenever the viewer has been quiet for 1.5 s, yielding the moment real
reads resume so interactive streaming always wins. A 42 MB model that
levelled off at 85% viewed now completes seconds after load with no
interaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Streaming a federation over the network re-downloads everything on every
visit: browsers do not populate their HTTP cache from ranged fetches
(measured at 0 of 78 range requests served from cache even with a strong
ETag). Host pages have started hand-rolling OPFS caches against the
library's own source seam — this is the second app to port the same ~350
lines — so the capability moves into the library.
The design keeps what those pages got right: the cache fills FROM THE
VIEWER'S OWN RANGED READS (no second download, and only bytes the camera
actually needed), entries are keyed by a hash of the URL and validated
by ETag (falling back to Last-Modified + size), and a byte-span ledger
guarantees a partial copy is never mistaken for a whole one. What it
fixes: writes go through a FileSystemSyncAccessHandle in an inline
worker — positional writes with no copy-on-open, where the pages'
createWritable({keepExistingData}) paid a whole-file copy per flush
(quadratic as the cache fills) and buffered up to 48 MB per model in JS
to compensate — the handle's exclusive lock makes a second tab fall back
to plain network instead of corrupting the entry; a complete copy now
opens when the server is unreachable (offline was dead before despite
the bytes being local); and entry names are hashes, where prefix-matched
sanitised names could delete a sibling model's cache.
viewer.cacheInfo() reports entries and the storage estimate;
viewer.clearCache(url?) drops one or all. Browsers without OPFS or sync
handles, servers without validators, and second tabs all degrade to
exactly today's network streaming.
Verified: the sample round-trips to zero range requests on reload, and a
42 MB model goes from 108 range requests to 5 on the second visit — the
85% the camera had viewed comes off disk, coverage honestly reports
incomplete for the bytes streaming never needed. The test server now
sends a content-hash ETag so the specs exercise real validation.
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>
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>