diff --git a/src/components/ViewCube.tsx b/src/components/ViewCube.tsx index 44a3074..0e79926 100644 --- a/src/components/ViewCube.tsx +++ b/src/components/ViewCube.tsx @@ -1,7 +1,19 @@ import { useRef, useMemo, useEffect, useState } from 'react'; import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber'; import * as THREE from 'three'; -import { mainCameraRef, mainControlsRef, requestView } from './three/viewCubeBus'; +import { + mainCameraRef, + mainControlsRef, + requestView, + calibration, + startCalibration, + cancelCalibration, + pushCubeFace, + subscribeCalibration, +} from './three/viewCubeBus'; +import { useModelStore } from '@/stores/useModelStore'; +import { Check, RotateCcw } from 'lucide-react'; +import { toast } from 'sonner'; /** Builds a square canvas texture with a label centered on it. */ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture { @@ -10,17 +22,14 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture { c.width = size; c.height = size; const ctx = c.getContext('2d')!; - // Background const grad = ctx.createLinearGradient(0, 0, 0, size); grad.addColorStop(0, accent ? '#1a2742' : '#101725'); grad.addColorStop(1, accent ? '#0c1424' : '#070b14'); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); - // Border ctx.strokeStyle = '#00f3ff'; ctx.lineWidth = 6; ctx.strokeRect(3, 3, size - 6, size - 6); - // Label ctx.fillStyle = '#e6faff'; ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace'; ctx.textAlign = 'center'; @@ -35,7 +44,6 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture { } const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [ - // BoxGeometry material order: +X, -X, +Y, -Y, +Z, -Z { label: 'DIR', dir: [ 1, 0, 0] }, { label: 'ESQ', dir: [-1, 0, 0] }, { label: 'TOPO', dir: [ 0, 1, 0] }, @@ -44,7 +52,7 @@ const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [ { label: 'ATRÁS', dir: [ 0, 0,-1] }, ]; -function CubeMesh() { +function CubeMesh({ calibrating }: { calibrating: boolean }) { const meshRef = useRef(null); const { camera: localCam } = useThree(); const [hover, setHover] = useState(null); @@ -70,7 +78,6 @@ function CubeMesh() { }; }, [materials]); - // Apply hover tint useEffect(() => { materials.forEach((m, i) => { m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex; @@ -78,14 +85,12 @@ function CubeMesh() { }); }, [hover, materials]); - // Mirror main camera orientation each frame useFrame(() => { const main = mainCameraRef.current; const controls = mainControlsRef.current; if (!main) return; const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3(); const dir = new THREE.Vector3().subVectors(main.position, target).normalize(); - // Place local cam along same direction at fixed distance localCam.position.copy(dir.multiplyScalar(3)); localCam.up.copy(main.up); localCam.lookAt(0, 0, 0); @@ -97,9 +102,16 @@ function CubeMesh() { if (idx == null) return; const def = FACE_DEFS[idx]; if (!def) return; - requestView(new THREE.Vector3(...def.dir)); + const dirVec = new THREE.Vector3(...def.dir); + if (calibrating) { + pushCubeFace(dirVec); + } else { + requestView(dirVec); + } }; + // 70% of previous 1.4 → ~0.98 + const SIZE = 0.98; return ( setHover(null)} > - + ); } -/** Tiny axis tripod next to the cube to reinforce the UCS reading. */ function AxisTripod() { return ( @@ -128,22 +139,129 @@ function AxisTripod() { ); } +const STEP_HINTS: Record = { + 'await-cube-1': '1/3 — clique numa face do cubo', + 'await-model-1': '1/3 — clique na face correspondente da peça', + 'await-cube-2': '2/3 — clique noutra face do cubo', + 'await-model-2': '2/3 — clique na face correspondente da peça', + 'await-cube-3': '3/3 (verificação) — clique numa terceira face do cubo (opcional)', + 'await-model-3': '3/3 (verificação) — clique na face correspondente da peça', + 'done': 'Calibração concluída', +}; + export function ViewCube() { + const [, force] = useState(0); + useEffect(() => subscribeCalibration(() => force(t => t + 1)), []); + 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); + + const isCalibrating = calibration.step !== 'idle' && calibration.step !== 'done'; + const isDone = calibration.step === 'done'; + const progress = isDone ? 1 : calibration.progress; + + const onClickCalibrar = () => { + if (isCalibrating) { + // Cancel mid-flow + cancelCalibration(); + toast.info('Calibração cancelada'); + return; + } + if (isDone) { + // Reset/finish state + cancelCalibration(); + return; + } + if (!active) { + toast.error('Selecione uma peça antes de calibrar'); + return; + } + if (active.locked) { + toast.error('Peça travada — destranque o cadeado para calibrar'); + return; + } + startCalibration(active.id); + toast('Calibração iniciada — clique numa face do cubo, depois na face correspondente da peça', { duration: 4000 }); + }; + + const onResetCalibration = () => { + if (!active) return; + setCalibrationStore(active.id, null); + toast.success('Calibração removida'); + }; + return (
- - - - - + + + + + +
+ + {/* Calibrar button with progress fill */} + + + {/* Step hint while calibrating */} + {(isCalibrating || isDone) && ( +
+ {STEP_HINTS[calibration.step] ?? ''} + {isDone && Number.isFinite(calibration.verifyErrorDeg) && ( +
+ erro {calibration.verifyErrorDeg.toFixed(1)}° +
+ )} +
+ )} + + {/* Reset existing calibration */} + {!isCalibrating && active?.calibrationQuat && ( + + )} ); } diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 0b8cde3..bd176c3 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -8,7 +8,7 @@ import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCirc import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms'; import { parseIFCtoThree } from '@/lib/convertIFC'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import { mainCameraRef, mainControlsRef, viewAnim } from './viewCubeBus'; +import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus'; interface ModelViewerProps { url?: string; // legacy, ignored — uses store.models @@ -143,6 +143,10 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: 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); @@ -246,6 +250,18 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: ? 'y' : 'z'; + // 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); + return ( 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) { @@ -273,13 +311,13 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: rotation={[rotXRad, rotYRad, rotZRad]} scale={[s, s, s]} > - {/* 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. */} - - - - + {/* Calibration rotation (innermost, around center). */} + + + + + +
diff --git a/src/components/three/viewCubeBus.ts b/src/components/three/viewCubeBus.ts index 72d42fe..643247f 100644 --- a/src/components/three/viewCubeBus.ts +++ b/src/components/three/viewCubeBus.ts @@ -3,14 +3,14 @@ import * as THREE from 'three'; /** * Shared bus between the main 3D viewer and the floating ViewCube widget. * - mainCameraRef / mainControlsRef: written by ModelViewer each frame - * - requestView(dir): asks the main camera to animate to a canonical view - * along the given world-space direction (unit vector pointing FROM target - * TO camera). e.g. (0,1,0) = top view, (0,0,1) = front. + * - viewAnim: smooth camera animation when a face is clicked + * - calibration: orchestrates a 4-click (+ optional 2-click verify) procedure + * to align the active model's intrinsic axes to the world axes. */ export const mainCameraRef: { current: THREE.Camera | null } = { current: null }; -// OrbitControls instance — typed loosely to avoid drei type churn export const mainControlsRef: { current: any | null } = { current: null }; +// ── View animation ──────────────────────────────────────────────── export interface ViewAnim { active: boolean; startPos: THREE.Vector3; @@ -39,7 +39,6 @@ export function requestView(dir: THREE.Vector3) { const distance = cam.position.distanceTo(target); const d = dir.clone().normalize(); const endPos = target.clone().add(d.multiplyScalar(distance)); - // Up vector: keep Y up unless we're looking straight up/down const up = new THREE.Vector3(0, 1, 0); if (Math.abs(dir.y) > 0.99) { up.set(0, 0, dir.y > 0 ? -1 : 1); @@ -51,3 +50,151 @@ export function requestView(dir: THREE.Vector3) { viewAnim.t = 0; viewAnim.active = true; } + +// ── Calibration state machine ───────────────────────────────────── +export type CalibrationStep = + | 'idle' + | 'await-cube-1' + | 'await-model-1' + | 'await-cube-2' + | 'await-model-2' + | 'await-cube-3' // optional verification + | 'await-model-3' + | 'done'; + +interface Pair { cube: THREE.Vector3; model: THREE.Vector3 } + +interface CalState { + step: CalibrationStep; + modelId: string | null; + pairs: Pair[]; + pendingCube: THREE.Vector3 | null; + /** 0..1 progress for the button visual. */ + progress: number; + /** Last verification angular error, in degrees. NaN until verify pair captured. */ + verifyErrorDeg: number; + /** Listener notified on every state change so the UI re-renders. */ + listeners: Set<() => void>; +} + +export const calibration: CalState = { + step: 'idle', + modelId: null, + pairs: [], + pendingCube: null, + progress: 0, + verifyErrorDeg: NaN, + listeners: new Set(), +}; + +function notify() { calibration.listeners.forEach(fn => { try { fn(); } catch {} }); } + +export function subscribeCalibration(fn: () => void): () => void { + calibration.listeners.add(fn); + return () => { calibration.listeners.delete(fn); }; +} + +export function startCalibration(modelId: string) { + calibration.step = 'await-cube-1'; + calibration.modelId = modelId; + calibration.pairs = []; + calibration.pendingCube = null; + calibration.progress = 0; + calibration.verifyErrorDeg = NaN; + notify(); +} + +export function cancelCalibration() { + calibration.step = 'idle'; + calibration.modelId = null; + calibration.pairs = []; + calibration.pendingCube = null; + calibration.progress = 0; + calibration.verifyErrorDeg = NaN; + notify(); +} + +/** Called when user clicks a cube face during calibration. */ +export function pushCubeFace(dirWorld: THREE.Vector3) { + if (calibration.step === 'await-cube-1') { + calibration.pendingCube = dirWorld.clone().normalize(); + calibration.step = 'await-model-1'; + } else if (calibration.step === 'await-cube-2') { + calibration.pendingCube = dirWorld.clone().normalize(); + calibration.step = 'await-model-2'; + } else if (calibration.step === 'await-cube-3') { + calibration.pendingCube = dirWorld.clone().normalize(); + calibration.step = 'await-model-3'; + } else { + return; + } + notify(); +} + +/** + * Called when user clicks a model face during calibration. + * @param normalWorld world-space face normal of the clicked face + * @param calGroupWorldQuat current world quaternion of the calibration group + * (used to map both cube and model directions into the same local frame). + */ +export function pushModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) { + if (!calibration.pendingCube) return; + const cubeWorld = calibration.pendingCube; + const inv = calGroupWorldQuat.clone().invert(); + const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize(); + const modelLocal = normalWorld.clone().applyQuaternion(inv).normalize(); + calibration.pairs.push({ cube: cubeLocal, model: modelLocal }); + calibration.pendingCube = null; + + if (calibration.step === 'await-model-1') { + calibration.step = 'await-cube-2'; + calibration.progress = 0.33; + } else if (calibration.step === 'await-model-2') { + calibration.progress = 0.75; + calibration.step = 'await-cube-3'; + } else if (calibration.step === 'await-model-3') { + calibration.progress = 1; + calibration.verifyErrorDeg = computeVerifyError(calibration.pairs); + calibration.step = 'done'; + } + notify(); +} + +/** Computes a quaternion R such that R*pairs[i].model ≈ pairs[i].cube using + * Gram-Schmidt to ortho-normalize both bases. Returns null if degenerate. */ +export function computeCalibrationQuaternion(pairs: Pair[]): 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); + + // R = C * Nᵀ where columns of N are (n1,n2,n3) and C are (c1,c2,c3). + // In three.js Matrix4: makeBasis sets columns. + 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; +} + +/** Predicted angular error of the verify pair, in degrees. */ +export function computeVerifyError(pairs: Pair[]): number { + if (pairs.length < 3) return NaN; + const q = computeCalibrationQuaternion(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/stores/useModelStore.ts b/src/stores/useModelStore.ts index abdf245..cd1821b 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -33,7 +33,7 @@ function saveXRFeatures(f: XRFeatureFlags) { try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {} } -interface StoredPlacement { fineTuning: FineTuning; } +interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; } function loadPlacements(): Record { try { const raw = localStorage.getItem(PLACEMENT_KEY); @@ -41,10 +41,14 @@ function loadPlacements(): Record { } catch {} return {}; } -function savePlacement(fileName: string, fineTuning: FineTuning) { +function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) { try { const all = loadPlacements(); - all[fileName] = { fineTuning }; + const prev = all[fileName] ?? { fineTuning }; + all[fileName] = { + fineTuning, + calibrationQuat: calibrationQuat === undefined ? prev.calibrationQuat ?? null : calibrationQuat, + }; localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all)); } catch {} } @@ -120,6 +124,8 @@ export interface SceneModel { visible: boolean; locked: boolean; fineTuning: FineTuning; + /** Local rotation (xyzw) that aligns the model's intrinsic axes to world axes. */ + calibrationQuat: [number, number, number, number] | null; } const MAX_MODELS = 5; @@ -191,6 +197,9 @@ interface ModelStore { clearAllModels: () => void; maxModels: number; + /** Calibração (rotação local) ↔ ViewCube. Aplicada antes do fineTuning rotation. */ + setCalibration: (id: string, quat: [number, number, number, number] | null) => void; + // ── Legacy single-model accessors (proxy to active) ───────────── model: ModelInfo | null; setModel: (model: ModelInfo | null) => void; @@ -330,6 +339,7 @@ export const useModelStore = create((set, get) => ({ const fineTuning: FineTuning = saved?.fineTuning ? { ...saved.fineTuning } : { ...defaultFineTuning, posX: offsetIdx * 0.15 }; + const calibrationQuat = saved?.calibrationQuat ?? null; const newModel: SceneModel = { id, fileName: m.fileName, @@ -339,6 +349,7 @@ export const useModelStore = create((set, get) => ({ visible: true, locked: false, fineTuning, + calibrationQuat, }; const models = [...state.models, newModel]; set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) }); @@ -399,6 +410,15 @@ export const useModelStore = create((set, get) => ({ clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }), + setCalibration: (id, quat) => set((state) => { + const models = state.models.map(m => { + if (m.id !== id) return m; + savePlacement(m.fileName, m.fineTuning, quat); + return { ...m, calibrationQuat: quat }; + }); + return { models }; + }), + // ── Legacy single-model proxies ───────────── model: null, setModel: (m) => { @@ -412,6 +432,7 @@ export const useModelStore = create((set, get) => ({ id, fileName: m.fileName, fileSize: m.fileSize, url: m.url, color: MODEL_COLORS[0], visible: true, locked: false, fineTuning: { ...defaultFineTuning }, + calibrationQuat: null, }; set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) }); },