diff --git a/src/components/three/SmartMeasure.ts b/src/components/three/SmartMeasure.ts index c5a8df5..a7441ef 100644 --- a/src/components/three/SmartMeasure.ts +++ b/src/components/three/SmartMeasure.ts @@ -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;