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, detectCircularEdgeAtPoint } from './SmartMeasure'; import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; interface ModelViewerProps { url?: string; // legacy, ignored — uses store.models } function isPickableModelMesh(obj: THREE.Object3D): obj is THREE.Mesh { 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; } /** Walk up the parent chain until we find a node tagged as `ifcElement`. * Falls back to the clicked mesh so older/demo GLBs without IFC metadata * still support selection/highlight. */ export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | null { const fallback = isPickableModelMesh(obj as THREE.Object3D) ? obj : null; let cur: THREE.Object3D | null = obj; while (cur) { if (cur.userData?.ifcElement) return cur; cur = cur.parent; } return fallback; } function hasIfcAncestor(obj: THREE.Object3D): boolean { let cur = obj.parent; while (cur) { if (cur.userData?.ifcElement) return true; cur = cur.parent; } return false; } /** Stable key for an element across reloads: prefers ifcId, falls back to name. */ export function elementKey(modelId: string, el: THREE.Object3D): string { const id = el.userData?.ifcId ?? el.name ?? el.uuid; return `${modelId}:${id}`; } /** Globally accessible ref to the active R3F scene (set by SceneRefCapture). */ export const sceneRef: { current: THREE.Scene | null } = { current: null }; function SceneRefCapture() { const { scene } = useThree(); useEffect(() => { sceneRef.current = scene; return () => { if (sceneRef.current === scene) sceneRef.current = null; }; }, [scene]); return null; } 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 selectionMode = useModelStore((s) => s.selectionMode); 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(); if ((e.delta ?? 0) > 4) return; if (selectionMode) { const element = findElementRoot(e.object); if (element) { useModelStore.getState().toggleElementSelection(elementKey(sceneModel.id, element)); } return; } 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). This is the local frame where measurement points are stored, so measurements follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */} ); } /** Innermost local frame of a model. Registers itself in the global model * registry so the store can convert world points to this group's local frame. */ function ModelLocalFrame({ modelId, center, children, }: { modelId: string; center: THREE.Vector3; children: React.ReactNode; }) { const ref = useRef(null); useEffect(() => { const g = ref.current; if (!g) return; registerModelLocalGroup(modelId, g); return () => unregisterModelLocalGroup(modelId, g); }, [modelId]); return ( {children} ); } 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' | 'accent'; fixed?: boolean }) { const dfDynamic = useLabelDistanceFactor(position); const df = fixed ? dfDynamic : 1.5; 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 (
{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 (works both inside and outside measure mode) */} {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, ]; if (m.kind === 'hole') { return ( ); } if (m.kind === 'edge') { return ( ); } 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; } 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 => { 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; }); 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 snap = findNearestVertex( hit.object, hit.point, camera, { width: canvasW, height: canvasH }, 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. * 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 setHoverInfo = useModelStore((s) => s.setHoverInfo); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); const hoverTimer = useRef | null>(null); const hideTimer = useRef | null>(null); const lastHitKey = useRef(''); useEffect(() => { 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(); 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); clearTimers(); return; } // 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(); hoverTimer.current = setTimeout(() => { if (!(hit.object instanceof THREE.Mesh)) return; const canvas = gl.domElement; const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight }; // 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({ type: 'hole', 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; } } // Outside measure mode, auto-hide after 4 s const inMeasure = useModelStore.getState().measureMode; if (!inMeasure) { hideTimer.current = setTimeout(() => setHoverInfo(null), 4000); } }, 1000); }; const onLeave = () => { lastHitKey.current = ''; setHoverInfo(null); clearTimers(); }; gl.domElement.addEventListener('pointermove', onMove); gl.domElement.addEventListener('pointerleave', onLeave); return () => { gl.domElement.removeEventListener('pointermove', onMove); gl.domElement.removeEventListener('pointerleave', onLeave); clearTimers(); }; }, [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; // 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; } 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; } /** Pointer handler for element-selection mode. Clicks an IFC element, sub-mesh * of a non-IFC model, or any pickable mesh group. Toggles selection in store. */ function SelectionHandler() { const { camera, scene, gl } = useThree(); const selectionMode = useModelStore((s) => s.selectionMode); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); useEffect(() => { if (!selectionMode) return; const prevCursor = gl.domElement.style.cursor; gl.domElement.style.cursor = 'cell'; const canvas = gl.domElement; const onR3FMiss = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail || detail.button !== 0) return; const rect = canvas.getBoundingClientRect(); mouse.x = ((detail.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((detail.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); const hit = intersects.find((i) => isPickableModelMesh(i.object)); if (!hit) return; const element = findElementRoot(hit.object); const modelId = useModelStore.getState().activeModelId; if (!modelId || !element) return; useModelStore.getState().toggleElementSelection(elementKey(modelId, element)); }; canvas.addEventListener('tsxr-selection-miss', onR3FMiss); return () => { canvas.removeEventListener('tsxr-selection-miss', onR3FMiss); gl.domElement.style.cursor = prevCursor; }; }, [selectionMode, camera, scene, gl, raycaster, mouse]); return null; } /** Walks the scene each time visibility/selection state changes and applies * per-element visibility + emissive highlight for selected elements. */ function VisibilityApplier() { const { scene } = useThree(); const nonce = useModelStore((s) => s.visibilityNonce); const models = useModelStore((s) => s.models); const opacity = useModelStore((s) => s.opacity); const renderMode = useModelStore((s) => s.renderMode); const checklist = useModelStore((s) => s.checklist); useEffect(() => { const st = useModelStore.getState(); const { hiddenElementKeys, isolatedElementKeys, selectedElementKeys } = st; scene.traverse((root) => { const modelId = root.userData?.modelId as string | undefined; if (!modelId) return; root.traverse((el) => { if (!el.userData?.ifcElement && !(isPickableModelMesh(el) && !hasIfcAncestor(el))) return; const key = elementKey(modelId, el); const hidden = hiddenElementKeys.has(key); const isolated = isolatedElementKeys ? !isolatedElementKeys.has(key) : false; el.visible = !(hidden || isolated); const selected = selectedElementKeys.has(key); el.traverse((m) => { if (!(m instanceof THREE.Mesh)) return; if (selected) { if (!m.userData.__origMaterial) { m.userData.__origMaterial = m.material; const src = Array.isArray(m.material) ? m.material[0] : m.material; const cloned = (src as THREE.Material).clone() as THREE.MeshStandardMaterial; if (cloned instanceof THREE.MeshStandardMaterial) { cloned.color.set('#f59e0b'); cloned.emissive.set('#f59e0b'); cloned.emissiveIntensity = 0.8; cloned.needsUpdate = true; } m.material = cloned; } } else if (m.userData.__origMaterial) { // Dispose the cloned highlight material const cur = Array.isArray(m.material) ? m.material[0] : m.material; (cur as THREE.Material)?.dispose?.(); m.material = m.userData.__origMaterial as THREE.Material; delete m.userData.__origMaterial; } }); }); }); }, [scene, nonce, models, opacity, renderMode, checklist]); 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); const selectionMode = useModelStore((s) => s.selectionMode); return ( { if (useModelStore.getState().selectionMode) { (e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e })); } }} onCreated={({ gl }) => { const isApple = /Mac|iPhone|iPad/.test(navigator.platform); const maxRatio = isApple ? 2 : 1.5; gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio)); }} > }> ); }