wgpu: volume measurement tool (V hotkey, selection-driven HUD + per-object labels)

Ports Bonsai's volumeOfObjects + volumesPerObject onto WgpuViewportWindow.
Mesh-local volumes are precomputed at applyCachedModel via signed-
tetrahedra-from-origin (dequantising positions from the 12 B/vertex GPU
layout); per-instance volume is just the cached local × |det(placement)|.
No GPU readback — measurement is O(K) in the selection size.

Streaming path computes volumes per-chunk as they arrive — fills any
mesh whose chunk just delivered, then re-runs updateVolumeReadout if
the user is staring at a Volume readout while the geometry pages in.

UX matches GL: V toggles, Esc exits, selection-driven (LMB pick / marquee
/ Shift/Ctrl set ops all funnel into updateVolumeReadout). HUD shows
total + count; one overlay label per object at its AABB centre, capped
at 200 to keep the label-texture cache bounded on large marquees.

Side fixes layered on the label overlay:
- O(1) AABB lookup via object_id_to_instance instead of linear-scanning
  every model's instance list per selected object.
- Label texture cache evicts entries not touched this frame, so churning
  through "X.XXXX m³" strings doesn't pin GPU memory.
- DrawRec stores the WGPUBindGroup handle by value rather than a
  LabelTexture* pointer into the QHash — getOrCreateLabelTexture can
  rehash the table and invalidate every captured pointer, which crashed
  large marquee selections with BindGroup-no-longer-alive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-31 20:57:44 +10:00
parent 3ac7a79b7a
commit 0774398d4e
4 changed files with 359 additions and 9 deletions
+13
View File
@@ -26,6 +26,7 @@
#include <cstdint>
#include <limits>
#include <string>
#include <unordered_map>
#include <vector>
#include "InstancedGeometry.h"
@@ -280,6 +281,18 @@ struct WgpuModelGpuData {
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> 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<double> 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<uint32_t, uint32_t> 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
+49 -9
View File
@@ -23,6 +23,7 @@
#include <QFontMetrics>
#include <QImage>
#include <QPainter>
#include <QSet>
#include <QStringList>
#include <QtMath>
@@ -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<QString> 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<DrawRec> draws;
std::vector<float> 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);
}
}
}
+267
View File
@@ -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<uint32_t>& 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<std::pair<uint32_t, double>>
WgpuViewportWindow::volumesPerObject(
const std::vector<uint32_t>& object_ids) const {
std::vector<std::pair<uint32_t, double>> 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<uint32_t> 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<WgpuOverlayRenderer::Label> 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("");
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();
+30
View File
@@ -295,6 +295,27 @@ private:
void setOverlayLabels(const std::vector<WgpuOverlayRenderer::Label>& 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<uint32_t>& 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<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
@@ -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