Compare commits

...

34 Commits

Author SHA1 Message Date
dependabot[bot] 17a0aaccfa build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 00:48:46 +00:00
Dion Moult d808104725 ci: test the daily bonsai build against the branch that built it
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>
2026-08-25 10:25:42 +10:00
Dion Moult b71b217814 ci: trigger bonsai and ifcsverchok dailies from v0.9.0
Daily builds for user testing now come from the v0.9.0 branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:25:10 +10:00
Dion Moult d9f218eacf Bump build e333c1c > ad113e1
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>
2026-08-25 10:25:04 +10:00
Dion Moult 0faba0fdd8 bonsai: drop Intel macOS builds
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>
2026-08-25 10:24:53 +10:00
Dion Moult ad113e1283 ifcviewer: latch the motion contribution cull instead of flip-flopping
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>
2026-08-25 07:42:13 +10:00
Dion Moult 4a761b51f5 ifcviewer: pack the cull-hot instance fields and skip unchanged culls
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>
2026-08-25 07:42:13 +10:00
Dion Moult 73f8e6aea7 ifcviewer-web: complete the OPFS cache in the background
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>
2026-08-25 07:42:13 +10:00
Dion Moult e5072460cd ifcviewer-web: OPFS model cache behind addUrl(url, {cache: true})
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>
2026-08-25 07:42:13 +10:00
Dion Moult 311b75a955 ifcviewer: carve the margin out of the cache on the first driver growth refusal
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>
2026-08-25 07:42:13 +10:00
Dion Moult 6f24133d35 ifcviewer-web: stream getObjects() per model instead of one scene-sized JSON
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>
2026-08-25 07:42:13 +10:00
Dion Moult 091b4d4113 ifcviewer: bound the CPU triangle shadow and the cull scratch to residency
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>
2026-08-25 07:42:13 +10:00
Dion Moult 06d87e21a7 ifcviewer-web: never hand setBindGroup the whole wasm heap as dynamic offsets
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>
2026-08-25 07:42:13 +10:00
Dion Moult b7f1f1728f ifcviewer-web: expose frame stats and per-model unload/load to the page
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>
2026-08-25 07:42:13 +10:00
Dion Moult 8074541057 Surface VRAM shortfall to the user and let them unload models
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>
2026-08-25 07:42:13 +10:00
Dion Moult d755ca3a59 bonsaiviewer: show the pool's capacity in the VRAM readout, budget alongside
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>
2026-08-25 07:42:13 +10:00
Dion Moult 777b728205 ifcviewer: move the live budget only on sustained device readings
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>
2026-08-25 07:42:13 +10:00
Dion Moult 24616ed655 ifcviewer: stop the live budget from over-shrinking and from going stale
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>
2026-08-25 07:42:13 +10:00
Dion Moult 6201c4052b ifcviewer: make the geometry cache budget live, not fixed at startup
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>
2026-08-25 07:42:13 +10:00
Dion Moult ab99024307 ifcviewer: budget the geometry cache and make required allocations fallible
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>
2026-08-25 07:42:12 +10:00
Dion Moult b7d2b2fa3a bonsaiviewer: show pool and device VRAM in the performance stats
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>
2026-08-25 07:42:12 +10:00
Andrej730 5fa14c3dea IfcAlignment examples: fix Wunknown-pragmas
gcc was complaining, because it couldn't recognize msvc pragmas
2026-08-24 19:18:00 +05:00
Andrej730 7fa8506fac build-all: test examples if they were built 2026-08-24 19:11:10 +05:00
Andrej730 9d68e7b9ca build-all: support --occt-shared 2026-08-24 19:11:10 +05:00
Andrej730 9ffbfe0dbb build-all: fix missing is_on_off
Update build-all.py
2026-08-24 19:10:28 +05:00
Andrej730 5b00c8b451 build-all: add --help and verify args 2026-08-24 18:50:23 +05:00
Andrej730 a5b6f83a3d Normalize whitespaces in cmake files 2026-08-24 18:50:23 +05:00
Andrej730 908d85a51a IfcAdvancedHouse: report an error in case serialization fails
Instead of a crash
2026-08-24 18:50:23 +05:00
Thomas Krijnen 9089a20ce3 Add branch filter for v0.9.0 in CI workflow #9336 2026-08-22 13:21:43 +02:00
Thomas Krijnen d4a5420851 Rename job to publish_ifctester_org 2026-08-22 13:18:20 +02:00
Thomas Krijnen 87bc6bfbab Add string decode/encode api 2026-08-22 13:12:36 +02:00
Petru Conduraru a5e94cf0d8 Version and SOVERSION for geometry_serializer 2026-08-22 13:04:57 +02:00
Dion Moult 2c1d445d5b ifcviewer-web: mint session model ids when a load is requested
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>
2026-08-21 11:30:56 +10:00
Dion Moult d86f89090b ifcviewer-web: draw the axis indicator (corner gizmo + orbit pivot)
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>
2026-08-21 11:16:41 +10:00
72 changed files with 4540 additions and 873 deletions
+6 -1
View File
@@ -284,12 +284,17 @@ PATTERNS = (
"*.cpp",
"*.h",
"*.i",
"*.cmake",
"*/CMakeLists.txt",
)
REPO_ROOT = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip())
# Generated files; formatted by the express codegen, not by this script.
IGNORED_DIRS = (REPO_ROOT / "src/ifcparse/schemas",)
IGNORED_DIRS = (
REPO_ROOT / "src/ifcparse/schemas",
REPO_ROOT / "win/patches",
)
def get_tracked_files(root: Path | None = None) -> list[Path]:
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
max-size: 5000MB
- name: Set up Python for connector build
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.12'
+2 -11
View File
@@ -16,7 +16,7 @@ on:
- 'src/ifc5d/ifc5d/**'
- 'src/ifccityjson/**'
branches:
- v0.8.0
- v0.9.0
workflow_dispatch:
jobs:
@@ -51,19 +51,10 @@ jobs:
name: "Linux Build",
short_name: linux,
}
- {
name: "MacOS Build",
short_name: macos,
}
- {
name: "MacOS ARM Build",
short_name: macosm1,
}
exclude:
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
- pyver: py313
config:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
@@ -128,7 +119,7 @@ jobs:
blender --command extension install-file -r user_default -e $bonsai_zip
blender --command extension list
git clone https://github.com/IfcOpenShell/IfcOpenShell.git IfcOpenShell
git clone --branch ${{ github.ref_name }} --single-branch https://github.com/IfcOpenShell/IfcOpenShell.git IfcOpenShell
# Reregister Bonsai.
# Note that running it in background might miss some errors
-9
View File
@@ -34,19 +34,10 @@ jobs:
name: "Linux Build",
short_name: linux,
}
- {
name: "MacOS Build",
short_name: macos,
}
- {
name: "MacOS ARM Build",
short_name: macosm1,
}
exclude:
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
- pyver: py313
config:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
+1 -1
View File
@@ -7,7 +7,7 @@ on:
- '.github/workflows/ci-ifcsverchok-build.yml'
- 'src/ifcsverchok/*'
branches:
- v0.8.0
- v0.9.0
jobs:
activate:
+3 -1
View File
@@ -3,11 +3,13 @@ name: ci-ifctester-org
on:
workflow_dispatch:
push:
branches:
- v0.9.0
paths:
- src/ifctester/**
jobs:
publish_website:
publish_ifctester_org:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
+227 -54
View File
@@ -32,20 +32,7 @@ Example usage:
python build-all.py IfcParse IfcOpenShell-Python
Available arguments:
``-py-313`` - build for specific Python version
(building for all supported Python version by default).
``-occt-xxx`` - use a specific OCCT version (e.g. ``-occt-7.8.1``) instead of the default
``-wasm`` - compile for wasm
``-without-xxx`` - do not build dependency ``xxx`` (e.g. ``--without-swig``)
``-mac-cross-compile-intel`` - cross compile for Intel Mac on Apple Silicon host
``-shared`` - build shared libraries. By default will build static.
``-ifcopenshell-shared`` - build only IfcOpenShell's own libraries as shared
(dependencies stay static). Redundant if ``-shared`` is also passed.
``-diskcleanup`` - clean up build directories after finishing building dependencies
``-build-examples`` - build IfcOpenShell examples
``-lto`` - enable link-time optimization (adds ``-flto`` to compiler flags)
``-v`` - enable verbose logs
Run with --help to see available arguments.
Used environment variables:
@@ -74,6 +61,8 @@ Used environment variables:
`ADD_COMMIT_SHA` and `VERSION_OVERRIDE` will be set to `ON` while configuring IfcOpenShell
- ``BUILD_BONSAIVIEWER`` - enable building BonsaiViewer, `off` by default.
- ``IFCOS_BUILD_PYTHON_WRAPPER`` - enable building the Python wrapper, `on` by default.
- ``PYTHON_USER_SITE`` - install the Python wrapper into the user's site-packages directory
instead of the interpreter's prefix, `off` by default.
# This script builds IfcOpenShell and its dependencies #
# #
@@ -118,6 +107,9 @@ Used environment variables:
"""
from __future__ import annotations
import argparse
import glob
import logging
import multiprocessing
@@ -129,12 +121,13 @@ import subprocess as sp
import sys
import sysconfig
import tarfile
import textwrap
import threading
import time
from collections.abc import Generator, Sequence
from datetime import datetime
from pathlib import Path
from typing import Literal
from typing import Literal, NamedTuple
from urllib.request import urlretrieve
from typing_extensions import assert_never
@@ -162,6 +155,7 @@ ADD_COMMIT_SHA = is_on_off(os.getenv("ADD_COMMIT_SHA"), default=False)
IFCOS_BUILD_PYTHON_WRAPPER = is_on_off(os.getenv("IFCOS_BUILD_PYTHON_WRAPPER"), default=True)
BUILD_BONSAIVIEWER = is_on_off(os.getenv("BUILD_BONSAIVIEWER"), default=False)
USE_OCCT = is_on_off(os.getenv("USE_OCCT"), default=True)
PYTHON_USER_SITE = is_on_off(os.getenv("PYTHON_USER_SITE"), default=False)
PYTHON_VERSIONS = ["3.10.3", "3.11.8", "3.12.1", "3.13.6", "3.14.0"]
JSON_VERSION = "3.11.3"
@@ -202,10 +196,142 @@ strip = "strip"
xz = "xz" # Used implicitly for `tar -xf *.tar.xz`.
brew = "brew"
explicit_targets = [s for s in sys.argv[1:] if not s.startswith("-")]
class Args(NamedTuple):
explicit_targets: list[str]
build_examples: bool
diskcleanup: bool
lto: bool
verbose: bool
shared: bool
ifcopenshell_shared: bool
occt_shared: bool
mac_cross_compile_intel: bool
wasm: bool
class DynamicArgs(NamedTuple):
without: set[str]
py_versions: set[str]
occt_version: str | None
@classmethod
def from_unknown_flags(cls, unknown_flags: list[str], arg_parser: argparse.ArgumentParser) -> DynamicArgs:
flags = set(s.lstrip("-") for s in unknown_flags if s.startswith("-"))
without: set[str] = set()
py_versions: set[str] = set()
occt_versions: set[str] = set()
leftover: set[str] = set()
for f in flags:
if f.startswith("without-"):
without.add(f.removeprefix("without-").lower())
elif f.startswith("py-"):
py_versions.add(f.removeprefix("py-"))
elif f.startswith("occt-"):
occt_versions.add(f.removeprefix("occt-"))
else:
leftover.add(f)
if leftover:
arg_parser.error(f"unrecognized arguments: {', '.join('-' + f for f in sorted(leftover))}")
if len(occt_versions) > 1:
arg_parser.error(f"more than one OCCT version provided: {', '.join(sorted(occt_versions))}")
occt_version = next(iter(occt_versions), None)
return cls(without=without, py_versions=py_versions, occt_version=occt_version)
def parse_args() -> tuple[Args, DynamicArgs]:
arg_parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Additional dynamic -flags (not declared above):
-py-313 build for specific Python version
(building for all supported Python versions by default)
-occt-xxx use a specific OCCT version (e.g. -occt-7.8.1) instead of the default
-without-xxx do not build dependency `xxx` (e.g. --without-swig)"""),
)
arg_parser.add_argument("explicit_targets", nargs="*", help="Targets provided by CLI.")
arg_parser.add_argument(
"--build-examples",
action="store_true",
default=False,
help="Build IfcOpenShell examples.",
)
arg_parser.add_argument(
"-diskcleanup",
"--diskcleanup",
action="store_true",
default=False,
help="Clean up build directories after finishing building dependencies.",
)
arg_parser.add_argument(
"-lto",
"--lto",
action="store_true",
default=False,
help="Enable link-time optimization (adds -flto to compiler flags).",
)
arg_parser.add_argument(
"-v",
"--verbose",
action="store_true",
default=False,
help="Enable verbose logs.",
)
arg_parser.add_argument(
"-shared",
"--shared",
action="store_true",
default=False,
help="Build shared libraries. By default will build static.",
)
arg_parser.add_argument(
"-ifcopenshell-shared",
"--ifcopenshell-shared",
action="store_true",
default=False,
help="Build only IfcOpenShell's own libraries as shared (dependencies stay static). "
"Redundant if -shared is also passed.",
)
arg_parser.add_argument(
"--occt-shared",
action="store_true",
default=False,
help="Build OCCT as shared. Redundant if -shared is also passed.",
)
arg_parser.add_argument(
"-mac-cross-compile-intel",
"--mac-cross-compile-intel",
action="store_true",
default=False,
help="Cross compile for Intel Mac on Apple Silicon host.",
)
arg_parser.add_argument("-wasm", "--wasm", action="store_true", default=False, help="Compile for wasm.")
namespace, unknown_flags = arg_parser.parse_known_args()
args = Args(
explicit_targets=namespace.explicit_targets,
build_examples=namespace.build_examples,
diskcleanup=namespace.diskcleanup,
lto=namespace.lto,
verbose=namespace.verbose,
shared=namespace.shared,
ifcopenshell_shared=namespace.ifcopenshell_shared or namespace.shared,
occt_shared=namespace.occt_shared or namespace.shared,
mac_cross_compile_intel=namespace.mac_cross_compile_intel,
wasm=namespace.wasm,
)
dynamic_args = DynamicArgs.from_unknown_flags(unknown_flags, arg_parser)
return args, dynamic_args
ARGS, DYNAMIC_ARGS = parse_args()
explicit_targets: set[str] = set(ARGS.explicit_targets)
"""Targets provided by CLI."""
flags = set(s.lstrip("-") for s in sys.argv[1:] if s.startswith("-"))
"""CLI flags."""
# Helper function for coloured printing
@@ -224,17 +350,11 @@ def cecho(message, color=NO_COLOR):
logger.info(f"{color}{message}\033[0m")
# Flags.
BUILD_EXAMPLES = "build-examples" in flags
DISK_CLEANUP = "diskcleanup" in flags
LTO = "lto" in flags
VERBOSE = "v" in flags
APPLE = platform.system() == "Darwin"
MAC_CROSS_COMPILE_INTEL = "mac-cross-compile-intel" in flags
MAC_CROSS_COMPILE_INTEL = ARGS.mac_cross_compile_intel
assert platform.system() == "Darwin" or not MAC_CROSS_COMPILE_INTEL
WASM = "wasm" in flags
WASM = ARGS.wasm
"""Build WASM outside pyodide build environment."""
WASM_CMAKE_IS_USING_INIT_VARS = False
if WASM:
@@ -380,12 +500,12 @@ dependency_tree: dict[str, tuple[str, ...]] = {
def gather_dependencies(dep: str) -> Generator[str]:
yield dep
for d in dependency_tree[dep]:
if f"without-{d.lower()}" not in flags:
if d.lower() not in DYNAMIC_ARGS.without:
for x in gather_dependencies(d):
yield x
if VERBOSE:
if ARGS.verbose:
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
ch.setFormatter(formatter)
@@ -406,29 +526,26 @@ else:
MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS = []
OFF_ON = ["OFF", "ON"]
BUILD_STATIC = "shared" not in flags
BUILD_STATIC = not ARGS.shared
"""Whether dependencies are built static."""
IFCOPENSHELL_STATIC = BUILD_STATIC and "ifcopenshell-shared" not in flags
"""Whether IfcOpenShell's own libraries are built static."""
ENABLE_FLAG = "--enable-static" if BUILD_STATIC else "--enable-shared"
DISABLE_FLAG = "--disable-shared" if BUILD_STATIC else "--disable-static"
LINK_TYPE = "static" if BUILD_STATIC else "shared"
LINK_TYPE_UCFIRST = LINK_TYPE.capitalize()
LIBRARY_EXT = "a" if BUILD_STATIC else "so"
PIC = "-fPIC" if BUILD_STATIC else ""
if any(f.startswith("py-") for f in flags):
PYTHON_VERSIONS = [pyv for pyv in PYTHON_VERSIONS if f"py-{''.join(pyv.split('.')[:2])}" in flags]
if DYNAMIC_ARGS.py_versions:
PYTHON_VERSIONS = [pyv for pyv in PYTHON_VERSIONS if "".join(pyv.split(".")[:2]) in DYNAMIC_ARGS.py_versions]
if any(f.startswith("occt-") for f in flags):
OCCT_VERSION = next(f.split("-", 1)[1] for f in flags if f.startswith("occt-"))
if DYNAMIC_ARGS.occt_version is not None:
OCCT_VERSION = DYNAMIC_ARGS.occt_version
if explicit_targets:
targets = {dep for target in explicit_targets for dep in gather_dependencies(target)}
else:
targets = set(dependency_tree.keys())
targets = set(t for t in targets if "without-%s" % t.lower() not in flags)
targets = set(t for t in targets if t.lower() not in DYNAMIC_ARGS.without)
if not explicit_targets and not BUILD_BONSAIVIEWER:
targets.difference_update({"BonsaiViewer", "qt6"})
if BUILD_BONSAIVIEWER:
@@ -511,7 +628,7 @@ def restore_env(var_name: str, old_value: str | None) -> None:
os.environ[var_name] = old_value
def run(cmds: Sequence[str], cwd: str | None = None, can_fail: bool = False) -> str:
def run(cmds: Sequence[str], cwd: str | None = None, can_fail: bool = False, env: dict[str, str] | None = None) -> str:
"""
Wraps `subprocess.Popen.communicate()` and logs the command being executed,
sets up logging `stderr` to `LOG_FILE` (in append mode) and returns stdout
@@ -535,7 +652,7 @@ def run(cmds: Sequence[str], cwd: str | None = None, can_fail: bool = False) ->
# Ensure both live logs available in the log file
# and the putput.
with open(LOG_FILE, "a", encoding="utf-8") as log_file_handle:
proc = sp.Popen(cmds, cwd=cwd, stdout=sp.PIPE, stderr=sp.PIPE, encoding="utf-8")
proc = sp.Popen(cmds, cwd=cwd, stdout=sp.PIPE, stderr=sp.PIPE, encoding="utf-8", env=env)
assert proc.stdout and proc.stderr
t_out = threading.Thread(target=stream_reader, args=(proc.stdout, stdout, log_file_handle))
@@ -821,7 +938,7 @@ def build_dependency(
)
logger.info(f"\rInstalled {name} \n")
if DISK_CLEANUP:
if ARGS.diskcleanup:
shutil.rmtree(build_dir, ignore_errors=True)
@@ -927,6 +1044,8 @@ ADDITIONAL_ARGS_STR = " ".join(ADDITIONAL_ARGS)
CXXFLAGS_MINIMAL = f"{CXXFLAGS} {PIC} {ADDITIONAL_ARGS_STR}"
CFLAGS_MINIMAL = f"{CFLAGS} {PIC} {ADDITIONAL_ARGS_STR}"
CXXFLAGS_SHARED = CXXFLAGS_MINIMAL
CFLAGS_SHARED = CFLAGS_MINIMAL
if WASM:
# WASM `SIDE_MODULE_` are absorbed by `emcmake` automatically.
CXXFLAGS = CXXFLAGS_MINIMAL
@@ -936,19 +1055,19 @@ elif sp.call([bash, "-c", "ld --gc-sections 2>&1 | grep -- --gc-sections &> /dev
CXXFLAGS = f"{CXXFLAGS} {PIC} -fdata-sections -ffunction-sections -fvisibility=hidden -fvisibility-inlines-hidden {ADDITIONAL_ARGS_STR}"
CFLAGS = f"{CFLAGS} {PIC} -fdata-sections -ffunction-sections -fvisibility=hidden {ADDITIONAL_ARGS_STR}"
else:
CXXFLAGS = CXXFLAGS_MINIMAL
CFLAGS = CFLAGS_MINIMAL
CXXFLAGS = CXXFLAGS_SHARED
CFLAGS = CFLAGS_SHARED
LDFLAGS = f"{LDFLAGS} -Wl,--gc-sections {ADDITIONAL_ARGS_STR}"
else:
if BUILD_STATIC:
CXXFLAGS = f"{CXXFLAGS} {PIC} -fvisibility=hidden -fvisibility-inlines-hidden {ADDITIONAL_ARGS_STR}"
CFLAGS = f"{CFLAGS} {PIC} -fvisibility=hidden -fvisibility-inlines-hidden {ADDITIONAL_ARGS_STR}"
else:
CXXFLAGS = CXXFLAGS_MINIMAL
CFLAGS = CFLAGS_MINIMAL
CXXFLAGS = CXXFLAGS_SHARED
CFLAGS = CFLAGS_SHARED
LDFLAGS = f"{LDFLAGS} {ADDITIONAL_ARGS_STR}"
if LTO:
if ARGS.lto:
for f in compiler_flags:
locals()[f] += f" -flto={IFCOS_NUM_BUILD_PROCS}"
@@ -1031,6 +1150,9 @@ if "swig" in targets:
if USE_OCCT and "occ" in targets:
occt_args: list[str] = []
patches: list[str] = []
occt_link_type = "Shared" if ARGS.occt_shared else "Static"
occt_name = f"occt-shared-{OCCT_VERSION}" if ARGS.occt_shared else f"occt-{OCCT_VERSION}"
OCCT_INSTALL_PATH = f"{DEPS_DIR}/install/{occt_name}"
if OCCT_VERSION < "7.4":
patches.append("./patches/occt/enable-exception-handling.patch")
@@ -1045,12 +1167,23 @@ if USE_OCCT and "occ" in targets:
if WASM:
patches.append("./patches/occt/no_em_js.patch")
if ARGS.occt_shared:
# Using static flags for shared builds break it
# (e.g. `-fvisibility=hidden` hides many symbols).
# So we temporarily override flags.
OLD_CPP_FLAGS = os.environ["CPPFLAGS"]
OLD_CXX_FLAGS = os.environ["CXXFLAGS"]
OLD_C_FLAGS = os.environ["CFLAGS"]
os.environ["CXXFLAGS"] = CXXFLAGS_SHARED
os.environ["CPPFLAGS"] = CXXFLAGS_SHARED
os.environ["CFLAGS"] = CFLAGS_SHARED
build_dependency(
name=f"occt-{OCCT_VERSION}",
name=occt_name,
mode="cmake",
build_tool_args=[
f"-DINSTALL_DIR={DEPS_DIR}/install/occt-{OCCT_VERSION}",
f"-DBUILD_LIBRARY_TYPE={LINK_TYPE_UCFIRST}",
f"-DINSTALL_DIR={OCCT_INSTALL_PATH}",
f"-DBUILD_LIBRARY_TYPE={occt_link_type}",
f"-DBUILD_MODULE_Draw=0",
f"-DBUILD_RELEASE_DISABLE_EXCEPTIONS=Off",
# Disable xlib explicitly, as it tries to use it on Desktop Ubuntu, adding unnecessary dependency.
@@ -1069,6 +1202,11 @@ if USE_OCCT and "occ" in targets:
patch=patches,
revision="V" + OCCT_VERSION.replace(".", "_"),
)
if ARGS.occt_shared:
restore_env("CPPFLAGS", OLD_CPP_FLAGS)
restore_env("CXXFLAGS", OLD_CXX_FLAGS)
restore_env("CFLAGS", OLD_C_FLAGS)
elif "occ" in targets:
build_dependency(
name=f"oce-{OCE_VERSION}",
@@ -1431,7 +1569,7 @@ if "qt6" in targets:
cecho("Building IfcOpenShell:", GREEN)
IFCOS_DIR = os.path.join(DEPS_DIR, "build", "ifcopenshell")
if os.environ.get("NO_CLEAN", "").lower() not in {"1", "on", "true"}:
if not is_on_off(os.getenv("NO_CLEAN"), default=False):
if os.path.exists(IFCOS_DIR):
shutil.rmtree(IFCOS_DIR)
os.makedirs(IFCOS_DIR, exist_ok=True)
@@ -1441,8 +1579,8 @@ os.makedirs(ifcos_build_dir, exist_ok=True)
cmake_args = [
"-DUSE_MMAP=OFF",
f"-DBUILD_EXAMPLES={OFF_ON[BUILD_EXAMPLES]}",
"-DBUILD_SHARED_LIBS=" + OFF_ON[not IFCOPENSHELL_STATIC],
f"-DBUILD_EXAMPLES={OFF_ON[ARGS.build_examples]}",
"-DBUILD_SHARED_LIBS=" + OFF_ON[ARGS.ifcopenshell_shared],
"-DGLTF_SUPPORT=ON",
"-DBoost_NO_BOOST_CMAKE=On",
"-DCREATE_BUNDLE=On",
@@ -1487,7 +1625,7 @@ if "cgal" in targets:
cmake_args.append(f"-DCGAL_WITH_GMPXX=Off")
if "occ" in targets and USE_OCCT:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/occt-{OCCT_VERSION}")
cmake_args_prefix_path.append(OCCT_INSTALL_PATH)
elif "occ" in targets:
# We don't support find_package for OCE.
@@ -1573,6 +1711,42 @@ if not WASM and (
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=ifcos_build_dir)
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=ifcos_build_dir)
def test_examples() -> None:
cecho("Running examples...", GREEN)
examples_bin_dir = Path(DEPS_DIR) / "install" / "ifcopenshell" / "bin"
examples_env = os.environ.copy()
ld_library_paths = ["../lib"]
if ARGS.occt_shared:
ld_library_paths.append(f"{OCCT_INSTALL_PATH}/lib")
examples_env["LD_LIBRARY_PATH"] = os.pathsep.join(ld_library_paths)
examples: dict[tuple[str, ...], str | None] = {
("./IfcOpenHouse",): "IfcOpenHouse.ifc",
("./IfcParseExamples", "IfcOpenHouse.ifc"): None,
("./IfcAdvancedHouse",): "IfcAdvancedHouse.ifc",
}
# Only for ifc4x3 schema.
if (examples_bin_dir / "IfcAlignment").is_file():
examples[("./IfcAlignment",)] = "FHWA_Bridge_Geometry_Alignment_Example.ifc"
examples[("./IfcSimplifiedAlignment",)] = "FHWA_Bridge_Geometry_Alignment_Example_Simplified.ifc"
produced_files: set[str] = set()
try:
for cmd, expected_file in examples.items():
run(cmd, cwd=str(examples_bin_dir), env=examples_env)
if expected_file is None:
continue
if not (examples_bin_dir / expected_file).is_file():
raise RuntimeError(f"Example `{' '.join(cmd)}` did not produce expected file '{expected_file}'.")
produced_files.add(expected_file)
finally:
for produced_file in produced_files:
(examples_bin_dir / produced_file).unlink(missing_ok=True)
if ARGS.build_examples:
test_examples()
if "IfcOpenShell-Python" in targets:
wrapper_ldflags = ""
if platform.system() == "Darwin":
@@ -1624,8 +1798,7 @@ if "IfcOpenShell-Python" in targets:
*([f"-DPYTHON_MODULE_INSTALL_DIR={REPO_PATH}"] * WASM),
f"-DPYTHON_INCLUDE_DIR={python_include}",
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell/tmp",
"-DUSERSPACE_PYTHON_PREFIX="
+ ["Off", "On"][os.environ.get("PYTHON_USER_SITE", "").lower() in {"1", "on", "true"}],
"-DUSERSPACE_PYTHON_PREFIX=" + OFF_ON[PYTHON_USER_SITE],
],
cmake_dir=CMAKE_DIR,
cwd=ifcos_build_dir,
+4 -23
View File
@@ -69,32 +69,17 @@ endif # def PYVERSION
IFCMERGE_VERSION:=2026-04-07
ifdef PLATFORM
SUPPORTED_PLATFORMS := linux macos macosm1 win
SUPPORTED_PLATFORMS := linux macosm1 win
ifeq ($(filter $(PLATFORM),$(SUPPORTED_PLATFORMS)),)
$(error Unsupported PLATFORM=$(PLATFORM). Must be one of $(SUPPORTED_PLATFORMS))
endif
ifeq ($(PLATFORM),macos)
ifeq ($(PYVERSION),py313)
$(error Blender 5.1 with Python 3.13 doesn't support intel macOS.)
endif
endif
ifeq ($(PLATFORM), linux)
PYPI_PLATFORM:=--platform manylinux_2_17_x86_64
BLENDER_PLATFORM:=linux-x64
endif
ifeq ($(PLATFORM), macos)
ifeq ($(PYVERSION), py311)
PYPI_PLATFORM:=--platform macosx_10_10_x86_64
else
PYPI_PLATFORM:=--platform macosx_10_13_x86_64
endif
BLENDER_PLATFORM:=macos-x64
endif
ifeq ($(PLATFORM), macosm1)
PYPI_PLATFORM:=--platform macosx_11_0_arm64
BLENDER_PLATFORM:=macos-arm64
@@ -108,7 +93,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
OLD:=3e7b739
OLD:=ad113e1
.PHONY: bump
bump:
ifndef NEW
@@ -194,10 +179,8 @@ endif
# Provides networkx graph analysis for project dependency calculations
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
# Required by IFCDiff
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
# to 10_13 (matching py312/py313).
# Pinned <9.1: deepdiff 9.1.0 adds the compiled dependency cachebox<6,>=5.2,
# which this platformless download cannot provide for every target platform.
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
# Required by IFCCSV and ifcopenshell.util.selector
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
@@ -215,8 +198,6 @@ endif
# pyradiance is using different platform versions than defaults in our makefile.
ifeq ($(PLATFORM), linux)
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else ifeq ($(PLATFORM), macos)
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
endif
+56 -4
View File
@@ -428,7 +428,8 @@ void MainWindow::setupPanels() {
spatial_panel_ = new modules::spatial_hierarchy::SpatialHierarchyPanel(this);
properties_panel_ = new modules::properties::PropertiesPanel(this);
models_view_ = new modules::models::ModelsPanelView(models_panel_, session_state_, this);
models_view_ = new modules::models::ModelsPanelView(
models_panel_, session_state_, viewport_widget_->viewport(), this);
spatial_view_ = new modules::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, session_state_, this);
properties_view_ = new modules::properties::PropertiesPanelView(properties_panel_, session_state_, this);
@@ -481,6 +482,13 @@ void MainWindow::setupStatus() {
status_mode_label_ = new QLabel("Ready", this);
status_selection_label_ = new QLabel("No selection", this);
status_perf_label_ = new QLabel(this);
status_memory_label_ = new QLabel(this);
status_memory_label_->setVisible(false);
status_memory_label_->setToolTip(
"The geometry in view needs more GPU memory than is available, so the "
"viewer keeps the largest on-screen parts resident and streams the rest "
"as you move. Right-click a model in the Models panel and choose "
"\"Unload Model\" to free its GPU memory for the others.");
status_progress_bar_ = new QProgressBar(this);
status_perf_label_->setVisible(AppSettings::instance().showStats());
status_progress_bar_->setMaximumWidth(200);
@@ -489,6 +497,7 @@ void MainWindow::setupStatus() {
statusBar()->setSizeGripEnabled(false);
statusBar()->addWidget(status_mode_label_);
statusBar()->addWidget(status_selection_label_, 1);
statusBar()->addPermanentWidget(status_memory_label_);
statusBar()->addPermanentWidget(status_perf_label_);
statusBar()->addPermanentWidget(status_progress_bar_);
@@ -554,16 +563,59 @@ void MainWindow::setupLoader() {
connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this,
[this](const ViewportWindow::FrameStats& stats) {
const double mb = 1.0 / (1024.0 * 1024.0);
// Missing chunks are normal for a moment after every camera move
// while streaming catches up; only a shortfall that persists means
// the view does not fit, and only that is worth telling the user.
constexpr qint64 kShortfallNoticeMs = 3000;
if (stats.chunks_wanted_missing == 0) {
memory_shortfall_since_.invalidate();
status_memory_label_->setVisible(false);
} else {
if (!memory_shortfall_since_.isValid()) memory_shortfall_since_.start();
if (memory_shortfall_since_.elapsed() >= kShortfallNoticeMs) {
status_memory_label_->setText(
QString("GPU memory full: %1 of %2 visible chunks (%3 MB) not loaded")
.arg(stats.chunks_wanted_missing)
.arg(stats.chunks_wanted)
.arg(double(stats.wanted_missing_bytes) * mb, 0, 'f', 0));
status_memory_label_->setVisible(true);
}
}
if (!status_perf_label_->isVisible()) return;
status_perf_label_->setText(
QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws")
QString text =
QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws | VRAM %8/%9 MB")
.arg(stats.fps, 0, 'f', 1)
.arg(stats.frame_time_ms, 0, 'f', 1)
.arg(stats.visible_objects)
.arg(stats.total_objects)
.arg(stats.visible_triangles)
.arg(stats.total_triangles)
.arg(stats.gl_draw_calls));
.arg(stats.gl_draw_calls)
.arg(double(stats.vram_used_bytes) * mb, 0, 'f', 0)
.arg(double(stats.vram_capacity_bytes) * mb, 0, 'f', 0);
// The budget is where the pool may grow to; the pool can also sit
// a sub-buffer above it (a release would undershoot). Show it
// only when it tells the user something capacity does not.
if (stats.vram_budget_bytes > 0
&& stats.vram_budget_bytes != stats.vram_capacity_bytes) {
text += QString(" (budget %1)")
.arg(double(stats.vram_budget_bytes) * mb, 0, 'f', 0);
}
// Device total is only known when a driver backend answered.
if (stats.device_vram_total_bytes > 0) {
text += QString(" | Device %1/%2 MB")
.arg(double(stats.device_vram_used_bytes) * mb, 0, 'f', 0)
.arg(double(stats.device_vram_total_bytes) * mb, 0, 'f', 0);
}
if (stats.chunks_wanted_missing > 0) {
text += QString(" | %1/%2 chunks waiting")
.arg(stats.chunks_wanted_missing)
.arg(stats.chunks_wanted);
}
status_perf_label_->setText(text);
});
connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked,
this, [this](uint32_t object_id) {
+5
View File
@@ -26,6 +26,7 @@
#include <QStringList>
class QLabel;
#include <QElapsedTimer>
class QDockWidget;
class QMenu;
class QProgressBar;
@@ -70,6 +71,10 @@ private:
QLabel* status_mode_label_ = nullptr;
QLabel* status_selection_label_ = nullptr;
QLabel* status_perf_label_ = nullptr;
// Shown while the visible geometry persistently exceeds what fits in
// GPU memory (see onFrameStats): the user's cue to unload models.
QLabel* status_memory_label_ = nullptr;
QElapsedTimer memory_shortfall_since_;
QProgressBar* status_progress_bar_ = nullptr;
bonsaiviewer::components::TabBar* ribbon_tabs_ = nullptr;
QStackedWidget* ribbon_pages_ = nullptr;
+4
View File
@@ -199,6 +199,10 @@ void SessionState::notifyModelGeometryReady(uint32_t session_model_id) {
emit modelGeometryReady(session_model_id);
}
void SessionState::notifyModelLoadStateChanged(const QString& model_id) {
emit modelLoadStateChanged(model_id);
}
void SessionState::notifyProjectOpened(const QString& path) {
emit projectOpened(path);
}
+5
View File
@@ -89,6 +89,7 @@ public:
void notifyFederationChanged();
void notifyVisibilityChanged();
void notifyModelGeometryReady(uint32_t session_model_id);
void notifyModelLoadStateChanged(const QString& model_id);
void notifyProjectOpened(const QString& path);
void notifyProjectSaved(const QString& path);
void notifyProjectReset();
@@ -107,6 +108,10 @@ signals:
// for both sidecar-cache and stream loads; subscribers that just need to
// re-derive view state (e.g. ViewportView::refresh) listen to this.
void modelGeometryReady(uint32_t session_model_id);
// Fires when a model was unloaded from, or loaded back onto, the GPU
// (commands::unloadModel / loadModel). The viewport is the authority
// for the state itself — ViewportWindow::isModelUnloaded.
void modelLoadStateChanged(const QString& model_id);
// Fires when a model's live IFC data source (the .ifc/.rdb, opened in the
// background after a sidecar-cache hit) becomes available for queries —
// e.g. so the spatial hierarchy can be built once the file is loaded.
@@ -262,6 +262,28 @@ void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host,
session.setStatusMessage("Models", "Model removed");
}
void unloadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id) {
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0) return;
if (session.loader()->isLoadingModel(session_model_id)) return;
const double freed_mb = double(viewport.modelVramBytes(session_model_id)) / (1024.0 * 1024.0);
viewport.unloadModel(session_model_id);
session.notifyModelLoadStateChanged(model_id);
session.setStatusMessage("Models", QString("Model unloaded (freed %1 MB of GPU memory)")
.arg(freed_mb, 0, 'f', 0));
}
void loadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id) {
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0) return;
if (!viewport.loadModel(session_model_id)) {
session.setStatusMessage("Models", "Not enough GPU memory to load this model");
return;
}
session.notifyModelLoadStateChanged(model_id);
session.setStatusMessage("Models", "Model loaded");
}
void viewModels(SessionState& session, ViewportWindow& viewport, const QStringList& model_ids) {
// Federation ids are the panel's currency; the viewport speaks session
// model ids. sessionModelIdForModelId returns 0 for a model the viewport
@@ -60,6 +60,12 @@ void moveGroup(SessionState& session, const QString& id, const QString& parent_g
void moveModels(SessionState& session, const QStringList& ids, const QString& parent_group_id);
void removeGroup(SessionState& session, QWidget& host, const QString& group_id);
void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& model_id);
// GPU residency, distinct from visibility (hide) and from membership
// (remove): unloadModel frees everything the model holds on the device
// while it stays in the federation; loadModel brings it back. Both emit
// modelLoadStateChanged.
void unloadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id);
void loadModel(SessionState& session, ViewportWindow& viewport, const QString& model_id);
// "View Selected Model" — frame the camera on just these models' geometry, the
// way View All frames the whole federation. Models that carry no loaded
// geometry (never loaded, or still streaming their metadata) contribute
@@ -25,16 +25,25 @@
#include "../../../ifcviewer/Federation.h"
#include <QBrush>
#include <QFont>
#include <QColor>
namespace bonsaiviewer::modules::models {
namespace {
QStandardItem* siblingVisibilityItem(QStandardItem* name_item) {
QStandardItem* siblingItem(QStandardItem* name_item, Column column) {
QStandardItem* parent = name_item->parent();
if (!parent) parent = name_item->model()->invisibleRootItem();
return parent->child(name_item->row(), 1);
return parent->child(name_item->row(), int(column));
}
QStandardItem* siblingVisibilityItem(QStandardItem* name_item) {
return siblingItem(name_item, VisibilityColumn);
}
QString formatMegabytes(quint64 bytes) {
return QString("%1 MB").arg(double(bytes) / (1024.0 * 1024.0), 0, 'f', 0);
}
template <typename F>
@@ -51,7 +60,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
: QStandardItemModel(parent)
, federation_(federation)
{
setColumnCount(2);
setColumnCount(ColumnCount);
rebuildAll();
connect(federation_, &Federation::groupAdded, this, &FederationItemModel::onGroupAdded);
@@ -67,7 +76,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
void FederationItemModel::rebuildAll() {
clear();
setColumnCount(2);
setColumnCount(ColumnCount);
id_to_name_item_.clear();
for (const auto& root_group : federation_->rootGroups()) {
@@ -121,6 +130,14 @@ QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visib
return item;
}
QStandardItem* FederationItemModel::makeMemoryItem() const {
auto* item = new QStandardItem(QString());
item->setEditable(false);
item->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
item->setForeground(QBrush(QColor(bonsaiviewer::ViewerSettings::instance().color("disabled_text"))));
return item;
}
void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visible) const {
QStandardItem* vis_item = siblingVisibilityItem(name_item);
if (visible) {
@@ -133,6 +150,22 @@ void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visi
}
}
void FederationItemModel::setModelResidency(const QString& model_id, bool unloaded, quint64 vram_bytes) {
QStandardItem* name_item = findItem(model_id);
if (!name_item) return;
QStandardItem* memory_item = siblingItem(name_item, MemoryColumn);
if (!memory_item) return;
const QString text = unloaded ? QStringLiteral("unloaded")
: vram_bytes > 0 ? formatMegabytes(vram_bytes)
: QString();
if (memory_item->text() != text) memory_item->setText(text);
QFont font = name_item->font();
if (font.italic() != unloaded) {
font.setItalic(unloaded);
name_item->setFont(font);
}
}
QStandardItem* FederationItemModel::findItem(const QString& id) const {
return id_to_name_item_.value(id, nullptr);
}
@@ -148,7 +181,7 @@ void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QStrin
if (!model) return;
auto* name_item = makeModelNameItem(model_id, model->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(model_id));
parent_item->appendRow({name_item, vis_item});
parent_item->appendRow({name_item, makeMemoryItem(), vis_item});
id_to_name_item_.insert(model_id, name_item);
styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(model_id));
}
@@ -158,7 +191,7 @@ void FederationItemModel::appendGroupSubtreeTo(QStandardItem* parent_item, const
if (!group) return;
auto* name_item = makeGroupNameItem(group_id, group->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Group, group->visible);
parent_item->appendRow({name_item, vis_item});
parent_item->appendRow({name_item, makeMemoryItem(), vis_item});
id_to_name_item_.insert(group_id, name_item);
styleRowVisibility(name_item, group->visible);
@@ -31,7 +31,7 @@ class Federation;
namespace bonsaiviewer::modules::models {
// QStandardItemModel that mirrors the Federation tree (groups + models in
// two columns: name + visibility icon). Subscribes directly to Federation's
// three columns: name, GPU memory, visibility icon). Subscribes directly to Federation's
// granular signals so each mutation only touches the affected rows — view
// state (expansion, selection, scroll) is preserved automatically.
//
@@ -57,6 +57,12 @@ public:
// previously- and newly-active model rows.
void setActiveModelId(const QString& model_id);
// GPU residency is viewport state, not Federation state, so it is pushed
// in by the owning View: the memory column shows `vram_bytes` for a
// loaded model and "unloaded" for one the user unloaded (which is also
// drawn in italics). Models the viewport knows nothing about show blank.
void setModelResidency(const QString& model_id, bool unloaded, quint64 vram_bytes);
private slots:
void onGroupAdded(const QString& group_id);
void onGroupRemoved(const QString& group_id);
@@ -72,6 +78,7 @@ private:
QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const;
QStandardItem* makeModelNameItem(const QString& model_id, const QString& display_name) const;
QStandardItem* makeVisibilityItem(ItemKind kind, bool visible) const;
QStandardItem* makeMemoryItem() const;
void styleRowVisibility(QStandardItem* name_item, bool visible) const;
QStandardItem* findItem(const QString& id) const;
+28 -6
View File
@@ -28,6 +28,7 @@
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QDataStream>
#include <QDrag>
@@ -79,6 +80,7 @@ QStringList selectedModelIdsAt(QTreeView* tree, const QModelIndex& clicked_index
}
constexpr int kVisibilityColumnWidth = 28;
constexpr int kMemoryColumnWidth = 72; // "1234 MB" / "unloaded"
// QTreeView subclass that handles drag-and-drop. Drop logic dispatches
// through commands (not directly into the model) so notifications + status
@@ -250,7 +252,7 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) {
if (!index.isValid()) return;
if (index.column() == 1) {
if (index.column() == VisibilityColumn) {
commands::toggleVisibility(*session_state_, kindOf(index), idOf(index));
return;
}
@@ -380,6 +382,24 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
commands::saveModelAsToCloud(*session_state_, *this, id);
});
// GPU residency. Unload keeps the model in the federation (and
// its visibility) but frees everything it holds on the GPU — the
// lever when the scene does not fit in VRAM. Load brings it back.
menu.addSeparator();
const uint32_t session_model_id = session_state_->sessionModelIdForModelId(id);
const bool unloaded = session_model_id != 0 && viewport_->isModelUnloaded(session_model_id);
QAction* residency = menu.addAction(
components::icons::makeSvgIcon(":/icons/cube.svg"),
unloaded ? "Load Model" : "Unload Model");
residency->setEnabled(session_model_id != 0);
residency->setToolTip(unloaded
? "Allocate GPU memory for this model again and stream its geometry back in."
: "Free this model's GPU memory while keeping it in the federation.");
connect(residency, &QAction::triggered, this, [this, id, unloaded]() {
if (unloaded) commands::loadModel(*session_state_, *viewport_, id);
else commands::unloadModel(*session_state_, *viewport_, id);
});
menu.addSeparator();
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
@@ -409,14 +429,16 @@ void ModelsPanel::setModel(FederationItemModel* model) {
}
void ModelsPanel::applyColumnLayout() {
// Column 0 (name) stretches to fill; column 1 (visibility icon) is fixed.
// The name stretches to fill; memory and visibility are fixed.
QHeaderView* header = tree_->header();
if (header->count() < 2) return;
if (header->count() < ColumnCount) return;
header->setStretchLastSection(false);
header->setMinimumSectionSize(kVisibilityColumnWidth);
header->setSectionResizeMode(0, QHeaderView::Stretch);
header->setSectionResizeMode(1, QHeaderView::Fixed);
header->resizeSection(1, kVisibilityColumnWidth);
header->setSectionResizeMode(NameColumn, QHeaderView::Stretch);
header->setSectionResizeMode(MemoryColumn, QHeaderView::Fixed);
header->resizeSection(MemoryColumn, kMemoryColumnWidth);
header->setSectionResizeMode(VisibilityColumn, QHeaderView::Fixed);
header->resizeSection(VisibilityColumn, kVisibilityColumnWidth);
}
} // namespace bonsaiviewer::modules::models
+8
View File
@@ -31,6 +31,14 @@ enum class ItemKind {
Model,
};
// Columns of the models tree: name | GPU memory | visibility eye.
enum Column : int {
NameColumn = 0,
MemoryColumn = 1,
VisibilityColumn = 2,
ColumnCount = 3,
};
struct TreeNode {
QString id;
QString name;
+30 -1
View File
@@ -26,6 +26,9 @@
#include "../../ViewerSettings.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QTimer>
namespace bonsaiviewer::modules::models {
@@ -54,17 +57,19 @@ QList<GroupOption> validMoveTargets(const Federation& federation,
ModelsPanelView::ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent)
: QObject(parent)
, widget_(widget)
, session_state_(session_state)
, viewport_(viewport)
, model_(new FederationItemModel(session_state->federation(), this))
{
widget_->setModel(model_);
// Coarse signals: full rebuild + re-style. The granular Federation
// signals are handled inside FederationItemModel and don't reach here.
auto rebuild = [this]() { model_->rebuildAll(); };
auto rebuild = [this]() { model_->rebuildAll(); refreshResidency(); };
connect(session_state_, &SessionState::projectReset, this, rebuild);
connect(session_state_, &SessionState::projectOpened, this, rebuild);
connect(&bonsaiviewer::ViewerSettings::instance(),
@@ -73,6 +78,30 @@ ModelsPanelView::ModelsPanelView(ModelsPanel* widget,
connect(session_state_, &SessionState::activeModelChanged, this, [this](const QString& model_id) {
model_->setActiveModelId(model_id);
});
// Residency: immediately on the events that change it, and on a slow
// tick for the memory figures, which move as chunks stream.
auto refresh = [this]() { refreshResidency(); };
connect(session_state_, &SessionState::modelLoadStateChanged, this, refresh);
connect(session_state_, &SessionState::modelGeometryReady, this, refresh);
connect(session_state_, &SessionState::modelsChanged, this, refresh);
auto* tick = new QTimer(this);
tick->setInterval(1000);
connect(tick, &QTimer::timeout, this, refresh);
tick->start();
}
void ModelsPanelView::refreshResidency() {
for (const auto& model : session_state_->federation()->models()) {
const uint32_t session_model_id = session_state_->sessionModelIdForModelId(model.id);
if (session_model_id == 0) {
model_->setModelResidency(model.id, false, 0);
continue;
}
model_->setModelResidency(model.id,
viewport_->isModelUnloaded(session_model_id),
viewport_->modelVramBytes(session_model_id));
}
}
} // namespace bonsaiviewer::modules::models
+10
View File
@@ -26,6 +26,7 @@
#include <QObject>
class Federation;
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::models {
@@ -45,16 +46,25 @@ QList<GroupOption> validMoveTargets(const Federation& federation,
// coarse session signals (project open/reset, theme change) — those are the
// "rebuild from scratch" cases the model itself doesn't subscribe to.
// Granular Federation events are handled inside the model.
//
// Also the bridge for the one thing the tree shows that is not Federation
// state: each model's GPU residency (memory column, unloaded styling). The
// viewport owns that state, so this view polls it once a second — the
// numbers move continuously while geometry streams — and pushes it in.
class ModelsPanelView : public QObject {
Q_OBJECT
public:
explicit ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent = nullptr);
private:
void refreshResidency();
ModelsPanel* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
ViewportWindow* viewport_ = nullptr;
FederationItemModel* model_ = nullptr;
};
+6 -1
View File
@@ -101,7 +101,12 @@ int main() {
// IfcFacetedBRep. If it would not be a polyhedron, serialise() can only be successful when linked
// to the IFC4 model and with `advanced` set to `true` which introduces IfcAdvancedFace. It would
// return `0` otherwise.
auto building_shape = ifcopenshell::geom::serialise(file, building_shell, false).as<IfcSchema::IfcProductDefinitionShape>();
auto building_shape_result = ifcopenshell::geom::serialise(file, building_shell, false);
if (!building_shape_result) {
std::cerr << "Failed to serialize building shell." << std::endl;
return 1;
}
auto building_shape = building_shape_result.as<IfcSchema::IfcProductDefinitionShape>();
file.add_entity(building_shape);
auto building_representations = building_shape.Representations();
+2
View File
@@ -28,7 +28,9 @@
// alignment explicitly
// Disable warnings coming from IfcOpenShell
#if defined(_MSC_VER)
#pragma warning(disable : 4018 4267 4250 4984 4985)
#endif
#include "../ifcparse/schemas/Ifc4x3_add2.h"
#include "../ifcparse/hierarchy_helper.h"
+2
View File
@@ -28,7 +28,9 @@
// to simplify alignment construction
// Disable warnings coming from IfcOpenShell
#if defined(_MSC_VER)
#pragma warning(disable : 4018 4267 4250 4984 4985)
#endif
#include "../ifcparse/schemas/Ifc4x3_add2.h"
#include "../ifcparse/alignment_helper.h"
+3
View File
@@ -16,6 +16,9 @@ set_target_properties(geometry_serializer PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${geometry_serialization_plugin_runtime_dir}"
LIBRARY_OUTPUT_DIRECTORY "${geometry_serialization_plugin_runtime_dir}"
)
if (NOT CREATE_BUNDLE)
set_target_properties(geometry_serializer PROPERTIES VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
endif()
target_link_libraries(geometry_serializer plugin IfcGeom IfcParse ${OpenCASCADE_LIBRARIES})
if(NOT WASM_BUILD)
target_link_libraries(geometry_serializer geometry_kernel_opencascade)
+2 -2
View File
@@ -57,8 +57,8 @@ ifeq ($(PLATFORM), win64)
PLATFORMTAG:=win_amd64
endif
BINARY_VERSION:=0.8.6
BUILD_COMMIT:=e333c1c
BINARY_VERSION:=0.9.0alpha0
BUILD_COMMIT:=ad113e1
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
@@ -95,6 +95,8 @@ from .sql import sqlite, sqlite_entity
rocksdb_lazy_instance = file_module.rocksdb_lazy_instance
decode_spf_string = ifcopenshell_wrapper.decode_spf_string
encode_spf_string = ifcopenshell_wrapper.encode_spf_string
get_log = ifcopenshell_wrapper.get_log
logger = ifcopenshell_wrapper.logger if hasattr(ifcopenshell_wrapper, "logger") else None
if hasattr(ifcopenshell_wrapper, "logger_or_root"):
@@ -114,6 +116,8 @@ def optional_logger_args(logger: ifcopenshell_wrapper.logger | None) -> tuple[lo
# (it's a requirement for a typed library)
__all__ = [
"clear_plugin_search_paths",
"decode_spf_string",
"encode_spf_string",
"entity_instance",
"file",
"get_plugin_search_paths",
@@ -1649,6 +1649,8 @@ def construct_iterator_with_include_exclude_id(
geometry_library, settings, file, elems, include, num_threads, logger=None
): ...
def convert_loop_to_function_item(loop): ...
def decode_spf_string(value: str) -> str: ...
def encode_spf_string(value: str) -> str: ...
class attribute_value_derived: ...
@@ -1,6 +1,14 @@
import ifcopenshell
def test_spf_strings_can_be_encoded_and_decoded():
decoded = "Café's \\"
encoded = r"'Caf\X2\00E9\X0\''s \\'"
assert ifcopenshell.encode_spf_string(decoded) == encoded
assert ifcopenshell.decode_spf_string(encoded) == decoded
def test_skip_over_non_entity_instance():
data = """
ISO-10303-21;
+1 -5
View File
@@ -67,10 +67,6 @@ class IFC_PARSE_API character_decoder {
std::string get(size_t& offset);
};
} // namespace ifcopenshell
namespace ifcopenshell {
class IFC_PARSE_API character_encoder {
private:
std::u32string str_;
@@ -80,6 +76,6 @@ class IFC_PARSE_API character_encoder {
operator std::string();
};
} // namespace IfcWrite
} // namespace ifcopenshell
#endif
+20
View File
@@ -550,6 +550,26 @@ std::string token::to_string() {
return result;
}
std::string ifcopenshell::encode_spf_string(const std::string& value) {
return character_encoder(value);
}
std::string ifcopenshell::decode_spf_string(const std::string& value) {
std::string wrapped;
auto value_p = &value;
if (!value.empty() && value.front() != '\'') {
wrapped = "'" + value + "'";
value_p = &wrapped;
}
file_reader<full_buffer_impl> reader(*value_p, caller_fed_tag{});
spf_lexer<file_reader<full_buffer_impl>> lexer(&reader);
token decoded = lexer.next();
if (!decoded.is_string()) {
throw exception("Expected an SPF string");
}
return decoded.as_string();
}
namespace {
template<typename Variant, typename T>
+4
View File
@@ -47,6 +47,10 @@ extern IFC_PARSE_API const char *IFCOPENSHELL_VERSION;
namespace ifcopenshell {
IFC_PARSE_API std::string encode_spf_string(const std::string& value);
IFC_PARSE_API std::string decode_spf_string(const std::string& value);
/// A stream of tokens to be read from a file_reader.
template <typename Reader>
class IFC_PARSE_API spf_lexer {
@@ -2,9 +2,19 @@
#include <catch2/catch_test_macros.hpp>
#include <ifcparse/file.h>
#include <ifcparse/parse.h>
#include <string>
#include <vector>
TEST_CASE("SPF strings can be encoded and decoded", "[ifcparse]") {
const std::string decoded = "Caf\xC3\xA9" "'s \\";
const std::string encoded = R"('Caf\X2\00E9\X0\''s \\')";
CHECK(ifcopenshell::encode_spf_string(decoded) == encoded);
CHECK(ifcopenshell::decode_spf_string(encoded) == decoded);
CHECK(ifcopenshell::decode_spf_string(encoded.substr(1, encoded.size() - 2)) == decoded);
}
TEST_CASE("IfcPropertySetDefinitionSet references are resolved without replacing their owner", "[ifcparse]") {
const std::string fixture = std::string(IFCOPENSHELL_TEST_FIXTURES) + "/ColumnPSetsOfSets.ifc";
ifcopenshell::file file(fixture);
+1 -1
View File
@@ -115,7 +115,7 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
"-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c','_ifcv_set_federation_unit_c','_ifcv_set_false_origin_c','_ifcv_get_false_origin_c','_ifcv_set_model_transform_c','_ifcv_clear_model_transform_c','_ifcv_set_model_name_c','_ifcv_get_model_georef_c']"
"-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c','_ifcv_set_federation_unit_c','_ifcv_set_false_origin_c','_ifcv_get_false_origin_c','_ifcv_set_model_transform_c','_ifcv_clear_model_transform_c','_ifcv_set_model_name_c','_ifcv_get_model_georef_c','_ifcv_get_frame_stats_c','_ifcv_unload_model_c','_ifcv_load_model_c','_ifcv_model_unloaded_c','_ifcv_model_vram_bytes_c']"
# ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c,
# and the nav-preset name to ifcv_set_nav_preset_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
+12
View File
@@ -54,9 +54,21 @@ public:
// core.render().
bool consumeFrameRequest();
// The most recent per-frame stats (fps, VRAM, working set). Latched
// here so the page can read them whenever it likes (ifcv_get_frame_stats_c)
// instead of being called back every frame across the wasm boundary.
void onFrameStats(const FrameStats& stats) override { last_stats_ = stats; }
// No measurement tools on web yet, so nothing reads the CPU triangle
// shadow — and at 12 B/vertex it is a large slice of a 4 GB-capped
// wasm heap. Flip when the tools are ported.
bool wantsCpuMeshTriangles() const override { return false; }
const FrameStats& lastFrameStats() const { return last_stats_; }
private:
std::string canvas_selector_;
bool request_frame_pending_ = true; // arm an initial frame
FrameStats last_stats_ = {};
};
#endif // WEBVIEWPORTHOST_H
+111 -19
View File
@@ -195,9 +195,10 @@ int fillIdsAscending(const std::unordered_set<std::uint32_t>& ids,
// Quote `s` as a JSON string literal. IFC names come straight from the model
// and can hold quotes, backslashes and control characters; UTF-8 continuation
// bytes are already legal JSON and pass through untouched.
std::string jsonString(const std::string& s) {
std::string out = "\"";
for (unsigned char c : s) {
void appendJsonString(std::string& out, const char* data, std::uint32_t length) {
out += '"';
for (std::uint32_t i = 0; i < length; ++i) {
const unsigned char c = (unsigned char)data[i];
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
@@ -216,9 +217,10 @@ std::string jsonString(const std::string& s) {
}
}
}
return out + '"';
out += '"';
}
NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button,
bool shift, bool ctrl, bool alt) {
using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod;
@@ -253,6 +255,10 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
app->nav_drag_px = 0.0f;
app->down_x = e->targetX; // canvas-relative CSS px
app->down_y = e->targetY;
// Show the pivot triad for the duration of an orbit / pan drag, so
// it's visible what the camera turns around (matches the desktop).
if (kind == NavKind::Orbit || kind == NavKind::Pan)
app->core.setPivotIndicatorVisible(true);
}
return EM_TRUE;
}
@@ -297,6 +303,10 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
const NavKind kind = app->nav_kind;
app->nav_active = false;
app->nav_kind = NavKind::None;
// Drag is over — hide the pivot indicator without afterglow. Only for the
// gesture that raised it; a stray mouseup must not cut a wheel afterglow.
if (was_active && (kind == NavKind::Orbit || kind == NavKind::Pan))
app->core.setPivotIndicatorVisible(false);
// End a section-gizmo drag (took over the press; no pick/orbit on release).
if (app->section_dragging) {
@@ -351,7 +361,7 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
if (id != 0) {
app->core.logSelectedObjectGuidWeb(id);
} else if (!add && !remove) {
EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1); });
EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1, -1); });
}
app->host.requestFrame();
});
@@ -373,6 +383,9 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) {
// In fly mode the wheel tunes move speed (Blender convention), not zoom.
if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; }
app->core.dollyBy(-float(dy) / 100.0f);
// Pivot afterglow on wheel — visible for 600 ms so the user can see what
// they're zooming around without holding a drag.
app->core.setPivotIndicatorVisible(true, 600);
return EM_TRUE; // consume so the page doesn't scroll
}
@@ -811,26 +824,49 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_model_georef_c(int source_id, doubl
// Promise the JS layer is holding.
extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_request_objects_c(int token) {
if (!g_app || !g_app->ready) {
EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, '[]'); }, token);
EM_ASM({ if (Module.__ifcvOnObjectsDone) Module.__ifcvOnObjectsDone($0); }, token);
return;
}
g_app->core.loadAllElementMetadataWeb([token](bool) {
// Partial failures are not fatal: a model whose element block failed to
// fetch simply contributes no rows, and the rest still resolve.
std::string json = "[";
bool first = true;
for (const ViewportCore::ElementRef& e : g_app->core.elements()) {
if (!first) json += ',';
first = false;
json += "{\"objectId\":" + std::to_string(e.object_id)
+ ",\"model\":" + std::to_string(e.model_index)
+ ",\"guid\":" + jsonString(e.guid)
+ ",\"name\":" + jsonString(e.name)
+ ",\"type\":" + jsonString(e.type) + '}';
//
// Serialised one model per batch, straight from string-table slices.
// The whole-scene single-string version materialised three string
// copies per element plus a scene-sized JSON blob simultaneously —
// a 400+ MB transient at ~600k elements, and the wasm heap never
// returns pages, so that peak became the session's floor. Peak is
// now one model's JSON; the string keeps its capacity across models
// so it reallocates only up to the largest one.
std::string json;
const int model_count = g_app->core.streamingModelCount();
for (int model_index = 0; model_index < model_count; ++model_index) {
json.clear();
json += '[';
bool first = true;
g_app->core.visitModelElements(model_index,
[&](const ViewportCore::ElementSlices& e) {
if (!first) json += ',';
first = false;
json += "{\"objectId\":";
json += std::to_string(e.object_id);
json += ",\"model\":";
json += std::to_string(model_index);
json += ",\"sourceId\":";
json += std::to_string(e.source_id);
json += ",\"guid\":";
appendJsonString(json, e.guid, e.guid_len);
json += ",\"name\":";
appendJsonString(json, e.name, e.name_len);
json += ",\"type\":";
appendJsonString(json, e.type, e.type_len);
json += '}';
});
json += ']';
EM_ASM({ if (Module.__ifcvOnObjectsBatch) Module.__ifcvOnObjectsBatch($0, UTF8ToString($1)); },
token, json.c_str());
}
json += ']';
EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, UTF8ToString($1)); },
token, json.c_str());
EM_ASM({ if (Module.__ifcvOnObjectsDone) Module.__ifcvOnObjectsDone($0); }, token);
});
}
@@ -914,6 +950,62 @@ extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_loaded_c() {
return double(loaded_bytes);
}
// ---- Frame stats + GPU residency ------------------------------------------
// The latest FrameStats as doubles, in this order (see FrameStats.h):
// 0 fps, 1 frame_time_ms, 2 total_objects, 3 visible_objects,
// 4 total_triangles, 5 visible_triangles, 6 draw_calls,
// 7 vram_used_bytes, 8 vram_capacity_bytes, 9 vram_budget_bytes,
// 10 chunks_wanted, 11 chunks_wanted_missing, 12 wanted_missing_bytes.
// Device-wide VRAM is not included: there is no query for it on web.
// Returns the number of values written (0 before the first frame).
constexpr int kFrameStatsValues = 13;
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_frame_stats_c(double* out, int capacity) {
if (!g_app || !out || capacity < kFrameStatsValues) return 0;
const FrameStats& s = g_app->host.lastFrameStats();
out[0] = s.fps;
out[1] = s.frame_time_ms;
out[2] = s.total_objects;
out[3] = s.visible_objects;
out[4] = s.total_triangles;
out[5] = s.visible_triangles;
out[6] = s.gl_draw_calls;
out[7] = double(s.vram_used_bytes);
out[8] = double(s.vram_capacity_bytes);
out[9] = double(s.vram_budget_bytes);
out[10] = s.chunks_wanted;
out[11] = s.chunks_wanted_missing;
out[12] = double(s.wanted_missing_bytes);
return kFrameStatsValues;
}
// Per-model GPU residency, keyed by source id like the other per-model
// exports. Unload frees everything the model holds on the GPU while it stays
// in the scene; load brings it back (0 if the device cannot fit its buffers).
// Neither touches visibility.
extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_unload_model_c(int source_id) {
if (!g_app) return;
const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id);
if (session_model_id == 0) return;
g_app->core.unloadModel(session_model_id);
}
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_load_model_c(int source_id) {
if (!g_app) return 0;
const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id);
if (session_model_id == 0) return 0;
return g_app->core.loadModel(session_model_id) ? 1 : 0;
}
extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_unloaded_c(int source_id) {
if (!g_app) return 0;
const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id);
return session_model_id != 0 && g_app->core.isModelUnloaded(session_model_id) ? 1 : 0;
}
extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_model_vram_bytes_c(int source_id) {
if (!g_app) return 0.0;
const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id);
return session_model_id == 0 ? 0.0 : double(g_app->core.modelVramBytes(session_model_id));
}
int main(int /*argc*/, char** /*argv*/) {
Log::info() << "ifcviewer-web: starting";
g_app = new AppState();
+99
View File
@@ -0,0 +1,99 @@
// This file was generated with the assistance of an AI coding tool.
//
// The RGB axis indicator, in both of its guises: the corner gizmo that sits
// in the viewport's bottom-left, and the pivot triad that appears at the
// orbit target while a navigation drag is running. Both are drawn by the
// shared AxisIndicatorRenderer from ViewportCore, so a regression here would
// most likely be a wiring one — the renderer never inited, the pivot gate
// never set, the corner pass encoded before the surface resolved — none of
// which any other test in the suite would notice.
import { test, expect } from '@playwright/test';
import zlib from 'node:zlib';
// Decode the top-left pixel (RGB) of a PNG buffer. Row 0 pixel 0 is
// filter-agnostic — every PNG predictor references zero neighbours there —
// so this can skip filter handling entirely.
function firstPixelRGB(png) {
let off = 8;
const idat = [];
while (off + 8 <= png.length) {
const len = png.readUInt32BE(off);
const type = png.toString('ascii', off + 4, off + 8);
const data = png.subarray(off + 8, off + 8 + len);
if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
off += 12 + len;
}
const raw = zlib.inflateSync(Buffer.concat(idat));
return [raw[1], raw[2], raw[3]]; // skip the row filter byte
}
// Is this pixel on the +Z arm? Its colour is Bonsai's decorator blue
// (0.157, 0.565, 1.000), so blue leads red by a mile. Everything it can be
// drawn over stays well under the threshold: the background is a near-grey
// (32, 35, 41), the sample model is white, and even the dim x-ray pass —
// 0.3 alpha where the arm is behind geometry — lands around (191, 222, 255).
const isAxisBlue = ([r, , b]) => b - r > 30;
// Sample 1x1 pixels straight up from (cx, cy), which is where the +Z arm
// points at the default camera pitch. Stepping rather than picking one exact
// pixel keeps this off the anti-aliased edges of a 2.5 px line.
async function scanUp(page, cx, cy, from, to, step = 4) {
const hits = [];
for (let dy = from; dy <= to; dy += step) {
const png = await page.screenshot({
clip: { x: Math.round(cx), y: Math.round(cy - dy), width: 1, height: 1 },
});
hits.push(firstPixelRGB(png));
}
return hits;
}
async function boot(page) {
await page.goto('/IfcViewerWeb.html');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await page.waitForTimeout(1200);
return page.locator('#viewer-canvas').boundingBox();
}
test('corner axis gizmo draws in the bottom-left', async ({ page }) => {
const box = await boot(page);
// Gizmo box: 110 CSS px square, 10 px in from the bottom-left corner. The
// +Z arm runs up from its centre for ~39 px (arm 1.0 in a 1.4 half-extent
// ortho, over a 55 px half-box).
const cx = box.x + 10 + 55;
const cy = box.y + box.height - 10 - 55;
const hits = await scanUp(page, cx, cy, 10, 34);
expect(
hits.some(isAxisBlue),
`no +Z arm above the gizmo centre — corner axis missing (sampled ${JSON.stringify(hits)})`,
).toBe(true);
});
test('pivot triad shows during an orbit drag and clears on release', async ({ page }) => {
const box = await boot(page);
// The orbit target projects to the viewport centre, and the pivot arms are
// 30 CSS px, so the +Z arm runs up from there.
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const before = await scanUp(page, cx, cy, 8, 26);
expect(before.some(isAxisBlue), 'pivot visible before any drag').toBe(false);
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.mouse.move(cx + 90, cy + 30, { steps: 8 });
await page.waitForTimeout(200);
const during = await scanUp(page, cx, cy, 8, 26);
await page.mouse.up();
expect(
during.some(isAxisBlue),
`no pivot triad mid-drag (sampled ${JSON.stringify(during)})`,
).toBe(true);
// Released without afterglow — the indicator goes on the next frame.
await page.waitForTimeout(400);
const after = await scanUp(page, cx, cy, 8, 26);
expect(after.some(isAxisBlue), 'pivot triad still up after mouse release').toBe(false);
});
@@ -0,0 +1,91 @@
// Regression guard for "GPURenderPassEncoder.setBindGroup: Argument 3 can't be
// an ArrayBuffer or an ArrayBufferView larger than 2 GB".
//
// Emscripten's generated WebGPU shim implements the dynamic-offset path of
// wgpuRenderPassEncoderSetBindGroup as
//
// pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);
//
// handing WebGPU the persistent view over the *entire* wasm linear memory.
// Browsers validate the byte length of that whole backing buffer rather than
// the (start, length) slice actually read, and reject anything past 2 GB. This
// build allows the heap to grow to 4 GB (ALLOW_MEMORY_GROWTH +
// MAXIMUM_MEMORY=4294967296, because large federations need the room), so on a
// big enough session every dynamic-offset draw throws on every frame for the
// life of the page. The axis gizmo, section gizmo and overlay lines all draw
// with dynamic offsets every frame, so the viewport dies as soon as the heap
// crosses the line. ifcviewer::setBindGroupDynamic (WgpuDynamicOffsets.h)
// copies the handful of offsets into a small Uint32Array instead.
//
// Rather than allocate 2 GB to reproduce, this asserts the invariant that
// actually matters and holds at any heap size: nothing we hand to
// setBindGroup may alias the wasm heap. Run against a build without the fix
// and it fails on the first frame — the observed buffer is the whole heap.
import { test, expect } from '@playwright/test';
// Comfortably above the 4 bytes a single dynamic offset needs, and ~5 orders
// of magnitude below INITIAL_MEMORY (256 MB), so this cannot pass by accident.
const SANE_MAX_BYTES = 4096;
test('setBindGroup is never handed the wasm heap as dynamic offsets', async ({ page }) => {
// Must be installed before the module boots so no frame is missed.
await page.addInitScript(() => {
const probe = { dynamicCalls: 0, maxBufferBytes: 0, samples: [] };
window.__bindGroupProbe = probe;
const proto = GPURenderPassEncoder.prototype;
const original = proto.setBindGroup;
proto.setBindGroup = function (index, group, data, ...rest) {
if (ArrayBuffer.isView(data)) {
probe.dynamicCalls++;
const bytes = data.buffer.byteLength;
if (bytes > probe.maxBufferBytes) probe.maxBufferBytes = bytes;
if (probe.samples.length < 5) {
probe.samples.push({ bytes, elements: data.length, ctor: data.constructor.name });
}
}
return original.call(this, index, group, data, ...rest);
};
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/IfcViewerWeb.html');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await page.waitForTimeout(1200);
// The corner gizmo draws every frame on its own; an orbit drag additionally
// brings up the pivot triad, which is the other pair of axis call sites.
const box = await page.locator('#viewer-canvas').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2 + 30, { steps: 8 });
await page.waitForTimeout(300);
await page.mouse.up();
await page.waitForTimeout(300);
const result = await page.evaluate(() => ({
...window.__bindGroupProbe,
heapBytes: window.Module.HEAPU32.buffer.byteLength,
}));
console.log('BINDGROUP ' + JSON.stringify(result));
// Without this the assertion below would pass vacuously on a build where
// nothing draws with dynamic offsets at all.
expect(
result.dynamicCalls,
'no dynamic-offset setBindGroup calls were observed — the gizmos did not draw, ' +
'so this test proved nothing',
).toBeGreaterThan(0);
expect(
result.maxBufferBytes,
`setBindGroup received a ${result.maxBufferBytes}-byte backing buffer; the wasm heap ` +
`is ${result.heapBytes} bytes. A match means the whole-heap HEAPU32 view is being ` +
`passed straight through, which throws once the heap passes 2 GB. ` +
`Samples: ${JSON.stringify(result.samples)}`,
).toBeLessThanOrEqual(SANE_MAX_BYTES);
expect(errors, `page errors during the run: ${errors.join(' | ')}`).toHaveLength(0);
});
+115
View File
@@ -0,0 +1,115 @@
import { test, expect } from '@playwright/test';
// GPU memory as the host page sees it: the per-frame stats (cache occupancy,
// working set) and the per-model unload/load lever. Mirrors what
// BonsaiViewer's status bar and Models panel show on desktop.
async function open(page) {
const errors = [];
page.on('console', (msg) => {
const t = msg.text();
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t);
});
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
return errors;
}
// Add the sample as a real source (the embedded one has no source id) and
// wait until every chunk is resident.
async function addSampleAndSettle(page) {
const sid = await page.evaluate(async () => {
const v = window.viewer;
const loaded = new Promise((resolve) => {
const off = v.onModelLoaded((d) => { off(); resolve(d); });
});
const sid = await v.addUrl('/sample.ifcview', { replace: true, name: 'mem' });
await loaded;
return sid;
});
await settled(page);
return sid;
}
async function settled(page) {
await page.waitForFunction(() => {
const v = window.viewer;
if (!v.modelCount()) return false;
for (let i = 0; i < v.modelCount(); ++i) {
const p = v.modelProgress(i);
if (!(p.total > 0 && p.resident === p.total)) return false;
}
return true;
}, null, { timeout: 30_000 });
}
test('stats() reports the frame, the cache and the working set', async ({ page }) => {
const errors = await open(page);
await addSampleAndSettle(page);
await page.waitForTimeout(300);
const s = await page.evaluate(() => window.viewer.stats());
expect(s).not.toBeNull();
expect(s.fps).toBeGreaterThan(0);
expect(s.frameTimeMs).toBeGreaterThan(0);
expect(s.objects.total).toBeGreaterThan(0);
expect(s.triangles.total).toBeGreaterThan(0);
// Resident geometry occupies the cache, within its capacity, and on web
// the cache is bounded from the start (the wasm heap cap).
expect(s.vram.usedBytes).toBeGreaterThan(0);
expect(s.vram.usedBytes).toBeLessThanOrEqual(s.vram.capacityBytes);
expect(s.vram.budgetBytes).toBeGreaterThan(0);
// Everything the camera wants is resident once settled.
expect(s.workingSet.chunks).toBeGreaterThan(0);
expect(s.workingSet.chunksMissing).toBe(0);
expect(s.workingSet.missingBytes).toBe(0);
expect(errors).toEqual([]);
});
test('unloadModel frees the model\'s GPU memory and loadModel streams it back', async ({ page }) => {
const errors = await open(page);
const sid = await addSampleAndSettle(page);
const before = await page.evaluate((sid) => ({
unloaded: window.viewer.modelUnloaded(sid),
bytes: window.viewer.modelVramBytes(sid),
used: window.viewer.stats().vram.usedBytes,
}), sid);
expect(before.unloaded).toBe(false);
expect(before.bytes).toBeGreaterThan(0);
// Unload: the model's bytes go to zero immediately, and it stays listed.
const after = await page.evaluate((sid) => {
const v = window.viewer;
v.unloadModel(sid);
return {
unloaded: v.modelUnloaded(sid),
bytes: v.modelVramBytes(sid),
modelCount: v.modelCount(),
};
}, sid);
expect(after.unloaded).toBe(true);
expect(after.bytes).toBe(0);
expect(after.modelCount).toBe(1);
// The cache reflects the release on the next frame.
await page.waitForFunction((used) => {
const s = window.viewer.stats();
return s && s.vram.usedBytes < used;
}, before.used, { timeout: 10_000 });
// Load: the buffers come back and the chunks stream in again.
const reloaded = await page.evaluate((sid) => window.viewer.loadModel(sid), sid);
expect(reloaded).toBe(true);
expect(await page.evaluate((sid) => window.viewer.modelUnloaded(sid), sid)).toBe(false);
await settled(page);
const restored = await page.evaluate((sid) => window.viewer.modelVramBytes(sid), sid);
expect(restored).toBe(before.bytes);
expect(errors).toEqual([]);
});
@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test';
// Which file did this object come from? Every host page answers that by taking
// the `model` index the viewer reports and looking it up in its own list of
// models, in the order it added them — the mapping the API documents. The
// index is only worth anything if it survives federated models finishing their
// loads out of order, which is exactly what happens over a real network.
//
// The two georef fixtures carry fixed GUIDs, so an object can be attributed to
// its file here without trusting the very index under test.
const GUIDS = {
'georef-a': ['13r0IXtWf5pf18Q1EGzHXl', '22CLYZYiz8ZhbpLaYDVIu6'],
'georef-b': ['3DkP2KRu5AIRxhhAz$DcQH', '2ueyz_jIr2QgMKs4v0fWl2'],
};
test('model index follows add order when the first model loads last', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
// georef-a is added first but served slowly, so every one of its range reads
// lands after georef-b's. Without a stable ordering the core hands out its
// load-order slots in completion order and the two models come back swapped.
const sourceIds = await page.evaluate(async () => {
const a = await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
const b = await window.viewer.addUrl('/georef-b.ifcview');
return [a, b];
});
expect(sourceIds[0]).toBeLessThan(sourceIds[1]);
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
const objects = await page.evaluate(() => window.viewer.getObjects());
const rowFor = (guid) => objects.find((o) => o.guid === guid) || {};
for (const guid of GUIDS['georef-a']) {
expect(rowFor(guid).model, `${guid} belongs to georef-a, added first`).toBe(0);
expect(rowFor(guid).sourceId, `${guid} came from georef-a's source`).toBe(sourceIds[0]);
}
for (const guid of GUIDS['georef-b']) {
expect(rowFor(guid).model, `${guid} belongs to georef-b, added second`).toBe(1);
expect(rowFor(guid).sourceId, `${guid} came from georef-b's source`).toBe(sourceIds[1]);
}
expect(errors, errors.join('\n')).toEqual([]);
});
test('a pick reports the source the model was added from', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
await page.evaluate(async () => {
await window.viewer.addUrl('/georef-a.ifcview?delay=120', { replace: true });
await window.viewer.addUrl('/georef-b.ifcview');
});
await page.waitForFunction(() => window.viewer.modelCount() === 2, null, { timeout: 30_000 });
// The pick payload is built from the element table, so make sure it is
// resident and take the same table to check the answer against.
const objects = await page.evaluate(() => window.viewer.getObjects());
await page.evaluate(() => window.viewer.viewAll());
await page.waitForTimeout(800);
// Whichever box the click lands on is fine — what is under test is that the
// pick and the object table agree about which file the object came from.
await page.evaluate(() => {
window.__pick = new Promise((resolve) => window.viewer.onSelect(resolve));
});
const box = await page.locator('#viewer-canvas').boundingBox();
// Web preset: RMB selects (LMB orbits).
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' });
const detail = await page.evaluate(() => window.__pick);
expect(detail.guid, 'click hit empty space').toBeTruthy();
const row = objects.find((o) => o.guid === detail.guid);
expect(row, 'picked a GUID that is not in the object table').toBeTruthy();
expect(detail.sourceId, 'pick and object table disagree on the source').toBe(row.sourceId);
expect(detail.modelIndex).toBe(row.model);
expect(detail.sourceId).not.toBeNull();
expect(errors, errors.join('\n')).toEqual([]);
});
+107
View File
@@ -0,0 +1,107 @@
import { test, expect } from '@playwright/test';
// The OPFS model cache behind addUrl(url, {cache: true}): the first load
// streams over HTTP Range and fills a local copy from those same reads; a
// reload of the page then loads the model with zero geometry traffic. One
// browser context spans both loads — OPFS is origin storage, so it survives
// page reloads within the context.
function watchRequests(page, counters) {
page.on('request', (req) => {
if (!req.url().includes('sample.ifcview')) return;
if (req.method() === 'HEAD') counters.head++;
else if (req.headers()['range']) counters.range++;
else counters.other++;
});
}
async function openScripting(page, errors) {
page.on('console', (msg) => {
const t = msg.text();
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t);
});
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
await page.goto('/scripting.html');
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
}
async function addCachedSampleAndSettle(page) {
await page.evaluate(async () => {
const v = window.viewer;
const loaded = new Promise((resolve) => {
const off = v.onModelLoaded((d) => { off(); resolve(d); });
});
await v.addUrl('/sample.ifcview', { replace: true, cache: true, name: 'cached' });
await loaded;
});
await page.waitForFunction(() => {
const v = window.viewer;
if (!v.modelCount()) return false;
const p = v.modelProgress(0);
return p.total > 0 && p.resident === p.total;
}, null, { timeout: 30_000 });
// The element table is read through the same source — pull it so its
// ranges land in the cache too, then let the write chain drain.
await page.evaluate(() => window.viewer.getObjects());
await page.waitForFunction(async () => {
const info = await window.viewer.cacheInfo();
const e = info.entries.find((x) => x.url.endsWith('/sample.ifcview'));
return !!(e && e.complete);
}, null, { timeout: 30_000 });
}
test('first load fills the cache from its own reads; a reload streams nothing', async ({ page }) => {
const errors = [];
const first = { head: 0, range: 0, other: 0 };
watchRequests(page, first);
await openScripting(page, errors);
await page.evaluate(() => window.viewer.clearCache());
await addCachedSampleAndSettle(page);
expect(first.range, 'first visit must stream over HTTP Range').toBeGreaterThan(0);
const info = await page.evaluate(() => window.viewer.cacheInfo());
const entry = info.entries.find((x) => x.url.endsWith('/sample.ifcview'));
expect(entry.complete).toBe(true);
expect(entry.cachedBytes).toBe(entry.size);
// Second visit: same context, fresh page. Only the HEAD validation may
// touch the network — every byte of geometry and metadata comes from OPFS.
await page.reload();
const second = { head: 0, range: 0, other: 0 };
watchRequests(page, second);
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
await addCachedSampleAndSettle(page);
expect(second.range, 'a complete validated copy must stream zero ranges').toBe(0);
expect(second.other, 'and never download the file whole').toBe(0);
expect(second.head).toBeGreaterThan(0);
// The cached model is actually usable: objects enumerate with GUIDs.
const objects = await page.evaluate(() => window.viewer.getObjects());
expect(objects.length).toBeGreaterThan(0);
expect(objects.some((o) => o.guid)).toBe(true);
expect(errors).toEqual([]);
});
test('clearCache drops the entry and the next load streams again', async ({ page }) => {
const errors = [];
await openScripting(page, errors);
await addCachedSampleAndSettle(page);
const cleared = await page.evaluate(() => window.viewer.clearCache('/sample.ifcview'));
expect(cleared).toBe(1);
const info = await page.evaluate(() => window.viewer.cacheInfo());
expect(info.entries.find((x) => x.url.endsWith('/sample.ifcview'))).toBeUndefined();
await page.reload();
const counters = { head: 0, range: 0, other: 0 };
watchRequests(page, counters);
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
{ timeout: 30_000 });
await addCachedSampleAndSettle(page);
expect(counters.range).toBeGreaterThan(0);
expect(errors).toEqual([]);
});
+13 -1
View File
@@ -6,6 +6,7 @@
// Serve dir resolution: $WEB_BUILD_DIR if set, else the repo's build-web.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -31,6 +32,12 @@ http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://localhost:${PORT}`);
let p = decodeURIComponent(url.pathname);
// ?delay=<ms> stalls every response for this URL, HEAD and Range alike.
// Load order across federated models is decided by whichever model's
// async read chain finishes first, so a test that wants a specific
// interleaving has to be able to make one source slower than another.
const delay = Number(url.searchParams.get('delay') || 0);
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
if (p === '/') p = '/IfcViewerWeb.html';
const inRoot = path.join(ROOT, p);
const inSrc = path.join(SRC, p);
@@ -40,6 +47,9 @@ http.createServer(async (req, res) => {
try { body = await readFile(inRoot); }
catch { body = await readFile(inSrc); } // fall back to the source dir
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
// A strong ETag from the content, so the OPFS cache spec can exercise
// validation exactly the way a real Accept-Ranges host would offer it.
const etag = '"' + createHash('sha1').update(body).digest('hex').slice(0, 16) + '"';
// HEAD: headers only — lets the remote backend resolve total size.
if (req.method === 'HEAD') {
@@ -47,6 +57,7 @@ http.createServer(async (req, res) => {
'Content-Type': ctype,
'Content-Length': body.length,
'Accept-Ranges': 'bytes',
'ETag': etag,
});
res.end();
return;
@@ -68,12 +79,13 @@ http.createServer(async (req, res) => {
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Accept-Ranges': 'bytes',
'Content-Length': slice.length,
'ETag': etag,
});
res.end(slice);
return;
}
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes' });
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes', 'ETag': etag });
res.end(body);
} catch {
res.writeHead(404).end('not found');
+4 -2
View File
@@ -12,8 +12,10 @@
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
/* Log overlay sits bottom-left and never eats pointer events. */
#status { position: fixed; bottom: 8px; left: 12px;
/* Log overlay sits bottom-left and never eats pointer events. Kept clear
of the corner axis gizmo, which the viewport draws in the bottom-left
110 CSS px (plus a 10 px margin). */
#status { position: fixed; bottom: 8px; left: 132px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
+65 -8
View File
@@ -42,6 +42,10 @@
ul#model-list .name b { font-weight: 600; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
ul#model-list .pct { color: #8a93a6; flex: 0 0 auto; }
ul#model-list .mem { color: #8a93a6; font-size: 11px; margin-left: 8px; flex: 0 0 auto; }
ul#model-list .mem button { font-size: 11px; padding: 1px 6px; margin-left: 6px; }
ul#model-list li.unloaded b { font-style: italic; color: #6f7988; }
#gpu-memory.full { color: #e0a040; }
.bar { height: 4px; margin-top: 5px; border-radius: 2px; background: #232833; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: #3182ce; }
.empty { color: #6f7988; font-size: 12px; padding: 4px 0; }
@@ -92,6 +96,7 @@
<div class="card">
<h2>Models in scene</h2>
<ul id="model-list"><li class="empty">No models loaded.</li></ul>
<div class="hint" id="gpu-memory">GPU memory: —</div>
</div>
<div class="card">
@@ -124,16 +129,61 @@
listEl.innerHTML = '';
models.forEach(function (m, i) {
var li = document.createElement('li');
if (m.unloaded) li.className = 'unloaded';
var pct = m.total > 0 ? Math.round(100 * m.resident / m.total) : 0;
var label = m.total > 0 ? pct + '%' : '…';
var label = m.unloaded ? 'unloaded' : m.total > 0 ? pct + '%' : '…';
var mem = m.unloaded ? '' : Math.round(m.vram / (1024 * 1024)) + ' MB';
li.innerHTML =
'<div class="name"><b title="' + m.name + '">' + m.name + '</b>' +
'<span class="mem">' + mem + '<button data-i="' + i + '">' +
(m.unloaded ? 'Load' : 'Unload') + '</button></span>' +
'<span class="pct">' + label + '</span></div>' +
'<div class="bar"><i style="width:' + pct + '%"></i></div>';
'<div class="bar"><i style="width:' + (m.unloaded ? 0 : pct) + '%"></i></div>';
listEl.appendChild(li);
});
}
// Unload frees a model's GPU memory while it stays in the scene — the lever
// when the GPU memory line reports chunks that cannot be loaded.
listEl.addEventListener('click', function (ev) {
var btn = ev.target.closest('button[data-i]');
if (!btn || !activeViewer) return;
var m = models[+btn.dataset.i];
if (!m || m.sid === undefined) return;
if (m.unloaded) {
if (!activeViewer.loadModel(m.sid)) { hintEl.textContent = 'Not enough GPU memory to load ' + m.name; return; }
} else {
activeViewer.unloadModel(m.sid);
}
m.unloaded = activeViewer.modelUnloaded(m.sid);
renderList();
});
var gpuMemEl = document.getElementById('gpu-memory');
var shortfallSince = 0;
var activeViewer = null; // set once IfcViewer.create resolves
function renderGpuMemory(viewer) {
var s = viewer.stats();
if (!s) return;
var mb = function (b) { return Math.round(b / (1024 * 1024)); };
var text = 'GPU memory: ' + mb(s.vram.usedBytes) + ' / ' + mb(s.vram.capacityBytes) + ' MB';
if (s.vram.budgetBytes && s.vram.budgetBytes !== s.vram.capacityBytes) {
text += ' (budget ' + mb(s.vram.budgetBytes) + ')';
}
// A few missing chunks right after a camera move are normal; a shortfall
// that persists means the view does not fit — say so.
var now = performance.now();
if (!s.workingSet.chunksMissing) shortfallSince = 0;
else if (!shortfallSince) shortfallSince = now;
var full = shortfallSince && now - shortfallSince > 3000;
if (full) {
text += ' — full: ' + s.workingSet.chunksMissing + ' of ' + s.workingSet.chunks +
' visible chunks (' + mb(s.workingSet.missingBytes) + ' MB) not loaded. Unload a model to make room.';
}
if (gpuMemEl.textContent !== text) gpuMemEl.textContent = text;
gpuMemEl.classList.toggle('full', !!full);
}
function setSelection(guid, modelName) {
selModelEl.textContent = modelName || '—';
if (guid) { selGuidEl.textContent = guid; selGuidEl.classList.remove('none'); }
@@ -154,16 +204,20 @@
// Keep clearing until it's gone; stop once the user adds their own model.
if (!userAddedAny && viewer.modelCount() > 0) viewer.clearScene();
if (!models.length) return;
renderGpuMemory(viewer);
var changed = false;
for (var i = 0; i < models.length; i++) {
var p = viewer.modelProgress(i);
if (p.resident !== models[i].resident || p.total !== models[i].total) {
models[i].resident = p.resident; models[i].total = p.total; changed = true;
var vram = models[i].sid !== undefined ? viewer.modelVramBytes(models[i].sid) : 0;
if (p.resident !== models[i].resident || p.total !== models[i].total
|| Math.round(vram / (1024 * 1024)) !== Math.round(models[i].vram / (1024 * 1024))) {
models[i].resident = p.resident; models[i].total = p.total; models[i].vram = vram; changed = true;
}
}
if (changed) renderList();
},
}).then(function (viewer) {
activeViewer = viewer;
// Report the picked object's model + IFC GlobalId in our own DOM (empty on
// deselect). sel.modelIndex indexes our JS model list (load order).
viewer.onSelect(function (sel) {
@@ -177,7 +231,10 @@
var urlInput = document.getElementById('url-input');
var urlBtn = document.getElementById('url-btn');
function addModelEntry(name) { models.push({ name: name, resident: 0, total: 0 }); renderList(); }
function addModelEntry(name, sid) {
models.push({ name: name, sid: sid, resident: 0, total: 0, vram: 0, unloaded: false });
renderList();
}
viewer.ready.then(function () {
hintEl.textContent = 'Ready — add a .ifcview model.';
@@ -188,7 +245,7 @@
fileInput.addEventListener('change', function (ev) {
if (ev.target.files.length) userAddedAny = true;
Array.prototype.forEach.call(ev.target.files, function (file) {
viewer.addFile(file).then(function () { addModelEntry(file.name); });
viewer.addFile(file).then(function (sid) { addModelEntry(file.name, sid); });
});
fileInput.value = '';
});
@@ -198,8 +255,8 @@
if (!url) return;
userAddedAny = true;
urlBtn.disabled = true;
viewer.addUrl(url).then(function () {
addModelEntry(url.split('/').pop() || url);
viewer.addUrl(url).then(function (sid) {
addModelEntry(url.split('/').pop() || url, sid);
urlInput.value = '';
}).catch(function (e) {
hintEl.textContent = 'URL load failed: ' + e.message;
+487 -18
View File
@@ -11,7 +11,7 @@
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
//
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model}]
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model, sourceId}]
// viewer.setSelection(['3vB2YO$MX4xv5uCqZZG05x']);
// viewer.setColor(objects.filter(o => o.type === 'IfcWall'), '#ff8800');
// viewer.setCamera({ yaw: 45, pitch: 30 });
@@ -21,6 +21,14 @@
// The canvas element MUST have id="viewer-canvas" — the wasm side hard-codes
// that selector for its WebGPU surface and input handlers.
//
// Model identity. addFile/addUrl return a source id: the handle for that model,
// minted the moment it is registered and stable for the session. Objects come
// back tagged with both their `sourceId` and a `model` index (the model's slot
// in load order). Map an object to the file it came from through the source id
// — the index is a POSITION, so it shifts down if an earlier model fails to
// load, and a host keying its own list off it then attributes objects to the
// wrong file.
//
// Object identity. Everything the scripting API takes or returns is keyed by
// `objectId`: a u32 the renderer assigns, unique across the federation but only
// meaningful for this session. IFC GlobalIds are the stable identity, and every
@@ -43,6 +51,349 @@
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
}
// ---- OPFS model cache ------------------------------------------------------
//
// addUrl(url, {cache: true}) keeps a local copy of the sidecar in the
// Origin Private File System, filled FROM THE VIEWER'S OWN RANGED READS —
// no second download, and only the bytes the camera actually needed.
// (Browsers do not populate their HTTP cache from ranged fetches: measured
// 0 of 78 range requests served from cache even with a strong ETag.)
//
// Entries are keyed by a hash of the URL and validated by ETag (falling
// back to Last-Modified + size); a byte-span ledger records which ranges
// are really on disk, so a partial copy is never mistaken for a whole one —
// a read is served locally only when its span is fully covered. On the next
// visit a complete validated copy loads with zero geometry traffic; if the
// server is unreachable, the newest complete copy is used as-is (offline).
//
// Writes go through a dedicated worker holding a FileSystemSyncAccessHandle:
// positional writes with no copy-on-open (createWritable({keepExistingData})
// copies the whole existing file into a swap file per open — quadratic as
// the cache fills), and the handle's exclusive lock makes a second tab fall
// back to plain network instead of corrupting the entry. Browsers without
// sync access handles just never cache — behaviour is exactly as without
// the flag.
const CACHE_DIR = 'ifcviewer-cache';
const cacheWorkerSource = `
const handles = new Map();
onmessage = async (e) => {
const { id, op, name, pos, data, len } = e.data;
const reply = (msg, transfer) => postMessage(Object.assign({ id }, msg), transfer || []);
try {
if (op === 'open') {
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle('${CACHE_DIR}', { create: true });
const fh = await dir.getFileHandle(name, { create: true });
handles.set(name, await fh.createSyncAccessHandle());
reply({ ok: true, size: handles.get(name).getSize() });
} else if (op === 'write') {
handles.get(name).write(new Uint8Array(data), { at: pos });
reply({ ok: true });
} else if (op === 'read') {
const buf = new Uint8Array(len);
const n = handles.get(name).read(buf, { at: pos });
reply({ ok: n === len, data: buf.buffer }, [buf.buffer]);
} else if (op === 'close') {
const h = handles.get(name);
if (h) { h.flush(); h.close(); handles.delete(name); }
reply({ ok: true });
} else {
reply({ ok: false, error: 'unknown op ' + op });
}
} catch (err) {
reply({ ok: false, error: String((err && err.message) || err) });
}
};
`;
let cacheWorker = null; // lazily created; false once known unusable
let cacheMsgId = 0;
const cachePending = new Map();
function cacheCall(op, name, extra, transfer) {
if (cacheWorker === false) return Promise.reject(new Error('no cache worker'));
if (!cacheWorker) {
try {
cacheWorker = new Worker(URL.createObjectURL(
new Blob([cacheWorkerSource], { type: 'text/javascript' })));
cacheWorker.onmessage = (e) => {
const pending = cachePending.get(e.data.id);
if (!pending) return;
cachePending.delete(e.data.id);
if (e.data.ok) pending.resolve(e.data);
else pending.reject(new Error(e.data.error || (op + ' failed')));
};
} catch (err) {
cacheWorker = false;
return Promise.reject(err);
}
}
const id = ++cacheMsgId;
return new Promise((resolve, reject) => {
cachePending.set(id, { resolve: resolve, reject: reject });
cacheWorker.postMessage(Object.assign({ id: id, op: op, name: name }, extra || {}),
transfer || []);
});
}
async function cacheDirHandle(create) {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle(CACHE_DIR, { create: !!create });
}
let persistAsked = false;
async function openCacheDir() {
try {
const dir = await cacheDirHandle(true);
// Without this a large cache is "best effort" and the browser may drop
// it under disk pressure — invisibly, looking like the site being slow
// again on the next visit.
if (!persistAsked && navigator.storage.persist) {
persistAsked = true;
navigator.storage.persist().catch(() => {});
}
return dir;
} catch (err) {
return null;
}
}
async function cacheEntryName(url) {
const bytes = new TextEncoder().encode(url);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest).slice(0, 16))
.map((b) => b.toString(16).padStart(2, '0')).join('');
}
// Sorted, merged, half-open [start, end) byte spans.
function spansAdd(spans, start, end) {
const out = [];
let s0 = start, e0 = end;
for (const [a, b] of spans) {
if (b < s0 || a > e0) out.push([a, b]);
else { s0 = Math.min(s0, a); e0 = Math.max(e0, b); }
}
out.push([s0, e0]);
out.sort((x, y) => x[0] - y[0]);
return out;
}
const spansCover = (spans, start, end) =>
spans.some(([a, b]) => a <= start && b >= end);
const spansBytes = (spans) => spans.reduce((sum, [a, b]) => sum + (b - a), 0);
async function readCacheMeta(dir, name) {
try {
const fh = await dir.getFileHandle(name + '.meta');
return JSON.parse(await (await fh.getFile()).text());
} catch (err) {
return null;
}
}
// The meta file is tiny, so main-thread createWritable is fine here; only
// the tab holding the data file's exclusive lock ever writes it.
async function writeCacheMeta(dir, name, meta) {
const fh = await dir.getFileHandle(name + '.meta', { create: true });
const w = await fh.createWritable();
await w.write(JSON.stringify(meta));
await w.close();
}
async function removeCacheEntry(dir, name) {
await dir.removeEntry(name).catch(() => {});
await dir.removeEntry(name + '.meta').catch(() => {});
}
async function headValidators(url) {
try {
const res = await fetch(url, { method: 'HEAD' });
if (!res.ok) return null;
return {
etag: res.headers.get('ETag') || null,
lastModified: res.headers.get('Last-Modified') || null,
size: parseInt(res.headers.get('Content-Length') || '0', 10) || 0,
};
} catch (err) {
return null; // offline, or CORS refused HEAD
}
}
function validatorsMatch(meta, head) {
if (meta.etag && head.etag) return meta.etag === head.etag;
if (meta.lastModified && head.lastModified) {
return meta.lastModified === head.lastModified && meta.size === head.size;
}
return false;
}
// A Blob-shaped source (`size` + `slice(a, b).arrayBuffer()`) backed by the
// OPFS entry, filling from every ranged read that goes through it. The
// wasm's read shim only ever calls those two members, so this passes for
// the File it would get from a picked file.
function fillingCacheSource(dir, name, url, meta) {
let spans = meta.spans.slice();
let dirtySince = 0; // bytes written since the ledger was persisted
let broken = false; // a write failed (quota?): serve network, stop filling
let complete = spansCover(spans, 0, meta.size);
let writeChain = Promise.resolve();
let lastForegroundRead = 0; // performance.now() of the viewer's last read
const persistLedger = () => {
dirtySince = 0;
return writeCacheMeta(dir, name, Object.assign({}, meta, { spans: spans }))
.catch(() => {});
};
const finishIfComplete = () => {
if (complete || !spansCover(spans, 0, meta.size)) return;
complete = true;
// Flush + release the lock; from here reads come off the closed file.
writeChain = writeChain
.then(() => cacheCall('close', name))
.then(persistLedger)
.catch(() => {});
};
const storeBytes = (start, stop, buf) => {
if (broken || complete || buf.byteLength !== stop - start) return;
const copy = buf.slice(0);
writeChain = writeChain
.then(() => cacheCall('write', name, { pos: start, data: copy }, [copy]))
.then(() => {
spans = spansAdd(spans, start, stop);
dirtySince += stop - start;
// Persist the ledger periodically — bytes on disk that the
// ledger does not record are merely re-fetched next visit.
if (dirtySince >= (4 << 20)) return persistLedger();
})
.then(finishIfComplete)
.catch(() => { broken = true; });
};
// Background completion: streaming only reads what the camera needs, so
// left alone the cache converges on the *viewed* bytes, not the file —
// and a user cannot be expected to orbit every model into view to
// finish it. Once the viewer has been quiet for a moment, fetch the
// uncovered spans in order, one modest range at a time, yielding
// whenever real reads resume so interactive streaming always wins.
const IDLE_MS = 1500, STEP_BYTES = 8 << 20;
let backgroundDone = false;
async function backgroundFill() {
while (!complete && !broken && !backgroundDone) {
if (performance.now() - lastForegroundRead < IDLE_MS) {
await new Promise((r) => setTimeout(r, IDLE_MS));
continue;
}
// First gap not yet covered.
let at = 0;
for (const [a, b] of spans) { if (a > at) break; at = Math.max(at, b); }
if (at >= meta.size) { finishIfComplete(); return; }
let stop = Math.min(at + STEP_BYTES, meta.size);
for (const [a] of spans) { if (a > at) { stop = Math.min(stop, a); break; } }
try {
const res = await fetch(url, {
headers: { Range: 'bytes=' + at + '-' + (stop - 1) },
});
if (res.status !== 206 && res.status !== 200) return; // server changed its mind
let buf = await res.arrayBuffer();
if (res.status === 200 && buf.byteLength > stop - at) buf = buf.slice(at, stop);
storeBytes(at, stop, buf);
await writeChain;
} catch (err) {
return; // offline etc: the foreground path is affected too, stop quietly
}
}
}
setTimeout(backgroundFill, IDLE_MS);
return {
size: meta.size,
stopBackgroundFill() { backgroundDone = true; },
slice(start, end) {
const stop = Math.min(end, meta.size);
return {
arrayBuffer: async () => {
lastForegroundRead = performance.now();
if (spansCover(spans, start, stop)) {
if (complete) {
const fh = await dir.getFileHandle(name);
return (await fh.getFile()).slice(start, stop).arrayBuffer();
}
// Serialise behind the writes so a just-written range is
// readable (sync-handle writes are visible to the same handle
// immediately; ordering through the chain keeps it simple).
return (writeChain = writeChain.then(() =>
cacheCall('read', name, { pos: start, len: stop - start })
)).then((r) => r.data);
}
const res = await fetch(url, {
headers: { Range: 'bytes=' + start + '-' + (stop - 1) },
});
if (res.status !== 206 && res.status !== 200) {
throw new Error('range fetch failed: ' + res.status);
}
let buf = await res.arrayBuffer();
if (res.status === 200 && buf.byteLength > stop - start) {
buf = buf.slice(start, stop);
}
storeBytes(start, stop, buf);
return buf;
},
};
},
};
}
// Decide what to hand the loader for a cached URL:
// {file} — a complete validated local copy (zero network)
// {source} — a Blob-shaped self-filling source
// null — cache unusable here (no OPFS / no validators / second tab):
// caller falls back to the plain URL path.
async function cachedUrlSource(url) {
if (!(crypto && crypto.subtle) || !(navigator.storage && navigator.storage.getDirectory)) {
return null;
}
const head = await headValidators(url);
const dir = await openCacheDir();
if (!dir) return null;
const name = await cacheEntryName(url);
const meta = await readCacheMeta(dir, name);
const completeLocal = async (m) => {
const fh = await dir.getFileHandle(name);
const file = await fh.getFile();
return file.size === m.size ? { file: file } : null;
};
if (!head) {
// Offline (or HEAD refused): a complete copy is better than nothing —
// this is the offline story. Anything less falls back to the URL path,
// which will fail the same way it always did.
if (meta && spansCover(meta.spans, 0, meta.size)) return completeLocal(meta);
return null;
}
if (!head.etag && !head.lastModified) return null; // nothing to validate by
if (!head.size) return null;
if (meta && validatorsMatch(meta, head)) {
if (spansCover(meta.spans, 0, meta.size)) {
const local = await completeLocal(meta);
if (local) return local;
}
// Partial copy of the still-current file: resume filling it.
} else if (meta) {
await removeCacheEntry(dir, name); // server has a different file now
}
const fresh = (!meta || !validatorsMatch(meta, head))
? { url: url, etag: head.etag, lastModified: head.lastModified,
size: head.size, spans: [] }
: meta;
try {
await cacheCall('open', name); // exclusive: a second tab lands in catch
} catch (err) {
return null;
}
await writeCacheMeta(dir, name, fresh).catch(() => {});
return { source: fillingCacheSource(dir, name, url, fresh) };
}
// Mouse navigation schemes the wasm's classifyPress understands. Named so a
// typo is an error here rather than a silent fall-back to blender in the core.
const NAV_PRESETS = ['blender', 'rhino', 'revit', 'web'];
@@ -197,15 +548,17 @@
// a remote URL (HTTP Range). load_sidecar_from_source_c(sid) streams one.
Module.__ifcvSources = Module.__ifcvSources || [];
// The wasm calls this on every single-object pick; (0, '', -1) means the
// The wasm calls this on every single-object pick; (0, '', -1, -1) means the
// selection was cleared. modelIndex is the picked object's model in load
// order (matches the modelProgress index), or -1. A marquee box-select does
// NOT fire this (it has no single object) — use onSelectionChange for that.
Module.__ifcvOnSelect = function (objectId, guid, modelIndex) {
// order (matches the modelProgress index) and sourceId the source it was
// added from, either null when unknown. A marquee box-select does NOT fire
// this (it has no single object) — use onSelectionChange for that.
Module.__ifcvOnSelect = function (objectId, guid, modelIndex, sourceId) {
const detail = {
objectId: objectId >>> 0,
guid: guid || null,
modelIndex: (typeof modelIndex === 'number' && modelIndex >= 0) ? modelIndex : null,
sourceId: (typeof sourceId === 'number' && sourceId >= 0) ? sourceId : null,
};
selectListeners.forEach(function (cb) {
try { cb(detail); } catch (e) { console.error(e); }
@@ -247,13 +600,22 @@
// Completion side of ifcv_request_objects_c: the element tables have all
// landed and the scene's objects are ready as JSON. `token` matches the
// request to its pending Promise.
// token -> {rows, resolve}. The wasm side streams the objects array one
// model per batch (so the whole-scene JSON never exists in one string on
// its heap); Done resolves with the accumulated rows.
const pendingObjects = new Map();
let objectsToken = 0;
Module.__ifcvOnObjects = function (token, json) {
const resolve = pendingObjects.get(token);
if (!resolve) return;
Module.__ifcvOnObjectsBatch = function (token, json) {
const pending = pendingObjects.get(token);
if (!pending) return;
const rows = JSON.parse(json);
for (let i = 0; i < rows.length; ++i) pending.rows.push(rows[i]);
};
Module.__ifcvOnObjectsDone = function (token) {
const pending = pendingObjects.get(token);
if (!pending) return;
pendingObjects.delete(token);
resolve(JSON.parse(json));
pending.resolve(pending.rows);
};
// Some test harnesses / the fullscreen page want the raw module on window.
@@ -279,8 +641,8 @@
// ---- Events ----------------------------------------------------------
// Single-object picks (click). Fires with {objectId, guid, modelIndex}.
// Returns an unsubscribe function.
// Single-object picks (click). Fires with
// {objectId, guid, modelIndex, sourceId}. Returns an unsubscribe function.
onSelect: function (cb) {
selectListeners.push(cb);
return function () {
@@ -421,15 +783,18 @@
// ---- Objects ---------------------------------------------------------
// Every object in the scene: [{objectId, guid, name, type, model}], where
// `model` is the index into the load-ordered model list (same index as
// modelProgress). Asynchronous — the element tables are fetched lazily per
// model so first paint never waits on them. Resolving this is also what
// lets every other call accept GlobalIds; the result is cached for that.
// Every object in the scene:
// [{objectId, guid, name, type, model, sourceId}], where `model` is the
// index into the load-ordered model list (same index as modelProgress)
// and `sourceId` the source the model was added from — see the model
// identity note at the top of the file. Asynchronous — the element tables
// are fetched lazily per model so first paint never waits on them.
// Resolving this is also what lets every other call accept GlobalIds; the
// result is cached for that.
getObjects: function () {
const token = ++objectsToken;
return new Promise(function (resolve) {
pendingObjects.set(token, resolve);
pendingObjects.set(token, { rows: [], resolve: resolve });
Module._ifcv_request_objects_c(token);
}).then(function (objects) {
objectIndex = new Map();
@@ -517,6 +882,45 @@
};
},
// The latest frame's statistics — what BonsaiViewer's status bar shows.
// `vram` is the streamed-geometry cache: bytes held by resident chunks,
// the pool's current capacity, and the budget it may grow to (0 while
// unbounded). `workingSet` is what the camera wants resident: chunks in
// view and large enough to draw, how many of those are not resident,
// and their size — transiently non-zero after a camera move, and
// persistently non-zero when the scene does not fit in GPU memory
// (unload a model to make room). Null before the first frame.
stats: function () {
const n = 13;
const ptr = Module._malloc(n * 8);
try {
if (!Module._ifcv_get_frame_stats_c(ptr, n)) return null;
const d = Module.HEAPF64.subarray(ptr >>> 3, (ptr >>> 3) + n);
return {
fps: d[0],
frameTimeMs: d[1],
objects: { visible: d[3], total: d[2] },
triangles: { visible: d[5], total: d[4] },
drawCalls: d[6],
vram: { usedBytes: d[7], capacityBytes: d[8], budgetBytes: d[9] },
workingSet: { chunks: d[10], chunksMissing: d[11], missingBytes: d[12] },
};
} finally {
Module._free(ptr);
}
},
// GPU residency per model, by source id. Unloading frees everything
// the model holds on the GPU while it stays in the scene (its
// visibility untouched); loading brings it back, streaming the
// geometry in again on demand. loadModel resolves false when the
// device cannot fit the model's buffers. This is the lever when
// stats().workingSet.chunksMissing stays above zero.
unloadModel: function (sourceId) { Module._ifcv_unload_model_c(sourceId | 0); },
loadModel: function (sourceId) { return Module._ifcv_load_model_c(sourceId | 0) !== 0; },
modelUnloaded: function (sourceId) { return Module._ifcv_model_unloaded_c(sourceId | 0) !== 0; },
modelVramBytes: function (sourceId) { return Module._ifcv_model_vram_bytes_c(sourceId | 0); },
registerFileSource: registerFile,
registerUrlSource: registerUrl,
@@ -532,14 +936,79 @@
Module._load_sidecar_from_source_c(sid);
return sid;
},
// `cache: true` keeps a local OPFS copy filled from the viewer's own
// ranged reads (see the OPFS model cache section above): the next visit
// loads it with zero geometry traffic, and a complete copy still opens
// when the server is unreachable. Falls back to plain URL streaming
// wherever the cache cannot help (no OPFS, no validators from the
// server, another tab already filling this entry).
addUrl: async function (url, o) {
if (o && o.replace) this.clearScene();
const sid = await registerUrl(url);
let sid = null;
if (o && o.cache) {
const cached = await cachedUrlSource(url).catch(() => null);
if (cached) {
const src = cached.file || cached.source;
sid = Module.__ifcvSources.length;
Module.__ifcvSources.push({ file: src, url: null, size: src.size });
}
}
if (sid === null) sid = await registerUrl(url);
if (o && o.name) this.setModelName(sid, o.name);
Module._load_sidecar_from_source_c(sid);
return sid;
},
// What the OPFS cache holds: [{url, size, cachedBytes, complete}] plus
// the browser's storage estimate. Entries whose ledger has not caught
// up with the last few reads under-report slightly; nothing over-reports.
cacheInfo: async function () {
const out = { entries: [], estimate: null };
try {
const dir = await cacheDirHandle(false);
for await (const key of dir.keys()) {
if (!key.endsWith('.meta')) continue;
try {
const meta = JSON.parse(await (await
(await dir.getFileHandle(key)).getFile()).text());
out.entries.push({
url: meta.url,
size: meta.size,
cachedBytes: spansBytes(meta.spans),
complete: spansCover(meta.spans, 0, meta.size),
});
} catch (err) { /* torn meta: skip */ }
}
} catch (err) { /* no cache dir yet */ }
if (navigator.storage && navigator.storage.estimate) {
out.estimate = await navigator.storage.estimate().catch(() => null);
}
return out;
},
// Drop cached models — one URL, or everything. Sources already handed
// to the viewer keep working (open handles and Files stay readable);
// the next visit simply streams from the network again.
clearCache: async function (url) {
try {
const dir = await cacheDirHandle(false);
const only = url ? await cacheEntryName(url) : null;
const names = [];
for await (const key of dir.keys()) {
const base = key.endsWith('.meta') ? key.slice(0, -5) : key;
if (!only || base === only) names.push(key);
}
let n = 0;
for (const key of names) {
await dir.removeEntry(key).catch(() => {});
if (!key.endsWith('.meta')) n++;
}
return n;
} catch (err) {
return 0;
}
},
// ---- Federation ------------------------------------------------------
//
// The concepts an .ifcfed file carries, without the file format: a
+424
View File
@@ -0,0 +1,424 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "AxisIndicatorRenderer.h"
#include "WgpuDynamicOffsets.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include "CameraMath.h"
namespace {
constexpr uint32_t kAxisUniformSlot = 256; // dynamic-offset slot stride
constexpr uint32_t kAxisVertexCount = 18; // 3 arms x 2 triangles x 3 verts
// Uniform slots in the shared buffer.
constexpr uint32_t kSlotCorner = 0;
constexpr uint32_t kSlotPivot = 1;
constexpr uint32_t kSlotPivotXray = 2;
WGPUStringView svFromCStr(const char* s) {
WGPUStringView v;
v.data = s;
v.length = s ? std::strlen(s) : 0;
return v;
}
// Thick-line rendering helper (shared shape with the other overlays) + the
// axis vertex shader. Each arm is expanded to a screen-space-thick,
// anti-aliased quad.
static const std::string AXIS_WGSL = std::string(R"WGSL(
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn thick_line_clip(p_start: vec4<f32>, p_end: vec4<f32>,
t: f32, side: f32,
viewport_size: vec2<f32>,
line_width_px: f32) -> vec4<f32> {
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / viewport_size;
return vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
struct AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
arm: f32,
alpha: f32,
line_width_px: f32,
viewport_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: AxisUniforms;
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col, u.alpha);
out.side_t = side;
return out;
}
)WGSL");
// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
void packAxisUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
float arm, float alpha, float line_width_px,
float viewport_w, float viewport_h) {
std::memset(dst, 0, kAxisUniformSlot);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
float ox = origin.x(), oy = origin.y(), oz = origin.z();
std::memcpy(dst + 64, &ox, sizeof(float));
std::memcpy(dst + 68, &oy, sizeof(float));
std::memcpy(dst + 72, &oz, sizeof(float));
std::memcpy(dst + 76, &arm, sizeof(float));
std::memcpy(dst + 80, &alpha, sizeof(float));
std::memcpy(dst + 84, &line_width_px, sizeof(float));
std::memcpy(dst + 88, &viewport_w, sizeof(float));
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
} // namespace
AxisIndicatorRenderer::~AxisIndicatorRenderer() { destroy(); }
bool AxisIndicatorRenderer::init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count) {
device_ = device;
queue_ = queue;
if (!device_ || !queue_) return false;
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo + marquee so all overlay
// colours come from one canonical source.
static const float axis_verts[] = {
// start end color (RGB — Bonsai decorators) t side
// ---- +X red ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y green ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z blue ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
WGPUBufferDescriptor vb = {};
vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
vb.size = sizeof(axis_verts);
vb.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
WGPUBufferDescriptor ub = {};
ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub.size = 3u * kAxisUniformSlot;
ub.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub);
WGPUBindGroupLayoutEntry ble = {};
ble.binding = 0;
ble.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
ble.buffer.type = WGPUBufferBindingType_Uniform;
ble.buffer.hasDynamicOffset = 1;
ble.buffer.minBindingSize = 96;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &ble;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUBindGroupEntry bge = {};
bge.binding = 0;
bge.buffer = uniform_buffer_;
bge.offset = 0;
bge.size = kAxisUniformSlot;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &bge;
bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
WGPUShaderSourceWGSL wgsl = {};
wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl.code = svFromCStr(AXIS_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Vertex layout: start vec3, end vec3, col vec3, t f32, side f32.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
// Pivot: inside the main MSAA pass, depth-tested against the scene but
// never writing depth. Two passes — LessEqual for the visible part,
// GreaterEqual for the dim x-ray showing through geometry.
auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
WGPURenderPipeline& out) {
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = cmp;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr(label);
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.depthStencil = &depth;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = uint32_t(sample_count);
rp.multisample.mask = 0xFFFFFFFFu;
out = wgpuDeviceCreateRenderPipeline(device_, &rp);
};
build_pivot(WGPUCompareFunction_LessEqual,
"ifcviewer-wgpu.axis_pivot_pipeline", pivot_pipeline_);
build_pivot(WGPUCompareFunction_GreaterEqual,
"ifcviewer-wgpu.axis_pivot_xray_pipeline", pivot_xray_pipeline_);
// Corner: resolved surface, no depth, sampleCount=1.
{
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = 1;
rp.multisample.mask = 0xFFFFFFFFu;
corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
}
return pivot_pipeline_ && pivot_xray_pipeline_ && corner_pipeline_;
}
void AxisIndicatorRenderer::encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f, bool visible) {
if (!visible || !pivot_pipeline_ || !pivot_xray_pipeline_) return;
if (f.viewport_h_px <= 0) return;
// Arm length = 30 logical px projected into world at the pivot's distance.
const float fovy_rad = f.camera_fov_y_deg * kPiF / 180.0f;
const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
* 2.0f / float(f.viewport_h_px);
const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
const float arm_world = arm_pixels * world_per_pixel;
const float dpr = float(f.device_pixel_ratio);
const float line_w = 2.5f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot_visible[kAxisUniformSlot];
uint8_t slot_xray[kAxisUniformSlot];
packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
1.00f, line_w, vw, vh);
packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
0.30f, line_w, vw, vh);
const uint32_t visible_off = kSlotPivot * kAxisUniformSlot;
const uint32_t xray_off = kSlotPivotXray * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, visible_off,
slot_visible, sizeof(slot_visible));
wgpuQueueWriteBuffer(queue_, uniform_buffer_, xray_off,
slot_xray, sizeof(slot_xray));
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, pivot_xray_pipeline_);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, pivot_pipeline_);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
}
void AxisIndicatorRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f) {
if (!corner_pipeline_ || !surface_view) return;
const int dpr = std::max(1, f.device_pixel_ratio);
const uint32_t gizmo_size = uint32_t(110 * dpr);
const uint32_t margin = uint32_t(10 * dpr);
if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
// Bottom-left in WebGPU framebuffer space (y down).
const uint32_t fb_h = uint32_t(f.viewport_h_px);
if (gizmo_size + margin > fb_h) return;
const uint32_t y = fb_h - margin - gizmo_size;
// Independent ortho projection from the camera's direction. Near the
// poles the up axis collapses against the look direction, so swap to
// Y-up there — mirrors buildViewProj's identical fix on the viewport.
const float yaw_rad = f.camera_yaw_deg * kPiF / 180.0f;
const float pitch_rad = f.camera_pitch_deg * kPiF / 180.0f;
const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
std::cos(pitch_rad) * std::sin(yaw_rad),
std::sin(pitch_rad));
const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
const Eigen::Matrix4f mvp = z_remap * gp * gv;
uint8_t slot[kAxisUniformSlot];
const float line_w = 2.5f * float(dpr);
packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
float(gizmo_size), float(gizmo_size));
const uint32_t slot_offset = kSlotCorner * kAxisUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
float(gizmo_size), float(gizmo_size),
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, kAxisVertexCount, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
void AxisIndicatorRenderer::destroy() {
if (pivot_pipeline_) { wgpuRenderPipelineRelease(pivot_pipeline_); pivot_pipeline_ = nullptr; }
if (pivot_xray_pipeline_) { wgpuRenderPipelineRelease(pivot_xray_pipeline_); pivot_xray_pipeline_ = nullptr; }
if (corner_pipeline_) { wgpuRenderPipelineRelease(corner_pipeline_); corner_pipeline_ = nullptr; }
if (layout_) { wgpuPipelineLayoutRelease(layout_); layout_ = nullptr; }
if (bgl_) { wgpuBindGroupLayoutRelease(bgl_); bgl_ = nullptr; }
if (bind_group_) { wgpuBindGroupRelease(bind_group_); bind_group_ = nullptr; }
if (vertex_buffer_) { wgpuBufferRelease(vertex_buffer_); vertex_buffer_ = nullptr; }
if (uniform_buffer_) { wgpuBufferRelease(uniform_buffer_); uniform_buffer_ = nullptr; }
if (shader_) { wgpuShaderModuleRelease(shader_); shader_ = nullptr; }
}
+84
View File
@@ -0,0 +1,84 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef AXISINDICATORRENDERER_H
#define AXISINDICATORRENDERER_H
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include "OverlayFrame.h"
// Qt-free renderer for the RGB axis indicator, in its two guises:
//
// - the corner gizmo: a fixed 110x110 px triad in the viewport's
// bottom-left corner, drawn on the resolved surface with its own ortho
// projection so only the camera's direction moves it;
// - the pivot indicator: the same triad drawn in world space at the orbit
// target while the user is navigating, depth-tested against the scene
// with a dim x-ray pass behind it.
//
// Lifted out of the Qt-coupled OverlayRenderer so BOTH the desktop and web
// builds draw one identical indicator from a single place (ViewportCore::render
// calls it on both) — same move SectionGizmoRenderer made.
class AxisIndicatorRenderer {
public:
AxisIndicatorRenderer() = default;
~AxisIndicatorRenderer();
AxisIndicatorRenderer(const AxisIndicatorRenderer&) = delete;
AxisIndicatorRenderer& operator=(const AxisIndicatorRenderer&) = delete;
// Create the shared triad VBO, the uniform buffer (three dynamic-offset
// slots: corner / pivot / pivot-xray), and the three pipelines.
// `color_format` is the render target's format; `sample_count` the MSAA
// count of the main pass the pivot draws into (the corner gizmo always
// targets the resolved, single-sampled surface). Returns false — and
// leaves the renderer inert — if pipeline creation fails.
bool init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count);
void destroy();
bool ready() const { return corner_pipeline_ != nullptr; }
// Orbit pivot indicator, drawn into the already-open main MSAA pass so it
// shares depth with the scene. `visible` is the viewport's UI gate (orbit /
// pan drag, wheel-zoom afterglow); when false this is a cheap no-op.
void encodePivot(WGPURenderPassEncoder pass, const OverlayFrame& f,
bool visible);
// Corner axis gizmo (bottom-left, 110x110 px). Opens its own load-op pass
// on the resolved surface, so it must run after the main pass has resolved.
void encodeCornerAxis(WGPUCommandEncoder enc, WGPUTextureView surface_view,
const OverlayFrame& f);
private:
WGPUDevice device_ = nullptr;
WGPUQueue queue_ = nullptr;
WGPUShaderModule shader_ = nullptr;
WGPUBindGroupLayout bgl_ = nullptr;
WGPUPipelineLayout layout_ = nullptr;
WGPUBindGroup bind_group_ = nullptr;
WGPUBuffer vertex_buffer_ = nullptr;
WGPUBuffer uniform_buffer_ = nullptr;
WGPURenderPipeline pivot_pipeline_ = nullptr;
WGPURenderPipeline pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline corner_pipeline_ = nullptr;
};
#endif // AXISINDICATORRENDERER_H
+84 -43
View File
@@ -18,7 +18,9 @@
********************************************************************************/
#include "BufferPool.h"
#include "GpuAllocScope.h"
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstring>
@@ -59,17 +61,22 @@ bool BufferPool::addSubBuffer() {
if (!device_ || per_sub_buffer_capacity_ == 0) return false;
if (growth_disabled_) return false;
// 64 MB floor: smaller sub-buffers aren't worth the per-allocation
// bookkeeping cost (one bind group per chunk, free-list overhead).
// If the driver won't grant even 64 MB the pool is genuinely at
// its ceiling; growth_disabled_ latches and future grow attempts
// skip the doomed retry.
constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
uint64_t try_size = last_growth_size_ > 0
? last_growth_size_
: per_sub_buffer_capacity_;
if (try_size < MIN_SUB_BUFFER_BYTES) try_size = MIN_SUB_BUFFER_BYTES;
// Never overshoot the budget: the cache's whole job is to stop short
// of what the required tier needs, and a sub-buffer that straddles
// the line would take exactly the bytes it was told to leave. A
// budget refusal is not a driver refusal, so growth_disabled_ is not
// latched — the budget is the (already lower) ceiling.
if (max_total_capacity_bytes_ > 0) {
const uint64_t total = total_capacity_bytes();
if (total + MIN_SUB_BUFFER_BYTES > max_total_capacity_bytes_) return false;
try_size = std::min(try_size, max_total_capacity_bytes_ - total);
}
#if defined(__EMSCRIPTEN__)
// Web can't synchronously learn whether createBuffer OOM'd: the
// desktop spin-wait that drains PopErrorScope would block the JS
@@ -94,7 +101,7 @@ bool BufferPool::addSubBuffer() {
desc.label.data = label;
desc.label.length = std::strlen(label);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
GpuAllocScope scope(instance_, device_);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
SubPool sp;
@@ -107,15 +114,7 @@ bool BufferPool::addSubBuffer() {
last_growth_size_ = try_size;
growth_pending_ = true;
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowSpontaneous;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
static_cast<BufferPool*>(ud1)->resolveProvisionalGrowth(
type != WGPUErrorType_NoError);
};
pcb.userdata1 = this;
wgpuDevicePopErrorScope(device_, pcb);
scope.end([this](bool ok) { resolveProvisionalGrowth(!ok); });
// No usable space yet: the provisional sub-buffer isn't handed out
// until validated. alloc fails this frame and retries on a later one.
@@ -132,31 +131,10 @@ bool BufferPool::addSubBuffer() {
desc.label.data = label;
desc.label.length = std::strlen(label);
// wgpu-native classifies "Not enough memory left" as Validation,
// not OutOfMemory. Nested scopes: OOM inner, Validation outer.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
GpuAllocScope scope(instance_, device_);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pop_result) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
auto* p = static_cast<PopResult*>(ud1);
p->done = true;
p->error = (type != WGPUErrorType_NoError);
};
pcb.userdata1 = &pop_result;
wgpuDevicePopErrorScope(device_, pcb);
while (!pop_result.done) wgpuInstanceProcessEvents(instance_);
};
PopResult oom_pop, validation_pop;
pop(oom_pop);
pop(validation_pop);
const bool ok = buf && !oom_pop.error && !validation_pop.error;
bool ok = false;
scope.end([&](bool result) { ok = result && buf; });
if (ok) {
SubPool sp;
@@ -188,6 +166,68 @@ bool BufferPool::addSubBuffer() {
#endif // __EMSCRIPTEN__
}
uint64_t BufferPool::releaseNewestSubBuffer(
const std::function<void(int sub_idx)>& evict_sub_buffer) {
if (sub_pools_.empty()) return 0;
const int idx = int(sub_pools_.size()) - 1;
if (sub_pools_[size_t(idx)].provisional) return 0;
evict_sub_buffer(idx);
SubPool& sub_pool = sub_pools_[size_t(idx)];
assert(sub_pool.used == 0 && "owner must free every slice before a sub-buffer is released");
if (sub_pool.buffer && sub_pool.owns_handle) {
// Destroy, not just release: the handle may still be
// referenced by in-flight work, and destroy tells the
// backend to reclaim the memory as soon as that completes
// instead of when the last reference goes away.
wgpuBufferDestroy(sub_pool.buffer);
wgpuBufferRelease(sub_pool.buffer);
}
const uint64_t released = sub_pool.capacity;
sub_pools_.pop_back();
return released;
}
namespace {
void logRelease(uint64_t released, uint64_t total, size_t count, uint64_t budget) {
if (released == 0) return;
std::fprintf(stderr,
"[wgpu pool] released %llu MB under memory pressure; pool now %llu MB "
"across %zu sub-buffer(s), budget %llu MB\n",
(unsigned long long)(released / (1024 * 1024)),
(unsigned long long)(total / (1024 * 1024)),
count,
(unsigned long long)(budget / (1024 * 1024)));
}
} // namespace
uint64_t BufferPool::shrinkToCapacity(
uint64_t target_bytes,
const std::function<void(int sub_idx)>& evict_sub_buffer) {
uint64_t released = 0;
while (!sub_pools_.empty()) {
const SubPool& newest = sub_pools_.back();
if (newest.provisional) break;
const uint64_t capacity = total_capacity_bytes();
if (capacity < target_bytes + newest.capacity) break; // would undershoot
released += releaseNewestSubBuffer(evict_sub_buffer);
}
logRelease(released, total_capacity_bytes(), sub_pools_.size(), max_total_capacity_bytes_);
return released;
}
uint64_t BufferPool::releaseAtLeast(
uint64_t bytes,
const std::function<void(int sub_idx)>& evict_sub_buffer) {
uint64_t released = 0;
while (released < bytes) {
const uint64_t got = releaseNewestSubBuffer(evict_sub_buffer);
if (got == 0) break;
released += got;
}
logRelease(released, total_capacity_bytes(), sub_pools_.size(), max_total_capacity_bytes_);
return released;
}
#if defined(__EMSCRIPTEN__)
void BufferPool::resolveProvisionalGrowth(bool failed) {
growth_pending_ = false;
@@ -325,9 +365,10 @@ uint64_t BufferPool::largest_free_run_bytes() const {
void BufferPool::addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity) {
SubPool sp;
sp.buffer = fake_buffer;
sp.capacity = capacity;
sp.used = 0;
sp.buffer = fake_buffer;
sp.capacity = capacity;
sp.used = 0;
sp.owns_handle = false;
sp.free_ranges.push_back({0, capacity});
sub_pools_.push_back(std::move(sp));
}
+45 -1
View File
@@ -23,6 +23,7 @@
#include <webgpu/webgpu.h>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@@ -105,6 +106,12 @@ public:
uint64_t next_growth_size_bytes() const {
return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_;
}
// Smallest sub-buffer worth adding: below this the per-allocation
// bookkeeping (one bind group per chunk, free-list overhead) outweighs
// the space. Growth that cannot reach the floor — the driver refusing,
// or the budget leaving less than this — is not attempted.
static constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
// Whether the pool can still attempt to add a sub-buffer. Flips to
// false the first time addSubBuffer is refused even at the floor
// size — eviction callers need this to know whether a future alloc
@@ -112,9 +119,17 @@ public:
bool can_grow() const {
return !growth_disabled_ && per_sub_buffer_capacity_ > 0
&& (max_total_capacity_bytes_ == 0
|| total_capacity_bytes() < max_total_capacity_bytes_);
|| total_capacity_bytes() + MIN_SUB_BUFFER_BYTES
<= max_total_capacity_bytes_);
}
// True once the driver (not the budget) has refused growth even at the
// floor size. On platforms with no memory query this is the only device
// report there is: the owner treats the first refusal as a pressure
// event and carves the required-tier margin out of the cache before a
// required allocation has to fail for it (see ViewportCore::render).
bool growth_was_refused() const { return growth_disabled_; }
// Whether a growth is in flight. On web that window is real time — a
// provisional sub-buffer validates asynchronously a frame or two later — so
// the streaming driver has to know that free space is still on its way and
@@ -127,6 +142,28 @@ public:
// is a bad_alloc that -fno-exceptions turns into an uncatchable abort, so
// the async grow-OOM detection can't save us — we must stop first.
void setMaxTotalCapacity(uint64_t max_bytes) { max_total_capacity_bytes_ = max_bytes; }
uint64_t max_total_capacity_bytes() const { return max_total_capacity_bytes_; }
// Release whole sub-buffers, newest first. Before each is dropped,
// `evict_sub_buffer(sub_idx)` is invoked so the owner can free every
// slice that lives in it — the pool does not know what a slice holds,
// and a sub-buffer is only released once it is empty. Releasing from
// the back keeps every surviving Slice::sub_idx valid. Both return the
// bytes released. This is how the cache yields memory to the required
// tier (see GpuBudget); on web a provisional sub-buffer that is still
// validating is left alone and the caller retries once it resolves.
//
// shrinkToCapacity never goes *below* target_bytes: a sub-buffer is
// released only while doing so keeps capacity ≥ target, so an excess
// smaller than the newest sub-buffer releases nothing (the budget's
// margin absorbs it) instead of dropping 256 MB for the last 36.
uint64_t shrinkToCapacity(uint64_t target_bytes,
const std::function<void(int sub_idx)>& evict_sub_buffer);
// releaseAtLeast frees sub-buffers until at least `bytes` have gone
// (or nothing is left) — for a failed required allocation that needs
// that much back no matter the granularity.
uint64_t releaseAtLeast(uint64_t bytes,
const std::function<void(int sub_idx)>& evict_sub_buffer);
// Proactively add a sub-buffer (no allocation). On web this kicks off the
// async provisional-validation cycle so validated free space appears a
@@ -161,6 +198,9 @@ private:
// capacity/free tallies skip provisional sub-pools so an
// unvalidated (possibly invalid) buffer is never handed out.
bool provisional = false;
// False only for addSubBufferForTesting's fake handles: release
// paths (shrinkToCapacity, destroy) then skip the wgpu calls.
bool owns_handle = true;
};
// Append a new sub-buffer to the pool. Starts at last_growth_size_
@@ -176,6 +216,10 @@ private:
// ≥ MIN_SUB_BUFFER_BYTES; false only when even the minimum size is
// refused, at which point growth_disabled_ latches.
bool addSubBuffer();
// Drop the newest sub-buffer after `evict_sub_buffer` empties it.
// Returns its capacity; 0 when the pool is empty or the newest
// sub-buffer is still provisional (web).
uint64_t releaseNewestSubBuffer(const std::function<void(int sub_idx)>& evict_sub_buffer);
#if defined(__EMSCRIPTEN__)
// Web-only async-growth resolver. Called from the AllowSpontaneous
+6
View File
@@ -139,17 +139,22 @@ endif()
#
# Keep this list explicit (no glob) the boundary is the whole point.
set(IFCVIEWER_CORE_SOURCES
AxisIndicatorRenderer.cpp
BufferPool.cpp
ChunkPlanner.cpp
InstanceCompose.cpp
LodBuilder.cpp
FederationMath.cpp
GpuAllocScope.cpp
GpuBudget.cpp
GpuMemory.cpp
SidecarCache.cpp
SidecarCompress.cpp
StreamingLoader.cpp
StreamingThread.cpp
SectionGizmoRenderer.cpp
ViewportCore.cpp
WgpuDynamicOffsets.cpp
)
# Web needs a zstd DECODER (Emscripten has no zstd port; the desktop links the
# full libzstd below). Rather than vendor a generated blob, fetch the pinned
@@ -187,6 +192,7 @@ if(EMSCRIPTEN)
# below which would mangle these absolute paths; added via target_sources.
endif()
set(IFCVIEWER_CORE_HEADERS
AxisIndicatorRenderer.h
BufferPool.h
CameraMath.h
ChunkPlanner.h
+20
View File
@@ -38,6 +38,26 @@ struct FrameStats {
std::uint32_t unique_meshes;
std::uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity
std::uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists
// Chunk geometry pool occupancy (see BufferPool): bytes held by
// resident chunks, the pool's current capacity, and the budget the
// pool may grow to (GpuBudget; 0 when still unbounded).
std::uint64_t vram_used_bytes;
std::uint64_t vram_capacity_bytes;
std::uint64_t vram_budget_bytes;
// The camera's working set: chunks the streaming driver wants resident
// (in frustum and large enough on screen) and how many of those are
// not — i.e. geometry the user should be seeing but is not yet, or
// cannot be because it does not fit the cache. Transiently non-zero
// after any camera move; persistently non-zero means the scene does
// not fit in VRAM.
std::uint32_t chunks_wanted;
std::uint32_t chunks_wanted_missing;
std::uint64_t wanted_missing_bytes; // raw vertex + index bytes of the missing chunks
// Whole-device VRAM from the driver (NVML / sysfs, see GpuMemory.h).
// Desktop only; zero on web or when no backend could answer, so
// consumers must treat 0 as "unknown" rather than as empty.
std::uint64_t device_vram_used_bytes;
std::uint64_t device_vram_total_bytes;
};
#endif // IFCVIEWER_FRAMESTATS_H
+92
View File
@@ -0,0 +1,92 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GpuAllocScope.h"
#include <cassert>
namespace {
// Shared between the two pop callbacks; freed by whichever fires last.
struct PendingPop {
GpuAllocScope::Callback on_result;
int remaining = 2;
bool error = false;
// Desktop: points at a flag on end()'s stack frame so the spin-wait
// can observe completion without touching this (freed) object.
bool* done = nullptr;
};
void onPopped(WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView,
void* userdata1, void* /*userdata2*/) {
auto* pending = static_cast<PendingPop*>(userdata1);
if (type != WGPUErrorType_NoError) pending->error = true;
if (--pending->remaining > 0) return;
const bool ok = !pending->error;
bool* done = pending->done;
GpuAllocScope::Callback on_result = std::move(pending->on_result);
delete pending;
on_result(ok);
if (done) *done = true;
}
} // namespace
GpuAllocScope::GpuAllocScope(WGPUInstance instance, WGPUDevice device)
: instance_(instance), device_(device) {
// Validation outer, OutOfMemory inner: each pop sees the errors of
// its own filter, and an OOM reported under either classification
// reaches one of the two.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
}
GpuAllocScope::~GpuAllocScope() {
assert(ended_ && "GpuAllocScope::end() must be called exactly once");
}
void GpuAllocScope::end(Callback on_result) {
assert(!ended_);
ended_ = true;
bool done = false;
auto* pending = new PendingPop{std::move(on_result)};
WGPUPopErrorScopeCallbackInfo cb = {};
#if defined(__EMSCRIPTEN__)
// Dawn-web resolves pops from the JS event loop; the caller proceeds
// provisionally and hears back in on_result.
cb.mode = WGPUCallbackMode_AllowSpontaneous;
#else
// wgpu-native fires these from wgpuInstanceProcessEvents, which we
// spin below so on_result has run by the time end() returns.
cb.mode = WGPUCallbackMode_AllowProcessEvents;
pending->done = &done;
#endif
cb.callback = onPopped;
cb.userdata1 = pending;
wgpuDevicePopErrorScope(device_, cb); // OutOfMemory (inner)
wgpuDevicePopErrorScope(device_, cb); // Validation (outer)
#if !defined(__EMSCRIPTEN__)
while (!done) wgpuInstanceProcessEvents(instance_);
#else
(void)done;
#endif
}
+67
View File
@@ -0,0 +1,67 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_GPUALLOCSCOPE_H
#define IFCVIEWER_GPUALLOCSCOPE_H
#include <webgpu/webgpu.h>
#include <functional>
// Brackets one or more wgpu resource creations so an out-of-memory is
// observed instead of silently producing an invalid resource.
//
// WebGPU never returns null from createBuffer / createTexture: a failed
// allocation yields an *error* resource, and the failure is only reported
// through an error scope. Left unobserved it surfaces later as a validation
// error on the first use -- and on wgpu-native an invalid attachment in
// wgpuQueueSubmit is a Rust panic across the FFI boundary, i.e. an abort
// with no recovery path. So every allocation the renderer cannot do
// without goes through one of these.
//
// Two filters are pushed, not one: wgpu-native classifies "Not enough
// memory left" as a Validation error, Dawn as OutOfMemory.
//
// Desktop and web differ only in *when* the answer arrives. On wgpu-native
// the scope pops synchronously (the instance is spun until the callback
// fires) and `end` invokes the callback before returning. On Dawn-web the
// pop is a promise and spinning would deadlock the JS event loop, so the
// callback fires later from the event loop; callers use the resource
// provisionally and correct course in the callback if it turns out bad.
class GpuAllocScope {
public:
using Callback = std::function<void(bool ok)>;
GpuAllocScope(WGPUInstance instance, WGPUDevice device);
~GpuAllocScope();
GpuAllocScope(const GpuAllocScope&) = delete;
GpuAllocScope& operator=(const GpuAllocScope&) = delete;
// Pop the scopes and deliver the verdict: `ok` is true when no error
// fired between construction and here. Must be called exactly once.
void end(Callback on_result);
private:
WGPUInstance instance_ = nullptr;
WGPUDevice device_ = nullptr;
bool ended_ = false;
};
#endif // IFCVIEWER_GPUALLOCSCOPE_H
+86
View File
@@ -0,0 +1,86 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GpuBudget.h"
#include <algorithm>
void GpuBudget::bound(std::uint64_t budget) {
if (hard_cap_ > 0) budget = std::min(budget, hard_cap_);
bounded_ = true;
budget_ = std::max(budget, kMinCacheBudgetBytes);
}
void GpuBudget::setHardCap(std::uint64_t hard_cap_bytes) {
hard_cap_ = hard_cap_bytes;
if (hard_cap_ > 0) bound(bounded_ ? budget_ : hard_cap_);
}
void GpuBudget::update(std::uint64_t device_free_bytes,
std::uint64_t cache_capacity_bytes) {
if (device_free_bytes == 0) return;
const std::uint64_t available = cache_capacity_bytes + device_free_bytes;
const std::uint64_t margin = margin_bytes();
const std::uint64_t reading = available > margin ? available - margin : 0;
if (!had_device_report_) {
had_device_report_ = true;
bound(reading);
return;
}
const bool tight = device_free_bytes < margin / 2;
const bool roomy = device_free_bytes > margin + margin / 2 && reading > budget_;
low_reports_ = tight ? low_reports_ + 1 : 0;
high_reports_ = roomy ? high_reports_ + 1 : 0;
if (low_reports_ >= kConfirmReports) {
bound(std::min(budget_, reading));
low_reports_ = 0;
} else if (high_reports_ >= kConfirmReports) {
bound(reading);
high_reports_ = 0;
}
}
bool GpuBudget::onPressure(std::uint64_t cache_capacity_bytes,
std::uint64_t bytes_needed,
std::uint64_t device_free_bytes) {
++pressure_events_;
// The driver refused bytes_needed while reporting device_free_bytes
// free, so at least (free - needed) of what it reports is not really
// available. Remember that so update() stops short of it next time.
if (device_free_bytes > bytes_needed) {
learned_margin_ = std::max(learned_margin_,
device_free_bytes - bytes_needed + kPressureSlackBytes);
}
// What the cache may keep once the failed allocation and its slack
// have been carved out of what it holds right now. The pool's actual
// capacity, not the previous budget, is the honest baseline: the
// budget may never have been reached (unbounded, or growth refused
// earlier by the driver), and lowering a number the pool never hit
// would free nothing.
const std::uint64_t carve = bytes_needed + kPressureSlackBytes;
const std::uint64_t target = cache_capacity_bytes > carve
? cache_capacity_bytes - carve
: 0;
const std::uint64_t lowered = std::max(target, kMinCacheBudgetBytes);
if (bounded_ && lowered >= budget_) return false;
bound(lowered);
low_reports_ = high_reports_ = 0;
return true;
}
+149
View File
@@ -0,0 +1,149 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_GPUBUDGET_H
#define IFCVIEWER_GPUBUDGET_H
#include <cstdint>
// How much device memory the elastic geometry cache (BufferPool) may hold.
//
// GPU memory in the viewer falls in two tiers. *Required* allocations --
// render attachments, per-model metadata, readback staging -- are allocated
// eagerly at deterministic moments (surface configure, model load) and the
// frame cannot be drawn without them. The *cache* -- streamed chunk
// geometry -- is elastic: a chunk that does not fit is simply not resident
// this frame. The rule that keeps the two from colliding is that the cache
// never takes the last byte: it grows only up to this budget, and yields
// whenever a required allocation fails.
//
// The budget is *live*, the way D3D12's QueryVideoMemoryInfo and Vulkan's
// memory_budget are meant to be used: on desktop the driver's free-memory
// report (GpuMemory.h) is polled and
//
// budget = cache capacity + device free - margin
//
// is recomputed each time, so the cache tracks what the device can give as
// other processes come and go. The attachments are eager, so at any poll
// they are already inside "used" at the *actual* surface size; nothing is
// idled for a hypothetical bigger window -- a resize that no longer fits is
// answered by the pressure path instead.
//
// The margin has a fixed part for the required-tier allocations that come
// later (the next model's metadata, staging) and a *learned* part: drivers
// refuse allocations while still reporting memory free (measured here: a
// refusal with 221 MB "free"), and a budget that trusts the report would
// grow straight back into the same refusal after every shrink. A pressure
// event therefore records how much reported-free memory turned out to be
// unusable, and the margin keeps that from then on.
//
// Web has no memory query, so it keeps a fixed ceiling (the wasm heap) and
// pressure feedback alone. The budget's source differs per platform, the
// mechanism does not.
//
// Pure policy, no wgpu: the pool applies the number via
// BufferPool::setMaxTotalCapacity / shrinkToCapacity.
class GpuBudget {
public:
// Below this the viewer cannot keep even a handful of 4 MB chunks
// resident, so there is no point lowering further: a required
// allocation that still fails at the floor is a genuinely exhausted
// device, and the caller degrades instead.
static constexpr std::uint64_t kMinCacheBudgetBytes = 64ull * 1024 * 1024;
// Held back for required allocations made after the cache has grown
// (a later model's metadata buffers, readback staging, driver
// bookkeeping).
static constexpr std::uint64_t kFixedMarginBytes = 256ull * 1024 * 1024;
// Headroom added on top of a failed allocation when lowering the
// budget, so the very next small required allocation does not fail
// again and trigger another shrink cycle.
static constexpr std::uint64_t kPressureSlackBytes = 32ull * 1024 * 1024;
// The live budget moves with every driver report, and reports jitter
// (upload staging, other processes). The pool's ceiling follows the
// budget exactly, but geometry already resident is only evicted once
// the pool is over budget by this much — i.e. once the device's free
// memory has dropped below half the margin — so a transient dip does
// not cost a shrink-and-reload.
static constexpr std::uint64_t kShrinkHysteresisBytes = kFixedMarginBytes / 2;
// Absolute ceiling regardless of device memory (the wasm heap on web).
// 0 = none.
void setHardCap(std::uint64_t hard_cap_bytes);
// Desktop: a fresh driver report. `device_free_bytes` 0 = the query
// could not answer -- ignored, the budget keeps its last value.
//
// The first report bounds the cache outright. After that the budget
// moves only on *sustained* readings, because the report includes
// transients the viewer itself creates -- the upload staging behind a
// burst of chunk loads, a released sub-buffer the driver has not yet
// reclaimed -- and a budget that followed every reading oscillated:
// grow, read a momentary low, shrink, read the rebound, grow again,
// reloading the same chunks every few seconds. So: lower when free
// memory is below half the margin on kConfirmReports consecutive
// reports; raise when it is above 1.5× the margin on as many; between
// those nothing changes. A refused allocation (onPressure) is never
// deferred.
void update(std::uint64_t device_free_bytes,
std::uint64_t cache_capacity_bytes);
static constexpr int kConfirmReports = 2;
// A required allocation of `bytes_needed` failed while the cache held
// `cache_capacity_bytes` and the driver reported `device_free_bytes`
// free (0 = unknown). Lowers the budget so that shrinking the cache to
// it frees bytes_needed + slack, and learns the unusable headroom for
// future update() calls. Returns false when the budget could not be
// lowered any further (already at the floor): the device is exhausted
// and the caller must degrade rather than retry.
bool onPressure(std::uint64_t cache_capacity_bytes,
std::uint64_t bytes_needed,
std::uint64_t device_free_bytes);
// Capacity the pool should shrink to right now, or 0 when it is within
// the hysteresis band (or the budget is unbounded).
std::uint64_t shrinkTarget(std::uint64_t cache_capacity_bytes) const {
if (!bounded_ || cache_capacity_bytes < budget_ + kShrinkHysteresisBytes) return 0;
return budget_;
}
// False until something bounds the cache (a device report, a cap, or
// a pressure event). The pool then grows until the driver refuses,
// exactly as before; the first of those bounds it.
bool bounded() const { return bounded_; }
// Meaningful only when bounded().
std::uint64_t cache_budget_bytes() const { return budget_; }
// Fixed + learned margin applied by update().
std::uint64_t margin_bytes() const { return kFixedMarginBytes + learned_margin_; }
std::uint32_t pressure_events() const { return pressure_events_; }
private:
void bound(std::uint64_t budget);
bool bounded_ = false;
std::uint64_t budget_ = 0;
bool had_device_report_ = false;
int low_reports_ = 0; // consecutive reports below the lower band
int high_reports_ = 0; // consecutive reports above the upper band
std::uint64_t hard_cap_ = 0;
// Reported-free memory that a refusal proved unusable, plus slack.
std::uint64_t learned_margin_ = 0;
std::uint32_t pressure_events_ = 0;
};
#endif // IFCVIEWER_GPUBUDGET_H
+201
View File
@@ -0,0 +1,201 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GpuMemory.h"
#include <cstdio>
#include <cstring>
#include <string>
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
#include <dirent.h>
#include <dlfcn.h>
#endif
namespace ifcviewer {
namespace {
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
// NVML, loaded at run time rather than linked: the viewer must run on machines
// with no NVIDIA driver at all, so a link-time dependency is not an option.
// Only the four entry points needed here are resolved.
struct Nvml {
void* handle = nullptr;
int (*init)() = nullptr;
int (*shutdown)() = nullptr;
int (*device_count)(unsigned*) = nullptr;
int (*handle_by_index)(unsigned, void**) = nullptr;
int (*pci_info)(void*, void*) = nullptr;
int (*memory_info)(void*, unsigned long long*) = nullptr;
bool load() {
// .so.1 first: the unversioned name is part of the -dev package and is
// frequently absent on user machines.
for (const char* name : {"libnvidia-ml.so.1", "libnvidia-ml.so"}) {
handle = dlopen(name, RTLD_LAZY | RTLD_LOCAL);
if (handle) break;
}
if (!handle) return false;
auto sym = [&](const char* n) { return dlsym(handle, n); };
init = (int (*)())sym("nvmlInit_v2");
shutdown = (int (*)())sym("nvmlShutdown");
device_count = (int (*)(unsigned*))sym("nvmlDeviceGetCount_v2");
handle_by_index = (int (*)(unsigned, void**))sym("nvmlDeviceGetHandleByIndex_v2");
pci_info = (int (*)(void*, void*))sym("nvmlDeviceGetPciInfo_v3");
memory_info = (int (*)(void*, unsigned long long*))sym("nvmlDeviceGetMemoryInfo");
return init && shutdown && device_count && handle_by_index && memory_info;
}
~Nvml() { if (handle) dlclose(handle); }
};
// nvmlPciInfo_t. Only pciDeviceId is read; the leading char arrays are sized
// from the NVML headers so the offset is right.
struct NvmlPciInfo {
char busIdLegacy[16];
unsigned domain;
unsigned bus;
unsigned device;
unsigned pciDeviceId; // (device_id << 16) | vendor_id
unsigned pciSubSystemId;
char busId[32];
};
bool queryNvml(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo& out) {
Nvml nvml;
if (!nvml.load()) return false;
if (nvml.init() != 0) return false;
unsigned count = 0;
bool found = false;
if (nvml.device_count(&count) == 0) {
for (unsigned i = 0; i < count && !found; ++i) {
void* dev = nullptr;
if (nvml.handle_by_index(i, &dev) != 0 || !dev) continue;
// Match the card wgpu picked. With a single NVIDIA device and no
// way to read its ids, fall through to it rather than reporting
// nothing -- a slightly uncertain number beats none.
if (nvml.pci_info && (vendor_id || device_id)) {
NvmlPciInfo pci{};
if (nvml.pci_info(dev, &pci) == 0) {
const unsigned dev_id = (pci.pciDeviceId >> 16) & 0xFFFF;
const unsigned ven_id = pci.pciDeviceId & 0xFFFF;
if (device_id && dev_id != device_id) continue;
if (vendor_id && ven_id != vendor_id) continue;
}
} else if (count != 1) {
continue;
}
// nvmlMemory_t: { total, free, used }, all unsigned long long.
//
// This reports more `used` than nvidia-smi does -- measured here,
// 1427 MiB against 1046 MiB, consistently -- because the v1 call
// folds driver-reserved memory into `used` where nvidia-smi
// accounts for it separately. The larger figure is the one worth
// having: what actually allocated on this card topped out around
// 2431 MB, against 2669 MB free by this measure and 3050 MB by
// nvidia-smi's. Budgeting against the optimistic number would
// promise memory that is not there.
unsigned long long mem[3] = {0, 0, 0};
if (nvml.memory_info(dev, mem) == 0 && mem[0] > 0) {
out.total_bytes = mem[0];
out.used_bytes = mem[2];
out.valid = true;
found = true;
}
}
}
nvml.shutdown();
return found;
}
// amdgpu and i915 expose VRAM through sysfs. Reads every card and keeps the
// one whose vendor/device ids match, because the first card is often the
// integrated GPU rather than the one in use.
bool readUint64(const std::string& path, std::uint64_t& out) {
FILE* f = std::fopen(path.c_str(), "r");
if (!f) return false;
unsigned long long v = 0;
const bool ok = std::fscanf(f, "%llu", &v) == 1;
std::fclose(f);
if (ok) out = v;
return ok;
}
bool readHexId(const std::string& path, std::uint32_t& out) {
FILE* f = std::fopen(path.c_str(), "r");
if (!f) return false;
unsigned v = 0;
const bool ok = std::fscanf(f, "0x%x", &v) == 1;
std::fclose(f);
if (ok) out = v;
return ok;
}
bool querySysfs(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo& out) {
DIR* dir = opendir("/sys/class/drm");
if (!dir) return false;
bool found = false;
while (dirent* entry = readdir(dir)) {
const std::string name = entry->d_name;
// "card0", not "card0-DP-1".
if (name.rfind("card", 0) != 0 || name.find('-') != std::string::npos) continue;
const std::string base = "/sys/class/drm/" + name + "/device/";
std::uint32_t ven = 0, dev = 0;
if (vendor_id && readHexId(base + "vendor", ven) && ven != vendor_id) continue;
if (device_id && readHexId(base + "device", dev) && dev != device_id) continue;
std::uint64_t total = 0, used = 0;
if (readUint64(base + "mem_info_vram_total", total) && total > 0) {
readUint64(base + "mem_info_vram_used", used);
out.total_bytes = total;
out.used_bytes = used;
out.valid = true;
found = true;
break;
}
}
closedir(dir);
return found;
}
#endif // __linux__ && !__EMSCRIPTEN__
} // namespace
GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id) {
GpuMemoryInfo info;
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
if (queryNvml(vendor_id, device_id, info)) return info;
if (querySysfs(vendor_id, device_id, info)) return info;
#else
// Windows (DXGI QueryVideoMemoryInfo) and macOS
// (recommendedMaxWorkingSetSize) both expose this; not implemented here
// because neither can be verified from this machine. `valid` stays false,
// and callers fall back to behaving as they did before.
(void)vendor_id; (void)device_id;
#endif
return info;
}
} // namespace ifcviewer
+62
View File
@@ -0,0 +1,62 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef GPUMEMORY_H
#define GPUMEMORY_H
#include <cstdint>
// How much video memory the card has, and how much of it is in use.
//
// WebGPU deliberately exposes neither -- `maxBufferSize` reports 1 TB on this
// stack and is useless as a proxy -- so this goes outside the graphics API.
// That is legitimate on desktop, where the viewer is a native app, and it is
// the number every other part of the residency story needs: a gauge to show
// the user, a budget to keep the pool under, and a pre-flight check that can
// refuse a model before it wedges the session.
//
// Matching the right GPU matters. On a laptop with switchable graphics the
// obvious sysfs entry is often the *integrated* chip rather than the one wgpu
// selected -- measured here: /sys/class/drm/card1 reports a 512 MB AMD iGPU
// while wgpu is running on a 4 GB GeForce. So the query takes the vendor and
// device ids from WGPUAdapterInfo and matches on them.
namespace ifcviewer {
struct GpuMemoryInfo {
std::uint64_t total_bytes = 0;
std::uint64_t used_bytes = 0;
// False when no backend could answer -- an unknown driver, a platform
// without a query, or a device the probe could not match. Callers must
// treat that as "unknown" and not as "zero": refusing to load because an
// unavailable query returned 0 would be worse than not asking.
bool valid = false;
std::uint64_t free_bytes() const {
return total_bytes > used_bytes ? total_bytes - used_bytes : 0;
}
};
// Query the GPU wgpu selected. `vendor_id` / `device_id` come from
// wgpuAdapterGetInfo. Cheap enough to call once a second; not per frame.
GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id);
} // namespace ifcviewer
#endif
+50 -1
View File
@@ -319,7 +319,9 @@ struct ModelGpuData {
// (Module.__ifcvSources[id] = a picked File or a remote URL) this model's
// chunk + element metadata reads pull from. Lets several federated models stream
// from different files at once, mirroring the desktop per-model path.
int web_source_id = 0;
// -1 when the model came from somewhere else (a path read on desktop, the
// embedded sample) — source id 0 is a real source, so it can't mean "none".
int web_source_id = -1;
// v15 element metadata (web, on-demand). The IFC element metadata
// (elements + string_table — names/GUIDs, for UI/picking, never
@@ -393,6 +395,25 @@ struct ModelGpuData {
std::vector<MeshInfo> meshes;
std::vector<InstanceInfo> instances;
// The cull-hot per-instance fields packed contiguously. InstanceInfo is
// 232 bytes with the AABB 200 bytes from the ids, so the per-frame cull
// paid two or three cache lines per instance — at half a million
// instances that is the whole frame budget on the single-threaded web
// build. 40 bytes per entry here makes the walk sequential. Rebuilt by
// rebuildCullInstances wherever instances change (applyCachedModel,
// uploadInstanceRecords — which every recompose and colour change
// already funnels through).
struct CullInstance {
float aabb_min[3];
float aabb_max[3];
std::uint32_t mesh_id;
std::uint32_t object_id;
std::uint32_t color_override_rgba8;
std::uint32_t chunk_idx;
};
static_assert(sizeof(CullInstance) == 40, "keep the cull walk dense");
std::vector<CullInstance> cull_instances;
// Per-mesh "any vertex has alpha < 255?" flag, indexed by mesh_id.
// Populated at uploadStreamedMesh / applyStreamedChunk as vertex bytes
// become CPU-resident. Used at cull time to classify each instance
@@ -432,6 +453,19 @@ struct ModelGpuData {
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
};
std::vector<MeshTriangles> mesh_triangles_cache;
// How many RESIDENT chunks currently contain each mesh (the spatial
// planner may duplicate a mesh into several chunks). Maintained by
// applyStreamedChunk / unloadChunk; when it drops to zero the mesh's
// mesh_triangles_cache entry is released — the shadow follows GPU
// residency instead of accumulating every mesh ever loaded, which on
// a large federation grew monotonically toward the whole scene's
// geometry on the CPU heap. mesh_local_volumes is NOT released: the
// Volume tool needs it for evicted meshes too, and it is 8 B/mesh.
std::vector<std::uint16_t> mesh_resident_chunk_refs;
// Bytes currently held by mesh_triangles_cache, maintained at the fill
// (applyStreamedChunk) and release (unloadChunk) sites so the heartbeat
// log can report the shadow without walking every mesh per frame.
std::uint64_t cpu_shadow_bytes = 0;
// object_id (globally rebased) → instance index in `instances`.
// Populated alongside the instance vector so the Volume tool can do
@@ -445,6 +479,15 @@ struct ModelGpuData {
// is gone; cull iterates m.chunks instead.
bool hidden = false;
// Unloaded by the user: every chunk evicted and the model's own GPU
// buffers released, while the CPU mirrors (meshes, instances, chunk
// plan, element metadata) stay so the entry remains in the scene and
// loadModel can bring it back without touching the disk. Distinct
// from hidden (a viewing state; the geometry may stay resident) and
// from removal (the model leaves the scene).
bool unloaded = false;
// Whether cull / draw / pick / streaming should consider this model.
bool drawable() const { return !hidden && !unloaded; }
// Per-model federation matrices in metres. Default identity → no
// per-model contribution to the composed transform. See bonsai's
@@ -473,5 +516,11 @@ struct ModelGpuData {
// ranges via `pool.free()`) and clear its size mirrors. Safe to call
// repeatedly; idempotent on already-released entries.
void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool);
// Just the model's own (non-pool) wgpu buffers: mesh + instance storage
// and the per-chunk cull buffers. Chunk bookkeeping is left intact so the
// buffers can be re-created — the undo step of a failed model load.
void releaseModelBuffers(ModelGpuData& m);
// Refresh ModelGpuData::cull_instances from instances + instance_chunk_idx.
void rebuildCullInstances(ModelGpuData& m);
#endif // WGPUMODELGPUDATA_H
+2 -365
View File
@@ -18,8 +18,7 @@
********************************************************************************/
#include "OverlayRenderer.h"
#include "CameraMath.h"
#include "WgpuDynamicOffsets.h"
#include <QFont>
#include <QFontMetrics>
@@ -27,7 +26,6 @@
#include <QPainter>
#include <QSet>
#include <QStringList>
#include <QtMath>
#include <algorithm>
#include <array>
@@ -49,44 +47,6 @@ WGPUStringView svFromCStr(const char* s) {
return v;
}
// Populate `attribs[5]` with the standard thick-line vertex layout:
// loc 0: start (vec3 @ 0) loc 1: end (vec3 @ 12)
// loc 2: col (vec3 @ 24) loc 3: t (f32 @ 36)
// loc 4: side (f32 @ 40)
// Returns a WGPUVertexBufferLayout aliasing the caller-owned `attribs`.
WGPUVertexBufferLayout thickLineVertexLayout(WGPUVertexAttribute attribs[5]) {
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
return vbl;
}
// Pack the axis uniform's 256-byte slot. Layout matches WGSL AxisUniforms:
// mat4 + vec3 + f32 + f32 + f32 + vec2 = 96 B used, padded to 256.
void packAxisUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp, const Eigen::Vector3f& origin,
float arm, float alpha, float line_width_px,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
float ox = origin.x(), oy = origin.y(), oz = origin.z();
std::memcpy(dst + 64, &ox, sizeof(float));
std::memcpy(dst + 68, &oy, sizeof(float));
std::memcpy(dst + 72, &oz, sizeof(float));
std::memcpy(dst + 76, &arm, sizeof(float));
std::memcpy(dst + 80, &alpha, sizeof(float));
std::memcpy(dst + 84, &line_width_px, sizeof(float));
std::memcpy(dst + 88, &viewport_w, sizeof(float));
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
} // namespace
// -----------------------------------------------------------------------------
@@ -126,35 +86,6 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
}
)WGSL";
static const std::string AXIS_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct AxisUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
arm: f32,
alpha: f32,
line_width_px: f32,
viewport_size: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: AxisUniforms;
@vertex
fn vs_main(@location(0) start: vec3<f32>,
@location(1) end: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(u.origin + start * u.arm, 1.0);
let p_end = u.mvp * vec4<f32>(u.origin + end * u.arm, 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col, u.alpha);
out.side_t = side;
return out;
}
)WGSL";
static const std::string MARQUEE_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct MarqueeUniforms {
rect_min: vec2<f32>,
@@ -383,7 +314,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
queue_ = queue;
surface_format_ = surface_format;
sample_count_ = sample_count;
if (!buildAxisIndicator()) return false;
// Section-plane gizmos moved to the shared SectionGizmoRenderer (ViewportCore).
if (!buildMarquee()) return false;
if (!buildOverlayLines()) return false;
@@ -394,17 +324,6 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
}
void OverlayRenderer::destroy() {
// Axis indicator
if (axis_bind_group_) { wgpuBindGroupRelease(axis_bind_group_); axis_bind_group_ = nullptr; }
if (axis_pivot_pipeline_) { wgpuRenderPipelineRelease(axis_pivot_pipeline_); axis_pivot_pipeline_ = nullptr; }
if (axis_pivot_xray_pipeline_){ wgpuRenderPipelineRelease(axis_pivot_xray_pipeline_); axis_pivot_xray_pipeline_ = nullptr; }
if (axis_corner_pipeline_) { wgpuRenderPipelineRelease(axis_corner_pipeline_); axis_corner_pipeline_ = nullptr; }
if (axis_shader_module_) { wgpuShaderModuleRelease(axis_shader_module_); axis_shader_module_ = nullptr; }
if (axis_pipeline_layout_) { wgpuPipelineLayoutRelease(axis_pipeline_layout_); axis_pipeline_layout_ = nullptr; }
if (axis_bgl_) { wgpuBindGroupLayoutRelease(axis_bgl_); axis_bgl_ = nullptr; }
if (axis_uniform_buffer_) { wgpuBufferRelease(axis_uniform_buffer_); axis_uniform_buffer_ = nullptr; }
if (axis_vertex_buffer_) { wgpuBufferRelease(axis_vertex_buffer_); axis_vertex_buffer_ = nullptr; }
// Section visualizer
// Marquee
@@ -466,288 +385,6 @@ void OverlayRenderer::destroy() {
hud_text_.clear();
}
// -----------------------------------------------------------------------------
// Axis indicator
// -----------------------------------------------------------------------------
bool OverlayRenderer::buildAxisIndicator() {
// Bonsai decorator palette (src/bonsai/bonsai/bim/ui.py:593+):
// decorator_color_error = (1.000, 0.200, 0.322) — red → +X
// decorator_color_selected = (0.545, 0.863, 0.000) — green → +Y
// decorator_color_special = (0.157, 0.565, 1.000) — blue → +Z
// Same palette is reused for the section gizmo + marquee so all overlay
// colours come from one canonical source.
static const float axis_verts[] = {
// start end color (RGB — Bonsai decorators) t side
// ---- +X red ----
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, -1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 0.f, +1.f,
0,0,0, 1,0,0, 1.000f, 0.200f, 0.322f, 1.f, +1.f,
// ---- +Y green ----
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, -1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 0.f, +1.f,
0,0,0, 0,1,0, 0.545f, 0.863f, 0.000f, 1.f, +1.f,
// ---- +Z blue ----
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, -1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 0.f, +1.f,
0,0,0, 0,0,1, 0.157f, 0.565f, 1.000f, 1.f, +1.f,
};
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = sizeof(axis_verts);
bdesc.label = svFromCStr("ifcviewer-wgpu.axis_vbo");
axis_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, axis_vertex_buffer_, 0, axis_verts, sizeof(axis_verts));
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
bdesc.size = 3u * kAxisUniformSlotSize;
bdesc.label = svFromCStr("ifcviewer-wgpu.axis_uniforms");
axis_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
}
{
WGPUBindGroupLayoutEntry entry = {};
entry.binding = 0;
entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
entry.buffer.type = WGPUBufferBindingType_Uniform;
entry.buffer.hasDynamicOffset = 1;
entry.buffer.minBindingSize = 96;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &entry;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.axis_bgl");
axis_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
}
{
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &axis_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.axis_pipeline_layout");
axis_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
}
{
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.buffer = axis_uniform_buffer_;
entry.offset = 0;
entry.size = kAxisUniformSlotSize;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = axis_bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &entry;
bg_desc.label = svFromCStr("ifcviewer-wgpu.axis_bind_group");
axis_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
}
{
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(AXIS_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.axis_wgsl");
axis_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
WGPUVertexAttribute attribs[5] = {};
WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
auto build_pivot = [&](WGPUCompareFunction cmp, const char* label,
WGPURenderPipeline& out) {
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = axis_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = cmp;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = axis_pipeline_layout_;
rp_desc.label = svFromCStr(label);
rp_desc.vertex.module = axis_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 1;
rp_desc.vertex.buffers = &vbl;
rp_desc.fragment = &frag;
rp_desc.depthStencil = &depth;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = uint32_t(sample_count_);
rp_desc.multisample.mask = 0xFFFFFFFFu;
out = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
};
build_pivot(WGPUCompareFunction_LessEqual,
"ifcviewer-wgpu.axis_pivot_pipeline",
axis_pivot_pipeline_);
build_pivot(WGPUCompareFunction_GreaterEqual,
"ifcviewer-wgpu.axis_pivot_xray_pipeline",
axis_pivot_xray_pipeline_);
// Corner: resolved surface, no depth, sampleCount=1.
{
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = axis_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = axis_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.axis_corner_pipeline");
rp_desc.vertex.module = axis_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 1;
rp_desc.vertex.buffers = &vbl;
rp_desc.fragment = &frag;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
axis_corner_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
}
return axis_pivot_pipeline_ && axis_pivot_xray_pipeline_
&& axis_corner_pipeline_;
}
void OverlayRenderer::encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f,
bool visible) {
if (!visible || !axis_pivot_pipeline_ || !axis_pivot_xray_pipeline_) return;
if (f.viewport_h_px <= 0) return;
// Arm length = 30 logical px projected into world at the pivot's distance.
const float fovy_rad = qDegreesToRadians(f.camera_fov_y_deg);
const float world_per_pixel = f.camera_distance * std::tan(fovy_rad * 0.5f)
* 2.0f / float(f.viewport_h_px);
const float arm_pixels = 30.0f * float(f.device_pixel_ratio);
const float arm_world = arm_pixels * world_per_pixel;
const float dpr = float(f.device_pixel_ratio);
const float line_w = 2.5f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot_visible[256];
uint8_t slot_xray[256];
packAxisUniform(slot_visible, f.view_proj, f.camera_target, arm_world,
1.00f, line_w, vw, vh);
packAxisUniform(slot_xray, f.view_proj, f.camera_target, arm_world,
0.30f, line_w, vw, vh);
const uint32_t visible_off = 1u * kAxisUniformSlotSize;
const uint32_t xray_off = 2u * kAxisUniformSlotSize;
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, visible_off,
slot_visible, sizeof(slot_visible));
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, xray_off,
slot_xray, sizeof(slot_xray));
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_xray_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &xray_off);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
wgpuRenderPassEncoderSetPipeline(pass, axis_pivot_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &visible_off);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
}
void OverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f) {
if (!axis_corner_pipeline_ || !surface_view) return;
const int dpr = std::max(1, f.device_pixel_ratio);
const uint32_t gizmo_size = uint32_t(110 * dpr);
const uint32_t margin = uint32_t(10 * dpr);
if (gizmo_size == 0 || f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return;
// Bottom-left in WebGPU framebuffer space (y down).
const uint32_t fb_h = uint32_t(f.viewport_h_px);
if (gizmo_size + margin > fb_h) return;
const uint32_t y = fb_h - margin - gizmo_size;
// Independent ortho projection from the camera's direction. Near the
// poles the up axis collapses against the look direction, so swap to
// Y-up there — mirrors buildViewProj's identical fix on the viewport.
const float yaw_rad = qDegreesToRadians(f.camera_yaw_deg);
const float pitch_rad = qDegreesToRadians(f.camera_pitch_deg);
const Eigen::Vector3f eye_dir(std::cos(pitch_rad) * std::cos(yaw_rad),
std::cos(pitch_rad) * std::sin(yaw_rad),
std::sin(pitch_rad));
const Eigen::Vector3f world_up = (std::abs(f.camera_pitch_deg) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Matrix4f gv = lookAtRH(eye_dir * 3.0f, Eigen::Vector3f::Zero(), world_up);
const Eigen::Matrix4f gp = orthoGL(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
const Eigen::Matrix4f mvp = z_remap * gp * gv;
uint8_t slot[256];
const float line_w = 2.5f * float(dpr);
packAxisUniform(slot, mvp, Eigen::Vector3f(0, 0, 0), 1.0f, 1.0f, line_w,
float(gizmo_size), float(gizmo_size));
const uint32_t slot_offset = 0u;
wgpuQueueWriteBuffer(queue_, axis_uniform_buffer_, slot_offset, slot, sizeof(slot));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0.0, 0.0, 0.0, 1.0 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.corner_axis_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetViewport(pass, float(margin), float(y),
float(gizmo_size), float(gizmo_size),
0.0f, 1.0f);
wgpuRenderPassEncoderSetPipeline(pass, axis_corner_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, axis_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderSetBindGroup(pass, 0, axis_bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, 18, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
// -----------------------------------------------------------------------------
// Marquee
// -----------------------------------------------------------------------------
@@ -1250,7 +887,7 @@ void OverlayRenderer::encodeOverlayLines(WGPURenderPassEncoder pass,
wgpuQueueWriteBuffer(queue_, overlay_line_uniform_buffer_,
slot_off + 96, viewport, sizeof(viewport));
const uint32_t dynamic_offsets[1] = { uint32_t(slot_off) };
wgpuRenderPassEncoderSetBindGroup(pass, 0, overlay_line_bind_group_,
ifcviewer::setBindGroupDynamic(pass, 0, overlay_line_bind_group_,
1, dynamic_offsets);
wgpuRenderPassEncoderDraw(pass, d.vertex_count, 1, d.first_vertex, 0);
}
+14 -33
View File
@@ -33,10 +33,14 @@
#include "OverlayFrame.h"
#include "SectionPlane.h"
// All viewport overlays in one place: axis indicator (corner + pivot),
// section plane gizmos, and the marquee drag rect. Mirrors GL's
// OverlayRenderer split so ViewportWindow.cpp doesn't have to
// carry ~1.5k lines of pipeline plumbing.
// The Qt-coupled viewport overlays: the marquee drag rect, measure-tool
// lines / points / highlight patches, and the QPainter-rasterised labels
// and HUD. Mirrors GL's OverlayRenderer split so ViewportWindow.cpp
// doesn't have to carry ~1.5k lines of pipeline plumbing.
//
// The Qt-free overlays live in their own shared renderers so the web build
// gets them too: SectionGizmoRenderer and AxisIndicatorRenderer (corner
// axis gizmo + orbit pivot), both driven by ViewportCore::render.
//
// Lifecycle: init() once after the device is up, destroy() before the
// device dies. Pipelines are immutable after init; only per-frame
@@ -56,16 +60,11 @@ public:
void destroy();
// ---- Inside the main MSAA pass, after geometry ----
// Both share depth with the scene so they're correctly occluded.
// These share depth with the scene so they're correctly occluded.
// Orbit pivot indicator. `visible` is the viewport's UI gate (orbit
// drag / wheel-zoom afterglow). When false this is a cheap no-op.
void encodePivot(WGPURenderPassEncoder pass,
const OverlayFrame& f,
bool visible);
// Section-plane gizmos moved to the shared SectionGizmoRenderer (drawn by
// ViewportCore for both desktop + web).
// Section-plane gizmos moved to the shared SectionGizmoRenderer, and the
// orbit pivot to AxisIndicatorRenderer (both drawn by ViewportCore for
// desktop + web).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -148,12 +147,8 @@ public:
const OverlayFrame& f);
// ---- After the edge silhouette pass, on the resolved surface ----
// Corner axis gizmo (bottom-left, 110×110 px). Independent ortho
// projection — only the camera direction matters.
void encodeCornerAxis(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& f);
// (The corner axis gizmo also draws here — from ViewportCore, via
// AxisIndicatorRenderer.)
// Marquee box-select drag rect (translucent fill + thick outline).
// No-op when `active` is false.
@@ -170,7 +165,6 @@ public:
static constexpr int kMaxSectionPlanes = 6;
private:
bool buildAxisIndicator();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -200,19 +194,6 @@ private:
WGPUTextureFormat surface_format_ = WGPUTextureFormat_Undefined;
int sample_count_ = 1;
// ---- Axis indicator (shared shape, three pipelines) ----
// Slot 0 = corner gizmo. Slots 1/2 = pivot visible/x-ray.
WGPUShaderModule axis_shader_module_ = nullptr;
WGPUBindGroupLayout axis_bgl_ = nullptr;
WGPUPipelineLayout axis_pipeline_layout_ = nullptr;
WGPURenderPipeline axis_pivot_pipeline_ = nullptr;
WGPURenderPipeline axis_pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline axis_corner_pipeline_ = nullptr;
WGPUBuffer axis_vertex_buffer_ = nullptr;
WGPUBuffer axis_uniform_buffer_ = nullptr;
WGPUBindGroup axis_bind_group_ = nullptr;
static constexpr uint32_t kAxisUniformSlotSize = 256;
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
+2 -1
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "SectionGizmoRenderer.h"
#include "WgpuDynamicOffsets.h"
#include <algorithm>
#include <array>
@@ -335,7 +336,7 @@ void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matri
bitangent, nn, tr, tg, tb, 1.0f, vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
ifcviewer::setBindGroupDynamic(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, uint32_t(vertex_count_), 1, 0, 0);
}
}
File diff suppressed because it is too large Load Diff
+199 -12
View File
@@ -47,7 +47,10 @@
#include <utility>
#include <vector>
#include "AxisIndicatorRenderer.h"
#include "BufferPool.h"
#include "GpuBudget.h"
#include "GpuMemory.h"
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
#include "ModelGpuData.h"
@@ -55,6 +58,7 @@
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
#include "Stopwatch.h"
#include "StreamingLoader.h"
#include "StreamingThread.h"
#include "ViewportHost.h"
@@ -153,6 +157,17 @@ public:
void resetScene();
void hideModel(uint32_t session_model_id);
void showModel(uint32_t session_model_id);
// Release a model's GPU memory (every chunk + its own buffers) while
// keeping it in the scene; loadModel recreates the buffers from the
// CPU mirrors and lets chunks stream back. Neither touches hidden.
// loadModel returns false when the device cannot fit the model's
// buffers even after the cache yielded (it stays unloaded).
void unloadModel(uint32_t session_model_id);
bool loadModel(uint32_t session_model_id);
bool isModelUnloaded(uint32_t session_model_id) const;
// Bytes this model currently holds on the GPU: resident chunk
// geometry plus its mesh/instance/cull buffers. 0 when unloaded.
std::uint64_t modelVramBytes(uint32_t session_model_id) const;
// Federation matrix setters. Each writes to model state and posts
// a recompose so per-instance world matrices stay consistent with
@@ -281,9 +296,9 @@ public:
//
// Pixel-delta camera moves, shared by every host (Qt desktop + web).
// Hosts translate raw pointer/wheel events into these calls and own
// their own UI concerns (drag promotion, pivot indicator, cursor
// capture); the orbit math lives here so it can't drift between
// platforms. Each schedules a frame via the host.
// their own UI concerns (drag promotion, cursor capture); the orbit
// math lives here so it can't drift between platforms. Each schedules
// a frame via the host.
//
// orbitBy: drag-right yaws the world right (yaw -= dx), drag-down
// tilts the camera up (pitch += dy). 0.4 deg/px matches GL.
@@ -296,6 +311,18 @@ public:
void panBy(float dx_px, float dy_px, int viewport_height_px);
void dollyBy(float notches);
// ---- Pivot indicator ----------------------------------------------------
//
// The RGB triad drawn at the orbit target while the user navigates, so it's
// obvious what the camera is turning around. Hosts gate it: (true) when an
// orbit / pan drag starts, (false) when it ends. `hide_after_ms` > 0 arms an
// afterglow instead — the wheel path uses it so a zoom without a held drag
// still shows the pivot for a moment. State lives here (not in the host) so
// desktop and web behave identically; render() consults it each frame and
// keeps requesting frames until an armed afterglow expires.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
bool pivotIndicatorVisible() const;
// ---- First-person / fly navigation --------------------------------------
//
// Shared fly-camera math (desktop + web). The HOST owns the fly-mode flag,
@@ -467,6 +494,12 @@ public:
// brings them in. Triggers an auto-viewAll on the first model (so a
// freshly-loaded scene frames itself).
void applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata);
// The model's required-tier buffers (mesh + instance storage, per-chunk
// cull buffers) as one allocation unit — see allocateRequired. False
// when the device cannot fit them even after the cache yielded.
bool createModelBuffers(std::uint32_t session_model_id, ModelGpuData& m,
const std::vector<MeshGpu>& mesh_gpu,
const std::vector<InstanceGpu>& inst_gpu);
// Qt-free sidecar load: readSidecarMetadata + applyCachedModel.
// Used by the web build (and any other non-Qt embedder) so the
@@ -530,8 +563,10 @@ public:
// Per-model progress for a federation loading UI. count() is how many
// models have metadata (are in the scene); progress(idx,…) gives the
// idx-th model's resident/total chunks, ordered by session_model_id (= load order)
// so each model keeps a stable UI slot as it streams.
// idx-th model's resident/total chunks, ordered by session_model_id — which
// is minted when a load is REQUESTED, so this is the order the host asked
// for its models, not the order their reads finished. Each model keeps a
// stable UI slot as it streams.
int streamingModelCount() const;
void streamingModelProgress(int idx, int& resident_chunks,
int& total_chunks) const;
@@ -542,11 +577,17 @@ public:
int modelLoadIndex(std::uint32_t session_model_id) const;
// One row of the element table: the IFC identity behind a rendered
// object_id. `model_index` is the load-order slot (modelLoadIndex), so a
// host UI can attribute an object to the file it came from.
// object_id, plus which model it came from, said two ways.
//
// `model_index` is the load-order slot (modelLoadIndex) — a POSITION, so it
// shifts if an earlier model fails to load. `source_id` is the JS byte-source
// the model was added from (-1 when it came from somewhere else), which the
// host minted itself and which never moves. Prefer the latter for
// attributing an object to a file; the index is for UI slots.
struct ElementRef {
std::uint32_t object_id = 0;
int model_index = -1;
int source_id = -1;
std::string guid;
std::string name;
std::string type;
@@ -556,6 +597,20 @@ public:
// resident. On web that means calling loadAllElementMetadataWeb first —
// models still lazily un-fetched simply contribute nothing.
std::vector<ElementRef> elements() const;
// One model's elements (by load-order index, same as modelProgress),
// handed out as slices into the model's string table — valid only for
// the duration of the visit, no per-element string copies. The web
// objects export serialises hundreds of thousands of elements straight
// from these; materialising ElementRefs there tripled the peak heap.
struct ElementSlices {
std::uint32_t object_id = 0;
int source_id = -1;
const char* guid = nullptr; std::uint32_t guid_len = 0;
const char* name = nullptr; std::uint32_t name_len = 0;
const char* type = nullptr; std::uint32_t type_len = 0;
};
void visitModelElements(int model_index,
const std::function<void(const ElementSlices&)>& visit) const;
// The single element behind one object_id — the pick path's lookup, which
// must not pay for materialising the whole table. Scans only the model that
@@ -683,6 +738,9 @@ public:
// dimensions match. Resets ping-pong state so any in-flight map is
// dropped (caller already ensured the surface resize blocked).
void ensureHizTextures(int viewport_w, int viewport_h);
// Drop just the resolve texture + staging buffers (pipeline stays),
// resetting the ping-pong state. ensureHizTextures recreates them.
void releaseHizTextures();
// Tear down every HiZ-owned wgpu resource (pipeline + textures +
// staging buffers + pyramid). Called from shutdown() before
@@ -778,8 +836,13 @@ public:
bool buildPickPipeline();
// (Re)allocate the pick MRT attachments + readback staging buffers
// to the supplied size. Idempotent when dimensions match.
void ensurePickAttachments(int w, int h);
// to the supplied size. Idempotent when dimensions match. Created
// eagerly with the other attachments in configureSurface; the pick
// entry points call it again only as the retry after a pressure
// shrink, and bail when it returns false.
bool ensurePickAttachments(int w, int h);
// The raw (unscoped) creation ensurePickAttachments wraps.
void createPickAttachments(int w, int h);
// Encode the one-shot pick pass + copy the (x, y) texel into the pick
// staging buffer(s) and submit. Shared by the sync (pickObjectAt) and
@@ -1030,9 +1093,83 @@ public:
private:
bool createPool();
// The scene's models in load order (ascending session_model_id). Every
// per-model API indexes against this, so a model keeps a stable UI slot
// instead of hopping with unordered_map iteration order.
// ---- Memory tiers (see GpuBudget.h) ------------------------------------
//
// Every allocation the frame cannot do without — the per-pixel
// attachments, a model's metadata buffers, readback staging — is
// "required" and goes through one of these so an out-of-memory is
// observed and answered by shrinking the geometry cache, instead of
// surfacing as an invalid resource that aborts in wgpuQueueSubmit.
// Bytes every per-pixel attachment set costs (MSAA colour + depth,
// selection mask trio, pick MRT + depth) — sizes the pressure
// carve-out when an attachment set fails.
static std::uint64_t attachmentBytesPerPixel();
// Desktop: the driver's view of the adapter wgpu picked (GpuMemory.h);
// `valid` false on web or an unsupported driver.
ifcviewer::GpuMemoryInfo queryDeviceMemory() const;
// Desktop, at most once a second from render(): refresh the device
// figures for FrameStats and re-derive the live cache budget from
// them, shrinking the pool when the device has less to give than the
// pool holds (another process took memory).
void pollDeviceMemory();
// Push budget_ to the pool: the growth ceiling, and a shrink when the
// pool is over it by at least a sub-buffer.
void applyBudgetToPool();
// A required allocation of `bytes` (`what` names it for the log)
// failed. Lowers the budget, evicts and releases cache sub-buffers
// down to it, and on desktop waits for the device to actually reclaim
// them so an immediate retry can succeed. Returns false when the
// cache had nothing left to give: the device is exhausted and the
// caller degrades (skips the operation) rather than retrying.
bool onRequiredAllocationFailed(const char* what, std::uint64_t bytes);
// Unload every resident chunk whose slices live in pool sub-buffer
// `sub_idx`; the evictor BufferPool::shrinkToCapacity calls before it
// releases that sub-buffer.
void evictChunksInSubBuffer(int sub_idx);
// Run `create` (one or more wgpu allocations totalling ~`bytes`) under
// an allocation scope. Desktop: verified synchronously; on failure
// `release` undoes the attempt, the cache yields, and `create` runs
// again, until it succeeds or the cache has nothing left to give
// (false). Web: the resources are used
// provisionally and true is returned; if the scope later reports a
// failure the cache yields and `on_web_failure` (if any) corrects
// course, since the caller has long since moved on.
bool allocateRequired(const char* what, std::uint64_t bytes,
const std::function<void()>& create,
const std::function<void()>& release,
std::function<void()> on_web_failure = {});
// allocateRequired for a single buffer: the buffer, or null when the
// device could not fit it even after the cache yielded.
WGPUBuffer createRequiredBuffer(const WGPUBufferDescriptor& desc,
const char* what);
// (Re)create every per-pixel attachment for a width_px × height_px
// surface as one required allocation. False when they could not be
// allocated even after the cache yielded; render() then skips the
// frame rather than submitting with invalid views.
bool ensureRenderAttachments(int width_px, int height_px);
void releaseRenderAttachments();
GpuBudget budget_;
// Latch: the pool's first driver-refused growth has been answered by
// carving the margin out of the cache (see render()).
bool pool_growth_refusal_handled_ = false;
// Adapter ids, read once at init, for matching the driver's memory
// report to the card wgpu is actually using.
std::uint32_t adapter_vendor_id_ = 0;
std::uint32_t adapter_device_id_ = 0;
// Latched false by ensureRenderAttachments when the device could not
// fit the attachments; re-evaluated on the next configureSurface.
bool render_attachments_ok_ = true;
// The scene's models in load order (ascending session_model_id, minted at
// request time — see loadSidecarMetadataWeb). Every per-model API indexes
// against this, so a model keeps a stable UI slot instead of hopping with
// unordered_map iteration order.
std::vector<std::uint32_t> modelIdsInLoadOrder() const;
public:
@@ -1092,6 +1229,14 @@ private:
// Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws
// everywhere; the desktop's OverlayRenderer no longer draws it.
SectionGizmoRenderer section_gizmo_;
// Corner axis gizmo + orbit pivot indicator, likewise shared by desktop +
// web. Same lift out of the Qt-coupled OverlayRenderer.
AxisIndicatorRenderer axis_indicator_;
bool pivot_indicator_visible_ = false;
// Only running while an afterglow is armed; a drag-held indicator leaves it
// invalid so the triad stays up until the host clears it.
Stopwatch pivot_indicator_timer_;
int pivot_indicator_hide_ms_ = 0;
// HiZ occlusion-cull pipeline group. Downsamples MSAA depth into a
// mip pyramid; consumed by next-frame cull.
@@ -1449,15 +1594,57 @@ private:
// for parallel-vs-serial benchmarking. Default ON.
bool cull_threads_enabled_ = true;
// ---- Cull-input tracking -------------------------------------------
//
// The CPU cull is the single largest per-frame cost (the whole frame on
// the single-threaded web build), and most requested frames do not
// change its inputs — overlay redraws, pick feedback, streaming frames
// where no chunk actually landed. scene_epoch_ is bumped by everything
// that can alter a cull's outcome besides the camera (residency,
// visibility, colours, transforms, model set, HiZ pyramid updates);
// render() re-culls only when the epoch, the camera, or a cull-relevant
// setting changed, and otherwise draws from the buffers the last cull
// uploaded.
std::uint64_t scene_epoch_ = 0;
void markCullInputsChanged() { ++scene_epoch_; }
bool has_last_cull_ = false;
Eigen::Matrix4f last_cull_vp_ = Eigen::Matrix4f::Zero();
std::uint64_t last_cull_epoch_ = 0;
float last_cull_min_px_ = -1.0f;
float last_cull_lod_px_ = -1.0f;
float last_cull_xray_ = -1.0f;
bool last_cull_hiz_ = false;
// Per-frame stats latched by render() for FrameStats emission +
// the interactive heartbeat / bench per-frame line.
std::uint32_t last_visible_objects_ = 0;
std::uint32_t last_visible_triangles_ = 0;
std::uint32_t last_sub_draws_ = 0;
// Device-wide VRAM readout for FrameStats and the live cache budget
// (pollDeviceMemory). The driver query is too slow for per-frame use,
// so it is re-polled at most once a second and the last answer is
// repeated in between.
std::uint64_t device_vram_used_bytes_ = 0;
std::uint64_t device_vram_total_bytes_ = 0;
Stopwatch device_vram_poll_timer_;
std::size_t polled_sub_buffer_count_ = 0;
double last_cull_ms_ = 0.0;
double last_cull_compute_ms_ = 0.0;
double last_cull_upload_ms_ = 0.0;
double last_stream_ms_ = 0.0;
// Motion-cull latch. The coarse motion threshold used to follow the
// per-frame "did the camera move" test directly, which flip-flops
// during a slow low-fps drag: coalesced mouse events leave frames
// where the camera happens not to change, so the cull alternated
// between the 3 px and 15 px thresholds — most of the scene vanishing
// and reappearing every few frames, with a full visible-set re-upload
// at each flip. The latch holds the coarse threshold until the camera
// has been still for kMotionHoldMs, so a drag degrades once at its
// start and restores once, shortly after it ends.
static constexpr int kMotionHoldMs = 250;
bool motion_cull_latched_ = false;
Stopwatch motion_hold_timer_;
// True when the cull just used motion_min_pixel_radius_ — render()
// schedules one more frame so the camera-now-stopped state recomputes
// the cull at the still threshold and previously dropped sub-pixel
+7
View File
@@ -99,6 +99,13 @@ public:
// is encoded; QtViewportHost forwards to `emit frameStatsUpdated(...)`.
virtual void onFrameStats(const FrameStats& /*stats*/) {}
// Whether this host's tools need the CPU-side triangle shadow
// (ModelGpuData::mesh_triangles_cache) that surface raycasts and the
// measurement tools read. It costs 12 B/vertex + 4 B/index of heap for
// every resident mesh, so hosts without those tools (the web viewer,
// for now) skip populating it entirely.
virtual bool wantsCpuMeshTriangles() const { return true; }
// Overlay encode hooks. ViewportCore::render() calls these mid-
// frame so the Qt-bound OverlayRenderer (which carries QString
// labels for the HUD) can encode its passes without core having
+22 -34
View File
@@ -435,14 +435,14 @@ void ViewportWindow::onFrameStats(const FrameStats& stats) {
void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
const OverlayFrame& frame) {
// Section gizmos, highlight triangles, pivot, overlay lines / points
// — drawn inside the MSAA pass so depth-test correctly hides them
// behind closer geometry. (Corner axis / marquee / labels run on the
// resolved surface; see encodeOverlaysPostMain.)
// NB: section-plane gizmos now draw from ViewportCore::render via the shared
// SectionGizmoRenderer (desktop + web), so they are NOT drawn here.
// Highlight triangles + overlay lines / points — drawn inside the MSAA
// pass so depth-test correctly hides them behind closer geometry.
// (Marquee / labels run on the resolved surface; see
// encodeOverlaysPostMain.)
// NB: section-plane gizmos and the pivot indicator now draw from
// ViewportCore::render via their shared renderers (desktop + web), so
// they are NOT drawn here.
overlays_.encodeHighlightTriangles(pass, frame);
overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
overlays_.encodeOverlayPoints(pass, frame);
}
@@ -450,7 +450,8 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
void ViewportWindow::encodeOverlaysPostMain(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& frame) {
overlays_.encodeCornerAxis(enc, surface_view, frame);
// NB: the corner axis gizmo draws from ViewportCore::render (shared
// AxisIndicatorRenderer), just before this hook.
overlays_.encodeMarquee(enc, surface_view, frame,
box_select_start_pos_,
box_select_current_pos_,
@@ -593,6 +594,10 @@ void ViewportWindow::removeModel(uint32_t session_model_id) { core_.removeMode
void ViewportWindow::resetScene() { core_.resetScene(); }
void ViewportWindow::hideModel(uint32_t session_model_id) { core_.hideModel(session_model_id); }
void ViewportWindow::showModel(uint32_t session_model_id) { core_.showModel(session_model_id); }
void ViewportWindow::unloadModel(uint32_t session_model_id) { core_.unloadModel(session_model_id); }
bool ViewportWindow::loadModel(uint32_t session_model_id) { return core_.loadModel(session_model_id); }
bool ViewportWindow::isModelUnloaded(uint32_t session_model_id) const { return core_.isModelUnloaded(session_model_id); }
std::uint64_t ViewportWindow::modelVramBytes(uint32_t session_model_id) const { return core_.modelVramBytes(session_model_id); }
void ViewportWindow::setFederatedFalseOrigin(const Eigen::Matrix4d& m) {
core_.setFederatedFalseOrigin(m);
@@ -907,25 +912,8 @@ bool ViewportWindow::initWgpu() {
// encodeEdgePass moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
void ViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_ms) {
if (!pivot_indicator_hide_timer_) {
pivot_indicator_hide_timer_ = new QTimer(this);
pivot_indicator_hide_timer_->setSingleShot(true);
QObject::connect(pivot_indicator_hide_timer_, &QTimer::timeout, this,
[this]() {
pivot_indicator_visible_ = false;
requestUpdate();
});
}
pivot_indicator_visible_ = visible;
if (visible && hide_after_ms > 0) {
pivot_indicator_hide_timer_->start(hide_after_ms);
} else {
pivot_indicator_hide_timer_->stop();
}
requestUpdate();
}
// setPivotIndicatorVisible moved to ViewportCore (drawn by the shared
// AxisIndicatorRenderer, so the visibility gate lives there too).
// releaseEdgeResources moved to ViewportCore (#84-s).
// -----------------------------------------------------------------------------
@@ -1585,11 +1573,11 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
if (event->button() == orbit_button_
&& (mods & Qt::KeyboardModifierMask) == orbit_mods_) {
nav_drag_kind_ = NavDrag::Orbit;
setPivotIndicatorVisible(true); // hidden again on release
core_.setPivotIndicatorVisible(true); // hidden again on release
} else if (event->button() == pan_button_
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
setPivotIndicatorVisible(true);
core_.setPivotIndicatorVisible(true);
} else if (event->button() == select_button_
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
@@ -1683,7 +1671,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1700,7 +1688,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1715,7 +1703,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
emit surfacePickedInTool(px, py, int(event->modifiers()));
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
return;
}
@@ -1800,7 +1788,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
// Drag is over — hide the pivot indicator without afterglow.
setPivotIndicatorVisible(false);
core_.setPivotIndicatorVisible(false);
}
}
@@ -2050,7 +2038,7 @@ void ViewportWindow::wheelEvent(QWheelEvent* event) {
core_.dollyBy(notches);
// Pivot afterglow on wheel — visible for 600 ms so the user can see
// what they're zooming around without holding a drag.
setPivotIndicatorVisible(true, 600);
core_.setPivotIndicatorVisible(true, 600);
}
void ViewportWindow::shutdown() {
+14 -16
View File
@@ -21,7 +21,6 @@
#define WGPUVIEWPORTWINDOW_H
#include <QWindow>
#include <QTimer>
#include <string>
#include <unordered_set>
@@ -143,6 +142,13 @@ public:
// consults. requestUpdate() so the change is visible immediately.
void hideModel(uint32_t session_model_id);
void showModel(uint32_t session_model_id);
// GPU residency of a model, independent of visibility: unloadModel
// frees everything it holds on the device while it stays in the
// scene; loadModel brings it back (false if the device cannot fit it).
void unloadModel(uint32_t session_model_id);
bool loadModel(uint32_t session_model_id);
bool isModelUnloaded(uint32_t session_model_id) const;
std::uint64_t modelVramBytes(uint32_t session_model_id) const;
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
@@ -306,12 +312,9 @@ private:
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
// Show/hide the pivot indicator. hide_after_ms > 0 starts the
// single-shot auto-hide timer used by the wheel-zoom afterglow;
// drag callers pass 0 and toggle manually on press/release. The
// actual gizmo rendering lives in OverlayRenderer — this just
// manages the UI-side visibility timer.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
// setPivotIndicatorVisible moved to ViewportCore — the indicator is drawn
// by the shared AxisIndicatorRenderer now, so its visibility (afterglow
// included) lives next to the drawing for desktop + web alike.
// releaseEdgeResources / buildPickPipeline / ensurePickAttachments /
// releasePickResources moved to ViewportCore (#84-s, #84-t).
@@ -670,15 +673,10 @@ private:
WGPUBindGroup& edge_bind_group_;
bool& edges_enabled_;
// Pivot visibility state — the gizmo itself lives in overlays_.
// The timer auto-hides the pivot after a wheel-zoom afterglow.
bool pivot_indicator_visible_ = false;
QTimer* pivot_indicator_hide_timer_ = nullptr;
// All viewport overlays (axis indicator, section gizmos, marquee
// rect) — pipelines + shaders + buffers + encoders. The viewport
// builds a OverlayFrame each frame and asks the renderer to
// encode each overlay; see OverlayRenderer.h.
// The Qt-coupled viewport overlays (marquee rect, measure lines /
// points / labels, highlight triangles) — pipelines + shaders +
// buffers + encoders. The viewport builds a OverlayFrame each frame
// and asks the renderer to encode each overlay; see OverlayRenderer.h.
OverlayRenderer overlays_;
// Active measurement tool. setToolMode() / setSelection mutations
+65
View File
@@ -0,0 +1,65 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "WgpuDynamicOffsets.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/em_js.h>
#endif
namespace ifcviewer {
#ifdef __EMSCRIPTEN__
namespace {
EM_JS(void, ifcv_set_bind_group_dynamic_js,
(WGPURenderPassEncoder pass, uint32_t group_index, WGPUBindGroup group,
uint32_t offsets_ptr, uint32_t count), {
// HEAPU32.slice() copies into a freshly allocated buffer of exactly
// `count` elements; .subarray() would alias the whole heap again and
// reintroduce the bug this function exists to avoid.
var start = offsets_ptr >>> 2;
var small = HEAPU32.slice(start, start + count);
WebGPU.getJsObject(pass).setBindGroup(
group_index, WebGPU.getJsObject(group), small, 0, count);
});
} // namespace
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets) {
if (count == 0) {
wgpuRenderPassEncoderSetBindGroup(pass, group_index, group, 0, nullptr);
return;
}
ifcv_set_bind_group_dynamic_js(pass, group_index, group,
uint32_t(reinterpret_cast<uintptr_t>(offsets)), count);
}
#else
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets) {
wgpuRenderPassEncoderSetBindGroup(pass, group_index, group, count, offsets);
}
#endif
} // namespace ifcviewer
+61
View File
@@ -0,0 +1,61 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUDYNAMICOFFSETS_H
#define WGPUDYNAMICOFFSETS_H
#include <cstdint>
#include <webgpu/webgpu.h>
namespace ifcviewer {
// setBindGroup with dynamic offsets. Always use this instead of calling
// wgpuRenderPassEncoderSetBindGroup with a nonzero offset count.
//
// Emscripten's generated WebGPU shim implements the dynamic-offset path as
//
// pass.setBindGroup(index, group, HEAPU32, ptr >>> 2, count);
//
// where HEAPU32 is the persistent view over the *entire* wasm linear memory.
// Browsers validate the byte length of the whole backing buffer handed to
// setBindGroup, not the (start, length) slice actually read, and reject
// anything over 2 GB:
//
// TypeError: GPURenderPassEncoder.setBindGroup: Argument 3 can't be an
// ArrayBuffer or an ArrayBufferView larger than 2 GB
//
// So once the heap grows past 2^31 bytes every dynamic-offset draw throws, on
// every frame, for the life of the page -- and this build deliberately allows
// that (ALLOW_MEMORY_GROWTH with MAXIMUM_MEMORY=4 GB, because large
// federations need the headroom). The offsets are only a handful of uint32_t,
// so the web implementation copies them into a small short-lived Uint32Array.
// Native builds forward straight through; wgpu-native reads the pointer
// directly and has no such limit.
//
// Defined out-of-line in WgpuDynamicOffsets.cpp: the web path is an EM_JS
// function, and EM_JS emits real per-translation-unit symbols that collide at
// link time if instantiated in more than one TU.
void setBindGroupDynamic(WGPURenderPassEncoder pass, uint32_t group_index,
WGPUBindGroup group, uint32_t count,
const uint32_t* offsets);
} // namespace ifcviewer
#endif
+7 -1
View File
@@ -109,6 +109,12 @@ endif()
add_ifcviewer_unit_test(test_selection)
add_ifcviewer_unit_test(test_visibility)
# GpuBudget: the pure policy deciding how much device memory the geometry
# cache may hold and how it yields under pressure. No wgpu at all.
add_ifcviewer_unit_test(test_gpu_budget
SOURCES ${IFCVIEWER_SRC}/GpuBudget.cpp
)
# BufferPool sub-allocator invariants. The pool's wgpu calls live inside
# addSubBuffer() (the growth path); tests use the addSubBufferForTesting
# seam to preseed sub-pools with fake handles, so the only wgpu touchpoint
@@ -117,7 +123,7 @@ add_ifcviewer_unit_test(test_visibility)
# the pool go out of scope holding any. Linking wgpu_native satisfies the
# symbol regardless.
add_ifcviewer_unit_test(test_buffer_pool
SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp
SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp ${IFCVIEWER_SRC}/GpuAllocScope.cpp
LIBS wgpu_native
)
if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
+98
View File
@@ -34,6 +34,7 @@
#include <catch2/catch_all.hpp>
#include <cstdint>
#include <vector>
namespace {
@@ -252,3 +253,100 @@ TEST_CASE("free with invalid slice is a no-op", "[buffer_pool]") {
pool.free(a);
REQUIRE(pool.total_used_bytes() == 0);
}
// ---- Budget ceiling + shrink (the cache yielding to the required tier) ----
TEST_CASE("can_grow respects the total-capacity budget with sub-buffer granularity", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
// No device configured, so can_grow is false regardless; the budget
// arithmetic is what we check via max_total_capacity_bytes.
pool.setMaxTotalCapacity(256ull * 1024 * 1024);
REQUIRE(pool.max_total_capacity_bytes() == 256ull * 1024 * 1024);
pool.addSubBufferForTesting(fake_handle(1), 200ull * 1024 * 1024);
// 200 MB held + 64 MB floor > 256 MB budget: a grow could not fit.
REQUIRE_FALSE(pool.can_grow());
}
TEST_CASE("shrinkToCapacity releases sub-buffers newest-first after the owner empties them", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 1024);
pool.addSubBufferForTesting(fake_handle(2), 1024);
pool.addSubBufferForTesting(fake_handle(3), 1024);
auto a = pool.alloc(256, 16); // sub 0
auto b = pool.alloc(1024, 16); // sub 1 (sub 0 has only 768 left)
auto c = pool.alloc(512, 16); // sub 0 again (first fit)
auto d = pool.alloc(512, 16); // sub 2
REQUIRE(a.sub_idx == 0);
REQUIRE(b.sub_idx == 1);
REQUIRE(c.sub_idx == 0);
REQUIRE(d.sub_idx == 2);
std::vector<int> evicted;
auto evict = [&](int sub_idx) {
evicted.push_back(sub_idx);
if (sub_idx == 2) pool.free(d);
if (sub_idx == 1) pool.free(b);
if (sub_idx == 0) { pool.free(a); pool.free(c); }
};
// Shrink to 1024: drops sub 2 then sub 1; sub 0 and its slices survive
// with their sub_idx still valid.
const uint64_t released = pool.shrinkToCapacity(1024, evict);
REQUIRE(released == 2048);
REQUIRE(evicted == std::vector<int>{2, 1});
REQUIRE(pool.sub_buffer_count() == 1);
REQUIRE(pool.total_capacity_bytes() == 1024);
REQUIRE(pool.total_used_bytes() == 256 + 512);
REQUIRE(pool.largest_free_run_bytes() == 256);
// Already at or below target: nothing happens, evictor not consulted.
evicted.clear();
REQUIRE(pool.shrinkToCapacity(1024, evict) == 0);
REQUIRE(evicted.empty());
// Shrinking to zero empties the pool entirely.
REQUIRE(pool.shrinkToCapacity(0, evict) == 1024);
REQUIRE(pool.sub_buffer_count() == 0);
REQUIRE(evicted == std::vector<int>{0});
}
TEST_CASE("shrinkToCapacity never undershoots the target", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 256);
pool.addSubBufferForTesting(fake_handle(2), 256);
pool.addSubBufferForTesting(fake_handle(3), 73);
pool.addSubBufferForTesting(fake_handle(4), 73);
auto evict = [](int) {};
// 658 held, target 476: 182 over. The two 73s go (36 still over);
// the 256 would undershoot, so it stays — the margin absorbs 36.
REQUIRE(pool.shrinkToCapacity(476, evict) == 146);
REQUIRE(pool.total_capacity_bytes() == 512);
// An excess smaller than the newest sub-buffer releases nothing.
REQUIRE(pool.shrinkToCapacity(500, evict) == 0);
REQUIRE(pool.total_capacity_bytes() == 512);
}
TEST_CASE("releaseAtLeast frees whole sub-buffers until the requested bytes are gone", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 256);
pool.addSubBufferForTesting(fake_handle(2), 73);
pool.addSubBufferForTesting(fake_handle(3), 73);
std::vector<int> evicted;
auto evict = [&](int sub_idx) { evicted.push_back(sub_idx); };
// Needs 100: 73 is not enough, 73+73 is. Overshoot by a sub-buffer is
// the point — the allocation must fit.
REQUIRE(pool.releaseAtLeast(100, evict) == 146);
REQUIRE(evicted == std::vector<int>{2, 1});
REQUIRE(pool.total_capacity_bytes() == 256);
// More than the pool holds: everything goes, no crash.
REQUIRE(pool.releaseAtLeast(1000, evict) == 256);
REQUIRE(pool.sub_buffer_count() == 0);
REQUIRE(pool.releaseAtLeast(1, evict) == 0);
}
+208
View File
@@ -0,0 +1,208 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// GpuBudget decides how much device memory the streamed-geometry cache may
// hold. It is pure policy: a live number derived from what the platform
// can tell us (desktop: the driver's free-memory report; web: nothing but
// a heap ceiling), lowered by pressure events when a required allocation
// fails anyway, and learning from those how much reported-free memory the
// driver will not actually grant. These pin down the arithmetic, the floor
// and the learning.
#include "GpuBudget.h"
#include <catch2/catch_all.hpp>
namespace {
constexpr std::uint64_t MB = 1024ull * 1024;
}
TEST_CASE("nothing known leaves the cache unbounded", "[gpu_budget]") {
GpuBudget b;
REQUIRE_FALSE(b.bounded());
b.update(0, 512 * MB); // query could not answer: still unbounded
REQUIRE_FALSE(b.bounded());
}
TEST_CASE("the first device report bounds the cache at held + free - margin", "[gpu_budget]") {
GpuBudget b;
b.update(2800 * MB, 0);
REQUIRE(b.bounded());
REQUIRE(b.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes);
// The pool now holds 1000 MB and the driver reports 1800 MB free: the
// cache's own bytes count as available to it, so nothing moves.
b.update(1800 * MB, 1000 * MB);
b.update(1800 * MB, 1000 * MB);
REQUIRE(b.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes);
}
TEST_CASE("a momentary reading never moves the budget; a sustained one does", "[gpu_budget]") {
const std::uint64_t margin = GpuBudget::kFixedMarginBytes;
GpuBudget b;
b.update(2800 * MB, 0);
const std::uint64_t initial = b.cache_budget_bytes();
// Pool at budget; one poll reads 80 MB free (upload staging in flight).
b.update(80 * MB, initial);
REQUIRE(b.cache_budget_bytes() == initial);
// The staging drained: back in the dead band, streak reset.
b.update(margin, initial);
b.update(80 * MB, initial);
REQUIRE(b.cache_budget_bytes() == initial);
// Tight on two consecutive reports: another process really took it.
b.update(80 * MB, initial);
REQUIRE(b.cache_budget_bytes() == initial + 80 * MB - margin);
const std::uint64_t lowered = b.cache_budget_bytes();
// One roomy report is not enough to raise it...
b.update(1500 * MB, lowered);
REQUIRE(b.cache_budget_bytes() == lowered);
// ...two are.
b.update(1500 * MB, lowered);
REQUIRE(b.cache_budget_bytes() == lowered + 1500 * MB - margin);
}
TEST_CASE("free memory inside the dead band changes nothing however long it lasts", "[gpu_budget]") {
const std::uint64_t margin = GpuBudget::kFixedMarginBytes;
GpuBudget b;
b.update(2800 * MB, 0);
const std::uint64_t initial = b.cache_budget_bytes();
for (int i = 0; i < 10; ++i) b.update(margin, initial); // exactly the margin
for (int i = 0; i < 10; ++i) b.update(margin + margin / 2, initial); // top of the band
for (int i = 0; i < 10; ++i) b.update(margin / 2, initial); // bottom of the band
REQUIRE(b.cache_budget_bytes() == initial);
}
TEST_CASE("a refusal lowers the budget immediately and resets the streaks", "[gpu_budget]") {
GpuBudget b;
b.update(2800 * MB, 0);
const std::uint64_t initial = b.cache_budget_bytes();
b.update(80 * MB, initial); // one tight report
REQUIRE(b.onPressure(initial, 100 * MB, 80 * MB));
REQUIRE(b.cache_budget_bytes() < initial);
const std::uint64_t after = b.cache_budget_bytes();
// The streak did not carry over: one more tight report is not two.
b.update(80 * MB, after);
REQUIRE(b.cache_budget_bytes() == after);
}
TEST_CASE("less than the margin available floors the budget, not zero", "[gpu_budget]") {
GpuBudget b;
b.update(100 * MB, 0);
REQUIRE(b.bounded());
REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes);
}
TEST_CASE("a hard cap bounds the cache on its own (web) and clamps a device-derived budget", "[gpu_budget]") {
GpuBudget web;
web.setHardCap(3072 * MB);
REQUIRE(web.bounded());
REQUIRE(web.cache_budget_bytes() == 3072 * MB);
GpuBudget both;
both.setHardCap(3072 * MB);
both.update(8000 * MB, 0);
REQUIRE(both.cache_budget_bytes() == 3072 * MB);
GpuBudget small_device;
small_device.setHardCap(3072 * MB);
small_device.update(2800 * MB, 0);
REQUIRE(small_device.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes);
}
TEST_CASE("pressure lowers the budget below what the cache currently holds", "[gpu_budget]") {
GpuBudget b;
REQUIRE_FALSE(b.bounded());
// The pool grew to 2048 MB unbounded; a 120 MB attachment set then failed.
REQUIRE(b.onPressure(2048 * MB, 120 * MB, 0));
REQUIRE(b.bounded());
REQUIRE(b.cache_budget_bytes()
== 2048 * MB - 120 * MB - GpuBudget::kPressureSlackBytes);
REQUIRE(b.pressure_events() == 1);
}
TEST_CASE("pressure is measured against actual capacity, not the previous budget", "[gpu_budget]") {
// Budget said ~2500 MB but the driver only ever granted 1024 MB; a
// failure must carve out of the 1024, else nothing would be released.
GpuBudget b;
b.update(2800 * MB, 0);
REQUIRE(b.onPressure(1024 * MB, 100 * MB, 0));
REQUIRE(b.cache_budget_bytes()
== 1024 * MB - 100 * MB - GpuBudget::kPressureSlackBytes);
}
TEST_CASE("pressure never raises the budget", "[gpu_budget]") {
GpuBudget b;
b.setHardCap(500 * MB);
REQUIRE(b.onPressure(256 * MB, 0, 0));
REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes);
// A later event whose arithmetic lands above the current budget is a no-op.
REQUIRE_FALSE(b.onPressure(4096 * MB, 0, 0));
REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes);
}
TEST_CASE("pressure bottoms out at the floor and then reports exhaustion", "[gpu_budget]") {
GpuBudget b;
REQUIRE(b.onPressure(100 * MB, 90 * MB, 0));
REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes);
// Already at the floor: nothing more to give.
REQUIRE_FALSE(b.onPressure(64 * MB, 90 * MB, 0));
REQUIRE(b.pressure_events() == 2);
}
TEST_CASE("a refusal with memory still reported free teaches the margin", "[gpu_budget]") {
GpuBudget b;
b.update(2800 * MB, 0);
REQUIRE(b.margin_bytes() == GpuBudget::kFixedMarginBytes);
// 59 MB refused with 221 MB "free" (the measured crash): at least
// 162 MB of what the driver reports is not usable.
REQUIRE(b.onPressure(2048 * MB, 59 * MB, 221 * MB));
REQUIRE(b.margin_bytes()
== GpuBudget::kFixedMarginBytes + 162 * MB + GpuBudget::kPressureSlackBytes);
// The next live reports stop short by the learned amount, so the pool
// does not grow straight back into the same refusal.
b.update(221 * MB, 2048 * MB);
b.update(221 * MB, 2048 * MB);
REQUIRE(b.cache_budget_bytes() == 2048 * MB + 221 * MB - b.margin_bytes());
// Learning only ever grows; a later refusal with less phantom free
// memory does not shrink it.
b.onPressure(1500 * MB, 59 * MB, 100 * MB);
REQUIRE(b.margin_bytes()
== GpuBudget::kFixedMarginBytes + 162 * MB + GpuBudget::kPressureSlackBytes);
// A refusal that needed more than was reported free teaches nothing.
b.onPressure(1500 * MB, 500 * MB, 100 * MB);
REQUIRE(b.margin_bytes()
== GpuBudget::kFixedMarginBytes + 162 * MB + GpuBudget::kPressureSlackBytes);
}
TEST_CASE("resident geometry is only shrunk once over budget by the hysteresis", "[gpu_budget]") {
GpuBudget b;
REQUIRE(b.shrinkTarget(4096 * MB) == 0); // unbounded: never
b.update(2800 * MB, 0);
const std::uint64_t budget = b.cache_budget_bytes();
REQUIRE(b.shrinkTarget(budget) == 0);
REQUIRE(b.shrinkTarget(budget + GpuBudget::kShrinkHysteresisBytes - 1) == 0);
REQUIRE(b.shrinkTarget(budget + GpuBudget::kShrinkHysteresisBytes) == budget);
}
+6
View File
@@ -962,6 +962,12 @@ private:
};
%include "../ifcparse/ifc_parse_api.h"
namespace ifcopenshell {
std::string encode_spf_string(const std::string& value);
std::string decode_spf_string(const std::string& value);
}
%include "../ifcparse/spf_header.h"
%pythoncode %{
+1 -1
View File
@@ -11,7 +11,7 @@ target_include_directories(plugin PUBLIC
)
if (NOT CREATE_BUNDLE)
set_target_properties(plugin PROPERTIES
set_target_properties(plugin PROPERTIES
VERSION "${PROJECT_VERSION}"
SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}"
)