Users had no feedback during the seconds before/while a model streams. Add a
top progress strip + caption driven by the streaming state:
- "Loading model… N MB" while the critical metadata downloads (no chunks yet),
- "Loading geometry — R / T chunks · N MB" as chunks go resident,
- "Loaded — T chunks · N MB", then it hides.
ViewportCore::streamingProgress(resident, total) sums chunk residency across
models; main_web exports ifcv_chunks_resident_c / ifcv_chunks_total_c for
shell.html to poll each RAF. The EM_JS range reader accumulates
Module.__ifcvBytesLoaded so the caption can show MB downloaded. Shown only for
streamed loads (?model= URL / picked file), not the embedded sample.
6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First-paint over a network is metadata-bound: the whole post-index metadata
(~10 MB on a 118 MB model) had to download before any geometry. But ~25% of
it — elements + string_table, the IFC element tree (names/GUIDs/hierarchy) —
is used only for UI/picking, never for rendering (ViewportCore never touches
it).
v15 splits the post-index metadata into a render-CRITICAL block (meshes,
instances, georef, chunk TOC) preceded by its byte length, then a DEFERRED
block (elements + string_table). The web loader reads only the critical block
before painting; the deferred block sits at a known, self-describing offset
([critical end, EOF)) and is fetched on demand. Desktop reads both (local).
Web on-demand path is wired and complete (not yet called — no UI consumer):
loadDeferredMetadataWeb(model_id) range-fetches + parses the deferred block
into ModelGpuData.elements/string_table, at most once; the first consumer
will be "show the selected object's name" on pick. No background prefetch —
view-only sessions never download the property data (saves 2.64 MB on this
model).
parseSidecarTail split into parseSidecarCritical + parseSidecarDeferred (pure,
unit-tested); StreamingSidecar gains the critical-block locator. Measured
(118 MB model): critical metadata 10.35 -> 7.71 MB, deferred 2.64 MB off the
path; first paint 10.5 -> 9.6 s @ 24 Mbps. (Instances still dominate the
critical block — the next metadata lever.) Format -> v15, no back-compat;
regenerate sidecars. 111/111 unit + 6/6 web smoke pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SHIFT+CTRL+CLICK on Activate Drawing now imports the
annotations of all selected drawings without switching
the active view or camera. The drawing camera is imported
when missing so annotations are collected into the correct
drawing collection. Loading is idempotent.
Generated with the assistance of an AI coding tool.
The drawing Include filter replaced the camera-view element set
entirely, so elements outside the camera boundary were drawn.
Intersect the filter results with the camera-view set instead.
Generated with the assistance of an AI coding tool.
The georef orientation gizmo (crosshair, project-north arrow,
grid-north arrow, true-north arrow, WCS leader) is a coordinate-
system overlay: its purpose is to communicate orientation regardless
of what the model contains, so it must remain visible regardless of
whether 3D geometry occupies the gizmo's z=0 footprint.
Wrap GeoreferenceDecorator.draw_geometry's draw cycle in a
gpu.state.depth_test_set("ALWAYS") / restore pair so the overlay
draws on top of any 3D geometry between the camera and the gizmo.
Matches the precedent set by the dashed-line overlay in
bim/module/model/opening.py.
Generated with the assistance of an AI coding tool.
Three crashes that surfaced when viewport decorators ran against
selected non-IFC blender objects or top-level objects with no
aggregate parent:
- WallAxisDecorator.draw_wall_axis: tool.Ifc.get_entity(obj) returns
None for a non-IFC selection (default cube, lamp, camera). The
subsequent element.is_a("IfcWall") raised AttributeError on every
redraw. Guard with `element and element.is_a(...)`.
- _ConnectedNetworkPathDecorator flow-segment loop: same shape;
iterates entries that may be None, calls .is_a("IfcFlowSegment")
unconditionally. Same guard.
- AggregateDecorator.draw_aggregate: indexes aggregates_list[-1]
unconditionally in the else branch; raises IndexError when the
selected element has no aggregate parent. Also leaves `aggregate`
unbound across loop iterations in the `in_aggregate_mode` branch
when `index <= 0`. Define `aggregate = None` per loop iteration
and guard the [-1] indexing with `elif aggregates_list:`.
Generated with the assistance of an AI coding tool.
Migrate 17 legacy viewport decorators (ClashDecorator, SolarDecorator,
MeasureDecorator, ItemDecorator, GeoreferenceDecorator, NestDecorator,
NestModeDecorator, GridDecorator, LoadsDecorator, AggregateDecorator,
AggregateModeDecorator, PolylineDecorator, ProductDecorator,
WallAxisDecorator, SlabDirectionDecorator, FaceAreaDecorator,
BoundingBoxDecorator) from hand-rolled install/uninstall lifecycles
onto the canonical tool.Blender.ViewportDecorator base. The legacy
uninstall removed each handler from Blender but never cleared
cls.handlers, growing a stale-reference list across enable/disable
cycles. The base's uninstall clears the list correctly.
State-derived install methods (ItemDecorator, ProductDecorator,
LoadsDecorator, PolylineDecorator) keep an install override per the
base's documented contract.
Drop the now-redundant per-class draw_batch copies and the module-
or method-scope transparent_color defs in favour of the base helpers
introduced in the preceding commit. system/decorator.py and
boundary/decorator.py keep their installed-flag lifecycle (different
pattern, no leak) but consume tool.Blender.transparent_color.
Add an AST forward-compat guard pinning the contract structurally:
any class declaring handlers = [] (Assign or AnnAssign) must subclass
tool.Blender.ViewportDecorator. Add a runtime regression on
ClashDecorator's install/uninstall cycle.
Generated with the assistance of an AI coding tool.
Add two helpers to tool.Blender that 17+ existing viewport decorators
re-implement byte-identically:
- ViewportDecorator.draw_batch(shader_type, content_pos, color, indices=None)
collapses the validate + batch_for_shader + uniform_float + draw cycle
every shader-driven decorator needs.
- Blender.transparent_color(color, alpha=0.1) is the RGBA-alpha-override
helper duplicated across nest, project, aggregate, model, system module
scopes plus six nested-def copies inside draw methods.
Pure additions on the tool/ layer with direct unit tests covering the
default-alpha, explicit-alpha, non-mutation, new-list-instance, and
validation-guard branches.
Generated with the assistance of an AI coding tool.
Makes large-model streaming over a network actually good — fixing read
amplification, then first-paint latency — building on the byte-range work.
v14 layout + TOC (SidecarLayout, pure + unit-tested)
The loader chunks meshes by spatial Morton order, but the sidecar stored
geometry in mesh-id order, so a chunk's meshes were scattered through the
file: streaming one chunk meant either hundreds of tiny range requests or
reading (and discarding) everything between them — a 113 MB model fetched
~340 MB, a 531 MB model 2.25 GB (4.2x). Fix: at bake, reorder meshes into
the loader's chunk order and rebuild vertex/index(LOD0+LOD1)/instance
sections so each chunk is one CONTIGUOUS byte range, and bake a chunk TOC
({first_mesh, mesh_count}). The loader builds chunks straight from the TOC
rather than re-deriving the plan — the float Morton quantisation isn't
bit-identical across toolchains (x86 baker vs wasm loader), so a re-derived
plan scatters the chunks. Format bumped to v14 (regenerate sidecars). The
reorder buckets instances by per-instance mesh_id (the baker never sets
MeshInfo.first_instance — trusting it scrambled every transform → geometry
at the origin). Multiset-verified on a 28,900-instance model: every
instance's placement + geometry preserved. Result: 531 MB fetches 531 MB
(1.0x) in 72 requests (was 2036).
Progressive streaming (concurrency cap + small chunks)
Even at 1x, geometry appeared only after ~the whole model arrived: the
browser multiplexes every in-flight Range request over one HTTP/2 conn, so
unbounded concurrency (9 in flight) split the bandwidth and nothing finished
until the end (measured: first paint after 113 of 118 MB / 35 s @ 24 Mbps).
Cap concurrent chunk loads (kMaxWebInflightChunks=2): the priority-sorted
top chunks finish and paint first, then the next → first paint 9 s. Chunk
size dropped 16->4 MB (cheap now that each chunk is one read; matches Cesium
3D Tiles / xeokit / SVF2) for smoother progression. First-paint is now
metadata-bound (~10 MB tail) — the next lever.
111/111 unit (new test_sidecar_layout: geometry preserved, contiguous layout,
Morton-identity) + 6/6 web smoke pass; desktop bake (SceneLoader) reorders
before writeSidecar; embedded web sample regenerated to v14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pending RegionView3D.update() timer registered before wm.open_mainfile()
fires during the load against freshly-allocated regions whose GPU contexts
are not yet wired, CTD-ing inside GPU_matrix_ortho_set. Cancel both the
refresh and cap-rebuild timers in a new load_pre handler, hold a
_file_loading gate from load_pre through the first on_pre_view tick (first
paint = GPU ready), and short-circuit on_depsgraph_update during the
window so its IFC-reload schedule_refresh + apply_clip_planes_direct
branches can't re-arm against unready regions.
Generated with the assistance of an AI coding tool.
Lets tooling read the wasm heap size (e.g. to verify a large sidecar
streams by byte range instead of loading whole, and to watch memory while
battle-testing big models over the network). Standard emscripten runtime
method, zero size cost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Battle-testing real sidecars over HTTP Range exposed severe thrash: a
531 MB model re-fetched 2.25 GB (4×) and never converged — viewAll puts
the whole model in frustum, so every chunk wants to be resident, and the
web async path made it worse two ways:
- A web load only consumes pool space when it COMPLETES (async), so the
per-frame issuance over-committed the pool; completions then failed
applyStreamedChunk on a full pool, the chunk re-candidated with no
cooldown, and re-fetched every frame.
- Pool growth is itself async on web (provisional sub-buffers validated
off the JS event loop), so even fetched chunks failed to alloc until
the pool caught up, and re-fetched.
Fix: gate web chunk issuance on VALIDATED free space + in-flight
reservation, and grow the pool BEFORE fetching:
- streaming_web_inflight_bytes_ reserves each in-flight load's footprint
so we never have more bytes in flight than the pool can place.
- When a visible chunk doesn't fit validated free, don't fetch — call
pool_.requestGrowth() (BufferPool: drives the async provisional grow
without allocating) and short-back-off; the chunk is fetched once,
after space exists. When the pool is saturated (model > GPU memory),
long-cooldown so a never-fitting chunk isn't re-fetched. Gating before
the evictor also kills phase-2 visible↔visible swap thrash.
- On async load failure, cool down (short if the pool can still grow,
long if saturated) instead of re-candidating next frame.
Result (manual battle tool, host.mjs + real files): 531 MB now loads
23/23 chunks, 322 MB loads 14/14 — resident climbs monotonically with
ZERO thrash warnings and a stable resident set, vs the old re-fetch loop.
The whole model resides on the GPU and stays. (Remaining ~3× ramp
over-fetch — per-chunk re-loads during the async-growth ramp + read
amplification from chunk byte-locality — is a separate efficiency
follow-up, not thrash.) 6/6 web smoke + 107/107 unit pass; desktop
unaffected (the gate is web-only; requestGrowth is a no-op wrapper there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
beginWebChunkLoad read the vertex ranges, then in the completion callback
read the index ranges, then applied — two serial round trips per chunk.
On a network that's the dominant per-chunk latency. Now both reads fire
at once and a small shared join (payloads + per-read done/ok flags) runs
the apply when the second lands, halving per-chunk RTT. Model re-lookup
still happens at apply time, so a resetScene mid-flight is dropped safely.
Per-chunk concurrency stacks with the existing across-chunk concurrency
(driveStreamingLoads issues several loads per frame); the browser caps
simultaneous connections per origin, so no explicit in-flight cap is
needed. 6/6 web smoke + 107/107 unit pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:
- ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
header (206). If a server ignores Range and returns 200, the requested
window is sliced out so it still works (without the bandwidth saving).
- ifcvFileSize: Blob size, or the URL's total length resolved up front.
- ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
- The metadata bootstrap is extracted into a source-agnostic
loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
thin entries. streaming_from_blob → streaming_from_web (now covers both).
main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).
Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: open a model in the web viewer, then launch the desktop
BonsaiViewer and open another model — the browser tab freezes, and a
fresh web tab then fails with "RequestDevice failed: Not enough memory
left". Root cause is GPU-memory contention: two heavy GPU clients on one
GPU, and the desktop app's allocations starve the browser's WebGPU
process, which reclaims our device.
We can't conjure GPU memory, but we were amplifying the symptom: with no
device-lost handler, render() kept driving a dead device —
wgpuSurfaceGetCurrentTexture returns Lost every frame and the
reconfigure + requestFrame retry becomes a tight per-RAF loop that hangs
the tab. Now the web device descriptor wires a device-lost callback that
latches device_lost_ (ignoring the intentional Destroyed reason from our
own shutdown); render() bails while set, so the loop goes idle instead of
spinning, and the console logs guidance to reload. The fresh-tab
RequestDevice OOM is genuine GPU exhaustion — surfaced as before, now with
a clearer message.
Verified the lost callback doesn't disturb Dawn-web's RequestDevice (all
5 web smoke tests still init + pass); desktop unaffected (device_lost_
stays false). The real contention path can't be reproduced in the headless
harness, so the loss handler itself is covered by review, not a test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fragment shader pre-decodes sRGB→linear to cancel the surface's
automatic linear→sRGB write encoding, so the final bytes match the GL
backend. That only holds when the render target is an sRGB format. On
desktop the surface's preferred format already is (e.g. BGRA8UnormSrgb),
but the browser canvas only offers plain BGRA8Unorm — so nothing
re-encoded and the whole image (background + models) rendered ~3× too
dark (authored bg 0.125,0.137,0.161 → ~32,35,41 collapsed to ~3,4,6).
Fix: when the surface format isn't sRGB, render through an sRGB *view* of
it — the standard WebGPU canvas pattern. surface_view_format_ is the sRGB
sibling of surface_format_ (unchanged when already sRGB, so desktop is a
no-op); configureSurface advertises it via viewFormats, the colour
pipelines (main, MSAA target, edge) target it, and render() creates the
surface view with it. The screenshot path still reads the base texture, so
its BGRA byte-order check stays on surface_format_.
Regression test: sample a 1x1 background pixel and assert it isn't crushed
dark (R,B > 20). Verified visually too — bg is now the correct dark
blue-gray and the cube is properly lit. 5/5 web smoke + 107/107 unit pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On first web load the sample stayed blank until a click/drag, then popped
in. Root cause: the main draw + cull run before driveStreamingLoads in
render(), so a chunk that becomes resident there is only painted a frame
later. On desktop the streaming thread keeps inFlightApprox() > 0 during a
load, so the render loop keeps ticking and the next frame paints it. On web
the sync MEMFS / Blob load finishes instantly (inFlightApprox stays 0), so
the single post-load requestFrame fired once and the on-demand loop went
idle before the geometry was ever drawn — until some input re-armed it.
Fix: arm a bounded settle burst (kStreamingSettleFrames) whenever there's
streaming activity — a load this frame, work still queued, or a visible
chunk not yet resident — and bleed it down over the next few frames, each
requesting one more. Covers the cull→display latency under an on-demand
loop and still quiesces at idle (no busy-rendering). General, not web-only.
Regression test: the sample must render with NO pointer input — a centred
patch (the framed cube) differs from a corner patch (background); a blank
stall leaves both as background. Verified empirically with a no-interaction
probe (canvas went from a static blank hash to a stable rendered one).
107/107 unit tests pass; 4/4 web smoke tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third Playwright case: click dead-centre on the framed sample, assert the
canvas changes (selection highlight rendered) with zero WebGPU errors. A
broken async pick would hang init or leave the canvas unchanged. All three
cases pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Desktop pick reads the pick staging buffer back with a blocking
`while(!done) waitTickInstance()` spin. On web that spin is a no-op
(Asyncify is off) and hangs the JS event loop, so click-to-select was
dead on web. This adds an async sibling that uses the spontaneous
map-callback pattern (AllowSpontaneous + the browser microtask loop)
already proven by the HiZ readback — no blocking.
- encodePickReadbackToStaging(x,y,want_normal): the pick-pass render +
copy-texel-to-staging, extracted from pickObjectAt verbatim and shared
by both readbacks (desktop sync path unchanged).
- pickObjectAtAsync(x,y,cb) [web]: encode, then map the staging buffer
with a spontaneous callback that delivers object_id to cb. One pick in
flight at a time (a pick issued mid-map is dropped → cb(0)).
- applyPickToSelection(id, add, remove): routes a pick result through the
selection state machine (replace / Shift-add / Ctrl-remove / empty-click
clear), mirroring the desktop ViewportWindow click semantics. selection_
marks dirty so the next render's uploadSelectionFlagsIfDirty flushes the
highlight.
main_web wires it: a left release under a 4px drag threshold (no orbit) is
a pick at down-position * devicePixelRatio, with Shift/Ctrl modifiers;
the result callback applies selection and requests a frame. Web + desktop
build clean; 107/107 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse two parallel hand-maintained lists in the addon-preferences
PropertyGroups into derivations from `tool.Parametric.EDIT_TYPES`:
- GizmoPreferences: the 10 `<name>: BoolProperty` annotations now
generated from the full EDIT_TYPES list.
- DefaultParameters: add `has_default_parameters` flag to
ParametricObject (set True on door/window/stair/railing/roof);
derive the 5 `<name>: PointerProperty(type=BIM<X>Properties)`
annotations and collapse the 5 hand-written `draw_expandable_panel`
blocks in `draw_default_parameters` into loops driven by the flag.
Existing `test_gizmo_preferences_field_per_registry_entry` pinned the
GizmoPreferences contract; new
`test_default_parameters_field_per_registry_entry_with_defaults`
pins the DefaultParameters contract (one-directional: flag=True
implies field present, flag=False allows absence).
Generated with the assistance of an AI coding tool.
Refs gh#8088. Array regen + multi-opening drops fan out N+1 wall recuts
per operator (one per child filling deletion + the final mirror recut),
making CSG opening-subtraction O(N^2) for a linear UX action.
Introduces tool.Geometry.batch_host_recut() — a context manager that
coalesces switch_representation and bpy.ops.bim.update_representation
calls per voided element within one operator transaction. The drain
re-reads the active representation so the recut reflects current IFC.
Wraps 7 entry points (regenerate_array, RegenerateArray, RemoveArray,
AddOpening, RecalculateFill, CloneOpening, regenerate_from_type) and
rewires 7 leaf call sites in opening.py, void/operator.py, and
mirror_parent_void_fillings_to_children.
An AST forward-compat guard pins the rewire contract: no direct
switch_representation or bpy.ops.bim.update_representation in the
three target files outside the helper definitions.
A 16-child array regen now recuts the wall once instead of 17 times.
The CSG cost per recut is unchanged; only the count is reduced.
21 new tests across three lanes (helper unit, entry-point coalescing,
AST guard) — all green.
Generated with the assistance of an AI coding tool.
Mirror the icon-click entry into wall parametric edit on the TAB key.
The dispatch in Modifier.try_applying_edit_mode had no branch for fresh
LAYER2 walls, so TAB landed in item mode instead of the parametric
draft + gizmos. Add the missing entry leg of the toggle, placed after
the generic is_object_editing branch so the finish leg still fires
when a wall is already in edit mode.
Generated with the assistance of an AI coding tool.
Adds a second Playwright case that picks the sample sidecar through the
file input, waits for the C side to confirm the blob load (console),
then asserts an orbit drag changes the canvas with zero WebGPU errors.
This exercises the #88 path distinctly from the embedded MEMFS sample —
a broken metadata-head/tail or chunk range read renders blank and fails
the orbit-changed-canvas check. Both cases pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picked files are no longer copied whole into the wasm heap. The browser
File object stays in JS (Module.__ifcvFile) and is read lazily through
Blob.slice byte ranges, so a 200-500 MB sidecar never enters wasm linear
memory — only chunk-sized slices do.
Mechanism (web-only, #if __EMSCRIPTEN__):
- JS glue (EM_JS): ifcvFileSize + ifcvReadRangeInto — slice [off,off+n)
of the File and copy it into a caller-provided heap pointer, then call
back _ifcv_on_range_done. No malloc across the boundary; C pre-sizes
the destination from the read plan.
- webReadRangesAsync: reuses planSidecarReadRanges to coalesce a range
set into Blob.slice reads (1 MB gap — each slice is an async hop),
scatters them into a destination laid out in input order, and fires a
continuation when the whole set lands. An in-flight map keyed by id
survives unordered_map rehash (scratch buffers are heap-owned).
- loadSidecarFromBlobWeb: async metadata load — head (16 B) -> index
count -> tail-to-EOF -> parseSidecarHead/Tail -> applyCachedModel, then
tags the model streaming_from_blob and frames it.
- driveStreamingLoads: blob-sourced models route to beginWebChunkLoad
(async vertex+index range reads -> applyStreamedChunk in the callback),
holding is_loading until the bytes arrive. The embedded MEMFS sample
keeps the synchronous fopen path.
shell.html stashes the File and calls _load_sidecar_from_blob_c instead of
FS.writeFile'ing the whole thing; EXPORTED_RUNTIME_METHODS=['FS'] dropped.
Desktop is untouched (the new members + driveStreamingLoads branch are all
emscripten-guarded). Web links clean; desktop rebuilds; 107/107 unit tests
pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Splits the v13 metadata wire-format knowledge out of the FILE*-bound
streaming reader into pure, buffer-based functions so the web byte-range
path (#88) can reuse it without loading the whole sidecar into the wasm
heap:
- parseSidecarHead — validates the 16-byte head, yields num_vertex_bytes
- parseSidecarTail — parses meshes/instances/georef/elements/strings
from an in-memory tail buffer, bounds-checked
- planSidecarReadRanges + SidecarReadPlan — the range-coalescing /
scatter planner, promoted out of the anonymous namespace
readSidecarMetadataOnly and the range readers now call these; desktop
behaviour is unchanged (head + tail are small, the bulk is still skipped
via seek). The metadata tail is split from the head around the bulk
sections, so a blob-backed loader just slices those two regions and
hands the bytes to the same parsers.
Closes a coverage gap: StreamingLoader had no unit tests. Adds
test_streaming_loader.cpp (7 cases: metadata round-trip, corrupt/truncated
rejection, vertex+index range scatter, head validation, tail truncation,
read-plan coalescing). 107/107 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drives the built web page in a real Chrome (channel:'chrome', so no
`playwright install`): waits for wgpu init, then asserts an orbit drag
changes the composited canvas — one check that simultaneously proves the
scene rendered, mouse input is wired, and the log overlay isn't eating
events — and that zero uncaptured WebGPU errors were logged. Every web
bring-up bug so far (blank render, error-buffer cascade, overlay
swallowing input) is this shape; this would have caught them.
serve.mjs statically serves build-web; the config launches headed
against the real GPU (--use-angle=vulkan + --ignore-gpu-blocklist are
load-bearing for a non-null adapter on Linux Chrome). node_modules and
results are gitignored. See README.md to run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On web we skip the desktop error-scope spin-wait (it blocks the JS event
loop and hangs the page). The old web addSubBuffer then judged success by
`buf != nullptr` — but Dawn-web returns a NON-NULL error buffer on OOM,
so the pool committed an invalid sub-buffer, alloc handed out slices in
it, and every chunk_bind_group built against it failed ("BindGroup is
invalid" spam + a wgpuQueueSubmit panic). Loading a model larger than the
browser's WebGPU budget triggered exactly this.
Add the grown sub-buffer as *provisional* (alloc and the capacity/free
tallies skip it) and validate it through a non-blocking AllowSpontaneous
PopErrorScope. resolveProvisionalGrowth() clears the flag when it's good,
or drops the sub-buffer and latches growth_disabled_ on a real OOM — at
which point the streaming evictor bounds the working set to what fits
instead of cascading. Only one provisional grow is in flight at a time
(growth_pending_). Desktop keeps its synchronous halve-retry path
unchanged. All 100 unit tests still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status overlay was position:fixed top/left/right with max-height
80vh and pointer-events:auto — a near-fullscreen div that both hid the
model and swallowed mouse events, so orbit drags over most of the canvas
did nothing. Move it to a small bottom-left box, set pointer-events:none
so it never intercepts navigation, and collapse it to a few dimmed lines
once the app goes live (errors re-expand it). It still auto-scrolls to
the newest line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an "Open .ifcview…" button that opens the browser's native file
chooser. The picked file's bytes are written into MEMFS and a new
exported entry point, load_uploaded_model_c, reads them back via the
existing loadSidecarFromPath: it resetScene()s the current model
(replace, not append), loads the sidecar, and viewAll()s it. Geometry
becomes resident over subsequent frames through render()'s inline
driveStreamingLoads, same as the embedded sample.
Deliberately a file picker, not drag-drop: browser file drag-drop needs
an X11 drag source (a file manager) to drag *from*, which a minimal WM
(ratpoison) doesn't provide. The native chooser is WM-independent.
Exports _load_uploaded_model_c and the FS runtime method; URL/byte-range
fetch of remote models stays for #88.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop pool walked down from 4 GB looking for the largest single
buffer the runtime would grant, then used that as the pool's first
sub-buffer and growth increment. On a stack that advertises an
effectively unbounded maxBufferSize (1 TB observed on wgpu-native here)
the walk lands on 2 GB, so loading even a 1.2 MB model allocated a 2 GB
sub-buffer. That plus the depth/MSAA/HiZ/pick attachments exhausted
VRAM, and the next tiny allocation — the ~4 KB selection_flags buffer —
failed with "Not enough memory left", invalidating its bind group and
panicking wgpuQueueSubmit.
Drop the probe and size the pool to demand, mirroring the web path:
configure a modest initial sub-buffer and let BufferPool::addSubBuffer
grow it lazily (halve-retrying to its 64 MB floor on constrained
devices). A single chunk is capped at 16 MB, so the initial sub-buffer
only has to clear that. Web keeps its 64 MB initial (Chrome contends on
large first allocations); desktop uses 256 MB to keep sub-buffer count
low for big models. The two platforms now share one createPool() (was
probeAndCreatePool — it no longer probes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register emscripten HTML5 pointer/wheel handlers that translate raw
events into ViewportCore::orbitBy / panBy / dollyBy: left-drag orbits,
middle/right-drag pans, wheel zooms. mousedown binds to the canvas;
mousemove/up bind to the window so a drag keeps tracking off-canvas. A
contextmenu suppressor lets right-drag pan without the browser menu.
Pure callbacks — no Asyncify, no sync spin — so none of the web init
gotchas apply. Pick stays deferred until the async buffer-readback
rewrite. The embedded sample is now navigable on Chrome + Firefox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the inline orbit/pan/wheel math in ViewportWindow's mouse
handlers with calls to ViewportCore::orbitBy / panBy / dollyBy. The core
methods request the frame (via the host), so the now-redundant
requestUpdate() calls drop out; the pivot-indicator afterglow and 3px
drag-promotion stay in the Qt layer where they belong. Behaviour is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The orbit navigation math lived in the Qt ViewportWindow, operating on
ViewportCore's camera fields through references. That left the web host
with no way to drive the camera — orbit/pan/zoom were Qt-only.
Lift the three pixel-delta moves into ViewportCore as orbitBy / panBy /
dollyBy so every host (Qt desktop + web) shares one implementation and
the math can't drift between platforms. panBy takes the viewport height
as a parameter (the one Qt coupling: pan's world-units-per-pixel needs
it) so the core stays toolkit-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merging more than two IFC models with the MergeProjects recipe leaves
duplicated IfcGeometricRepresentationContext entities behind. All elements
are kept, but the accumulated contexts cause later disciplines to appear
"not merged" in viewers.
This test merges three projects and asserts the elements are kept, a single
IfcProject remains, and the geometric contexts are reused rather than
accumulated. It currently fails on the context assertion, reproducing #7973.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>