From ce642ff505ece4a8dad12e19ae3ebb02edcd755e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 20:06:21 +0000 Subject: [PATCH] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/viewCubeBus.ts | 157 +++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 5 deletions(-) diff --git a/src/components/three/viewCubeBus.ts b/src/components/three/viewCubeBus.ts index 72d42fe..9e0d55a 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') { + // Compute and apply the rotation right away — third pair only verifies. + calibration.progress = 0.75; + calibration.step = 'await-cube-3'; + } else if (calibration.step === 'await-model-3') { + calibration.progress = 1; + 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)); +}