Commit Graph

62 Commits

Author SHA1 Message Date
Dion Moult 5bc7e4b98b ifcviewer-full: length tool (2/3/4+ point distance, angle, polygon area)
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>
2026-05-25 16:34:18 +10:00
Dion Moult ad62dd6d91 ifcviewer: viewport overlay subsystem (highlight tris + HUD text)
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>
2026-05-07 16:43:38 +10:00
Dion Moult 016c278748 ifcviewer-full: console-print accumulating coplanar-patch area tool
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>
2026-05-07 12:03:22 +10:00
Dion Moult f2b655fcf6 ifcviewer-full: print object volume on click via lazy GPU readback
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>
2026-05-07 11:25:30 +10:00
Dion Moult e0e143bab5 ifcviewer: F to frame selection from tree, debug coord dump
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>
2026-05-02 19:01:50 +10:00
Dion Moult 7f29850022 ifcviewer: compose federation pipeline at SSBO upload
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>
2026-05-01 20:41:25 +10:00
Dion Moult 3e2869b6aa ifcviewer: re-enable contribution culling in ortho mode
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>
2026-04-30 15:05:32 +10:00
Dion Moult db1a2705a3 ifcviewer: edge enhancement post-pass
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>
2026-04-30 14:34:38 +10:00
Dion Moult e7787b6aad ifcviewer: hemisphere ambient + fill light + cavity hint
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>
2026-04-30 14:22:08 +10:00
Dion Moult 6f0327d4b5 ifcviewer: orthographic toggle and standard axis-aligned views
- 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>
2026-04-30 13:30:18 +10:00
Dion Moult 6341d7dd31 ifcviewer: bind Shift+K to clear all section planes
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>
2026-04-30 12:37:37 +10:00
Dion Moult 51971dd493 ifcviewer: section tool — gizmo, drag, K shortcut
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>
2026-04-30 12:32:49 +10:00
Dion Moult 309e20009c ifcviewer: add section-plane clipping plumbing
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>
2026-04-30 12:13:17 +10:00
Dion Moult a06e5ecdf5 ifcviewer: add Focus-on-Object and View-All camera shortcuts
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>
2026-04-30 11:34:35 +10:00
Dion Moult 1ca2e12f92 ifcviewer: draw 3D pivot indicator during navigation
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>
2026-04-30 10:21:23 +10:00
Dion Moult 7bc64bef1a ifcviewer-full: add .ifcfed federation save/load
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>
2026-04-27 18:31:59 +10:00
Dion Moult 81a7c5b50e ifcviewer: add Shift+F fly-mode camera
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>
2026-04-26 20:12:52 +10:00
Dion Moult f898089b27 Queue viewer ops before GL init
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.
2026-04-23 21:32:25 +10:00
Dion Moult 3015f758ba ifcviewer: shrink vertex format from 16 to 12 bytes (oct i8x2 normals)
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>
2026-04-23 21:32:25 +10:00
Dion Moult 094d96c735 ifcviewer: remove GPU compute cull (IFC_GPU_CULL)
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>
2026-04-23 21:32:25 +10:00
Dion Moult ed6e8d831e ifcviewer: benchmark CLI, settle recull fix, and Phase 3G documentation
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>
2026-04-23 21:32:25 +10:00
Dion Moult 930678e3d2 ifcviewer: motion-adaptive contribution culling + sub-draw diagnostics
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>
2026-04-23 21:32:24 +10:00
Dion Moult 4e3cc63de1 ifcviewer: fix HiZ depth blit and make occlusion test conservative
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>
2026-04-23 21:32:24 +10:00
Dion Moult 7b64dd338b ifcviewer: dirty-mesh tracking + consume sub-phase profiling for GPU cull
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>
2026-04-23 21:32:24 +10:00
Dion Moult 71612e0780 ifcviewer: hybrid GPU frustum+contribution cull with async readback
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>
2026-04-23 21:32:24 +10:00
Dion Moult 9aae8f0329 Revert "ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1"
This reverts commit 4fe32b54105ca2c5c00290603db17164837211e1.
2026-04-23 21:32:24 +10:00
Dion Moult 175efcfffe Revert "ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b)"
This reverts commit 7defbe982464536e34e80aa85d2cd7eaafbb62ee.
2026-04-23 21:32:24 +10:00
Dion Moult 643a2e1c1f Revert "ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c)"
This reverts commit 77cac3ec170b622db6977829f66b62603266a047.
2026-04-23 21:32:24 +10:00
Dion Moult 3c5c8e44cb Revert "ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d)"
This reverts commit 9a7a48944f4b62f9ca431149139eb846229f6114.
2026-04-23 21:32:24 +10:00
Dion Moult fc89ffeb19 Revert "ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount"
This reverts commit d5b7b87ba17c90008cf0673c838ce8431ad85e36.
2026-04-23 21:32:24 +10:00
Dion Moult 4bedb40d8a ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount
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>
2026-04-23 21:32:24 +10:00
Dion Moult a34c36d22e ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d)
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>
2026-04-23 21:32:24 +10:00
Dion Moult e5ed7b53d4 ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c)
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>
2026-04-23 21:32:24 +10:00
Dion Moult 069ef20c46 ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b)
Extend the GPU-cull indirect buffer from M to 2M commands: the first M
are the forward (non-reflected, CCW) bucket, the second M are the
reverse (reflected, CW) bucket.  The compact shader reads flags bit 0
from the AABB SSBO and routes each survivor to the appropriate bucket
via bucket = reflected ? mesh_id + M : mesh_id.

