From 9b7f237afcf7ce40f9890f53ebe7a58ef09263c3 Mon Sep 17 00:00:00 2001 From: admtracksteel Date: Thu, 28 May 2026 21:16:09 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Auto-deploy:=20melhoria=20no=20s?= =?UTF-8?q?nap=20e=20medi=C3=A7=C3=A3o=20AR=20em=2028/05/2026=2021:16:09?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/three/XRHudInWorld.tsx | 136 +++++++++++++++++- src/components/three/XRViewCube.tsx | 125 +++++++++++++++++ src/components/three/xrCalibrationBus.ts | 168 +++++++++++++++++++++++ src/pages/XRSession.tsx | 83 +++++++++-- 4 files changed, 494 insertions(+), 18 deletions(-) create mode 100644 src/components/three/XRViewCube.tsx create mode 100644 src/components/three/xrCalibrationBus.ts diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx index 8f18227..b6e3cbf 100644 --- a/src/components/three/XRHudInWorld.tsx +++ b/src/components/three/XRHudInWorld.tsx @@ -12,8 +12,16 @@ import { stopRecording, captureScreenshot, formatElapsed, } from '@/hooks/useCanvasRecorder'; import { getAllModelLocalGroups } from '@/lib/modelTransforms'; +import { toast } from 'sonner'; +import { + xrCalibration, + startXRCalibration, + cancelXRCalibration, + subscribeXRCalibration, +} from './xrCalibrationBus'; +import { XRViewCube } from './XRViewCube'; -type Tab = 'scene' | 'tools' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr'; +type Tab = 'scene' | 'tools' | 'calib' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr'; interface XRHudInWorldProps { freeMove: boolean; @@ -225,6 +233,7 @@ function FloatingPanel({ const tabs: { id: Tab; label: string; icon: string }[] = [ { id: 'scene', label: 'Cena', icon: '🧩' }, { id: 'tools', label: 'Ferram.', icon: 'šŸ› ' }, + { id: 'calib', label: 'Calibrar', icon: 'šŸŽÆ' }, { id: 'cuts', label: 'Cortes', icon: 'āœ‚' }, { id: 'inspection', label: 'Inspeção', icon: 'āœ“' }, { id: 'capture', label: 'Captura', icon: 'šŸŽ„' }, @@ -257,15 +266,16 @@ function FloatingPanel({ {tabs.map((t, i) => ( setTab(t.id)} fontSize={0.0068} /> + onClick={() => setTab(t.id)} fontSize={0.0062} /> ))} {tab === 'scene' && } {tab === 'tools' && } + {tab === 'calib' && } {tab === 'cuts' && } {tab === 'inspection' && } {tab === 'capture' && } @@ -879,3 +889,123 @@ function RecIndicator() { ); } + +function CalibrationTab() { + const activeModelId = useModelStore((s) => s.activeModelId); + const models = useModelStore((s) => s.models); + const setCalibrationStore = useModelStore((s) => s.setCalibration); + const active = models.find((m) => m.id === activeModelId); + + // Escuta o barramento de calibração do XR + const [, force] = useState(0); + useEffect(() => subscribeXRCalibration(() => force((t) => t + 1)), []); + + const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done'; + const isDone = xrCalibration.step === 'done'; + const progress = isDone ? 1 : xrCalibration.progress; + + const onClickCalibrar = () => { + if (isCalibrating || isDone) { + cancelXRCalibration(); + return; + } + if (!active) { + toast.error('Selecione uma peƧa antes de calibrar'); + return; + } + if (active.locked) { + toast.error('PeƧa travada — destranque a peƧa para calibrar'); + return; + } + startXRCalibration(active.id); + }; + + const onResetCalibration = () => { + if (!active) return; + setCalibrationStore(active.id, null); + toast.success('Calibração removida'); + }; + + const STEP_HINTS_AR: Record = { + 'await-cube-1': '1/3 - Clique numa face do Cubo Ć  direita', + 'await-model-1': '1/3 - Aponte e clique na face correspondente da peƧa', + 'await-cube-2': '2/3 - Clique em outra face do Cubo', + 'await-model-2': '2/3 - Clique na face correspondente da peƧa', + 'await-cube-3': '3/3 (Verificar) - Clique em uma 3ĀŖ face do Cubo (opcional)', + 'await-model-3': '3/3 - Clique na face correspondente da peƧa', + 'done': 'Calibração concluĆ­da com sucesso!', + }; + + const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Clique em "Iniciar Calibrar" para alinhar o modelo'; + + return ( + + {/* Coluna da esquerda: InformaƧƵes e Fluxo */} + + Calibração 3D da PeƧa + + + + Alinhe os eixos intrĆ­nsecos do modelo 3D com a estrutura fĆ­sica correspondente no ambiente. + + + {/* Caixa de status do passo atual */} + + + + + + + {currentHint} + + {isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && ( + + Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}° + + )} + + + {/* BotƵes na parte inferior esquerda */} + + + + {active?.calibrationQuat && !isCalibrating && ( + + )} + + + + {active ? `PeƧa ativa: ${active.fileName}` : 'Selecione uma peƧa na aba "Cena"'} + + + {/* Coluna da direita: Cubo de views 3D interativo */} + + + {/* TripĆ© dos eixos abaixo do cubo */} + + + + + + + Cubo de Calibração + + + + ); +} diff --git a/src/components/three/XRViewCube.tsx b/src/components/three/XRViewCube.tsx new file mode 100644 index 0000000..466824d --- /dev/null +++ b/src/components/three/XRViewCube.tsx @@ -0,0 +1,125 @@ +import { useRef, useMemo, useEffect, useState } from 'react'; +import { useFrame, ThreeEvent } from '@react-three/fiber'; +import * as THREE from 'three'; +import { pushXRCubeFace } from './xrCalibrationBus'; +import { getModelLocalGroup } from '@/lib/modelTransforms'; + +function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture { + const size = 256; + const c = document.createElement('canvas'); + c.width = size; + c.height = size; + const ctx = c.getContext('2d')!; + const grad = ctx.createLinearGradient(0, 0, 0, size); + grad.addColorStop(0, accent ? '#1e3a8a' : '#111827'); + grad.addColorStop(1, accent ? '#0f172a' : '#030712'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + ctx.strokeStyle = '#3b82f6'; + ctx.lineWidth = 8; + ctx.strokeRect(4, 4, size - 8, size - 8); + ctx.fillStyle = '#ffffff'; + ctx.font = '700 52px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = '#3b82f6'; + ctx.shadowBlur = 10; + ctx.fillText(label, size / 2, size / 2); + const tex = new THREE.CanvasTexture(c); + tex.anisotropy = 4; + tex.needsUpdate = true; + return tex; +} + +const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [ + { label: 'DIR', dir: [ 1, 0, 0] }, + { label: 'ESQ', dir: [-1, 0, 0] }, + { label: 'TOPO', dir: [ 0, 1, 0] }, + { label: 'BASE', dir: [ 0,-1, 0] }, + { label: 'FRENTE', dir: [ 0, 0, 1] }, + { label: 'ATRƁS', dir: [ 0, 0,-1] }, +]; + +export function XRViewCube({ activeModelId, size = 0.06 }: { activeModelId: string | null; size?: number }) { + const groupRef = useRef(null); + const [hover, setHover] = useState(null); + + const materials = useMemo(() => { + return FACE_DEFS.map((f, i) => { + const tex = makeFaceTexture(f.label, false); + const mat = new THREE.MeshBasicMaterial({ map: tex }); + mat.userData.baseTex = tex; + mat.userData.hoverTex = makeFaceTexture(f.label, true); + mat.userData.faceIndex = i; + return mat; + }); + }, []); + + useEffect(() => { + return () => { + materials.forEach((m) => { + m.userData.baseTex?.dispose?.(); + m.userData.hoverTex?.dispose?.(); + m.dispose(); + }); + }; + }, [materials]); + + useEffect(() => { + materials.forEach((m, i) => { + m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex; + m.needsUpdate = true; + }); + }, [hover, materials]); + + const modelQuat = useMemo(() => new THREE.Quaternion(), []); + + const getModelWorldQuat = (): THREE.Quaternion => { + const g = getModelLocalGroup(activeModelId); + if (g) { + g.updateWorldMatrix(true, false); + g.getWorldQuaternion(modelQuat); + return modelQuat; + } + modelQuat.identity(); + return modelQuat; + }; + + useFrame(() => { + if (groupRef.current) { + // Sincroniza a rotação do cubo no HUD AR com a rotação do modelo ativo no mundo AR. + // Desta forma, o cubo reflete a orientação 3D da peƧa. + groupRef.current.quaternion.copy(getModelWorldQuat()); + } + }); + + const onClick = (e: ThreeEvent) => { + e.stopPropagation(); + const idx = e.face?.materialIndex; + if (idx == null) return; + const def = FACE_DEFS[idx]; + if (!def) return; + const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize(); + pushXRCubeFace(dirWorld); + }; + + return ( + + { + e.stopPropagation(); + const idx = e.face?.materialIndex ?? null; + setHover(idx); + }} + onPointerOut={(e) => { + e.stopPropagation(); + setHover(null); + }} + > + + + + ); +} diff --git a/src/components/three/xrCalibrationBus.ts b/src/components/three/xrCalibrationBus.ts new file mode 100644 index 0000000..43df044 --- /dev/null +++ b/src/components/three/xrCalibrationBus.ts @@ -0,0 +1,168 @@ +import * as THREE from 'three'; +import { useModelStore } from '@/stores/useModelStore'; + +export type XRCalibrationStep = + | 'idle' + | 'await-cube-1' + | 'await-model-1' + | 'await-cube-2' + | 'await-model-2' + | 'await-cube-3' // opcional verificação + | 'await-model-3' + | 'done'; + +interface XRPair { + cube: THREE.Vector3; + model: THREE.Vector3; +} + +interface XRCalState { + step: XRCalibrationStep; + modelId: string | null; + pairs: XRPair[]; + pendingCube: THREE.Vector3 | null; + progress: number; + verifyErrorDeg: number; + listeners: Set<() => void>; +} + +export const xrCalibration: XRCalState = { + step: 'idle', + modelId: null, + pairs: [], + pendingCube: null, + progress: 0, + verifyErrorDeg: NaN, + listeners: new Set(), +}; + +function notify() { + xrCalibration.listeners.forEach((fn) => { + try { + fn(); + } catch {} + }); +} + +export function subscribeXRCalibration(fn: () => void): () => void { + xrCalibration.listeners.add(fn); + return () => { + xrCalibration.listeners.delete(fn); + }; +} + +export function startXRCalibration(modelId: string) { + xrCalibration.step = 'await-cube-1'; + xrCalibration.modelId = modelId; + xrCalibration.pairs = []; + xrCalibration.pendingCube = null; + xrCalibration.progress = 0; + xrCalibration.verifyErrorDeg = NaN; + notify(); +} + +export function cancelXRCalibration() { + xrCalibration.step = 'idle'; + xrCalibration.modelId = null; + xrCalibration.pairs = []; + xrCalibration.pendingCube = null; + xrCalibration.progress = 0; + xrCalibration.verifyErrorDeg = NaN; + notify(); +} + +export function pushXRCubeFace(dirWorld: THREE.Vector3) { + if (xrCalibration.step === 'await-cube-1') { + xrCalibration.pendingCube = dirWorld.clone().normalize(); + xrCalibration.step = 'await-model-1'; + } else if (xrCalibration.step === 'await-cube-2') { + xrCalibration.pendingCube = dirWorld.clone().normalize(); + xrCalibration.step = 'await-model-2'; + } else if (xrCalibration.step === 'await-cube-3') { + xrCalibration.pendingCube = dirWorld.clone().normalize(); + xrCalibration.step = 'await-model-3'; + } else { + return; + } + notify(); +} + +function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 { + const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg)); + const axes = [ + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(-1, 0, 0), + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(0, 0, -1), + ]; + let best = v; + let bestDot = cosT; + for (const a of axes) { + const d = v.dot(a); + if (d > bestDot) { + bestDot = d; + best = a.clone(); + } + } + return best; +} + +export function pushXRModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) { + if (!xrCalibration.pendingCube) return; + const cubeWorld = xrCalibration.pendingCube; + const inv = calGroupWorldQuat.clone().invert(); + const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize(); + const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize(); + const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12); + xrCalibration.pairs.push({ cube: cubeLocal, model: modelLocal }); + xrCalibration.pendingCube = null; + + if (xrCalibration.step === 'await-model-1') { + xrCalibration.step = 'await-cube-2'; + xrCalibration.progress = 0.33; + } else if (xrCalibration.step === 'await-model-2') { + xrCalibration.progress = 0.75; + xrCalibration.step = 'await-cube-3'; + } else if (xrCalibration.step === 'await-model-3') { + xrCalibration.progress = 1; + xrCalibration.verifyErrorDeg = computeXRVerifyError(xrCalibration.pairs); + xrCalibration.step = 'done'; + } + notify(); +} + +export function computeXRCalibrationQuaternion(pairs: XRPair[]): THREE.Quaternion | null { + if (pairs.length < 2) return null; + const n1 = pairs[0].model.clone().normalize(); + const n2raw = pairs[1].model.clone(); + let n2 = n2raw.sub(n1.clone().multiplyScalar(n2raw.dot(n1))); + if (n2.lengthSq() < 1e-6) return null; + n2.normalize(); + const n3 = new THREE.Vector3().crossVectors(n1, n2); + + const c1 = pairs[0].cube.clone().normalize(); + const c2raw = pairs[1].cube.clone(); + let c2 = c2raw.sub(c1.clone().multiplyScalar(c2raw.dot(c1))); + if (c2.lengthSq() < 1e-6) return null; + c2.normalize(); + const c3 = new THREE.Vector3().crossVectors(c1, c2); + + const N = new THREE.Matrix4().makeBasis(n1, n2, n3); + const C = new THREE.Matrix4().makeBasis(c1, c2, c3); + const Nt = N.clone().transpose(); + const R = new THREE.Matrix4().multiplyMatrices(C, Nt); + const q = new THREE.Quaternion().setFromRotationMatrix(R); + return q; +} + +export function computeXRVerifyError(pairs: XRPair[]): number { + if (pairs.length < 3) return NaN; + const q = computeXRCalibrationQuaternion(pairs.slice(0, 2)); + if (!q) return NaN; + const predicted = pairs[2].model.clone().applyQuaternion(q).normalize(); + const target = pairs[2].cube.clone().normalize(); + const cos = THREE.MathUtils.clamp(predicted.dot(target), -1, 1); + return THREE.MathUtils.radToDeg(Math.acos(cos)); +} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index de7d61d..70c5ea7 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -26,6 +26,12 @@ import { VisibilityApplier } from '@/components/three/ModelViewer'; import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; import { parseIFCtoThree } from '@/lib/convertIFC'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { + xrCalibration, + pushXRModelFaceNormal, + computeXRCalibrationQuaternion, + subscribeXRCalibration, +} from '@/components/three/xrCalibrationBus'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); @@ -100,8 +106,15 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore'). }, [sceneModel.url, sceneModel.fileName]); const ref = useRef(null); + const calGroupRef = useRef(null); + const localFrameRef = useRef(null); + + // Escuta mudanƧas de calibração para re-renderizar no AR + const [, setCalTick] = useState(0); + useEffect(() => subscribeXRCalibration(() => setCalTick(t => t + 1)), []); + useEffect(() => { - const g = ref.current; + const g = localFrameRef.current; if (!g) return; registerModelLocalGroup(sceneModel.id, g); return () => unregisterModelLocalGroup(sceneModel.id, g); @@ -201,24 +214,64 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore'). const scaleRatio = useModelStore((st) => st.scaleRatio); const renderFactor = scaleRatio?.factor ?? 1; + const calQuatArr = sceneModel.calibrationQuat; + const isCalibratingThis = xrCalibration.modelId === sceneModel.id && xrCalibration.step !== 'idle' && xrCalibration.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]); + if (!sceneModel.visible) return null; if (!scene) return null; return ( - - - - + { + e.stopPropagation(); + + // Calibração XR: captura normal da face do modelo em espaƧo de mundo. + if ( + xrCalibration.modelId === sceneModel.id && + (xrCalibration.step === 'await-model-1' || xrCalibration.step === 'await-model-2' || xrCalibration.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); + pushXRModelFaceNormal(n, wq); + + // Aplica/atualiza calibração se tivermos >= 2 pares + if (xrCalibration.pairs.length >= 2) { + const q = computeXRCalibrationQuaternion(xrCalibration.pairs); + if (q) { + useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]); + } + } + return; + } + }} + > + {/* Translação */} + + {/* Rotação e escala em volta do centro geomĆ©trico */} + + {/* Rotação de calibração */} + + {/* Sistema local do modelo com pivot no centro */} + + + + + + );