From 8761f9ac4652b12db59f9cfe9f8aa0a27581f99e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 31 May 2026 22:12:26 +1000 Subject: [PATCH] wgpu: area measurement tool (A hotkey, BFS coplanar patch + cyan highlight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports Bonsai's AreaMeasurement onto WgpuViewportWindow as a new WgpuAreaMeasurement class. Each LMB pick resolves to (instance, triangle), BFS-expands the coplanar patch (dot(normal, seed_normal) > 0.9999, ~0.81° tolerance), and toggles it in/out of the running set. Alt+LMB skips BFS for single-triangle accumulate. Connected-components sweep over the selected set produces one "X.XXXX m²" label per patch at its area-weighted centroid in world space; HUD shows the running total + triangle count. Dependencies layered in: - WgpuOverlayRenderer.setHighlightTriangles / encodeHighlightTriangles: translucent world-space triangle list (cyan @ 0.45 alpha), depth- tested but depth-write off so the corner gizmo + labels still sit on top. - WgpuViewportWindow.pickMeshLocalAt: reuses pickSurfaceAt for the world hit, then inverts the instance's composed transform to express it in mesh-local space — what the BFS needs. Uses the live map key (`mid`) rather than InstanceCpu.model_id, which is whatever the GL streamer wrote at sidecar-write time and goes stale across sessions. - WgpuViewportWindow.readbackMeshTriangles: CPU mesh shadow lookup. The shadow itself is populated during the same dequant pass that computes mesh-local volume — applyCachedModel for full loads and applyStreamedChunk for streaming, so the BFS has data the moment the user can pick it. WgpuModelGpuData gains a MeshTriangles vector indexed by mesh_id; doubles per-vertex CPU memory (12 B/vert) but skips wgpu mapAsync plumbing for now. Bounds-check at pick time gracefully no-ops when a stale sidecar field is out of range. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuAreaMeasurement.cpp | 405 +++++++++++++++++++++ src/ifcviewer-wgpu/WgpuAreaMeasurement.h | 95 +++++ src/ifcviewer-wgpu/WgpuModelGpuData.h | 18 + src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp | 198 ++++++++++ src/ifcviewer-wgpu/WgpuOverlayRenderer.h | 28 ++ src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 224 ++++++++++-- src/ifcviewer-wgpu/WgpuViewportWindow.h | 48 ++- 7 files changed, 977 insertions(+), 39 deletions(-) create mode 100644 src/ifcviewer-wgpu/WgpuAreaMeasurement.cpp create mode 100644 src/ifcviewer-wgpu/WgpuAreaMeasurement.h diff --git a/src/ifcviewer-wgpu/WgpuAreaMeasurement.cpp b/src/ifcviewer-wgpu/WgpuAreaMeasurement.cpp new file mode 100644 index 0000000000..8acac21b38 --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuAreaMeasurement.cpp @@ -0,0 +1,405 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +#include "WgpuAreaMeasurement.h" + +#include "WgpuOverlayRenderer.h" +#include "WgpuViewportWindow.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +// Undirected edge key between two mesh-local vertex indices. +uint64_t edgeKey(uint32_t a, uint32_t b) { + if (a > b) std::swap(a, b); + return (uint64_t(a) << 32) | uint64_t(b); +} + +// Triangle area = 0.5 * |(b - a) × (c - a)|. Also returns the unit +// normal (zeroed for degenerate tris). +double triAreaAndNormal(const float* a, const float* b, const float* c, + float n_out[3]) { + const double bax = double(b[0]) - a[0]; + const double bay = double(b[1]) - a[1]; + const double baz = double(b[2]) - a[2]; + const double cax = double(c[0]) - a[0]; + const double cay = double(c[1]) - a[1]; + const double caz = double(c[2]) - a[2]; + const double nx = bay * caz - baz * cay; + const double ny = baz * cax - bax * caz; + const double nz = bax * cay - bay * cax; + const double len = std::sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0.0) { + n_out[0] = float(nx / len); + n_out[1] = float(ny / len); + n_out[2] = float(nz / len); + } else { + n_out[0] = n_out[1] = n_out[2] = 0.0f; + } + return 0.5 * len; +} + +// Squared distance from point `p` to triangle (a, b, c) — clipped to +// the triangle's interior or boundary, whichever is closest. Standard +// Ericson "Real-Time Collision Detection" implementation; identical to +// the GL AreaMeasurement helper. +double pointTriangleDistSq(const float p[3], + const float a[3], const float b[3], const float c[3]) { + auto sub = [](const float u[3], const float v[3], double r[3]) { + r[0] = double(u[0]) - v[0]; + r[1] = double(u[1]) - v[1]; + r[2] = double(u[2]) - v[2]; + }; + auto dot = [](const double u[3], const double v[3]) { + return u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; + }; + double ab[3], ac[3], ap[3]; + sub(b, a, ab); + sub(c, a, ac); + sub(p, a, ap); + const double d1 = dot(ab, ap); + const double d2 = dot(ac, ap); + if (d1 <= 0.0 && d2 <= 0.0) { + return ap[0]*ap[0] + ap[1]*ap[1] + ap[2]*ap[2]; + } + double bp[3]; + sub(p, b, bp); + const double d3 = dot(ab, bp); + const double d4 = dot(ac, bp); + if (d3 >= 0.0 && d4 <= d3) { + return bp[0]*bp[0] + bp[1]*bp[1] + bp[2]*bp[2]; + } + const double vc = d1 * d4 - d3 * d2; + if (vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0) { + const double v = d1 / (d1 - d3); + const double qx = ap[0] - v * ab[0]; + const double qy = ap[1] - v * ab[1]; + const double qz = ap[2] - v * ab[2]; + return qx*qx + qy*qy + qz*qz; + } + double cp[3]; + sub(p, c, cp); + const double d5 = dot(ab, cp); + const double d6 = dot(ac, cp); + if (d6 >= 0.0 && d5 <= d6) { + return cp[0]*cp[0] + cp[1]*cp[1] + cp[2]*cp[2]; + } + const double vb = d5 * d2 - d1 * d6; + if (vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0) { + const double w = d2 / (d2 - d6); + const double qx = ap[0] - w * ac[0]; + const double qy = ap[1] - w * ac[1]; + const double qz = ap[2] - w * ac[2]; + return qx*qx + qy*qy + qz*qz; + } + const double va = d3 * d6 - d5 * d4; + if (va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0) { + const double w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + const double qx = double(b[0]) + w * (double(c[0]) - b[0]) - p[0]; + const double qy = double(b[1]) + w * (double(c[1]) - b[1]) - p[1]; + const double qz = double(b[2]) + w * (double(c[2]) - b[2]) - p[2]; + return qx*qx + qy*qy + qz*qz; + } + const double denom = 1.0 / (va + vb + vc); + const double v = vb * denom; + const double w = vc * denom; + const double qx = double(a[0]) + v * ab[0] + w * ac[0] - p[0]; + const double qy = double(a[1]) + v * ab[1] + w * ac[1] - p[1]; + const double qz = double(a[2]) + v * ab[2] + w * ac[2] - p[2]; + return qx*qx + qy*qy + qz*qz; +} + +constexpr double kCoplanarDot = 0.9999; // ~0.81° tolerance, matches GL + +} // namespace + +WgpuAreaMeasurement::WgpuAreaMeasurement() = default; + +void WgpuAreaMeasurement::clear(WgpuViewportWindow& vp) { + mesh_cache_.clear(); + selected_.clear(); + total_area_m2_ = 0.0; + vp.setHighlightTriangles({}, 0, 0, 0, 0); + vp.setOverlayLabels({}); +} + +WgpuAreaMeasurement::MeshAdj* +WgpuAreaMeasurement::meshAdj(WgpuViewportWindow& vp, + uint32_t model_id, uint32_t mesh_id) { + const uint64_t key = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + auto it = mesh_cache_.find(key); + if (it != mesh_cache_.end()) return &it->second; + + // Need the raw positions + indices for adjacency. We never store + // them in the per-mesh cache (positions can be hundreds of KB each + // and live in the viewport already), so just look them up freshly + // each time the user picks a brand-new mesh. + WgpuViewportWindow::MeshTriangles tris; + if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) return nullptr; + if (tris.indices.size() < 3) return nullptr; + + MeshAdj a; + const size_t n_tris = tris.indices.size() / 3; + a.tri_normals.resize(n_tris * 3); + a.tri_areas.resize(n_tris); + a.edges.reserve(n_tris * 3); + for (size_t t = 0; t < n_tris; ++t) { + const uint32_t ia = tris.indices[3 * t + 0]; + const uint32_t ib = tris.indices[3 * t + 1]; + const uint32_t ic = tris.indices[3 * t + 2]; + if (3 * ia + 2 >= tris.positions.size() + || 3 * ib + 2 >= tris.positions.size() + || 3 * ic + 2 >= tris.positions.size()) continue; + const float* pa = &tris.positions[3 * ia]; + const float* pb = &tris.positions[3 * ib]; + const float* pc = &tris.positions[3 * ic]; + float n[3]; + a.tri_areas[t] = triAreaAndNormal(pa, pb, pc, n); + a.tri_normals[3 * t + 0] = n[0]; + a.tri_normals[3 * t + 1] = n[1]; + a.tri_normals[3 * t + 2] = n[2]; + a.edges[edgeKey(ia, ib)].push_back(uint32_t(t)); + a.edges[edgeKey(ib, ic)].push_back(uint32_t(t)); + a.edges[edgeKey(ic, ia)].push_back(uint32_t(t)); + } + return &mesh_cache_.emplace(key, std::move(a)).first->second; +} + +void WgpuAreaMeasurement::onPick(WgpuViewportWindow& vp, + int x_phys, int y_phys, bool alt) { + WgpuViewportWindow::MeshLocalPick pick; + if (!vp.pickMeshLocalAt(x_phys, y_phys, pick)) return; + + WgpuViewportWindow::MeshTriangles tris; + if (!vp.readbackMeshTriangles(pick.model_id, pick.mesh_id, tris)) return; + const size_t n_tris = tris.indices.size() / 3; + if (n_tris == 0) return; + + MeshAdj* adj = meshAdj(vp, pick.model_id, pick.mesh_id); + if (!adj) return; + + // Seed: the triangle whose interior (or boundary) is closest to the + // mesh-local pick point. + uint32_t seed = 0; + double best = std::numeric_limits::infinity(); + for (size_t t = 0; t < n_tris; ++t) { + const uint32_t ia = tris.indices[3 * t + 0]; + const uint32_t ib = tris.indices[3 * t + 1]; + const uint32_t ic = tris.indices[3 * t + 2]; + const double d = pointTriangleDistSq(pick.mesh_local, + &tris.positions[3 * ia], + &tris.positions[3 * ib], + &tris.positions[3 * ic]); + if (d < best) { best = d; seed = uint32_t(t); } + } + + // Coplanar patch via BFS over shared edges. Alt skips the expand + // (single-triangle accumulate). + std::vector patch; + if (alt) { + patch.push_back(seed); + } else { + const float* sn = &adj->tri_normals[3 * seed]; + std::unordered_set visited; + visited.insert(seed); + std::queue frontier; + frontier.push(seed); + while (!frontier.empty()) { + const uint32_t t = frontier.front(); frontier.pop(); + patch.push_back(t); + for (int e = 0; e < 3; ++e) { + const uint32_t ia = tris.indices[3 * t + e]; + const uint32_t ib = tris.indices[3 * t + (e + 1) % 3]; + auto eit = adj->edges.find(edgeKey(ia, ib)); + if (eit == adj->edges.end()) continue; + for (uint32_t nt : eit->second) { + if (nt == t || visited.count(nt)) continue; + const float* nn = &adj->tri_normals[3 * nt]; + const double dot = double(sn[0]) * nn[0] + + double(sn[1]) * nn[1] + + double(sn[2]) * nn[2]; + if (dot < kCoplanarDot) continue; + visited.insert(nt); + frontier.push(nt); + } + } + } + } + + // Toggle: if the seed was already in the set, remove the patch; + // otherwise add it. + const uint64_t seed_key = triKey(pick.object_id, seed); + const bool removing = selected_.count(seed_key) > 0; + double delta = 0.0; + for (uint32_t t : patch) { + const uint64_t k = triKey(pick.object_id, t); + if (removing) { + auto it = selected_.find(k); + if (it != selected_.end()) { + if (t < adj->tri_areas.size()) delta -= adj->tri_areas[t]; + selected_.erase(it); + } + } else { + SelectedTri sel; + sel.model_id = pick.model_id; + sel.mesh_id = pick.mesh_id; + sel.tri = t; + std::memcpy(sel.composed_transform, pick.composed_transform, + sizeof(sel.composed_transform)); + if (selected_.emplace(k, sel).second) { + if (t < adj->tri_areas.size()) delta += adj->tri_areas[t]; + } + } + } + total_area_m2_ += delta; + + rebuildHighlightAndLabels(vp); + + qInfo("[wgpu area] %s%.6f m^2 (total: %.6f m^2, %zu tris)", + delta >= 0.0 ? "+" : "", delta, + total_area_m2_, selected_.size()); +} + +void WgpuAreaMeasurement::rebuildHighlightAndLabels(WgpuViewportWindow& vp) { + // 1) Highlight triangle list — each selected tri's three vertices + // transformed by its captured composed_transform. Push as a + // flat world-space tri list; the overlay tints them translucent + // cyan to match GL. + std::vector world_xyz; + world_xyz.reserve(selected_.size() * 9); + + // Cache the latest MeshTriangles per (model,mesh) for this rebuild + // to avoid repeated viewport lookups when many tris share a mesh. + std::unordered_map tris_cache; + + auto get_tris = [&](uint32_t model_id, uint32_t mesh_id) + -> WgpuViewportWindow::MeshTriangles* { + const uint64_t k = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + auto it = tris_cache.find(k); + if (it != tris_cache.end()) return &it->second; + WgpuViewportWindow::MeshTriangles t; + if (!vp.readbackMeshTriangles(model_id, mesh_id, t)) return nullptr; + return &tris_cache.emplace(k, std::move(t)).first->second; + }; + + for (const auto& [key, sel] : selected_) { + WgpuViewportWindow::MeshTriangles* t = get_tris(sel.model_id, sel.mesh_id); + if (!t) continue; + if (size_t(sel.tri) * 3 + 2 >= t->indices.size()) continue; + const float* M = sel.composed_transform; // column-major + for (int e = 0; e < 3; ++e) { + const uint32_t vi = t->indices[3 * sel.tri + e]; + if (3 * vi + 2 >= t->positions.size()) continue; + const float* p = &t->positions[3 * vi]; + // World = M * (p, 1). Column-major: M[col*4 + row]. + const float wx = M[0]*p[0] + M[4]*p[1] + M[8]*p[2] + M[12]; + const float wy = M[1]*p[0] + M[5]*p[1] + M[9]*p[2] + M[13]; + const float wz = M[2]*p[0] + M[6]*p[1] + M[10]*p[2] + M[14]; + world_xyz.push_back(wx); + world_xyz.push_back(wy); + world_xyz.push_back(wz); + } + } + // Bonsai's area-tool cyan tint: 0.20, 0.85, 1.00 @ 0.45 alpha. + vp.setHighlightTriangles(world_xyz, 0.20f, 0.85f, 1.00f, 0.45f); + + // 2) Per-patch labels via connected-components sweep restricted to + // selected tris, one label per component at its area-weighted + // centroid (mesh-local → world via the captured transform). + std::unordered_map> by_object; + for (const auto& [key, sel] : selected_) { + const uint32_t object_id = uint32_t(key >> 32); + by_object[object_id].push_back(&sel); + } + + std::vector labels; + for (const auto& [obj_id, sels] : by_object) { + if (sels.empty()) continue; + const SelectedTri& any = *sels[0]; + WgpuViewportWindow::MeshTriangles* t = get_tris(any.model_id, any.mesh_id); + if (!t) continue; + MeshAdj* adj = meshAdj(vp, any.model_id, any.mesh_id); + if (!adj) continue; + + std::unordered_set remaining; + remaining.reserve(sels.size()); + for (const SelectedTri* s : sels) remaining.insert(s->tri); + + while (!remaining.empty()) { + const uint32_t start = *remaining.begin(); + std::unordered_set in_comp{start}; + std::queue frontier; + frontier.push(start); + std::vector component; + while (!frontier.empty()) { + const uint32_t tri = frontier.front(); frontier.pop(); + component.push_back(tri); + if (size_t(tri) * 3 + 2 >= t->indices.size()) continue; + for (int e = 0; e < 3; ++e) { + const uint32_t ia = t->indices[3 * tri + e]; + const uint32_t ib = t->indices[3 * tri + (e + 1) % 3]; + auto eit = adj->edges.find(edgeKey(ia, ib)); + if (eit == adj->edges.end()) continue; + for (uint32_t nt : eit->second) { + if (in_comp.count(nt) || remaining.count(nt) == 0) continue; + in_comp.insert(nt); + frontier.push(nt); + } + } + } + for (uint32_t tri : component) remaining.erase(tri); + + double area = 0.0, cx = 0.0, cy = 0.0, cz = 0.0; + for (uint32_t tri : component) { + if (size_t(tri) >= adj->tri_areas.size()) continue; + const double a = adj->tri_areas[tri]; + area += a; + const uint32_t ia = t->indices[3 * tri + 0]; + const uint32_t ib = t->indices[3 * tri + 1]; + const uint32_t ic = t->indices[3 * tri + 2]; + const float* va = &t->positions[3 * ia]; + const float* vb = &t->positions[3 * ib]; + const float* vc = &t->positions[3 * ic]; + cx += a * (double(va[0]) + vb[0] + vc[0]) / 3.0; + cy += a * (double(va[1]) + vb[1] + vc[1]) / 3.0; + cz += a * (double(va[2]) + vb[2] + vc[2]) / 3.0; + } + if (area <= 0.0) continue; + cx /= area; cy /= area; cz /= area; + + const float* M = any.composed_transform; + WgpuOverlayRenderer::Label lbl; + lbl.world_pos[0] = float(M[0]*cx + M[4]*cy + M[8]*cz + M[12]); + lbl.world_pos[1] = float(M[1]*cx + M[5]*cy + M[9]*cz + M[13]); + lbl.world_pos[2] = float(M[2]*cx + M[6]*cy + M[10]*cz + M[14]); + lbl.text = QString::number(area, 'f', 4) + QStringLiteral(" m²"); + labels.push_back(std::move(lbl)); + } + } + vp.setOverlayLabels(labels); +} diff --git a/src/ifcviewer-wgpu/WgpuAreaMeasurement.h b/src/ifcviewer-wgpu/WgpuAreaMeasurement.h new file mode 100644 index 0000000000..fd2e51d221 --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuAreaMeasurement.h @@ -0,0 +1,95 @@ +/******************************************************************************** + * * + * 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 WGPUAREAMEASUREMENT_H +#define WGPUAREAMEASUREMENT_H + +#include +#include +#include + +class WgpuViewportWindow; + +// Click-to-accumulate area measurement for the wgpu viewport. Mirrors +// src/bonsaiviewer/Measurement.h's AreaMeasurement: each pick resolves +// to (instance, triangle) via WgpuViewportWindow::pickMeshLocalAt, then +// either adds or removes the connected coplanar patch (BFS over shared +// edges, dot(normal, seed_normal) > 0.9999) depending on whether the +// seed triangle was already in the running set. Alt-click skips the BFS. +// Picks on different instances (even of the same mesh) are kept as +// separate patches. +// +// On every mutation the world-space triangles of the running set are +// pushed to WgpuViewportWindow::setHighlightTriangles for the +// translucent cyan patch shading, and per-component "X.XXXX m²" labels +// are pushed to setOverlayLabels at each connected component's +// area-weighted centroid. +class WgpuAreaMeasurement { +public: + WgpuAreaMeasurement(); + + // Pixel coords are physical (post-DPR), to match + // WgpuViewportWindow::pickMeshLocalAt's convention. + void onPick(WgpuViewportWindow& vp, int x_phys, int y_phys, bool alt); + void clear(WgpuViewportWindow& vp); + + double totalArea() const { return total_area_m2_; } + size_t triangleCount() const { return selected_.size(); } + +private: + // Cached per-mesh derived data: triangle normals + areas + edge + // adjacency. Computed once per (model, mesh) on first pick; the + // raw positions + indices live in + // WgpuViewportWindow::readbackMeshTriangles' CPU shadow. + struct MeshAdj { + std::vector tri_normals; // 3 floats per tri (unit, mesh-local) + std::vector tri_areas; // mesh-local area per tri + // edge_key (min<<32 | max) → list of triangle indices touching it. + std::unordered_map> edges; + }; + // Keyed by (model_id << 32) | mesh_id. + MeshAdj* meshAdj(WgpuViewportWindow& vp, + uint32_t model_id, uint32_t mesh_id); + + // Per-selected-triangle record. The composed transform is captured + // at pick time so highlight rebuilds don't have to re-query the + // viewport for it (and so the overlay keeps working if the picked + // instance later goes hidden). + struct SelectedTri { + uint32_t model_id; + uint32_t mesh_id; + uint32_t tri; + float composed_transform[16]; + }; + + // Selection key: object_id (high 32) | tri index (low 32). Packing + // by object_id rather than mesh_id keeps two distinct instances of + // the same mesh contributing independently — matches the GL impl. + static uint64_t triKey(uint32_t object_id, uint32_t tri) { + return (uint64_t(object_id) << 32) | uint64_t(tri); + } + + void rebuildHighlightAndLabels(WgpuViewportWindow& vp); + + std::unordered_map mesh_cache_; + std::unordered_map selected_; + double total_area_m2_ = 0.0; +}; + +#endif // WGPUAREAMEASUREMENT_H diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 05203f45d4..eed7d4dded 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -288,6 +288,24 @@ struct WgpuModelGpuData { // until the chunk holding the mesh has been delivered. std::vector mesh_local_volumes; + // CPU shadow of each mesh's mesh-local positions + LOD0 indices. + // Populated at applyCachedModel (or per-chunk in streaming) from + // the same raw vertex bytes the volume calc dequantises. The Area + // measurement tool reads this directly — no GPU readback, matching + // the Volume tool's policy. + // + // Doubles per-vertex memory (12 B/vert GPU + 12 B/vert CPU). The + // alternative is a wgpu mapAsync readback per first-touched mesh, + // which adds async plumbing and a per-click stall; pay the memory + // upfront instead. Trim by sizing each entry down at population + // (reserve exact). For huge federations this can be a real + // working-set cost — revisit if it shows up in profiles. + struct MeshTriangles { + std::vector positions; // 3 * vertex_count, mesh-local + std::vector indices; // 3 * triangle_count, LOD0 + }; + std::vector mesh_triangles_cache; + // 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. diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp index 29f761300a..678ebe5ad4 100644 --- a/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp +++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.cpp @@ -381,6 +381,28 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } )WGSL"; +// Highlight-triangle shader: world-space triangle list, translucent +// uniform fill. view_proj projects to clip; fragment outputs the +// per-set RGBA tint. Depth-test honours occlusion against geometry; +// depth-write off so subsequent overlays can still draw on top. +static const char* HIGHLIGHT_TRIANGLES_WGSL = R"WGSL( +struct HiUniforms { + view_proj: mat4x4, + color: vec4, +}; +@group(0) @binding(0) var u: HiUniforms; + +@vertex +fn vs_main(@location(0) pos: vec3) -> @builtin(position) vec4 { + return u.view_proj * vec4(pos, 1.0); +} + +@fragment +fn fs_main() -> @location(0) vec4 { + return u.color; +} +)WGSL"; + // Label shader: textured quads in screen space. Each visible label/HUD // item contributes 6 vertices (NDC position + uv); a pre-rasterised // QImage carrying both the dark-grey background fill and the white @@ -430,6 +452,7 @@ bool WgpuOverlayRenderer::init(WGPUInstance instance, WGPUDevice device, if (!buildMarquee()) return false; if (!buildOverlayLines()) return false; if (!buildOverlayPoints()) return false; + if (!buildHighlightTriangles()) return false; if (!buildLabels()) return false; return true; } @@ -489,6 +512,18 @@ void WgpuOverlayRenderer::destroy() { overlay_point_vertex_capacity_ = 0; overlay_point_vertex_count_ = 0; + // Highlight triangles + if (highlight_bind_group_) { wgpuBindGroupRelease(highlight_bind_group_); highlight_bind_group_ = nullptr; } + if (highlight_pipeline_) { wgpuRenderPipelineRelease(highlight_pipeline_); highlight_pipeline_ = nullptr; } + if (highlight_shader_module_) { wgpuShaderModuleRelease(highlight_shader_module_); highlight_shader_module_ = nullptr; } + if (highlight_pipeline_layout_) { wgpuPipelineLayoutRelease(highlight_pipeline_layout_); highlight_pipeline_layout_ = nullptr; } + if (highlight_bgl_) { wgpuBindGroupLayoutRelease(highlight_bgl_); highlight_bgl_ = nullptr; } + if (highlight_uniform_buffer_) { wgpuBufferRelease(highlight_uniform_buffer_); highlight_uniform_buffer_ = nullptr; } + if (highlight_vertex_buffer_) { wgpuBufferRelease(highlight_vertex_buffer_); highlight_vertex_buffer_ = nullptr; } + highlight_vertex_capacity_ = 0; + highlight_vertex_count_ = 0; + highlight_color_[0] = highlight_color_[1] = highlight_color_[2] = highlight_color_[3] = 0.0f; + // Labels + HUD releaseLabelTextures(); if (label_sampler_) { wgpuSamplerRelease(label_sampler_); label_sampler_ = nullptr; } @@ -1695,6 +1730,169 @@ void WgpuOverlayRenderer::encodeOverlayPoints(WGPURenderPassEncoder pass, wgpuRenderPassEncoderDraw(pass, overlay_point_vertex_count_, 1, 0, 0); } +// ----------------------------------------------------------------------------- +// Highlight triangles (translucent world-space triangle list) +// ----------------------------------------------------------------------------- + +bool WgpuOverlayRenderer::buildHighlightTriangles() { + { + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; + bdesc.size = 256; // grows in setHighlightTriangles + bdesc.label = svFromCStr("ifcviewer-wgpu.highlight_vbo"); + highlight_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + highlight_vertex_capacity_ = 256; + } + { + // WGSL HiUniforms: mat4(64) + vec4(16) = 80 B; struct rounds up + // to 16-multiple = 80 B already. Allocate 256 for slack. + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + bdesc.size = 256; + bdesc.label = svFromCStr("ifcviewer-wgpu.highlight_uniforms"); + highlight_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + } + { + WGPUBindGroupLayoutEntry entry = {}; + entry.binding = 0; + entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; + entry.buffer.type = WGPUBufferBindingType_Uniform; + entry.buffer.minBindingSize = 80; + WGPUBindGroupLayoutDescriptor bgl_desc = {}; + bgl_desc.entryCount = 1; + bgl_desc.entries = &entry; + bgl_desc.label = svFromCStr("ifcviewer-wgpu.highlight_bgl"); + highlight_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc); + } + { + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 1; + pl_desc.bindGroupLayouts = &highlight_bgl_; + pl_desc.label = svFromCStr("ifcviewer-wgpu.highlight_pipeline_layout"); + highlight_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + } + { + WGPUBindGroupEntry entry = {}; + entry.binding = 0; + entry.buffer = highlight_uniform_buffer_; + entry.offset = 0; + entry.size = 80; + WGPUBindGroupDescriptor bg_desc = {}; + bg_desc.layout = highlight_bgl_; + bg_desc.entryCount = 1; + bg_desc.entries = &entry; + bg_desc.label = svFromCStr("ifcviewer-wgpu.highlight_bind_group"); + highlight_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc); + } + { + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(HIGHLIGHT_TRIANGLES_WGSL); + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.highlight_wgsl"); + highlight_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + } + + WGPUVertexAttribute attribs[1] = {}; + attribs[0].format = WGPUVertexFormat_Float32x3; + attribs[0].offset = 0; + attribs[0].shaderLocation = 0; + WGPUVertexBufferLayout vbl = {}; + vbl.arrayStride = 12; + vbl.stepMode = WGPUVertexStepMode_Vertex; + vbl.attributeCount = 1; + vbl.attributes = attribs; + + WGPUBlendState blend = {}; + blend.color.srcFactor = WGPUBlendFactor_SrcAlpha; + blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.color.operation = WGPUBlendOperation_Add; + blend.alpha.srcFactor = WGPUBlendFactor_One; + blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha; + blend.alpha.operation = WGPUBlendOperation_Add; + + WGPUColorTargetState ct = {}; + ct.format = surface_format_; + ct.blend = &blend; + ct.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = highlight_shader_module_; + frag.entryPoint = svFromCStr("fs_main"); + frag.targetCount = 1; + frag.targets = &ct; + + // Depth-tested but no depth-write — patches sit behind closer + // geometry but later overlays (axis gizmo, labels) still draw over. + WGPUDepthStencilState depth = {}; + depth.format = WGPUTextureFormat_Depth32Float; + depth.depthWriteEnabled = WGPUOptionalBool_False; + depth.depthCompare = WGPUCompareFunction_LessEqual; + depth.stencilFront.compare = WGPUCompareFunction_Always; + depth.stencilBack.compare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = highlight_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.highlight_pipeline"); + rp_desc.vertex.module = highlight_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 1; + rp_desc.vertex.buffers = &vbl; + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = uint32_t(sample_count_); + rp_desc.multisample.mask = 0xFFFFFFFFu; + highlight_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + return highlight_pipeline_ != nullptr; +} + +void WgpuOverlayRenderer::setHighlightTriangles( + const std::vector& world_xyz, + float r, float g, float b, float a) { + highlight_color_[0] = r; + highlight_color_[1] = g; + highlight_color_[2] = b; + highlight_color_[3] = a; + const size_t n_floats = world_xyz.size(); + if (n_floats < 9 || (n_floats % 9) != 0 || a <= 0.0f) { + highlight_vertex_count_ = 0; + return; + } + const uint64_t bytes = uint64_t(n_floats) * sizeof(float); + if (bytes > highlight_vertex_capacity_) { + const uint64_t new_cap = bytes + bytes / 2; + if (highlight_vertex_buffer_) wgpuBufferRelease(highlight_vertex_buffer_); + WGPUBufferDescriptor bdesc = {}; + bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst; + bdesc.size = new_cap; + bdesc.label = svFromCStr("ifcviewer-wgpu.highlight_vbo"); + highlight_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc); + highlight_vertex_capacity_ = new_cap; + } + wgpuQueueWriteBuffer(queue_, highlight_vertex_buffer_, 0, + world_xyz.data(), size_t(bytes)); + highlight_vertex_count_ = uint32_t(n_floats / 3); +} + +void WgpuOverlayRenderer::encodeHighlightTriangles(WGPURenderPassEncoder pass, + const WgpuOverlayFrame& f) { + if (!highlight_pipeline_ || highlight_vertex_count_ == 0) return; + // Pack mat4 + vec4 into the slot. mat4 is column-major 16 floats. + uint8_t slot[80] = {}; + std::memcpy(slot, f.view_proj.constData(), 16 * sizeof(float)); + std::memcpy(slot + 64, highlight_color_, 4 * sizeof(float)); + wgpuQueueWriteBuffer(queue_, highlight_uniform_buffer_, 0, slot, sizeof(slot)); + + wgpuRenderPassEncoderSetPipeline(pass, highlight_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, highlight_bind_group_, 0, nullptr); + wgpuRenderPassEncoderSetVertexBuffer(pass, 0, highlight_vertex_buffer_, + 0, WGPU_WHOLE_SIZE); + wgpuRenderPassEncoderDraw(pass, highlight_vertex_count_, 1, 0, 0); +} + // ----------------------------------------------------------------------------- // Labels + HUD text (textured quads, content-cached) // ----------------------------------------------------------------------------- diff --git a/src/ifcviewer-wgpu/WgpuOverlayRenderer.h b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h index a7212f13e5..9ce116db42 100644 --- a/src/ifcviewer-wgpu/WgpuOverlayRenderer.h +++ b/src/ifcviewer-wgpu/WgpuOverlayRenderer.h @@ -97,6 +97,18 @@ public: const WgpuOverlayFrame& f, const std::vector& planes); + // Replace the highlight-triangle list. `world_xyz` is 3 floats per + // vertex, 3 vertices per triangle, in world space (post-composed- + // transform). Empty disables the overlay. Color is RGBA in [0, 1] — + // alpha < 1 gives the translucent patch shading the Area tool uses. + // Drawn inside the main MSAA pass so depth-test hides patches + // behind closer geometry; depth-write stays off so later overlays + // can still draw over the highlight. + void setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a); + void encodeHighlightTriangles(WGPURenderPassEncoder pass, + const WgpuOverlayFrame& f); + // One stylistic group of world-space line segments. Mirrors GL // OverlayRenderer::LineGroup so callers can target either backend // with one struct. @@ -193,6 +205,7 @@ private: bool buildMarquee(); bool buildOverlayLines(); bool buildOverlayPoints(); + bool buildHighlightTriangles(); bool buildLabels(); // Rasterise a single string at `font_pt` with dark-grey padded @@ -290,6 +303,21 @@ private: WGPUBindGroup overlay_point_bind_group_ = nullptr; uint32_t overlay_point_vertex_count_ = 0; + // ---- Highlight triangles (translucent world-space triangle list) ---- + // One pipeline + one uniform buffer (view_proj + RGBA tint). Vertex + // buffer grows on demand to fit the current set; empty set ⇒ encode + // is a no-op. + WGPUShaderModule highlight_shader_module_ = nullptr; + WGPUBindGroupLayout highlight_bgl_ = nullptr; + WGPUPipelineLayout highlight_pipeline_layout_ = nullptr; + WGPURenderPipeline highlight_pipeline_ = nullptr; + WGPUBuffer highlight_vertex_buffer_ = nullptr; + uint64_t highlight_vertex_capacity_ = 0; + WGPUBuffer highlight_uniform_buffer_ = nullptr; + WGPUBindGroup highlight_bind_group_ = nullptr; + uint32_t highlight_vertex_count_ = 0; + float highlight_color_[4] = {0, 0, 0, 0}; + // ---- Labels + HUD text (textured quads, cached by content) ---- // One QPainter-rasterised QImage per unique text string, uploaded as // an RGBA8 texture and re-used across frames. Per-frame work is diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 7d7949faec..570ce028c7 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "WgpuViewportWindow.h" +#include "WgpuAreaMeasurement.h" #include "WgpuStreamingLoader.h" #include @@ -94,9 +95,13 @@ static QVector3D orbitEye(const float target[3], float dist, // 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. +// Also writes the dequantised positions + index copy into `out_tris` +// so the Area tool's CPU shadow is built in the same pass — the loop +// already touches every vertex, so the marginal cost is one memcpy. static double computeMeshLocalVolumeQuantised( const MeshInfo& mesh, - const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices); + const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices, + WgpuModelGpuData::MeshTriangles* out_tris); // ----------------------------------------------------------------------------- // Small helpers @@ -938,10 +943,11 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id, 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. + // loaded, so mesh-local volumes + the Area-tool CPU shadow can't + // be precomputed here. Both fill in per-chunk inside + // applyStreamedChunk as the bytes arrive. m.mesh_local_volumes.assign(m.meshes.size(), 0.0); + m.mesh_triangles_cache.assign(m.meshes.size(), WgpuModelGpuData::MeshTriangles{}); // object_id → instance index lookup. Volume tool reads it on every // selection mutation; per-pick latency stays O(K) instead of O(K*N). @@ -1304,11 +1310,12 @@ 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. + // Mesh-local volumes (m³) + CPU mesh shadow for the Area tool. + // Both come from the same dequant pass per mesh — see + // computeMeshLocalVolumeQuantised. Helper works on raw quantised + // bytes so the streaming path can reuse it. m.mesh_local_volumes.assign(m.meshes.size(), 0.0); + m.mesh_triangles_cache.assign(m.meshes.size(), WgpuModelGpuData::MeshTriangles{}); 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; @@ -1316,7 +1323,7 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { 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); + mesh, vbase, ibase, mesh.index_count, &m.mesh_triangles_cache[mi]); } // object_id → instance index lookup. Volume tool reads it on every @@ -2904,6 +2911,89 @@ void WgpuViewportWindow::setHudText(const QString& text) { if (isExposed()) requestUpdate(); } +void WgpuViewportWindow::setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a) { + overlays_.setHighlightTriangles(world_xyz, r, g, b, a); + if (isExposed()) requestUpdate(); +} + +bool WgpuViewportWindow::readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, + MeshTriangles& out) const { + auto mit = models_gpu_.find(model_id); + if (mit == models_gpu_.end()) return false; + const WgpuModelGpuData& m = mit->second; + if (mesh_id >= m.mesh_triangles_cache.size()) return false; + const auto& src = m.mesh_triangles_cache[mesh_id]; + if (src.indices.empty() || src.positions.empty()) return false; + // Copy out — callers iterate freely without worrying about lifetime + // (streaming may evict a chunk and rebuild the shadow on next load). + out = src; + return true; +} + +bool WgpuViewportWindow::pickMeshLocalAt(int x, int y, MeshLocalPick& out) { + uint32_t obj_id = 0; + QVector3D world_pos, world_normal; + if (!pickSurfaceAt(x, y, obj_id, world_pos, world_normal)) return false; + + // O(1) instance lookup via object_id_to_instance — see also the + // Volume tool. composed_transform is the float `inst.transform`, + // already the per-frame world placement. + // + // Use the OUTER mid (the live map key) rather than inst.model_id — + // the InstanceCpu's model_id field is whatever the GL streamer + // wrote at sidecar-write time, which is stale across sessions and + // doesn't match the current load's globally-rebased model id. + for (const auto& [mid, m] : models_gpu_) { + auto it = m.object_id_to_instance.find(obj_id); + if (it == m.object_id_to_instance.end()) continue; + const InstanceCpu& inst = m.instances[it->second]; + + QMatrix4x4 T(inst.transform[0], inst.transform[4], inst.transform[8], inst.transform[12], + inst.transform[1], inst.transform[5], inst.transform[9], inst.transform[13], + inst.transform[2], inst.transform[6], inst.transform[10], inst.transform[14], + inst.transform[3], inst.transform[7], inst.transform[11], inst.transform[15]); + bool ok = false; + const QMatrix4x4 Ti = T.inverted(&ok); + if (!ok) return false; + const QVector4D mp = Ti * QVector4D(world_pos.x(), world_pos.y(), world_pos.z(), 1.0f); + + if (inst.mesh_id >= m.meshes.size()) return false; + + out.object_id = obj_id; + out.model_id = mid; + out.mesh_id = inst.mesh_id; + out.mesh_local[0] = mp.x(); + out.mesh_local[1] = mp.y(); + out.mesh_local[2] = mp.z(); + out.world_pos [0] = world_pos.x(); + out.world_pos [1] = world_pos.y(); + out.world_pos [2] = world_pos.z(); + out.world_normal[0] = world_normal.x(); + out.world_normal[1] = world_normal.y(); + out.world_normal[2] = world_normal.z(); + std::memcpy(out.composed_transform, inst.transform, + sizeof(out.composed_transform)); + return true; + } + return false; +} + +void WgpuViewportWindow::onAreaPick(int x_phys, int y_phys, bool alt) { + if (!area_tool_) return; + area_tool_->onPick(*this, x_phys, y_phys, alt); + updateAreaHud(); +} + +void WgpuViewportWindow::updateAreaHud() { + if (tool_mode_ != ToolMode::Area || !area_tool_) return; + overlays_.setHudText( + QStringLiteral("Area: %1 m² (%2 tris)") + .arg(area_tool_->totalArea(), 0, 'f', 4) + .arg(area_tool_->triangleCount())); + 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. @@ -2923,9 +3013,14 @@ static double det3OfPlacement(const double M[16]) { // 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. +// +// When `out_tris` is non-null, dequantised positions + the LOD0 index +// copy are written into it for the Area tool's CPU shadow. Avoids a +// second pass over every vertex. static double computeMeshLocalVolumeQuantised( const MeshInfo& mesh, - const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices) { + const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices, + WgpuModelGpuData::MeshTriangles* out_tris) { 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]; @@ -2934,26 +3029,43 @@ static double computeMeshLocalVolumeQuantised( 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; + + // Eager-dequant every vertex once into a stack-allocated scratch + // (small per-mesh — bounded by mesh.vertex_count, typically tens + // to thousands). The Area shadow needs the same floats, so writing + // to scratch + memcpying out is cheaper than dequantising twice. + std::vector positions; + positions.resize(size_t(mesh.vertex_count) * 3); + for (uint32_t v = 0; v < mesh.vertex_count; ++v) { + const uint8_t* p = vbase + size_t(v) * 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); - }; + std::memcpy(&qx, p + 0, 2); + std::memcpy(&qy, p + 2, 2); + std::memcpy(&qz, p + 4, 2); + positions[3 * v + 0] = ax + float(qx) * inv_q * ex; + positions[3 * v + 1] = ay + float(qy) * inv_q * ey; + positions[3 * v + 2] = 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; + const uint32_t i0 = ibase[i + 0]; + const uint32_t i1 = ibase[i + 1]; + const uint32_t i2 = ibase[i + 2]; + if (i0 >= mesh.vertex_count || i1 >= mesh.vertex_count + || i2 >= mesh.vertex_count) continue; + const float* p0 = &positions[3 * i0]; + const float* p1 = &positions[3 * i1]; + const float* p2 = &positions[3 * i2]; + const double cx = double(p1[1]) * p2[2] - double(p1[2]) * p2[1]; + const double cy = double(p1[2]) * p2[0] - double(p1[0]) * p2[2]; + const double cz = double(p1[0]) * p2[1] - double(p1[1]) * p2[0]; + sum += double(p0[0]) * cx + double(p0[1]) * cy + double(p0[2]) * cz; + } + + if (out_tris) { + out_tris->positions = std::move(positions); + out_tris->indices.assign(ibase, ibase + n_indices); } return std::abs(sum) / 6.0; } @@ -2961,19 +3073,27 @@ static double computeMeshLocalVolumeQuantised( void WgpuViewportWindow::setToolMode(ToolMode m) { if (tool_mode_ == m) return; tool_mode_ = m; + // Always tear down the previous tool's overlay artefacts before + // switching — easier than per-from-state branching, and the new + // tool re-primes whatever it owns on its first update. + if (area_tool_) area_tool_->clear(*this); + overlays_.setHudText(QString()); + overlays_.setOverlayLabels({}); + overlays_.setHighlightTriangles({}, 0, 0, 0, 0); + 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; + case ToolMode::Area: + if (!area_tool_) area_tool_ = std::make_unique(); + qInfo() << "[wgpu measure] area tool — LMB pick coplanar patch, Alt+LMB single tri, click again to remove, Esc exits"; + overlays_.setHudText(QStringLiteral("Area: 0.0000 m² (0 tris)")); + break; } if (isExposed()) requestUpdate(); } @@ -4185,6 +4305,12 @@ void WgpuViewportWindow::render() { // active clip plane cuts. Drawn inside the main MSAA pass. overlays_.encodeSectionGizmos(pass, overlay_frame, section_planes_); + // Highlight triangles (Area-tool patch shading). Drawn inside the + // main MSAA pass so depth-test correctly hides patches behind closer + // geometry; depth-write off so the corner gizmo / labels still render + // on top. + overlays_.encodeHighlightTriangles(pass, overlay_frame); + // Pivot indicator. Encoded inside the main MSAA pass after geometry so // depth interaction is correct — the indicator vanishes behind closer // surfaces. Visibility is driven by orbit/wheel UI handlers. @@ -5166,8 +5292,13 @@ bool WgpuViewportWindow::applyStreamedChunk( + size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; if (v_end > vbytes.size()) continue; if (i_off + mesh.index_count > idx.size()) continue; + WgpuModelGpuData::MeshTriangles* tris = + (mi < m.mesh_triangles_cache.size()) + ? &m.mesh_triangles_cache[mi] + : nullptr; m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised( - mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count); + mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count, + tris); filled_volume = true; } } @@ -6238,6 +6369,7 @@ void WgpuViewportWindow::mousePressEvent(QMouseEvent* event) { setPivotIndicatorVisible(true); } else if (event->button() == Qt::LeftButton && !section_tool_active_ + && tool_mode_ != ToolMode::Area && nav_drag_kind_ == NavDrag::Inactive) { // Arm marquee box-select. Plain / Shift / Ctrl LMB without a tool // intercepting the click; if the cursor never moves past the @@ -6332,6 +6464,22 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) { return; } + // Area tool: plain LMB resolves to (instance, triangle) and + // accumulates the coplanar patch; Alt+LMB skips BFS for a + // single-triangle accumulate. Re-clicking inside a previously + // accumulated patch removes it. Shift/Ctrl fall through to + // selection so the user can still manage selection state. + if (tool_mode_ == ToolMode::Area + && (event->modifiers() == Qt::NoModifier + || event->modifiers() == Qt::AltModifier)) { + const bool alt = (event->modifiers() & Qt::AltModifier) != 0; + onAreaPick(px, py, alt); + nav_active_button_ = Qt::NoButton; + nav_drag_kind_ = NavDrag::Inactive; + setPivotIndicatorVisible(false); + return; + } + const uint32_t id = pickObjectAt(px, py); const auto mods = event->modifiers(); if (id == 0) { @@ -6643,13 +6791,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). + // Measurement tools. V toggles Volume, A toggles Area; Esc exits + // whichever tool is active. Mirrors GL ViewportWindow + Bonsai's + // bind_shortcut(V) / bind_shortcut(A). if (key == Qt::Key_V && mods == Qt::NoModifier && !event->isAutoRepeat()) { setToolMode(tool_mode_ == ToolMode::Volume ? ToolMode::NoTool : ToolMode::Volume); return; } + if (key == Qt::Key_A && mods == Qt::NoModifier && !event->isAutoRepeat()) { + setToolMode(tool_mode_ == ToolMode::Area ? ToolMode::NoTool + : ToolMode::Area); + return; + } if (tool_mode_ != ToolMode::NoTool && key == Qt::Key_Escape && !event->isAutoRepeat()) { setToolMode(ToolMode::NoTool); diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 1ad08fbcef..900ce9cf1d 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -282,6 +283,7 @@ private: void clearSectionPlanes(); int sectionPlaneCount() const { return int(section_planes_.size()); } +public: // Overlay primitives. Mirror GL ViewportWindow so the Measurement + // dimension tools can target either backend through one API. // Empty inputs clears the corresponding set. @@ -294,14 +296,44 @@ private: float stroke_extra); void setOverlayLabels(const std::vector& labels); void setHudText(const QString& 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 = WgpuModelGpuData::MeshTriangles; + bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, + MeshTriangles& out) const; + + // 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); // 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. + // 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 toggle, 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 }; + enum class ToolMode { NoTool, Volume, Area }; ToolMode toolMode() const { return tool_mode_; } void setToolMode(ToolMode m); @@ -311,6 +343,7 @@ private: // 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> @@ -499,6 +532,13 @@ private: // after entering Volume mode this primes the overlay. void updateVolumeReadout(); + // Area tool state lives in WgpuAreaMeasurement (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(); + // 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