uploadGpuCullStaticBuffers() now precomputes exact per-mesh fwd/rev
instance counts so each bucket reserves only the slots it needs
(total visible_ssbo size unchanged — sum of fwd + rev = total).

Draw loop issues two MDIs per model under IFC_GPU_CULL: first M
commands CCW, next M commands CW.

Sub-draws doubled (172k → 345k) which further regresses FPS due to
command-processor overhead from zero-instance sub-draws — the same
issue noted in 3a.  MDI compaction remains the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 0b122ae1f5 ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1
Promote the compute cull from a validation shader to the actual draw
driver.  With the gate on, the CPU cull fan-out is skipped and MDI
consumes gpu_indirect_buffer / gpu_visible_ssbo directly.

- uploadGpuCullStaticBuffers() pre-fills per-mesh DrawElementsIndirect
  commands and a mesh_base prefix sum so the compact shader can scatter
  survivors into a fixed per-mesh range.  Instance count for each
  command is zeroed by a tiny reset dispatch, then the compact shader
  atomically writes survivors and increments instanceCount.
- Draw loop branches on the gate: single CCW MDI with all mesh
  commands.  Fwd/rev winding split, LOD selection, and HiZ are still
  CPU-path-only; reflected instances render with wrong winding under
  this gate (step 3b).
- Once-per-second readback of each model's indirect buffer populates
  the survivor / visible-object / visible-triangle stats so the
  [frame] line reflects what the GPU actually drew.

Known regression: sub_draws is the full mesh count per model (~172k on
the test dataset) vs the handful of non-empty commands the CPU path
produces.  Command-processor overhead from zero-instance sub-draws is
what drives the FPS drop, not the cull itself (0.05 ms).  Compacting
non-empty commands requires glMultiDrawElementsIndirectCount, a GL 4.6
entrypoint not exposed by Qt's QOpenGLFunctions_4_5_Core; deferring to
3a-followup so we don't bolt a getProcAddress loader into the renderer
mid-restructure.

IFC_GPU_CULL is off by default, so this does not affect normal runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult a0cc4b874b ifcviewer: add GPU frustum-cull validation shader (IFC_GPU_CULL=1)
First Phase 3E milestone: a compute shader that reads the per-instance
world-AABB SSBO added in the last commit, tests each instance against
the 6 frustum planes, and atomicAdds a global counter.  No visible list
or indirect-buffer writes yet — the output is just a survivor count,
cross-checked each frame against the CPU cull's numbers in the stats
line (`gpu_cull[Xms in=A surv=B]`) so we can verify the plumbing end-
to-end before we hand the GPU responsibility for the actual render data.

