import { useEffect, useRef, useMemo, useState, useCallback } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { Grid, Billboard, Sky, Line } from '@react-three/drei'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import * as THREE from 'three'; import { useModelStore, type SceneModel } from '@/stores/useModelStore'; import { findNearestVertex } from './SmartMeasure'; import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; import { parseIFCtoThree } from '@/lib/convertIFC'; function findModelId(obj: THREE.Object3D): string | undefined { let cur: THREE.Object3D | null = obj; while (cur) { if (cur.userData?.modelId) return cur.userData.modelId as string; cur = cur.parent; } return undefined; } // ─── XRModel ─────────────────────────────────────────── export function XRModel({ sceneModel }: { sceneModel: SceneModel }) { 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('[XRModel] IFC parsing error', err)); } else { const loader = new GLTFLoader(); loader.load( sceneModel.url, (gltf) => { if (active) setRawScene(gltf.scene); }, undefined, (err) => console.error('[XRModel] GLTF loading error', err) ); } return () => { active = false; }; }, [sceneModel.url, sceneModel.fileName]); const ref = useRef(null); useEffect(() => { const g = ref.current; if (!g) return; registerModelLocalGroup(sceneModel.id, g); return () => unregisterModelLocalGroup(sceneModel.id, g); }, [sceneModel.id]); const fineTuning = sceneModel.fineTuning; 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 measureMode = useModelStore((s) => s.measureMode); 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]); 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) { const isEdgesOrMeasure = renderMode === 'edges' || measureMode; mat.visible = true; const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity; mat.transparent = targetOpacity < 1; mat.opacity = targetOpacity; mat.wireframe = false; if (hasRejected) { mat.color.setHSL(0, 0.7, 0.5); } else if (allApproved) { mat.color.setHSL(0.38, 0.7, 0.45); } else { 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: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor, linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness, }); 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; const xrScaleMode = useModelStore((st) => st.xrScaleMode); const isTabletop = xrScaleMode === 'tabletop'; if (!sceneModel.visible) return null; if (!scene) return null; // Escala final: se for tabletop, escala de maquete proporcional ao tamanho do objeto const maxDim = Math.max(modelInfo.size.x, modelInfo.size.y, modelInfo.size.z); const finalScaleFactor = isTabletop ? (maxDim > 0 ? 0.5 / maxDim : 0.05) : renderFactor; // Posição local: no tabletop, eleva-se o modelo para que sua base coincida com Y=0 da origem do pai (topo da mesa) const localPos: [number, number, number] = isTabletop ? [ -modelInfo.center.x + fineTuning.posX, (-modelInfo.center.y + (modelInfo.size.y / 2)) + fineTuning.posY, -modelInfo.center.z + fineTuning.posZ, ] : [ -modelInfo.center.x + fineTuning.posX, -modelInfo.center.y + fineTuning.posY, -modelInfo.center.z + fineTuning.posZ, ]; const parentPos: [number, number, number] = isTabletop ? [0, 0.85, 0] : [0, 0, 0]; return ( ); } export function XRBackgroundModels() { const models = useModelStore((s) => s.models); const activeId = useModelStore((s) => s.activeModelId); return ( <> {models.filter(m => m.id !== activeId).map((m) => ( ))} ); } export function XRActiveModel() { const models = useModelStore((s) => s.models); const activeId = useModelStore((s) => s.activeModelId); const active = models.find(m => m.id === activeId); if (!active) return null; return ; } // ─── ControllerFineTuning ────────────────────────────── export function ControllerFineTuning({ freeMove }: { freeMove: boolean }) { const { setFineTuning } = useModelStore(); const session = useThree((s) => s.gl.xr.getSession()); const isActiveLocked = useModelStore((s) => { const a = s.models.find(m => m.id === s.activeModelId); return !!a?.locked; }); useFrame(() => { if (!session || isActiveLocked) return; const inputSources = session.inputSources; if (!inputSources) return; let leftAxes: number[] | null = null; let rightAxes: number[] | null = null; let leftTrig = 0; let rightTrig = 0; let gripHeld = false; let anyGripHeld = false; for (const source of inputSources) { const gp = source.gamepad; if (!gp) continue; const gripBtn = gp.buttons[2]; const trigBtn = gp.buttons[1]; const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0; if (gripBtn?.pressed) gripHeld = true; if (gripVal > 0.3) anyGripHeld = true; const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; if (source.handedness === 'left' && gp.axes.length >= 4) { leftAxes = [gp.axes[2], gp.axes[3]]; leftTrig = trigVal; } if (source.handedness === 'right' && gp.axes.length >= 4) { rightAxes = [gp.axes[2], gp.axes[3]]; rightTrig = trigVal; } } if (anyGripHeld) return; if (!freeMove && !gripHeld) return; const baseStep = 0.001; const baseRot = 0.1; const deadzone = 0.15; const speedL = 0.3 + leftTrig * 2.7; const speedR = 0.3 + rightTrig * 2.7; const curve = (v: number) => Math.sign(v) * v * v; const modelStore = useModelStore.getState(); const ft = { ...modelStore.fineTuning }; if (leftAxes) { if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL; if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL; } if (rightAxes) { if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR; if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR; } setFineTuning(ft); }); return null; } function XRMeasurementText({ text, color }: { text: string; color: string }) { const texture = useMemo(() => { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 64; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = 'rgba(11, 18, 32, 0.85)'; ctx.beginPath(); const x = 0, y = 0, width = 256, height = 64, radius = 12; ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.stroke(); ctx.fillStyle = color; ctx.font = 'bold 24px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 128, 32); } const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.SRGBColorSpace; return tex; }, [text, color]); return ( ); } export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) { return ( ); } export function XRLocalModelMeasurements({ modelId }: { modelId: string }) { const measurements = useModelStore((s) => s.measurements); const modelMeasurements = useMemo(() => { return measurements.filter(m => m.modelId === modelId); }, [measurements, modelId]); return ( <> {modelMeasurements.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]; const isHole = m.kind === 'hole'; const color = isHole ? '#f59e0b' : '#3b82f6'; const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`; return ( {!isHole && ( <> )} ); })} ); } export function XRMeasurementOverlay() { const measurements = useModelStore((s) => s.measurements); const measurePoints = useModelStore((s) => s.measurePoints); const snapPoint = useModelStore((s) => s.snapPoint); const measureMode = useModelStore((s) => s.measureMode); const globalMeasurements = useMemo(() => { return measurements.filter(m => !m.modelId); }, [measurements]); return ( <> {measureMode && snapPoint && ( )} {measurePoints.length === 1 && ( )} {globalMeasurements.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]; const isHole = m.kind === 'hole'; const color = isHole ? '#f59e0b' : '#3b82f6'; const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`; return ( {!isHole && ( <> )} ); })} ); } export function XRSnapHandler() { const { camera, scene, gl } = useThree(); const measureMode = useModelStore((s) => s.measureMode); const setSnapPoint = useModelStore((s) => s.setSnapPoint); const addMeasurePoint = useModelStore((s) => s.addMeasurePoint); const snapPoint = useModelStore((s) => s.snapPoint); const raycaster = useMemo(() => new THREE.Raycaster(), []); const mouse = useMemo(() => new THREE.Vector2(), []); useEffect(() => { if (!measureMode) 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; }; gl.domElement.addEventListener('pointermove', onMove); return () => gl.domElement.removeEventListener('pointermove', onMove); }, [gl, mouse, measureMode]); useFrame(() => { if (gl.xr.isPresenting) { setSnapPoint(null); return; } 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 || 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); const hitModelId = findModelId(hit.object); setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId } : null); } else { setSnapPoint(null); } }); useEffect(() => { if (!measureMode) return; if (gl.xr.isPresenting) return; const onClick = (e: MouseEvent) => { if (snapPoint) { addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z, modelId: snapPoint.modelId }); return; } 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 => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine); if (hit) { const hitModelId = findModelId(hit.object); addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId }); } }; gl.domElement.addEventListener('click', onClick); return () => gl.domElement.removeEventListener('click', onClick); }, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]); useEffect(() => { gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab'; return () => { gl.domElement.style.cursor = 'grab'; }; }, [measureMode, gl]); return null; } export function XRGrid() { const showGrid = useModelStore((s) => s.showGrid); const gridY = useModelStore((s) => s.gridY); if (!showGrid) return null; return ( ); } export function XRGridAutoFollower() { const models = useModelStore((s) => s.models); const { scene } = useThree(); useEffect(() => { if (!useModelStore.getState().gridAutoFollow) return; 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) { 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 }); }, 150); return () => clearTimeout(id); }, [models, scene]); return null; } export function XREnvironmentSelector() { const xrEnvironment = useModelStore((s) => s.xrEnvironment); if (xrEnvironment === 'passthrough') return null; return ( {/* Luzes adicionais para ambientes imersivos virtuais */} {/* Cenário Escritório */} {xrEnvironment === 'office' && ( {/* Carpete do escritório */} {/* Tampo da Mesa */} {/* Perna da Mesa */} {/* Base da perna */} )} {/* Cenário Campo Aberto */} {xrEnvironment === 'field' && ( {/* Chão de grama */} )} ); }