diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index f79e293e28..05203f45d4 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -26,6 +26,7 @@ #include #include #include +#include #include #include "InstancedGeometry.h" @@ -280,6 +281,18 @@ struct WgpuModelGpuData { std::vector meshes; std::vector instances; + // Local-frame volume (m³) of every mesh, indexed by mesh_id. Computed + // once at applyCachedModel via signed-tetrahedra-from-origin on the + // raw vertex+index data; reused by the Volume measurement tool to + // avoid re-reading the GPU buffers per click. Empty in streaming mode + // until the chunk holding the mesh has been delivered. + std::vector mesh_local_volumes; + + // object_id (globally rebased) → instance index in `instances`. + // Populated alongside the instance vector so the Volume tool can do + // O(1) instance lookup instead of linear-scanning every model. + std::unordered_map object_id_to_instance; + // Spatial chunk-cull replaced the per-model BVH walk — chunks are // already a one-level spatial partition of the instances, so a // single frustum test per chunk gives the same wholesale-reject diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp index 8db8557cca..29f761300a 100644 --- a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp +++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -1930,28 +1931,44 @@ void WgpuOverlayRenderer::encodeLabels(WGPUCommandEncoder enc, WGPUTextureView surface_view, const WgpuOverlayFrame& f) { if (!label_pipeline_ || !surface_view) return; - if (labels_.empty() && hud_text_.isEmpty()) return; + if (labels_.empty() && hud_text_.isEmpty()) { + // Selection cleared / tool exited — drop the cache so we don't + // pin GPU memory for unreferenced strings until destroy(). + releaseLabelTextures(); + return; + } if (f.viewport_w_px <= 0 || f.viewport_h_px <= 0) return; const int dpr = std::max(1, f.device_pixel_ratio); const float w_phys = float(f.viewport_w_px); const float h_phys = float(f.viewport_h_px); - // Resolve each label/HUD to (texture, NDC quad), expanding into the - // per-frame vertex buffer. - struct DrawRec { LabelTexture* tex; uint32_t first_vertex; }; + // Track which cache keys we touched this frame so we can evict + // stale entries afterwards — measure tools push a new string per + // object on every selection mutation, so without pruning the cache + // grows by O(selections_seen) over the session. + QSet used_keys; + + // Resolve each label/HUD to (bind_group, NDC quad), expanding into + // the per-frame vertex buffer. Store the WGPUBindGroup handle by + // value — getOrCreateLabelTexture may insert into label_tex_cache_ + // and trigger a QHash rehash, which invalidates any LabelTexture* + // captured from earlier iterations. The handle itself is a stable + // wgpu-side pointer that stays valid as long as the cache holds it, + // and we don't evict mid-encode. + struct DrawRec { WGPUBindGroup bind_group; uint32_t first_vertex; }; std::vector draws; std::vector verts; draws.reserve(labels_.size() + 1); verts.reserve((labels_.size() + 1) * 6 * 4); - auto push_quad = [&](LabelTexture* tex, float nx0, float ny0, + auto push_quad = [&](WGPUBindGroup bg, float nx0, float ny0, float nx1, float ny1) { // Two triangles, top-left at (nx0, ny0) (NDC Y up). // UV layout: (0,0) at top-left of image → flip Y because NDC Y // increases upward but image V increases downward. const float u0 = 0.0f, u1 = 1.0f, v0 = 0.0f, v1 = 1.0f; - draws.push_back({tex, uint32_t(verts.size() / 4)}); + draws.push_back({bg, uint32_t(verts.size() / 4)}); const float quad[24] = { nx0, ny0, u0, v0, nx1, ny0, u1, v0, nx0, ny1, u0, v1, nx0, ny1, u0, v1, nx1, ny0, u1, v0, nx1, ny1, u1, v1, @@ -1975,8 +1992,12 @@ void WgpuOverlayRenderer::encodeLabels(WGPUCommandEncoder enc, const float sx_phys = (ndc_x * 0.5f + 0.5f) * w_phys; const float sy_phys = (1.0f - (ndc_y * 0.5f + 0.5f)) * h_phys; const QString key = QStringLiteral("L9:") + lbl.text; + used_keys.insert(key); LabelTexture* tex = getOrCreateLabelTexture(key, lbl.text, 9, dpr); if (!tex) continue; + // Snapshot the fields we need NOW — `tex` may be invalidated by + // the next getOrCreateLabelTexture call's hash rehash. + const WGPUBindGroup bg = tex->bind_group; const float wq = float(tex->width_px); const float hq = float(tex->height_px); const float lx_phys = sx_phys - wq * 0.5f; @@ -1985,14 +2006,16 @@ void WgpuOverlayRenderer::encodeLabels(WGPUCommandEncoder enc, const float nx1 = ((lx_phys + wq) / w_phys) * 2.0f - 1.0f; const float ny0 = 1.0f - 2.0f * ly_phys / h_phys; // top const float ny1 = 1.0f - 2.0f * (ly_phys + hq) / h_phys; // bottom - push_quad(tex, nx0, ny0, nx1, ny1); + push_quad(bg, nx0, ny0, nx1, ny1); } // HUD: top-left, point size 11 (matches GL OverlayRenderer). if (!hud_text_.isEmpty()) { const QString key = QStringLiteral("H11:") + hud_text_; + used_keys.insert(key); LabelTexture* tex = getOrCreateLabelTexture(key, hud_text_, 11, dpr); if (tex) { + const WGPUBindGroup bg = tex->bind_group; const float margin_phys = 12.0f * float(dpr); const float lx_phys = margin_phys; const float ly_phys = margin_phys; @@ -2002,7 +2025,7 @@ void WgpuOverlayRenderer::encodeLabels(WGPUCommandEncoder enc, const float nx1 = ((lx_phys + wq) / w_phys) * 2.0f - 1.0f; const float ny0 = 1.0f - 2.0f * ly_phys / h_phys; const float ny1 = 1.0f - 2.0f * (ly_phys + hq) / h_phys; - push_quad(tex, nx0, ny0, nx1, ny1); + push_quad(bg, nx0, ny0, nx1, ny1); } } @@ -2040,9 +2063,26 @@ void WgpuOverlayRenderer::encodeLabels(WGPUCommandEncoder enc, wgpuRenderPassEncoderSetVertexBuffer(pass, 0, label_vertex_buffer_, 0, WGPU_WHOLE_SIZE); for (const auto& d : draws) { - wgpuRenderPassEncoderSetBindGroup(pass, 0, d.tex->bind_group, 0, nullptr); + wgpuRenderPassEncoderSetBindGroup(pass, 0, d.bind_group, 0, nullptr); wgpuRenderPassEncoderDraw(pass, 6, 1, d.first_vertex, 0); } wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); + + // Evict cache entries that weren't referenced this frame. Measure + // tools push a fresh string per object on every selection mutation, + // so without this the cache grows by O(unique strings seen) for + // the session. + if (label_tex_cache_.size() > used_keys.size()) { + for (auto it = label_tex_cache_.begin(); it != label_tex_cache_.end(); ) { + if (used_keys.contains(it.key())) { + ++it; + continue; + } + if (it.value().bind_group) wgpuBindGroupRelease(it.value().bind_group); + if (it.value().view) wgpuTextureViewRelease(it.value().view); + if (it.value().texture) wgpuTextureRelease(it.value().texture); + it = label_tex_cache_.erase(it); + } + } } diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index fa3a95cd41..7d7949faec 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -91,6 +91,13 @@ static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256; static QVector3D orbitEye(const float target[3], float dist, float yaw_deg, float pitch_deg); +// Forward declaration — defined alongside the Volume tool. Called from +// both applyCachedModel (full load) and applyStreamedChunk (per-chunk +// fill in streaming mode) so the same quantised-bytes path runs in both. +static double computeMeshLocalVolumeQuantised( + const MeshInfo& mesh, + const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices); + // ----------------------------------------------------------------------------- // Small helpers // ----------------------------------------------------------------------------- @@ -930,6 +937,20 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id, m.meshes = std::move(metadata.meta.meshes); m.instances = std::move(metadata.meta.instances); + // Streaming defers per-mesh vertex data until the owning chunk is + // loaded, so mesh-local volumes can't be precomputed here. Volume + // tool returns 0 for unloaded meshes; once we add lazy per-chunk + // volume computation this assign() becomes the seed. + m.mesh_local_volumes.assign(m.meshes.size(), 0.0); + + // object_id → instance index lookup. Volume tool reads it on every + // selection mutation; per-pick latency stays O(K) instead of O(K*N). + m.object_id_to_instance.clear(); + m.object_id_to_instance.reserve(m.instances.size()); + for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) { + m.object_id_to_instance.emplace(m.instances[i].object_id, i); + } + // Compute per-chunk world AABBs + instance-id lists from the // instance_to_chunk mapping. Under spatial bucketing this captures // each bucket's actual instance extent; under mesh-keyed it's @@ -1283,6 +1304,29 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { m.meshes = std::move(data.meshes); m.instances = std::move(data.instances); + // Mesh-local volumes (m³). Computed once per mesh by signed-tetrahedra- + // from-origin over the LOD0 triangles; the Volume measurement tool + // later just multiplies by |det(placement_3x3)| per instance. Helper + // works on raw quantised bytes so the streaming path can reuse it. + m.mesh_local_volumes.assign(m.meshes.size(), 0.0); + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + const MeshInfo& mesh = m.meshes[mi]; + if (mesh.vertex_count == 0 || mesh.index_count < 3) continue; + const uint8_t* vbase = data.vertices.data() + mesh.vbo_byte_offset; + const uint32_t* ibase = data.indices.data() + + (mesh.ebo_byte_offset / sizeof(uint32_t)); + m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised( + mesh, vbase, ibase, mesh.index_count); + } + + // object_id → instance index lookup. Volume tool reads it on every + // selection mutation; per-pick latency stays O(K) instead of O(K*N). + m.object_id_to_instance.clear(); + m.object_id_to_instance.reserve(m.instances.size()); + for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) { + m.object_id_to_instance.emplace(m.instances[i].object_id, i); + } + // Per-chunk world AABB + instance-id list. Same logic as the // streaming path. Lets cull frustum-test each chunk's AABB once // and skip every instance inside in one shot when the chunk is @@ -2860,6 +2904,181 @@ void WgpuViewportWindow::setHudText(const QString& text) { if (isExposed()) requestUpdate(); } +// |det(upper-left 3×3)| of a column-major 4×4 placement. Picks up +// mapped-item scale / mirror so a uniformly-scaled clone of a 1 m³ mesh +// reports its actual volume. +static double det3OfPlacement(const double M[16]) { + const double m00 = M[0], m10 = M[1], m20 = M[2]; + const double m01 = M[4], m11 = M[5], m21 = M[6]; + const double m02 = M[8], m12 = M[9], m22 = M[10]; + return m00 * (m11 * m22 - m12 * m21) + - m01 * (m10 * m22 - m12 * m20) + + m02 * (m10 * m21 - m11 * m20); +} + +// Local-frame volume of a mesh from its raw quantised vertex+index bytes. +// `vbase` points at the first vertex (12 B/vertex, 3×uint16 pos quantised +// against mesh.local_aabb), `ibase` at the first u32 index in mesh-local +// numbering, `n_indices` is the LOD0 index count. Signed-tetrahedra- +// from-origin → |sum|/6 so winding doesn't matter. Same algorithm as +// Bonsai's meshLocalVolume; takes the dequant step from +// INSTANCED_VERTEX_STRIDE_BYTES layout. +static double computeMeshLocalVolumeQuantised( + const MeshInfo& mesh, + const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices) { + if (n_indices < 3 || vbase == nullptr || ibase == nullptr) return 0.0; + const float ax = mesh.local_aabb_min[0]; + const float ay = mesh.local_aabb_min[1]; + const float az = mesh.local_aabb_min[2]; + const float ex = mesh.local_aabb_max[0] - ax; + const float ey = mesh.local_aabb_max[1] - ay; + const float ez = mesh.local_aabb_max[2] - az; + const float inv_q = 1.0f / 65535.0f; + auto dequant = [&](uint32_t vi, double out[3]) { + const uint8_t* v = vbase + size_t(vi) * INSTANCED_VERTEX_STRIDE_BYTES; + uint16_t qx, qy, qz; + std::memcpy(&qx, v + 0, 2); + std::memcpy(&qy, v + 2, 2); + std::memcpy(&qz, v + 4, 2); + out[0] = double(ax + float(qx) * inv_q * ex); + out[1] = double(ay + float(qy) * inv_q * ey); + out[2] = double(az + float(qz) * inv_q * ez); + }; + double sum = 0.0; + for (uint32_t i = 0; i + 2 < n_indices; i += 3) { + double p0[3], p1[3], p2[3]; + dequant(ibase[i + 0], p0); + dequant(ibase[i + 1], p1); + dequant(ibase[i + 2], p2); + const double cx = p1[1] * p2[2] - p1[2] * p2[1]; + const double cy = p1[2] * p2[0] - p1[0] * p2[2]; + const double cz = p1[0] * p2[1] - p1[1] * p2[0]; + sum += p0[0] * cx + p0[1] * cy + p0[2] * cz; + } + return std::abs(sum) / 6.0; +} + +void WgpuViewportWindow::setToolMode(ToolMode m) { + if (tool_mode_ == m) return; + tool_mode_ = m; + switch (tool_mode_) { + case ToolMode::NoTool: + // Drop any HUD/labels the previous tool left behind. We don't + // own the GL backend's per-tool clear callbacks, so the tool's + // own state lives in the overlay renderer. + overlays_.setHudText(QString()); + overlays_.setOverlayLabels({}); + qInfo() << "[wgpu measure] tool off"; + break; + case ToolMode::Volume: + qInfo() << "[wgpu measure] volume tool — pick / marquee objects, Esc to exit"; + updateVolumeReadout(); + break; + } + if (isExposed()) requestUpdate(); +} + +double WgpuViewportWindow::volumeOfObjects( + const std::vector& object_ids) const { + if (object_ids.empty()) return 0.0; + double total = 0.0; + for (uint32_t oid : object_ids) { + for (const auto& [mid, m] : models_gpu_) { + auto it = m.object_id_to_instance.find(oid); + if (it == m.object_id_to_instance.end()) continue; + const InstanceCpu& inst = m.instances[it->second]; + if (inst.mesh_id >= m.mesh_local_volumes.size()) break; + const double v_local = m.mesh_local_volumes[inst.mesh_id]; + const double det = std::abs(det3OfPlacement(inst.placement_transformation)); + total += v_local * det; + break; // object_id is globally unique → at most one hit + } + } + return total; +} + +std::vector> +WgpuViewportWindow::volumesPerObject( + const std::vector& object_ids) const { + std::vector> out; + if (object_ids.empty()) return out; + out.reserve(object_ids.size()); + for (uint32_t oid : object_ids) { + for (const auto& [mid, m] : models_gpu_) { + auto it = m.object_id_to_instance.find(oid); + if (it == m.object_id_to_instance.end()) continue; + const InstanceCpu& inst = m.instances[it->second]; + if (inst.mesh_id >= m.mesh_local_volumes.size()) break; + const double v_local = m.mesh_local_volumes[inst.mesh_id]; + const double det = std::abs(det3OfPlacement(inst.placement_transformation)); + out.emplace_back(oid, v_local * det); + break; + } + } + return out; +} + +void WgpuViewportWindow::updateVolumeReadout() { + if (tool_mode_ != ToolMode::Volume) return; + + const auto& sel = selection_.ids(); + if (sel.empty()) { + overlays_.setHudText(QString()); + overlays_.setOverlayLabels({}); + return; + } + + const std::vector ids(sel.begin(), sel.end()); + const auto per_obj = volumesPerObject(ids); + + // Per-object label cap. Each label allocates one wgpu texture + + // bind group on first sight; rendering thousands of unique + // "X.XXXX m³" strings drives the label-texture cache off a cliff + // and the QPainter rasterise per label dominates the click cost. + // The HUD total stays correct above the cap — only the per-object + // overlay labels are suppressed. 200 fits a normal multi-object + // selection and keeps both memory and per-frame draw count bounded. + static constexpr size_t kMaxPerObjectLabels = 200; + const bool show_labels = per_obj.size() <= kMaxPerObjectLabels; + + double total = 0.0; + std::vector labels; + if (show_labels) labels.reserve(per_obj.size()); + for (const auto& [oid, v] : per_obj) { + total += v; + if (!show_labels) continue; + // O(1) instance lookup via object_id_to_instance, then read the + // world AABB from the cached InstanceCpu directly — same data + // computeObjectAabb's linear scan would have produced for the + // first matching instance. For label placement at the AABB + // centre this is identical-looking; only the rare multi- + // representation object_id sees a slightly smaller union. + for (const auto& [mid, m] : models_gpu_) { + auto it = m.object_id_to_instance.find(oid); + if (it == m.object_id_to_instance.end()) continue; + const InstanceCpu& inst = m.instances[it->second]; + WgpuOverlayRenderer::Label lbl; + lbl.world_pos[0] = (inst.world_aabb_min[0] + inst.world_aabb_max[0]) * 0.5f; + lbl.world_pos[1] = (inst.world_aabb_min[1] + inst.world_aabb_max[1]) * 0.5f; + lbl.world_pos[2] = (inst.world_aabb_min[2] + inst.world_aabb_max[2]) * 0.5f; + lbl.text = QString::number(v, 'f', 4) + QStringLiteral(" m³"); + labels.push_back(std::move(lbl)); + break; + } + } + + QString hud = QStringLiteral("Volume: %1 m³ (%2 object%3)") + .arg(total, 0, 'f', 4) + .arg(per_obj.size()) + .arg(per_obj.size() == 1 ? "" : "s"); + if (!show_labels) { + hud += QStringLiteral("\n(per-object labels hidden above %1)") + .arg(kMaxPerObjectLabels); + } + overlays_.setHudText(hud); + overlays_.setOverlayLabels(labels); +} + // Project a world point to LOGICAL pixel coords (Qt's mouse-event units). // Returns false if behind the camera. static bool projectWorldToLogicalScreen(const QMatrix4x4& vp, @@ -4925,6 +5144,39 @@ bool WgpuViewportWindow::applyStreamedChunk( c.is_resident = true; c.is_loading = false; c.loaded_frame_idx = streaming_frame_idx_; + + // Mesh-local volumes for the meshes in this chunk. applyCachedModelStreaming + // left them zero because the bytes weren't in memory yet; the first + // chunk to deliver each mesh fills it in. Spatial-bucket mode may + // re-enter for the same mesh from a different chunk — the != 0 guard + // skips the redundant work. Indices are mesh-local (numbered against + // the mesh's own vertex range), so vbase + ibase are per-mesh slices + // into the chunk's freshly-arrived bytes. + bool filled_volume = false; + if (!m.mesh_local_volumes.empty() && !idx.empty()) { + for (uint32_t mi : c.mesh_ids) { + if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue; + if (m.mesh_local_volumes[mi] != 0.0) continue; + const MeshInfo& mesh = m.meshes[mi]; + if (mesh.vertex_count == 0 || mesh.index_count < 3) continue; + const size_t v_off = size_t(m.mesh_chunk_local_base_vertex[mi]) + * INSTANCED_VERTEX_STRIDE_BYTES; + const size_t i_off = m.mesh_chunk_local_ebo_first_u32[mi]; + const size_t v_end = v_off + + size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; + if (v_end > vbytes.size()) continue; + if (i_off + mesh.index_count > idx.size()) continue; + m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised( + mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count); + filled_volume = true; + } + } + // If the user is staring at a Volume readout while chunks page in, + // refresh as soon as a chunk delivers a mesh we just filled — they'd + // otherwise see 0 m³ for the whole selection until they click again. + if (filled_volume && tool_mode_ == ToolMode::Volume) { + updateVolumeReadout(); + } return true; } @@ -6041,6 +6293,7 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) { } nav_active_button_ = Qt::NoButton; nav_drag_kind_ = NavDrag::Inactive; + updateVolumeReadout(); requestUpdate(); return; } @@ -6149,6 +6402,7 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) { tracked_object_id_ = 0; tracked_chunk_idx_ = SIZE_MAX; } + updateVolumeReadout(); requestUpdate(); } nav_active_button_ = Qt::NoButton; @@ -6389,6 +6643,19 @@ void WgpuViewportWindow::keyPressEvent(QKeyEvent* event) { } } + // Measurement tools. V toggles Volume; Esc exits whichever tool is + // active. Mirrors GL ViewportWindow + Bonsai's bind_shortcut(V). + if (key == Qt::Key_V && mods == Qt::NoModifier && !event->isAutoRepeat()) { + setToolMode(tool_mode_ == ToolMode::Volume ? ToolMode::NoTool + : ToolMode::Volume); + return; + } + if (tool_mode_ != ToolMode::NoTool && key == Qt::Key_Escape + && !event->isAutoRepeat()) { + setToolMode(ToolMode::NoTool); + return; + } + // GL-parity viewport hotkeys. if (key == Qt::Key_F && mods == Qt::NoModifier && !event->isAutoRepeat()) { focusOnSelectedObject(); diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 7af09ccf84..1ad08fbcef 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -295,6 +295,27 @@ private: void setOverlayLabels(const std::vector& labels); void setHudText(const QString& text); + // Measurement tools. Mirrors GL ViewportWindow::ToolMode. Volume is + // the first ported tool — selection-driven (LMB pick / marquee / + // Shift/Ctrl set ops drive the readout), no clicks-to-place. V + // toggles, Esc exits. + // 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 }; + ToolMode toolMode() const { return tool_mode_; } + void setToolMode(ToolMode m); + + // 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; + // 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 @@ -469,6 +490,15 @@ private: // encode each overlay; see WgpuOverlayRenderer.h. WgpuOverlayRenderer 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(); + // 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