wgpu: length measurement tool (L hotkey, adaptive 1/2/3/4+ point readout)

Ports Bonsai's LengthMeasurement onto WgpuViewportWindow as a new
WgpuLengthMeasurement class. Each LMB appends a world-space pick point
and the readout adapts to the running count:

  1 pt   → laser-measure: coplanar-patch BFS on the click's surface
           projects every patch vertex into the surface's own tangent
           basis to get face extents (X/Y/Z bars dashed in world space),
           plus ENH coords for the picked point, plus a vertical
           raycast for floor/ceiling distance on horizontal surfaces.
  2 pts  → distance A→B + axis-coloured ΔX/ΔY/ΔZ stair-step + dashed
           perpendicular projection when both picks landed on
           near-parallel surfaces.
  3 pts  → angle at middle vertex + triangle area + perimeter.
  4+ pts → polygon area via best-fit-plane shoelace (Jacobi-3x3
           eigendecomp inline; no Eigen dep) or fan-triangulated
           fallback for non-planar loops, plus closed-loop perimeter.

Backspace / Del removes the last point; Esc / L again exits.

Dependencies layered in:

- pickMeshLocalAt now refines the AABB-coarse pickSurfaceAt hit into a
  real triangle hit via Möller-Trumbore against the picked instance's
  CPU mesh shadow. Without this the BFS seeds with whatever triangle
  is closest to the bounding-box corner — producing patches and
  extents shaped like the AABB instead of the surface.
- meshLocalToGlobal: applies the instance's placement_transformation
  only (no per-model CoordinateOperation in wgpu yet). ENH equals
  IFC-world for non-federated loads, which is what the minimal viewer
  handles.
- raycast: brute-force world-AABB cull + Möller-Trumbore over the CPU
  mesh shadow. Used by the laser-measure ceiling/floor distance.
- ToolMode gains Length; click handler routes plain/Alt LMB through
  onLengthPick, Backspace through onLengthBackspace. Marquee-arm is
  gated off in Length mode.

