This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 02:58:18 +00:00
parent 7d4fd06bb0
commit d367d2d1c9
3 changed files with 536 additions and 4 deletions
+200 -4
View File
@@ -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 (
<mesh position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
</mesh>
);
}
/** 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 && (
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
)}
{/* Hover auto-detect tooltip */}
{hoverInfo && (
<Html position={hoverInfo.position} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
{hoverInfo.type === 'hole' ? '⌀ ' : ''}
{hoverInfo.value.toFixed(1)} mm
</span>
</div>
</Html>
)}
{/* Pending first point */}
{measurePoints.length === 1 && (
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
@@ -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<ReturnType<typeof setTimeout> | 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) {
<MeasurementOverlay />
<MeasureClickHandler />
<SmartSnapHandler />
<HoverDetector />
<GridLayer />