Files
IfcOpenShell/src/ifcviewer/ViewportWindow.h
T
Dion Moult da5c0b7991 ifcviewer: section-plane cut tool on web — shared gizmo, true-face pick, drag/Del
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>
2026-07-03 19:20:50 +10:00

975 lines
49 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef WGPUVIEWPORTWINDOW_H
#define WGPUVIEWPORTWINDOW_H
#include <QWindow>
#include <QTimer>
#include <string>
#include <unordered_set>
#include "Stopwatch.h"
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <atomic>
#include <cstdint>
#include <deque>
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include "FrameStats.h"
#include "SidecarCache.h"
#include "BufferPool.h"
#include "InstanceCompose.h"
#include "ModelGpuData.h"
#include "OverlayRenderer.h"
#include "SelectionState.h"
#include "StreamingThread.h"
#include "ViewportCore.h"
#include "ViewportHost.h"
#include "VisibilityState.h"
// Stage-2 wgpu viewport: opens a native QWindow, brings up a wgpu instance/
// adapter/device, configures a surface against the platform-native window
// handle, and clears to background_color_ on every UpdateRequest. Models
// loaded from `.ifcview` sidecars are uploaded as wgpu buffers (no draw
// path yet — that's stage 3).
//
// Mirrors the lifecycle shape of the GL ViewportWindow so subsequent stages
// can grow this into a full IFC renderer without restructuring the host.
//
// Also implements ViewportHost: as the Path-A refactor moves rendering
// state out into ViewportCore, this class plays the embedder role
// (provides the wgpu surface, schedules frames, forwards notifications
// to Q_SIGNALS). The web target's host is the analog on the Emscripten
// side. Today most state still lives here; the override implementations
// at the bottom of the class are the bridge for whatever has already
// moved.
class ViewportWindow : public QWindow, public ViewportHost {
Q_OBJECT
public:
explicit ViewportWindow(QWindow* parent = nullptr);
~ViewportWindow();
// --- ViewportHost ----------------------------------------------------
//
// Implementations live in ViewportWindow.cpp alongside the
// platform-specific surface code so the Qt + native window-handle
// bits stay co-located. Notification overrides (onObjectPicked
// etc.) forward to the existing Q_SIGNALS so bonsai-side consumers
// see no change.
WGPUSurface createSurface(WGPUInstance instance) override;
void framebufferSize(int& width_px, int& height_px) const override;
float dpr() const override;
void requestFrame() override;
void quit() override;
void onObjectPicked(uint32_t object_id) override;
void onSurfacePickedInTool(int x_px, int y_px, int modifiers) override;
void onToolModeChanged(int tool_mode) override;
void onToolBackspacePressed() override;
void onFrameStats(const FrameStats& stats) override;
void encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
const OverlayFrame& frame) override;
void encodeOverlaysPostMain(WGPUCommandEncoder enc,
WGPUTextureView surface_view,
const OverlayFrame& frame) override;
void saveScreenshotRgba8(const std::string& path, const std::uint8_t* rgba,
int w, int h) override;
void setBackgroundColor(float r, float g, float b, float a = 1.0f);
// Queue a sidecar path to be loaded after wgpu init completes. Safe to
// call before the window is exposed. The path is resolved against the
// working directory and read via SidecarCache::readSidecar (which
// normalises stem → .ifcview).
void queueLoadSidecar(const std::string& path);
// Synchronous metadata load + GPU upload. Requires wgpu init to have
// completed (i.e. the window has been exposed at least once). Returns
// the assigned model_id, or 0 on failure. Reads metadata only (mesh
// dict + instance dict + georef); per-chunk vertex / index bytes are
// read on demand by the per-frame loader as chunks become visible.
uint32_t loadSidecar(const std::string& path);
// Allocates per-chunk small buffers and the model-shared mesh /
// instance storage upfront, but leaves each chunk's pool ranges
// unclaimed and is_resident=false. The per-frame loader
// (driveStreamingLoads) sub-allocates the chunk's vertex + index
// ranges from pool_ on demand as cull flags them visible.
void applyCachedModel(uint32_t model_id,
struct StreamingSidecar metadata);
// Direct-IFC ingestion (mirrors GL ViewportWindow). The host (typically
// a GeometryStreamer running on a worker) calls uploadMeshChunk +
// uploadInstanceChunk once per representation / placement as the IFC
// triangulates; finalizeModel commits when the iterator finishes.
// Staged in CPU memory; finalizeModel runs the chunk planner over the
// staged data, allocates pool slices, and uploads — same render path
// as a sidecar load. Bytes are gathered from memory (no disk I/O), so
// every chunk lands `is_resident=true` immediately. The streamer's
// model_id is passed through unchanged; the viewport's globally-unique
// object_id rebasing happens at finalize time.
void uploadMeshChunk(const struct MeshChunk& chunk);
void uploadInstanceChunk(const struct InstanceChunk& chunk);
void finalizeModel(uint32_t model_id);
void removeModel(uint32_t model_id);
void resetScene();
// Model-level visibility. Mirrors the GL ViewportWindow API — flips
// ModelGpuData::hidden, which every render/pick/cull pass already
// consults. requestUpdate() so the change is visible immediately.
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// Wgpu does not yet recompose instances against these matrices —
// composeInstanceFromPlacement is still placement-only — so the
// setters store the input and post requestUpdate(). Bonsai-side
// integration compiles against these signatures; visual georef parity
// arrives with the recompose+SSBO-rewrite work tracked separately.
void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters);
void setModelCoordinateOperation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters);
void setModelTransformation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters);
size_t modelCount() const { return models_gpu_.size(); }
// Frame the union of all loaded models' world AABBs. No-op on empty
// scenes. Called automatically after the first model loads (unless
// setCamera was already invoked); clients can re-invoke to re-frame.
void viewAll();
// Explicit camera state, mirroring the GL ViewportWindow API. Suppresses
// the auto-viewAll on first load so a script-driven camera survives
// model loading. Parameters match the GL --camera tx,ty,tz,dist,yaw,pitch
// order so a pasted camera string lands the same view in both backends.
void setCamera(float tx, float ty, float tz,
float dist, float yaw_deg, float pitch_deg);
// GL-parity camera helpers. setStandardView snaps to an axis-aligned
// angle without re-framing (used by X/Y/Z keys). focusOnSelectedObject
// frames the union AABB of the current selection. toggleProjection
// flips perspective <-> orthographic. cameraString formats the current
// state for a --camera CLI arg.
void setStandardView(float yaw_deg, float pitch_deg);
void focusOnSelectedObject();
void toggleProjection();
bool projectionOrtho() const { return projection_ortho_; }
std::string cameraString() const;
// Snapshot of the orbit camera. Canonical struct now lives in
// ViewportCore so the camera-mutator path stays Qt-free (#84-i);
// the alias keeps bonsai's "save view" / "restore view" callers
// working unchanged.
using CameraState = ViewportCore::CameraState;
CameraState cameraState() const;
// Tool toggles: flip between NoTool and the named tool. Wrappers
// around setToolMode so bonsai's verb actions stay terse.
void toggleAreaTool();
void toggleLengthTool();
void toggleVolumeTool();
// Element-level visibility verbs. The fine-grained per-id mutations
// go through visibility_; these high-level methods are what bonsai's
// Commands.cpp calls. Hidden elements are dropped from cull (no draw,
// no depth, no pick).
void hideSelectedElements();
void isolateSelectedElements();
void showAllElements();
void invertElementVisibility();
// Replace the selection with {id} (or clear if id == 0). Used by
// SessionState mirroring and by project commands that drop selection
// on model removal. Wrapper around selection_.replace / clear.
void setSelectedObjectId(uint32_t id);
// FPS / fly mode. enterFpsMode swaps the orbit camera for a WASD/QE
// free-fly camera (hotkey: Shift+F). exitFpsMode restores the orbit
// pivot and reveals the cursor. Mouse-look uses raw deltas (cursor is
// hidden and recentered each frame).
void enterFpsMode();
void exitFpsMode();
bool fpsMode() const { return fps_mode_; }
private:
// Common camera math used by render, cull, streaming, and pick. Produces
// the view matrix and a WebGPU-correct projection (z mapped to [0, 1]).
// Single helper so projection_ortho_ and the up-vector switch at near-
// vertical pitch land identically everywhere.
// buildViewProj moved to ViewportCore (#84-h).
// Per-frame WASD integration when fps_mode_ is true. Called near the
// top of render() so the displayed frame already reflects movement.
void fpsIntegrate();
// Build the camera AABB for a single object across all loaded models.
bool computeObjectAabb(uint32_t object_id,
float mn[3], float mx[3]) const;
public:
// Eigen::Vector3f overload — matches GL ViewportWindow::computeObjectAabb so
// bonsai's volume readout / focus callers compile unchanged. Just a
// thin wrapper around the float[3] version.
bool computeObjectAabb(uint32_t object_id,
Eigen::Vector3f& mn, Eigen::Vector3f& mx) const;
private:
// Re-aim the orbit camera so the bounding sphere of [mn, mx] fits.
void frameAabb(const float mn[3], const float mx[3], float padding);
// chunkScreenAreaPx moved to ViewportCore (#84-h).
public:
// Apply a nav mouse preset by name ("blender"|"rhino"|"revit"|"web").
// Sources the shared binding table from ViewportCore; called from init
// (env / persisted setting) and live from the Settings dialog.
void applyNavPreset(const char* name);
// Queue a one-shot framebuffer capture: the next rendered frame is
// copied back to host memory and saved to `path` as PNG. If
// `quit_after` is true, QCoreApplication::quit() is called once the
// PNG is written. Use this for headless verification and pixel-diff
// parity testing against the GL backend.
void captureNextFrameToPng(const std::string& path, bool quit_after = true);
// Benchmark mode: render N timed frames (after a small warmup), yaw-
// sweeping the camera at 0.5°/frame, then print a stats block on
// stderr and QCoreApplication::quit(). Mirrors the GL minimal's
// --benchmark output format so a script can diff them line for line.
void setBenchmarkFrames(int frames);
protected:
void exposeEvent(QExposeEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool event(QEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
private:
bool initWgpu();
bool createSurface();
// configureSurface moved to ViewportCore (#84-u).
void render();
void shutdown();
bool buildPipelines();
void buildModelBindGroup(ModelGpuData& m);
// buildChunkBindGroup / applyStreamedChunk / loadChunkBytesAndUploadGpu
// / unloadChunk moved to ViewportCore (#84-n). Call them via core_.
//
// Called from render() after cull: find non-resident chunks with
// current visible draw counts > 0 and bring them resident. When the
// pool is full, evicts LRU non-visible chunks first, then falls back
// to evicting the farthest-from-camera visible chunks if a closer
// candidate needs the space. Triggers requestUpdate() if more remain.
void driveStreamingLoads();
void ensureDepthTexture(int w, int h);
void releaseDepthTexture();
void ensureMsaaColorTexture(int w, int h);
void releaseMsaaColorTexture();
bool buildHizPipeline();
bool buildEdgePipeline();
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
// Show/hide the pivot indicator. hide_after_ms > 0 starts the
// single-shot auto-hide timer used by the wheel-zoom afterglow;
// drag callers pass 0 and toggle manually on press/release. The
// actual gizmo rendering lives in OverlayRenderer — this just
// manages the UI-side visibility timer.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
// releaseEdgeResources / buildPickPipeline / ensurePickAttachments /
// releasePickResources moved to ViewportCore (#84-s, #84-t).
// Make sure selection_flags_buffer_ is large enough to address every
// object_id in next_object_id_. Recreates (and rebuilds frame_bind_group_)
// if it grew. Safe to call every frame; idempotent when already sized.
void ensureSelectionFlagsBuffer();
// Repack the CPU selection into bit-flags and wgpuQueueWriteBuffer to
// the GPU. Called from render() when selection_.dirty().
void uploadSelectionFlagsIfDirty();
// Synchronous pick: encodes a one-shot R32UInt render of the current
// visible_draws against the click pixel, copies the single texel back,
// waits, and returns the object_id (0 if nothing was hit). Call from
// the main thread between renders. When `normal_out` is non-null, the
// pick pass's RGBA16F normal MRT is also sampled at the same pixel
// (decoded from ×0.5+0.5 packing) so the section tool can drop
// perpendicular cuts.
uint32_t pickObjectAt(int x_pixels, int y_pixels,
Eigen::Vector3f* normal_out = nullptr);
// Pick + ray-cast — returns the object's id, the world-space point
// where the pick-pixel pillar enters that instance's AABB, and a
// camera-facing normal. Returns false on a background miss. We do
// CPU ray-AABB rather than reading per-pixel depth because WebGPU's
// copyTextureToBuffer for Depth32Float requires copying the whole
// mip extent — wasteful per click — and ray-vs-AABB lands close
// enough to the click for the section tool's "drop a plane here" UX.
bool pickSurfaceAt(int x_pixels, int y_pixels,
uint32_t& object_id_out,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out = nullptr);
// Rectangle pick: render the pick pass, copy the rect region of the
// R32UInt color attachment, and return every unique non-zero
// object_id covered. `rect` is in physical pixels (post-DPR), already
// clipped to the framebuffer by the caller.
std::vector<uint32_t> picksInRect(int x, int y, int w, int h);
public:
// Section-cutting tool. Mirrors the GL ViewportWindow API:
// K toggle (sectionToolActive / toggleSectionTool)
// Shift+K clearSectionPlanes
// click addSectionPlaneAtSurface (when tool active)
// Del/Backspace removeSectionPlane (most recent, when tool active)
// Esc deactivate tool
bool sectionToolActive() const { return section_tool_active_; }
void toggleSectionTool();
bool addSectionPlaneAtSurface(const Eigen::Vector3f& point,
const Eigen::Vector3f& normal,
float visual_radius = 0.0f);
void removeSectionPlane(int index);
void clearSectionPlanes();
int sectionPlaneCount() const { return int(section_planes_.size()); }
// Overlay primitives. Mirror GL ViewportWindow so the Measurement +
// dimension tools can target either backend through one API.
// Empty inputs clears the corresponding set.
void setOverlayLines(const std::vector<OverlayRenderer::LineGroup>& groups);
void setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float stroke_r, float stroke_g,
float stroke_b, float stroke_a,
float stroke_extra);
void setOverlayLabels(const std::vector<OverlayRenderer::Label>& labels);
void setHudText(const std::string& text);
// Translucent world-space triangle overlay (Area-tool patch shading).
// Empty list disables; color is RGBA in [0, 1].
void setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a);
// CPU mesh shadow: positions (3 floats/vert, mesh-local) + indices
// (LOD0). Populated at applyCachedModel / applyStreamedChunk —
// returns false if the mesh isn't loaded yet (streaming) or the
// (model_id, mesh_id) pair doesn't resolve. Matches the GL
// ViewportWindow::MeshTriangles + readbackMeshTriangles shape so
// the measure tools port verbatim.
using MeshTriangles = ModelGpuData::MeshTriangles;
bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
MeshTriangles& out) const;
// Pure CPU lookup: object_id → owning model + mesh + raw placement
// matrix (column-major, pre-CoordinateOperation / FederatedFalseOrigin
// / ModelTransformation). Mirrors GL ViewportWindow::InstanceLookup
// so Measurement.cpp ports unchanged. The canonical struct lives in
// InstanceCompose so the lookup can be unit-tested without Qt.
using InstanceLookup = InstanceCompose::InstanceLookup;
bool findInstance(uint32_t object_id, InstanceLookup& out) const;
// A point that actually lies on the model's first instance — the
// first instance's mesh AABB centre transformed by that instance's
// placement, in metres, pre-CoordinateOperation. Lookup only — the
// viewport already keeps the CPU-side MeshInfo + InstanceInfo around
// for picking / measurement; the federation false-origin guess
// (ViewportView::guessFederatedFalseOriginFromFirstModel) consumes
// this lazily on modelGeometryReady. Returns false when the model
// is unknown or has no instances.
bool firstGeometryPointWorldM(uint32_t model_id,
Eigen::Vector3d& out) const;
// Re-frame the camera onto the federated false origin in post-shift
// space. After ViewportView's first-model false-origin guess sets a
// federation origin and the resulting recomposeAndUploadModel runs,
// the federation false origin (in world coords) maps to (0,0,0) in
// render coords — so we target (0,0,0) and the first model's
// anchor point sits dead-centre.
//
// Distance comes from the model's post-shift AABB diagonal with the
// same padding math as viewAll(), but clamped to `max_distance_m`
// so a model with one crazy-coord outlier vertex (16 km AABB
// diagonal because of one bad triangle) can't pull the camera so
// far back that the bulk of the geometry becomes a single pixel.
// Yaw/pitch unchanged — preserves the user's current look direction.
//
// Unlike viewAll() this *never* iterates all loaded models — it
// frames around the specific model the guess fired for, ignoring
// models with bad coordinates elsewhere in the session.
void frameOnFederatedOrigin(uint32_t model_id, float max_distance_m);
// Selection accessor. Exposed for callers (bonsai's volume readout)
// that need to read selectionIds() / activeObjectId(). Mutation goes
// through the existing setSelection / pick paths.
SelectionState& selection() { return selection_; }
const SelectionState& selection() const { return selection_; }
// Pick + resolve to mesh-local space. MeshLocalPick lives in
// ViewportCore (#84-t); re-exported here so existing bonsai
// callers keep referring to ViewportWindow::MeshLocalPick.
using MeshLocalPick = ViewportCore::MeshLocalPick;
bool pickMeshLocalAt(int x, int y, MeshLocalPick& out);
// Resolve a mesh-local point to a (placement-applied) global frame.
// Matches GL ViewportWindow::meshLocalToGlobal's shape so the Length
// tool's ENH readout ports unchanged. The wgpu viewer doesn't carry
// per-model CoordinateOperation yet, so this currently outputs
// placement_transformation · mesh_local (i.e. the IFC's own world
// coords pre-georeferencing); ENH and IFC-world coincide for the
// non-federated case the minimal viewer handles today.
bool meshLocalToGlobal(uint32_t object_id, const float mesh_local[3],
double global_out[3]) const;
// CPU world-space raycast. Brute-force: per-instance world-AABB
// reject, then ray-into-mesh-local + Möller-Trumbore against the
// CPU mesh shadow. `dir` must be a unit vector — distance is the
// ray's t parameter, which equals world distance only at |dir|=1.
// Used by the Length tool's 1-point laser-measure overlay to find
// the ceiling/floor counterpart of a horizontal-surface click.
// RaycastHit moved to ViewportCore (#84-t); re-exported here.
using RaycastHit = ViewportCore::RaycastHit;
bool raycast(const float origin[3], const float dir[3], RaycastHit& out) const;
// Measurement tools. Mirrors GL ViewportWindow::ToolMode. Volume is
// selection-driven (LMB / marquee). Area is click-to-accumulate:
// each LMB picks a triangle and either adds or removes its
// coplanar patch via BFS over shared edges; Alt+LMB skips the BFS.
// V/A/L toggle, Esc exits. Length consumes Backspace too for
// remove-last-point semantics.
// NoTool (not None) because X11/X.h #define's None as 0L; including
// it transitively via Qt's xcb back-end breaks any enum named None.
enum class ToolMode { NoTool, Volume, Area, Length };
Q_ENUM(ToolMode)
ToolMode toolMode() const { return tool_mode_; }
void setToolMode(ToolMode m);
// Per-frame snapshot of cull / scene stats, emitted via
// frameStatsUpdated at the end of each render(). Mirrors GL
// ViewportWindow::FrameStats so bonsai's status bar binding ports
// unchanged. gl_draw_calls is the wgpu draw-call count (named for
// continuity with the GL field bonsai's status format string uses).
// ViewportWindow::FrameStats is a using-alias for ::FrameStats (moved
// to its own Qt-free header so ViewportHost::onFrameStats can carry
// it without dragging Qt into core). Existing bonsai signal binding
// (frameStatsUpdated) keeps using the qualified name.
using FrameStats = ::FrameStats;
signals:
// Selection moved by a pick / marquee. Emitted with the active id
// (0 = miss). Bonsai mirrors this into SessionState.
void objectPicked(uint32_t object_id);
void frameStatsUpdated(const ViewportWindow::FrameStats& stats);
// Emitted instead of objectPicked when an Area / Length tool is
// active. The host branches on toolMode() and calls
// pickMeshLocalAt(x, y, ...) for hit details. Coordinates are in
// physical pixels (post-DPR).
void surfacePickedInTool(int x, int y, int modifiers);
// Emitted whenever the active tool changes (incl. on→off).
void toolModeChanged(ViewportWindow::ToolMode mode);
// Backspace/Delete pressed while a tool is active. Length tool's
// remove-last-point; other tools may ignore.
void toolBackspacePressed();
public:
// Sum of mesh-local volumes (m³) of every instance whose object_id
// is in `object_ids`. Each instance is scaled by |det(placement_3x3)|
// to pick up mapped-item scale/mirror; signed-tetrahedra absolute
// value means winding is ignored. Volumes are precomputed at
// applyCachedModel — this call is just lookups + multiplies.
double volumeOfObjects(const std::vector<uint32_t>& object_ids) const;
private:
// Per-object variant. Used by the Volume tool to drive both the
// total HUD and the per-object overlay labels at AABB centres.
std::vector<std::pair<uint32_t, double>>
volumesPerObject(const std::vector<uint32_t>& object_ids) const;
void ensureHizTextures(int viewport_w, int viewport_h);
void releaseHizResources();
// Resolves the just-rendered MSAA depth into the small single-sample
// HiZ texture and copies it to whichever staging slot is currently
// idle. Returns the slot index used, or -1 if both slots are still
// in flight (resolve is skipped this frame — fine, we already have
// a recent pyramid). Encoded onto `enc` so it ships in the same
// command buffer as the main draw.
int encodeHizResolve(WGPUCommandEncoder enc);
// Issues a non-blocking mapAsync on `slot` after submit, so the
// callback can fire whenever the GPU has actually finished writing.
void startHizMap(int slot, const Eigen::Matrix4f& vp_used);
// Drains pending mapAsync callbacks (via processEvents — does NOT
// block on GPU work). For any slot that just signalled Mapped, reads
// it, unmaps it, max-reduces the mip pyramid, and updates hiz_vp_.
void drainHizReadbacks();
// Project AABB through hiz_vp_ and test against the pyramid. False
// (keep) if HiZ isn't valid yet, AABB straddles the near plane, or
// any projection is unreliable. True (cull) when AABB is provably
// behind every relevant pyramid cell.
bool aabbOccludedByHiz(const float mn[3], const float mx[3]) const;
void flushPendingSidecarQueue();
// computeSceneAabb moved to ViewportCore (#84-h).
// Cull `m`'s instances against the supplied frustum planes (world-space,
// ax+by+cz+d >= 0 means inside), bucket survivors by (mesh_id, lod), and
// write the flat visible-index list into m.visible_buffer via
// wgpuQueueWriteBuffer. After return, m.mesh_draws is the per-mesh,
// per-LOD draw schedule for the frame.
//
// `eye` and `forward` (forward = unit (target - eye)) are used to compute
// each instance's view-space depth for the projected-radius formula.
// `focal_px` = viewport_height / (2 * tan(fov_y / 2)).
//
// Two pixel-radius thresholds:
// `min_radius_px` — instances projected below this are dropped
// entirely (contribution culling).
// `lod1_threshold_px` — survivors projected below this get the mesh's
// LOD1 index slice when one was baked.
// min_radius_px == 0 disables contribution culling.
// CPU-only phase of cull: produces m.visible_draws_scratch /
// prefix_sums_scratch and sets total_visible_draws / total_visible_
// vertices. Touches no wgpu state, so this can run on a worker thread
// (multiple models culled in parallel). Returns the number of HiZ
// rejections accumulated (caller adds to the per-frame stat).
// right/up are world-space camera basis vectors (orthonormal with
// forward). Used by the streaming priority accumulator to project
// each instance's world AABB to a screen-space rectangle — far
// tighter than a bounding-sphere projection for BIM geometry, which
// is overwhelmingly thin-in-one-axis (pipes, columns, slabs,
// windows). Sphere projection is kept for contribution / LOD picks
// because conservative-over is the right failure mode there.
// cullModelCpuCompute / cullModelCpuUpload moved to ViewportCore
// (#84-p). The render path calls core_.cullModelCpuCompute with a
// ViewportCore::HizOccludedFn that wraps aabbOccludedByHiz when
// HiZ is enabled (the pyramid + readback orchestration is still
// here), or null otherwise.
// Compose one instance's `transform` (float[16] column-major) from
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// and recompute its world AABB from the mesh's local AABB. Maths runs
// in double; the cast to float happens last so large IFC placements
// get cancelled by the federation false origin before precision is
// narrowed. Mirrors GL ViewportWindow::composeInstanceFromPlacement.
// Implementation lives in ViewportCore now (#84-d); this declaration
// stayed during the move and forwards to core_ — once every internal
// caller routes through ViewportCore directly the forwarder goes away.
// Walk every instance of `model_id`, recompose its transform from the
// current federation matrices, refresh per-chunk world AABBs, and
// re-upload InstanceGpu[] into m.instance_storage. No-op if the model
// is unknown, has no instances, or wgpu init hasn't completed.
void recomposeAndUploadModel(uint32_t model_id);
bool& wgpu_initialized_;
int& configured_w_;
int& configured_h_;
// ---- wgpu lifecycle state aliases ----------------------------------
//
// Actual storage lives in core_ (declared below; ViewportWindow is a
// friend of ViewportCore so these references can bind). Existing
// member-access sites in ViewportWindow.cpp keep working unchanged —
// they just resolve to core_.device_ etc. through these references.
// Each one is removed when its owning render method moves into
// ViewportCore (#84-b onwards).
ViewportCore core_;
WGPUInstance& instance_;
WGPUAdapter& adapter_;
WGPUDevice& device_;
WGPUQueue& queue_;
WGPUSurface& surface_;
WGPUTextureFormat& surface_format_;
bool& surface_configured_;
// Pipeline + bind-group-layout alias references — actual storage
// lives in core_ (see ViewportCore.h). Each goes away as the
// building method (buildPipelines / buildEdgePipeline /
// buildPickPipeline) migrates into ViewportCore.
WGPUShaderModule& main_shader_module_;
WGPUBindGroupLayout& frame_bgl_; // group 0
WGPUBindGroupLayout& model_bgl_; // group 1
WGPUPipelineLayout& pipeline_layout_;
WGPURenderPipeline& main_pipeline_;
WGPURenderPipeline& main_pipeline_transparent_;
// Frame uniforms + selection flags aliases (storage in core_).
WGPUBuffer& frame_uniform_buffer_;
WGPUBindGroup& frame_bind_group_;
WGPUBuffer& selection_flags_buffer_;
uint32_t& selection_flags_capacity_;
std::vector<uint32_t>& selection_flags_scratch_;
SelectionState& selection_;
VisibilityState& visibility_;
// Depth + MSAA attachment aliases (storage in core_, #84-r).
WGPUTexture& depth_texture_;
WGPUTextureView& depth_view_;
int& depth_w_;
int& depth_h_;
WGPUTexture& msaa_color_texture_;
WGPUTextureView& msaa_color_view_;
int& msaa_w_;
int& msaa_h_;
// SAMPLE_COUNT moved to ViewportCore.h as kViewportSampleCount (#84-k).
static constexpr uint32_t SAMPLE_COUNT = kViewportSampleCount;
// HiZ aliases (storage in core_, #84-r). The render path still
// drives the per-frame HiZ submit/drain orchestration, but the
// pipeline + pyramid + slot state all live in ViewportCore now.
WGPUShaderModule& hiz_shader_module_;
WGPUBindGroupLayout& hiz_bgl_;
WGPUPipelineLayout& hiz_pipeline_layout_;
WGPURenderPipeline& hiz_pipeline_;
WGPUBuffer& hiz_uniform_buffer_;
WGPUBindGroup& hiz_bind_group_;
WGPUTexture& hiz_resolve_texture_;
WGPUTextureView& hiz_resolve_view_;
uint32_t& hiz_resolve_w_;
uint32_t& hiz_resolve_h_;
uint32_t& hiz_padded_bpr_;
// Edge silhouette post-process (stage 9). Samples the MSAA depth
// texture in a fullscreen pass, computes a depth Laplacian, blends
// dark lines into the resolved surface colour. Matches GL's
// renderEdgePass() visually.
// Edge silhouette pipeline aliases (storage in core_).
WGPUShaderModule& edge_shader_module_;
WGPUBindGroupLayout& edge_bgl_;
WGPUPipelineLayout& edge_pipeline_layout_;
WGPURenderPipeline& edge_pipeline_;
WGPUBindGroup& edge_bind_group_;
bool& edges_enabled_;
// Pivot visibility state — the gizmo itself lives in overlays_.
// The timer auto-hides the pivot after a wheel-zoom afterglow.
bool pivot_indicator_visible_ = false;
QTimer* pivot_indicator_hide_timer_ = nullptr;
// All viewport overlays (axis indicator, section gizmos, marquee
// rect) — pipelines + shaders + buffers + encoders. The viewport
// builds a OverlayFrame each frame and asks the renderer to
// encode each overlay; see OverlayRenderer.h.
OverlayRenderer overlays_;
// Active measurement tool. setToolMode() / setSelection mutations
// both funnel into updateVolumeReadout() which pushes the HUD +
// per-object labels into overlays_.
ToolMode tool_mode_ = ToolMode::NoTool;
// Recompute the volume HUD + per-object labels from the current
// selection. No-op unless tool_mode_ == Volume; on the first call
// after entering Volume mode this primes the overlay.
void updateVolumeReadout();
// Area tool state lives in AreaMeasurement (header below). The
// viewport owns it for the session and routes LMB picks in Area
// mode through onAreaPick.
std::unique_ptr<class AreaMeasurement> area_tool_;
void onAreaPick(int x_phys, int y_phys, bool alt);
void updateAreaHud();
// Length tool state lives in LengthMeasurement. Same lifecycle
// pattern: lazily constructed on first L press, cleared on tool
// exit, click handler routes LMB through onLengthPick + Backspace
// through onLengthBackspace.
std::unique_ptr<class LengthMeasurement> length_tool_;
void onLengthPick(int x_phys, int y_phys, bool alt);
void onLengthBackspace();
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
// pulled from the same visible_draws / instances buffers as the main
// pass — pick fragment outputs the instance's object_id. The pick
// pipeline reuses pipeline_layout_ because it needs the same set of
// bindings (frame uniform at group=0, per-model storages at group=1).
// Pick pipeline + targets + staging all moved to ViewportCore (#84-t).
// No remaining VW callers need the textures/buffers by name, so no
// aliases here.
WGPURenderPipeline& pick_pipeline_;
// Section-cutting state aliases (storage in core_). The section tool
// mutates section_planes_ through addSectionPlaneAtSurface /
// removeSectionPlane; the per-frame uniform packs the same vector
// for the WGSL fragment-side clip gate.
std::vector<SectionPlane>& section_planes_;
bool section_tool_active_ = false;
// X-ray cap alias (storage in core_). Default 1.0 = no effect
// (fragment shader clamps alpha = min(in.color.a, xray_alpha_cap_),
// which returns in.color.a when the cap is 1). Alt+X drops it to
// 0.3 to translucent the whole scene; pressing again restores 1.0.
// When < 1.0, the cull classifier (also in core) routes 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).
float& xray_alpha_cap_;
// Marquee box-select. Armed on LMB press (when no other tool consumes
// the click), becomes active after the cursor moves past
// kBoxSelectThresholdPx — until then a release still routes through
// the single-pick path. Press-time modifiers decide the set op at
// release: plain → replace, Shift → add, Ctrl → remove.
bool box_select_armed_ = false;
bool box_select_active_ = false;
Eigen::Vector2i box_select_start_pos_; // logical px
Eigen::Vector2i box_select_current_pos_; // logical px
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
static constexpr int kBoxSelectThresholdPx = 5;
// box_pick_staging_buffer_/_capacity_ moved to ViewportCore (#84-t).
// Section-gizmo drag state + hit-test/drag math moved to ViewportCore
// (shared with web; the mouse handlers call core_.begin/update/endSectionDrag
// and core_.hitTestSectionGizmo).
// HiZ slot + pyramid aliases (storage in core_, #84-r). VW's render
// loop reads hiz_valid_ / hiz_vp_ to gate the HizOccludedFn, and
// resets hiz_trace_budget_ once per frame; everything else moved.
std::vector<float>& hiz_pyramid_;
std::vector<uint32_t>& hiz_mip_offset_;
std::vector<uint32_t>& hiz_mip_w_;
std::vector<uint32_t>& hiz_mip_h_;
Eigen::Matrix4f& hiz_vp_;
bool& hiz_valid_;
uint32_t& hiz_reject_count_;
std::atomic<int>& hiz_trace_budget_;
Eigen::Vector4f& background_color_;
// Camera state aliases (storage in core_).
float (&camera_target_)[3];
float& camera_distance_;
bool& projection_ortho_;
// Fly / FPS-mode state. Mirrors GL ViewportWindow::CameraMode::Fps.
// While fps_mode_ is true: cursor is hidden, mouse-look uses raw
// deltas, fps_keys_held_ accumulates pressed W/A/S/D/Q/E/Shift, and
// render() integrates a movement step each frame from those keys.
// exit via Esc (also any unrelated key click) — recenter the cursor
// back at fps_press_center_ so the orbit camera resumes cleanly.
bool fps_mode_ = false;
std::unordered_set<int> fps_keys_held_;
Stopwatch fps_last_tick_;
Eigen::Vector2i fps_press_center_;
bool fps_ignore_next_mouse_move_ = false;
// Fly base speed (m/s) + wheel adjustment now live in ViewportCore
// (fly_move_speed_ / flyAdjustSpeed), shared with the web fly path.
// Per-frame [fly] dt log when WGPU_FLY_DEBUG=1. Diagnoses stutter:
// print dt of each fpsIntegrate call and the prior render's elapsed
// ms. Off by default (env-gated) so the normal log stays clean.
bool fly_debug_ = false;
Stopwatch fly_render_clock_;
// Click-and-track aliases (storage in core_). The pick handler still
// lives in VW so it touches these by name; driveStreamingLoads in
// core dumps the priority / pool stats at eviction.
uint32_t& tracked_object_id_;
uint32_t& tracked_chunk_mid_;
size_t& tracked_chunk_idx_;
bool& tracked_was_resident_;
// Mouse-navigation bindings — mirrors GL's NavBindings + currentNavBindings().
// Selection stays on LMB for every preset (none of the presets steal it),
// so the click-vs-drag distinction at mouseReleaseEvent's pick path keeps
// working. Set at init from WGPU_NAV_PRESET=blender|rhino|revit (default
// blender, matching GL's AppSettings::NavPreset::Blender default).
// Mirror of ViewportCore's preset bindings, mapped to Qt types by
// applyNavPreset (the core owns the preset table; these are the Qt-side
// cache the mouse handlers compare against).
Qt::MouseButton orbit_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier;
Qt::MouseButton pan_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier;
Qt::MouseButton select_button_ = Qt::LeftButton;
Qt::KeyboardModifiers select_mods_ = Qt::NoModifier;
// Set by mousePressEvent based on which binding matched; consumed by
// mouseMoveEvent so mid-drag modifier changes don't switch axes.
enum class NavDrag : uint8_t { Inactive, Orbit, Pan };
NavDrag nav_drag_kind_ = NavDrag::Inactive;
float& camera_yaw_deg_;
float& camera_pitch_deg_;
float& camera_fov_y_deg_;
float& camera_near_;
float& camera_far_;
// Contribution-cull thresholds. Still-frame uses min_pixel_radius_;
// when the camera changed since last frame, the bigger motion threshold
// kicks in to drop more sub-pixel detail (and slash per-frame cull cost).
//
// GL ships 2.0 / 10.0, but uses euclidean distance for projected_px
// (sqrt(dx² + dy² + dz²)) while wgpu uses view-Z distance (the
// perspective-divide-correct denominator). For off-axis instances
// view_z < euclidean, so wgpu's projected_px is larger than GL's at
// the same numeric threshold — i.e. wgpu is structurally less
// aggressive. Bumping to 3.0 / 15.0 compensates so the effective drop
// rate matches GL's; on the federation scene this lands obj/tri
// counts within ~10% of GL's across an orbit (vs ~3× without the
// bump). Override at runtime via WGPU_MIN_PX / WGPU_MIN_PX_MOTION.
// Cull-tuning aliases (storage in core_, #84-x). VW's env-var prelude
// still mutates these through the alias.
float& min_pixel_radius_;
float& motion_min_pixel_radius_;
// Whether driveCull dispatches per-model work via std::async. ON by
// default; setting WGPU_CULL_THREADS=0 forces sequential cull for
// measurement (does std::async actually parallelize on this libstdc++?
// and is per-model the right granularity?).
bool& cull_threads_enabled_;
public:
// Master switch for HiZ occlusion. OFF by default — has two issues vs
// the GL backend on this codepath (see task #58):
// (1) Correctness: bottom-edge AABBs get falsely rejected as the
// camera rotates. Math review didn't pin it down; root cause
// likely needs RenderDoc capture of the pyramid.
// (2) Perf: HiZ ON costs ~2.5 ms more cull time than HiZ OFF on
// the federation bench but only saves ~1 ms of raster work
// because our indirect-draw iterates the visible_draws buffer
// regardless. Net 9% slower (49.7 vs 54.2 fps).
// Opt-in via WGPU_HIZ=1. Storage in core_; aliased here so the
// env-var setup + render() gating keep compiling.
bool& hiz_enabled_;
// When true, initWgpu requests the WebGPU mandatory floor limits
// (maxStorageBufferBindingSize=128MB, maxBufferSize=256MB) instead of
// the adapter's actual maximum. Use this to verify on desktop that a
// scene fits through the constraints a browser will impose.
bool web_limits_ = false;
// Monotonic frame counter alias (storage in core_). Bumped at the
// top of driveStreamingLoads; used as the LRU key for chunk
// eviction.
uint64_t& streaming_frame_idx_;
// Sub-allocator for all chunk vertex + index bytes. Sized at startup
// by probeAndCreatePool() — the runtime tells us how big a single
// buffer it can actually deliver, eliminating the per-machine OOM
// ceiling that one-WGPUBuffer-per-chunk would otherwise hit. All
// chunk allocations land here; nothing else uses the pool. Replaces
// the old hand-picked streaming_vram_budget_bytes_ knob entirely.
// Scene-state aliases (storage in core_).
BufferPool& pool_;
StreamingThread& streaming_thread_;
// Per-frame streaming activity aliases (storage in core_). Written
// by driveStreamingLoads; consumed by the still-in-VW benchmark
// harness.
int& streaming_loads_this_frame_;
bool& streaming_more_pending_;
// Per-frame streaming counters (storage in core_). Consumed by the
// bench-warm timeout dump in render() (still VW-side).
int& streaming_candidates_this_frame_;
int& streaming_evictions_lru_this_frame_;
int& streaming_evictions_pri_this_frame_;
int& streaming_drained_this_frame_;
int& streaming_blocked_oom_this_frame_;
bool& streaming_debug_;
// Bench warm-phase counters. We wait until N consecutive frames with
// 0 loads (convergence) before starting the orbit sweep, capped by
// MAX_WARM_FRAMES so chronically thrashing scenes still produce
// numbers. Both reset implicitly per bench run via setBenchmarkFrames.
int& bench_warm_streak_;
int& bench_warm_frames_total_;
bool& bench_warm_done_;
private:
// LOD1 pixel threshold alias (storage in core_, #84-x).
float& lod1_pixel_threshold_;
// Per-model state aliases (storage in core_).
std::unordered_map<uint32_t, ModelGpuData>& models_gpu_;
uint32_t& next_model_id_;
uint32_t& next_object_id_;
// Sidecar paths queued before init completes.
std::deque<std::string> pending_sidecars_;
// pending_direct_loads_ + initial_view_applied_ moved to ViewportCore
// (#84-q). initial_view_applied_ stays accessible here as a reference
// alias so VW::setCamera can flip it without poking through core_.
bool& initial_view_applied_;
// Camera-state aliases for motion detection (storage in core_, #84-x).
float (&prev_camera_target_)[3];
float& prev_camera_distance_;
float& prev_camera_yaw_deg_;
float& prev_camera_pitch_deg_;
bool& has_prev_camera_;
bool& last_cull_was_motion_;
// Pending one-shot screenshot path alias (storage in core_).
// Captured at the end of the next render(); driveStreamingLoads
// observes the non-empty value to switch into the sync chunk-load
// fallback so the first-frame capture isn't an empty buffer.
std::string& pending_screenshot_path_;
bool& pending_screenshot_quit_;
// Mouse navigation state. LMB drag orbits, MMB drag pans, wheel zooms.
// LMB-click-without-drag picks the object under the cursor. No
// Blender/Maya preset awareness yet — that arrives with AppSettings.
Qt::MouseButton nav_active_button_ = Qt::NoButton;
Eigen::Vector2i nav_last_pos_;
Eigen::Vector2i nav_press_pos_;
bool nav_dragged_ = false;
// Benchmark + frame-stats aliases (storage in core_, #84-x).
// setBenchmarkFrames(N) writes the bench_* fields through these
// aliases; the heartbeat / FrameStats path in core_.render reads
// and resets them.
int& bench_total_;
int& bench_count_;
int& bench_warmup_;
float& bench_yaw_start_;
float& bench_yaw_speed_;
std::vector<float>& bench_frame_ms_;
uint32_t& last_visible_objects_;
uint32_t& last_visible_triangles_;
uint32_t& last_sub_draws_;
double& bench_cull_ms_total_;
double& bench_stream_ms_total_;
double& bench_hiz_readback_ms_total_;
double& last_cull_ms_;
double& last_cull_compute_ms_;
double& last_cull_upload_ms_;
double& last_stream_ms_;
int& interactive_frame_count_;
// FederatedFalseOrigin matrix, in metres. Default identity. Stored
// but not yet applied to per-instance composed transforms — the
// recompose pass arrives with the federation-load OOM work.
// Federation false-origin alias (storage in core_).
Eigen::Matrix4d& federated_false_origin_meters_;
// Per-frame LOD selection counts (storage in core_, mutated from
// core_.cullModelCpuCompute). The [frame] heartbeat in VW's render()
// still reads + resets them.
uint32_t& lod1_dbg_count_;
uint32_t& lod0_dbg_eligible_count_;
uint32_t& lod0_dbg_no_lod1_count_;
uint64_t& lod1_dbg_tris_saved_;
};
#endif // WGPUVIEWPORTWINDOW_H