Habilitou hover em modo geral

X-Lovable-Edit-ID: edt-6c00009c-acbc-45f1-b8c4-3fdd53f191fb
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-21 13:45:56 +00:00
3 changed files with 264 additions and 46 deletions
+133 -38
View File
@@ -4,7 +4,7 @@ import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore'; import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure'; import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
interface ModelViewerProps { interface ModelViewerProps {
url?: string; // legacy, ignored — uses store.models url?: string; // legacy, ignored — uses store.models
@@ -228,11 +228,19 @@ function useLabelDistanceFactor(position: [number, number, number]): number {
return ref.current; return ref.current;
} }
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary'; fixed?: boolean }) { function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const dfDynamic = useLabelDistanceFactor(position); const dfDynamic = useLabelDistanceFactor(position);
const df = fixed ? dfDynamic : 1.5; const df = fixed ? dfDynamic : 1.5;
const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60'; const borderClass = variant === 'success'
const textClass = variant === 'success' ? 'text-success' : 'text-primary'; ? 'border-success/60'
: variant === 'accent'
? 'border-amber-400/70'
: 'border-primary/60';
const textClass = variant === 'success'
? 'text-success'
: variant === 'accent'
? 'text-amber-400'
: 'text-primary';
return ( return (
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}> <Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}> <div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
@@ -261,15 +269,16 @@ function MeasurementOverlay() {
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} /> <SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
)} )}
{/* Hover auto-detect tooltip */} {/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
{hoverInfo && ( {hoverInfo && (
<MeasurementLabel <MeasurementLabel
position={hoverInfo.position} position={hoverInfo.position}
text={`${hoverInfo.type === 'hole' ? '⌀ ' : ''}${hoverInfo.value.toFixed(1)} mm`} text={hoverInfo.type === 'hole'
variant="primary" ? `Ø ${hoverInfo.value.toFixed(1)}`
: `${hoverInfo.value.toFixed(1)} mm`}
variant={hoverInfo.type === 'hole' ? 'accent' : 'primary'}
fixed fixed
/> />
)} )}
{/* Pending first point */} {/* Pending first point */}
@@ -287,6 +296,26 @@ function MeasurementOverlay() {
(a[2] + b[2]) / 2, (a[2] + b[2]) / 2,
]; ];
if (m.kind === 'hole') {
return (
<group key={m.id}>
<PointMarker position={a} color="#f59e0b" />
<MeasurementLabel position={a} text={m.label ?? `Ø ${m.distanceMM.toFixed(1)}`} variant="accent" />
</group>
);
}
if (m.kind === 'edge') {
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={m.label ?? `${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
return ( return (
<group key={m.id}> <group key={m.id}>
<PointMarker position={a} color="#22c55e" /> <PointMarker position={a} color="#22c55e" />
@@ -333,6 +362,15 @@ function SmartSnapHandler() {
return; return;
} }
const st = useModelStore.getState();
// Highest priority: a live hover label pointing at a hole → snap to its center
if (st.hoverInfo?.type === 'hole') {
const [hx, hy, hz] = st.hoverInfo.position;
setSnapPoint({ x: hx, y: hy, z: hz });
return;
}
raycaster.setFromCamera(mouse, camera); raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true); const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => { const hit = intersects.find(i => {
@@ -344,11 +382,39 @@ function SmartSnapHandler() {
return obj instanceof THREE.Mesh; return obj instanceof THREE.Mesh;
}); });
const canvas = gl.domElement;
const canvasW = canvas.clientWidth;
const canvasH = canvas.clientHeight;
// Cursor in screen px (mouse is in NDC [-1, 1])
const cursorX = (mouse.x * 0.5 + 0.5) * canvasW;
const cursorY = (-mouse.y * 0.5 + 0.5) * canvasH;
// Snap to centers of saved hole measurements within 20 px on screen
const holeSnapPxThreshold = 20;
let bestHoleDist = Infinity;
let bestHole: { x: number; y: number; z: number } | null = null;
const proj = new THREE.Vector3();
for (const m of st.measurements) {
if (m.kind !== 'hole') continue;
proj.set(m.pointA.x, m.pointA.y, m.pointA.z).project(camera);
const sx = (proj.x * 0.5 + 0.5) * canvasW;
const sy = (-proj.y * 0.5 + 0.5) * canvasH;
const d = Math.hypot(sx - cursorX, sy - cursorY);
if (d < bestHoleDist && d <= holeSnapPxThreshold) {
bestHoleDist = d;
bestHole = { x: m.pointA.x, y: m.pointA.y, z: m.pointA.z };
}
}
if (bestHole) {
setSnapPoint(bestHole);
return;
}
if (hit && hit.object instanceof THREE.Mesh) { if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex( const snap = findNearestVertex(
hit.object, hit.point, camera, hit.object, hit.point, camera,
{ width: canvas.clientWidth, height: canvas.clientHeight }, { width: canvasW, height: canvasH },
10 10
); );
if (snap) { if (snap) {
@@ -364,22 +430,24 @@ function SmartSnapHandler() {
return null; return null;
} }
/** Hover detector for auto-detect hole diameter and edge length */ /** Hover detector for auto-detect hole diameter and edge length.
* Runs in BOTH view mode and measure mode. Outside measure mode the label
* auto-hides after 4s; in measure mode it persists until the user clicks
* (registering it as a saved measurement) or moves the mouse significantly. */
function HoverDetector() { function HoverDetector() {
const { camera, scene, gl } = useThree(); const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setHoverInfo = useModelStore((s) => s.setHoverInfo); const setHoverInfo = useModelStore((s) => s.setHoverInfo);
const raycaster = useMemo(() => new THREE.Raycaster(), []); const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []); const mouse = useMemo(() => new THREE.Vector2(), []);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHitKey = useRef(''); const lastHitKey = useRef('');
useEffect(() => { useEffect(() => {
if (measureMode) { const clearTimers = () => {
setHoverInfo(null); if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
if (hoverTimer.current) clearTimeout(hoverTimer.current); if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
return; };
}
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect(); const rect = gl.domElement.getBoundingClientRect();
@@ -400,24 +468,31 @@ function HoverDetector() {
if (!hit || !(hit.object instanceof THREE.Mesh)) { if (!hit || !(hit.object instanceof THREE.Mesh)) {
lastHitKey.current = ''; lastHitKey.current = '';
setHoverInfo(null); setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current); clearTimers();
return; return;
} }
// Stability check same approximate position for debounce // Stability check same approximate position for debounce (~3 mm)
const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`; const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
if (key === lastHitKey.current) return; // timer already running if (key === lastHitKey.current) return; // timer already running
lastHitKey.current = key; lastHitKey.current = key;
setHoverInfo(null); setHoverInfo(null);
clearTimers();
if (hoverTimer.current) clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => { hoverTimer.current = setTimeout(() => {
if (!(hit.object instanceof THREE.Mesh)) return; if (!(hit.object instanceof THREE.Mesh)) return;
const canvas = gl.domElement; const canvas = gl.domElement;
const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight }; const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
// Try hole detection first // 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
if (hit.faceIndex !== undefined) { const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
if (circ) {
setHoverInfo({
type: 'hole',
position: [circ.center.x, circ.center.y, circ.center.z],
value: circ.diameterMM,
});
} else if (hit.faceIndex !== undefined && (() => {
const hole = detectHoleAtFace(hit.object, hit.faceIndex); const hole = detectHoleAtFace(hit.object, hit.faceIndex);
if (hole) { if (hole) {
setHoverInfo({ setHoverInfo({
@@ -425,29 +500,42 @@ function HoverDetector() {
position: [hole.center.x, hole.center.y, hole.center.z], position: [hole.center.x, hole.center.y, hole.center.z],
value: hole.diameterMM, value: hole.diameterMM,
}); });
return true;
}
return false;
})()) {
// handled inside IIFE
} else {
// 3) Edge length
const edge = findNearestEdgeSegment(hit.object, hit.point, camera, canvasSize, 15);
if (edge) {
setHoverInfo({
type: 'edge',
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
value: edge.lengthMM,
endpoints: {
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
},
});
} else {
setHoverInfo(null);
return; return;
} }
} }
// Try edge detection // Outside measure mode, auto-hide after 4 s
const edge = findNearestEdgeSegment(hit.object, hit.point, camera, canvasSize, 15); const inMeasure = useModelStore.getState().measureMode;
if (edge) { if (!inMeasure) {
setHoverInfo({ hideTimer.current = setTimeout(() => setHoverInfo(null), 4000);
type: 'edge',
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
value: edge.lengthMM,
});
return;
} }
}, 2000);
setHoverInfo(null);
}, 1000);
}; };
const onLeave = () => { const onLeave = () => {
lastHitKey.current = ''; lastHitKey.current = '';
setHoverInfo(null); setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current); clearTimers();
}; };
gl.domElement.addEventListener('pointermove', onMove); gl.domElement.addEventListener('pointermove', onMove);
@@ -455,9 +543,9 @@ function HoverDetector() {
return () => { return () => {
gl.domElement.removeEventListener('pointermove', onMove); gl.domElement.removeEventListener('pointermove', onMove);
gl.domElement.removeEventListener('pointerleave', onLeave); gl.domElement.removeEventListener('pointerleave', onLeave);
if (hoverTimer.current) clearTimeout(hoverTimer.current); clearTimers();
}; };
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]); }, [camera, scene, gl, raycaster, mouse, setHoverInfo]);
return null; return null;
} }
@@ -499,7 +587,14 @@ function MeasureClickHandler() {
const st = useModelStore.getState(); const st = useModelStore.getState();
if (!st.measureMode) return; if (!st.measureMode) return;
// Prefer snap // If a hover label is currently shown (hole diameter or edge length),
// a click registers it as a saved measurement.
if (st.hoverInfo) {
st.registerHoverMeasurement(st.hoverInfo);
return;
}
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
if (st.snapPoint) { if (st.snapPoint) {
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z }); st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
return; return;
+96 -7
View File
@@ -189,7 +189,7 @@ export function detectHoleAtFace(
} }
/** Kasa circle fit: returns { cx, cy, radius } in the input coordinate system */ /** Kasa circle fit: returns { cx, cy, radius } in the input coordinate system */
function circleFitKasa(points: { u: number; v: number }[]): { cx: number; cy: number; radius: number } | null { export function circleFitKasa(points: { u: number; v: number }[]): { cx: number; cy: number; radius: number } | null {
const n = points.length; const n = points.length;
if (n < 3) return null; if (n < 3) return null;
@@ -243,7 +243,7 @@ export function findNearestEdgeSegment(
camera: THREE.Camera, camera: THREE.Camera,
canvasSize: { width: number; height: number }, canvasSize: { width: number; height: number },
thresholdPx: number = 12 thresholdPx: number = 12
): { midpoint: THREE.Vector3; lengthMM: number } | null { ): { midpoint: THREE.Vector3; lengthMM: number; a: THREE.Vector3; b: THREE.Vector3 } | null {
// Look for edge line segments children // Look for edge line segments children
let edgeLines: THREE.LineSegments | null = null; let edgeLines: THREE.LineSegments | null = null;
mesh.children.forEach(c => { mesh.children.forEach(c => {
@@ -263,9 +263,11 @@ export function findNearestEdgeSegment(
const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height; const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld; const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
let bestDist = Infinity; let bestDist = Infinity;
let bestMid: THREE.Vector3 | null = null; let bestMid: THREE.Vector3 | null = null;
let bestA: THREE.Vector3 | null = null;
let bestB: THREE.Vector3 | null = null;
let bestLen = 0; let bestLen = 0;
const segCount = posAttr.count / 2; const segCount = posAttr.count / 2;
@@ -278,7 +280,6 @@ export function findNearestEdgeSegment(
a.fromBufferAttribute(posAttr, i * 2).applyMatrix4(matrix); a.fromBufferAttribute(posAttr, i * 2).applyMatrix4(matrix);
b.fromBufferAttribute(posAttr, i * 2 + 1).applyMatrix4(matrix); b.fromBufferAttribute(posAttr, i * 2 + 1).applyMatrix4(matrix);
// Project to screen
pa.copy(a).project(camera); pa.copy(a).project(camera);
pb.copy(b).project(camera); pb.copy(b).project(camera);
const ax = (pa.x * 0.5 + 0.5) * canvasSize.width; const ax = (pa.x * 0.5 + 0.5) * canvasSize.width;
@@ -286,24 +287,112 @@ export function findNearestEdgeSegment(
const bx = (pb.x * 0.5 + 0.5) * canvasSize.width; const bx = (pb.x * 0.5 + 0.5) * canvasSize.width;
const by = (-pb.y * 0.5 + 0.5) * canvasSize.height; const by = (-pb.y * 0.5 + 0.5) * canvasSize.height;
// Distance from point to line segment in screen space
const dist = pointToSegmentDist(hitX, hitY, ax, ay, bx, by); const dist = pointToSegmentDist(hitX, hitY, ax, ay, bx, by);
if (dist < bestDist) { if (dist < bestDist) {
bestDist = dist; bestDist = dist;
bestMid = a.clone().add(b).multiplyScalar(0.5); bestMid = a.clone().add(b).multiplyScalar(0.5);
bestA = a.clone();
bestB = b.clone();
bestLen = a.distanceTo(b) * 1000; // mm bestLen = a.distanceTo(b) * 1000; // mm
} }
} }
if (!edgeLines) geo.dispose(); if (!edgeLines) geo.dispose();
if (bestDist <= thresholdPx && bestMid && bestLen > 0.5) { if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) {
return { midpoint: bestMid, lengthMM: bestLen }; return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB };
} }
return null; return null;
} }
/**
* Detect a circular hole by fitting a circle through edge vertices that lie
* within `radiusPx` (screen space) around `worldPoint`. Returns center + diameter.
*/
export function detectCircularEdgeAtPoint(
mesh: THREE.Mesh,
worldPoint: THREE.Vector3,
camera: THREE.Camera,
canvasSize: { width: number; height: number },
radiusPx: number = 60
): { center: THREE.Vector3; diameterMM: number } | null {
let edgeLines: THREE.LineSegments | null = null;
mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
});
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const hitScreen = worldPoint.clone().project(camera);
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
const verts: THREE.Vector3[] = [];
const seen = new Set<string>();
const tmp = new THREE.Vector3();
const proj = new THREE.Vector3();
const count = posAttr.count;
for (let i = 0; i < count; i++) {
tmp.fromBufferAttribute(posAttr, i).applyMatrix4(matrix);
proj.copy(tmp).project(camera);
const sx = (proj.x * 0.5 + 0.5) * canvasSize.width;
const sy = (-proj.y * 0.5 + 0.5) * canvasSize.height;
if (Math.hypot(sx - hitX, sy - hitY) > radiusPx) continue;
const key = `${tmp.x.toFixed(5)},${tmp.y.toFixed(5)},${tmp.z.toFixed(5)}`;
if (seen.has(key)) continue;
seen.add(key);
verts.push(tmp.clone());
}
if (!edgeLines) geo.dispose();
if (verts.length < 6) return null;
// Compute centroid + best-fit plane normal via covariance
const centroid = new THREE.Vector3();
verts.forEach(v => centroid.add(v));
centroid.divideScalar(verts.length);
let xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
for (const v of verts) {
const dx = v.x - centroid.x, dy = v.y - centroid.y, dz = v.z - centroid.z;
xx += dx * dx; xy += dx * dy; xz += dx * dz;
yy += dy * dy; yz += dy * dz; zz += dz * dz;
}
// Normal = eigenvector with smallest eigenvalue. Approximate using cross of two
// axes with largest variance.
const axisA = new THREE.Vector3(xx, xy, xz).normalize();
const axisB = new THREE.Vector3(xy, yy, yz).normalize();
const normal = new THREE.Vector3().crossVectors(axisA, axisB);
if (normal.lengthSq() < 1e-8) return null;
normal.normalize();
// Build basis on plane
const basisU = new THREE.Vector3();
if (Math.abs(normal.x) < 0.9) basisU.crossVectors(normal, new THREE.Vector3(1, 0, 0)).normalize();
else basisU.crossVectors(normal, new THREE.Vector3(0, 1, 0)).normalize();
const basisV = new THREE.Vector3().crossVectors(normal, basisU).normalize();
const points2D = verts.map(v => {
const rel = v.clone().sub(centroid);
return { u: rel.dot(basisU), v: rel.dot(basisV) };
});
const fit = circleFitKasa(points2D);
if (!fit) return null;
const diameterMM = fit.radius * 2 * 1000;
if (diameterMM < 2 || diameterMM > 500) return null;
const center3D = centroid.clone()
.add(basisU.clone().multiplyScalar(fit.cx))
.add(basisV.clone().multiplyScalar(fit.cy));
return { center: center3D, diameterMM };
}
function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
const dx = bx - ax; const dx = bx - ax;
const dy = by - ay; const dy = by - ay;
+35 -1
View File
@@ -114,12 +114,18 @@ export interface Measurement {
pointA: MeasurePoint; pointA: MeasurePoint;
pointB: MeasurePoint; pointB: MeasurePoint;
distanceMM: number; distanceMM: number;
/** 'distance' = point-to-point (default), 'hole' = registered diameter at center, 'edge' = registered edge length */
kind?: 'distance' | 'hole' | 'edge';
/** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */
label?: string;
} }
export interface HoverInfo { export interface HoverInfo {
type: 'hole' | 'edge'; type: 'hole' | 'edge';
position: [number, number, number]; position: [number, number, number];
value: number; // mm value: number; // mm (diameter or length)
/** For edges: the two endpoints in world coords. */
endpoints?: { a: MeasurePoint; b: MeasurePoint };
} }
export interface ChecklistItem { export interface ChecklistItem {
@@ -221,6 +227,8 @@ interface ModelStore {
clearMeasurements: () => void; clearMeasurements: () => void;
removeMeasurement: (id: string) => void; removeMeasurement: (id: string) => void;
undoLastMeasurement: () => void; undoLastMeasurement: () => void;
/** Register the current HoverInfo (hole diameter or edge length) as a saved measurement. */
registerHoverMeasurement: (info: HoverInfo) => void;
compareMode: boolean; compareMode: boolean;
@@ -485,6 +493,32 @@ export const useModelStore = create<ModelStore>((set, get) => ({
} }
return { measurements: state.measurements.slice(0, -1) }; return { measurements: state.measurements.slice(0, -1) };
}), }),
registerHoverMeasurement: (info) => set((state) => {
let measurement: Measurement;
if (info.type === 'hole') {
const c: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
measurement = {
id: `m_${Date.now()}`,
pointA: c,
pointB: c,
distanceMM: info.value,
kind: 'hole',
label: `Ø ${info.value.toFixed(1)}`,
};
} else {
const a = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] };
const b = info.endpoints?.b ?? a;
measurement = {
id: `m_${Date.now()}`,
pointA: a,
pointB: b,
distanceMM: info.value,
kind: 'edge',
label: `${info.value.toFixed(1)} mm`,
};
}
return { measurements: [...state.measurements, measurement], hoverInfo: null };
}),
compareMode: false, compareMode: false,