diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 68c308b..3d2a9f1 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -529,7 +529,7 @@ function HoverDetector() { if (!inMeasure) { hideTimer.current = setTimeout(() => setHoverInfo(null), 4000); } - }, 2000); + }, 1000); }; const onLeave = () => { diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx index 5ca495c..03c9c75 100644 --- a/src/components/three/XRControllerMeasure.tsx +++ b/src/components/three/XRControllerMeasure.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; import { useModelStore } from '@/stores/useModelStore'; -import { resolveSnap } from './SmartMeasure'; +import { resolveSnap, detectCircularEdgeAtPoint, findNearestEdgeSegment } from './SmartMeasure'; const TRIG_ON = 0.7; const TRIG_OFF = 0.3; @@ -44,6 +44,11 @@ export function XRControllerMeasure() { const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false })); const tipColor = useRef(new THREE.Color('#22c55e')); + // Dwell detection for hover-based smart measurement (1 s) + const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity)); + const dwellStart = useRef(0); + const dwellFired = useRef(false); + useFrame((_state, _dt, frame: XRFrame | undefined) => { const measureMode = useModelStore.getState().measureMode; if (laserRef.current) laserRef.current.visible = false; @@ -101,32 +106,90 @@ export function XRControllerMeasure() { }); let snappedPoint: THREE.Vector3 | null = null; - let snapKind: 'vertex' | 'edge' | 'surface' = 'surface'; + let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface'; + let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null; if (hit && hit.object instanceof THREE.Mesh) { - if (snapEnabled) { - // Use a virtual canvas size based on session — fall back to current renderer size - const size = gl.getSize(new THREE.Vector2()); - const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100); - fakeCam.position.copy(tmpOrigin.current); - fakeCam.quaternion.copy(tmpQuat.current); - fakeCam.updateMatrixWorld(true); - const snap = resolveSnap( - hit.object, - hit.point, - fakeCam, - { width: size.x || 1024, height: size.y || 1024 }, - 14, - 18 - ); - snappedPoint = snap.point; - snapKind = snap.type; + const size = gl.getSize(new THREE.Vector2()); + const canvasSize = { width: size.x || 1024, height: size.y || 1024 }; + const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100); + fakeCam.position.copy(tmpOrigin.current); + fakeCam.quaternion.copy(tmpQuat.current); + fakeCam.updateMatrixWorld(true); + + // Snap to existing registered hole centers first (within ~30px screen) + const existing = useModelStore.getState().measurements; + let bestHoleCenter: THREE.Vector3 | null = null; + let bestHolePx = Infinity; + const hitProj = hit.point.clone().project(fakeCam); + const hx = (hitProj.x * 0.5 + 0.5) * canvasSize.width; + const hy = (-hitProj.y * 0.5 + 0.5) * canvasSize.height; + for (const m of existing) { + if (m.kind !== 'hole') continue; + const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z); + const p = c.clone().project(fakeCam); + const sx = (p.x * 0.5 + 0.5) * canvasSize.width; + const sy = (-p.y * 0.5 + 0.5) * canvasSize.height; + const d = Math.hypot(sx - hx, sy - hy); + if (d < 30 && d < bestHolePx) { bestHolePx = d; bestHoleCenter = c; } + } + + if (snapEnabled && bestHoleCenter) { + snappedPoint = bestHoleCenter; + snapKind = 'hole'; + } else if (snapEnabled) { + // Try detecting a circular edge (hole) at hit + const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 60); + if (circle) { + snappedPoint = circle.center; + snapKind = 'hole'; + hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center }; + } else { + const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 14, 18); + snappedPoint = snap.point; + snapKind = snap.type; + if (snap.type === 'edge') { + const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 18); + if (seg) { + const lenMM = seg.a.distanceTo(seg.b) * 1000; + hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } }; + } + } + } } else { snappedPoint = hit.point.clone(); snapKind = 'surface'; } } + // ── Dwell detection (1 s) to auto-register hovered hole/edge ───── + const measureModeNow = useModelStore.getState().measureMode; + const nowT = performance.now(); + if (snappedPoint && hoverDetected) { + const dist = dwellPos.current.distanceTo(snappedPoint); + if (dist > 0.005) { + dwellPos.current.copy(snappedPoint); + dwellStart.current = nowT; + dwellFired.current = false; + } else if (!dwellFired.current && nowT - dwellStart.current > 1000) { + dwellFired.current = true; + if (measureModeNow) { + useModelStore.getState().registerHoverMeasurement({ + type: hoverDetected.kind, + value: hoverDetected.value, + position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z], + endpoints: hoverDetected.endpoints && { + a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z }, + b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z }, + }, + }); + } + } + } else { + dwellFired.current = false; + dwellStart.current = nowT; + } + // ── Update laser visual ─────────────────────────────────────────── if (laserRef.current && tipRef.current) { const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY)); @@ -136,7 +199,7 @@ export function XRControllerMeasure() { positions.needsUpdate = true; laserGeom.current.computeBoundingSphere(); - const color = snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308'; + const color = snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308'; tipColor.current.set(color); (laserRef.current.material as THREE.LineBasicMaterial).color.set(color); ((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current); diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 210c99f..3ec35cc 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -296,13 +296,19 @@ function XRMeasurementOverlay() { const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z]; const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z]; const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2]; + const isHole = m.kind === 'hole'; + const color = isHole ? '#f59e0b' : '#3b82f6'; + const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`; return ( - - - + {!isHole && ( + <> + + + + + )} - {/* Background plate */} @@ -310,13 +316,13 @@ function XRMeasurementOverlay() { - {`${m.distanceMM.toFixed(1)} mm`} + {text}