Habilitou hover em modo geral

X-Lovable-Edit-ID: edt-6c00009c-acbc-45f1-b8c4-3fdd53f191fb
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-21 13:45:56 +00:00
3 changed files with 264 additions and 46 deletions
+133 -38
View File
@@ -4,7 +4,7 @@ import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
interface ModelViewerProps {
url?: string; // legacy, ignored — uses store.models
@@ -228,11 +228,19 @@ function useLabelDistanceFactor(position: [number, number, number]): number {
return ref.current;
}
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary'; fixed?: boolean }) {
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const dfDynamic = useLabelDistanceFactor(position);
const df = fixed ? dfDynamic : 1.5;
const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60';
const textClass = variant === 'success' ? 'text-success' : 'text-primary';
const borderClass = variant === 'success'
? 'border-success/60'
: variant === 'accent'
? 'border-amber-400/70'
: 'border-primary/60';
const textClass = variant === 'success'
? 'text-success'
: variant === 'accent'
? 'text-amber-400'
: 'text-primary';
return (
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
@@ -261,15 +269,16 @@ function MeasurementOverlay() {
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
)}
{/* Hover auto-detect tooltip */}
{/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
{hoverInfo && (
<MeasurementLabel
position={hoverInfo.position}
text={`${hoverInfo.type === 'hole' ? '⌀ ' : ''}${hoverInfo.value.toFixed(1)} mm`}
variant="primary"
text={hoverInfo.type === 'hole'
? `Ø ${hoverInfo.value.toFixed(1)}`
: `${hoverInfo.value.toFixed(1)} mm`}
variant={hoverInfo.type === 'hole' ? 'accent' : 'primary'}
fixed
/>
)}
{/* Pending first point */}
@@ -287,6 +296,26 @@ function MeasurementOverlay() {
(a[2] + b[2]) / 2,
];
if (m.kind === 'hole') {
return (
<group key={m.id}>
<PointMarker position={a} color="#f59e0b" />
<MeasurementLabel position={a} text={m.label ?? `Ø ${m.distanceMM.toFixed(1)}`} variant="accent" />
</group>
);
}
if (m.kind === 'edge') {
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line points={[a, b]} color="#22c55e" lineWidth={2} depthTest={false} />
<MeasurementLabel position={mid} text={m.label ?? `${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
}
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
@@ -333,6 +362,15 @@ function SmartSnapHandler() {
return;
}
const st = useModelStore.getState();
// Highest priority: a live hover label pointing at a hole → snap to its center
if (st.hoverInfo?.type === 'hole') {
const [hx, hy, hz] = st.hoverInfo.position;
setSnapPoint({ x: hx, y: hy, z: hz });
return;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
@@ -344,11 +382,39 @@ function SmartSnapHandler() {
return obj instanceof THREE.Mesh;
});
const canvas = gl.domElement;
const canvasW = canvas.clientWidth;
const canvasH = canvas.clientHeight;
// Cursor in screen px (mouse is in NDC [-1, 1])
const cursorX = (mouse.x * 0.5 + 0.5) * canvasW;
const cursorY = (-mouse.y * 0.5 + 0.5) * canvasH;
// Snap to centers of saved hole measurements within 20 px on screen
const holeSnapPxThreshold = 20;
let bestHoleDist = Infinity;
let bestHole: { x: number; y: number; z: number } | null = null;
const proj = new THREE.Vector3();
for (const m of st.measurements) {
if (m.kind !== 'hole') continue;
proj.set(m.pointA.x, m.pointA.y, m.pointA.z).project(camera);
const sx = (proj.x * 0.5 + 0.5) * canvasW;
const sy = (-proj.y * 0.5 + 0.5) * canvasH;
const d = Math.hypot(sx - cursorX, sy - cursorY);
if (d < bestHoleDist && d <= holeSnapPxThreshold) {
bestHoleDist = d;
bestHole = { x: m.pointA.x, y: m.pointA.y, z: m.pointA.z };
}
}
if (bestHole) {
setSnapPoint(bestHole);
return;
}
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 },
{ width: canvasW, height: canvasH },
10
);
if (snap) {
@@ -364,22 +430,24 @@ function SmartSnapHandler() {
return null;
}
/** Hover detector for auto-detect hole diameter and edge length */
/** Hover detector for auto-detect hole diameter and edge length.
* Runs in BOTH view mode and measure mode. Outside measure mode the label
* auto-hides after 4s; in measure mode it persists until the user clicks
* (registering it as a saved measurement) or moves the mouse significantly. */
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 hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHitKey = useRef('');
useEffect(() => {
if (measureMode) {
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
return;
}
const clearTimers = () => {
if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
};
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
@@ -400,24 +468,31 @@ function HoverDetector() {
if (!hit || !(hit.object instanceof THREE.Mesh)) {
lastHitKey.current = '';
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
clearTimers();
return;
}
// Stability check same approximate position for debounce
// Stability check same approximate position for debounce (~3 mm)
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);
clearTimers();
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) {
// 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
if (circ) {
setHoverInfo({
type: 'hole',
position: [circ.center.x, circ.center.y, circ.center.z],
value: circ.diameterMM,
});
} else if (hit.faceIndex !== undefined && (() => {
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
if (hole) {
setHoverInfo({
@@ -425,29 +500,42 @@ function HoverDetector() {
position: [hole.center.x, hole.center.y, hole.center.z],
value: hole.diameterMM,
});
return true;
}
return false;
})()) {
// handled inside IIFE
} else {
// 3) Edge length
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,
endpoints: {
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
},
});
} else {
setHoverInfo(null);
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;
// Outside measure mode, auto-hide after 4 s
const inMeasure = useModelStore.getState().measureMode;
if (!inMeasure) {
hideTimer.current = setTimeout(() => setHoverInfo(null), 4000);
}
setHoverInfo(null);
}, 1000);
}, 2000);
};
const onLeave = () => {
lastHitKey.current = '';
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
clearTimers();
};
gl.domElement.addEventListener('pointermove', onMove);
@@ -455,9 +543,9 @@ function HoverDetector() {
return () => {
gl.domElement.removeEventListener('pointermove', onMove);
gl.domElement.removeEventListener('pointerleave', onLeave);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
clearTimers();
};
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]);
}, [camera, scene, gl, raycaster, mouse, setHoverInfo]);
return null;
}
@@ -499,7 +587,14 @@ function MeasureClickHandler() {
const st = useModelStore.getState();
if (!st.measureMode) return;
// Prefer snap
// If a hover label is currently shown (hole diameter or edge length),
// a click registers it as a saved measurement.
if (st.hoverInfo) {
st.registerHoverMeasurement(st.hoverInfo);
return;
}
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
if (st.snapPoint) {
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
return;
+95 -6
View File
@@ -189,7 +189,7 @@ export function detectHoleAtFace(
}
/** 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 {
export function circleFitKasa(points: { u: number; v: number }[]): { cx: number; cy: number; radius: number } | null {
const n = points.length;
if (n < 3) return null;
@@ -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 => {
@@ -266,6 +266,8 @@ export function findNearestEdgeSegment(
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<string>();
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;
+35 -1
View File
@@ -114,12 +114,18 @@ export interface Measurement {
pointA: MeasurePoint;
pointB: MeasurePoint;
distanceMM: number;
/** 'distance' = point-to-point (default), 'hole' = registered diameter at center, 'edge' = registered edge length */
kind?: 'distance' | 'hole' | 'edge';
/** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */
label?: string;
}
export interface HoverInfo {
type: 'hole' | 'edge';
position: [number, number, number];
value: number; // mm
value: number; // mm (diameter or length)
/** For edges: the two endpoints in world coords. */
endpoints?: { a: MeasurePoint; b: MeasurePoint };
}
export interface ChecklistItem {
@@ -221,6 +227,8 @@ interface ModelStore {
clearMeasurements: () => void;
removeMeasurement: (id: string) => void;
undoLastMeasurement: () => void;
/** Register the current HoverInfo (hole diameter or edge length) as a saved measurement. */
registerHoverMeasurement: (info: HoverInfo) => void;
compareMode: boolean;
@@ -485,6 +493,32 @@ export const useModelStore = create<ModelStore>((set, get) => ({
}
return { measurements: state.measurements.slice(0, -1) };
}),
registerHoverMeasurement: (info) => set((state) => {
let measurement: Measurement;
if (info.type === 'hole') {
const c: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
measurement = {
id: `m_${Date.now()}`,
pointA: c,
pointB: c,
distanceMM: info.value,
kind: 'hole',
label: `Ø ${info.value.toFixed(1)}`,
};
} else {
const a = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] };
const b = info.endpoints?.b ?? a;
measurement = {
id: `m_${Date.now()}`,
pointA: a,
pointB: b,
distanceMM: info.value,
kind: 'edge',
label: `${info.value.toFixed(1)} mm`,
};
}
return { measurements: [...state.measurements, measurement], hoverInfo: null };
}),
compareMode: false,