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

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

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

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

Dependencies layered in:

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-01 08:44:12 +10:00
parent 8761f9ac46
commit 699f22b502
4 changed files with 1197 additions and 10 deletions
+35 -2
View File
@@ -326,14 +326,39 @@ public:
};
bool pickMeshLocalAt(int x, int y, MeshLocalPick& out);
// Resolve a mesh-local point to a (placement-applied) global frame.
// Matches GL ViewportWindow::meshLocalToGlobal's shape so the Length
// tool's ENH readout ports unchanged. The wgpu viewer doesn't carry
// per-model CoordinateOperation yet, so this currently outputs
// placement_transformation · mesh_local (i.e. the IFC's own world
// coords pre-georeferencing); ENH and IFC-world coincide for the
// non-federated case the minimal viewer handles today.
bool meshLocalToGlobal(uint32_t object_id, const float mesh_local[3],
double global_out[3]) const;
// CPU world-space raycast. Brute-force: per-instance world-AABB
// reject, then ray-into-mesh-local + Möller-Trumbore against the
// CPU mesh shadow. `dir` must be a unit vector — distance is the
// ray's t parameter, which equals world distance only at |dir|=1.
// Used by the Length tool's 1-point laser-measure overlay to find
// the ceiling/floor counterpart of a horizontal-surface click.
struct RaycastHit {
uint32_t object_id = 0;
float distance = 0.0f;
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
};
bool raycast(const float origin[3], const float dir[3], RaycastHit& out) const;
// Measurement tools. Mirrors GL ViewportWindow::ToolMode. Volume is
// selection-driven (LMB / marquee). Area is click-to-accumulate:
// each LMB picks a triangle and either adds or removes its
// coplanar patch via BFS over shared edges; Alt+LMB skips the BFS.
// V/A toggle, Esc exits.
// V/A/L toggle, Esc exits. Length consumes Backspace too for
// remove-last-point semantics.
// NoTool (not None) because X11/X.h #define's None as 0L; including
// it transitively via Qt's xcb back-end breaks any enum named None.
enum class ToolMode { NoTool, Volume, Area };
enum class ToolMode { NoTool, Volume, Area, Length };
ToolMode toolMode() const { return tool_mode_; }
void setToolMode(ToolMode m);
@@ -539,6 +564,14 @@ private:
void onAreaPick(int x_phys, int y_phys, bool alt);
void updateAreaHud();
// Length tool state lives in WgpuLengthMeasurement. Same lifecycle
// pattern: lazily constructed on first L press, cleared on tool
// exit, click handler routes LMB through onLengthPick + Backspace
// through onLengthBackspace.
std::unique_ptr<class WgpuLengthMeasurement> length_tool_;
void onLengthPick(int x_phys, int y_phys, bool alt);
void onLengthBackspace();
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
// pulled from the same visible_draws / instances buffers as the main
// pass — pick fragment outputs the instance's object_id. The pick