import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react'; import { Canvas, useThree, useFrame } from '@react-three/fiber'; import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera } 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, getModelWorldScaleFactor } from '@/lib/modelTransforms'; import { parseIFCtoThree } from '@/lib/convertIFC'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus'; 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, camera } = useThree(); useEffect(() => { sceneRef.current = scene; return () => { if (sceneRef.current === scene) sceneRef.current = null; }; }, [scene]); useEffect(() => { mainCameraRef.current = camera; return () => { if (mainCameraRef.current === camera) mainCameraRef.current = null; }; }, [camera]); return null; } /** Drives the camera animation requested by the ViewCube. */ function ViewCubeAnimator() { const { camera } = useThree(); useFrame((_state, delta) => { if (!viewAnim.active) return; viewAnim.t = Math.min(1, viewAnim.t + delta / viewAnim.duration); const k = viewAnim.t < 0.5 ? 2 * viewAnim.t * viewAnim.t : 1 - Math.pow(-2 * viewAnim.t + 2, 2) / 2; camera.position.lerpVectors(viewAnim.startPos, viewAnim.endPos, k); camera.up.lerpVectors(viewAnim.startUp, viewAnim.endUp, k).normalize(); const controls = mainControlsRef.current; if (controls?.target) camera.lookAt(controls.target); controls?.update?.(); if (viewAnim.t >= 1) viewAnim.active = false; }); return null; } /** Switches between perspective and orthographic projection while keeping * the visual framing (position, target, on-screen size of the scene). */ function CameraSwitcher() { const mode = useModelStore((s) => s.cameraMode); const { size } = useThree(); // Snapshot current view from whichever camera is active right now, before swap. const snap = useMemo(() => { const c = mainCameraRef.current; const ctrl = mainControlsRef.current; const target = (ctrl?.target ?? new THREE.Vector3()).clone(); const pos = c ? c.position.clone() : new THREE.Vector3(2, 2, 2); const up = c ? c.up.clone() : new THREE.Vector3(0, 1, 0); const dist = pos.distanceTo(target); // Compute ortho zoom that matches current perspective framing. let zoom = 100; if (c && (c as THREE.PerspectiveCamera).isPerspectiveCamera) { const fov = ((c as THREE.PerspectiveCamera).fov ?? 50) * (Math.PI / 180); const worldHeight = 2 * Math.max(dist, 0.001) * Math.tan(fov / 2); zoom = Math.max(1, size.height / Math.max(worldHeight, 1e-6)); } else if (c && (c as THREE.OrthographicCamera).isOrthographicCamera) { zoom = (c as THREE.OrthographicCamera).zoom; } return { target, pos, up, zoom }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); // Sync OrbitControls target after camera swap. useEffect(() => { const ctrl = mainControlsRef.current; if (ctrl?.target) ctrl.target.copy(snap.target); ctrl?.update?.(); }, [snap]); if (mode === 'ortho') { return ( ); } return ( ); } function LoadingFallback() { return (
Carregando modelo…
); } function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) { const [rawScene, setRawScene] = useState(null); const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]); useEffect(() => { let active = true; const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc'); if (isIfc) { fetch(sceneModel.url) .then((res) => res.arrayBuffer()) .then((buf) => parseIFCtoThree(buf)) .then((threeScene) => { if (active) setRawScene(threeScene); }) .catch((err) => console.error('[GLBModel] IFC parsing error', err)); } else { const loader = new GLTFLoader(); loader.load( sceneModel.url, (gltf) => { if (active) setRawScene(gltf.scene); }, undefined, (err) => console.error('[GLBModel] GLTF loading error', err) ); } return () => { active = false; }; }, [sceneModel.url, sceneModel.fileName]); 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 measureMode = useModelStore((s) => s.measureMode); const fineTuning = sceneModel.fineTuning; // Re-render when calibration state changes (so calQuat resets to identity during the flow). const [, setCalTick] = useState(0); useEffect(() => subscribeCalibration(() => setCalTick(t => t + 1)), []); const modelInfo = useMemo(() => { if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() }; 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(() => { if (!scene) return; 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' && !measureMode) { mat.visible = false; } else { mat.visible = true; const targetOpacity = measureMode ? 0.25 : opacity; mat.transparent = targetOpacity < 1; mat.opacity = targetOpacity; 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' || measureMode) && child.geometry) { const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle); const lineMat = new THREE.LineBasicMaterial({ color: measureMode ? '#00f3ff' : wireframeColor, linewidth: measureMode ? 2 : wireframeThickness, toneMapped: false, transparent: true, opacity: 0.95, }); const lineSegments = new THREE.LineSegments(edgesGeo, lineMat); lineSegments.userData.__edgeLine = true; child.add(lineSegments); } } }); }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]); 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; // Calibration quaternion (applied innermost, around center). We render with identity // while calibration is in progress for this model so face normals reflect the // un-calibrated frame; the result is committed at the end. const calQuatArr = sceneModel.calibrationQuat; const isCalibratingThis = calibration.modelId === sceneModel.id && calibration.step !== 'idle' && calibration.step !== 'done'; const calQuat = useMemo(() => { if (isCalibratingThis) return new THREE.Quaternion(); if (!calQuatArr) return new THREE.Quaternion(); return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]); }, [calQuatArr, isCalibratingThis]); const calGroupRef = useRef(null); if (!sceneModel.visible) return null; if (!scene) 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; // Calibration: capture model face normal in world space. if ( calibration.modelId === sceneModel.id && (calibration.step === 'await-model-1' || calibration.step === 'await-model-2' || calibration.step === 'await-model-3') && e.face && e.object ) { const n = e.face.normal.clone(); const nm = new THREE.Matrix3().getNormalMatrix(e.object.matrixWorld); n.applyMatrix3(nm).normalize(); const wq = new THREE.Quaternion(); (calGroupRef.current ?? e.object).getWorldQuaternion(wq); pushModelFaceNormal(n, wq); // Apply / refresh calibration whenever we have ≥2 pairs. if (calibration.pairs.length >= 2) { const q = computeCalibrationQuaternion(calibration.pairs); if (q) { useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]); } } 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 */} {/* Calibration rotation (innermost, around center). */} ); } /** 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: 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) => ( ))} ); } /** World-units-per-pixel for the active camera (persp or ortho). */ function computeWorldPerPixel( camera: THREE.Camera, size: { height: number }, worldPos: THREE.Vector3, ): number { const ortho = camera as THREE.OrthographicCamera; if ((ortho as any).isOrthographicCamera) { const visibleHeight = (ortho.top - ortho.bottom) / (ortho.zoom || 1); return visibleHeight / Math.max(1, size.height); } const persp = camera as THREE.PerspectiveCamera; const dist = camera.position.distanceTo(worldPos); const fov = (persp.fov ?? 50) * (Math.PI / 180); return (2 * dist * Math.tan(fov / 2)) / Math.max(1, size.height); } /** 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(); const tmpVec = useMemo(() => new THREE.Vector3(), []); const worldPos = useMemo(() => new THREE.Vector3(), []); useFrame(() => { if (!ref.current) return; ref.current.getWorldPosition(worldPos); const wpp = computeWorldPerPixel(camera, size, worldPos); const targetWorld = Math.max(0.0005, 6 * wpp); const parentScale = ref.current.parent ? ref.current.parent.getWorldScale(tmpVec).x || 1 : 1; ref.current.scale.setScalar(targetWorld / parentScale); }); return ( ); } /** Snap ring indicator — fixed ~10 mm diameter in world units (follows piece scale). * World units in this app are meters (1 unit = 1000 mm), so 5 mm radius = 0.005 * world units, divided by the model's cumulative world scale so it remains * ~10 mm physically when the piece is scaled. */ function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) { const ref = useRef(null); const { camera, size } = useThree(); const worldPos = useMemo(() => new THREE.Vector3(), []); useFrame(() => { if (!ref.current) return; ref.current.lookAt(camera.position); const activeId = useModelStore.getState().activeModelId; const sf = getModelWorldScaleFactor(activeId); let radiusWorld = 0.005 / Math.max(1e-6, sf); // 5 mm in world units if (!Number.isFinite(radiusWorld) || radiusWorld <= 0) { ref.current.getWorldPosition(worldPos); const wpp = computeWorldPerPixel(camera, size, worldPos); radiusWorld = Math.max(0.0008, 8 * wpp); } ref.current.scale.setScalar(radiusWorld); }); return ( ); } function MeasurementLabel({ position, text, variant }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) { const { camera } = useThree(); const isOrtho = (camera as THREE.OrthographicCamera).isOrthographicCamera === true; // In orthographic mode, drei's distanceFactor uses perspective math and can // explode → the HTML label scales massively and visually wipes the model. // Use a plain screen-space Html label in ortho; perspective keeps a stable factor. const df = isOrtho ? undefined : 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 a single measurement's geometry + label. * Coordinates are interpreted in whichever frame the component is mounted in. */ function MeasurementGraphic({ m }: { m: import('@/stores/useModelStore').Measurement }) { 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 ( ); } /** Renders measurements attached to a given model, in that model's local frame. * Mounted inside GLBModel so they follow the model when it is repositioned. */ function ModelMeasurements({ modelId }: { modelId: string }) { const measurements = useModelStore((s) => s.measurements); const attached = measurements.filter((m) => m.modelId === modelId); if (attached.length === 0) return null; return ( <> {attached.map((m) => ( ))} ); } /** Renders snap indicator, hover tooltip, pending point and any *legacy* * world-frame measurements that aren't attached to a specific model. */ 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 && ( )} {/* Hover auto-detect tooltip (works both inside and outside measure mode) */} {hoverInfo && ( )} {/* Pending first point (always in world coords) */} {measurePoints.length === 1 && ( )} {/* Legacy world-frame measurements (no modelId) */} {measurements.filter((m) => !m.modelId).map((m) => ( ))} ); } /** 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. */ export 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)); }} > }> { mainControlsRef.current = c; }} /> ); }