Volume HUD now shows "Volume: 0.0000 m³  (0 objects)" the moment V is
pressed, matching how A primes "Area: 0.0000 m²" — gives the user a
visible cue the tool is active before any selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-01 08:44:12 +10:00
parent 8761f9ac46
commit 699f22b502
4 changed files with 1197 additions and 10 deletions
@@ -0,0 +1,738 @@
/********************************************************************************
* *
* 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 "WgpuLengthMeasurement.h"
#include "WgpuOverlayRenderer.h"
#include <QDebug>
#include <QStringList>
#include <QtGlobal>
#include <QtMath>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
#include <queue>
#include <unordered_map>
#include <unordered_set>
namespace {
double dist3(const std::array<float, 3>& a, const std::array<float, 3>& b) {
const double dx = double(b[0]) - a[0];
const double dy = double(b[1]) - a[1];
const double dz = double(b[2]) - a[2];
return std::sqrt(dx*dx + dy*dy + dz*dz);
}
double triArea3(const std::array<float, 3>& a,
const std::array<float, 3>& b,
const std::array<float, 3>& c) {
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;
return 0.5 * std::sqrt(nx*nx + ny*ny + nz*nz);
}
// Symmetric 3×3 eigendecomposition via Jacobi rotations. Tiny inline
// alternative to pulling Eigen into the wgpu module for the single
// polygon-planarity check. Converges in <10 sweeps for 3×3.
void jacobiEigen3(double m00, double m01, double m02,
double m11, double m12, double m22,
double eigvals[3], double eigvecs[3][3]) {
double a[3][3] = {{m00, m01, m02},
{m01, m11, m12},
{m02, m12, m22}};
double v[3][3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}};
for (int iter = 0; iter < 50; ++iter) {
// Pick largest off-diagonal magnitude.
int p = 0, q = 1;
double off = std::abs(a[0][1]);
if (std::abs(a[0][2]) > off) { p = 0; q = 2; off = std::abs(a[0][2]); }
if (std::abs(a[1][2]) > off) { p = 1; q = 2; off = std::abs(a[1][2]); }
if (off < 1e-12) break;
const double app = a[p][p], aqq = a[q][q], apq = a[p][q];
const double theta = (aqq - app) / (2.0 * apq);
double t = (theta >= 0.0) ? 1.0 / (theta + std::sqrt(1.0 + theta*theta))
: 1.0 / (theta - std::sqrt(1.0 + theta*theta));
const double c = 1.0 / std::sqrt(1.0 + t*t);
const double s = t * c;
a[p][p] = app - t * apq;
a[q][q] = aqq + t * apq;
a[p][q] = a[q][p] = 0.0;
for (int k = 0; k < 3; ++k) {
if (k == p || k == q) continue;
const double akp = a[k][p], akq = a[k][q];
a[k][p] = a[p][k] = c * akp - s * akq;
a[k][q] = a[q][k] = s * akp + c * akq;
}
for (int k = 0; k < 3; ++k) {
const double vkp = v[k][p], vkq = v[k][q];
v[k][p] = c * vkp - s * vkq;
v[k][q] = s * vkp + c * vkq;
}
}
eigvals[0] = a[0][0];
eigvals[1] = a[1][1];
eigvals[2] = a[2][2];
std::memcpy(eigvecs, v, sizeof(v));
}
struct PolygonAreaResult {
double area_m2;
const char* method;
};
PolygonAreaResult polygonArea(const std::vector<std::array<float, 3>>& pts) {
const size_t n = pts.size();
// Centroid + bounding box (for the planarity threshold).
double centroid[3] = {0, 0, 0};
double bbox_min[3] = { std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity(),
std::numeric_limits<double>::infinity() };
double bbox_max[3] = {-std::numeric_limits<double>::infinity(),
-std::numeric_limits<double>::infinity(),
-std::numeric_limits<double>::infinity() };
for (const auto& p : pts) {
centroid[0] += p[0]; centroid[1] += p[1]; centroid[2] += p[2];
bbox_min[0] = std::min(bbox_min[0], double(p[0]));
bbox_min[1] = std::min(bbox_min[1], double(p[1]));
bbox_min[2] = std::min(bbox_min[2], double(p[2]));
bbox_max[0] = std::max(bbox_max[0], double(p[0]));
bbox_max[1] = std::max(bbox_max[1], double(p[1]));
bbox_max[2] = std::max(bbox_max[2], double(p[2]));
}
centroid[0] /= double(n);
centroid[1] /= double(n);
centroid[2] /= double(n);
const double bbx = bbox_max[0] - bbox_min[0];
const double bby = bbox_max[1] - bbox_min[1];
const double bbz = bbox_max[2] - bbox_min[2];
const double bbox_diag = std::sqrt(bbx*bbx + bby*bby + bbz*bbz);
// 3×3 symmetric covariance. Smallest eigenvector → plane normal.
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
for (const auto& p : pts) {
const double dx = double(p[0]) - centroid[0];
const double dy = double(p[1]) - centroid[1];
const double dz = double(p[2]) - centroid[2];
c00 += dx*dx; c01 += dx*dy; c02 += dx*dz;
c11 += dy*dy; c12 += dy*dz; c22 += dz*dz;
}
double eigvals[3];
double eigvecs[3][3];
jacobiEigen3(c00, c01, c02, c11, c12, c22, eigvals, eigvecs);
int min_i = 0;
if (eigvals[1] < eigvals[min_i]) min_i = 1;
if (eigvals[2] < eigvals[min_i]) min_i = 2;
const double normal[3] = { eigvecs[0][min_i],
eigvecs[1][min_i],
eigvecs[2][min_i] };
double sq_sum = 0.0;
for (const auto& p : pts) {
const double d = (double(p[0]) - centroid[0]) * normal[0]
+ (double(p[1]) - centroid[1]) * normal[1]
+ (double(p[2]) - centroid[2]) * normal[2];
sq_sum += d * d;
}
const double rms = std::sqrt(sq_sum / double(n));
const bool planar = bbox_diag > 0.0 && (rms / bbox_diag) < 1e-3;
if (planar) {
// In-plane orthonormal basis. Cross with whichever world axis is
// least parallel to the normal so u doesn't collapse.
double u[3];
u[0] = normal[1] * 0.0 - normal[2] * 0.0; // normal × X
u[1] = normal[2] * 1.0 - normal[0] * 0.0;
u[2] = normal[0] * 0.0 - normal[1] * 1.0;
double ul2 = u[0]*u[0] + u[1]*u[1] + u[2]*u[2];
if (ul2 < 1e-6) {
u[0] = normal[1] * 0.0 - normal[2] * 1.0; // normal × Y
u[1] = normal[2] * 0.0 - normal[0] * 0.0;
u[2] = normal[0] * 1.0 - normal[1] * 0.0;
ul2 = u[0]*u[0] + u[1]*u[1] + u[2]*u[2];
}
const double ul = std::sqrt(ul2);
u[0] /= ul; u[1] /= ul; u[2] /= ul;
const double v[3] = {
normal[1]*u[2] - normal[2]*u[1],
normal[2]*u[0] - normal[0]*u[2],
normal[0]*u[1] - normal[1]*u[0]
};
// Project + shoelace.
std::vector<std::array<double, 2>> uv(n);
for (size_t i = 0; i < n; ++i) {
const double dx = double(pts[i][0]) - centroid[0];
const double dy = double(pts[i][1]) - centroid[1];
const double dz = double(pts[i][2]) - centroid[2];
uv[i][0] = dx*u[0] + dy*u[1] + dz*u[2];
uv[i][1] = dx*v[0] + dy*v[1] + dz*v[2];
}
double s = 0.0;
for (size_t i = 0; i < n; ++i) {
const auto& a = uv[i];
const auto& b = uv[(i + 1) % n];
s += a[0] * b[1] - b[0] * a[1];
}
return { 0.5 * std::abs(s), "planar" };
}
// Fan from p0 — heuristic for non-planar / star-shaped 3D loops.
double area = 0.0;
for (size_t i = 1; i + 1 < n; ++i) {
area += triArea3(pts[0], pts[i], pts[i + 1]);
}
return { area, "fan-triangulated (non-planar)" };
}
uint64_t edgeKey(uint32_t a, uint32_t b) {
if (a > b) std::swap(a, b);
return (uint64_t(a) << 32) | uint64_t(b);
}
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
// Visual style — reused across all length-tool overlay paths.
constexpr float LINE_WIDTH = 1.5f;
constexpr float LINE_HALO = 0.5f;
constexpr float DOT_SIZE = 6.0f;
constexpr float DOT_HALO = 1.0f;
constexpr float DASH_PERIOD = 9.0f; // px
constexpr float DASH_ON_RATIO = 0.55f; // 5 on, 4 off
WgpuOverlayRenderer::LineGroup makeGroup(std::vector<float> xyz,
float r, float g, float b,
bool dashed = false) {
WgpuOverlayRenderer::LineGroup gp;
gp.world_xyz = std::move(xyz);
gp.color[0] = r; gp.color[1] = g; gp.color[2] = b; gp.color[3] = 1.0f;
gp.stroke_color[0] = 0.0f; gp.stroke_color[1] = 0.0f;
gp.stroke_color[2] = 0.0f; gp.stroke_color[3] = 1.0f;
gp.line_width = LINE_WIDTH;
gp.stroke_extra = LINE_HALO;
gp.dash_period_px = dashed ? DASH_PERIOD : 0.0f;
gp.dash_on_ratio = DASH_ON_RATIO;
return gp;
}
void pushDot(std::vector<float>& xyz, const std::array<float, 3>& p) {
xyz.push_back(p[0]); xyz.push_back(p[1]); xyz.push_back(p[2]);
}
void pushSeg(std::vector<float>& xyz,
const std::array<float, 3>& a,
const std::array<float, 3>& b) {
xyz.insert(xyz.end(), a.begin(), a.end());
xyz.insert(xyz.end(), b.begin(), b.end());
}
WgpuOverlayRenderer::Label makeLabel(const std::array<float, 3>& a,
const std::array<float, 3>& b,
const QString& text) {
WgpuOverlayRenderer::Label lbl;
lbl.world_pos[0] = 0.5f * (a[0] + b[0]);
lbl.world_pos[1] = 0.5f * (a[1] + b[1]);
lbl.world_pos[2] = 0.5f * (a[2] + b[2]);
lbl.text = text;
return lbl;
}
void pushDots(WgpuViewportWindow& vp, const std::vector<float>& xyz) {
vp.setOverlayPoints(xyz,
/*inner*/ 1.0f, 1.0f, 1.0f, 1.0f,
/*size*/ DOT_SIZE,
/*stroke*/ 0.0f, 0.0f, 0.0f, 1.0f,
/*extra*/ DOT_HALO);
}
const char* dominantAxisLabel(const float v[3]) {
const float ax = std::abs(v[0]);
const float ay = std::abs(v[1]);
const float az = std::abs(v[2]);
if (az >= ax && az >= ay) return "Z";
if (ax >= ay) return "X";
return "Y";
}
} // namespace
WgpuLengthMeasurement::WgpuLengthMeasurement() = default;
void WgpuLengthMeasurement::clear(WgpuViewportWindow& vp) {
points_.clear();
normals_.clear();
vp.setOverlayPoints({}, 0,0,0,0, 0, 0,0,0,0, 0);
vp.setOverlayLines({});
vp.setOverlayLabels({});
vp.setHudText(QString());
}
void WgpuLengthMeasurement::onPick(WgpuViewportWindow& vp,
int x_phys, int y_phys, bool /*alt*/) {
WgpuViewportWindow::MeshLocalPick pick;
if (!vp.pickMeshLocalAt(x_phys, y_phys, pick)) return;
points_.push_back({pick.world_pos[0], pick.world_pos[1], pick.world_pos[2]});
normals_.push_back({pick.world_normal[0], pick.world_normal[1], pick.world_normal[2]});
if (points_.size() == 1) {
first_pick_ = pick; // record info the laser BFS needs
}
rebuildOverlay(vp);
}
void WgpuLengthMeasurement::removeLastPoint(WgpuViewportWindow& vp) {
if (points_.empty()) return;
points_.pop_back();
if (!normals_.empty()) normals_.pop_back();
rebuildOverlay(vp);
}
void WgpuLengthMeasurement::rebuildOverlay(WgpuViewportWindow& vp) {
if (points_.size() == 1 && normals_.size() == 1) {
rebuildLaserOverlay(vp);
return;
}
std::vector<float> pts_xyz;
pts_xyz.reserve(points_.size() * 3);
for (const auto& p : points_) pushDot(pts_xyz, p);
pushDots(vp, pts_xyz);
std::vector<WgpuOverlayRenderer::LineGroup> groups;
std::vector<WgpuOverlayRenderer::Label> labels;
const size_t n = points_.size();
if (n == 2) {
const auto& a = points_[0];
const auto& b = points_[1];
groups.push_back(makeGroup({a[0], a[1], a[2], b[0], b[1], b[2]},
1.0f, 1.0f, 1.0f));
labels.push_back(makeLabel(a, b,
QString::number(dist3(a, b), 'f', 3) + QStringLiteral(" m")));
const std::array<float, 3> kx = {b[0], a[1], a[2]};
const std::array<float, 3> ky = {b[0], b[1], a[2]};
const double dx = std::abs(double(b[0]) - a[0]);
const double dy = std::abs(double(b[1]) - a[1]);
const double dz = std::abs(double(b[2]) - a[2]);
if (dx > 1e-6) {
groups.push_back(makeGroup({a[0],a[1],a[2], kx[0],kx[1],kx[2]},
1.00f, 0.30f, 0.30f));
labels.push_back(makeLabel(a, kx,
QStringLiteral("ΔX: ") + QString::number(dx, 'f', 3) + QStringLiteral(" m")));
}
if (dy > 1e-6) {
groups.push_back(makeGroup({kx[0],kx[1],kx[2], ky[0],ky[1],ky[2]},
0.30f, 0.90f, 0.30f));
labels.push_back(makeLabel(kx, ky,
QStringLiteral("ΔY: ") + QString::number(dy, 'f', 3) + QStringLiteral(" m")));
}
if (dz > 1e-6) {
groups.push_back(makeGroup({ky[0],ky[1],ky[2], b[0],b[1],b[2]},
0.30f, 0.55f, 1.00f));
labels.push_back(makeLabel(ky, b,
QStringLiteral("ΔZ: ") + QString::number(dz, 'f', 3) + QStringLiteral(" m")));
}
if (normals_.size() == 2) {
const auto& na = normals_[0];
const auto& nb = normals_[1];
const double dot_nn = double(na[0])*nb[0]
+ double(na[1])*nb[1]
+ double(na[2])*nb[2];
if (std::abs(dot_nn) > 0.95) {
const float sign = dot_nn >= 0.0 ? 1.0f : -1.0f;
float n_avg[3] = {
0.5f * (na[0] + sign * nb[0]),
0.5f * (na[1] + sign * nb[1]),
0.5f * (na[2] + sign * nb[2]),
};
const float len = std::sqrt(n_avg[0]*n_avg[0]
+ n_avg[1]*n_avg[1]
+ n_avg[2]*n_avg[2]);
if (len > 1e-6f) {
n_avg[0] /= len; n_avg[1] /= len; n_avg[2] /= len;
}
const double abx = double(b[0]) - a[0];
const double aby = double(b[1]) - a[1];
const double abz = double(b[2]) - a[2];
const double perp = abx*n_avg[0] + aby*n_avg[1] + abz*n_avg[2];
const double abs_perp = std::abs(perp);
constexpr double kAxisCollapseTol = 1e-3;
const bool redundant =
std::abs(abs_perp - dx) < kAxisCollapseTol
|| std::abs(abs_perp - dy) < kAxisCollapseTol
|| std::abs(abs_perp - dz) < kAxisCollapseTol;
if (abs_perp > 1e-6 && !redundant) {
const std::array<float, 3> tip = {
float(a[0] + perp * n_avg[0]),
float(a[1] + perp * n_avg[1]),
float(a[2] + perp * n_avg[2]),
};
auto perp_grp = makeGroup(
{a[0],a[1],a[2], tip[0],tip[1],tip[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true);
groups.push_back(perp_grp);
labels.push_back(makeLabel(a, tip,
QStringLiteral("perp: ") + QString::number(abs_perp, 'f', 3) + QStringLiteral(" m")));
}
}
}
} else if (n >= 3) {
std::vector<float> seg_xyz;
seg_xyz.reserve(n * 6);
labels.reserve(n);
auto addSeg = [&](const std::array<float, 3>& a,
const std::array<float, 3>& b) {
pushSeg(seg_xyz, a, b);
labels.push_back(makeLabel(a, b,
QString::number(dist3(a, b), 'f', 3) + QStringLiteral(" m")));
};
for (size_t i = 0; i + 1 < n; ++i) addSeg(points_[i], points_[i + 1]);
if (n >= 4) addSeg(points_[n - 1], points_[0]);
groups.push_back(makeGroup(std::move(seg_xyz), 1.0f, 1.0f, 1.0f));
}
vp.setOverlayLines(groups);
vp.setOverlayLabels(labels);
vp.setHudText(formatReadout());
}
void WgpuLengthMeasurement::rebuildLaserOverlay(WgpuViewportWindow& vp) {
const auto& wp = first_pick_.world_pos;
const auto& n = first_pick_.world_normal;
// Tangent basis in world space.
constexpr float WORLD_UP[3] = {0.0f, 0.0f, 1.0f};
const float dot_un = WORLD_UP[0]*n[0] + WORLD_UP[1]*n[1] + WORLD_UP[2]*n[2];
float t1[3] = {
WORLD_UP[0] - dot_un * n[0],
WORLD_UP[1] - dot_un * n[1],
WORLD_UP[2] - dot_un * n[2],
};
float t1_len = std::sqrt(t1[0]*t1[0] + t1[1]*t1[1] + t1[2]*t1[2]);
if (t1_len < 0.1f) {
constexpr float WORLD_X[3] = {1.0f, 0.0f, 0.0f};
const float dot_xn = WORLD_X[0]*n[0] + WORLD_X[1]*n[1] + WORLD_X[2]*n[2];
t1[0] = WORLD_X[0] - dot_xn * n[0];
t1[1] = WORLD_X[1] - dot_xn * n[1];
t1[2] = WORLD_X[2] - dot_xn * n[2];
t1_len = std::sqrt(t1[0]*t1[0] + t1[1]*t1[1] + t1[2]*t1[2]);
}
if (t1_len > 1e-6f) {
t1[0] /= t1_len; t1[1] /= t1_len; t1[2] /= t1_len;
}
const float t2[3] = {
n[1]*t1[2] - n[2]*t1[1],
n[2]*t1[0] - n[0]*t1[2],
n[0]*t1[1] - n[1]*t1[0],
};
std::vector<WgpuOverlayRenderer::LineGroup> groups;
std::vector<WgpuOverlayRenderer::Label> labels;
QStringList hud_lines;
hud_lines << QStringLiteral("Laser measure (click another point for distance)");
double enh[3] = {0.0, 0.0, 0.0};
if (vp.meshLocalToGlobal(first_pick_.object_id, first_pick_.mesh_local, enh)) {
hud_lines << QStringLiteral("ENH: %1, %2, %3")
.arg(enh[0], 0, 'f', 3)
.arg(enh[1], 0, 'f', 3)
.arg(enh[2], 0, 'f', 3);
}
// Coplanar-patch BFS for face extent.
WgpuViewportWindow::MeshTriangles tris;
bool have_extent = false;
double min_t1 = 0.0, max_t1 = 0.0, min_t2 = 0.0, max_t2 = 0.0;
if (vp.readbackMeshTriangles(first_pick_.model_id, first_pick_.mesh_id, tris)) {
const size_t n_verts = tris.positions.size() / 3;
const size_t n_tris = tris.indices.size() / 3;
if (n_tris > 0) {
std::vector<float> wv(n_verts * 3);
const float* M = first_pick_.composed_transform;
for (size_t i = 0; i < n_verts; ++i) {
const float* p = &tris.positions[i * 3];
wv[i*3 + 0] = M[0]*p[0] + M[4]*p[1] + M[8]*p[2] + M[12];
wv[i*3 + 1] = M[1]*p[0] + M[5]*p[1] + M[9]*p[2] + M[13];
wv[i*3 + 2] = M[2]*p[0] + M[6]*p[1] + M[10]*p[2] + M[14];
}
std::vector<std::array<float, 3>> tri_n(n_tris);
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
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];
const float* a = &wv[3*ia];
const float* b = &wv[3*ib];
const float* c = &wv[3*ic];
const float bax = b[0]-a[0], bay = b[1]-a[1], baz = b[2]-a[2];
const float cax = c[0]-a[0], cay = c[1]-a[1], caz = c[2]-a[2];
float nx = bay*caz - baz*cay;
float ny = baz*cax - bax*caz;
float nz = bax*cay - bay*cax;
const float nl = std::sqrt(nx*nx + ny*ny + nz*nz);
if (nl > 0.0f) { nx /= nl; ny /= nl; nz /= nl; }
tri_n[t] = {nx, ny, nz};
edges[edgeKey(ia, ib)].push_back(uint32_t(t));
edges[edgeKey(ib, ic)].push_back(uint32_t(t));
edges[edgeKey(ic, ia)].push_back(uint32_t(t));
}
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(
wp, &wv[3*ia], &wv[3*ib], &wv[3*ic]);
if (d < best) { best = d; seed = uint32_t(t); }
}
const auto& sn = tri_n[seed];
std::unordered_set<uint32_t> in_patch;
in_patch.insert(seed);
std::queue<uint32_t> frontier;
frontier.push(seed);
while (!frontier.empty()) {
const uint32_t t = frontier.front(); frontier.pop();
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 it = edges.find(edgeKey(ia, ib));
if (it == edges.end()) continue;
for (uint32_t nt : it->second) {
if (nt == t || in_patch.count(nt)) continue;
const auto& nn = tri_n[nt];
const double dot = double(sn[0])*nn[0]
+ double(sn[1])*nn[1]
+ double(sn[2])*nn[2];
if (dot < kCoplanarDot) continue;
in_patch.insert(nt);
frontier.push(nt);
}
}
}
std::unordered_set<uint32_t> patch_verts;
for (uint32_t t : in_patch) {
patch_verts.insert(tris.indices[3*t + 0]);
patch_verts.insert(tris.indices[3*t + 1]);
patch_verts.insert(tris.indices[3*t + 2]);
}
for (uint32_t vi : patch_verts) {
const float* v = &wv[3 * vi];
const double dx = double(v[0]) - wp[0];
const double dy = double(v[1]) - wp[1];
const double dz = double(v[2]) - wp[2];
const double a1 = dx*t1[0] + dy*t1[1] + dz*t1[2];
const double a2 = dx*t2[0] + dy*t2[1] + dz*t2[2];
if (!have_extent) {
min_t1 = max_t1 = a1;
min_t2 = max_t2 = a2;
have_extent = true;
} else {
min_t1 = std::min(min_t1, a1); max_t1 = std::max(max_t1, a1);
min_t2 = std::min(min_t2, a2); max_t2 = std::max(max_t2, a2);
}
}
}
}
auto pushBar = [&](const float t[3], double mn, double mx) {
const std::array<float, 3> a = {
float(wp[0] + mn * t[0]),
float(wp[1] + mn * t[1]),
float(wp[2] + mn * t[2]),
};
const std::array<float, 3> b = {
float(wp[0] + mx * t[0]),
float(wp[1] + mx * t[1]),
float(wp[2] + mx * t[2]),
};
const double extent = mx - mn;
const QString axis = QString::fromLatin1(dominantAxisLabel(t));
groups.push_back(makeGroup({a[0],a[1],a[2], b[0],b[1],b[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true));
labels.push_back(makeLabel(a, b,
QStringLiteral("%1 extent: %2 m").arg(axis).arg(extent, 0, 'f', 3)));
hud_lines << QStringLiteral("%1 extent: %2 m").arg(axis).arg(extent, 0, 'f', 3);
};
if (have_extent && (max_t1 - min_t1) > 1e-6) pushBar(t1, min_t1, max_t1);
if (have_extent && (max_t2 - min_t2) > 1e-6) pushBar(t2, min_t2, max_t2);
// Hybrid vertical raycast for floors / ceilings.
if (std::abs(n[2]) > 0.85f) {
constexpr float NUDGE = 1e-3f;
const float ro[3] = {
wp[0] + NUDGE * n[0],
wp[1] + NUDGE * n[1],
wp[2] + NUDGE * n[2],
};
WgpuViewportWindow::RaycastHit hit;
if (vp.raycast(ro, n, hit)) {
const double dist = double(hit.distance) + double(NUDGE);
const std::array<float, 3> a = {wp[0], wp[1], wp[2]};
const std::array<float, 3> b = {hit.world_pos[0],
hit.world_pos[1],
hit.world_pos[2]};
const QString tag = (n[2] > 0.0f)
? QStringLiteral("ceiling height")
: QStringLiteral("floor distance");
groups.push_back(makeGroup({a[0],a[1],a[2], b[0],b[1],b[2]},
1.0f, 1.0f, 1.0f, /*dashed*/ true));
labels.push_back(makeLabel(a, b,
QStringLiteral("%1: %2 m").arg(tag).arg(dist, 0, 'f', 3)));
hud_lines << QStringLiteral("%1: %2 m").arg(tag).arg(dist, 0, 'f', 3);
}
}
pushDots(vp, std::vector<float>(wp, wp + 3));
vp.setOverlayLines(groups);
vp.setOverlayLabels(labels);
vp.setHudText(hud_lines.join('\n'));
}
QString WgpuLengthMeasurement::formatReadout() const {
const size_t n = points_.size();
if (n == 0) return QStringLiteral("Length tool: click first point");
if (n == 1) return QStringLiteral("1 point (click another)");
if (n == 2) {
const auto& a = points_[0];
const auto& b = points_[1];
const double d = dist3(a, b);
const double dx = std::abs(double(b[0]) - a[0]);
const double dy = std::abs(double(b[1]) - a[1]);
const double dz = std::abs(double(b[2]) - a[2]);
return QStringLiteral("Length: %1 m\nΔX: %2 ΔY: %3 ΔZ: %4 m")
.arg(d, 0, 'f', 4)
.arg(dx, 0, 'f', 4)
.arg(dy, 0, 'f', 4)
.arg(dz, 0, 'f', 4);
}
if (n == 3) {
const auto& a = points_[0];
const auto& b = points_[1];
const auto& c = points_[2];
const double bax = double(a[0]) - b[0];
const double bay = double(a[1]) - b[1];
const double baz = double(a[2]) - b[2];
const double bcx = double(c[0]) - b[0];
const double bcy = double(c[1]) - b[1];
const double bcz = double(c[2]) - b[2];
const double la = std::sqrt(bax*bax + bay*bay + baz*baz);
const double lc = std::sqrt(bcx*bcx + bcy*bcy + bcz*bcz);
double angle_deg = 0.0;
if (la > 0.0 && lc > 0.0) {
const double cosang = std::clamp(
(bax*bcx + bay*bcy + baz*bcz) / (la * lc), -1.0, 1.0);
angle_deg = std::acos(cosang) * 180.0 / M_PI;
}
return QStringLiteral("Angle at pt 2: %1°\nTriangle area: %2 m²\nPerimeter: %3 m")
.arg(angle_deg, 0, 'f', 2)
.arg(triArea3(a, b, c), 0, 'f', 4)
.arg(dist3(a, b) + dist3(b, c) + dist3(c, a), 0, 'f', 4);
}
const PolygonAreaResult r = polygonArea(points_);
double perimeter = 0.0;
for (size_t i = 0; i < n; ++i) {
perimeter += dist3(points_[i], points_[(i + 1) % n]);
}
return QStringLiteral("Polygon (%1 pts, %2)\nArea: %3 m²\nPerimeter: %4 m")
.arg(n)
.arg(r.method)
.arg(r.area_m2, 0, 'f', 4)
.arg(perimeter, 0, 'f', 4);
}
@@ -0,0 +1,75 @@
/********************************************************************************
* *
* 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 WGPULENGTHMEASUREMENT_H
#define WGPULENGTHMEASUREMENT_H
#include "WgpuViewportWindow.h"
#include <QString>
#include <array>
#include <vector>
// Click-to-place length / angle / area measurement for the wgpu viewport.
// Mirrors src/bonsaiviewer/Measurement.h's LengthMeasurement — the
// readout adapts to the running point count:
//
// 1 point → laser-measure: BFS the coplanar surface patch the click
// landed on, project its vertices into the surface's own
// tangent basis to get the face extent, plus a vertical
// raycast for floor/ceiling distance on horizontal surfaces.
// 2 points → straight-line distance + ΔX/ΔY/ΔZ stair-step + optional
// perpendicular projection when both picks landed on
// near-parallel surfaces.
// 3 points → angle at the middle vertex + triangle area + perimeter.
// 4+ pts → polygon area via best-fit-plane shoelace if near-planar,
// fan-triangulated otherwise; perimeter on the closed loop.
//
// Pushes the running set as overlay points + the connecting polyline +
// per-segment labels to the viewport; the multi-line HUD carries the
// adaptive readout.
class WgpuLengthMeasurement {
public:
WgpuLengthMeasurement();
// Pixel coords are physical (post-DPR). `alt` is currently unused
// (kept for API symmetry with the Area tool).
void onPick(WgpuViewportWindow& vp, int x_phys, int y_phys, bool alt);
void removeLastPoint(WgpuViewportWindow& vp);
void clear(WgpuViewportWindow& vp);
size_t pointCount() const { return points_.size(); }
private:
void rebuildOverlay(WgpuViewportWindow& vp);
void rebuildLaserOverlay(WgpuViewportWindow& vp);
QString formatReadout() const;
std::vector<std::array<float, 3>> points_;
std::vector<std::array<float, 3>> normals_; // surface normal at each pick
// Captured at the very first pick of a fresh sequence and never
// updated afterwards. Used by the 1-pt laser BFS to locate the
// mesh-local position of points_[0] without re-picking. Stays valid
// while points_[0] does (pop_back never touches the first element).
WgpuViewportWindow::MeshLocalPick first_pick_{};
};
#endif // WGPULENGTHMEASUREMENT_H
+349 -8
View File
@@ -19,6 +19,7 @@
#include "WgpuViewportWindow.h"
#include "WgpuAreaMeasurement.h"
#include "WgpuLengthMeasurement.h"
#include "WgpuStreamingLoader.h"
#include <QGuiApplication>
@@ -103,6 +104,54 @@ static double computeMeshLocalVolumeQuantised(
const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices,
WgpuModelGpuData::MeshTriangles* out_tris);
// Ray-AABB (slab) + ray-triangle (Möller-Trumbore). Used by raycast()
// AND by pickMeshLocalAt to refine the AABB-coarse surface hit into a
// real triangle hit — see pickMeshLocalAt's refinement block.
// Slab method ray-AABB. inv_d is precomputed 1/dir per axis.
static bool rayAabbSlab(const float ro[3], const float inv_d[3],
const float bmin[3], const float bmax[3]) {
float tmin = 0.0f, tmax = std::numeric_limits<float>::infinity();
for (int i = 0; i < 3; ++i) {
const float t1 = (bmin[i] - ro[i]) * inv_d[i];
const float t2 = (bmax[i] - ro[i]) * inv_d[i];
tmin = std::max(tmin, std::min(t1, t2));
tmax = std::min(tmax, std::max(t1, t2));
}
return tmax >= tmin && tmax >= 0.0f;
}
// Möller-Trumbore. Returns true on hit; t is in dir-units.
static bool rayTriMT(const float ro[3], const float rd[3],
const float v0[3], const float v1[3], const float v2[3],
float& t_out) {
constexpr float EPS = 1e-7f;
const float e1[3] = {v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2]};
const float e2[3] = {v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2]};
const float h[3] = {
rd[1]*e2[2] - rd[2]*e2[1],
rd[2]*e2[0] - rd[0]*e2[2],
rd[0]*e2[1] - rd[1]*e2[0]
};
const float a = e1[0]*h[0] + e1[1]*h[1] + e1[2]*h[2];
if (a > -EPS && a < EPS) return false;
const float f = 1.0f / a;
const float s[3] = {ro[0]-v0[0], ro[1]-v0[1], ro[2]-v0[2]};
const float u = f * (s[0]*h[0] + s[1]*h[1] + s[2]*h[2]);
if (u < 0.0f || u > 1.0f) return false;
const float q[3] = {
s[1]*e1[2] - s[2]*e1[1],
s[2]*e1[0] - s[0]*e1[2],
s[0]*e1[1] - s[1]*e1[0]
};
const float v = f * (rd[0]*q[0] + rd[1]*q[1] + rd[2]*q[2]);
if (v < 0.0f || u + v > 1.0f) return false;
const float t = f * (e2[0]*q[0] + e2[1]*q[1] + e2[2]*q[2]);
if (t <= EPS) return false;
t_out = t;
return true;
}
// -----------------------------------------------------------------------------
// Small helpers
// -----------------------------------------------------------------------------
@@ -2956,22 +3005,130 @@ bool WgpuViewportWindow::pickMeshLocalAt(int x, int y, MeshLocalPick& out) {
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;
// pickSurfaceAt returns a bounding-box hit (WebGPU bans the
// depth readback that would give us a real surface point), so
// world_pos sits on the AABB face — not on any triangle of the
// mesh. Refine against the picked instance's CPU mesh shadow:
// re-project the click into a world ray and Möller-Trumbore it
// against every triangle of this mesh. On a hit, replace
// world_pos with the real surface point and world_normal with
// the transformed face normal. Without this, the Area/Length
// BFS seeds with whatever triangle is closest to the AABB
// corner — often a perpendicular face, which produces
// bounding-box-shaped patches instead of surface patches.
QVector3D refined_world_pos = world_pos;
QVector3D refined_world_normal = world_normal;
if (inst.mesh_id < m.mesh_triangles_cache.size()) {
const auto& tris = m.mesh_triangles_cache[inst.mesh_id];
if (!tris.indices.empty() && configured_w_ > 0 && configured_h_ > 0) {
QMatrix4x4 view, proj;
buildViewProj(view, proj);
bool inv_ok = false;
const QMatrix4x4 inv_vp = (proj * view).inverted(&inv_ok);
if (inv_ok) {
const float ndc_x = (2.0f * float(x) / float(configured_w_)) - 1.0f;
const float ndc_y = 1.0f - (2.0f * float(y) / float(configured_h_));
const QVector4D far_clip(ndc_x, ndc_y, 1.0f, 1.0f);
const QVector4D far_w = inv_vp * far_clip;
if (std::abs(far_w.w()) >= 1e-6f) {
const QVector3D far_world = far_w.toVector3D() / far_w.w();
const QVector3D eye = orbitEye(
camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
QVector3D ray_dir = far_world - eye;
if (ray_dir.lengthSquared() > 1e-8f) {
ray_dir.normalize();
// Inverse-transform the world ray into mesh-local.
const QVector4D ro_l4 = Ti * QVector4D(eye.x(), eye.y(), eye.z(), 1.0f);
const QVector4D rd_l4 = Ti * QVector4D(ray_dir.x(), ray_dir.y(), ray_dir.z(), 0.0f);
const float ro_l[3] = { ro_l4.x(), ro_l4.y(), ro_l4.z() };
const float rd_l[3] = { rd_l4.x(), rd_l4.y(), rd_l4.z() };
const float ldn = std::sqrt(
rd_l[0]*rd_l[0] + rd_l[1]*rd_l[1] + rd_l[2]*rd_l[2]);
if (ldn > 0.0f) {
float best_t_world = std::numeric_limits<float>::infinity();
uint32_t best_tri = UINT32_MAX;
const size_t n_tris = tris.indices.size() / 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* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
float t_local = 0.0f;
if (!rayTriMT(ro_l, rd_l, va, vb, vc, t_local)) continue;
const float t_world = t_local / ldn;
if (t_world < best_t_world) {
best_t_world = t_world;
best_tri = uint32_t(t);
}
}
if (best_tri != UINT32_MAX) {
refined_world_pos = eye + ray_dir * best_t_world;
// Face normal of the chosen tri,
// transformed back to world.
const uint32_t ia = tris.indices[3 * best_tri + 0];
const uint32_t ib = tris.indices[3 * best_tri + 1];
const uint32_t ic = tris.indices[3 * best_tri + 2];
const float* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
const float bax = vb[0]-va[0], bay = vb[1]-va[1], baz = vb[2]-va[2];
const float cax = vc[0]-va[0], cay = vc[1]-va[1], caz = vc[2]-va[2];
float n_local[3] = {
bay*caz - baz*cay,
baz*cax - bax*caz,
bax*cay - bay*cax,
};
const float nl = std::sqrt(
n_local[0]*n_local[0]
+ n_local[1]*n_local[1]
+ n_local[2]*n_local[2]);
if (nl > 0.0f) {
n_local[0] /= nl;
n_local[1] /= nl;
n_local[2] /= nl;
}
const float* M = inst.transform;
QVector3D n_world(
M[0]*n_local[0] + M[4]*n_local[1] + M[8] *n_local[2],
M[1]*n_local[0] + M[5]*n_local[1] + M[9] *n_local[2],
M[2]*n_local[0] + M[6]*n_local[1] + M[10]*n_local[2]);
if (n_world.lengthSquared() > 1e-12f) {
n_world.normalize();
refined_world_normal = n_world;
}
}
}
}
}
}
}
}
const QVector4D mp = Ti * QVector4D(refined_world_pos.x(),
refined_world_pos.y(),
refined_world_pos.z(), 1.0f);
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();
out.world_pos [0] = refined_world_pos.x();
out.world_pos [1] = refined_world_pos.y();
out.world_pos [2] = refined_world_pos.z();
out.world_normal[0] = refined_world_normal.x();
out.world_normal[1] = refined_world_normal.y();
out.world_normal[2] = refined_world_normal.z();
std::memcpy(out.composed_transform, inst.transform,
sizeof(out.composed_transform));
return true;
@@ -2985,6 +3142,155 @@ void WgpuViewportWindow::onAreaPick(int x_phys, int y_phys, bool alt) {
updateAreaHud();
}
bool WgpuViewportWindow::meshLocalToGlobal(uint32_t object_id,
const float mesh_local[3],
double global_out[3]) const {
// Find the instance via the per-model object_id_to_instance map.
// Use the live map key (`mid`) — see pickMeshLocalAt comment about
// stale InstanceCpu::model_id from sidecar writes.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(object_id);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
// GL composes coordinate_operation · placement · local; the wgpu
// viewer doesn't carry per-model CoordinateOperation yet, so
// apply just the placement_transformation (double precision —
// matches the IFC's own world coordinates for a non-federated
// load).
const double* P = inst.placement_transformation; // column-major
const double lx = double(mesh_local[0]);
const double ly = double(mesh_local[1]);
const double lz = double(mesh_local[2]);
global_out[0] = P[0]*lx + P[4]*ly + P[8]*lz + P[12];
global_out[1] = P[1]*lx + P[5]*ly + P[9]*lz + P[13];
global_out[2] = P[2]*lx + P[6]*ly + P[10]*lz + P[14];
return true;
}
return false;
}
bool WgpuViewportWindow::raycast(const float origin[3], const float dir[3],
RaycastHit& out) const {
// World-AABB cull per instance, then transform the ray into the
// mesh's local frame and intersect every triangle. No BVH — typical
// BIM scenes have enough AABB-cull to make this acceptable (~ms);
// a per-model BVH would be the next optimisation.
float inv_d[3] = {
std::abs(dir[0]) > 1e-20f ? 1.0f / dir[0] : std::numeric_limits<float>::infinity(),
std::abs(dir[1]) > 1e-20f ? 1.0f / dir[1] : std::numeric_limits<float>::infinity(),
std::abs(dir[2]) > 1e-20f ? 1.0f / dir[2] : std::numeric_limits<float>::infinity(),
};
float best_t = std::numeric_limits<float>::infinity();
uint32_t best_oid = 0;
float best_normal[3] = {0, 0, 0};
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (uint32_t inst_idx = 0; inst_idx < uint32_t(m.instances.size()); ++inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
if (!rayAabbSlab(origin, inv_d, inst.world_aabb_min, inst.world_aabb_max)) {
continue;
}
if (inst.mesh_id >= m.mesh_triangles_cache.size()) continue;
const auto& tris = m.mesh_triangles_cache[inst.mesh_id];
if (tris.indices.empty()) continue;
// Transform ray into mesh-local frame. We need both a point
// (origin) and a direction (dir) inverse-transformed; dir is
// a vector so the translation drops out.
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) continue;
const QVector4D ro_local4 = Ti * QVector4D(origin[0], origin[1], origin[2], 1.0f);
const QVector4D rd_local4 = Ti * QVector4D(dir[0], dir[1], dir[2], 0.0f);
const float ro_local[3] = { ro_local4.x(), ro_local4.y(), ro_local4.z() };
const float rd_local[3] = { rd_local4.x(), rd_local4.y(), rd_local4.z() };
const size_t n_tris = tris.indices.size() / 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* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
float t_local = 0.0f;
if (!rayTriMT(ro_local, rd_local, va, vb, vc, t_local)) continue;
// Convert t_local into world units. Because we
// inverse-transformed dir without normalising, world-t =
// local-t × (|world-dir| / |local-dir|). The caller
// guarantees world-dir is unit; we compute local-dir
// length here.
const float ldn = std::sqrt(rd_local[0]*rd_local[0]
+ rd_local[1]*rd_local[1]
+ rd_local[2]*rd_local[2]);
if (ldn <= 0.0f) continue;
const float t_world = t_local / ldn;
if (t_world >= best_t) continue;
best_t = t_world;
best_oid = inst.object_id;
// Mesh-local triangle normal → world via the transform's
// rotation block. Same column-major math as
// applyCachedModel uses for AABB normals.
const float bax = vb[0]-va[0], bay = vb[1]-va[1], baz = vb[2]-va[2];
const float cax = vc[0]-va[0], cay = vc[1]-va[1], caz = vc[2]-va[2];
float n_local[3] = {
bay * caz - baz * cay,
baz * cax - bax * caz,
bax * cay - bay * cax,
};
const float nl = std::sqrt(n_local[0]*n_local[0]
+ n_local[1]*n_local[1]
+ n_local[2]*n_local[2]);
if (nl > 0.0f) { n_local[0] /= nl; n_local[1] /= nl; n_local[2] /= nl; }
// Normal transform = inverse-transpose; for a rigid +
// uniform-scale transform the upper-left 3×3 is fine.
const float* M = inst.transform;
best_normal[0] = M[0]*n_local[0] + M[4]*n_local[1] + M[8]*n_local[2];
best_normal[1] = M[1]*n_local[0] + M[5]*n_local[1] + M[9]*n_local[2];
best_normal[2] = M[2]*n_local[0] + M[6]*n_local[1] + M[10]*n_local[2];
const float wnl = std::sqrt(best_normal[0]*best_normal[0]
+ best_normal[1]*best_normal[1]
+ best_normal[2]*best_normal[2]);
if (wnl > 0.0f) {
best_normal[0] /= wnl;
best_normal[1] /= wnl;
best_normal[2] /= wnl;
}
}
}
}
if (!std::isfinite(best_t)) return false;
out.object_id = best_oid;
out.distance = best_t;
out.world_pos[0] = origin[0] + best_t * dir[0];
out.world_pos[1] = origin[1] + best_t * dir[1];
out.world_pos[2] = origin[2] + best_t * dir[2];
out.world_normal[0] = best_normal[0];
out.world_normal[1] = best_normal[1];
out.world_normal[2] = best_normal[2];
return true;
}
void WgpuViewportWindow::onLengthPick(int x_phys, int y_phys, bool alt) {
if (!length_tool_) return;
length_tool_->onPick(*this, x_phys, y_phys, alt);
}
void WgpuViewportWindow::onLengthBackspace() {
if (!length_tool_) return;
length_tool_->removeLastPoint(*this);
}
void WgpuViewportWindow::updateAreaHud() {
if (tool_mode_ != ToolMode::Area || !area_tool_) return;
overlays_.setHudText(
@@ -3076,9 +3382,12 @@ void WgpuViewportWindow::setToolMode(ToolMode 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);
if (area_tool_) area_tool_->clear(*this);
if (length_tool_) length_tool_->clear(*this);
overlays_.setHudText(QString());
overlays_.setOverlayLabels({});
overlays_.setOverlayLines({});
overlays_.setOverlayPoints({}, 0,0,0,0, 0, 0,0,0,0, 0);
overlays_.setHighlightTriangles({}, 0, 0, 0, 0);
switch (tool_mode_) {
@@ -3087,6 +3396,7 @@ void WgpuViewportWindow::setToolMode(ToolMode m) {
break;
case ToolMode::Volume:
qInfo() << "[wgpu measure] volume tool — pick / marquee objects, Esc to exit";
overlays_.setHudText(QStringLiteral("Volume: 0.0000 m³ (0 objects)"));
updateVolumeReadout();
break;
case ToolMode::Area:
@@ -3094,6 +3404,11 @@ void WgpuViewportWindow::setToolMode(ToolMode m) {
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;
case ToolMode::Length:
if (!length_tool_) length_tool_ = std::make_unique<WgpuLengthMeasurement>();
qInfo() << "[wgpu measure] length tool — LMB add point, Backspace remove last, Esc exits";
overlays_.setHudText(QStringLiteral("Length tool: click first point"));
break;
}
if (isExposed()) requestUpdate();
}
@@ -6370,6 +6685,7 @@ void WgpuViewportWindow::mousePressEvent(QMouseEvent* event) {
} else if (event->button() == Qt::LeftButton
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
&& tool_mode_ != ToolMode::Length
&& 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
@@ -6480,6 +6796,20 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
return;
}
// Length tool: plain LMB appends a world-space pick point;
// the readout adapts to the running count (laser / distance
// / angle / polygon). Shift/Ctrl fall through to selection.
if (tool_mode_ == ToolMode::Length
&& (event->modifiers() == Qt::NoModifier
|| event->modifiers() == Qt::AltModifier)) {
const bool alt = (event->modifiers() & Qt::AltModifier) != 0;
onLengthPick(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) {
@@ -6804,6 +7134,17 @@ void WgpuViewportWindow::keyPressEvent(QKeyEvent* event) {
: ToolMode::Area);
return;
}
if (key == Qt::Key_L && mods == Qt::NoModifier && !event->isAutoRepeat()) {
setToolMode(tool_mode_ == ToolMode::Length ? ToolMode::NoTool
: ToolMode::Length);
return;
}
if (tool_mode_ == ToolMode::Length
&& (key == Qt::Key_Backspace || key == Qt::Key_Delete)
&& !event->isAutoRepeat()) {
onLengthBackspace();
return;
}
if (tool_mode_ != ToolMode::NoTool && key == Qt::Key_Escape
&& !event->isAutoRepeat()) {
setToolMode(ToolMode::NoTool);
+35 -2
View File
@@ -326,14 +326,39 @@ public:
};
bool pickMeshLocalAt(int x, int y, MeshLocalPick& out);
// Resolve a mesh-local point to a (placement-applied) global frame.
// Matches GL ViewportWindow::meshLocalToGlobal's shape so the Length
// tool's ENH readout ports unchanged. The wgpu viewer doesn't carry
// per-model CoordinateOperation yet, so this currently outputs
// placement_transformation · mesh_local (i.e. the IFC's own world
// coords pre-georeferencing); ENH and IFC-world coincide for the
// non-federated case the minimal viewer handles today.
bool meshLocalToGlobal(uint32_t object_id, const float mesh_local[3],
double global_out[3]) const;
// CPU world-space raycast. Brute-force: per-instance world-AABB
// reject, then ray-into-mesh-local + Möller-Trumbore against the
// CPU mesh shadow. `dir` must be a unit vector — distance is the
// ray's t parameter, which equals world distance only at |dir|=1.
// Used by the Length tool's 1-point laser-measure overlay to find
// the ceiling/floor counterpart of a horizontal-surface click.
struct RaycastHit {
uint32_t object_id = 0;
float distance = 0.0f;
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
};
bool raycast(const float origin[3], const float dir[3], RaycastHit& out) const;
// Measurement tools. Mirrors GL ViewportWindow::ToolMode. Volume is
// selection-driven (LMB / marquee). Area is click-to-accumulate:
// each LMB picks a triangle and either adds or removes its
// coplanar patch via BFS over shared edges; Alt+LMB skips the BFS.
// V/A toggle, Esc exits.
// V/A/L toggle, Esc exits. Length consumes Backspace too for
// remove-last-point semantics.
// NoTool (not None) because X11/X.h #define's None as 0L; including
// it transitively via Qt's xcb back-end breaks any enum named None.
enum class ToolMode { NoTool, Volume, Area };
enum class ToolMode { NoTool, Volume, Area, Length };
ToolMode toolMode() const { return tool_mode_; }
void setToolMode(ToolMode m);
@@ -539,6 +564,14 @@ private:
void onAreaPick(int x_phys, int y_phys, bool alt);
void updateAreaHud();
// Length tool state lives in WgpuLengthMeasurement. Same lifecycle
// pattern: lazily constructed on first L press, cleared on tool
// exit, click handler routes LMB through onLengthPick + Backspace
// through onLengthBackspace.
std::unique_ptr<class WgpuLengthMeasurement> length_tool_;
void onLengthPick(int x_phys, int y_phys, bool alt);
void onLengthBackspace();
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
// pulled from the same visible_draws / instances buffers as the main
// pass — pick fragment outputs the instance's object_id. The pick