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 */}
+
+
+
+
+
+
);