Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-24 20:06:21 +00:00
parent ca12fffbf6
commit ce642ff505
+152 -5
View File
@@ -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));
}