## 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>
Keep placement transformations in double precision through streaming, sidecar caching, and viewport recomposition so large coordinates can be cancelled before the final GPU float upload.
Generated with the assistance of an AI coding tool.
Renamed HeadlessSidecarBuilder to SidecarBuilder and reused it for live
loads. SceneLoader now constructs one per stream load, forwards meshReady
/instanceReady chunks alongside the viewport upload, and finalizes +
writes the sidecar at onStreamerFinished — no more GPU readback path
via ViewportWindow::snapshotModel (removed). Same code path now produces
sidecars for both live loads and the .rdbview offline export.
Sidecar use is opt-in per direction via SceneLoader::setShouldReadSidecar
and setShouldWriteSidecar; both default off so embedders that don't want
caching get a pure-streaming loader. ifcviewer-full and ifcviewer-minimal
opt in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire a new "Export Geometry Database" tool button in AddModelDialog,
adjacent to "Convert IFC File to Database", to produce a zipped
read-only artifact combining a lossy RDB (with IfcRepresentationItem
stripped) and a .ifcview geometry sidecar. Intended for cloud
coordination workflows where parametric geometry editing is not needed.
Pipeline changes to support this:
- document_serializer_context gains a `skip_supertypes` field; the
rdb plugin forwards it to RocksDbSerializer so the same registry
path produces full or lossy RDBs.
- Vertex quantization helpers (octEncodeNormal + quantizeVertex) move
out of ViewportWindow.cpp into a shared header so the sidecar's
byte layout stays identical regardless of whether it came from a
GPU readback or a CPU pipeline.
- New HeadlessSidecarBuilder runs a GeometryStreamer on the calling
thread, captures MeshChunk/InstanceChunk into a SidecarData on the
CPU, then computes georef + packed elements + LODs and writes the
.ifcview — no ViewportWindow or GL context required.
The Controller's export flow runs RDB conversion + sidecar build +
QZipWriter packaging on a background QThread, writing through
`<dest>.tmp` then renaming for atomic appearance in cloud-sync
folders. ifcviewer-full now links Qt6::CorePrivate for QZipWriter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Style hidden interface models with disabled text and move the first length-tool pick coordinates to the HUD as ENH in the global georeferenced frame.
Generated with the assistance of an AI coding tool.
Adds VisibilityState, a CPU-only sibling to SelectionState. It owns
the canonical hidden-id set plus a flat per-object_id byte vector that
the cull's hot path queries inline (bounds check + byte load + compare
per surviving instance). Hidden elements never reach the visible[]
SSBO so they don't draw or pick — matching Blender/CAD convention.
ViewportWindow registers every streamed and sidecar-cached object_id
with the new state, resets it on clearScene, and connects the changed
signal to invalidate cached cull state. Three convenience verbs:
hideSelectedElements (union into hidden), isolateSelectedElements
(replace hidden with live-object_ids minus selection, skipping
model-hidden models so element-hide doesn't pile on top of model-hide),
and showAllElements (clears the override; model-hidden models stay
hidden, per the user's spec).
Bound in the View menu: H hide, Shift+H isolate, Alt+H show all.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Promotes five env-var-driven knobs to AppSettings + the settings dialog
(min pixel radius, motion min pixel radius, LOD1 pixel threshold, HiZ
resolution, HiZ on/off). Defaults: motion min pixel radius is now 10
(was 0/disabled) and IFC_HIZ_MOTION is on by default — the strict
view-projection gate reverts via env var =0 when chasing HiZ
correctness bugs. ViewportWindow connects each *Changed signal so
changes invalidate cached cull state and take effect on the next
frame.
Removes "Load Property Data Source" and "Apply Coordinate Operation"
from the settings dialog: both are now hardcoded on. The basic-info
property fallback (used when there's no live IFC source for an object,
e.g. .ifcview without a sibling) now triggers organically when
ElementRegistry::findEntity returns null instead of being gated on a
user toggle. Federation::guessFederatedFalseOrigin lost its
apply_coordinate_operation parameter and now uses
georef.has_coordinate_operation directly.
src/ifcviewer/settings.rst documents the remaining diagnostic env vars
(IFC_HIZ_MOTION, IFC_CULL_THREADS, IFC_SKIP_MDI, IFC_MAX_SUBDRAWS,
IFC_FPS_HITCH_MS, IFC_SUBDRAW_DIAG, IFC_LOD_*) plus a cross-walk from
the old promoted-knob env-var names to their new QSettings keys.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the Area tool's display: HUD shows total volume + object count,
each selected object gets a label at its world-AABB centroid showing
its individual volume. Gated behind ToolMode::Volume (Ctrl+Shift+V) so
it stays out of the way until invoked.
Volume is a passive tool — selection works as in None (multi-select,
modifier toggle, box-select all keep working). Area / Length still
intercept clicks through surfacePickedInTool.
Adds volumesPerObject() reusing the same mesh-cached readback path as
volumeOfObjects, so the per-object split costs no extra GL readbacks.
computeObjectAabb is promoted to public for the centroid lookup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SelectionState (new) owns the multi-set, the "active" id (last single-
clicked), and a per-object_id flags SSBO bound at binding=3. Main
shader reads sel_flags[v_object_id] for the in-set tint and a separate
u_active_id uniform for a stronger tint on the active.
Click semantics: plain replaces, Shift/Ctrl toggles. LMB-drag past 5px
boxes the rect through a pick-pass readback — plain replaces, Shift
adds, Ctrl removes; box-select preserves the active. Drag promotes
regardless of start point so a press on geometry doesn't disqualify it.
Sidecar fast-path bulk-loads instances, so noteObjectId is also called
from the apply path — without it the flags buffer was sized to 1 slot
while object_ids were in the 100k+ range and the in-set bit was lost.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Length tool's 1-pt laser is now hybrid:
- On any surface, a coplanar BFS finds the connected face patch
around the click and projects its vertices into the surface
tangent basis to get an exact bounding-box extent. Stops at
the face edge by construction — no overshoot into adjacent
geometry like the previous tangent-raycast did.
- On near-horizontal surfaces (|n.z| > 0.85, i.e. floors and
ceilings) it additionally fires one raycast in +n to the
opposing surface — so a single floor click reports X extent +
Y extent + ceiling height.
- Bars are labelled by their dominant world axis (X/Y/Z) instead
of "vertical/horizontal", which reads cleanly on either kind
of surface.
The 2-pt readout now draws the world-space XYZ stair-step (red ΔX,
green ΔY, blue ΔZ) with each leg labelled, and a dashed
perpendicular line whenever the two picks landed on near-parallel
surfaces — useful for measuring across walls.
To support multiple line styles per frame, OverlayRenderer's
setOverlayLines takes std::vector<LineGroup> instead of a single
inline style; each group has its own color/halo/width and an
optional dash period. The line shader gained v_along_px +
u_dash_period uniforms (screen-space dashes), and both line and
point shaders now use a sharp step() for the inner→stroke
transition with AA only on the outer halo edge — much crisper than
the previous soft band. Default visual style trimmed: 1.5px lines
(0.5px halo), 6px dots (1px halo), opaque black halo.
Also adds ViewportWindow::raycast(origin, dir, RaycastHit&) — CPU
ray traversal of each model's per-instance BVH followed by
Möller-Trumbore against the candidate meshes' triangles (lazily
read back, cached per call). Used by the floor/ceiling laser path
today and reusable for any future raycast-based feature.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ViewportWindow trades the area_tool_active_ bool for an enum ToolMode
{None, Area, Length}; the existing surfacePickedInTool signal carries
both, the app dispatches on toolMode(). Esc exits any active tool;
Backspace/Delete in length mode emits toolBackspacePressed which the
length tool uses to remove the last point.
LengthMeasurement collects clicked world-space points and adapts the
readout: 2pt → distance + axis-aligned ΔX/ΔY/ΔZ, 3pt → angle at the
middle vertex + triangle area, 4+pt → best-fit-plane PCA + shoelace
when planar (RMS plane distance / bbox diag < 1e-3) else fan
triangulation, with the chosen method labelled in the readout. Per-
segment lengths float at each midpoint.
OverlayRenderer grows three new pipelines to support this:
- point sprite shader: gl_PointCoord-based outlined disc with
fwidth-smoothed inner/stroke bands, a single draw call.
- line shader: CPU-expand each segment to 6 verts carrying both
endpoints + (side, along) corner index; vertex shader computes
the screen-space perpendicular and offsets accordingly. Real
outlined lines independent of the driver's glLineWidth clamp.
- screen-space rect shader: HUD + label backgrounds drawn as raw
GL quads in NDC. QPainter::fillRect on QOpenGLPaintDevice was
silently dropping fills across drivers; bypassing it entirely
via this shader makes backgrounds reliable. Cull-face is also
explicitly disabled here — GL_TRIANGLES respects it but the
line/point primitives don't, so this was the one path needing
the fix.
setOverlayLines / setOverlayPoints take an inner color, an outline
color, and an extra-pixels-per-side stroke amount. Lines + points
draw with GL_ALWAYS so measurement annotations stay visible through
geometry; highlight tris stay depth-aware (GL_LEQUAL) so area
shading still tints the surface in place.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New OverlayRenderer module owns every client-supplied overlay primitive
drawn after the main pass: tinted, depth-aware highlight triangles via
its own GL shader, and top-left HUD text via QPainter on a
QOpenGLPaintDevice. Public surface on ViewportWindow is just two
forwarders (setHighlightTriangles, setHudText).
ViewportWindow's MeshLocalPick now exposes the instance's composed
transform so consumers can map mesh-local geometry back to world
space without re-querying. AreaMeasurement uses both: its selection
key is now (object_id, tri) so per-instance highlighting works for
two distinct walls sharing a mesh, and on every pick it rebuilds the
world-space tri list and the HUD readout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a click-to-measure area mode triggered by Ctrl+Shift+A. Each LMB
click expands the picked triangle into its connected coplanar patch
(BFS over shared edges, dot(normal, seed) > 0.9999); re-clicking
removes that patch; Alt+LMB skips expansion for a single triangle.
Picks across different meshes accumulate as separate patches.
ViewportWindow gains pickMeshLocalAt (screen pick → mesh-local hit
via inverse composed transform) and a tool-mode pattern mirroring
the section tool (toggleAreaTool, surfacePickedInTool signal,
areaToolToggled signal, Esc to exit). Per-mesh adjacency is built
lazily on first pick of each mesh and dropped on tool toggle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds neutral primitives on ViewportWindow (readbackMeshTriangles,
findInstance) so consumers can compute per-object geometry queries
without the library retaining a CPU triangle copy. Measurement.cpp in
ifcviewer-full uses them to sum signed-tetrahedra in mesh-local space,
weighted by |det(placement_3x3)| per instance for mapped-item scaling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tree -> viewport selection was already wired (onTreeSelectionChanged
calls setSelectedObjectId), but pressing F afterwards routed to the
focused tree widget rather than the viewport, so framing didn't fire.
Add a window-level View > Frame Selected QAction with Qt::Key_F that
delegates to ViewportWindow::focusOnSelectedObject — works regardless
of which child widget has focus. The viewport's own F handler stays
in place for when the viewport itself owns focus.
For debugging coordinate problems, add View > Print Selected Coords
(Ctrl+Shift+P) -> ViewportWindow::printSelectedObjectCoords, which
qInfo's:
- a sample vertex (first vertex of the selected mesh, decoded on
demand from the quantised VBO so no extra CPU storage is needed);
- placement_transformation (the per-instance matrix that maps the
sample vertex from mesh-local into the model's pre-georef frame);
- global = CoordinateOperation . placement_transformation (where
the IFC's own IfcCoordinateOperation has been folded in);
- the sample vertex transformed through both matrices.
The print is a no-op when nothing is selected or GL hasn't initialised.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
InstanceCpu now carries both placement_transformation (raw streamer
output, the iterator's per-shape transform with vertex-rebasing offset
folded in) and transform (the composed FederatedFalseOrigin ·
ModelTransformation · CoordinateOperation · placement_transformation
result that lands in the SSBO). World AABBs are recomputed from the
composed transform — frustum/BVH culling sees the actual rendered
position regardless of stage state.
ViewportWindow gains:
- ModelGpuData::coordinate_operation_meters / model_transformation_meters
- federated_false_origin_meters_ (federation-wide member)
- composeInstanceFromPlacement / recomposeAndUploadModel helpers
- public setFederatedFalseOrigin / setModelCoordinateOperation /
setModelTransformation
Each setter rewrites the affected model's SSBO, refreshes the
reflection flags, and rebuilds the BVH. Defaults are identity, so
behaviour is unchanged until something wires a setter up — that's
the next commit (MainWindow listening to Federation::dirtyChanged
and SceneLoader::modelGeoref ready signals).
Sidecar bumped 9 -> 10: InstanceCpu grew 104 B -> 168 B. Existing
sidecars rebuild on next load. v10 sidecars store
placement_transformation, so they remain reusable across .ifcfeds —
the composed transform on disk is overwritten with the right one
on load.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous projection-toggle commit short-circuited contribution
culling when projection_ortho_ was set — the formula
r_px = focal_px * r / dist looks like it depends on per-instance
distance, which doesn't apply in ortho. Result: every frustum-
visible object drew, including sub-pixel ones, and FPS tanked on
top-down plan views.
In ortho the projected pixel size of a bounding sphere is constant:
r_px = pixels_per_world * r, where pixels_per_world equals the
existing focal_px / camera_distance_ (the ortho box was sized to
match perspective at the pivot's distance). So the same formula
gives the right answer if we replace per-instance dist with
camera_distance_.
cullModelCpu now does that substitution for both contributionPasses
and pixelRadius (the latter feeds LOD1 selection too — sub-pixel
objects pick LOD1 in ortho the same way they do in perspective).
The "camera inside AABB" early-return is kept; it only fires in
perspective where dist→0 would otherwise blow up r_px, and is
harmless in ortho.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a per-frame depth-laplacian pass that darkens pixels at sharp
depth discontinuities — silhouettes, overlapping-surface boundaries,
section-cut edges. Catches the wall-against-wall and slab-against-
ceiling cases that the cavity hint in the lighting shader misses.
Implementation:
- New edge_depth_fbo_ / edge_depth_tex_ — single-sample D24S8 the
size of the window. After the main draw, blit the default FB
depth into it (handles MSAA resolve in the same call).
- Fullscreen triangle generated from gl_VertexID, samples four
cardinal neighbours, computes |4c - n - s - e - w| on linearized
depth. Linearization branches between perspective and ortho via
u_is_ortho. Threshold scales with depth so distant edges still
register.
- Output is multiplicatively blended (GL_DST_COLOR, GL_ZERO) so
colours just darken; no separate composite step.
- Runs before the pivot/section/axis gizmos so they aren't outlined
themselves. HiZ pyramid build still runs after, unchanged.
Per-frame cost is one MSAA depth blit + one fullscreen pass with
five depth samples. Sub-millisecond at 1080p on a mid GPU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the flat 0.25 ambient + single-Lambert key with three cheap
shape-readability tricks, all in the fragment shader:
- Hemisphere ambient (sky/ground tint mixed by n.z) so floors,
ceilings, and walls get visibly different ambient colour even when
shadowed. +Z is world-up.
- Secondary fill light at 35% intensity from roughly the opposite
horizontal direction so backs of objects are not pitch black.
- Cavity hint: clamp(length(fwidth(n)) * 1.5, 0, 0.35) darkens
fragments where adjacent normals diverge sharply. Catches
wall-floor seams, column-slab joints, and stair edges as faint
dark lines without any post-process.
Total cost: ~8 extra ALU ops per fragment, no extra passes, no extra
buffers. No change to cull/HiZ/MDI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- P toggles ortho/perspective. The ortho box is sized so the
visible rectangle at the pivot's distance matches what the
perspective camera would show — toggling at any zoom keeps the
framing identical, and the wheel keeps working by rescaling the
box. Contribution culling is disabled in ortho since its
r_px = focal_px * r / dist formula assumes perspective; frustum
and HiZ culling still run.
- X / Y / Z snap the camera to look from +X / +Y / +Z; Shift+X /
Y / Z snap to the negative side. Yaw and pitch are set
directly so top/bottom land on exactly ±90°.
- updateCamera() picks the lookAt up vector dynamically: world +Z
except within 1° of the pole, where it switches to world +Y.
That keeps lookAt well-conditioned at the poles and gives top
views the architectural "Y as north" screen orientation.
- Pan now derives screen-right / screen-up from the real camera
basis instead of from yaw/pitch alone — the old derivation
assumed up = world +Z and silently inverted at top/bottom.
- Standard views preserve target and distance — rotate only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Convenient escape hatch when the user has stacked several cuts
and wants to start over without exiting the tool first. Also
resets the selection and drag state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires up the user-facing section-cut tool on top of the clipping
plumbing landed in the previous commit.
- K toggles the tool.
- LMB while the tool is active:
* On an existing plane's arrow gizmo (screen-space line-segment
hit test, 12 px grab radius) → select + start drag.
* Otherwise on geometry → pickSurfaceAt + addSectionPlaneAt-
Surface, select the new plane.
* Otherwise → deselect.
- LMB drag updates the plane's origin by projecting the cursor
delta onto the screen-space normal axis and converting back to
metres. d is rederived from the new origin each frame.
- Delete removes the selected plane; Esc exits the tool.
- Each plane renders a 2x2 m quad outline plus a yellow arrow
along +n at its origin. Selected plane draws cyan and
thicker.
LMB object-pick is suppressed while the tool is active so plane
creation does not also change selection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a clip-plane pipeline used by the upcoming section tool:
- Up to 8 SectionPlane{n, d} entries, AND-combined as
fragment-shader discard against world position. Main and pick
fragment shaders both honour the planes, so cut areas are
neither drawn nor selectable.
- Main vertex shader now passes v_world_pos through.
- Pick FBO grows two attachments (RGB32F world position, RGB16F
world normal) and the pick shader writes both alongside the
object id. pickSurfaceAt() does a single readback of all
three. Existing pickObjectAt() still works unchanged for
callers that just want the id.
- addSectionPlaneAtSurface(point, normal) auto-flips the normal
toward the camera so the first click immediately cuts the
camera-facing half.
No UI yet — that's the next commit (gizmo, drag, K shortcut).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
F (no modifier) re-aims the orbit camera at the selected object's
world AABB centroid and dollies camera_distance_ so the bounding
sphere fits the current viewport. Home does the same for the union
of all finalized models. Both preserve yaw/pitch so the user keeps
their orientation; both no-op in FPS mode.
Scene AABB prefers the per-model BVH root when available and falls
back to walking InstanceCpu world AABBs. Object AABB unions every
matching instance. Distance accounts for portrait windows by using
the tighter of the horizontal and vertical FOV constraints.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A small RGB axis cross is rendered at camera_target_ while the user is
orbiting, panning, or has just zoomed. Visibility toggles on
middle-mouse press/release; the wheel arms a single-shot QTimer that
hides it 750 ms after the last notch.
Drawn in two passes: GL_GREATER at 30% alpha for the occluded portion
(X-ray cue) and GL_LEQUAL at full alpha for the visible portion. Arm
length is computed from camera_distance_, fovy, and viewport height so
the cross stays ~30 px on screen across zoom levels.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Federation (JSON) tracks an ordered list of model sources plus an
optional home-view camera state. Sources are stored relative when
under the federation file's directory, absolute otherwise.
File menu now exposes New / Open / Save / Save As; Add Files moves
to Ctrl+Shift+O. View menu gains Set/Go to Home View. Window title
binds to dirty state via setWindowModified, and the close-window
prompt offers Save/Discard/Cancel.
Per-model transform (4x4 column-major) and visible round-trip
through load/save but are not yet applied at the viewport — the
georeferencing work uses them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WASD strafe, Q/E down/up, mouse-look (cursor hidden + recentered),
Shift to sprint, scrollwheel scales speed, click or Esc returns to
orbit. Exiting drops back to the same viewpoint because rotation
re-pins camera_target_ to keep camera_eye_ stationary.
Movement integrates wall-clock dt inside render() and the next frame
self-schedules via requestUpdate() while any key is held. A QTimer
would fight Qt's event loop during long swapBuffers blocks and produce
"camera pauses one frame" stalls; render-driven integration keeps
movement phase-locked to vsync and absorbs slow frames in a single
catch-up step.
IFC_FPS_HITCH_MS=<n> logs frames slower than n ms while in fly mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Buffer viewport model mutations until the OpenGL context is initialized so loads that start before first exposure do not silently drop geometry or model state.
Generated with the assistance of an AI coding tool.
Replace i16x2 octahedral normals with i8x2, filling the 2-byte padding
after position and saving 4 bytes per vertex. int8 gives ~1.4 deg
worst-case angular error — invisible for BIM geometry which is
overwhelmingly axis-aligned. 25% VBO reduction; sidecar files shrink
~15% overall (5.4 GB -> 4.6 GB on a 111-model test scene). Bumps
sidecar format to v7.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Benchmarks showed negligible gain (52 vs 51 fps) — the CPU BVH path
already culls efficiently, and the GPU path still read back to CPU for
LOD/winding/HiZ. Removes ~570 lines of dead weight: compute shader,
async readback, one-frame-late consume, per-model AABB SSBOs, and
profiling counters.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for
reproducible performance measurement. The benchmark orbits the camera
(0.5°/frame yaw) for N frames after a 5-frame warmup, prints
avg/median/p1/p99 frame times, then exits. Press C during interactive
use to print the current camera as a --camera argument.
Fix settle recull to fire after ANY camera motion (not just when
IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames
are always cleared when the camera stops.
Document Phase 3G (motion-adaptive culling + HiZ during motion) in
README with benchmark results from 1.06M-instance scene:
- Baseline: 16.3 fps
- IFC_MIN_PX_MOTION=10: 26.5 fps (1.6x)
- IFC_HIZ_MOTION=1: 46.6 fps (2.9x)
- Both combined: 51.0 fps (3.1x)
- + GPU_CULL: 52.0 fps (3.2x, negligible gain)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION)
to aggressively cull small objects, dramatically reducing sub_draws and
improving orbit fps (e.g. 29→67 fps on 1M-instance scene). When the camera
stops, automatically re-cull at the base threshold to restore full detail.
Key behaviors:
- IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled)
- Settle recull fires on the first still frame after motion
- HiZ pyramid invalidated on settle (stale from sparse motion frame)
- GPU cull results skipped on settle (dispatched at motion threshold)
- requestUpdate() ensures the settle frame actually runs
Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis
and documents Phase 3E/3F experiment results in README.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The HiZ pipeline had two bugs causing false occlusions:
1. The scaling depth blit (glBlitFramebuffer from window-size to HiZ-size)
produced GL_INVALID_VALUE on some drivers. Replace with a fullscreen-
triangle shader that samples the resolved depth and writes gl_FragDepth.
2. The resolve texture used GL_DEPTH_COMPONENT24 but Qt's default FBO uses
D24S8 (depth+stencil). Mismatched formats cause the MSAA resolve blit
to fail. Fix by using GL_DEPTH24_STENCIL8 for the resolve texture.
Additionally, the occlusion test was too aggressive for scenes with
compressed depth ranges (entire scene in 0.99-1.0). Change from
"max over coarse mip texels" to "reject only if ALL fine-mip texels
agree the AABB is behind them", with early-out on first non-occluding
texel and a 64-sample cap.
Also fix IFC_HIZ_MOTION=0 being treated as enabled (checked env var
existence, not value).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only clear and emit mesh buckets that received survivors in the previous
frame, converting both phases from O(total_meshes) to O(active_meshes).
Adds per-sub-phase timing (bin/clr/class/emit) to the stats line.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the CPU BVH traversal + frustum + contribution stages with a
GPU compute path (IFC_GPU_CULL=1). A single scene-wide dispatch tests
all instances against frustum planes and screen-space contribution
threshold, compacting survivors into a flat uint32 buffer via atomicAdd.
Uses one-frame-late async readback: frame N dispatches and fences,
frame N+1 polls the fence (non-blocking) and reads the persistent-
mapped result buffer with zero GPU sync cost. CPU still handles HiZ,
LOD selection, winding bucketing, and indirect command generation from
the compact survivor list; draw path is unchanged.
On a 1M-instance / 111-model scene (GTX 1650):
GPU dispatch: 0.70 ms (frustum + contribution, brute-force)
Readback: 0.00 ms (fence already signaled, persistent map)
CPU consume: 5.7–6.7 ms (parallel emit across models)
Cull wall: 5.8–6.9 ms (vs 9.6–15.2 ms CPU-only path)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pack compute shader compacts non-empty indirect commands into
contiguous fwd/rev ranges, eliminating ~690k empty sub-draws that
dominated command-processor overhead. GL 4.6 entrypoint loaded via
getProcAddress with ARB fallback; graceful degradation to uncompacted
MDI when unavailable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-phase compute-cull dispatch when IFC_GPU_CULL=1:
Phase 1 frustum + contribution + LOD, no HiZ → survivors
Depth render survivors depth-only into half-viewport FBO
Build GPU compute max-reduce depth → R32F mip pyramid
Phase 2 same cull + HiZ test → final survivors
Color render final survivors
The compact shader's new hizOccluded() projects 8 AABB corners to
screen space, picks the mip level where the covered rect fits in ≤2×2
texels, and rejects when the AABB's near-depth exceeds the pyramid's
max depth.
New GPU resources (per-window):
hiz_gpu_fbo_ / hiz_gpu_depth_tex_ — depth-only FBO at half viewport
hiz_gpu_pyramid_tex_ — R32F mipmapped pyramid
hiz_gpu_copy_prog_ — compute: depth → pyramid L0
hiz_gpu_reduce_prog_ — compute: max-reduce L(n-1)→L(n)
hiz_gpu_depth_prog_ — vertex + trivial fragment
On a dense 18-model BIM dataset:
survivors: 140k → 65k (HiZ rejects ~50%)
triangles: 22M → 13M
gpu_cull: 0.06ms → 22.5ms (depth pre-pass CP overhead)
The depth pre-pass suffers the same empty-sub-draws CP overhead as the
color pass (690k commands, most with instanceCount=0). Once MDI
compaction lands, both passes will be fast. For now, net FPS is flat
(savings on color ≈ cost of depth pre-pass).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The compact shader now computes per-instance pixel radius and routes
survivors to LOD1 buckets when the projected sphere falls below the
LOD1 threshold (default 30 px, same as CPU path, tunable via
IFC_LOD1_PX).
Layout expanded from 2 to 4 buckets per mesh:
[0..M) fwd_lod0 [M..2M) fwd_lod1
[2M..3M) rev_lod0 [3M..4M) rev_lod1
Two MDIs per model: CCW for [0..2M), CW for [2M..4M). Per-mesh
has_lod1 flags live in a new gpu_mesh_flags_ssbo (binding 4).
Contribution cull refactored: the compact shader now computes
pixelRadius() once and uses it for both the min_pixel_radius rejection
and LOD routing, matching the CPU path's logic.
Visible-buffer worst case is 2 × total_instances (each LOD bucket
reserves the full fwd/rev capacity per mesh, since LOD selection is
dynamic).
Tri count drops ~60% on the test dataset (53M → 22M) thanks to LOD1
decimated meshes. FPS recovers from 16 to 36 despite 690k sub_draws
(4M layout). MDI compaction remains the final perf fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>