import { Suspense, useRef, useMemo, useEffect } from 'react'; import { Canvas, useThree, useFrame } from '@react-three/fiber'; 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'; interface ModelViewerProps { url?: string; // legacy, ignored — uses store.models } function LoadingFallback() { return (
Carregando modelo…
); } function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) { const { scene: rawScene } = useGLTF(sceneModel.url); // Clone scene per instance so multiple GLBModels with same url don't share materials/transforms const scene = useMemo(() => rawScene.clone(true), [rawScene]); const ref = useRef(null); const opacity = useModelStore((s) => s.opacity); const renderMode = useModelStore((s) => s.renderMode); const wireframeColor = useModelStore((s) => s.wireframeColor); const wireframeThickness = useModelStore((s) => s.wireframeThickness); const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle); const checklist = useModelStore((s) => s.checklist); const setActive = useModelStore((s) => s.setActiveModel); const fineTuning = sceneModel.fineTuning; const modelInfo = useMemo(() => { const box = new THREE.Box3().setFromObject(scene); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); return { size, center }; }, [scene]); const originalColors = useRef>(new Map()); useEffect(() => { const hasRejected = checklist.some(i => i.status === 'rejected'); const allApproved = checklist.every(i => i.status === 'approved'); scene.traverse((child) => { if (child instanceof THREE.Mesh) { if (Array.isArray(child.material)) { child.material = child.material.map(m => m.clone()); } else { child.material = child.material.clone(); } const toRemove: THREE.Object3D[] = []; child.children.forEach(c => { if (c.userData.__edgeLine) toRemove.push(c); }); toRemove.forEach(c => { if (c instanceof THREE.LineSegments) { c.geometry.dispose(); (c.material as THREE.Material).dispose(); } child.remove(c); }); const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat: THREE.Material) => { if (mat instanceof THREE.MeshStandardMaterial) { if (!originalColors.current.has(mat)) { originalColors.current.set(mat, mat.color.clone()); } if (renderMode === 'edges') { mat.visible = false; } else { mat.visible = true; mat.transparent = opacity < 1; mat.opacity = opacity; mat.wireframe = renderMode === 'wireframe'; if (renderMode === 'wireframe') { mat.wireframeLinewidth = wireframeThickness; mat.color.set(wireframeColor); } else if (hasRejected) { mat.color.setHSL(0, 0.7, 0.5); } else if (allApproved) { mat.color.setHSL(0.38, 0.7, 0.45); } else { // Tint with the per-model color (subtle) mat.color.set(sceneModel.color); } } mat.needsUpdate = true; } }); if (renderMode === 'edges' && child.geometry) { const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle); const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness, }); const lineSegments = new THREE.LineSegments(edgesGeo, lineMat); lineSegments.userData.__edgeLine = true; child.add(lineSegments); } } }); }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]); const rotXRad = (fineTuning.rotX * Math.PI) / 180; const rotYRad = (fineTuning.rotY * Math.PI) / 180; const rotZRad = (fineTuning.rotZ * Math.PI) / 180; const s = fineTuning.scale ?? 1; const scaleRatio = useModelStore((st) => st.scaleRatio); const renderFactor = scaleRatio?.factor ?? 1; if (!sceneModel.visible) return null; // Determine dominant local axis of the model (longest bbox side) for axial roll const dominantAxis: 'x' | 'y' | 'z' = modelInfo.size.x >= modelInfo.size.y && modelInfo.size.x >= modelInfo.size.z ? 'x' : modelInfo.size.y >= modelInfo.size.z ? 'y' : 'z'; return ( { e.stopPropagation(); setActive(sceneModel.id); }} userData={{ modelId: sceneModel.id, dominantAxis }} > {/* Translation */} {/* Rotation + scale around the geometry center */} {/* Shift geometry so its center sits at the parent's origin (pivot = center) */} ); } function SceneModels() { const models = useModelStore((s) => s.models); const activeId = useModelStore((s) => s.activeModelId); return ( <> {models.map((m) => ( ))} ); } /** Sphere marker at a 3D point — scaled per-frame to keep constant screen size */ function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) { const ref = useRef(null); const { camera, size } = useThree(); useFrame(() => { if (!ref.current) return; const dist = camera.position.distanceTo(ref.current.position); const perspCam = camera as THREE.PerspectiveCamera; const fov = (perspCam.fov ?? 50) * (Math.PI / 180); const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height; // Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp) const s = Math.max(0.0005, 6 * worldPerPixel); ref.current.scale.setScalar(s); }); return ( ); } /** Snap ring indicator — scaled per-frame */ function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) { const ref = useRef(null); const { camera, size } = useThree(); useFrame(() => { if (!ref.current) return; ref.current.lookAt(camera.position); const dist = camera.position.distanceTo(ref.current.position); const perspCam = camera as THREE.PerspectiveCamera; const fov = (perspCam.fov ?? 50) * (Math.PI / 180); const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height; const s = Math.max(0.0008, 10 * worldPerPixel); ref.current.scale.setScalar(s); }); return ( ); } /** Computes a distanceFactor that keeps an Html label roughly constant on screen */ function useLabelDistanceFactor(position: [number, number, number]): number { const { camera, size } = useThree(); const v = useMemo(() => new THREE.Vector3(), []); const ref = useRef(1); useFrame(() => { v.set(position[0], position[1], position[2]); const dist = camera.position.distanceTo(v); const perspCam = camera as THREE.PerspectiveCamera; const fov = (perspCam.fov ?? 50) * (Math.PI / 180); const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height; // distanceFactor scales the html so it stays ~140 px tall at this distance ref.current = Math.max(0.05, worldPerPixel * 140); }); return ref.current; } function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary'; 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'; return (
{text}
); } /** 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); const scaleRatio = useModelStore((s) => s.scaleRatio); const factor = scaleRatio?.factor ?? 1; return ( <> {/* Snap indicator */} {measureMode && snapPoint && ( )} {/* Hover auto-detect tooltip */} {hoverInfo && ( )} {/* Pending first point */} {measurePoints.length === 1 && ( )} {/* Completed measurements */} {measurements.map((m) => { 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, ]; return ( ); })} ); } /** 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 | 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; } /** Pointer handler for measurement mode – uses snap point when available. * Uses pointerdown/up with a drag gate so accidental drags don't add points. */ function MeasureClickHandler() { const { camera, scene, gl } = useThree(); const measureMode = useModelStore((s) => s.measureMode); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); useEffect(() => { if (!measureMode) { gl.domElement.style.cursor = 'grab'; return; } gl.domElement.style.cursor = 'crosshair'; const canvas = gl.domElement; let downX = 0, downY = 0, downBtn = 0; let armed = false; const onDown = (e: PointerEvent) => { if (e.button !== 0) { armed = false; return; } downX = e.clientX; downY = e.clientY; downBtn = e.button; armed = true; }; const onUp = (e: PointerEvent) => { if (!armed || e.button !== downBtn) return; armed = false; const dx = e.clientX - downX; const dy = e.clientY - downY; if (Math.hypot(dx, dy) > 4) return; // it was a drag, not a click const st = useModelStore.getState(); if (!st.measureMode) return; // Prefer snap if (st.snapPoint) { st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z }); return; } const rect = canvas.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.Mesh)) return false; if (obj.userData.__edgeLine) return false; if (obj.geometry instanceof THREE.SphereGeometry) return false; if (obj.geometry instanceof THREE.RingGeometry) return false; if (obj.geometry instanceof THREE.PlaneGeometry) return false; return true; }); if (hit) { st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); } }; const onKey = (e: KeyboardEvent) => { const st = useModelStore.getState(); if (!st.measureMode) return; if (e.key === 'Escape') { if (st.measurePoints.length > 0) st.undoLastMeasurePoint(); } else if ((e.key === 'z' || e.key === 'Z') && !e.ctrlKey && !e.metaKey) { st.undoLastMeasurement(); } }; canvas.addEventListener('pointerdown', onDown); canvas.addEventListener('pointerup', onUp); window.addEventListener('keydown', onKey); return () => { canvas.removeEventListener('pointerdown', onDown); canvas.removeEventListener('pointerup', onUp); window.removeEventListener('keydown', onKey); gl.domElement.style.cursor = 'grab'; }; }, [measureMode, camera, scene, gl, raycaster, mouse]); return null; } function GridLayer() { const showGrid = useModelStore((s) => s.showGrid); const gridY = useModelStore((s) => s.gridY); if (!showGrid) return null; return ( ); } /** Computes min Y of all visible models and updates store gridY when auto-follow is on. */ function GridAutoFollower() { const models = useModelStore((s) => s.models); const { scene } = useThree(); useEffect(() => { const auto = useModelStore.getState().gridAutoFollow; if (!auto) return; // Defer to next tick so models have rendered/positioned const id = setTimeout(() => { if (!useModelStore.getState().gridAutoFollow) return; const box = new THREE.Box3(); let has = false; scene.traverse((obj) => { if (obj instanceof THREE.Mesh && obj.geometry) { // Skip helpers (markers, rings) if (obj.geometry instanceof THREE.SphereGeometry) return; if (obj.geometry instanceof THREE.RingGeometry) return; if (obj.userData.__edgeLine) return; obj.updateWorldMatrix(true, false); const b = new THREE.Box3().setFromObject(obj); if (isFinite(b.min.y)) { if (!has) { box.copy(b); has = true; } else box.union(b); } } }); if (has) { useModelStore.setState({ gridY: box.min.y - 0.005 }); } }, 100); return () => clearTimeout(id); }, [models, scene]); return null; } /** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */ function PositionDragHandler() { const { camera, gl, scene } = useThree(); const positionMode = useModelStore((s) => s.positionMode); const activeId = useModelStore((s) => s.activeModelId); useEffect(() => { if (!positionMode) { gl.domElement.style.cursor = 'grab'; return; } gl.domElement.style.cursor = 'move'; const canvas = gl.domElement; let dragging = false; let button = 0; let shiftKey = false; let lastX = 0; let lastY = 0; let pixelsPerWorldUnit = 1; const computePixelScale = () => { const st = useModelStore.getState(); const active = st.models.find((m) => m.id === st.activeModelId); if (!active) return; // Find the model's group position (approx world): use renderFactor + fineTuning pos const factor = st.scaleRatio?.factor ?? 1; const wp = new THREE.Vector3( active.fineTuning.posX * factor, active.fineTuning.posY * factor, active.fineTuning.posZ * factor, ); const dist = camera.position.distanceTo(wp); const perspCam = camera as THREE.PerspectiveCamera; const fov = (perspCam.fov ?? 50) * (Math.PI / 180); const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / canvas.clientHeight; pixelsPerWorldUnit = worldPerPixel; // world units per pixel }; const onDown = (e: PointerEvent) => { const st = useModelStore.getState(); if (!st.positionMode || !st.activeModelId) return; dragging = true; button = e.button; shiftKey = e.shiftKey; lastX = e.clientX; lastY = e.clientY; computePixelScale(); (e.target as Element).setPointerCapture?.(e.pointerId); e.preventDefault(); }; const onMove = (e: PointerEvent) => { if (!dragging) return; const st = useModelStore.getState(); const active = st.models.find((m) => m.id === st.activeModelId); if (!active) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; shiftKey = e.shiftKey; const ft = active.fineTuning; const factor = st.scaleRatio?.factor ?? 1; if (button === 2) { // Right button: rotate const sens = 0.4; // deg per pixel if (shiftKey) { useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens }); } else { useModelStore.getState().setFineTuning({ rotY: ft.rotY + dx * sens, rotX: ft.rotX + dy * sens, }); } } else if (button === 1) { // Middle button (wheel): roll around the piece's own longitudinal axis const sens = 0.5; let dominant: 'x' | 'y' | 'z' = 'x'; scene.traverse((o) => { if (o.userData?.modelId === active.id && o.userData?.dominantAxis) { dominant = o.userData.dominantAxis; } }); const delta = dx * sens; if (dominant === 'x') { useModelStore.getState().setFineTuning({ rotX: ft.rotX + delta }); } else if (dominant === 'y') { useModelStore.getState().setFineTuning({ rotY: ft.rotY + delta }); } else { useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta }); } } else if (button === 0 && shiftKey) { // Shift+left: depth (Z in camera space) const worldDelta = dy * pixelsPerWorldUnit; const camDir = new THREE.Vector3(); camera.getWorldDirection(camDir); const move = camDir.multiplyScalar(worldDelta).divideScalar(factor); useModelStore.getState().setFineTuning({ posX: ft.posX + move.x, posY: ft.posY + move.y, posZ: ft.posZ + move.z, }); } else { // Left: translate in camera plane const right = new THREE.Vector3(); const up = new THREE.Vector3(); camera.matrixWorld.extractBasis(right, up, new THREE.Vector3()); const worldDX = dx * pixelsPerWorldUnit; const worldDY = -dy * pixelsPerWorldUnit; const move = right.multiplyScalar(worldDX).add(up.multiplyScalar(worldDY)).divideScalar(factor); useModelStore.getState().setFineTuning({ posX: ft.posX + move.x, posY: ft.posY + move.y, posZ: ft.posZ + move.z, }); } }; const onUp = (e: PointerEvent) => { dragging = false; (e.target as Element).releasePointerCapture?.(e.pointerId); }; const onContext = (e: MouseEvent) => e.preventDefault(); canvas.addEventListener('pointerdown', onDown); canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerup', onUp); canvas.addEventListener('pointercancel', onUp); canvas.addEventListener('contextmenu', onContext); return () => { canvas.removeEventListener('pointerdown', onDown); canvas.removeEventListener('pointermove', onMove); canvas.removeEventListener('pointerup', onUp); canvas.removeEventListener('pointercancel', onUp); canvas.removeEventListener('contextmenu', onContext); gl.domElement.style.cursor = 'grab'; }; }, [positionMode, activeId, camera, gl, scene]); return null; } export function ModelViewerCanvas({ url }: ModelViewerProps) { const positionMode = useModelStore((s) => s.positionMode); const measureMode = useModelStore((s) => s.measureMode); return ( { const isApple = /Mac|iPhone|iPad/.test(navigator.platform); const maxRatio = isApple ? 2 : 1.5; gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio)); }} > }> ); }