/******************************************************************************** * * * 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 . * * * ********************************************************************************/ #ifndef WGPUVIEWPORTWINDOW_H #define WGPUVIEWPORTWINDOW_H #include #include #include #include #include "Stopwatch.h" #include #include #include #include #include #include #include #include #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 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); // Resolve nav_preset_ env var to orbit/pan bindings. void applyNavPreset(const char* name); // chunkScreenAreaPx moved to ViewportCore (#84-h). public: // 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(); void configureSurface(int width_px, int height_px); void render(); void shutdown(); bool buildPipelines(); void buildModelBindGroup(ModelGpuData& m); void buildChunkBindGroup(ModelGpuData& m, size_t chunk_idx); // Streaming: read the chunk's vertex + index bytes from disk, // sub-allocate ranges in pool_, queueWriteBuffer them in, build the // chunk's bind group, flip is_resident=true. Returns true on success; // false if either the disk read or a pool alloc fails (caller is // expected to have already evicted enough). No-op (returns true) // when already resident. bool loadChunkBytesAndUploadGpu(ModelGpuData& m, size_t chunk_idx); // Pool-allocate + queueWriteBuffer + build bind group for a chunk // whose vbytes/idx have already been read (by either the worker // thread's drained result or the sync fallback). Returns false on // pool OOM. Toggles is_resident=true / is_loading=false on success. bool applyStreamedChunk(ModelGpuData& m, size_t chunk_idx, const std::vector& vbytes, const std::vector& idx); // Release a resident chunk's pool ranges + bind group; flip // is_resident=false. The chunk's CPU metadata (offsets, AABB, // visible-draw scratch) is retained so a subsequent // loadChunkBytesAndUploadGpu can bring it back without re-planning. void unloadChunk(ModelGpuData& m, size_t chunk_idx); // 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); void releaseEdgeResources(); bool buildPickPipeline(); // 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(); void ensurePickAttachments(int w, int h); void releasePickResources(); // 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 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& groups); void setOverlayPoints(const std::vector& 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& 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& 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 + InstanceCpu 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. Runs pickSurfaceAt to get the // world-space hit, then inverts the instance's composed transform // to express the hit in the mesh's own coordinates — what // readbackMeshTriangles returns. Returns false on miss. struct MeshLocalPick { uint32_t object_id = 0; uint32_t model_id = 0; uint32_t mesh_id = 0; float mesh_local [3] = {0, 0, 0}; float world_pos [3] = {0, 0, 0}; float world_normal[3] = {0, 0, 0}; float composed_transform[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; }; 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. struct RaycastHit { uint32_t object_id = 0; float distance = 0.0f; float world_pos[3] = {0, 0, 0}; float world_normal[3]= {0, 0, 0}; }; 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). struct FrameStats { float fps; float frame_time_ms; uint32_t total_objects; uint32_t visible_objects; uint32_t total_triangles; uint32_t visible_triangles; uint32_t unique_meshes; uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists }; 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& 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> volumesPerObject(const std::vector& 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 updateFrameUniforms(); 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. uint32_t cullModelCpuCompute(ModelGpuData& m, const float planes[6][4], const float eye[3], const float forward[3], const float right[3], const float up[3], float focal_px, float min_radius_px, float lod1_threshold_px, bool hiz_enabled) const; // Upload phase: wgpuQueueWriteBuffer for visible_draws / prefix_sums / // per-model uniform. Main-thread only (wgpu queue ops are not all // thread-safe). void cullModelCpuUpload(ModelGpuData& m); // 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& selection_flags_scratch_; SelectionState& selection_; VisibilityState& visibility_; // Depth attachment (4× MSAA), recreated on surface resize. WGPUTexture depth_texture_ = nullptr; WGPUTextureView depth_view_ = nullptr; int depth_w_ = 0; int depth_h_ = 0; // 4× MSAA color target. Surface format-matched, recreated on resize. // The render pass writes here, then resolves into the surface texture. WGPUTexture msaa_color_texture_ = nullptr; WGPUTextureView msaa_color_view_ = nullptr; int msaa_w_ = 0; int msaa_h_ = 0; // SAMPLE_COUNT moved to ViewportCore.h as kViewportSampleCount (#84-k). static constexpr uint32_t SAMPLE_COUNT = kViewportSampleCount; // HiZ occlusion culling. After each frame's main render pass we // downsample MSAA depth into a small single-sample Depth32Float texture // (hiz_resolve_texture_), copy it into a CPU-mappable staging buffer, // wait for the map via processEvents, and max-reduce a mip pyramid on // CPU. The cull pass in the *next* frame projects each instance's AABB // through hiz_vp_ (the VP used to fill the pyramid) and rejects when // the AABB's nearest projected z is behind the pyramid's coverage. // // GL's HiZ default is 256 wide; we match. Height tracks viewport aspect. static constexpr uint32_t HIZ_BASE_W = 256; // HiZ pipeline aliases (storage in core_). WGPUShaderModule& hiz_shader_module_; WGPUBindGroupLayout& hiz_bgl_; WGPUPipelineLayout& hiz_pipeline_layout_; WGPURenderPipeline& hiz_pipeline_; WGPUBuffer hiz_uniform_buffer_ = nullptr; WGPUBindGroup hiz_bind_group_ = nullptr; WGPUTexture hiz_resolve_texture_ = nullptr; WGPUTextureView hiz_resolve_view_ = nullptr; uint32_t hiz_resolve_w_ = 0; uint32_t hiz_resolve_h_ = 0; uint32_t hiz_padded_bpr_ = 0; // bytes per row in the staging buffer // Ping-pong async readback. Frame N submits a copy into slot // hiz_write_idx_ and calls mapAsync (non-blocking) on that slot. Frame // N+K (K ≥ 1) calls processEvents to drain callbacks; whichever slot // signalled completion is mapped, read into hiz_pyramid_, and unmapped // — making the pyramid 1+ frames stale, which is fine ("slightly-stale // depth" pattern the GL backend already documents). Two slots overlap // GPU write with CPU read; we never block on the readback. // 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_ = nullptr; bool edges_enabled_ = true; // 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 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 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 alias (storage in core_). WGPURenderPipeline& pick_pipeline_; WGPUTexture pick_color_texture_ = nullptr; WGPUTextureView pick_color_view_ = nullptr; // Second pick MRT: RGBA16F packed world-space normal. Sampled by // pickSurfaceAt so section cuts are perpendicular to the actual // picked triangle (rather than the AABB face that contains it). WGPUTexture pick_normal_texture_ = nullptr; WGPUTextureView pick_normal_view_ = nullptr; WGPUBuffer pick_normal_staging_buffer_ = nullptr; // 256 B (one RGBA16F texel padded) WGPUTexture pick_depth_texture_ = nullptr; WGPUTextureView pick_depth_view_ = nullptr; WGPUBuffer pick_staging_buffer_ = nullptr; // 256 B (single texel + bytes-per-row pad) int pick_w_ = 0; int pick_h_ = 0; // Section-cutting state. SectionPlane lives in OverlayRenderer.h // because the visualiser reads it; the viewport owns the authoritative // vector that the section tool mutates. std::vector section_planes_; bool section_tool_active_ = false; // X-ray mode. 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 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_ = 1.0f; // 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; // R32UInt staging for the rect-pick. Sized to the largest rect we've // seen so far (padded to 256 bpr), regrown if a bigger rect arrives. WGPUBuffer box_pick_staging_buffer_ = nullptr; uint64_t box_pick_staging_capacity_ = 0; // Drag-to-move state for the arrow gizmo. While `section_drag_active_` // is true, mouseMoveEvent calls updateSectionDrag instead of letting // the press fall through to the orbit/pan handlers. bool section_drag_active_ = false; int section_drag_index_ = -1; Eigen::Vector2i section_drag_start_mouse_; Eigen::Vector3f section_drag_start_origin_; // Mirrors GL ViewportWindow::hitTestSectionGizmo: returns the index of // the plane whose arrow gizmo is within grab_px of (x, y), or -1. int hitTestSectionGizmo(int x, int y) const; // Mirrors GL ViewportWindow::updateSectionDrag: projects the cursor // delta onto the plane's normal in screen space and slides the plane // along that direction. void updateSectionDrag(int x, int y); enum class HizSlotState : uint8_t { Idle, Mapping, Mapped }; static constexpr int HIZ_SLOTS = 2; WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr }; Eigen::Matrix4f hiz_slot_vp_ [HIZ_SLOTS]; HizSlotState hiz_slot_state_ [HIZ_SLOTS] = { HizSlotState::Idle, HizSlotState::Idle }; int hiz_write_idx_ = 0; // CPU mip pyramid (max-reduce). hiz_pyramid_[hiz_mip_offset_[L] + y*W + x]. std::vector hiz_pyramid_; std::vector hiz_mip_offset_; std::vector hiz_mip_w_; std::vector hiz_mip_h_; Eigen::Matrix4f hiz_vp_; bool hiz_valid_ = false; uint32_t hiz_reject_count_ = 0; // per-frame stat // WGPU_HIZ_TRACE=1 — diagnostic logging budget shared across the // parallel cull threads. Set to a non-zero count at start of cull // when tracing is on; each rejection in aabbOccludedByHiz atomically // decrements and logs while >0. Atomic because cull dispatches one // thread per model. mutable std::atomic hiz_trace_budget_{0}; 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 fps_keys_held_; Stopwatch fps_last_tick_; Eigen::Vector2i fps_press_center_; bool fps_ignore_next_mouse_move_ = false; // Fly base speed in m/s at no-modifier (Shift gives a 5× boost). Default // 5.0 matches GL fps_move_speed_. Scrollwheel in fly mode adjusts this // by ×1.25 / ×0.8 per notch, Blender-style — wheel does NOT zoom while // in fly mode (which would change camera_distance_ underneath us and // make speed jitter if speed were distance-scaled). float fps_move_speed_ = 5.0f; // 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 diagnostic: when a pick lands, stash the chunk // that holds the picked object. driveStreamingLoads watches for that // chunk's `is_resident` flipping true→false and dumps the priority // / pool stats at the moment of eviction so we can see why it lost. uint32_t tracked_object_id_ = 0; uint32_t tracked_chunk_mid_ = 0; size_t tracked_chunk_idx_ = SIZE_MAX; bool tracked_was_resident_ = false; // 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). Qt::MouseButton orbit_button_ = Qt::MiddleButton; Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier; Qt::MouseButton pan_button_ = Qt::MiddleButton; Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier; // 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. float min_pixel_radius_ = 3.0f; float motion_min_pixel_radius_ = 15.0f; // 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_ = true; 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. bool hiz_enabled_ = false; // 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, bumped at the top of driveStreamingLoads. // Used as the LRU key for chunk eviction. uint64_t streaming_frame_idx_ = 0; // 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, written by driveStreamingLoads, // consumed by the benchmark harness to delay the orbit sweep until // the initial cold-load settles. `loads` = chunks brought resident // this frame; `more_pending` = the loader wants to keep going. int streaming_loads_this_frame_ = 0; bool streaming_more_pending_ = false; // Per-frame streaming counters for WGPU_STREAM_DEBUG. Mutated inside // driveStreamingLoads, consumed by the per-frame debug print and the // bench-warm timeout dump. int streaming_candidates_this_frame_ = 0; int streaming_evictions_lru_this_frame_ = 0; int streaming_evictions_pri_this_frame_ = 0; int streaming_drained_this_frame_ = 0; int streaming_blocked_oom_this_frame_ = 0; bool streaming_debug_ = false; // WGPU_STREAM_DEBUG=1 // 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_ = 0; int bench_warm_frames_total_ = 0; bool bench_warm_done_ = false; // latch: once true, gate is open for this run private: // Switch to LOD1 when an instance's projected bounding-sphere radius // drops below this many pixels. 0 disables (always LOD0). Defaults // mirror AppSettings::lod1PixelThreshold() in the GL backend. float lod1_pixel_threshold_ = 30.0f; // Per-model state aliases (storage in core_). std::unordered_map& models_gpu_; uint32_t& next_model_id_; uint32_t& next_object_id_; // Sidecar paths queued before init completes. std::deque pending_sidecars_; // Direct-IFC staging buffers, keyed by streamer model_id. Populated // by uploadMeshChunk / uploadInstanceChunk; consumed and cleared by // finalizeModel. Shape matches SidecarData so the same chunk-planner // + apply flow services both sidecar and direct-IFC loads. Held by // unique_ptr so emplace / erase don't copy the (potentially huge) // vertex byte vector when the map rehashes. std::unordered_map> pending_direct_loads_; // Set after the first model load triggers a viewAll(); prevents // subsequent loads from snapping the camera away from where the // user pointed it. bool initial_view_applied_ = false; // Camera state at the previous render() for motion detection. Any // change means we apply the motion contribution threshold this frame // (drops more sub-pixel work mid-orbit; matches GL behaviour). float prev_camera_target_[3] = { 0, 0, 0 }; float prev_camera_distance_ = 0.0f; float prev_camera_yaw_deg_ = 0.0f; float prev_camera_pitch_deg_ = 0.0f; bool has_prev_camera_ = false; // True iff the last cull used the motion threshold. Render schedules a // single settle frame after motion stops so the previously dropped // sub-pixel instances reappear at the still threshold. Without this, // event-driven rendering would leave those instances missing forever // because no further frame is requested after the user releases the // mouse. Matches GL's last_cull_was_motion_ behaviour. bool last_cull_was_motion_ = false; // Pending one-shot screenshot, captured at the end of the next render(). std::string pending_screenshot_path_; bool pending_screenshot_quit_ = false; // 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 mode. setBenchmarkFrames(N) arms it; render() integrates the // yaw, captures per-frame ms after warmup, and prints + quits when the // target frame count is hit. int bench_total_ = 0; int bench_count_ = 0; int bench_warmup_ = 5; float bench_yaw_start_ = 0.0f; float bench_yaw_speed_ = 0.5f; // degrees per frame std::vector bench_frame_ms_; // Per-frame stat snapshot from the last cull. Sum of m.mesh_draws across // visible models. Exposed via the benchmark summary; will grow into a // proper FrameStats signal when stage 11's host integration arrives. uint32_t last_visible_objects_ = 0; uint32_t last_visible_triangles_ = 0; uint32_t last_sub_draws_ = 0; // Phase-time accumulators for benchmark mode. Each window measures a // distinct slice of render() so we can attribute frame cost. Totals // across the timed window are divided by bench_total_ on print. double bench_cull_ms_total_ = 0.0; double bench_stream_ms_total_ = 0.0; // driveStreamingLoads only double bench_hiz_readback_ms_total_ = 0.0; double bench_submit_ms_total_ = 0.0; // Last-frame per-phase times. Available in interactive mode (no // bench) so the periodic [frame] heartbeat log can show cull / // stream cost without needing the bench averaging machinery. double last_cull_ms_ = 0.0; double last_cull_compute_ms_ = 0.0; // parallel per-model cull double last_cull_upload_ms_ = 0.0; // sequential queueWriteBuffer pass double last_stream_ms_ = 0.0; // Tick count for the interactive (non-bench) [frame] heartbeat log. // Increments every render() and prints stats every N frames. int interactive_frame_count_ = 0; // Rolling 60-sample frame-time window for the smoothed fps emitted // via frameStatsUpdated. Index wraps; sum kept incrementally to // avoid a per-frame reduction. static constexpr int FRAME_TIME_WINDOW = 60; double frame_time_ms_window_[FRAME_TIME_WINDOW] = {}; int frame_time_ms_count_ = 0; int frame_time_ms_head_ = 0; double frame_time_ms_sum_ = 0.0; // 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, mutated from cullModelCpuCompute // and reset after the [frame] heartbeat prints them. Keeps an eye // on whether LOD1 is actually firing on real scenes — early-days // diagnostic while we trust the new code path. mutable uint32_t lod1_dbg_count_ = 0; mutable uint32_t lod0_dbg_eligible_count_ = 0; mutable uint32_t lod0_dbg_no_lod1_count_ = 0; mutable uint64_t lod1_dbg_tris_saved_ = 0; }; #endif // WGPUVIEWPORTWINDOW_H