mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 10:57:49 +00:00
wgpu: area measurement tool (A hotkey, BFS coplanar patch + cyan highlight)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "WgpuAreaMeasurement.h"
|
||||
|
||||
#include "WgpuOverlayRenderer.h"
|
||||
#include "WgpuViewportWindow.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QString>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <queue>
|
||||
#include <unordered_set>
|
||||
|
||||
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<double>::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<uint32_t> patch;
|
||||
if (alt) {
|
||||
patch.push_back(seed);
|
||||
} else {
|
||||
const float* sn = &adj->tri_normals[3 * seed];
|
||||
std::unordered_set<uint32_t> visited;
|
||||
visited.insert(seed);
|
||||
std::queue<uint32_t> 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<float> 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<uint64_t, WgpuViewportWindow::MeshTriangles> 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<uint32_t, std::vector<const SelectedTri*>> 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<WgpuOverlayRenderer::Label> 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<uint32_t> 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<uint32_t> in_comp{start};
|
||||
std::queue<uint32_t> frontier;
|
||||
frontier.push(start);
|
||||
std::vector<uint32_t> 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);
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef WGPUAREAMEASUREMENT_H
|
||||
#define WGPUAREAMEASUREMENT_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<float> tri_normals; // 3 floats per tri (unit, mesh-local)
|
||||
std::vector<float> tri_areas; // mesh-local area per tri
|
||||
// edge_key (min<<32 | max) → list of triangle indices touching it.
|
||||
std::unordered_map<uint64_t, std::vector<uint32_t>> 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<uint64_t, MeshAdj> mesh_cache_;
|
||||
std::unordered_map<uint64_t, SelectedTri> selected_;
|
||||
double total_area_m2_ = 0.0;
|
||||
};
|
||||
|
||||
#endif // WGPUAREAMEASUREMENT_H
|
||||
@@ -288,6 +288,24 @@ struct WgpuModelGpuData {
|
||||
// until the chunk holding the mesh has been delivered.
|
||||
std::vector<double> 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<float> positions; // 3 * vertex_count, mesh-local
|
||||
std::vector<uint32_t> indices; // 3 * triangle_count, LOD0
|
||||
};
|
||||
std::vector<MeshTriangles> 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.
|
||||
|
||||
@@ -381,6 +381,28 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
)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<f32>,
|
||||
color: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: HiUniforms;
|
||||
|
||||
@vertex
|
||||
fn vs_main(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
|
||||
return u.view_proj * vec4<f32>(pos, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main() -> @location(0) vec4<f32> {
|
||||
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<float>& 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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -97,6 +97,18 @@ public:
|
||||
const WgpuOverlayFrame& f,
|
||||
const std::vector<WgpuSectionPlane>& 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<float>& 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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
********************************************************************************/
|
||||
|
||||
#include "WgpuViewportWindow.h"
|
||||
#include "WgpuAreaMeasurement.h"
|
||||
#include "WgpuStreamingLoader.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
@@ -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<float>& 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<float> 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<WgpuAreaMeasurement>();
|
||||
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);
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
@@ -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<WgpuOverlayRenderer::Label>& 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<float>& world_xyz,
|
||||
float r, float g, float b, float a);
|
||||
|
||||
// CPU mesh shadow: positions (3 floats/vert, mesh-local) + indices
|
||||
// (LOD0). Populated at applyCachedModel / applyStreamedChunk —
|
||||
// returns false if the mesh isn't loaded yet (streaming) or the
|
||||
// (model_id, mesh_id) pair doesn't resolve. Matches the GL
|
||||
// ViewportWindow::MeshTriangles + readbackMeshTriangles shape so
|
||||
// the measure tools port verbatim.
|
||||
using MeshTriangles = 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<uint32_t>& object_ids) const;
|
||||
private:
|
||||
// Per-object variant. Used by the Volume tool to drive both the
|
||||
// total HUD and the per-object overlay labels at AABB centres.
|
||||
std::vector<std::pair<uint32_t, double>>
|
||||
@@ -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<class WgpuAreaMeasurement> 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
|
||||
|
||||
Reference in New Issue
Block a user