diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 3e0bd59..4832f93 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -4,6 +4,7 @@ import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; import { Loader2 } from 'lucide-react'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; +import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure'; interface ModelViewerProps { url: string; @@ -140,13 +141,43 @@ function PointMarker({ position, color = '#e8a838' }: { position: [number, numbe ); } -/** Renders all measurements and pending points */ +/** Snap ring indicator */ +function SnapRing({ position }: { position: [number, number, number] }) { + return ( + + + + + ); +} + +/** Renders all measurements, snap point, and hover info */ function MeasurementOverlay() { const measurements = useModelStore((s) => s.measurements); const measurePoints = useModelStore((s) => s.measurePoints); + const snapPoint = useModelStore((s) => s.snapPoint); + const hoverInfo = useModelStore((s) => s.hoverInfo); + const measureMode = useModelStore((s) => s.measureMode); return ( <> + {/* Snap indicator */} + {measureMode && snapPoint && ( + + )} + + {/* Hover auto-detect tooltip */} + {hoverInfo && ( + +
+ + {hoverInfo.type === 'hole' ? '⌀ ' : ''} + {hoverInfo.value.toFixed(1)} mm + +
+ + )} + {/* Pending first point */} {measurePoints.length === 1 && ( @@ -185,17 +216,180 @@ function MeasurementOverlay() { ); } -/** Raycasting click handler for measurement mode */ +/** Smart vertex snap on pointer move */ +function SmartSnapHandler() { + const { camera, scene, gl } = useThree(); + const measureMode = useModelStore((s) => s.measureMode); + const setSnapPoint = useModelStore((s) => s.setSnapPoint); + const raycaster = useMemo(() => new THREE.Raycaster(), []); + const mouse = useMemo(() => new THREE.Vector2(), []); + const mouseRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const onMove = (e: MouseEvent) => { + const rect = gl.domElement.getBoundingClientRect(); + mouseRef.current.x = e.clientX - rect.left; + mouseRef.current.y = e.clientY - rect.top; + mouse.x = (mouseRef.current.x / rect.width) * 2 - 1; + mouse.y = -(mouseRef.current.y / rect.height) * 2 + 1; + }; + gl.domElement.addEventListener('pointermove', onMove); + return () => gl.domElement.removeEventListener('pointermove', onMove); + }, [gl, mouse]); + + useFrame(() => { + if (!measureMode) { + setSnapPoint(null); + return; + } + + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(scene.children, true); + const hit = intersects.find(i => { + const obj = i.object; + if (obj instanceof THREE.GridHelper) return false; + if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false; + if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false; + if (obj.userData.__edgeLine) return false; + return obj instanceof THREE.Mesh; + }); + + 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 }, + 10 + ); + if (snap) { + setSnapPoint({ x: snap.x, y: snap.y, z: snap.z }); + } else { + setSnapPoint(null); + } + } else { + setSnapPoint(null); + } + }); + + return null; +} + +/** Hover detector for auto-detect hole diameter and edge length */ +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 lastHitKey = useRef(''); + + useEffect(() => { + if (measureMode) { + setHoverInfo(null); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + return; + } + + const onMove = (e: MouseEvent) => { + const rect = gl.domElement.getBoundingClientRect(); + mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(scene.children, true); + const hit = intersects.find(i => { + const obj = i.object; + if (obj instanceof THREE.GridHelper) return false; + if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false; + if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false; + if (obj.userData.__edgeLine) return false; + return obj instanceof THREE.Mesh; + }); + + if (!hit || !(hit.object instanceof THREE.Mesh)) { + lastHitKey.current = ''; + setHoverInfo(null); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + return; + } + + // Stability check – same approximate position for debounce + 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); + + 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) { + const hole = detectHoleAtFace(hit.object, hit.faceIndex); + if (hole) { + setHoverInfo({ + type: 'hole', + position: [hole.center.x, hole.center.y, hole.center.z], + value: hole.diameterMM, + }); + 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; + } + + setHoverInfo(null); + }, 1000); + }; + + const onLeave = () => { + lastHitKey.current = ''; + setHoverInfo(null); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + }; + + gl.domElement.addEventListener('pointermove', onMove); + gl.domElement.addEventListener('pointerleave', onLeave); + return () => { + gl.domElement.removeEventListener('pointermove', onMove); + gl.domElement.removeEventListener('pointerleave', onLeave); + if (hoverTimer.current) clearTimeout(hoverTimer.current); + }; + }, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]); + + return null; +} + +/** Raycasting click handler for measurement mode – uses snap point when available */ function MeasureClickHandler() { const { camera, scene, gl } = useThree(); const measureMode = useModelStore((s) => s.measureMode); const addMeasurePoint = useModelStore((s) => s.addMeasurePoint); + const snapPoint = useModelStore((s) => s.snapPoint); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); const handleClick = useCallback((event: MouseEvent) => { if (!measureMode) return; + // Use snap point if available + if (snapPoint) { + addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z }); + return; + } + const rect = gl.domElement.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; @@ -203,12 +397,12 @@ function MeasureClickHandler() { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); - // Filter out measurement markers and grid const hit = intersects.find(i => { const obj = i.object; if (obj instanceof THREE.GridHelper) return false; if (obj instanceof THREE.Mesh) { if (obj.geometry instanceof THREE.SphereGeometry) return false; + if (obj.geometry instanceof THREE.RingGeometry) return false; } return true; }); @@ -216,7 +410,7 @@ function MeasureClickHandler() { if (hit) { addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); } - }, [measureMode, addMeasurePoint, camera, scene, gl, raycaster, mouse]); + }, [measureMode, addMeasurePoint, snapPoint, camera, scene, gl, raycaster, mouse]); useEffect(() => { const canvas = gl.domElement; @@ -274,6 +468,8 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) { + + diff --git a/src/components/three/SmartMeasure.ts b/src/components/three/SmartMeasure.ts new file mode 100644 index 0000000..33b1792 --- /dev/null +++ b/src/components/three/SmartMeasure.ts @@ -0,0 +1,318 @@ +import * as THREE from 'three'; + +const _v = new THREE.Vector3(); +const _projected = new THREE.Vector3(); + +/** + * Find the nearest vertex in a mesh to a world-space point, + * returning it only if it's within `thresholdPx` pixels on screen. + */ +export function findNearestVertex( + mesh: THREE.Mesh, + worldPoint: THREE.Vector3, + camera: THREE.Camera, + canvasSize: { width: number; height: number }, + thresholdPx: number = 10 +): THREE.Vector3 | null { + const geo = mesh.geometry; + const posAttr = geo.attributes.position; + if (!posAttr) return null; + + // Project hit point to screen + 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; + + let bestDist = Infinity; + let bestVertex: THREE.Vector3 | null = null; + + for (let i = 0; i < posAttr.count; i++) { + _v.fromBufferAttribute(posAttr, i); + _v.applyMatrix4(mesh.matrixWorld); + + _projected.copy(_v).project(camera); + const sx = (_projected.x * 0.5 + 0.5) * canvasSize.width; + const sy = (-_projected.y * 0.5 + 0.5) * canvasSize.height; + + const dx = sx - hitX; + const dy = sy - hitY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < bestDist) { + bestDist = dist; + bestVertex = _v.clone(); + } + } + + if (bestDist <= thresholdPx && bestVertex) { + return bestVertex; + } + return null; +} + +/** + * Detect if the face under the cursor belongs to a cylindrical hole. + * Uses face normals around the hit to detect radial patterns → circle fit. + * Returns diameter in mm, or null if not a hole. + */ +export function detectHoleAtFace( + mesh: THREE.Mesh, + faceIndex: number +): { center: THREE.Vector3; diameterMM: number } | null { + const geo = mesh.geometry; + const posAttr = geo.attributes.position; + const normalAttr = geo.attributes.normal; + const indexAttr = geo.index; + + if (!posAttr || !normalAttr || !indexAttr) return null; + + // Get hit face normal + const i0 = indexAttr.getX(faceIndex * 3); + const i1 = indexAttr.getX(faceIndex * 3 + 1); + const i2 = indexAttr.getX(faceIndex * 3 + 2); + + const hitNormal = new THREE.Vector3(); + const n0 = new THREE.Vector3().fromBufferAttribute(normalAttr, i0); + const n1 = new THREE.Vector3().fromBufferAttribute(normalAttr, i1); + const n2 = new THREE.Vector3().fromBufferAttribute(normalAttr, i2); + hitNormal.addVectors(n0, n1).add(n2).normalize(); + + const hitCenter = new THREE.Vector3(); + const p0 = new THREE.Vector3().fromBufferAttribute(posAttr, i0); + const p1 = new THREE.Vector3().fromBufferAttribute(posAttr, i1); + const p2 = new THREE.Vector3().fromBufferAttribute(posAttr, i2); + hitCenter.addVectors(p0, p1).add(p2).divideScalar(3); + + // Collect neighboring faces with similar "cylindrical" normal pattern + // Cylindrical faces have normals perpendicular to the cylinder axis + // We look for faces whose normals are roughly perpendicular to each other + // but share a common axis (the hole axis) + + const faceCount = indexAttr.count / 3; + const cylinderVertices: THREE.Vector3[] = []; + const faceNormals: THREE.Vector3[] = []; + + // Threshold for proximity (local search) + const searchRadius = 0.05; // 50mm in model units + + for (let f = 0; f < faceCount; f++) { + const fi0 = indexAttr.getX(f * 3); + const fi1 = indexAttr.getX(f * 3 + 1); + const fi2 = indexAttr.getX(f * 3 + 2); + + const fp0 = new THREE.Vector3().fromBufferAttribute(posAttr, fi0); + const fp1 = new THREE.Vector3().fromBufferAttribute(posAttr, fi1); + const fp2 = new THREE.Vector3().fromBufferAttribute(posAttr, fi2); + + const fc = new THREE.Vector3().addVectors(fp0, fp1).add(fp2).divideScalar(3); + if (fc.distanceTo(hitCenter) > searchRadius) continue; + + const fn = new THREE.Vector3(); + const fn0 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi0); + const fn1 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi1); + const fn2 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi2); + fn.addVectors(fn0, fn1).add(fn2).normalize(); + + // For cylindrical surfaces, normals should be roughly perpendicular to hole axis + // and the dot product between hit normal and face normal reveals if they share curvature + const dot = Math.abs(hitNormal.dot(fn)); + // Cylindrical faces: normals vary (dot < 0.95) but aren't opposite (dot > -0.5) + if (dot < 0.98) { + cylinderVertices.push(fp0, fp1, fp2); + faceNormals.push(fn); + } + } + + if (cylinderVertices.length < 9) return null; // Need at least 3 faces + + // Try to find a common axis and fit a circle + // Estimate axis as cross product of two differing normals + let axis: THREE.Vector3 | null = null; + for (let i = 1; i < faceNormals.length; i++) { + const cross = new THREE.Vector3().crossVectors(faceNormals[0], faceNormals[i]); + if (cross.length() > 0.1) { + axis = cross.normalize(); + break; + } + } + if (!axis) return null; + + // Project vertices onto plane perpendicular to axis + // Use least-squares circle fit on 2D projections + const basisU = new THREE.Vector3(); + const basisV = new THREE.Vector3(); + + // Create orthonormal basis + if (Math.abs(axis.x) < 0.9) { + basisU.crossVectors(axis, new THREE.Vector3(1, 0, 0)).normalize(); + } else { + basisU.crossVectors(axis, new THREE.Vector3(0, 1, 0)).normalize(); + } + basisV.crossVectors(axis, basisU).normalize(); + + // Project unique vertices to 2D + const seen = new Set(); + const points2D: { u: number; v: number }[] = []; + + for (const vert of cylinderVertices) { + const key = `${vert.x.toFixed(6)},${vert.y.toFixed(6)},${vert.z.toFixed(6)}`; + if (seen.has(key)) continue; + seen.add(key); + + const rel = vert.clone().sub(hitCenter); + points2D.push({ + u: rel.dot(basisU), + v: rel.dot(basisV), + }); + } + + if (points2D.length < 4) return null; + + // Least-squares circle fit (Kasa method) + const result = circleFitKasa(points2D); + if (!result) return null; + + const radiusModel = result.radius; + const diameterMM = radiusModel * 2 * 1000; + + // Filter: reasonable hole sizes (2mm to 200mm diameter) + if (diameterMM < 2 || diameterMM > 200) return null; + + // Reconstruct 3D center + const center3D = hitCenter.clone() + .add(basisU.clone().multiplyScalar(result.cx)) + .add(basisV.clone().multiplyScalar(result.cy)); + + center3D.applyMatrix4(mesh.matrixWorld); + + return { center: center3D, diameterMM }; +} + +/** 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 { + const n = points.length; + if (n < 3) return null; + + let su = 0, sv = 0, suu = 0, svv = 0, suv = 0, suuu = 0, svvv = 0, suvv = 0, svuu = 0; + + for (const p of points) { + su += p.u; sv += p.v; + suu += p.u * p.u; svv += p.v * p.v; + suv += p.u * p.v; + suuu += p.u * p.u * p.u; svvv += p.v * p.v * p.v; + suvv += p.u * p.v * p.v; svuu += p.v * p.u * p.u; + } + + const A = n * suu - su * su; + const B = n * suv - su * sv; + const C = n * svv - sv * sv; + const D = 0.5 * (n * suuu + n * suvv - su * suu - su * svv); + const E = 0.5 * (n * svvv + n * svuu - sv * suu - sv * svv); + + const denom = A * C - B * B; + if (Math.abs(denom) < 1e-12) return null; + + const cx = (D * C - E * B) / denom; + const cy = (A * E - B * D) / denom; + + let r2sum = 0; + for (const p of points) { + r2sum += (p.u - cx) ** 2 + (p.v - cy) ** 2; + } + const radius = Math.sqrt(r2sum / n); + + // Check fit quality: standard deviation of radii should be small relative to radius + let variance = 0; + for (const p of points) { + const r = Math.sqrt((p.u - cx) ** 2 + (p.v - cy) ** 2); + variance += (r - radius) ** 2; + } + const stdDev = Math.sqrt(variance / n); + if (stdDev / radius > 0.15) return null; // Poor fit, not a circle + + return { cx, cy, radius }; +} + +/** + * Find nearest edge segment from EdgesGeometry data. + * Returns distance in mm or null. + */ +export function findNearestEdgeSegment( + mesh: THREE.Mesh, + worldPoint: THREE.Vector3, + camera: THREE.Camera, + canvasSize: { width: number; height: number }, + thresholdPx: number = 12 +): { midpoint: THREE.Vector3; lengthMM: number } | null { + // Look for edge line segments children + let edgeLines: THREE.LineSegments | null = null; + mesh.children.forEach(c => { + if (c.userData.__edgeLine && c instanceof THREE.LineSegments) { + edgeLines = c; + } + }); + + // If no edge lines, generate from EdgesGeometry + const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15); + const posAttr = geo.attributes.position; + if (!posAttr) return null; + + // Project hit point to screen + 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 matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld; + + let bestDist = Infinity; + let bestMid: THREE.Vector3 | null = null; + let bestLen = 0; + + const segCount = posAttr.count / 2; + const a = new THREE.Vector3(); + const b = new THREE.Vector3(); + const pa = new THREE.Vector3(); + const pb = new THREE.Vector3(); + + for (let i = 0; i < segCount; i++) { + 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; + const ay = (-pa.y * 0.5 + 0.5) * canvasSize.height; + 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); + bestLen = a.distanceTo(b) * 1000; // mm + } + } + + if (!edgeLines) geo.dispose(); + + if (bestDist <= thresholdPx && bestMid && bestLen > 0.5) { + return { midpoint: bestMid, lengthMM: bestLen }; + } + return null; +} + +function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + if (lenSq < 0.01) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2); + + let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const closestX = ax + t * dx; + const closestY = ay + t * dy; + return Math.sqrt((px - closestX) ** 2 + (py - closestY) ** 2); +} diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 2e91ba4..c6a3bf4 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -30,6 +30,12 @@ export interface Measurement { distanceMM: number; } +export interface HoverInfo { + type: 'hole' | 'edge'; + position: [number, number, number]; + value: number; // mm +} + export interface ChecklistItem { id: string; label: string; @@ -99,6 +105,12 @@ interface ModelStore { compareImage: string | null; setCompareImage: (url: string | null) => void; + snapPoint: MeasurePoint | null; + setSnapPoint: (p: MeasurePoint | null) => void; + + hoverInfo: HoverInfo | null; + setHoverInfo: (info: HoverInfo | null) => void; + screenshots: string[]; addScreenshot: (dataUrl: string) => void; removeScreenshot: (index: number) => void; @@ -192,6 +204,12 @@ export const useModelStore = create((set) => ({ compareImage: null, setCompareImage: (compareImage) => set({ compareImage }), + snapPoint: null, + setSnapPoint: (snapPoint) => set({ snapPoint }), + + hoverInfo: null, + setHoverInfo: (hoverInfo) => set({ hoverInfo }), + screenshots: [], addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })), removeScreenshot: (index) => set((state) => ({