From d8f37b23769265e30c4058c56347c93d7f211513 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 May 2026 08:50:02 +1000 Subject: [PATCH] ifcviewer-full: 1-pt laser, 2-pt XYZ + perpendicular, sharper visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Length tool's 1-pt laser is now hybrid: - On any surface, a coplanar BFS finds the connected face patch around the click and projects its vertices into the surface tangent basis to get an exact bounding-box extent. Stops at the face edge by construction — no overshoot into adjacent geometry like the previous tangent-raycast did. - On near-horizontal surfaces (|n.z| > 0.85, i.e. floors and ceilings) it additionally fires one raycast in +n to the opposing surface — so a single floor click reports X extent + Y extent + ceiling height. - Bars are labelled by their dominant world axis (X/Y/Z) instead of "vertical/horizontal", which reads cleanly on either kind of surface. The 2-pt readout now draws the world-space XYZ stair-step (red ΔX, green ΔY, blue ΔZ) with each leg labelled, and a dashed perpendicular line whenever the two picks landed on near-parallel surfaces — useful for measuring across walls. To support multiple line styles per frame, OverlayRenderer's setOverlayLines takes std::vector instead of a single inline style; each group has its own color/halo/width and an optional dash period. The line shader gained v_along_px + u_dash_period uniforms (screen-space dashes), and both line and point shaders now use a sharp step() for the inner→stroke transition with AA only on the outer halo edge — much crisper than the previous soft band. Default visual style trimmed: 1.5px lines (0.5px halo), 6px dots (1px halo), opaque black halo. Also adds ViewportWindow::raycast(origin, dir, RaycastHit&) — CPU ray traversal of each model's per-instance BVH followed by Möller-Trumbore against the candidate meshes' triangles (lazily read back, cached per call). Used by the floor/ceiling laser path today and reusable for any future raycast-based feature. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/Measurement.cpp | 423 ++++++++++++++++++++++++++--- src/ifcviewer-full/Measurement.h | 21 +- src/ifcviewer/OverlayRenderer.cpp | 124 +++++---- src/ifcviewer/OverlayRenderer.h | 68 ++--- src/ifcviewer/ViewportWindow.cpp | 172 +++++++++++- src/ifcviewer/ViewportWindow.h | 31 ++- 6 files changed, 702 insertions(+), 137 deletions(-) diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp index 45fba9a82c..3a68d9d836 100644 --- a/src/ifcviewer-full/Measurement.cpp +++ b/src/ifcviewer-full/Measurement.cpp @@ -474,12 +474,70 @@ PolygonAreaResult polygonArea(const std::vector>& pts) { LengthMeasurement::LengthMeasurement() = default; +namespace { + +// 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 + +OverlayRenderer::LineGroup makeGroup(std::vector xyz, + float r, float g, float b, + bool dashed = false) { + OverlayRenderer::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()); +} + +OverlayRenderer::Label makeLabel(const std::array& a, + const std::array& b, + const QString& text) { + OverlayRenderer::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(ViewportWindow& 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); +} + +} // namespace + void LengthMeasurement::clear(ViewportWindow& vp) { points_.clear(); - vp.setOverlayPoints({}, 0,0,0,0, 0, - 0,0,0,0, 0); - vp.setOverlayLines({}, 0,0,0,0, 0, - 0,0,0,0, 0); + normals_.clear(); + vp.setOverlayPoints({}, 0,0,0,0, 0, 0,0,0,0, 0); + vp.setOverlayLines({}); vp.setOverlayLabels({}); vp.setHudText(QString()); } @@ -488,63 +546,352 @@ void LengthMeasurement::onPick(ViewportWindow& vp, int x, int y, bool /*alt*/) { ViewportWindow::MeshLocalPick pick; if (!vp.pickMeshLocalAt(x, y, 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 LengthMeasurement::removeLastPoint(ViewportWindow& vp) { if (points_.empty()) return; points_.pop_back(); + if (!normals_.empty()) normals_.pop_back(); rebuildOverlay(vp); } void LengthMeasurement::rebuildOverlay(ViewportWindow& vp) { - // Points: orange inner with thin black halo — readable on every - // background. Inner 8px disc + 2px halo each side. + 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_) { - pts_xyz.push_back(p[0]); - pts_xyz.push_back(p[1]); - pts_xyz.push_back(p[2]); - } - vp.setOverlayPoints(pts_xyz, - /*inner*/ 1.00f, 1.00f, 1.00f, 1.00f, - /*size*/ 8.0f, - /*stroke*/ 0.00f, 0.00f, 0.00f, 0.85f, - /*extra*/ 2.0f); + for (const auto& p : points_) pushDot(pts_xyz, p); + pushDots(vp, pts_xyz); - // Connecting polyline. For 4+ points also close the polygon since - // that's the area-readout shape. Same orange + halo treatment. - std::vector seg_xyz; + std::vector groups; std::vector labels; + const size_t n = points_.size(); - if (points_.size() >= 2) { - const size_t n = points_.size(); + if (n == 2) { + // Direct line A→B (white) + total-length label. + 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) + " m")); + + // Axis-coloured stair-step A → (Bx,Ay,Az) → (Bx,By,Az) → B. + // Each leg gets its delta label (omit zero legs to keep the + // overlay clean when the points are axis-aligned). + 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, + "ΔX: " + QString::number(dx, 'f', 3) + " 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, + "ΔY: " + QString::number(dy, 'f', 3) + " 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, + "ΔZ: " + QString::number(dz, 'f', 3) + " m")); + } + + // Perpendicular projection: only when both picks landed on + // surfaces with near-parallel normals (|n_a · n_b| > 0.95). We + // pick the average normal (flipped to agree with n_a if needed) + // and project AB onto it. Drawn dashed from A to A + perp·n. + 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]; + if (std::abs(perp) > 1e-6) { + 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, + "perp: " + QString::number(std::abs(perp), 'f', 3) + " m")); + } + } + } + } else if (n >= 3) { + // 3-pt and 4+pt: white connecting polyline (closed for 4+) with + // per-segment length labels. HUD carries the angle/area readout. + std::vector seg_xyz; seg_xyz.reserve(n * 6); labels.reserve(n); - auto pushSegment = [&](const std::array& a, - const std::array& b) { - seg_xyz.insert(seg_xyz.end(), a.begin(), a.end()); - seg_xyz.insert(seg_xyz.end(), b.begin(), b.end()); - OverlayRenderer::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 = QString::number(dist3(a, b), 'f', 3) + " m"; - labels.push_back(std::move(lbl)); + 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) + " m")); }; - for (size_t i = 0; i + 1 < n; ++i) pushSegment(points_[i], points_[i + 1]); - if (n >= 4) pushSegment(points_[n - 1], points_[0]); + 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(seg_xyz, - /*inner*/ 1.00f, 1.00f, 1.00f, 1.00f, - /*width*/ 2.0f, - /*stroke*/ 0.00f, 0.00f, 0.00f, 0.85f, - /*extra*/ 1.5f); + + vp.setOverlayLines(groups); vp.setOverlayLabels(labels); vp.setHudText(formatReadout()); } +namespace { + +// Which world axis is `v` closest to? Used to label the BFS extent +// bars (X/Y/Z) without hard-coding wall vs floor convention. +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 + +void LengthMeasurement::rebuildLaserOverlay(ViewportWindow& vp) { + const auto& wp = first_pick_.world_pos; // float[3] world click + const auto& n = first_pick_.world_normal; // float[3] world normal + + // ---------- Tangent basis in world ---------- + // t1 = world-up Gram-Schmidt'd against n; fall back to world-X for + // near-horizontal surfaces so the basis never degenerates. + 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)"); + + // ---------- Coplanar-patch BFS for face extent ---------- + // Read back the seed mesh, transform every vertex into world space, + // build edge adjacency, BFS from the seed triangle keeping only + // co-normal neighbours, then project each patch vertex into the + // (t1, t2) basis to get the bounding extent of the face. Stops + // exactly at the face edge (no overshoot into adjacent geometry). + ViewportWindow::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) { + // Vertices → world. + 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]; + } + // Per-tri world normals + edge adjacency. + 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)); + } + // Seed = nearest triangle to world click. + 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); } + } + // BFS coplanar. + 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); + } + } + } + // Project unique patch vertices → tangent coords. + 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, + QString("%1 extent: %2 m").arg(axis).arg(extent, 0, 'f', 3))); + hud_lines << QString("%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 horizontal surfaces ---------- + // For floors / ceilings (|n.z| close to 1) the BFS extents give the + // floor footprint; the *useful* extra dimension is the room height, + // which a single raycast in +n finds. Skip on walls (|n.z| < 0.85) + // — there the BFS already covers the user's intent. + 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], + }; + ViewportWindow::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, + QString("%1: %2 m").arg(tag).arg(dist, 0, 'f', 3))); + hud_lines << QString("%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 LengthMeasurement::formatReadout() const { const size_t n = points_.size(); if (n == 0) return QStringLiteral("Length tool: click first point"); diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h index e6149a4f37..161ef9a5ab 100644 --- a/src/ifcviewer-full/Measurement.h +++ b/src/ifcviewer-full/Measurement.h @@ -21,14 +21,13 @@ #define IFCVIEWER_FULL_MEASUREMENT_H #include +#include "ViewportWindow.h" #include #include #include #include #include -class ViewportWindow; - // Sum of mesh-local volumes (m³) of every instance whose object_id is in // `object_ids`. Groups by (model, mesh) so each unique mesh is read back // from the GPU at most once per call; instances of the same mesh are scaled @@ -110,7 +109,12 @@ private: // Click-to-place length / angle / area measurement. Each pick appends a // world-space point. The readout adapts to the point count: // -// 1 point → "click another point" +// 1 point → "laser-measure" mode: 6 rays (±surface-normal, ±tangent₁, +// ±tangent₂ in the surface's own basis) trace into the scene. +// On a wall this gives thickness + floor-to-ceiling height + +// length-along-wall in one click. Tangent₁ is world up +// projected onto the surface plane (Gram-Schmidt against the +// normal); tangent₂ = normal × tangent₁. // 2 points → straight-line distance plus axis-aligned ΔX/ΔY/ΔZ // 3 points → angle at the middle vertex plus the triangle's area // 4+ → polygon area: best-fit-plane shoelace if the points are @@ -118,7 +122,8 @@ private: // box), else fan-triangulated from the first point // // Clicked points are pushed to the viewport overlay as small dots and -// the connecting polyline; readouts go to the multi-line HUD. +// the connecting polyline (or the laser rays for 1-point); readouts +// go to the multi-line HUD. class LengthMeasurement { public: LengthMeasurement(); @@ -131,9 +136,17 @@ public: private: void rebuildOverlay(ViewportWindow& vp); + void rebuildLaserOverlay(ViewportWindow& 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 re-locate the + // mesh-local position of points_[0] without re-picking. Stays valid + // while points_[0] does (pop_back never touches the first element). + ViewportWindow::MeshLocalPick first_pick_{}; }; #endif // IFCVIEWER_FULL_MEASUREMENT_H diff --git a/src/ifcviewer/OverlayRenderer.cpp b/src/ifcviewer/OverlayRenderer.cpp index 3254528104..e477e73717 100644 --- a/src/ifcviewer/OverlayRenderer.cpp +++ b/src/ifcviewer/OverlayRenderer.cpp @@ -95,8 +95,8 @@ void main() { // half-sprite (so 1.0 = no stroke, smaller = thicker stroke). The // fragment shader reads gl_PointCoord (range [0,1] across the sprite), // computes the distance from the centre normalised against the half- -// sprite, and picks inner vs stroke from that. ~1px AA at every band -// boundary using fwidth-style smoothstep with a narrow ramp. +// sprite, picks inner vs stroke with a sharp `step()` (no soft band), +// then anti-aliases the *outer* edge only. const char* POINT_FS = R"( #version 450 core uniform vec4 u_inner_color; @@ -107,10 +107,9 @@ void main() { vec2 c = gl_PointCoord - 0.5; float d = length(c) * 2.0; // 0 at centre, 1 at sprite edge if (d > 1.0) discard; - float aa = fwidth(d) * 1.2; // ~1px feather - float t_inner = smoothstep(u_inner_radius_norm - aa, - u_inner_radius_norm + aa, d); + float t_inner = step(u_inner_radius_norm, d); vec4 col = mix(u_inner_color, u_stroke_color, t_inner); + float aa = fwidth(d); float outer_alpha = smoothstep(1.0, 1.0 - aa, d); frag_color = vec4(col.rgb, col.a * outer_alpha); } @@ -136,6 +135,7 @@ uniform vec2 u_screen_size; // physical pixels uniform float u_half_width; // inner half-width (px) uniform float u_stroke_extra; // halo per side (px) out float v_dist_px; +out float v_along_px; // distance from segment start (px) void main() { vec4 clip_a = u_view_proj * vec4(in_a, 1.0); vec4 clip_b = u_view_proj * vec4(in_b, 1.0); @@ -160,7 +160,8 @@ void main() { vec2 ndc_out = screen_self / (u_screen_size * 0.5); gl_Position = vec4(ndc_out * clip_self.w, clip_self.z, clip_self.w); - v_dist_px = in_side * total_half; + v_dist_px = in_side * total_half; + v_along_px = in_along * len; } )"; @@ -190,18 +191,26 @@ void main() { const char* LINE_FS = R"( #version 450 core in float v_dist_px; +in float v_along_px; uniform vec4 u_inner_color; uniform vec4 u_stroke_color; uniform float u_half_width; uniform float u_stroke_extra; +uniform float u_dash_period; // 0 = solid +uniform float u_dash_on_ratio; out vec4 frag_color; void main() { + if (u_dash_period > 0.0) { + float t = mod(v_along_px, u_dash_period); + if (t > u_dash_period * u_dash_on_ratio) discard; + } float ad = abs(v_dist_px); float total = u_half_width + u_stroke_extra; if (ad > total) discard; - // ~1px AA on the inner/stroke boundary and the outer edge. - float t_stroke = smoothstep(u_half_width - 0.5, u_half_width + 0.5, ad); + // Sharp inner-to-stroke transition; AA only the outer halo edge so + // the line reads crisp instead of mushy. + float t_stroke = step(u_half_width, ad); vec4 col = mix(u_inner_color, u_stroke_color, t_stroke); float outer_a = smoothstep(total, total - 1.0, ad); frag_color = vec4(col.rgb, col.a * outer_a); @@ -282,6 +291,8 @@ void OverlayRenderer::initialize(QOpenGLFunctions_4_5_Core* gl) { u_ln_stroke_extra_ = gl_->glGetUniformLocation(program_ln_, "u_stroke_extra"); u_ln_inner_color_ = gl_->glGetUniformLocation(program_ln_, "u_inner_color"); u_ln_stroke_color_ = gl_->glGetUniformLocation(program_ln_, "u_stroke_color"); + u_ln_dash_period_ = gl_->glGetUniformLocation(program_ln_, "u_dash_period"); + u_ln_dash_on_ratio_ = gl_->glGetUniformLocation(program_ln_, "u_dash_on_ratio"); } // Screen-space rect program. { @@ -310,22 +321,24 @@ void OverlayRenderer::initialize(QOpenGLFunctions_4_5_Core* gl) { 0, 3 * sizeof(float)); // Line VAO/VBO: 8 floats per vertex (a:vec3, b:vec3, side, along). - gl_->glCreateVertexArrays(1, &lines_.vao); - gl_->glCreateBuffers(1, &lines_.vbo); + // Shared across every group; line_draws_ records the (first, count) + // slice for each. + gl_->glCreateVertexArrays(1, &vao_lines_); + gl_->glCreateBuffers(1, &vbo_lines_); const GLsizei stride = 8 * sizeof(float); - gl_->glEnableVertexArrayAttrib(lines_.vao, 0); - gl_->glVertexArrayAttribFormat(lines_.vao, 0, 3, GL_FLOAT, GL_FALSE, 0); - gl_->glVertexArrayAttribBinding(lines_.vao, 0, 0); - gl_->glEnableVertexArrayAttrib(lines_.vao, 1); - gl_->glVertexArrayAttribFormat(lines_.vao, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); - gl_->glVertexArrayAttribBinding(lines_.vao, 1, 0); - gl_->glEnableVertexArrayAttrib(lines_.vao, 2); - gl_->glVertexArrayAttribFormat(lines_.vao, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float)); - gl_->glVertexArrayAttribBinding(lines_.vao, 2, 0); - gl_->glEnableVertexArrayAttrib(lines_.vao, 3); - gl_->glVertexArrayAttribFormat(lines_.vao, 3, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float)); - gl_->glVertexArrayAttribBinding(lines_.vao, 3, 0); - gl_->glVertexArrayVertexBuffer(lines_.vao, 0, lines_.vbo, 0, stride); + gl_->glEnableVertexArrayAttrib(vao_lines_, 0); + gl_->glVertexArrayAttribFormat(vao_lines_, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribBinding(vao_lines_, 0, 0); + gl_->glEnableVertexArrayAttrib(vao_lines_, 1); + gl_->glVertexArrayAttribFormat(vao_lines_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); + gl_->glVertexArrayAttribBinding(vao_lines_, 1, 0); + gl_->glEnableVertexArrayAttrib(vao_lines_, 2); + gl_->glVertexArrayAttribFormat(vao_lines_, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float)); + gl_->glVertexArrayAttribBinding(vao_lines_, 2, 0); + gl_->glEnableVertexArrayAttrib(vao_lines_, 3); + gl_->glVertexArrayAttribFormat(vao_lines_, 3, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float)); + gl_->glVertexArrayAttribBinding(vao_lines_, 3, 0); + gl_->glVertexArrayVertexBuffer(vao_lines_, 0, vbo_lines_, 0, stride); // Screen-rect VAO/VBO: 2 floats per vertex (vec2 NDC). gl_->glCreateVertexArrays(1, &vao_rect_); @@ -342,8 +355,8 @@ void OverlayRenderer::release() { if (triangles_.vao) gl_->glDeleteVertexArrays(1, &triangles_.vao); if (points_.vbo) gl_->glDeleteBuffers(1, &points_.vbo); if (points_.vao) gl_->glDeleteVertexArrays(1, &points_.vao); - if (lines_.vbo) gl_->glDeleteBuffers(1, &lines_.vbo); - if (lines_.vao) gl_->glDeleteVertexArrays(1, &lines_.vao); + if (vbo_lines_) gl_->glDeleteBuffers(1, &vbo_lines_); + if (vao_lines_) gl_->glDeleteVertexArrays(1, &vao_lines_); if (vbo_rect_) gl_->glDeleteBuffers(1, &vbo_rect_); if (vao_rect_) gl_->glDeleteVertexArrays(1, &vao_rect_); if (program_tri_) gl_->glDeleteProgram(program_tri_); @@ -352,7 +365,9 @@ void OverlayRenderer::release() { if (program_rect_) gl_->glDeleteProgram(program_rect_); triangles_ = {}; points_ = {}; - lines_ = {}; + line_draws_.clear(); + vao_lines_ = vbo_lines_ = 0; + vbo_lines_capacity_ = 0; vao_rect_ = vbo_rect_ = 0; vbo_rect_capacity_ = 0; program_tri_ = program_pt_ = program_ln_ = program_rect_ = 0; @@ -392,23 +407,31 @@ void OverlayRenderer::setOverlayPoints(const std::vector& world_xyz, uploadFloats(gl_, points_.vbo, points_.vbo_capacity, world_xyz); } -void OverlayRenderer::setOverlayLines(const std::vector& world_xyz, - float r, float g, float b, float a, - float line_width, - float sr, float sg, float sb, float sa, - float stroke_extra) { +void OverlayRenderer::setOverlayLines(const std::vector& groups) { if (!gl_) return; - lines_.inner_color[0] = r; lines_.inner_color[1] = g; - lines_.inner_color[2] = b; lines_.inner_color[3] = a; - lines_.stroke_color[0] = sr; lines_.stroke_color[1] = sg; - lines_.stroke_color[2] = sb; lines_.stroke_color[3] = sa; - lines_.line_width = line_width; - lines_.stroke_extra = stroke_extra; + line_draws_.clear(); - std::vector expanded; - expandLineSegments(world_xyz, expanded); - lines_.vertex_count = GLsizei(expanded.size() / 8); - uploadFloats(gl_, lines_.vbo, lines_.vbo_capacity, expanded); + // Concatenate every group's CPU-expanded vertices into one big buffer + // and remember each group's (first, count) slice + style so render() + // can iterate without re-expanding. + std::vector combined; + for (const auto& g : groups) { + std::vector exp; + expandLineSegments(g.world_xyz, exp); + if (exp.empty()) continue; + LineDrawCall dc; + std::memcpy(dc.color, g.color, sizeof(dc.color)); + std::memcpy(dc.stroke_color, g.stroke_color, sizeof(dc.stroke_color)); + dc.line_width = g.line_width; + dc.stroke_extra = g.stroke_extra; + dc.dash_period_px = g.dash_period_px; + dc.dash_on_ratio = g.dash_on_ratio; + dc.first = GLint(combined.size() / 8); + dc.count = GLsizei(exp.size() / 8); + line_draws_.push_back(dc); + combined.insert(combined.end(), exp.begin(), exp.end()); + } + uploadFloats(gl_, vbo_lines_, vbo_lines_capacity_, combined); } void OverlayRenderer::render(const float view_proj[16], @@ -447,16 +470,21 @@ void OverlayRenderer::render(const float view_proj[16], // pass — the standard CAD convention. GL_ALWAYS wins every depth // compare; GL_LEQUAL is restored at the end of the function. gl_->glDepthFunc(GL_ALWAYS); - if (lines_.vertex_count > 0 && lines_.inner_color[3] > 0.0f) { + if (!line_draws_.empty()) { gl_->glUseProgram(program_ln_); gl_->glUniformMatrix4fv(u_ln_view_proj_, 1, GL_FALSE, view_proj); gl_->glUniform2f(u_ln_screen_size_, float(pixel_w), float(pixel_h)); - gl_->glUniform1f(u_ln_half_width_, lines_.line_width * 0.5f); - gl_->glUniform1f(u_ln_stroke_extra_, lines_.stroke_extra); - gl_->glUniform4fv(u_ln_inner_color_, 1, lines_.inner_color); - gl_->glUniform4fv(u_ln_stroke_color_, 1, lines_.stroke_color); - gl_->glBindVertexArray(lines_.vao); - gl_->glDrawArrays(GL_TRIANGLES, 0, lines_.vertex_count); + gl_->glBindVertexArray(vao_lines_); + for (const auto& dc : line_draws_) { + if (dc.count == 0 || dc.color[3] <= 0.0f) continue; + gl_->glUniform1f(u_ln_half_width_, dc.line_width * 0.5f); + gl_->glUniform1f(u_ln_stroke_extra_, dc.stroke_extra); + gl_->glUniform4fv(u_ln_inner_color_, 1, dc.color); + gl_->glUniform4fv(u_ln_stroke_color_, 1, dc.stroke_color); + gl_->glUniform1f(u_ln_dash_period_, dc.dash_period_px); + gl_->glUniform1f(u_ln_dash_on_ratio_, dc.dash_on_ratio); + gl_->glDrawArrays(GL_TRIANGLES, dc.first, dc.count); + } } if (points_.vertex_count > 0 && points_.inner_color[3] > 0.0f) { // Inner-radius ratio in [0, 1]: how much of the sprite is the diff --git a/src/ifcviewer/OverlayRenderer.h b/src/ifcviewer/OverlayRenderer.h index 0f2897c6e9..d3a0665839 100644 --- a/src/ifcviewer/OverlayRenderer.h +++ b/src/ifcviewer/OverlayRenderer.h @@ -44,19 +44,21 @@ public: void setHighlightTriangles(const std::vector& world_xyz, float r, float g, float b, float a); - // Replace the overlay-line list (3 floats per vertex, 2 verts per - // segment, world space). - // - // When stroke_a > 0, every segment is rendered twice — first a wider - // (line_width + 2*stroke_extra) stroke pass, then the inner line_width - // pass. Most desktop GL drivers clamp glLineWidth at ~1, so the - // stroke pass on lines may visually collapse onto the inner; reliable - // two-tone outlining will need a screen-space-quad thick-line shader. - void setOverlayLines(const std::vector& world_xyz, - float r, float g, float b, float a, - float line_width, - float stroke_r, float stroke_g, float stroke_b, float stroke_a, - float stroke_extra); + // One stylistic group of line segments rendered through the + // outlined / optionally-dashed line shader. Multiple groups in a + // single setOverlayLines call let the caller mix solid + dashed + + // axis-coloured legs in one frame (e.g. the length tool's white + // total line + RGB XYZ stair-step + dashed perpendicular). + struct LineGroup { + std::vector world_xyz; // 6 floats per segment (a, b) + float color[4] = {1, 1, 1, 1}; // inner color + float stroke_color[4] = {0, 0, 0, 1}; // outline (0 alpha = no outline) + float line_width = 1.5f; // pixels (inner) + float stroke_extra = 0.5f; // pixels per side outside inner + float dash_period_px = 0.0f; // 0 = solid; else screen-space dash period + float dash_on_ratio = 0.6f; // [0..1], used only when period > 0 + }; + void setOverlayLines(const std::vector& groups); // Replace the overlay-point list (3 floats per point, world space). // `pixel_size` is the inner-dot diameter in physical pixels. @@ -118,23 +120,19 @@ private: float stroke_extra = 0.0f; }; - // Line bundle: each input segment is CPU-expanded into 6 vertices - // (a quad as 2 triangles), each carrying both endpoints and a - // (side, along) corner index. The vertex shader projects to screen, - // computes the screen-space perpendicular, and offsets accordingly; - // the fragment shader uses the interpolated signed perpendicular - // distance to discard outside the half-width and to pick inner vs - // stroke color. Result: real outlined lines independent of the - // driver's glLineWidth clamp. - struct LineBundle { - GLuint vao = 0; - GLuint vbo = 0; - size_t vbo_capacity = 0; - GLsizei vertex_count = 0; - float inner_color[4] = {0, 0, 0, 0}; - float stroke_color[4] = {0, 0, 0, 0}; - float line_width = 1.0f; - float stroke_extra = 0.0f; + // Per-group draw-call record. setOverlayLines populates one of these + // per LineGroup, with `first` indexing into a shared expanded-vertex + // VBO. At render time we iterate them, set per-group uniforms, and + // issue one glDrawArrays each. + struct LineDrawCall { + float color[4] = {1, 1, 1, 1}; + float stroke_color[4] = {0, 0, 0, 0}; + float line_width = 1.5f; + float stroke_extra = 0.5f; + float dash_period_px = 0.0f; + float dash_on_ratio = 0.6f; + GLint first = 0; + GLsizei count = 0; }; QOpenGLFunctions_4_5_Core* gl_ = nullptr; @@ -160,6 +158,8 @@ private: GLint u_ln_stroke_extra_ = -1; GLint u_ln_inner_color_ = -1; GLint u_ln_stroke_color_ = -1; + GLint u_ln_dash_period_ = -1; + GLint u_ln_dash_on_ratio_ = -1; // Screen-space rect program (label + HUD backgrounds). Vertex // attribute is vec2 NDC; fragment outputs a uniform color. Drawn @@ -172,7 +172,13 @@ private: TriBundle triangles_; PointBundle points_; - LineBundle lines_; + + // Lines: one shared VAO/VBO holding the concatenated expanded + // vertices of every group; line_draws_ records each group's slice. + GLuint vao_lines_ = 0; + GLuint vbo_lines_ = 0; + size_t vbo_lines_capacity_ = 0; + std::vector line_draws_; std::vector