From 699f22b502d680e82b5ec134350c3cfb598ebf43 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 1 Jun 2026 08:44:12 +1000 Subject: [PATCH] wgpu: length measurement tool (L hotkey, adaptive 1/2/3/4+ point readout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ifcviewer-wgpu/WgpuLengthMeasurement.cpp | 738 +++++++++++++++++++ src/ifcviewer-wgpu/WgpuLengthMeasurement.h | 75 ++ src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 357 ++++++++- src/ifcviewer-wgpu/WgpuViewportWindow.h | 37 +- 4 files changed, 1197 insertions(+), 10 deletions(-) create mode 100644 src/ifcviewer-wgpu/WgpuLengthMeasurement.cpp create mode 100644 src/ifcviewer-wgpu/WgpuLengthMeasurement.h 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