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:
gpt-engineer-app[bot]
2026-05-24 20:08:41 +00:00
4 changed files with 362 additions and 38 deletions
+140 -22
View File
@@ -1,7 +1,19 @@
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 } 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. */
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
@@ -10,17 +22,14 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
c.width = size;
c.height = size;
const ctx = c.getContext('2d')!;
// Background
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);
// Border
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, size - 6, size - 6);
// Label
ctx.fillStyle = '#e6faff';
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
ctx.textAlign = 'center';
@@ -35,7 +44,6 @@ function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
}
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: 'ESQ', dir: [-1, 0, 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] },
];
function CubeMesh() {
function CubeMesh({ calibrating }: { calibrating: boolean }) {
const meshRef = useRef<THREE.Mesh>(null);
const { camera: localCam } = useThree();
const [hover, setHover] = useState<number | null>(null);
@@ -70,7 +78,6 @@ function CubeMesh() {
};
}, [materials]);
// Apply hover tint
useEffect(() => {
materials.forEach((m, i) => {
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
@@ -78,14 +85,12 @@ function CubeMesh() {
});
}, [hover, materials]);
// Mirror main camera orientation each frame
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();
// Place local cam along same direction at fixed distance
localCam.position.copy(dir.multiplyScalar(3));
localCam.up.copy(main.up);
localCam.lookAt(0, 0, 0);
@@ -97,9 +102,16 @@ function CubeMesh() {
if (idx == null) return;
const def = FACE_DEFS[idx];
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 (
<mesh
ref={meshRef}
@@ -112,12 +124,11 @@ function CubeMesh() {
}}
onPointerOut={() => setHover(null)}
>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<boxGeometry args={[SIZE, SIZE, SIZE]} />
</mesh>
);
}
/** Tiny axis tripod next to the cube to reinforce the UCS reading. */
function AxisTripod() {
return (
<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() {
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 (
<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' }}
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]}
<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"
>
<ambientLight intensity={1} />
<CubeMesh />
<AxisTripod />
</Canvas>
<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>
);
}
+46 -8
View File
@@ -8,7 +8,7 @@ import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCirc
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
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 {
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 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(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
const box = new THREE.Box3().setFromObject(scene);
@@ -246,6 +250,18 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
? 'y'
: '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 (
<group
scale={[renderFactor, renderFactor, renderFactor]}
@@ -253,6 +269,28 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
e.stopPropagation();
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) {
const element = findElementRoot(e.object);
if (element) {
@@ -273,13 +311,13 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
rotation={[rotXRad, rotYRad, rotZRad]}
scale={[s, s, s]}
>
{/* Shift geometry so its center sits at the parent's origin (pivot = center).
This is the local frame where measurement points are stored, so measurements
follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */}
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
{/* Calibration rotation (innermost, around center). */}
<group ref={calGroupRef} quaternion={calQuat}>
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
</group>
</group>
</group>
</group>
+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') {
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));
}
+24 -3
View File
@@ -33,7 +33,7 @@ function saveXRFeatures(f: XRFeatureFlags) {
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> {
try {
const raw = localStorage.getItem(PLACEMENT_KEY);
@@ -41,10 +41,14 @@ function loadPlacements(): Record<string, StoredPlacement> {
} catch {}
return {};
}
function savePlacement(fileName: string, fineTuning: FineTuning) {
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
try {
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));
} catch {}
}
@@ -120,6 +124,8 @@ export interface SceneModel {
visible: boolean;
locked: boolean;
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;
@@ -191,6 +197,9 @@ interface ModelStore {
clearAllModels: () => void;
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) ─────────────
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
@@ -330,6 +339,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const fineTuning: FineTuning = saved?.fineTuning
? { ...saved.fineTuning }
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
const calibrationQuat = saved?.calibrationQuat ?? null;
const newModel: SceneModel = {
id,
fileName: m.fileName,
@@ -339,6 +349,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
visible: true,
locked: false,
fineTuning,
calibrationQuat,
};
const models = [...state.models, newModel];
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 } }),
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 ─────────────
model: null,
setModel: (m) => {
@@ -412,6 +432,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
color: MODEL_COLORS[0], visible: true, locked: false,
fineTuning: { ...defaultFineTuning },
calibrationQuat: null,
};
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
},