Files
SteelXR/src/components/ViewCube.tsx
T
gpt-engineer-app[bot] e444a3a20f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:04 +00:00

303 lines
11 KiB
TypeScript

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,
calibration,
startCalibration,
cancelCalibration,
pushCubeFace,
subscribeCalibration,
} from './three/viewCubeBus';
import { useModelStore } from '@/stores/useModelStore';
import { getModelLocalGroup } from '@/lib/modelTransforms';
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 {
const size = 256;
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const ctx = c.getContext('2d')!;
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);
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, size - 6, size - 6);
ctx.fillStyle = '#e6faff';
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = '#00f3ff';
ctx.shadowBlur = 18;
ctx.fillText(label, size / 2, size / 2);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 4;
tex.needsUpdate = true;
return tex;
}
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
{ label: 'DIR', dir: [ 1, 0, 0] },
{ label: 'ESQ', dir: [-1, 0, 0] },
{ label: 'TOPO', dir: [ 0, 1, 0] },
{ label: 'BASE', dir: [ 0,-1, 0] },
{ label: 'FRENTE', dir: [ 0, 0, 1] },
{ label: 'ATRÁS', dir: [ 0, 0,-1] },
];
function CubeMesh({ calibrating, activeModelId }: { calibrating: boolean; activeModelId: string | null }) {
const meshRef = useRef<THREE.Mesh>(null);
const groupRef = useRef<THREE.Group>(null);
const { camera: localCam } = useThree();
const [hover, setHover] = useState<number | null>(null);
const materials = useMemo(() => {
return FACE_DEFS.map((f, i) => {
const tex = makeFaceTexture(f.label, false);
const mat = new THREE.MeshBasicMaterial({ map: tex });
mat.userData.baseTex = tex;
mat.userData.hoverTex = makeFaceTexture(f.label, true);
mat.userData.faceIndex = i;
return mat;
});
}, []);
useEffect(() => {
return () => {
materials.forEach((m) => {
m.userData.baseTex?.dispose?.();
m.userData.hoverTex?.dispose?.();
m.dispose();
});
};
}, [materials]);
useEffect(() => {
materials.forEach((m, i) => {
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
m.needsUpdate = true;
});
}, [hover, materials]);
// Reusable temp objects for the per-frame model-quaternion sync.
const modelQuat = useMemo(() => new THREE.Quaternion(), []);
/** Returns the active model's current world quaternion (fineTuning rotation
* + calibration). Used to keep the cube glued to the piece's pose. */
const getModelWorldQuat = (): THREE.Quaternion => {
const g = getModelLocalGroup(activeModelId);
if (g) {
g.updateWorldMatrix(true, false);
g.getWorldQuaternion(modelQuat);
return modelQuat;
}
modelQuat.identity();
return modelQuat;
};
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();
localCam.position.copy(dir.multiplyScalar(3));
localCam.up.copy(main.up);
localCam.lookAt(0, 0, 0);
// Sync the cube group orientation with the active model so it always
// mirrors the piece's pose (rotation from Posicionar, calibration, etc).
if (groupRef.current) {
groupRef.current.quaternion.copy(getModelWorldQuat());
}
});
const onClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
const idx = e.face?.materialIndex;
if (idx == null) return;
const def = FACE_DEFS[idx];
if (!def) return;
// Convert the local cube-face direction to world space using the model's
// current world quaternion — this keeps cube ↔ model coherent after any
// rotation/translation/reset of the piece.
const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize();
if (calibrating) {
pushCubeFace(dirWorld);
} else {
requestView(dirWorld);
}
};
// 70% of previous 1.4 → ~0.98
const SIZE = 0.98;
return (
<group ref={groupRef}>
<mesh
ref={meshRef}
material={materials}
onClick={onClick}
onPointerMove={(e) => {
e.stopPropagation();
const idx = e.face?.materialIndex ?? null;
setHover(idx);
}}
onPointerOut={() => setHover(null)}
>
<boxGeometry args={[SIZE, SIZE, SIZE]} />
</mesh>
<AxisTripod />
</group>
);
}
function AxisTripod() {
return (
<group position={[-1.2, -1.2, 0]}>
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
</group>
);
}
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() {
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 = () => {
const store = useModelStore.getState();
if (isCalibrating) {
// Cancel mid-flow — restore previous camera mode if we forced ortho.
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
cancelCalibration();
toast.info('Calibração cancelada');
return;
}
if (isDone) {
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
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;
}
// Force orthographic view for accurate face picking.
(window as any).__tsxr_prevCamMode = store.cameraMode;
if (store.cameraMode !== 'ortho') store.setCameraMode('ortho');
startCalibration(active.id);
toast('Calibração em vista ortogonal — 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 (
<div
className="absolute right-3 top-3 z-20 w-[120px] flex flex-col items-stretch gap-1.5"
style={{ pointerEvents: 'auto' }}
>
<div
className="h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
title="Cubo de vistas — clique numa face para girar"
>
<Canvas
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
gl={{ antialias: true, alpha: true }}
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>
);
}