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>
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>
Wire up wgpu init + scene load + RAF render so the embedded sample
sidecar paints on the canvas in both browsers.
Root-cause fix: BufferPool::addSubBuffer spin-waited on
PopErrorScope, which resolves via JS microtask on Dawn-web. The
spin blocked the JS event loop, so the microtask never fired and
the first allocation hung the page indefinitely. Skip the
error-scope dance on Emscripten; trust the buffer pointer.
ViewportCore: add initWgpuAsyncWeb (nested-callback adapter→device
chain with AllowSpontaneous mode, no spin) and loadSidecarFromPath
(Qt-free entry point). waitTickInstance becomes a no-op shim on
web; cull-threads / streaming_thread_ / wgpuSurfacePresent gated
off; chunk I/O runs inline.
main_web.cpp: AppState + initWgpuAsyncWeb → buildPipelines (+
HiZ/edge/pick) → loadSidecarFromPath → ready flag → Module._app_ptr
handoff. The RAF loop lives in shell.html (NOT here) because any
RAF helper called from inside Dawn-web's wgpu Promise.then chain
stalls the device callback.
CMakeLists.txt: EXIT_RUNTIME=0 + Module.noExitRuntime=true (shell)
keeps wasm alive past main() so the device promise lands; no
Asyncify; export _raf_tick_c so shell.html's RAF can call it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1) Async-wait spin loops in initWgpu / probeAndCreatePool / pick /
screenshot finalize all called wgpuInstanceProcessEvents in a tight
while. wgpu-native drives queued callbacks from there; Dawn-web
queues the callback for an event-loop tick that never happens
because wasm doesn't yield back to JS. Page hung at the first
await (RequestAdapter) and Firefox flagged the tab as slow.
New `waitTickInstance` helper calls emscripten_sleep(0) on
Emscripten (Asyncify unwinds wasm, JS resolves WebGPU promises,
resume) before wgpuInstanceProcessEvents drains completions into
our callback. Desktop path is unchanged. All five
`while (!done) ProcessEvents` sites switch to the helper.
2) streaming_thread_.start() inside initWgpu spawns a std::thread.
On Emscripten that needs -pthread + COOP/COEP headers from the
hosting page. None of that is wired yet, so the start is gated
`#if !defined(__EMSCRIPTEN__)`. The sync chunk-load fallback
already inside driveStreamingLoads carries the load until #88
replaces it with emscripten_fetch.
3) wgpuSurfacePresent at the end of render() aborted with
"wgpuSurfacePresent is unsupported (use requestAnimationFrame via
html5.h instead)" — Dawn-web composites the canvas at the end of
the RAF tick automatically. Call skipped on Emscripten;
WebViewportHost::requestFrame is the RAF-tick driver.
Page now loads, brings up wgpu, configures the surface, and renders
one frame with the configured background. The frame log in the
status overlay shows the first-frame startup spike (~1000 fps from
a 1 ms tick); subsequent frames are paint-on-demand, which on the
empty scene means no frames at all — consistent with the desktop
event-driven model.
Replaces the standalone wgpu-only clear-color spike in main_web.cpp
with a real WebViewportHost implementation: surface creation via the
emdawnwebgpu canvas-selector source, framebufferSize through
emscripten_get_element_css_size + dpr, requestFrame as a deferred
flag the RAF main_loop consumes, quit through emscripten_force_exit.
main_web.cpp now does the same lifecycle the desktop initWgpu shell
does: core_.initWgpu(web_limits=true) → buildPipelines → buildHiz/
Edge/Pick. The render loop runs core_.render() once per RAF tick when
the host has flagged a frame pending, with a surface reconfigure on
size changes.
Builds clean under emcc 6.0 + emdawnwebgpu (1.4 MB wasm, 278 KB JS
glue). Renders an empty scene with the configured background — the
plumbing is end-to-end through the same ViewportCore code path the
desktop build uses. No sidecar load yet: that lands with the
emscripten_fetch streaming backend (#88).
addSectionPlaneAtSurface (camera-facing auto-flip + kMaxSectionPlanes
cap check), removeSectionPlane, and clearSectionPlanes all move to
ViewportCore. ViewportWindow keeps tiny forwarders so the section
tool's input handlers (still VW) call through without seeing the move.
Each method now calls host_->requestFrame() in place of the
isExposed() + requestUpdate() gate, which means the section tool path
becomes the next piece that could exercise the WebViewportHost: a
click-to-add over WebGPU will work as soon as the host is wired,
without further core-side changes.
The frame loop — surface acquisition, parallel cull dispatch, streaming
drive, two-pass main render, HiZ resolve, edge pass, screenshot
capture, FrameStats emission, interactive / bench heartbeat, bench
summary + auto-quit — all live in ViewportCore now.
ViewportWindow::render() shrinks to the Qt-only prelude:
isExposed() guard, fpsIntegrate() (fly-mode WASD step), then
core_.render().
The overlay renderer stays Qt-bound (OverlayRenderer.h carries
QString labels). Core reaches it via two new ViewportHost virtuals:
encodeOverlaysInMainPass (section gizmos, highlights, pivot, lines,
points — in-MSAA-pass) and encodeOverlaysPostMain (corner axis,
marquee, labels — on the resolved surface). The QtViewportHost
implementation in ViewportWindow forwards each to overlays_.X().
FrameStats moves to its own Qt-free header (FrameStats.h) with
ViewportWindow::FrameStats re-exported as a using-alias so the
bonsai-side signal binding keeps working. ViewportHost::onFrameStats
replaces the placeholder 4-double signature with the typed POD.
OverlayFrame moves alongside (OverlayFrame.h) so the host overlay
callbacks can carry it without dragging Qt into core.
Bench + frame-stats + cull-tuning state (min_pixel_radius_,
motion_min_pixel_radius_, lod1_pixel_threshold_, cull_threads_enabled_,
prev_camera_*, has_prev_camera_, last_cull_was_motion_, last_visible_*,
last_cull_*ms_, last_stream_ms_, bench_*, interactive_frame_count_,
frame_time_ms_window_/_sum_/_count_/_head_) move to ViewportCore; VW
keeps reference aliases so the env-var prelude, setBenchmarkFrames,
and the various tool keybind setters keep compiling unchanged.
Smoke checks: --screenshot path renders + saves a clean PNG;
--benchmark 10 runs the warm gate, prints the per-frame log + summary,
and exits cleanly via host_->quit().
The inline screenshot path inside render() — surface-to-buffer copy,
async map, BGRA->RGBA swap into a tightly-packed RGBA8 image — moves
into core_.encodeScreenshotCapture / finalizeScreenshotCapture. render()
calls the two new helpers around its existing queueSubmit.
The PNG write itself stays Qt-bound, but it's now reached through a
new ViewportHost::saveScreenshotRgba8 virtual. The QtViewportHost
override (ViewportWindow::saveScreenshotRgba8) constructs a QImage
around the host-buffer and calls QImage::save("PNG") with the same
log lines as before; a future WebViewportHost will route the bytes
through stb_image_write or a download-URL emit instead. Either way,
the wgpu-side capture path never touches Qt again.
quit-after-screenshot now goes through host_->quit() too, so the
--screenshot CLI exit no longer reaches QCoreApplication::quit()
from render() directly.
Two small helpers + the screenshot-quit flag flip across; the render-
side capture encode + readback + PNG save itself stays in VW (those
need a stbi-style PNG writer to replace QImage::save before they can
move, and that's its own commit).
VW keeps tiny forwarders so bonsai's SceneLoader + the CLI screenshot
path don't see the move. The streaming sync-fallback gate already
reads the core-side pending_screenshot_path_, so capture timing is
unchanged.
The swapchain configuration path — WGPU_PRESENT_MODE handling,
mode-preference order (Mailbox / Immediate / FifoRelaxed / Fifo),
caps probe, cfg.alphaMode wiring, and the on-resize depth + MSAA +
HiZ texture reallocation + bind-group invalidation — all move to
ViewportCore. The only VW-side concern was a QString::fromLatin1 in
the advertised-modes log, which becomes a std::string concat with the
same output.
ViewportWindow's render() / resizeEvent / surface-outdated retry paths
call core_.configureSurface() now. createSurface() (which returns the
platform-specific WGPUSurface from the host) stays VW because Qt
platform discovery has to happen on the Qt side.
The whole pick pipeline (R32UInt + RGBA16F MRT, depth attachment,
ping-pong staging, single-pixel + rect readback) plus the public
pickObjectAt / pickSurfaceAt / picksInRect / pickMeshLocalAt / raycast
API and the rayAabbSlab / rayTriMT / rayAABBHit helpers all move to
ViewportCore. ViewportWindow keeps tiny forwarder methods so the
bonsai input + tool callers (mouseRelease, marquee, section tool,
Length/Area refinement) stay compiling.
MeshLocalPick + RaycastHit follow as nested types on ViewportCore;
ViewportWindow re-exports them as using-aliases to preserve the
ViewportWindow::MeshLocalPick / ViewportWindow::RaycastHit names
existing callers (and a couple of bonsai tests) reach for.
State migrated: pick_color_texture_/_view_, pick_normal_texture_/_view_,
pick_depth_texture_/_view_, pick_staging_buffer_, pick_normal_staging_buffer_,
pick_w_/_h_, box_pick_staging_buffer_/_capacity_. The pick_pipeline_
itself was already aliased.
The pick path no longer reaches into VW for any GPU state, so the
render() / shutdown() callers become core_.X() forwards and the pick
infrastructure can be exercised by the future web build without going
through Qt.
buildEdgePipeline, encodeEdgePass, releaseEdgeResources + the EDGE_WGSL
shader source all move to ViewportCore. The edge_bind_group_ + the
edges_enabled_ flag come along too (the latter aliased on VW so the
edge-toggle keybind keeps compiling).
The pass binds the now-core-side depth_view_ directly, so there's no
remaining cross-side state dependency for edge rendering. render()
still calls core_.encodeEdgePass(enc, surface_view) — once render()
itself moves, the call collapses to a sibling method invocation.
The whole HiZ occlusion-cull pipeline (resolve pass, ping-pong async
readback, CPU mip pyramid, per-instance AABB lookup, WGPU_HIZ_TRACE
diagnostic) moves to ViewportCore. The main render-pass depth
attachment and MSAA color attachment come along too — they're shared
between render() (still VW) and the HiZ resolve pass (now core).
Methods migrated: buildHizPipeline, ensureHizTextures,
releaseHizResources, encodeHizResolve, startHizMap, drainHizReadbacks,
aabbOccludedByHiz, ensureDepthTexture, releaseDepthTexture,
ensureMsaaColorTexture, releaseMsaaColorTexture. HIZ_WGSL moves with
them into ViewportCore.cpp's anon namespace.
State migrated: hiz_enabled_, hiz_valid_, hiz_vp_, hiz_pyramid_,
hiz_mip_offset_/_w_/_h_, hiz_reject_count_, hiz_trace_budget_,
hiz_uniform_buffer_, hiz_bind_group_, hiz_resolve_texture_/_view_/_w_/_h_,
hiz_padded_bpr_, hiz_staging_buffers_[2], hiz_slot_vp_[2],
hiz_slot_state_[2], hiz_write_idx_, depth_texture_/_view_/_w_/_h_,
msaa_color_texture_/_view_/_w_/_h_, plus the HizSlotState enum +
HIZ_SLOTS + HIZ_BASE_W constants. ViewportWindow keeps reference
aliases on every field VW.cpp still touches so the render path
compiles unchanged.
The HizOccludedFn shim in render() now wraps core_.aabbOccludedByHiz
directly. Once the render path itself moves into core, that shim
disappears and cull can call aabbOccludedByHiz as a sibling method.
applyCachedModel, uploadMeshChunk, uploadInstanceChunk, finalizeModel
all live in ViewportCore now. The bonsai-facing public entry points on
ViewportWindow are one-line forwarders that keep
SceneLoader → ViewportWindow* binding intact.
State + helpers that came along:
- pending_direct_loads_ (the SidecarData staging map keyed by model_id)
- initial_view_applied_ (auto-viewAll suppression; aliased on VW so
setCamera can still flip it)
- getOrCreateDirectStaging + createBufferWithData (anon namespace
helpers on the core side)
The Qt-bound isExposed() / requestUpdate() pair on the
applyCachedModel tail becomes host_->requestFrame() — the
QtViewportHost forwards to requestUpdate(); a WebViewportHost will
forward to requestAnimationFrame.
The sidecar load path is now fully core-side. ViewportWindow no
longer owns any of the model-creation machinery; everything from
"here's a parsed sidecar" to "fully-built models_gpu_ entry with
empty pool slices waiting on streaming" runs through ViewportCore.
CPU cull (frustum + contribution + LOD + opaque/transparent partition)
and its companion GPU-upload step now live in ViewportCore. The HiZ
occlusion test stays in VW — the pyramid + async readback machinery
hasn't migrated yet — and is plumbed through a
ViewportCore::HizOccludedFn callback the render path binds when HiZ
is enabled-and-fresh. Null callback means "no occlusion test", which
keeps the cull path host-agnostic.
extractFrustumPlanes + aabbInFrustum moved up into CameraMath.h so
both VW's render() (where the planes are extracted) and core's cull
(where they're tested) can share without one #including the other.
LOD-debug counters (lod1_dbg_count_, lod0_dbg_eligible_count_,
lod0_dbg_no_lod1_count_, lod1_dbg_tris_saved_) moved to core too —
they're written by cull and read/reset by VW's still-here per-frame
[frame] heartbeat through reference aliases.
The per-frame streaming residency driver — LRU/priority eviction,
worker-result drain, candidate selection, click-and-track diagnostic,
sync-fallback for screenshot capture — now lives in ViewportCore.
ViewportWindow::driveStreamingLoads is a one-line forwarder.
Streaming-related state moves to core with reference aliases on VW:
streaming_{loads,more_pending,candidates,evictions_{lru,pri},drained,
blocked_oom}_this_frame_, streaming_debug_, tracked_{object_id,
chunk_mid,chunk_idx,was_resident}_, and pending_screenshot_path_. The
pick handler and bench-warm gate (still VW) read/write through the
aliases unchanged.
Qt-isms in the body were replaced en route:
- QFileInfo(...).completeBaseName() → std::filesystem::path::stem()
- requestUpdate() → host_->requestFrame()
- QString::number(x, 'f', N) in numeric logs → raw double / int (we lose
fixed-precision in a couple of diag lines; acceptable tradeoff).
host_->requestFrame() means the streaming loop is now host-agnostic:
the WebViewportHost will provide its own requestAnimationFrame
equivalent when it lands.
The chunk-state machine that mediates between the streaming pool and the
per-chunk WGPU bind groups now lives in ViewportCore. ViewportWindow's
remaining streaming code (driveStreamingLoads, finalizeModel) calls
through to core_.applyStreamedChunk / core_.unloadChunk /
core_.loadChunkBytesAndUploadGpu, and the chunk request builder is a
static helper on ViewportCore so VW's still-here driveStreamingLoads can
enqueue requests against streaming_thread_ without reimplementing it.
streaming_frame_idx_ moved to core (alongside the residency clock),
aliased on VW so the inline streaming logic stays compiling. The
mesh-volume side effect inside applyStreamedChunk now fires a
std::function<void()> callback (core_.on_volume_dirty_) instead of
reaching into ViewportWindow::updateVolumeReadout — VW wires the
callback in its ctor, non-Qt hosts leave it null and pay nothing.
computeMeshLocalVolumeQuantised moved to ViewportCore.cpp's anonymous
namespace; it was only called by applyStreamedChunk.
Per-frame uniform packing now lives in ViewportCore::updateFrameUniforms,
which reads the camera (via the already-migrated buildViewProj), the
section_planes_ vector, and the xray_alpha_cap_ scalar — all of which
have moved into ViewportCore alongside frame_uniform_buffer_.
ViewportWindow keeps reference-aliases on section_planes_ and
xray_alpha_cap_ so the section-tool and X-ray toggle (still Qt-input-
bound, still living in VW) keep compiling unchanged. The render-path
caller in VW::render now does core_.updateFrameUniforms().
Extracted SectionPlane into its own Qt-free header (SectionPlane.h)
so ViewportCore doesn't have to include OverlayRenderer.h's QString /
QHash. OverlayRenderer.h re-exports it.
The instance/adapter/device/queue/pool/surface-format wgpu lifecycle now
lives in ViewportCore — including the OOM-scoped pool size probe and
the worker-thread startup. ViewportWindow::initWgpu becomes a Qt shell
that handles env-var tuning + nav-button preset wiring, then delegates
to core_.initWgpu(); the VW-only pipeline builders (HiZ, edge, overlays,
pick) still run after. ViewportWindow::shutdown drops the VW-only
resources (depth, msaa, hiz, edge, overlays, pick) and lets
core_.shutdown() release the shared wgpu handles it now owns.
The wgpu-native log callback (wgpuSetLogCallback / WGPULogLevel) is
gated on !__EMSCRIPTEN__: it's not part of the W3C spec header, and
the emdawnwebgpu port doesn't ship wgpu.h — validation errors there
land in the browser console regardless.
Drive-by: update test_federation to compare HomeView::target as
Eigen::Vector3f (left stale by #79 when QVector3D was retired).
Move the main render pipeline construction + the selection flags
buffer/bind group lifecycle. Both buildPipelines and the selection
flags methods produce/consume state ViewportCore already owns
(main_pipeline_, frame_bgl_, etc.) plus a handful of "frame
infrastructure" fields this commit also brings across.
State moved (7 fields):
WGPUBuffer frame_uniform_buffer_
WGPUBindGroup frame_bind_group_
WGPUBuffer selection_flags_buffer_
uint32_t selection_flags_capacity_
std::vector<u32> selection_flags_scratch_
SelectionState selection_
VisibilityState visibility_
Methods moved:
buildPipelines (~150 lines + 320-line MAIN_WGSL string)
ensureSelectionFlagsBuffer (~60 lines)
uploadSelectionFlagsIfDirty (~10 lines)
Plus the MAIN_WGSL constant + the svFromCStr helper into
ViewportCore.cpp's anonymous namespace. ViewportWindow.cpp keeps its
own svFromCStr copy (still used by 50+ label fields in the not-yet-
moved pipeline builders + render encoders).
Shared constants extracted to ViewportCore.h:
kMaxSectionPlanes (was OverlayRenderer::kMaxSectionPlanes — assert
in VW.cpp keeps them in sync)
kViewportSampleCount (was SAMPLE_COUNT in VW; VW keeps a static
constexpr alias for the existing callsites)
struct FrameUniforms (canonical layout for the per-frame UBO,
consumed by both core's buildPipelines and
VW's still-in-flight updateFrameUniforms)
Builds: desktop / bonsai / web all green. Tests 100/100.
Tiny followup to #84-i — move the const-lookup volume helpers used by
bonsai's measurement HUD:
double volumeOfObjects(const std::vector<uint32_t>&) const
vector<pair<uint32_t, double>> volumesPerObject(
const std::vector<uint32_t>&) const
The det3OfPlacement static helper moves with them into ViewportCore.cpp's
anonymous namespace (the original kept its mirror in
ViewportWindow.cpp; ViewportWindow's own internal callers are gone now
since these methods moved).
Pure read of models_gpu_ + mesh_local_volumes — all in core already.
Trivial transplant.
Builds: desktop / bonsai / web all green. Tests 100/100.
Move the cluster of camera-state mutators + per-object AABB helpers
now that the camera fields all live in ViewportCore. CameraState
struct is canonical in core; ViewportWindow keeps a `using` alias
so bonsai's HomeView round-trip (Commands.cpp setHome / restoreHome)
compiles unchanged.
Moved:
void viewAll()
void setCamera(...) — pitch + distance clamping included
void setStandardView(yaw, pitch) — bypasses clamp for ±90°
void toggleProjection()
std::string cameraString() const
CameraState cameraState() const
void frameAabb(mn, mx, padding)
bool computeObjectAabb(id, float[3], float[3]) const
bool computeObjectAabb(id, Eigen::Vector3f&, Eigen::Vector3f&) const
ViewportWindow keeps thin forwarders for the public ones (bonsai
calls them). setCamera additionally flips initial_view_applied_
on the VW side — the auto-viewAll suppression flag isn't in core
yet because the trigger for auto-viewAll lives in the still-in-VW
applyCachedModel path.
The isExposed()+requestUpdate() Qt pattern inside the moved bodies
becomes host_->requestFrame(); two viewAll/toggleProjection diagnostic
prints become fprintf since Log::info() doesn't reach into core.cpp
through the Qt logging surface.
Builds: desktop / bonsai / web all green. Tests 100/100.
Move the three camera-math methods that compute view/projection
matrices, scene bounds, and per-chunk screen footprint for the
streaming priority signal:
void buildViewProj(Eigen::Matrix4f&, Eigen::Matrix4f&) const
bool computeSceneAabb(float[3], float[3]) const
float chunkScreenAreaPx(const ModelGpuData::Chunk&,
const Eigen::Matrix4f&) const
Plus the orbitEye helper (anonymous namespace in ViewportCore.cpp;
the qDegreesToRadians dep got swapped for an inline M_PI/180 constant).
ViewportWindow.cpp's 9 internal callers (cull, streaming, pick,
render, debug) updated to use core_.buildViewProj() etc. The
buildViewProj forwarder stays out of ViewportWindow.h since no
external caller needs it — bonsai/minimal both go through
public API methods like viewAll which still wrap core_ access
on the VW side.
Builds: desktop / bonsai / web all green. Tests 100/100.
Move the camera/projection/clear-color fields that buildViewProj,
updateFrameUniforms, the cull screen-area projector, and the bonsai-
side cameraState/setCamera/viewAll surface depend on. Same alias
pattern; no method bodies move in this commit — the next one moves
the camera math methods now that all their state is in core.
State moved (12 fields):
int configured_w_, configured_h_
float camera_target_[3], camera_distance_
float camera_yaw_deg_, camera_pitch_deg_, camera_fov_y_deg_
float camera_near_, camera_far_
bool projection_ortho_
Eigen::Vector4f background_color_
ViewportWindow keeps reference aliases for each (including a proper
`float (&camera_target_)[3]` reference-to-array binding) so the
~150 call sites that touch camera state stay unchanged. Aliases
collapse when their owning methods migrate.
Builds: desktop / bonsai / web all green. Tests 100/100.
Move the eight scene-mutation methods that drive bonsai's load/unload
and georeference setters, plus the per-model GPU teardown helper.
All are mechanical transplants — no logic change — so behaviour stays
identical; only the owner has changed.
Methods moved (ViewportWindow public-API methods stay as forwarders
to keep the bonsai-side callers compiling):
removeModel / resetScene / hideModel / showModel
setFederatedFalseOrigin
setModelCoordinateOperation
setModelTransformation
recomposeAndUploadModel
State moved:
bool wgpu_initialized_ (storage → core_, alias kept in VW for
the initWgpu call site that still flips
it; goes when initWgpu moves)
Free function moved:
releaseWgpuModelGpuData(ModelGpuData&, BufferPool&) → ViewportCore.cpp
(must live in IfcViewerCore now that ViewportCore.cpp's
removeModel / resetScene call it; ViewportWindow.cpp's remaining
two call sites continue to resolve through ModelGpuData.h's
declaration — same linker view, different definition TU)
The `if (isExposed()) requestUpdate()` Qt pattern inside the moved
bodies became `host_->requestFrame()` since ViewportCore can't see
QWindow; the desktop ViewportHost override at the bottom of
ViewportWindow.cpp continues to translate that into requestUpdate().
Builds: desktop / bonsai / web all green. Tests 100/100.
Move two pure-read methods (no GPU touch, no Qt) that the bonsai
measurement / federation-origin paths use:
bool findInstance(uint32_t, InstanceLookup&) const
bool firstGeometryPointWorldM(uint32_t, Vector3d&) const
ViewportWindow keeps both public-API method names — they now forward
to core_ for the implementation so existing callers in
bonsaiviewer/Measurement.cpp + Federation hooks don't have to change.
The InstanceLookup type also stays a `using` alias in ViewportWindow
(was added in #74).
Both methods were already de-Qt'd (`findInstance` delegates to
InstanceCompose; `firstGeometryPointWorldM` is pure Eigen). The move
is a straight transplant — no behaviour change.
Builds: desktop / bonsai / web all green. Tests 100/100.
First method-body migration. composeInstanceFromPlacement composes the
federated-false-origin × model-transformation × coordinate-operation ×
placement chain and re-derives the world AABB; it's a small,
self-contained method that only reads scene state and one matrix.
Moved:
Eigen::Matrix4d federated_false_origin_meters_ (storage → core_)
void composeInstanceFromPlacement(InstanceCpu&, ...) (body → core_)
ViewportWindow keeps:
- alias reference to federated_false_origin_meters_ (existing
setFederatedFalseOrigin call site still writes through it)
- no method declaration — internal callers route through core_
Internal caller (recomposeAndUploadModel) now invokes
core_.composeInstanceFromPlacement; once recomposeAndUploadModel
itself moves into ViewportCore the call shortens back.
Pattern for the rest of #84: state moves, then method body moves,
then internal callers update. Each commit leaves desktop / bonsai /
web green and tests 100/100. This is one of many such steps.
Move the five scene-state fields that drive per-model GPU upload + the
streaming residency loop into ViewportCore:
BufferPool pool_ — vertex+index sub-allocator
StreamingThread streaming_thread_ — background chunk reader
std::unordered_map<uint32_t, ModelGpuData> models_gpu_
— per-model state
uint32_t next_model_id_ — model-id allocator
uint32_t next_object_id_ — globally-unique object-id allocator
ViewportCore.h gains transitive includes for BufferPool / StreamingThread
/ ModelGpuData; ViewportWindow keeps the same names as reference aliases
so existing method bodies that touch them don't have to change.
Same risk profile as #84-a and #84-b: the storage moved but the values
are still set and consumed by the same code paths, so behaviour stays
identical.
Builds: desktop / bonsai / web all green. Tests 100/100.
Move the 15 pipeline + bind-group-layout + shader-module handles
that buildPipelines / buildEdgePipeline / buildPickPipeline write to.
Same pattern as #84-a: storage lives in ViewportCore, ViewportWindow
keeps reference aliases so existing builder-method bodies don't
have to acquire a `core_.` prefix at every touch point.
Moved fields:
Main render group:
main_shader_module_, frame_bgl_ (group 0), model_bgl_ (group 1),
pipeline_layout_, main_pipeline_, main_pipeline_transparent_
HiZ occlusion-cull group:
hiz_shader_module_, hiz_bgl_, hiz_pipeline_layout_, hiz_pipeline_
Edge silhouette group:
edge_shader_module_, edge_bgl_, edge_pipeline_layout_, edge_pipeline_
Pick pass:
pick_pipeline_ (reuses pipeline_layout_ — same set of bindings)
ViewportWindow's constructor binds 16 new alias references after
the 7 lifecycle ones from #84-a; member-init order matches
declaration order so core_ is constructed before any alias binds.
Builds: desktop / bonsai / web all green. Tests 100/100.
First chunk of the #84 ViewportCore extraction. The seven wgpu lifecycle
handles (instance, adapter, device, queue, surface, surface_format,
surface_configured) now live as ViewportCore members; ViewportWindow
keeps reference aliases pointing at ViewportCore's storage so its
existing render-method bodies don't need a `core_.` prefix added at
every call site — 230+ touches deferred until each method moves
across.
Member init order in ViewportWindow's constructor:
core_(this) → constructs ViewportCore with host_=this
instance_(core_.instance_) → binds the alias to core_'s field
… same for adapter/device/queue/surface/…
Friend declaration on ViewportCore::ViewportWindow lets the references
bind to its private fields. The friend bond shrinks each commit as
render methods (and their `device_` / `queue_` references) migrate into
ViewportCore proper; the goal state is no friend and no aliases.
Next #84 chunks (separate commits) move pipelines, models_gpu_, pool_,
streaming_thread_, then the render/cull/encode methods. Each leaves
the desktop build green.
Builds: desktop / bonsai / web all green. Tests 100/100.
Last round of straight-swap Qt value types in ViewportWindow + its
overlay co-pilot.
setBackgroundColor(const QColor&) → (float r, float g, float b, float a)
QColor background_color_ → Eigen::Vector4f (linear, 0..1)
QPoint {nav_,box_select_,fps_, } → Eigen::Vector2i
{section_drag_start_mouse_}
QSet<int> fps_keys_held_ → std::unordered_set<int>
QElapsedTimer fps_last_tick_, → Stopwatch (new header in
fly_render_clock_, IfcViewerCore — std::chrono-
render_thread_local_ backed, exposes the existing
timers in render() QElapsedTimer .start/.restart/
.elapsed/.nsecsElapsed surface)
Also propagates the QPoint → Eigen::Vector2i change through
OverlayRenderer::encodeMarquee since the marquee corner coords flow
through that interface.
API-level helpers:
toV2i(QPoint) — small inline in ViewportWindow.cpp, isolates
the QMouseEvent→Vector2i conversion at the
five mouse-event handlers
Stopwatch.h — new file, IfcViewerCore. Same call shape as
QElapsedTimer; backed by std::chrono::steady_clock.
QSet method swaps:
.isEmpty() → .empty()
.contains(k) → .count(k) (C++17, no std contains() until C++20)
.remove(k) → .erase(k)
Eigen::Vector2i doesn't have .manhattanLength(); the box-select drag
threshold uses std::abs(diff.x()) + std::abs(diff.y()) inline.
Bonsai side: View.cpp's setBackgroundColor wrapper now decomposes the
QColor into floats at the call site (kept locally so the bonsai UI
keeps its QColor-driven theming).
Closes#81 + the QElapsedTimer half of #83. QTimer
(pivot_indicator_hide_timer_) still uses Qt — it needs the host's
scheduleOnce mechanism that lands with #85.
Builds: desktop / bonsai / web all green. Tests 100/100.
Take QString out of ViewportWindow's outward-facing surface so it can
eventually move into a Qt-free ViewportCore:
void queueLoadSidecar(const QString&) → (const std::string&)
uint32_t loadSidecar(const QString&) → (const std::string&)
QString cameraString() const → std::string …
void captureNextFrameToPng(const QString&, bool)
→ (const std::string&, bool)
void setHudText(const QString&) → (const std::string&)
Internal members also moved off QString:
std::deque<QString> pending_sidecars_ → std::deque<std::string>
QString pending_screenshot_path_ → std::string
Implementation strategy: convert at the boundary where ViewportWindow
still leans on Qt internals — `loadSidecar` bridges to QString once
for QFile/QDir/QFileInfo path handling; the screenshot save path
constructs a QString locally for QImage::save; the OverlayRenderer's
HUD setter still takes QString so setHudText converts before calling
through. Each of those bridges goes away when ViewportCore lands and
OverlayRenderer / SceneLoader / SidecarBuilder get their own de-Qt
sweeps. cameraString now produces its CSV via snprintf — no QString
ever instantiated.
Bonsai-side updates (compile-only):
ifcviewer-minimal/main.cpp — queueLoadSidecar / captureNextFrameToPng
callers add .toStdString() on the QString
parser result
modules/viewport/View.cpp — setHudText callers add .toStdString() to
their `QString::arg(...)` formatter chains;
two `QString()` empty sentinels become
`std::string()`
Measurement.cpp — same pattern, two setHudText sites
ifcviewer/LengthMeasurement.cpp — same, three sites
The cameraString string-streaming fix-up in ViewportWindow.cpp drops
the temporary .toUtf8().constData() bridge from #82 — Log::Stream's
std::string overload now handles it directly.
Builds: desktop / bonsai / web all green. Tests 100/100. Closes#80.
Add Log.h (in IfcViewerCore) — a tiny stream-style logger that backs
fprintf(stderr,...), with overloads for the common primitives + char
strings. Mimics qInfo()/qWarning()'s syntax surface enough that
mass-replacing qInfo()→Log::info() and qWarning()→Log::warn() keeps
existing call sites parsing unchanged; .noquote() / .nospace() exist
as compat no-ops so chained qInfo().noquote()<<x<<y patterns survive.
QString streaming is a transitional concern — the QString → std::string
sweep (#80) hasn't landed yet, so ViewportWindow and friends still
construct QStrings for log payloads. LogQt.h (in IfcViewer, not Core)
adds the QString / QStringView operator<< overloads so those streaming
sites work without source changes during the in-flight Qt removal.
When #80 retires QString, LogQt.h drops out.
ViewportWindow.cpp: 132 qInfo/qWarning callsites converted. The two
printf-style qInfo("fmt %s", ...) callsites get fprintf with explicit
[info]/[warn] prefixes to keep the output discoverable.
Also de-Qt'd:
AreaMeasurement.cpp — 1 qInfo("fmt", …) → fprintf
SceneLoader.cpp — 4 qDebug + 1 qWarning printf-style → fprintf
GeometryStreamer.cpp — 2 qDebug printf-style → fprintf
ifcviewer-minimal/main.cpp — 2 qWarning << → Log::warn
Drops <QDebug> from each. Closes#82.
Builds: desktop / bonsai / web all green. Tests 100/100 pass.
Replace Qt math wrappers with Eigen across ViewportWindow, OverlayRenderer,
Federation, and the bonsai-side viewport modules. Eigen was already the
canonical type for the actually-important matrix work (InstanceCompose,
ModelGpuData, federation matrices); QVector3D/QVector4D/QMatrix4x4 were
leftover from when Qt was the path of least resistance. They offered
nothing over Eigen for our use case beyond a few graphics helpers
(lookAt / perspective / ortho) which were 30 lines to write.
Substitutions:
QMatrix4x4 → Eigen::Matrix4f
QVector2D → Eigen::Vector2f
QVector3D → Eigen::Vector3f
QVector4D → Eigen::Vector4f
API rewrites:
.lengthSquared() → .squaredNorm()
.length() → .norm()
.isNull() → .isZero()
.setToIdentity() → .setIdentity()
.constData() → .data()
.toVector3D() → .head<3>()
.inverted(&ok) → tryInvert4f(M, out)
Q::dotProduct(a,b) → a.dot(b)
Q::crossProduct(a,b) → a.cross(b)
QMat4x4(... row-major) → Eigen::Map<const Matrix4f>(col-major buf)
QMat4x4().lookAt(...) → lookAtRH(eye, target, up)
QMat4x4().perspective(.) → perspectiveYFovGL(fovy, aspect, n, f)
QMat4x4().ortho(...) → orthoGL(l, r, b, t, n, f)
Default-init divergence handled explicitly (QMatrix4x4() = identity,
QVector3D() = zero; Eigen leaves both uninitialized). Public API
(CameraState, HomeView, ViewportWindow::computeObjectAabb, the
addSectionPlaneAtSurface / pickSurfaceAt / raycast signatures) follows
through to Eigen too; bonsai-side View.cpp and Commands.cpp updated to
match.
Camera helpers (lookAtRH, perspectiveYFovGL, orthoGL, tryInvert4f)
extracted to a new CameraMath.h so OverlayRenderer's gizmo MVP and
ViewportWindow's buildViewProj share the same definitions. Federation
drops its <QVector3D> include in favour of <Eigen/Dense> (already had
the latter for the georef matrices).
Builds: desktop IfcViewerMinimal ✓, BonsaiViewer ✓, web IfcViewerWeb ✓.
Tests: 100/100 pass. Closes#78 + #79; opens the door for #80-#83.
Define the boundary the Path-A web-bring-up refactor will move things
across:
- ViewportHost.h is the embedder interface — surface creation,
framebuffer geometry, frame scheduling, quit, and notification
callbacks (onObjectPicked, onToolModeChanged, …). Desktop hosts
forward notifications to Q_SIGNALS; the future web host pushes
them to JS callbacks.
- ViewportCore.{h,cpp} is the platform-agnostic render-core target.
Empty today — the body fills in across the #78-#86 sequence as
each Qt subsystem (matrices, vectors, strings, timers, render
path, input) gets de-Qt'd and moved over.
- ViewportWindow now multiply-inherits ViewportHost alongside QWindow
and implements the host overrides as thin forwarders: createSurface
returns the cached surface_, requestFrame -> requestUpdate, quit ->
QCoreApplication::quit, onObjectPicked -> emit objectPicked.
Renamed the DPR accessor `dpr()` (vs `devicePixelRatio`) to avoid
the inherited-virtual clash with QWindow's qreal-returning version.
No method movement yet — this is purely the architectural scaffold so
subsequent commits have a destination.
First Emscripten target. main_web.cpp brings up a wgpu instance against
a <canvas id="viewer-canvas">, requests adapter+device asynchronously
via the standard webgpu.h callback chain, configures the surface, and
clears to the BonsaiViewer slate background on each RAF tick. No
sidecar load, no pipelines, no scene state yet — the goal is to end-
to-end verify the build + canvas + wgpu plumbing.
src/ifcviewer-web/ is a separate CMake root (not a subdir under the
desktop cmake/CMakeLists.txt) so the web build doesn't have to opt out
of Qt / OpenCASCADE / IfcGeom find_packages it can't satisfy. It adds
src/ifcviewer EXCLUDE_FROM_ALL and consumes only IfcViewerCore.
src/ifcviewer/CMakeLists.txt now gates the wgpu-native fetch + the
Qt-using IfcViewer target + install commands behind NOT EMSCRIPTEN.
The wgpu_native link target still resolves under Emscripten as an
INTERFACE library that activates --use-port=emdawnwebgpu (Dawn's
webgpu.h, replaces the legacy -sUSE_WEBGPU=1).
Build:
source path/to/emsdk_env.sh
emcmake cmake -S src/ifcviewer-web -B build-web -G Ninja
ninja -C build-web
python3 -m http.server --directory build-web 8080
# open http://localhost:8080/IfcViewerWeb.html in a WebGPU-capable
# browser (Chrome 113+, Edge 113+).
Phase B step 3 of #45.
Remove the bonsaiviewer-autodesk Cargo examples that were used for local UI and dialog experiments.
Generated with the assistance of an AI coding tool.
Pull the Qt-free / OpenCASCADE-free files out of the IfcViewer target
into a new IfcViewerCore static lib: BufferPool, ChunkPlanner,
InstanceCompose, SidecarCache, StreamingLoader, StreamingThread,
LodBuilder, plus the header-only InstancedGeometry / ModelGpuData /
VertexQuantization / Selection / Visibility headers. IfcViewer PUBLIC-
links IfcViewerCore so existing consumers see no change.
This is the boundary the Emscripten web target will link against —
keeps Qt, IfcGeom, OpenCASCADE, CGAL, and Boost out of the wasm build.
Explicit file list, not glob, because the boundary is the whole point.
Replace qInfo() growth-event logging with fprintf(stderr,...) so
BufferPool.cpp has no Qt touchpoints. Lets the test target drop its
Qt6::Core link too. Prerequisite for the IfcViewerCore library boundary
the web target will link against.