Right-clicking a model in the models panel now offers "View Selected Model",
which frames the camera on just that model's geometry — View All, scoped to
one model. With several models selected the action reads "View Selected
Models" and frames their union, matching how the panel's existing Move to
Group already treats a multi-selection.
The AABB fold behind viewAll moves into InstanceCompose, which exists so this
kind of logic is unit-testable without a Qt window or a wgpu device (populating
ViewportCore's model map needs a real GPU, so the fold was previously
untestable in place). It splits in two:
- sceneWorldAabb — every VISIBLE model, what viewAll frames.
- modelsWorldAabb — only the named models, hidden or not. A model the caller
named explicitly is framed even if hidden; second-guessing
that is worse than honouring it. Models with no loaded
geometry contribute nothing, and if none of them do the
camera is left alone rather than flying to the origin.
Both are covered by six new cases in test_instance_compose (131 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createWgpuSurface() calls wgpu_macos_attach_metal_layer() in the Q_OS_MAC
branch at the top of the file, but the only #include of MetalSurface_mac.h
sat ~450 lines below the call site, so macOS builds failed with 'use of
undeclared identifier'. The header self-guards on __APPLE__, so move the
include up into the early platform block next to <Windows.h>; the lone
call site is the sole consumer, so the late include was dead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Backface Culling" checkbox persisted a value and reflected it, but
nothing consumed AppSettings::backfaceCulling — the opaque pipeline
hardcoded cullMode = Back, so toggling had no effect.
Build a second opaque pipeline (cullMode None) alongside the culled one and
pick between them per-frame from a backface_culling_ flag; setBackfaceCulling
flips the flag and requests a redraw (no rebuild). ViewportWindow forwards
it, and MainWindow applies the persisted value at startup and re-applies on
change — same wiring as the nav preset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
initWgpu() runs on the first exposeEvent, after MainWindow has already
applied the nav preset saved in Settings. It then unconditionally
re-applied "blender" whenever WGPU_NAV_PRESET was unset, silently
overriding the user's saved choice — so the applied navigation didn't
match what Settings showed.
Only apply the preset from WGPU_NAV_PRESET when that env override is
actually set; otherwise leave the current preset (MainWindow's persisted
choice, or the blender default). The startup log now reports the effective
orbit/pan bindings rather than a hardcoded name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously Del always removed the most recently added section plane. Now a
plane can be picked and deleted individually:
- ViewportCore tracks a selected plane index, kept valid as planes are
added (the new one becomes selected), removed, or cleared.
- Clicking a gizmo with the section tool active selects that plane.
- The section gizmo geometry is baked white and coloured via its per-plane
tint, so the selected plane draws in a bright amber highlight while the
rest stay red (unchanged look).
- Del/Backspace removes the selected plane, falling back to the most recent
one when nothing is selected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the two overloaded model identifiers and make object_id
assignment single-authority, fixing a pick -> properties mismatch.
Identifiers:
- Per-model UUID fed_id -> model_id; the uint32 runtime handle
model_id -> session_model_id (SessionState accessors + mirror hashes
renamed to match). "fed_id" was a misnomer -- the federation is the
whole collection, not one model.
object_id assignment (fixes wrong class on click):
- Producers (GeometryStreamer, .ifcview sidecar) now stamp model-LOCAL
object_ids; ViewportCore::applyCachedModel is the sole authority that
assigns the session-global id (base + local). Removed
SceneLoader::next_object_id_, GeometryStreamer::lastObjectId(), and the
streamer's start_object_id parameter.
- The element table is stamped by the same base on both load paths
(applySidecarData and onStreamerFinished), so registry ids match the
ids pick returns. Previously the sidecar path double-rebased instances
vs the registry (click IfcSite -> showed IfcDoor); the live-stream path
had the same latent mismatch. Both closed.
Naming / cleanup:
- SceneLoader::addFiles -> queueModels; startStreamLoadFor ->
loadFromGeometryStreamer; readSidecarMetadataOnly -> readSidecarMetadata.
- Federation::addModel takes an explicit display_name (no QFileInfo
fallback); callers pass QFileInfo(path).fileName().
- Disambiguate cryptic short locals (d->sidecar, m->model, c->chunk, ...)
in SceneLoader, Federation, ViewportWindow, AreaMeasurement,
SectionGizmoRenderer, and the SidecarData/SidecarReadPlan spots in
ViewportCore.
Tests: 125/125 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the streamer/sidecar transfer and record types to describe what
they are rather than how they move:
MeshChunk -> StreamedMesh
InstanceChunk -> StreamedInstance
InstanceCpu -> InstanceInfo
PackedElementInfo -> ElementTableRecord
uploadMeshChunk -> uploadStreamedMesh
uploadInstanceChunk -> uploadStreamedInstance
buildMeshChunk -> buildStreamedMesh
and the two post-index sidecar metadata blocks:
"critical" metadata -> "geometry" metadata (meshes/instances/georef/TOC)
"deferred" metadata -> "element" metadata (elements + string table)
parseSidecarCritical -> parseSidecarGeometryMetadata
parseSidecarDeferred -> parseSidecarElementMetadata
The one behavioural change: the element hierarchy (parent_id) was
carried through ElementInfo, ElementTableRecord, and the sidecar element
table but never consumed, so drop it and bump SIDECAR_VERSION 16 -> 17.
No back-compat: regenerate sidecars. sample.ifcview is regenerated at v17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full section tool for the web viewport, with the gizmo + interaction shared with
desktop from one codebase.
- True-face surface pick. pickSurfaceAt had always ray-cast the instance AABB (to
skip a depth readback), so cuts sat in front of the real surface. The pick
fragment already computes the exact world_pos (it clips sections with it); now
it OUTPUTS it to a 3rd pick MRT (RGBA32F) that every pick path renders, and
pickSurfaceAt / pickSurfaceAtAsync read it back (decodeMappedPickPosition;
ray-AABB kept only as a fallback). The web async pick chains id -> normal ->
position spontaneous staging maps.
- Web tool: LMB drops a cut at the picked surface (LMB drag still orbits), K
toggles, Shift+K clears; oriented to the real MRT surface normal. Exports + a
Section / Clear cuts toolbar pair.
- Shared gizmo: lifted the section-gizmo renderer (SECTION_WGSL + thick-line AA +
quad+arrow VBO + pack + screen-space hit-test) out of the Qt-coupled
OverlayRenderer into a Qt-free SectionGizmoRenderer that ViewportCore::render
draws for BOTH desktop and web (both already render via render()). One identical
gizmo; OverlayRenderer's now-dead section code removed. Fixed 1 m size (matches
the desktop constant).
- Interaction (shared): hitTestSectionGizmo (SectionGizmoRenderer::hitTest) +
beginSectionDrag / updateSectionDrag / endSectionDrag live in ViewportCore.
Drag a gizmo arrow to slide the plane along its normal; Del/Backspace removes
the most recent cut. Desktop's ViewportWindow dropped its duplicate hit-test /
drag math + state and delegates to the core; web wires the same calls.
Tests: sectionPlaneCount add/clear/cap (Catch2, 125); web smoke "click a surface
cuts geometry, clear restores" exercises the shared gizmo + 3-MRT pick (11/11).
Desktop object-pick / marquee unaffected; BonsaiViewer builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make orbit/pan/select mouse bindings pure data owned by ViewportCore so both
hosts and every preset share one source of truth, and add a "Web" preset. This
rounds out the matrix: the desktop gains a web-style scheme and the web inherits
all presets, with no per-platform hardcoding.
- Core: NavBindings { orbit, pan, select button + modifier } + setNavPreset
("blender" default | "rhino" | "revit" | "web") + navBindings(). Select is
preset-driven too (was hardcoded LMB) so "web" moves it to RMB. web = orbit
LMB, pan MMB, select RMB (LMB drag orbits with no click/drag ambiguity; RMB
click-selects / drag-marquees). NavMod uses "Plain" not "None" (X11 #defines
None to 0L).
- Desktop ViewportWindow: applyNavPreset sources the core table (mapped to Qt);
marquee-arm / single-pick dispatch keys off select_button_. Default stays
blender → no behaviour change.
- Desktop config: AppSettings::NavPreset gains Web + navPresetName(); the
Settings dialog lists it. This also FIXES a pre-existing gap — the preset combo
was persisted but never applied (only WGPU_NAV_PRESET env worked). MainWindow
now applies the persisted preset at startup (env override still wins) and live
on navPresetChanged, so all four presets actually work from the dialog.
- Web main_web: classifyPress routes the pressed button through navBindings()
(orbit/pan/select), defaulting to the "web" preset; context menu already
suppressed so RMB is free.
Tests: setNavPreset table (Catch2, 123 total); web smoke select tests use RMB.
BonsaiViewer builds; 9/9 web smoke.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lift the visibility + X-ray ops into ViewportCore so desktop and web share them.
The cull already reads visibility_ (hidden objects skipped) and the frame uniform
reads xray_alpha_cap_, both per frame, so each op just mutates state + schedules
a frame — no GPU buffers to rebuild, no rendering work:
- hideSelected (hide the selection, then deselect), isolateSelected (hide every
non-selected object in a visible model), showAll (clear the hidden set),
toggleXray (flip the alpha cap 1.0<->0.3; cull routes all to the transparent
pass), xrayActive().
- Desktop ViewportWindow: the H / Shift+H / Alt+H / Alt+X keys and the menu-action
wrappers now call the core; the four inline/duplicated implementations are gone.
- Web main_web: same keys (H hide · Shift+H isolate · Alt+H show all · Alt+X
x-ray) + toolbar buttons (Hide / Isolate / Show all / X-ray, the last reflecting
active state).
Verified on web: x-ray toggles and visibly translucent-izes the scene, hide after
a pick removes geometry, 0 GPU errors. 113/113 desktop + 6/6 web smoke; desktop
app builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DRY the fly camera into ViewportCore so desktop and web share identical math:
- flyMove(fwd,back,right,left,up,down,boost,dt): WASD in the view plane, QE
along world +Z, forward = eye→target (no snap after orbiting), Shift = 5x,
dt clamped to 0.1s. flyLook(dx,dy): turn in place (yaw/pitch, eye pinned,
0.2 deg/px, pitch ±89.9). flyAdjustSpeed(notches): wheel scales speed x1.25.
fly_move_speed_ + orbitEye now live in the core (orbitEye's duplicate removed
from ViewportWindow).
- Desktop ViewportWindow: fpsIntegrate / mouse-look / wheel-speed call the core
methods; behaviour unchanged, BonsaiViewer builds clean.
- Web main_web: Shift+F (or the Fly toolbar button) enters and pointer-locks the
canvas; W/A/S/D/Q/E + Shift are held-tracked (keydown/keyup) and integrated
each RAF frame with wall-clock dt; pointer-lock mouse deltas drive flyLook;
wheel tunes speed. Exit on Esc OR a canvas click (matches desktop) — a
pointerlockchange handler catches the browser eating the first Esc to release
the lock (so a single Esc exits), guarded by fly_locked so a denied lock on
entry doesn't insta-exit. Fly button reflects/ syncs active state.
Verified: web enter→WASD moves→click/Esc exits, 0 GPU errors; 113/113 desktop
core + 6/6 web smoke; desktop app builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The camera math already lived in the shared ViewportCore; the desktop just
bound keys to it. The web build had no keyboard handler and no camera UI, so
none of it was reachable. Wire it up (Tier 1 + 2 of nav parity; fly mode is a
separate follow-up).
Shared core:
- setStandardView(StandardView) — named Front/Back/Left/Right/Top/Bottom wrapper
over setStandardView(yaw,pitch), so the axis→angle mapping lives in one place.
- frameSelection() — lifts the desktop's "union selected AABBs → frameAabb(1.30)"
focus logic out of ViewportWindow into the core. Desktop's focusOnSelectedObject
and the X/Y/Z hotkeys now call these (DRY, behaviour unchanged).
Web:
- main_web gains a keydown handler matching the desktop bindings — Home=view all,
F=zoom to selected, P=ortho toggle, X/Y/Z (+Shift=negative)=standard views —
plus exported entry points (view_all_c / frame_selection_c / toggle_projection_c
/ projection_is_ortho_c / standard_view_c) for the toolbar.
- shell.html adds a bottom nav toolbar (Fit / Focus / Persp-Ortho / the six views)
with the hotkeys in tooltips; the ortho button reflects state.
Verified: Z key and Front button both move the camera, ortho toggles render +
label, zero GPU errors; 111/111 unit + 6/6 web smoke pass.
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>
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.
The chunk planner (Morton sort + greedy pack) and instance composition
(federation × placement matrix chain + world-AABB derive) were inline
helpers in ViewportWindow.cpp. Pulled both out as free-function modules
so the math + lookup logic can be exercised without a Qt window or a
wgpu device. ViewportWindow now delegates; InstanceLookup is a using-
alias to InstanceCompose::InstanceLookup.
Also added an addSubBufferForTesting / clearSubPoolsForTesting seam to
BufferPool so the sub-allocator invariants can be pinned with fake
WGPUBuffer handles. The fakes are never dereferenced; the guard drops
the sub-pools before destructor would call wgpuBufferRelease.
Three new test binaries under src/ifcviewer/tests/, 33 cases / 173
assertions: BufferPool first-fit + alignment + coalescing + multi-
sub-pool isolation; ChunkPlanner Morton split / interleave / stable
sort / greedy-pack monotonicity and single-mesh-oversize; InstanceCompose
identity / translation / order-of-multiplication / large-placement
cancellation against federation false origin / column-major writeback /
findInstance lookup paths.
## The bug
FZK-Haus windows rendered fully opaque despite every piece of the
data path carrying alpha correctly: vertex format is RGBA u8x4,
InstanceCpu/InstanceGpu carry color_override_rgba8 with its alpha
byte, fs_main returns vec4(rgb, in.color.a). Cause: the main render
pipeline's color target had `blend = nullptr`, which in wgpu disables
the blend stage entirely — fragment RGBA overwrites the back buffer
unmodified, alpha discarded.
## Why "just enable blend" isn't enough
Two failure modes that don't go away with a one-liner:
1. `depthWriteEnabled = True` on the main pipeline would make a
transparent window-frame pane occlude geometry behind it in
depth, so the wall behind the window then fails the depth test
and never draws — you'd see the silhouette of the window with
whatever colour was in the back buffer before, not the wall.
2. Order-dependent blending across transparent surfaces in arbitrary
cull order — overlapping transparent surfaces would shift colours
as the camera moves.
Standard fix for a BIM viewer is two-pass opaque-then-transparent.
## What this commit adds
### Per-mesh "has any alpha < 255" classifier
* `ModelGpuData::mesh_has_alpha` (uint8_t vector, parallel to meshes).
* Sized in `applyCachedModel`.
* Populated in `applyStreamedChunk` by scanning each in-chunk mesh's
vertex bytes for a vertex's alpha byte < 255 (offset 11 within
the 12-byte vertex record — the 4th byte of the third u32, which
the shader reads as `w2 >> 24`). Single chunk-arrival site covers
both sidecar streaming and the worker-result drain. First-load
IFC-without-sidecar geometry still routes opaque until the sidecar
bake completes; A-path scan is deferred.
### Per-chunk opaque/transparent partition during cull
* `Chunk::opaque_visible_vertices` / `opaque_visible_draws`
(per-frame counts).
* Transient `visible_draws_scratch_transparent` +
`transparent_per_draw_vertex_counts` filled alongside the existing
opaque half during the cull walk. Post-walk concat appends
transparent entries onto the opaque half and continues the
cumulative prefix-sum sequence — single buffer, single bind
group, no doubling.
* Classifier inside the cull lambda:
`xray_active ? always_transparent
: override_active ? (override.alpha < 255)
: mesh_has_alpha[mesh_id]`
### Per-chunk uniform layout extension
From `[total_draws, total_verts, 0, 0]` to
`[total_draws, total_verts, opaque_verts, opaque_draws]`. The third
slot is what `render()` passes as `firstVertex` to the transparent-
pass draw call so the shader's vid lands in the transparent range of
the same visible_draws_scratch buffer.
### `main_pipeline_transparent_`
Copy of `main_pipeline_` with `color_target.blend = SrcAlpha /
OneMinusSrcAlpha`. depthWriteEnabled stays True (see below).
### Two-pass `render()`
Opaque pass (`main_pipeline_`, firstVertex=0,
vertexCount=opaque_visible_vertices) then transparent pass
(`main_pipeline_transparent_`, firstVertex=opaque_visible_vertices,
vertexCount=total - opaque). Each loop skips empty halves so an
opaque-only chunk costs one draw call, transparent-only one draw,
mixed chunks two.
### depth_transparent.depthWriteEnabled = True (NOT off)
Initially set False (standard "let further-back geometry paint
through transparent front faces" trick) but that broke the edge-
detect pass: edge detection reads the depth buffer to find
silhouette discontinuities, and windows-without-depth meant the
glass had no silhouette at all (panes looked like framed holes) and
the edges of opaque geometry behind the glass painted through at
full intensity. Keeping the write avoids that — trade-off is depth-
test occlusion between transparent surfaces (closer occludes
farther), which for BIM panes that don't overlap in screen space
is invisible. Real fix for the overlap case is OIT or sort-back-
to-front, not depth-write toggling.
## Alt+X global X-ray (drops in basically free)
* `xray_alpha_cap` field on FrameUniforms + WGSL counterpart, default
1.0 (no effect). fs_main clamps `out.a = min(in.color.a, cap)`.
* `ViewportWindow::xray_alpha_cap_` member, default 1.0. Alt+X
toggles between 1.0 and 0.3.
* Cull classifier sees `xray_alpha_cap_ < 1.0` and forces every
instance into the transparent pass so the blend stage actually
fires (an opaque-pass fragment with capped alpha would still
overwrite the back buffer).
* No per-instance state mutation needed — toggle is a single float
in a uniform plus a re-cull. Excluding objects from x-ray later
would mean tagging them so the classifier skips the force-
transparent branch for them, also small.
Stress-tested on FZK-Haus: window glass visibly translucent with
correct silhouette edges; Alt+X turns the whole scene to a tinted
ghost of itself and back without artefact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The GL backend is gone (task #53). The wgpu/non-wgpu folder split and
the Wgpu* class prefix were both disambiguation artefacts from the
overlap period — now pure dead weight.
## Folder + library merge
* `src/ifcviewer-wgpu/` → folded into `src/ifcviewer/` (git mv tracks
every file as a rename so blame/log history survives).
* `src/ifcviewer-wgpu-minimal/` → `src/ifcviewer-minimal/` (the exe was
already named `IfcViewerMinimal`; this just brings the folder + CMake
target name into line).
* `src/ifcviewer-wgpu/tests/test_wgpu_{selection,visibility}.cpp` →
`src/ifcviewer/tests/test_{selection,visibility}.cpp`, folded into
the existing `add_ifcviewer_unit_test(...)` helper.
* The `IfcViewerWgpu` static library is dissolved — its sources become
part of the unified `IfcViewer` static library, which now bundles
scene/loader + renderer in one target. The pre-merge circular
dependency (IfcViewer linking IfcViewerWgpu just to get the
ViewportWindow.h include path that SceneLoader.h needs) goes away.
* The wgpu-native FetchContent block, the Cocoa/QuartzCore link on
Apple, the OBJCXX-enabled `.mm` source, and the wgpu-native runtime
install all move into `src/ifcviewer/CMakeLists.txt` unchanged.
## Type renames (Wgpu prefix dropped from every Wgpu* identifier)
WgpuAreaMeasurement → AreaMeasurement
WgpuBufferPool → BufferPool
WgpuLengthMeasurement → LengthMeasurement
WgpuMetalSurface → MetalSurface
WgpuModelGpuData → ModelGpuData
WgpuOverlayFrame → OverlayFrame
WgpuOverlayRenderer → OverlayRenderer
WgpuSectionPlane → SectionPlane
WgpuSelectionState → SelectionState
WgpuStreamingLoader → StreamingLoader
WgpuStreamingThread → StreamingThread
WgpuViewportWindow → ViewportWindow
WgpuVisibilityState → VisibilityState
CMake target IfcViewerWgpuMinimal → IfcViewerMinimal (exe name was
already this since wgpu shipped as default).
Deliberately kept: `onWgpuLog` (wgpu-native log callback — names a
binding to an external API, not one of *our* types), and the WGPU*
enum/struct prefixes from wgpu-native's own headers. `WgpuMemProbe`
lives in the separate `src/wgpu-mem-probe/` standalone diagnostic
project and isn't touched.
## Include-path updates
Every `#include "../ifcviewer-wgpu/Wgpu<X>.h"` → `"../ifcviewer/<X>.h"`,
every in-directory `#include "Wgpu<X>.h"` → `"<X>.h"`. Includes from
sibling subdirectories (modules/, etc.) are updated to point at
`../../../ifcviewer/` instead of `../../../ifcviewer-wgpu/`.
## cmake/CMakeLists.txt simplification
The redundant `add_subdirectory(ifcviewer-wgpu)` blocks (one inside
the BUILD_BONSAIVIEWER fan-in, one in the BONSAIVIEWER-less standalone
block) collapse into a single unconditional
`add_subdirectory(../src/ifcviewer ifcviewer)`. The standalone block
keeps only `wgpu-mem-probe` (the diagnostic tool, unrelated to the
viewer lib).
## Verification
* Full build green: `IfcViewer` static lib, `IfcViewerMinimal` exe,
`BonsaiViewer` exe, all four pre-existing ifcviewer unit tests, and
the two new-location tests (`test_selection`, `test_visibility`).
* No stray `Wgpu<X>` identifier remains across `src/ifcviewer/`,
`src/bonsaiviewer/`, `src/ifcviewer-minimal/` (verified by grep).
* Renames tracked by git as `R` entries — `git log --follow` on
ViewportWindow.cpp etc. continues to show history through the move.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.
SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
the StreamingSidecar off to the new applyCachedModel. Field accesses
inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
finalizeModel). The applyLodExtension call is dropped — wgpu has no
live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
next open.
Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
duration of the migration; the GL-side IfcViewer also publicly links
IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.
GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
removed. The first has no replacement (wgpu doesn't use a per-instance
BVH); the latter two are ported separately. test_lod_builder,
test_sidecar_cache, test_instanced_geometry, test_federation remain
(backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
of the surviving translation units reach for them.
Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the other half of task #10. The wgpu minimal already wrote PNGs
via wgpuCommandEncoderCopyTextureToBuffer + mapAsync; the GL backend
now has the equivalent via glReadPixels on the back buffer just before
swapBuffers.
- ViewportWindow::captureNextFrameToPng(path, quit_after=true) queues
a one-shot capture. render() reads the default framebuffer at full
pixel size (width * devicePixelRatio), flips bottom-up → top-down
into a QImage::Format_RGBA8888, saves PNG, and optionally
QCoreApplication::quit. Synchronous glReadPixels is fine here —
pick is interactive and rare; not used per-frame.
- ifcviewer-minimal --screenshot PATH wires through MinimalWindow
just like --camera / --benchmark. Honoured after all loads complete
(applyPendingBenchmark also drains pending_screenshot_).
Lets a parity script do:
IfcViewerMinimal foo.ifc --camera A,B,C,D,E,F --screenshot gl.png
IfcViewerWgpuMinimal foo.ifcview --camera A,B,C,D,E,F --screenshot wgpu.png
# then pixel-diff with whatever (ImageMagick, PIL, etc.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>