Ajustou cubo para 70%
X-Lovable-Edit-ID: edt-aa853db0-95f5-4dd2-83b8-186372652765 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+140
-22
@@ -1,7 +1,19 @@
|
|||||||
import { useRef, useMemo, useEffect, useState } from 'react';
|
import { useRef, useMemo, useEffect, useState } from 'react';
|
||||||
import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber';
|
import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
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. */
|
/** Builds a square canvas texture with a label centered on it. */
|
||||||
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
||||||
@@ -10,17 +22,14 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
|||||||
c.width = size;
|
c.width = size;
|
||||||
c.height = size;
|
c.height = size;
|
||||||
const ctx = c.getContext('2d')!;
|
const ctx = c.getContext('2d')!;
|
||||||
// Background
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
||||||
grad.addColorStop(0, accent ? '#1a2742' : '#101725');
|
grad.addColorStop(0, accent ? '#1a2742' : '#101725');
|
||||||
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
|
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.fillRect(0, 0, size, size);
|
ctx.fillRect(0, 0, size, size);
|
||||||
// Border
|
|
||||||
ctx.strokeStyle = '#00f3ff';
|
ctx.strokeStyle = '#00f3ff';
|
||||||
ctx.lineWidth = 6;
|
ctx.lineWidth = 6;
|
||||||
ctx.strokeRect(3, 3, size - 6, size - 6);
|
ctx.strokeRect(3, 3, size - 6, size - 6);
|
||||||
// Label
|
|
||||||
ctx.fillStyle = '#e6faff';
|
ctx.fillStyle = '#e6faff';
|
||||||
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
|
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -35,7 +44,6 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
|
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: 'DIR', dir: [ 1, 0, 0] },
|
||||||
{ label: 'ESQ', dir: [-1, 0, 0] },
|
{ label: 'ESQ', dir: [-1, 0, 0] },
|
||||||
{ label: 'TOPO', dir: [ 0, 1, 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] },
|
{ label: 'ATRÁS', dir: [ 0, 0,-1] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function CubeMesh() {
|
function CubeMesh({ calibrating }: { calibrating: boolean }) {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
const { camera: localCam } = useThree();
|
const { camera: localCam } = useThree();
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
@@ -70,7 +78,6 @@ function CubeMesh() {
|
|||||||
};
|
};
|
||||||
}, [materials]);
|
}, [materials]);
|
||||||
|
|
||||||
// Apply hover tint
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
materials.forEach((m, i) => {
|
materials.forEach((m, i) => {
|
||||||
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
||||||
@@ -78,14 +85,12 @@ function CubeMesh() {
|
|||||||
});
|
});
|
||||||
}, [hover, materials]);
|
}, [hover, materials]);
|
||||||
|
|
||||||
// Mirror main camera orientation each frame
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const main = mainCameraRef.current;
|
const main = mainCameraRef.current;
|
||||||
const controls = mainControlsRef.current;
|
const controls = mainControlsRef.current;
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
|
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
|
||||||
const dir = new THREE.Vector3().subVectors(main.position, target).normalize();
|
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.position.copy(dir.multiplyScalar(3));
|
||||||
localCam.up.copy(main.up);
|
localCam.up.copy(main.up);
|
||||||
localCam.lookAt(0, 0, 0);
|
localCam.lookAt(0, 0, 0);
|
||||||
@@ -97,9 +102,16 @@ function CubeMesh() {
|
|||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
const def = FACE_DEFS[idx];
|
const def = FACE_DEFS[idx];
|
||||||
if (!def) return;
|
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 (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
ref={meshRef}
|
ref={meshRef}
|
||||||
@@ -112,12 +124,11 @@ function CubeMesh() {
|
|||||||
}}
|
}}
|
||||||
onPointerOut={() => setHover(null)}
|
onPointerOut={() => setHover(null)}
|
||||||
>
|
>
|
||||||
<boxGeometry args={[1.4, 1.4, 1.4]} />
|
<boxGeometry args={[SIZE, SIZE, SIZE]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tiny axis tripod next to the cube to reinforce the UCS reading. */
|
|
||||||
function AxisTripod() {
|
function AxisTripod() {
|
||||||
return (
|
return (
|
||||||
<group position={[-1.2, -1.2, 0]}>
|
<group position={[-1.2, -1.2, 0]}>
|
||||||
@@ -128,22 +139,129 @@ function AxisTripod() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STEP_HINTS: Record<string, string> = {
|
||||||
|
'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() {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute right-3 top-3 z-20 h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
|
className="absolute right-3 top-3 z-20 w-[120px] flex flex-col items-stretch gap-1.5"
|
||||||
style={{ pointerEvents: 'auto' }}
|
style={{ pointerEvents: 'auto' }}
|
||||||
title="Cubo de vistas — clique numa face para girar"
|
|
||||||
>
|
>
|
||||||
<Canvas
|
<div
|
||||||
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
|
className="h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
|
||||||
gl={{ antialias: true, alpha: true }}
|
title="Cubo de vistas — clique numa face para girar"
|
||||||
dpr={[1, 2]}
|
|
||||||
>
|
>
|
||||||
<ambientLight intensity={1} />
|
<Canvas
|
||||||
<CubeMesh />
|
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
|
||||||
<AxisTripod />
|
gl={{ antialias: true, alpha: true }}
|
||||||
</Canvas>
|
dpr={[1, 2]}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={1} />
|
||||||
|
<CubeMesh calibrating={isCalibrating} />
|
||||||
|
<AxisTripod />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calibrar button with progress fill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClickCalibrar}
|
||||||
|
className={[
|
||||||
|
'relative h-7 w-full overflow-hidden rounded border font-mono text-[10px] uppercase tracking-widest transition-colors',
|
||||||
|
isDone
|
||||||
|
? 'border-success/60 bg-success/20 text-success'
|
||||||
|
: isCalibrating
|
||||||
|
? 'border-amber-400/60 bg-amber-400/10 text-amber-300'
|
||||||
|
: 'border-primary/40 bg-background/40 text-primary hover:bg-primary/10',
|
||||||
|
].join(' ')}
|
||||||
|
title={isCalibrating ? 'Cancelar calibração' : 'Calibrar orientação da peça com o cubo'}
|
||||||
|
>
|
||||||
|
{/* progress bar */}
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'absolute inset-y-0 left-0 transition-all duration-200',
|
||||||
|
isDone ? 'bg-success/40' : 'bg-primary/30',
|
||||||
|
].join(' ')}
|
||||||
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10 flex items-center justify-center gap-1">
|
||||||
|
{isDone ? <Check className="h-3 w-3" /> : null}
|
||||||
|
{isDone ? 'Calibrado' : isCalibrating ? 'Cancelar' : 'Calibrar'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Step hint while calibrating */}
|
||||||
|
{(isCalibrating || isDone) && (
|
||||||
|
<div className="rounded border border-primary/30 bg-background/80 px-2 py-1 text-center font-mono text-[9px] leading-tight text-foreground/90 backdrop-blur-sm">
|
||||||
|
{STEP_HINTS[calibration.step] ?? ''}
|
||||||
|
{isDone && Number.isFinite(calibration.verifyErrorDeg) && (
|
||||||
|
<div className="mt-0.5 text-success">
|
||||||
|
erro {calibration.verifyErrorDeg.toFixed(1)}°
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset existing calibration */}
|
||||||
|
{!isCalibrating && active?.calibrationQuat && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResetCalibration}
|
||||||
|
className="flex h-6 w-full items-center justify-center gap-1 rounded border border-muted-foreground/30 bg-background/40 font-mono text-[9px] uppercase tracking-widest text-muted-foreground hover:text-foreground hover:border-muted-foreground/60"
|
||||||
|
title="Remover calibração salva"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-2.5 w-2.5" /> reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCirc
|
|||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
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 {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
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 measureMode = useModelStore((s) => s.measureMode);
|
||||||
const fineTuning = sceneModel.fineTuning;
|
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(() => {
|
const modelInfo = useMemo(() => {
|
||||||
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
@@ -246,6 +250,18 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
? 'y'
|
? 'y'
|
||||||
: 'z';
|
: '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<THREE.Group>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
scale={[renderFactor, renderFactor, renderFactor]}
|
scale={[renderFactor, renderFactor, renderFactor]}
|
||||||
@@ -253,6 +269,28 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if ((e.delta ?? 0) > 4) return;
|
if ((e.delta ?? 0) > 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) {
|
if (selectionMode) {
|
||||||
const element = findElementRoot(e.object);
|
const element = findElementRoot(e.object);
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -273,13 +311,13 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||||
scale={[s, s, s]}
|
scale={[s, s, s]}
|
||||||
>
|
>
|
||||||
{/* Shift geometry so its center sits at the parent's origin (pivot = center).
|
{/* Calibration rotation (innermost, around center). */}
|
||||||
This is the local frame where measurement points are stored, so measurements
|
<group ref={calGroupRef} quaternion={calQuat}>
|
||||||
follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */}
|
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
|
||||||
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
|
<primitive object={scene} />
|
||||||
<primitive object={scene} />
|
<ModelMeasurements modelId={sceneModel.id} />
|
||||||
<ModelMeasurements modelId={sceneModel.id} />
|
</ModelLocalFrame>
|
||||||
</ModelLocalFrame>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import * as THREE from 'three';
|
|||||||
/**
|
/**
|
||||||
* Shared bus between the main 3D viewer and the floating ViewCube widget.
|
* Shared bus between the main 3D viewer and the floating ViewCube widget.
|
||||||
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
|
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
|
||||||
* - requestView(dir): asks the main camera to animate to a canonical view
|
* - viewAnim: smooth camera animation when a face is clicked
|
||||||
* along the given world-space direction (unit vector pointing FROM target
|
* - calibration: orchestrates a 4-click (+ optional 2-click verify) procedure
|
||||||
* TO camera). e.g. (0,1,0) = top view, (0,0,1) = front.
|
* to align the active model's intrinsic axes to the world axes.
|
||||||
*/
|
*/
|
||||||
export const mainCameraRef: { current: THREE.Camera | null } = { current: null };
|
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 };
|
export const mainControlsRef: { current: any | null } = { current: null };
|
||||||
|
|
||||||
|
// ── View animation ────────────────────────────────────────────────
|
||||||
export interface ViewAnim {
|
export interface ViewAnim {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
startPos: THREE.Vector3;
|
startPos: THREE.Vector3;
|
||||||
@@ -39,7 +39,6 @@ export function requestView(dir: THREE.Vector3) {
|
|||||||
const distance = cam.position.distanceTo(target);
|
const distance = cam.position.distanceTo(target);
|
||||||
const d = dir.clone().normalize();
|
const d = dir.clone().normalize();
|
||||||
const endPos = target.clone().add(d.multiplyScalar(distance));
|
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);
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
if (Math.abs(dir.y) > 0.99) {
|
if (Math.abs(dir.y) > 0.99) {
|
||||||
up.set(0, 0, dir.y > 0 ? -1 : 1);
|
up.set(0, 0, dir.y > 0 ? -1 : 1);
|
||||||
@@ -51,3 +50,151 @@ export function requestView(dir: THREE.Vector3) {
|
|||||||
viewAnim.t = 0;
|
viewAnim.t = 0;
|
||||||
viewAnim.active = true;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function saveXRFeatures(f: XRFeatureFlags) {
|
|||||||
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {}
|
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<string, StoredPlacement> {
|
function loadPlacements(): Record<string, StoredPlacement> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PLACEMENT_KEY);
|
const raw = localStorage.getItem(PLACEMENT_KEY);
|
||||||
@@ -41,10 +41,14 @@ function loadPlacements(): Record<string, StoredPlacement> {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function savePlacement(fileName: string, fineTuning: FineTuning) {
|
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
|
||||||
try {
|
try {
|
||||||
const all = loadPlacements();
|
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));
|
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -120,6 +124,8 @@ export interface SceneModel {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
fineTuning: FineTuning;
|
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;
|
const MAX_MODELS = 5;
|
||||||
@@ -191,6 +197,9 @@ interface ModelStore {
|
|||||||
clearAllModels: () => void;
|
clearAllModels: () => void;
|
||||||
maxModels: number;
|
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) ─────────────
|
// ── Legacy single-model accessors (proxy to active) ─────────────
|
||||||
model: ModelInfo | null;
|
model: ModelInfo | null;
|
||||||
setModel: (model: ModelInfo | null) => void;
|
setModel: (model: ModelInfo | null) => void;
|
||||||
@@ -330,6 +339,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const fineTuning: FineTuning = saved?.fineTuning
|
const fineTuning: FineTuning = saved?.fineTuning
|
||||||
? { ...saved.fineTuning }
|
? { ...saved.fineTuning }
|
||||||
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
|
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
|
||||||
|
const calibrationQuat = saved?.calibrationQuat ?? null;
|
||||||
const newModel: SceneModel = {
|
const newModel: SceneModel = {
|
||||||
id,
|
id,
|
||||||
fileName: m.fileName,
|
fileName: m.fileName,
|
||||||
@@ -339,6 +349,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
visible: true,
|
visible: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
fineTuning,
|
fineTuning,
|
||||||
|
calibrationQuat,
|
||||||
};
|
};
|
||||||
const models = [...state.models, newModel];
|
const models = [...state.models, newModel];
|
||||||
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
|
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
|
||||||
@@ -399,6 +410,15 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
|
|
||||||
clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }),
|
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 ─────────────
|
// ── Legacy single-model proxies ─────────────
|
||||||
model: null,
|
model: null,
|
||||||
setModel: (m) => {
|
setModel: (m) => {
|
||||||
@@ -412,6 +432,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
|
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
|
||||||
color: MODEL_COLORS[0], visible: true, locked: false,
|
color: MODEL_COLORS[0], visible: true, locked: false,
|
||||||
fineTuning: { ...defaultFineTuning },
|
fineTuning: { ...defaultFineTuning },
|
||||||
|
calibrationQuat: null,
|
||||||
};
|
};
|
||||||
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
|
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user