Dispatched from render() after the CPU cull completes, only when
IFC_GPU_CULL=1 and the camera moved (the skipped-cull still-frame path
doesn't re-check either).  The readback is synchronous — that's fine
for a validation path; it'll go away once the GPU writes indirect
commands directly.

Expected invariant: gpu_cull.surv >= cpu_cull.visible_objects, since
the GPU path does frustum-only and CPU adds contribution + HiZ cuts on
top.  A large mismatch (orders of magnitude, or surv < visible) means
the SSBO upload or shader logic is wrong.

No shader/buffer bindings overlap with the draw path (compute uses
bindings 0/1, restored before drawing; draw programs rebind 0/1/2).
2026-04-23 21:32:24 +10:00
Dion Moult 2f88778c9f ifcviewer: upload per-instance world AABBs to a GPU SSBO
Scaffolding for Phase 3E (GPU compute cull).  After finalizeModel /
applyCachedModel, pack each InstanceCpu's world AABB + mesh_id +
reflection bit into a std430-friendly 32 B record and push it to a
per-model aabb_ssbo.  No consumer yet — the CPU cull still drives
rendering — but the next commits will point a compute shader at this
buffer and have it produce the visible list + indirect commands
directly on the GPU.

Cost: 32 B per instance, ~18 MB for the 569 k-instance test scene.
One-shot upload at finalize time; streaming-time appends aren't
mirrored (the CPU cull doesn't need the SSBO, and finalizeModel
rebuilds the whole thing in one go).
2026-04-23 21:32:24 +10:00
Dion Moult 03662d2016 ifcviewer: fix pick-pass cull corruption and cached-model ID collisions
Two stability bugs:

1. Clicking an object left the scene with wrong shading until the camera
   moved.  The pick pass re-culls every model with its own parameters
   (min_pixel_radius=0, no HiZ) and overwrites each model's visible_ssbo
   and indirect buffer.  The next render() saw an unchanged camera,
   skipped the cull via the have_cached_cull_ shortcut, and drew the
   stale pick-pass buffers.  Fix: invalidate have_cached_cull_ at the
   end of pickObjectAt().

2. Loading two sidecar-cached models made the second model's picked
   properties resolve to the first model's elements.  Sidecars store raw
   object_id / model_id values from the session that wrote them, and
   both files start at object_id=1, so element_map_ entries collided.
   Fix: on load, rebase every PackedElementInfo and InstanceCpu by
   (next_object_id_ - min_id_in_sidecar) and overwrite model_id with
   the freshly-assigned handle before the elements hit element_map_.

Also document both in the README — the pick-pass note under 3A
contribution culling, the sidecar rebase under the sidecar format
section.
2026-04-23 21:32:24 +10:00
Dion Moult 99f409280a ifcviewer: disable HiZ cull when camera has moved
HiZ from last frame encodes depth from last frame's viewpoint. When
the camera moves, projecting a current-frame AABB through the stored
VP answers 'was this occluded last frame?' rather than 'is it occluded
now?' — a self-reinforcing feedback loop where objects culled in
prior frames never appear in any depth buffer and stay permanently
hidden at certain camera angles.

Fix: require hiz_vp_ == current VP for the HiZ test to apply. HiZ
still helps static views (kicks in one frame after camera stops) but
no longer produces false occlusions during orbit. The correct fix for
orbit coverage is a depth pre-pass feeding fresh HiZ — planned as
part of Phase 3E GPU compute cull.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 37fa4e9076 ifcviewer: parallel per-model CPU cull
Split cullAndUploadVisible into cullModelCpu (CPU-only, thread-safe) and
uploadCullResults (GL-only, main thread). render() fans the per-model
culls out via std::async and joins before the serial upload pass.

The cull scratch (vis_fwd/rev_lod0/1, visible_flat, indirect_scratch)
moved onto ModelGpuData so each worker owns its output buffers. Phase
timers and hiz_reject_count_ are atomic since workers fetch_add into
them. A new wall-clock timer around the dispatch block reports the
actual frame-time contribution; the existing clr/trv/emt counters are
now documented as per-thread sums.

Measured on the 18-model / 569k-instance test scene: wall-clock cull
dropped from ~25 ms to ~5 ms while the aggregate CPU work (trv) stayed
~30 ms. Frame time 34 ms -> 19 ms. IFC_CULL_THREADS=0 forces the
single-threaded fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 574bcfa6c5 ifcviewer: quantize VBO to 16 B/vertex (sidecar v6)
Position now u16x3 normalized against each mesh's local AABB; normal
oct-encoded to i16x2; RGBA8 colour unchanged. Per-mesh dequant basis
lives in a new MeshGpu SSBO at binding 2; both main and pick shaders
mix() against it before applying the instance transform.

Drops VBO and sidecar size by ~43 % (28 -> 16 B/vert), which matters
mostly for warm-load downloads of precomputed sidecars and steady-state
VRAM. LodBuilder dequantizes positions into a scratch buffer before
calling meshopt, since meshoptimizer needs float positions.

Also fixes a streaming-time crash in cullAndUploadVisible: bvh_items
was only populated at finalize, but the linear fallback indexes it
during streaming. Mirror BvhItem appends in uploadInstanceChunk so the
hot path stays valid before the BVH is built.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 09ffdd2028 ifcviewer: event-driven rendering, idle scenes cost zero CPU
Replaced the 16ms QTimer with QEvent::UpdateRequest delivered via
requestUpdate(), posted from every state mutator (mouse/wheel, model
lifecycle, selection, visibility, resize).  A static BIM scene — the
common case for a viewer — now does no work at all between user actions.

FPS is now measured as time spent inside render() rather than wall-clock
gap between frames, so idle gaps don't pollute the 1-second window and
the headline number reflects real render throughput.  Headline fps still
caps at vsync; sub-vsync profiling lives in the cull[...] phase timers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult d0c5bd5e85 Cull: skip cullAndUploadVisible + HiZ on still frames
render() was re-running the full cull every 16 ms timer tick even when
nothing had changed — the camera matrices, scene state, and therefore
visible set were all identical to the previous frame's.  The GPU was
still happy to redraw from the cached indirect buffer, but the CPU was
burning 21 ms/frame rebuilding the same visible list.

Detect the no-op case by comparing view/proj against last_cull_view_ /
last_cull_proj_ and checking a scene-dirty flag (have_cached_cull_)
that every mutator on models_gpu_ invalidates — finalizeModel,
applyCachedModel, applyLodExtension, hide/show/remove/reset, and
uploadInstanceChunk.  When the check passes we skip both
cullAndUploadVisible and buildHizPyramid (the depth buffer is
bit-identical, so re-reading it produces the same pyramid).

Per-model visible_objects / visible_triangles stats now live on
ModelGpuData so the stats line reports correct numbers on skipped
frames instead of reading from a stale indirect_scratch_.

Measured on a 569k-object overview: still frames go 22 fps → 62 fps;
orbiting goes 23 fps → ~30-50 fps depending on how hard you move the
mouse (the cull only pays its full cost on the ~25 % of frames where
the camera actually moved).  The stats line gains a "skipped N/M"
field so you can see the ratio live.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult c03a7fe117 Cull: read AABBs from compact bvh_items in the hot path
cullAndUploadVisible was reading each instance's AABB through
m.instances[idx] — a 104-byte InstanceCpu struct — for the frustum /
contribution / HiZ tests.  Only 24 of those bytes (the two float[3]
AABBs) are actually used by the tests; the rest (4×4 transform +
header) is pure cache-line waste, and with 569k instances the array
is 59 MB, well past any cache.

bvh_items[idx] already stores a 1:1 compact 28-byte record with the
same AABB, built unconditionally in buildBvhForModel().  Switch the
hot test path to read from it, and only touch InstanceCpu once an
instance has passed all three tests (for mesh_id).  Modest ~20 %
drop in cull-traverse time on a 569k-object overview (26 ms → 21 ms).

Also add four cull-phase timers (clr / trv / emt / upl) to the
per-second stats line so future optimisation work has concrete
numbers to chase.  Confirmed via these timers that bucket clears,
emit and GPU upload are all <1 ms combined; traversal is where the
remaining CPU cost lives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 8596d53a4a Phase 3C: Hierarchical-Z occlusion culling (CPU-side v1)
After the main draw, blit the MSAA default-framebuffer depth to a
single-sample 256×128 depth texture, read it back, and build a CPU
max-reduced mip pyramid.  Next frame's cullAndUploadVisible projects
each BVH node / instance AABB through the previous frame's VP and
compares the AABB's nearest depth against the pyramid's deepest value
at the matching mip level; strictly-beyond AABBs are rejected.

Conservative direction (aabb_near > hiz_max) — never wrongly rejects a
visible instance, so no flicker.  BVH subtree-level test lets a single
8-corner projection reject up to a leaf's worth of instances.

Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width.
New stats counter hiz_rej shows rejects/frame.

Measured: big win on interior views (GPU-bound), roughly zero net
effect on exterior overviews (CPU-bound on cull traversal, so the
saved GPU work is masked).  Tried a 3-deep PBO ring for async readback
and reverted — the extra frame of staleness produced visible flicker
on fast orbit, and the synchronous readback wasn't actually a measured
bottleneck at 256×128.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult c78e16eafb Phase 3B: per-instance LOD via meshoptimizer simplifySloppy
Decimate each unique mesh once at sidecar-build time and swap to the
reduced index slice per-instance per-frame when projected sphere radius
drops below IFC_LOD1_PX (default 30).  Same VBO, same SSBO, just a
different firstIndex/count in the indirect command.

Extends MeshInfo (48→56 B) with lod1_ebo_byte_offset + lod1_index_count
and bumps the sidecar to v5.  buildLods() runs inside
onStreamingFinished, appends decimated indices to sd.indices,
applyLodExtension pushes the EBO suffix to the live GPU state, and the
sidecar is written with LOD1 baked in.

simplifySloppy (voxel clustering) is used instead of the default
edge-collapse meshopt_simplify because BIM brep output is per-triangle-
unwelded and non-manifold after welding — simplify returned the input
unchanged for every mesh tested.  Sloppy ignores topology.  Knobs
(IFC_LOD_SLOPPY, IFC_LOD_ERROR, IFC_LOD_RATIO, IFC_LOD_MIN_SAVINGS,
IFC_LOD_LOCK_BORDER, IFC_LOD_DEBUG) are available for A/B tuning.

Result on the 128M-tri 10-model test scene (GTX 1650, 2px contribution
cull): 20.2 → 43.2 fps, 40M → 14M visible triangles, no change in
object count.  LOD build adds 100–600 ms per model on first open,
cached thereafter.

README Phase 3B section is now a full writeup of pipeline, selection,
decimator-choice rationale, env vars, and measured numbers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 90366f8236 Phase 3A: screen-space contribution culling
Reject frustum-visible objects whose bounding sphere projects below a
pixel-radius threshold.  Applied at both BVH-node level (whole subtrees
pruned) and per-instance level; short-circuits when the camera is
inside the AABB so nothing-you're-standing-next-to is ever lost.
Pick pass passes threshold 0 so sub-pixel objects stay clickable.

Threshold defaults to 2 px (radius), overridable via IFC_MIN_PX env
var.  Measured on the 128 M-tri test scene (GTX 1650):

  0 px (off):   6.7 fps, 128 M tris
  2 px:        20.2 fps,  40 M tris (31%)
  4 px:        30.3 fps,  15 M tris (12%)

The metric is sphere-based (cheap: one sqrt per test) rather than
AABB-corner projection; loses a little precision on very elongated
bounds but costs ~5x less per test and the BVH-node pre-cull means
the long-tail-of-small-things case is already handled by subtree
pruning before we touch individual instances.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult d3c21d7a81 Pivot Phase 3: diagnose as draw-bound, not upload-bound
Earlier probes pointed at per-frame glNamedBufferSubData uploads as the
bottleneck (60 fps when those two calls were commented out).  That was a
false reading — zeroing the uploads also emptied the indirect buffer, so
MDI drew nothing.  "No upload" and "no draw" were indistinguishable.

Two new diagnostic env vars in render() isolate the real costs:

  IFC_SKIP_MDI=1       keep cull + upload + binds, skip only the MDI
                       draws.  Gives 62 fps with everything else running,
                       confirming the non-draw path fits in ~16 ms.
  IFC_MAX_SUBDRAWS=N   cap each MDI's drawcount.  67k -> 30k sub-draws
                       saves 0 ms, confirming sub-draw count itself is
                       not the bottleneck; the long tail of sub-draws
                       carries ~no triangles.

On a GTX 1650 with 128 M triangles in view, nvidia-smi sits at 95 %
GPU util and FPS scales with triangle work, not sub-draw count.  The
card is simply rasterising at ~850 M tri/s.  No CPU-side or upload
trick recovers it.

Revised Phase 3 is therefore shedding triangles, not bytes:
  3A screen-space contribution culling (next)
  3B LOD
  3C HiZ occlusion
  3D GPU-side compute culling

README Phase 3 section rewritten around the diagnosis, including the
false lead, so future work doesn't re-tread the upload path.  The
aborted staging+resident ring-buffer implementation was reverted (the
uncommitted working tree is gone — pure glNamedBufferSubData retained
for the visible + indirect buffers, which we now know is fine).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult 3110c98429 Backface culling with reflection-aware two-pass MDI
Enables GL_CULL_FACE by default (user-toggleable in Settings) so
closed solids skip shading their back halves.  The catch is that
IFC placements can contain reflections (mat4 with det<0 — mirrored
families, symmetric instances).  Naively culling would make every
mirrored instance vanish because the rasterizer sees its screen-space
winding as backwards.

Fix: detect reflections at upload time via determinant sign, bucket
visible instances into forward (det>=0) and reverse (det<0) per mesh
during culling, and issue two glMultiDrawElementsIndirect calls per
model with glFrontFace toggled CCW/CW between them.  The indirect
buffer is still one buffer — just split into a forward slice followed
by a reverse slice, with m.indirect_forward_count recording the split.

Vertex shader flips the normal when the transform has negative
determinant, keeping lighting correct on mirrored instances.  The
fragment shader keeps the gl_FrontFacing fallback as a safety net
when culling is disabled (e.g. for files with open shells).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00
Dion Moult f532624dc1 Two-sided lighting, rename misleading draw-count stat
Two bugs conflated as "weird colors":

1. Two-sided lighting.  IFC placements often embed reflection
   matrices (mirrored families).  Transforming a_normal by
   mat3(inst.transform) produces a normal pointing the wrong way
   on those instances, and max(n·L, 0) then clamps the surface to
   pure ambient — reads as dark / washed out.  Use gl_FrontFacing
   to flip n in the fragment shader so both winding orientations
   shade correctly.  The proper fix (ship an inverse-transpose
   normal matrix or a det-sign bit per instance) is still owed;
   that would unlock re-enabling GL_CULL_FACE for a big fragment-
   work win on closed solids.

2. Stats label "inst_draws" was counting indirect sub-draws, not
   actual GL draw calls — misleading since MDI collapses N sub-
   draws into one glMultiDrawElementsIndirect.  Split into
   gl_draw_calls (real GL calls, = drawn-model count) and
   indirect_sub_draws (packed sub-commands).  For a BIM model
   with 47k unique meshes at full view this now correctly reads
   "1 gl_draws (47092 sub)" rather than suggesting 47k driver
   dispatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:24 +10:00