Changes
This commit is contained in:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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<ModelStore>((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) => ({
|
||||
|
||||
Reference in New Issue
Block a user