diff --git a/src/ifcviewer-wgpu/WgpuLengthMeasurement.cpp b/src/ifcviewer-wgpu/WgpuLengthMeasurement.cpp
new file mode 100644
index 0000000000..893d7840ee
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuLengthMeasurement.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "WgpuLengthMeasurement.h"
+
+#include "WgpuOverlayRenderer.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+double dist3(const std::array& a, const std::array& 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& a,
+ const std::array& b,
+ const std::array& 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>& 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::infinity(),
+ std::numeric_limits::infinity(),
+ std::numeric_limits::infinity() };
+ double bbox_max[3] = {-std::numeric_limits::infinity(),
+ -std::numeric_limits::infinity(),
+ -std::numeric_limits::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> 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 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& xyz, const std::array& p) {
+ xyz.push_back(p[0]); xyz.push_back(p[1]); xyz.push_back(p[2]);
+}
+
+void pushSeg(std::vector& xyz,
+ const std::array& a,
+ const std::array& b) {
+ xyz.insert(xyz.end(), a.begin(), a.end());
+ xyz.insert(xyz.end(), b.begin(), b.end());
+}
+
+WgpuOverlayRenderer::Label makeLabel(const std::array& a,
+ const std::array& 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& 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 pts_xyz;
+ pts_xyz.reserve(points_.size() * 3);
+ for (const auto& p : points_) pushDot(pts_xyz, p);
+ pushDots(vp, pts_xyz);
+
+ std::vector groups;
+ std::vector 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 kx = {b[0], a[1], a[2]};
+ const std::array 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 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 seg_xyz;
+ seg_xyz.reserve(n * 6);
+ labels.reserve(n);
+ auto addSeg = [&](const std::array& a,
+ const std::array& 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 groups;
+ std::vector 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 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> tri_n(n_tris);
+ std::unordered_map> 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::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 in_patch;
+ in_patch.insert(seed);
+ std::queue 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 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 a = {
+ float(wp[0] + mn * t[0]),
+ float(wp[1] + mn * t[1]),
+ float(wp[2] + mn * t[2]),
+ };
+ const std::array 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 a = {wp[0], wp[1], wp[2]};
+ const std::array 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(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);
+}
diff --git a/src/ifcviewer-wgpu/WgpuLengthMeasurement.h b/src/ifcviewer-wgpu/WgpuLengthMeasurement.h
new file mode 100644
index 0000000000..690977866a
--- /dev/null
+++ b/src/ifcviewer-wgpu/WgpuLengthMeasurement.h
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#ifndef WGPULENGTHMEASUREMENT_H
+#define WGPULENGTHMEASUREMENT_H
+
+#include "WgpuViewportWindow.h"
+
+#include
+
+#include
+#include
+
+// 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> points_;
+ std::vector> 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
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
index 570ce028c7..dd9ca0407c 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp
@@ -19,6 +19,7 @@
#include "WgpuViewportWindow.h"
#include "WgpuAreaMeasurement.h"
+#include "WgpuLengthMeasurement.h"
#include "WgpuStreamingLoader.h"
#include
@@ -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::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::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::infinity(),
+ std::abs(dir[1]) > 1e-20f ? 1.0f / dir[1] : std::numeric_limits::infinity(),
+ std::abs(dir[2]) > 1e-20f ? 1.0f / dir[2] : std::numeric_limits::infinity(),
+ };
+
+ float best_t = std::numeric_limits::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();
+ 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);
diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h
index 900ce9cf1d..4e1de41f93 100644
--- a/src/ifcviewer-wgpu/WgpuViewportWindow.h
+++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h
@@ -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 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