ba87af34f4
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
import * as THREE from 'three';
|
|
|
|
/**
|
|
* Shared bus between the main 3D viewer and the floating ViewCube widget.
|
|
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
|
|
* - 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 };
|
|
export const mainControlsRef: { current: any | null } = { current: null };
|
|
|
|
// ── View animation ────────────────────────────────────────────────
|
|
export interface ViewAnim {
|
|
active: boolean;
|
|
startPos: THREE.Vector3;
|
|
endPos: THREE.Vector3;
|
|
startUp: THREE.Vector3;
|
|
endUp: THREE.Vector3;
|
|
t: number;
|
|
duration: number;
|
|
}
|
|
|
|
export const viewAnim: ViewAnim = {
|
|
active: false,
|
|
startPos: new THREE.Vector3(),
|
|
endPos: new THREE.Vector3(),
|
|
startUp: new THREE.Vector3(0, 1, 0),
|
|
endUp: new THREE.Vector3(0, 1, 0),
|
|
t: 0,
|
|
duration: 0.5,
|
|
};
|
|
|
|
export function requestView(dir: THREE.Vector3) {
|
|
const cam = mainCameraRef.current;
|
|
const controls = mainControlsRef.current;
|
|
if (!cam || !controls) return;
|
|
const target: THREE.Vector3 = controls.target ?? new THREE.Vector3();
|
|
const distance = cam.position.distanceTo(target);
|
|
const d = dir.clone().normalize();
|
|
const endPos = target.clone().add(d.multiplyScalar(distance));
|
|
const up = new THREE.Vector3(0, 1, 0);
|
|
if (Math.abs(dir.y) > 0.99) {
|
|
up.set(0, 0, dir.y > 0 ? -1 : 1);
|
|
}
|
|
viewAnim.startPos.copy(cam.position);
|
|
viewAnim.endPos.copy(endPos);
|
|
viewAnim.startUp.copy(cam.up);
|
|
viewAnim.endUp.copy(up);
|
|
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;
|
|
}
|
|
// Auto-rotate the main camera to look straight at the picked cube face
|
|
// so the user sees the corresponding model face head-on (especially helpful
|
|
// in orthographic mode where this guarantees a perpendicular click).
|
|
try { requestView(dirWorld.clone()); } catch {}
|
|
notify();
|
|
}
|
|
|
|
/** Snap a unit vector to the nearest principal axis (±X/±Y/±Z) when within
|
|
* `maxDeg` degrees of it. Returns the snapped vector or the input unchanged. */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize();
|
|
// Snap near-axial picks (chamfered or slightly off faces) to the closest
|
|
// principal axis. This dramatically improves robustness when picking faces
|
|
// from an orthographic head-on view.
|
|
const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12);
|
|
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));
|
|
}
|