diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 41e04ee..68c308b 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -4,7 +4,7 @@ import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; import { Loader2 } from 'lucide-react'; import * as THREE from 'three'; import { useModelStore, type SceneModel } from '@/stores/useModelStore'; -import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure'; +import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure'; interface ModelViewerProps { url?: string; // legacy, ignored — uses store.models @@ -228,11 +228,19 @@ function useLabelDistanceFactor(position: [number, number, number]): number { 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 df = fixed ? dfDynamic : 1.5; - const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60'; - const textClass = variant === 'success' ? 'text-success' : 'text-primary'; + const borderClass = variant === 'success' + ? '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 (
@@ -261,15 +269,16 @@ function MeasurementOverlay() { )} - {/* Hover auto-detect tooltip */} + {/* Hover auto-detect tooltip (works both inside and outside measure mode) */} {hoverInfo && ( - )} {/* Pending first point */} @@ -287,6 +296,26 @@ function MeasurementOverlay() { (a[2] + b[2]) / 2, ]; + if (m.kind === 'hole') { + return ( + + + + + ); + } + + if (m.kind === 'edge') { + return ( + + + + + + + ); + } + return ( @@ -333,6 +362,15 @@ function SmartSnapHandler() { 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); const intersects = raycaster.intersectObjects(scene.children, true); const hit = intersects.find(i => { @@ -344,11 +382,39 @@ function SmartSnapHandler() { 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) { - const canvas = gl.domElement; const snap = findNearestVertex( hit.object, hit.point, camera, - { width: canvas.clientWidth, height: canvas.clientHeight }, + { width: canvasW, height: canvasH }, 10 ); if (snap) { @@ -364,22 +430,24 @@ function SmartSnapHandler() { 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() { const { camera, scene, gl } = useThree(); - const measureMode = useModelStore((s) => s.measureMode); const setHoverInfo = useModelStore((s) => s.setHoverInfo); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); const hoverTimer = useRef | null>(null); + const hideTimer = useRef | null>(null); const lastHitKey = useRef(''); useEffect(() => { - if (measureMode) { - setHoverInfo(null); - if (hoverTimer.current) clearTimeout(hoverTimer.current); - return; - } + const clearTimers = () => { + if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; } + if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; } + }; const onMove = (e: MouseEvent) => { const rect = gl.domElement.getBoundingClientRect(); @@ -400,24 +468,31 @@ function HoverDetector() { if (!hit || !(hit.object instanceof THREE.Mesh)) { lastHitKey.current = ''; setHoverInfo(null); - if (hoverTimer.current) clearTimeout(hoverTimer.current); + clearTimers(); 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)}`; if (key === lastHitKey.current) return; // timer already running lastHitKey.current = key; setHoverInfo(null); + clearTimers(); - if (hoverTimer.current) clearTimeout(hoverTimer.current); hoverTimer.current = setTimeout(() => { if (!(hit.object instanceof THREE.Mesh)) return; const canvas = gl.domElement; const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight }; - // Try hole detection first - if (hit.faceIndex !== undefined) { + // 1) Try circle fit from edge vertices around the hit (catches holes on flat faces) + 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); if (hole) { setHoverInfo({ @@ -425,29 +500,42 @@ function HoverDetector() { position: [hole.center.x, hole.center.y, hole.center.z], 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; } } - // Try edge detection - 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, - }); - return; + // Outside measure mode, auto-hide after 4 s + const inMeasure = useModelStore.getState().measureMode; + if (!inMeasure) { + hideTimer.current = setTimeout(() => setHoverInfo(null), 4000); } - - setHoverInfo(null); - }, 1000); + }, 2000); }; const onLeave = () => { lastHitKey.current = ''; setHoverInfo(null); - if (hoverTimer.current) clearTimeout(hoverTimer.current); + clearTimers(); }; gl.domElement.addEventListener('pointermove', onMove); @@ -455,9 +543,9 @@ function HoverDetector() { return () => { gl.domElement.removeEventListener('pointermove', onMove); 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; } @@ -499,7 +587,14 @@ function MeasureClickHandler() { const st = useModelStore.getState(); 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) { st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z }); return; diff --git a/src/components/three/SmartMeasure.ts b/src/components/three/SmartMeasure.ts index a7075ba..a7441ef 100644 --- a/src/components/three/SmartMeasure.ts +++ b/src/components/three/SmartMeasure.ts @@ -189,7 +189,7 @@ export function detectHoleAtFace( } /** 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; if (n < 3) return null; @@ -243,7 +243,7 @@ export function findNearestEdgeSegment( camera: THREE.Camera, canvasSize: { width: number; height: number }, 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 let edgeLines: THREE.LineSegments | null = null; mesh.children.forEach(c => { @@ -263,9 +263,11 @@ export function findNearestEdgeSegment( const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height; const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld; - + let bestDist = Infinity; let bestMid: THREE.Vector3 | null = null; + let bestA: THREE.Vector3 | null = null; + let bestB: THREE.Vector3 | null = null; let bestLen = 0; const segCount = posAttr.count / 2; @@ -278,7 +280,6 @@ export function findNearestEdgeSegment( a.fromBufferAttribute(posAttr, i * 2).applyMatrix4(matrix); b.fromBufferAttribute(posAttr, i * 2 + 1).applyMatrix4(matrix); - // Project to screen pa.copy(a).project(camera); pb.copy(b).project(camera); 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 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); if (dist < bestDist) { bestDist = dist; bestMid = a.clone().add(b).multiplyScalar(0.5); + bestA = a.clone(); + bestB = b.clone(); bestLen = a.distanceTo(b) * 1000; // mm } } if (!edgeLines) geo.dispose(); - if (bestDist <= thresholdPx && bestMid && bestLen > 0.5) { - return { midpoint: bestMid, lengthMM: bestLen }; + if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) { + return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB }; } 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(); + 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 { const dx = bx - ax; const dy = by - ay; diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 4d978f0..00cbfbf 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -114,12 +114,18 @@ export interface Measurement { pointA: MeasurePoint; pointB: MeasurePoint; 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 { type: 'hole' | 'edge'; 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 { @@ -221,6 +227,8 @@ interface ModelStore { clearMeasurements: () => void; removeMeasurement: (id: string) => void; undoLastMeasurement: () => void; + /** Register the current HoverInfo (hole diameter or edge length) as a saved measurement. */ + registerHoverMeasurement: (info: HoverInfo) => void; compareMode: boolean; @@ -485,6 +493,32 @@ export const useModelStore = create((set, get) => ({ } 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,