Files
SteelXR/src/components/three/XRHudInWorld.tsx
T

1150 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState, useEffect, forwardRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { useXRInputSourceState, useXR } from '@react-three/xr';
import { useNavigate } from 'react-router-dom';
import * as THREE from 'three';
import { Text } from '@react-three/drei';
import { XR3DButton, XR3DPanel } from './XRPanel3D';
import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore';
import { getBroadcastSource } from '@/lib/webrtc/broadcastSource';
import {
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
stopRecording, captureScreenshot, formatElapsed,
} from '@/hooks/useCanvasRecorder';
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
import { toast } from 'sonner';
import {
xrCalibration,
startXRCalibration,
cancelXRCalibration,
subscribeXRCalibration,
} from './xrCalibrationBus';
import { XRViewCube } from './XRViewCube';
type Tab = 'scene' | 'tools' | 'calib' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr';
interface XRHudInWorldProps {
freeMove: boolean;
onToggleFreeMove: () => void;
placementMode: boolean;
onTogglePlacement: () => void;
snapToPlanes: boolean;
onToggleSnap: () => void;
allowScale: boolean;
onToggleAllowScale: () => void;
liveCode?: string | null;
liveViewers?: number;
onStartLive?: () => void;
onStopLive?: () => void;
}
const POS_STEP = 0.001;
const ROT_STEP = 1;
const SCALE_LABELS = ['1:50', '1:20', '1:10', '1:1'];
const WristToggle = forwardRef<THREE.Group, { open: boolean; onToggle: () => void }>(
function WristToggle({ open, onToggle }, ref) {
return (
<group ref={ref}>
<mesh position={[0, 0, -0.0005]}>
<planeGeometry args={[0.064, 0.032]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6} />
</mesh>
<mesh onClick={(e) => { e.stopPropagation(); onToggle(); }}>
<planeGeometry args={[0.06, 0.028]} />
<meshBasicMaterial color={open ? '#3b82f6' : '#1a1a1a'} transparent opacity={0.95} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color="#ffffff" anchorX="center" anchorY="middle">
{open ? '✕ Fechar' : '☰ Menu'}
</Text>
</group>
);
},
);
export function XRHudInWorld(props: XRHudInWorldProps) {
const { camera } = useThree();
const leftCtrl = useXRInputSourceState('controller', 'left');
const rightCtrl = useXRInputSourceState('controller', 'right');
const xrSession = useXR((s) => s.session);
const navigate = useNavigate();
const [open, setOpen] = useState(true);
/** Visibility of the small head-locked floating button group (toggled by X button). */
const [hudVisible, setHudVisible] = useState(true);
const [tab, setTab] = useState<Tab>('scene');
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */
const [pinned, setPinned] = useState(true);
/** While true, the panel is glued to the right controller (drag mode). */
const [dragging, setDragging] = useState(false);
/** Forces re-anchor on next frame (used by "trazer" / unpin→pin). */
const reanchorRequested = useRef(true);
const panelRef = useRef<THREE.Group>(null);
const wristRef = useRef<THREE.Group>(null);
const headLockRef = useRef<THREE.Group>(null);
const targetPos = useRef(new THREE.Vector3());
const targetQuat = useRef(new THREE.Quaternion());
const lastButtonsState = useRef({
left4: false, // X
left5: false, // Y
left3: false, // Menu (☰)
right4: false, // A
right5: false, // B
});
/** Cached offset from controller to panel at drag start. */
const dragOffsetPos = useRef(new THREE.Vector3());
const dragOffsetQuat = useRef(new THREE.Quaternion());
const dragInitialized = useRef(false);
useFrame(() => {
// Escuta botões A, B, X, Y e Menu do esquerdo
const gpLeft = leftCtrl?.inputSource?.gamepad;
const gpRight = rightCtrl?.inputSource?.gamepad;
let xPressed = false;
let yPressed = false;
let menuPressed = false;
let aPressed = false;
let bPressed = false;
if (gpLeft) {
// Esquerdo: Menu (3), X (4), Y (5)
const btnMenu = gpLeft.buttons[3];
const btnX = gpLeft.buttons[4];
const btnY = gpLeft.buttons[5];
if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true;
if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true;
if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true;
lastButtonsState.current.left4 = !!btnX?.pressed;
lastButtonsState.current.left5 = !!btnY?.pressed;
lastButtonsState.current.left3 = !!btnMenu?.pressed;
}
if (gpRight) {
// Direito: A (4), B (5)
const btnA = gpRight.buttons[4];
const btnB = gpRight.buttons[5];
if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true;
if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true;
lastButtonsState.current.right4 = !!btnA?.pressed;
lastButtonsState.current.right5 = !!btnB?.pressed;
}
// Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR)
if (xPressed && open) {
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
setTimeout(() => navigate('/viewer'), 50);
toast.success("Projeto fechado!");
} else if (aPressed || bPressed || xPressed || yPressed) {
// Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu
setOpen(true);
reanchorRequested.current = true;
}
// Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente
if (menuPressed) {
setOpen(true);
setTab('tools');
reanchorRequested.current = true;
}
// Floating panel:
// • If pinned and already anchored once → don't move it (fixed in space).
// • If unpinned → continuously follow head with damping.
// • On (re)anchor request → snap target once and stop.
if (panelRef.current && open) {
// Drag mode → panel rigidly follows right controller using captured offset.
if (dragging) {
const ctrlObj = rightCtrl?.object;
if (ctrlObj) {
ctrlObj.updateMatrixWorld();
const cPos = new THREE.Vector3().setFromMatrixPosition(ctrlObj.matrixWorld);
const cQuat = new THREE.Quaternion().setFromRotationMatrix(ctrlObj.matrixWorld);
if (!dragInitialized.current) {
// Capture current panel pose relative to controller
const invQ = cQuat.clone().invert();
dragOffsetPos.current
.copy(panelRef.current.position).sub(cPos).applyQuaternion(invQ);
dragOffsetQuat.current.copy(invQ).multiply(panelRef.current.quaternion);
dragInitialized.current = true;
}
const newPos = dragOffsetPos.current.clone().applyQuaternion(cQuat).add(cPos);
const newQuat = cQuat.clone().multiply(dragOffsetQuat.current);
panelRef.current.position.copy(newPos);
panelRef.current.quaternion.copy(newQuat);
}
} else {
if (dragInitialized.current) dragInitialized.current = false;
const fwd = new THREE.Vector3(0, -0.05, -0.7).applyQuaternion(camera.quaternion);
const wantPos = new THREE.Vector3().copy(camera.position).add(fwd);
const euler = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ');
const wantQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, euler.y, 0, 'YXZ'));
if (reanchorRequested.current) {
targetPos.current.copy(wantPos);
targetQuat.current.copy(wantQuat);
panelRef.current.position.copy(wantPos);
panelRef.current.quaternion.copy(wantQuat);
reanchorRequested.current = false;
} else if (!pinned) {
targetPos.current.copy(wantPos);
targetQuat.current.copy(wantQuat);
panelRef.current.position.lerp(targetPos.current, 0.12);
panelRef.current.quaternion.slerp(targetQuat.current, 0.18);
}
// pinned + already anchored → leave panel where it is.
}
}
// Wrist toggle follows left controller
if (wristRef.current) {
const ctrlObj = leftCtrl?.object;
if (ctrlObj) {
ctrlObj.updateMatrixWorld();
const p = new THREE.Vector3().setFromMatrixPosition(ctrlObj.matrixWorld);
const q = new THREE.Quaternion().setFromRotationMatrix(ctrlObj.matrixWorld);
const offset = new THREE.Vector3(0, 0.06, 0.02).applyQuaternion(q);
wristRef.current.position.copy(p).add(offset);
wristRef.current.quaternion.copy(q);
wristRef.current.visible = true;
} else {
wristRef.current.visible = false;
}
}
// Head-locked fallback toggle — always visible in lower-left of FOV,
// so the user can reopen the menu even without controllers or if the
// panel is positioned out of view.
if (headLockRef.current) {
const offset = new THREE.Vector3(-0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
headLockRef.current.position.copy(camera.position).add(offset);
headLockRef.current.quaternion.copy(camera.quaternion);
}
});
const showGrid = useModelStore((s) => s.showGrid);
const setShowGrid = useModelStore((s) => s.setShowGrid);
return (
<>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{/* Head-locked fallback: floating quick-controls in lower-left of FOV.
Toggled on/off by the left controller X button. */}
<group ref={headLockRef} renderOrder={999} visible={hudVisible}>
<XR3DButton position={[0, 0.030, 0]} size={[0.07, 0.022]}
label={open ? '✕ Menu' : '☰ Menu'} active={open}
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }}
fontSize={0.0085} />
<XR3DButton position={[0, 0.004, 0]} size={[0.07, 0.022]}
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
<XR3DButton position={[0, -0.022, 0]} size={[0.07, 0.022]}
label="⏏ Sair AR"
onClick={() => {
// End the XR session while preserving placement/state in the store,
// then return to the Viewer. Re-entering AR restores the same state.
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
setTimeout(() => navigate('/viewer'), 50);
}}
fontSize={0.0085} />
<RecIndicator />
</group>
{open && (
<group ref={panelRef}>
<FloatingPanel
tab={tab}
setTab={setTab}
pinned={pinned}
onTogglePin={() => setPinned((v) => !v)}
dragging={dragging}
onToggleDrag={() => setDragging((v) => !v)}
onRecenter={() => { reanchorRequested.current = true; }}
{...props}
/>
</group>
)}
</>
);
}
function FloatingPanel({
tab, setTab, pinned, onTogglePin, dragging, onToggleDrag, onRecenter, ...p
}: {
tab: Tab;
setTab: (t: Tab) => void;
pinned: boolean;
onTogglePin: () => void;
dragging: boolean;
onToggleDrag: () => void;
onRecenter: () => void;
} & XRHudInWorldProps) {
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: 'scene', label: 'Cena', icon: '🧩' },
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
{ id: 'calib', label: 'Calibrar', icon: '🎯' },
{ id: 'cuts', label: 'Cortes', icon: '✂' },
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
{ id: 'capture', label: 'Captura', icon: '🎥' },
{ id: 'share', label: 'Compart.', icon: '📡' },
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
];
const W = 0.54, H = 0.38;
return (
<XR3DPanel size={[W, H]}>
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
anchorX="left" anchorY="middle">
SteelXR · HUD AR
</Text>
{/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel.
Group origin is at the right edge with margin; buttons extend leftward. */}
<group position={[W / 2 - 0.008, H / 2 - 0.018, 0.002]}>
{/* Trazer (recenter) — rightmost, half-width 0.029 from origin */}
<XR3DButton position={[-0.029, 0, 0]} size={[0.054, 0.022]}
label="⎚ Trazer" onClick={onRecenter} fontSize={0.007} />
{/* Mover (drag with right controller) */}
<XR3DButton position={[-0.088, 0, 0]} size={[0.054, 0.022]}
label={dragging ? '✋ Solte' : '✋ Mover'}
active={dragging} onClick={onToggleDrag} fontSize={0.007} />
{/* Pin */}
<XR3DButton position={[-0.144, 0, 0]} size={[0.054, 0.022]}
label={pinned ? '📌 Fixo' : '↻ Solto'}
active={pinned} onClick={onTogglePin} fontSize={0.007} />
</group>
<group position={[0, H / 2 - 0.045, 0.001]}>
{tabs.map((t, i) => (
<XR3DButton key={t.id}
position={[-W / 2 + 0.0495 + i * 0.063, 0, 0]} size={[0.058, 0.020]}
label={t.label} icon={t.icon} active={tab === t.id}
onClick={() => setTab(t.id)} fontSize={0.0062} />
))}
</group>
<group position={[0, -0.018, 0.001]}>
{tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />}
{tab === 'calib' && <CalibrationTab />}
{tab === 'cuts' && <CutsTab />}
{tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />}
{tab === 'share' && <ShareTab {...p} />}
{tab === 'webxr' && <WebXRTab />}
</group>
</XR3DPanel>
);
}
interface AxisRange { min: number; max: number; }
type Axis = 'x' | 'y' | 'z';
function computeBoundsByAxis(): Record<Axis, AxisRange> | null {
const groups = getAllModelLocalGroups();
if (groups.length === 0) return null;
const box = new THREE.Box3();
let has = false;
for (const g of groups) {
g.updateWorldMatrix(true, true);
const b = new THREE.Box3().setFromObject(g);
if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) {
if (!has) { box.copy(b); has = true; } else box.union(b);
}
}
if (!has) return null;
return {
x: { min: box.min.x, max: box.max.x },
y: { min: box.min.y, max: box.max.y },
z: { min: box.min.z, max: box.max.z },
};
}
const AXIS_COLOR: Record<Axis, string> = {
x: '#ef4444',
y: '#10b981',
z: '#0ea5e9',
};
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
function CutsTab() {
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const setSectionEnabled = useModelStore((s) => s.setSectionEnabled);
const setSectionInvert = useModelStore((s) => s.setSectionInvert);
const setSectionLevel = useModelStore((s) => s.setSectionLevel);
const activeModelId = useModelStore((s) => s.activeModelId);
const [bounds, setBounds] = useState<Record<Axis, AxisRange> | null>(null);
const [step, setStep] = useState<number>(0.01); // 10mm por padrão
useEffect(() => {
setBounds(computeBoundsByAxis());
}, [activeModelId]);
const handleStepChange = (sVal: number) => {
setStep(sVal);
};
const handleAdjust = (axis: Axis, dir: 1 | -1) => {
const range = bounds?.[axis];
if (!range) return;
const currentVal = level[axis];
const newVal = Math.max(range.min, Math.min(range.max, currentVal + dir * step));
setSectionLevel(axis, newVal);
};
const handleSetPreset = (axis: Axis, type: 'min' | 'center' | 'max') => {
const range = bounds?.[axis];
if (!range) return;
if (type === 'min') {
setSectionLevel(axis, range.min);
} else if (type === 'center') {
setSectionLevel(axis, (range.min + range.max) / 2);
} else if (type === 'max') {
setSectionLevel(axis, range.max);
}
};
const handleRecalculate = () => {
setBounds(computeBoundsByAxis());
};
return (
<group>
{/* Cabeçalho / Passo */}
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" anchorY="middle">
Passo do Ajuste:
</Text>
<XR3DButton position={[-0.09, 0.115, 0]} size={[0.048, 0.02]}
label="1 mm" active={step === 0.001} onClick={() => handleStepChange(0.001)} fontSize={0.007} />
<XR3DButton position={[-0.038, 0.115, 0]} size={[0.048, 0.02]}
label="10 mm" active={step === 0.01} onClick={() => handleStepChange(0.01)} fontSize={0.007} />
<XR3DButton position={[0.014, 0.115, 0]} size={[0.048, 0.02]}
label="50 mm" active={step === 0.05} onClick={() => handleStepChange(0.05)} fontSize={0.007} />
<XR3DButton position={[0.165, 0.115, 0]} size={[0.12, 0.02]}
label="↺ Recalcular Limites" active={false} onClick={handleRecalculate} fontSize={0.007} />
{/* Controles dos Eixos */}
{(['x', 'y', 'z'] as Axis[]).map((axis, idx) => {
const yOffset = 0.055 - idx * 0.055;
const range = bounds?.[axis];
const minMM = range ? range.min * 1000 : 0;
const maxMM = range ? range.max * 1000 : 100;
const valMM = level[axis] * 1000;
const isAxisEnabled = enabled[axis];
const isAxisInverted = invert[axis];
const isAxisDisabled = !range;
return (
<group key={axis} position={[0, yOffset, 0]}>
{/* Eixo label */}
<Text position={[-0.24, 0, 0]} fontSize={0.014} color={AXIS_COLOR[axis]} anchorX="left" anchorY="middle">
{AXIS_LABEL[axis]}
</Text>
{/* Toggle Cortar */}
<XR3DButton position={[-0.19, 0, 0]} size={[0.045, 0.022]}
label={isAxisEnabled ? "ON" : "OFF"} active={isAxisEnabled}
disabled={isAxisDisabled}
onClick={() => setSectionEnabled(axis, !isAxisEnabled)}
fontSize={0.0075} />
{/* Toggle Inverter */}
<XR3DButton position={[-0.135, 0, 0]} size={[0.052, 0.022]}
label={isAxisInverted ? "Invertido" : "Inverter"} active={isAxisInverted}
disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => setSectionInvert(axis, !isAxisInverted)}
fontSize={0.007} />
{/* Valor */}
<Text position={[-0.09, 0, 0]} fontSize={0.0085} color={isAxisEnabled ? "#ffffff" : "#64748b"} anchorX="left" anchorY="middle">
{isAxisDisabled ? "sem peça" : `${valMM.toFixed(1)} mm`}
</Text>
{/* Menos/Mais */}
<XR3DButton position={[0.01, 0, 0]} size={[0.024, 0.022]}
label="" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleAdjust(axis, -1)}
fontSize={0.01} />
<XR3DButton position={[0.038, 0, 0]} size={[0.024, 0.022]}
label="+" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleAdjust(axis, 1)}
fontSize={0.01} />
{/* Presets */}
<XR3DButton position={[0.076, 0, 0]} size={[0.034, 0.022]}
label="Min" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'min')}
fontSize={0.007} />
<XR3DButton position={[0.118, 0, 0]} size={[0.042, 0.022]}
label="Centro" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'center')}
fontSize={0.007} />
<XR3DButton position={[0.162, 0, 0]} size={[0.036, 0.022]}
label="Max" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'max')}
fontSize={0.007} />
</group>
);
})}
</group>
);
}
function SceneTab() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const setActive = useModelStore((s) => s.setActiveModel);
const toggleVisible = useModelStore((s) => s.toggleModelVisible);
const toggleLocked = useModelStore((s) => s.toggleModelLocked);
const remove = useModelStore((s) => s.removeModel);
const duplicate = useModelStore((s) => s.duplicateModel);
if (models.length === 0) {
return (
<Text fontSize={0.011} color="#94a3b8" anchorX="center" anchorY="middle" maxWidth={0.45}>
Nenhuma peça na cena. Saia do AR para carregar arquivos no Viewer.
</Text>
);
}
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Toque no nome para tornar a peça ativa
</Text>
{models.slice(0, 5).map((m, i) => {
const y = 0.085 - i * 0.04;
const isActive = m.id === activeId;
return (
<group key={m.id} position={[0, y, 0]}>
<mesh position={[-0.235, 0, 0.001]}>
<circleGeometry args={[0.005, 16]} />
<meshBasicMaterial color={m.color} />
</mesh>
<group onClick={(e) => { e.stopPropagation(); setActive(m.id); }}>
<mesh position={[-0.11, 0, 0]}>
<planeGeometry args={[0.22, 0.026]} />
<meshBasicMaterial color={isActive ? '#1e3a8a' : '#1a1a1a'} transparent opacity={0.85} />
</mesh>
<Text position={[-0.215, 0, 0.001]} fontSize={0.0085}
color={isActive ? '#ffffff' : '#cbd5e1'} anchorX="left" anchorY="middle"
maxWidth={0.2}>
{m.fileName}
</Text>
</group>
<XR3DButton position={[0.04, 0, 0]} size={[0.04, 0.024]}
label={m.visible ? '👁' : '⊘'} fontSize={0.012}
onClick={() => toggleVisible(m.id)} />
<XR3DButton position={[0.085, 0, 0]} size={[0.04, 0.024]}
label={m.locked ? '🔒' : '🔓'} fontSize={0.012}
onClick={() => toggleLocked(m.id)} />
<XR3DButton position={[0.13, 0, 0]} size={[0.04, 0.024]}
label="⎘" fontSize={0.012}
onClick={() => duplicate(m.id)} />
<XR3DButton position={[0.175, 0, 0]} size={[0.04, 0.024]}
label="✕" fontSize={0.012} color="#dc2626"
onClick={() => remove(m.id)} />
</group>
);
})}
</group>
);
}
function ToolsTab(p: XRHudInWorldProps) {
const opacity = useModelStore((s) => s.opacity);
const setOpacity = useModelStore((s) => s.setOpacity);
const renderMode = useModelStore((s) => s.renderMode);
const setRenderMode = useModelStore((s) => s.setRenderMode);
const showGrid = useModelStore((s) => s.showGrid);
const setShowGrid = useModelStore((s) => s.setShowGrid);
const measureMode = useModelStore((s) => s.measureMode);
const setMeasureMode = useModelStore((s) => s.setMeasureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
const setSelectionMode = useModelStore((s) => s.setSelectionMode);
const selectedCount = useModelStore((s) => s.selectedElementKeys.size);
const hideSelectedElements = useModelStore((s) => s.hideSelectedElements);
const isolateSelectedElements = useModelStore((s) => s.isolateSelectedElements);
const showAllElements = useModelStore((s) => s.showAllElements);
const hasHidden = useModelStore((s) =>
s.hiddenElementKeys.size > 0 || s.isolatedElementKeys !== null
);
const scaleRatio = useModelStore((s) => s.scaleRatio);
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
const resetScale = useModelStore((s) => s.resetScale);
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement);
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Wire"
active={renderMode === 'wireframe'} onClick={() => setRenderMode('wireframe')} />
<XR3DButton position={[0.0, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
<XR3DButton position={[-0.16, 0.025, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
<XR3DButton position={[-0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
active={p.snapToPlanes} onClick={p.onToggleSnap} />
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
{/* Selecionar — paridade com a tela inicial */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} />
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} />
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label}
</Text>
{SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
return (
<XR3DButton key={lbl}
position={[-0.16 + i * 0.054, -0.14, 0]} size={[0.05, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
<XR3DButton position={[0.076, -0.14, 0]} size={[0.046, 0.022]}
label="↺ Reset" color="#dc2626"
onClick={resetScale} fontSize={0.0075} />
<XR3DButton position={[0.126, -0.14, 0]} size={[0.05, 0.022]}
label="⎌ Desfaz" color="#3b82f6"
onClick={undoLastMeasurement} fontSize={0.0075} />
<XR3DButton position={[0.18, -0.14, 0]} size={[0.05, 0.022]}
label="✕ Limpa" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0075} />
{measureMode && (
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu
</Text>
)}
<GridFloorRow />
</group>
);
}
function GridFloorRow() {
const { camera } = useThree();
const gridY = useModelStore((s) => s.gridY);
const gridAutoFollow = useModelStore((s) => s.gridAutoFollow);
const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow);
const setGridY = useModelStore((s) => s.setGridY);
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
const setGridCalibMode = useModelStore((s) => s.setGridCalibMode);
const gridLandingMode = useModelStore((s) => s.gridLandingMode);
const setGridLandingMode = useModelStore((s) => s.setGridLandingMode);
return (
<group>
<Text position={[-0.24, -0.15, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Grid Y {(gridY * 1000).toFixed(0)}mm {gridAutoFollow ? '(auto)' : '(travado)'}
</Text>
<XR3DButton position={[-0.16, -0.175, 0]} size={[0.055, 0.022]} label="Auto"
active={gridAutoFollow} onClick={() => setGridAutoFollow(true)} />
<XR3DButton position={[-0.10, -0.175, 0]} size={[0.055, 0.022]} label="Travar"
active={!gridAutoFollow} onClick={() => setGridAutoFollow(false)} />
<XR3DButton position={[-0.04, -0.175, 0]} size={[0.04, 0.022]} label="1cm"
onClick={() => nudgeGridY(-0.01)} />
<XR3DButton position={[0.005, -0.175, 0]} size={[0.04, 0.022]} label="+1cm"
onClick={() => nudgeGridY(0.01)} />
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label={gridLandingMode ? "Pousando" : "Pousar"}
active={gridLandingMode} onClick={() => setGridLandingMode(!gridLandingMode)} />
<XR3DButton position={[0.155, -0.175, 0]} size={[0.075, 0.022]} label={gridCalibMode ? "Calibrando" : "Calibrar"}
active={gridCalibMode} onClick={() => setGridCalibMode(!gridCalibMode)} />
</group>
);
}
function TransformTab() {
const fineTuning = useModelStore((s) => s.fineTuning);
const setFineTuning = useModelStore((s) => s.setFineTuning);
const reset = useModelStore((s) => s.resetFineTuning);
const Row = ({
label, axis, isRot, y,
}: { label: string; axis: 'posX'|'posY'|'posZ'|'rotX'|'rotY'|'rotZ'; isRot: boolean; y: number }) => {
const step = isRot ? ROT_STEP : POS_STEP;
const val = fineTuning[axis];
const display = isRot ? `${val.toFixed(1)}°` : `${(val * 1000).toFixed(1)}mm`;
return (
<group position={[0, y, 0]}>
<Text position={[-0.235, 0, 0]} fontSize={0.009} color="#cbd5e1" anchorX="left" anchorY="middle">
{label}
</Text>
<XR3DButton position={[-0.10, 0, 0]} size={[0.04, 0.024]} label="−−"
onClick={() => setFineTuning({ [axis]: val - step * 10 })} />
<XR3DButton position={[-0.05, 0, 0]} size={[0.04, 0.024]} label=""
onClick={() => setFineTuning({ [axis]: val - step })} />
<Text position={[0.0, 0, 0]} fontSize={0.009} color="#3b82f6" anchorX="center" anchorY="middle">
{display}
</Text>
<XR3DButton position={[0.05, 0, 0]} size={[0.04, 0.024]} label="+"
onClick={() => setFineTuning({ [axis]: val + step })} />
<XR3DButton position={[0.10, 0, 0]} size={[0.04, 0.024]} label="++"
onClick={() => setFineTuning({ [axis]: val + step * 10 })} />
</group>
);
};
return (
<group>
<Row label="Pos X" axis="posX" isRot={false} y={0.105} />
<Row label="Pos Y" axis="posY" isRot={false} y={0.075} />
<Row label="Pos Z" axis="posZ" isRot={false} y={0.045} />
<Row label="Rot X" axis="rotX" isRot={true} y={0.005} />
<Row label="Rot Y" axis="rotY" isRot={true} y={-0.025} />
<Row label="Rot Z" axis="rotZ" isRot={true} y={-0.055} />
<XR3DButton position={[0, -0.105, 0]} size={[0.12, 0.026]} label="↺ Reset" onClick={reset} />
</group>
);
}
function InspectionTab() {
const checklist = useModelStore((s) => s.checklist);
const setStatus = useModelStore((s) => s.setChecklistItemStatus);
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Checklist de inspeção
</Text>
{checklist.map((item, i) => {
const y = 0.075 - i * 0.045;
const color = item.status === 'approved' ? '#22c55e'
: item.status === 'rejected' ? '#dc2626' : '#94a3b8';
return (
<group key={item.id} position={[0, y, 0]}>
<mesh position={[-0.235, 0, 0]}>
<circleGeometry args={[0.005, 16]} />
<meshBasicMaterial color={color} />
</mesh>
<Text position={[-0.22, 0, 0]} fontSize={0.009} color="#e5e7eb"
anchorX="left" anchorY="middle" maxWidth={0.18}>
{item.label}
</Text>
<XR3DButton position={[0.06, 0, 0]} size={[0.05, 0.024]} label="✓ OK" color="#16a34a"
active={item.status === 'approved'} onClick={() => setStatus(item.id, 'approved')} />
<XR3DButton position={[0.115, 0, 0]} size={[0.05, 0.024]} label="✗ NOK" color="#dc2626"
active={item.status === 'rejected'} onClick={() => setStatus(item.id, 'rejected')} />
</group>
);
})}
</group>
);
}
function ShareTab(p: XRHudInWorldProps) {
const isLive = !!p.liveCode;
return (
<group>
<Text position={[0, 0.105, 0]} fontSize={0.012} color={isLive ? '#22c55e' : '#94a3b8'}
anchorX="center" anchorY="middle">
{isLive ? '● TRANSMITINDO AO VIVO' : '○ Transmissão desligada'}
</Text>
{isLive ? (
<>
<Text position={[0, 0.07, 0]} fontSize={0.010} color="#94a3b8" anchorX="center" anchorY="middle">
Código da sala
</Text>
<Text position={[0, 0.04, 0]} fontSize={0.024} color="#3b82f6" anchorX="center" anchorY="middle">
{p.liveCode}
</Text>
<Text position={[0, -0.005, 0]} fontSize={0.010} color="#cbd5e1" anchorX="center" anchorY="middle">
{p.liveViewers ?? 0} espectador(es) conectado(s)
</Text>
<Text position={[0, -0.04, 0]} fontSize={0.008} color="#64748b" anchorX="center" anchorY="middle"
maxWidth={0.45}>
Os espectadores veem o ponto de vista 3D do operador, sem a câmera real (privacidade).
</Text>
<XR3DButton position={[0, -0.09, 0]} size={[0.18, 0.028]}
label="✕ Encerrar transmissão" color="#dc2626"
onClick={() => p.onStopLive?.()} />
</>
) : (
<>
<Text position={[0, 0.04, 0]} fontSize={0.010} color="#cbd5e1" anchorX="center" anchorY="middle"
maxWidth={0.45}>
Inicie uma transmissão WebRTC para que cliente/supervisor acompanhem ao vivo (até 5 viewers, link público).
</Text>
<XR3DButton position={[0, -0.04, 0]} size={[0.22, 0.03]}
label="📡 Iniciar transmissão" color="#3b82f6"
onClick={() => p.onStartLive?.()} />
<Text position={[0, -0.09, 0]} fontSize={0.008} color="#64748b" anchorX="center" anchorY="middle"
maxWidth={0.45}>
{getBroadcastSource() ? '✓ Mirror opaco ativo' : 'Mirror será criado ao iniciar'}
</Text>
</>
)}
</group>
);
}
function WebXRTab() {
const xrFeatures = useModelStore((s) => s.xrFeatures);
const setXRFeature = useModelStore((s) => s.setXRFeature);
const reset = useModelStore((s) => s.resetXRFeatures);
const keys: { k: keyof typeof xrFeatures; label: string }[] = [
{ k: 'handTracking', label: 'Hand' },
{ k: 'planeDetection', label: 'Planes' },
{ k: 'hitTest', label: 'HitTest' },
{ k: 'domOverlay', label: 'DOM Ovl' },
{ k: 'anchors', label: 'Anchors' },
{ k: 'meshDetection', label: 'Mesh' },
{ k: 'depthSensing', label: 'Depth' },
{ k: 'layers', label: 'Layers' },
{ k: 'bodyTracking', label: 'Body' },
];
return (
<group>
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest.
</Text>
<Text position={[-0.24, 0.095, 0]} fontSize={0.0075} color="#f59e0b" anchorX="left" maxWidth={0.48}>
Mudanças entram em vigor ao sair e re-entrar no AR.
</Text>
{keys.map((it, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const x = -0.18 + col * 0.14;
const y = 0.05 - row * 0.045;
const on = xrFeatures[it.k];
return (
<XR3DButton key={it.k}
position={[x, y, 0]} size={[0.125, 0.034]}
label={`${it.label} ${on ? 'ON' : 'OFF'}`}
active={on} onClick={() => setXRFeature(it.k, !on)}
fontSize={0.0085} />
);
})}
<XR3DButton position={[0, -0.14, 0]} size={[0.18, 0.028]}
label="↺ Resetar (todas ON)" onClick={reset} fontSize={0.009} />
</group>
);
}
function CaptureTab() {
const rec = useCanvasRecorder();
const addScreenshot = useModelStore((s) => s.addScreenshot);
const onPhoto = () => {
const dataUrl = captureScreenshot();
if (dataUrl) addScreenshot(dataUrl);
};
const statusColor =
rec.status === 'recording' ? '#dc2626' :
rec.status === 'paused' ? '#f59e0b' : '#94a3b8';
const statusLabel =
rec.status === 'recording' ? `● REC ${formatElapsed(rec.elapsedMs)}` :
rec.status === 'paused' ? `❚❚ PAUSADO ${formatElapsed(rec.elapsedMs)}` :
'○ Pronto para gravar';
return (
<group>
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar).
</Text>
<Text position={[0, 0.075, 0]} fontSize={0.014} color={statusColor}
anchorX="center" anchorY="middle">
{statusLabel}
</Text>
{/* Foto */}
<XR3DButton position={[-0.13, 0.02, 0]} size={[0.18, 0.038]}
label="📸 Tirar Foto" color="#3b82f6"
onClick={onPhoto} fontSize={0.011} />
{/* Iniciar / Parar gravação */}
{rec.status === 'idle' ? (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="● Iniciar Gravação" color="#dc2626"
onClick={startRecording} fontSize={0.011} />
) : (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="■ Parar e Salvar" color="#dc2626" active
onClick={stopRecording} fontSize={0.011} />
)}
{/* Pause / Resume */}
{rec.status === 'recording' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="❚❚ Pausar" color="#f59e0b"
onClick={pauseRecording} fontSize={0.011} />
)}
{rec.status === 'paused' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="▶ Retomar" color="#22c55e" active
onClick={resumeRecording} fontSize={0.011} />
)}
<Text position={[-0.24, -0.105, 0]} fontSize={0.0075} color="#64748b"
anchorX="left" maxWidth={0.48}>
Foto .png | Vídeo .webm | Arquivos vão para a pasta Downloads do navegador.
</Text>
</group>
);
}
function RecIndicator() {
const rec = useCanvasRecorder();
if (rec.status === 'idle') return null;
const color = rec.status === 'recording' ? '#dc2626' : '#f59e0b';
const label = rec.status === 'recording'
? `● REC ${formatElapsed(rec.elapsedMs)}`
: `❚❚ ${formatElapsed(rec.elapsedMs)}`;
return (
<group position={[0.09, 0.018, 0]}>
<mesh>
<planeGeometry args={[0.078, 0.022]} />
<meshBasicMaterial color="#0a0a0a" transparent opacity={0.9} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color={color}
anchorX="center" anchorY="middle">
{label}
</Text>
</group>
);
}
function CalibrationTab() {
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);
// Escuta o barramento de calibração do XR
const [, force] = useState(0);
useEffect(() => subscribeXRCalibration(() => force((t) => t + 1)), []);
const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
const isDone = xrCalibration.step === 'done';
const isCubeMode = xrCalibration.alignType === 'cube';
const isVirtRealMode = xrCalibration.alignType === 'virt-real';
const onClickCube = () => {
if (isCalibrating && isCubeMode) {
cancelXRCalibration();
return;
}
if (isDone && isCubeMode) {
cancelXRCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id, 'cube');
};
const onClickVirtReal = () => {
if (isCalibrating && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (isDone && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id, 'virt-real');
};
const onResetCalibration = () => {
if (!active) return;
setCalibrationStore(active.id, null);
toast.success('Calibração removida');
};
const STEP_HINTS_AR: Record<string, string> = {
'await-cube-1': '1/3 - Clique numa face do Cubo à direita',
'await-model-1': '1/3 - Aponte e clique na face correspondente da peça',
'await-cube-2': '2/3 - Clique em outra face do Cubo',
'await-model-2': '2/3 - Clique na face correspondente da peça',
'await-cube-3': '3/3 (Verificar) - Clique em uma 3ª face do Cubo (opcional)',
'await-model-3': '3/3 - Clique na face correspondente da peça',
'await-real-1': '1/3 Real - Clique na 1ª superfície da peça REAL',
'await-virtual-1': '1/3 Virtual - Clique na 1ª correspondente da peça VIRTUAL',
'await-real-2': '2/3 Real - Clique na 2ª superfície da peça REAL',
'await-virtual-2': '2/3 Virtual - Clique na 2ª correspondente da peça VIRTUAL',
'await-real-3': '3/3 Real - Clique na 3ª superfície da peça REAL',
'await-virtual-3': '3/3 Virtual - Clique na 3ª correspondente da peça VIRTUAL',
'done': 'Calibração concluída com sucesso!',
};
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Selecione "Cubo" ou "Virt/Real" para calibrar o modelo';
// Configurações dinâmicas de estados de botão
let cubeLabel = 'Cubo';
let cubeActive = false;
let cubeColor = '#3b82f6';
let vrLabel = 'Virt/Real';
let vrActive = false;
let vrColor = '#3b82f6';
if (isCalibrating) {
if (isCubeMode) {
cubeLabel = 'Cancelar';
cubeActive = true;
cubeColor = '#d97706';
} else {
cubeColor = '#475569';
}
if (isVirtRealMode) {
vrLabel = 'Cancelar';
vrActive = true;
vrColor = '#d97706';
} else {
vrColor = '#475569';
}
} else if (isDone) {
if (isCubeMode) {
cubeLabel = 'Concluído';
cubeActive = true;
cubeColor = '#22c55e';
}
if (isVirtRealMode) {
vrLabel = 'Concluído';
vrActive = true;
vrColor = '#22c55e';
}
}
return (
<group>
{/* Coluna da esquerda: Informações e Fluxo */}
<Text position={[-0.24, 0.11, 0]} fontSize={0.012} color="#3b82f6" anchorX="left" anchorY="middle">
Calibração 3D da Peça
</Text>
<Text position={[-0.24, 0.07, 0]} fontSize={0.008} color="#cbd5e1" anchorX="left" anchorY="top" maxWidth={0.28}>
Alinhe o modelo virtual 3D com a peça real usando o Cubo ou clique em 3 pontos de controle ("Virt/Real").
</Text>
{/* Caixa de status do passo atual */}
<group position={[-0.24, 0.00, 0]}>
<mesh position={[0.13, 0, -0.0005]}>
<planeGeometry args={[0.26, 0.05]} />
<meshBasicMaterial color="#111827" transparent opacity={0.65} />
</mesh>
<Text position={[0.01, 0, 0.001]} fontSize={0.008} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
{currentHint}
</Text>
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && isCubeMode && (
<Text position={[0.01, -0.016, 0.001]} fontSize={0.007} color="#22c55e" anchorX="left" anchorY="middle">
Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}°
</Text>
)}
</group>
{/* Botões na parte inferior esquerda */}
<group position={[-0.24, -0.06, 0]}>
<XR3DButton
position={[0.045, 0, 0]}
size={[0.08, 0.026]}
label={cubeLabel}
active={cubeActive}
color={cubeColor}
onClick={onClickCube}
fontSize={0.008}
/>
<XR3DButton
position={[0.135, 0, 0]}
size={[0.08, 0.026]}
label={vrLabel}
active={vrActive}
color={vrColor}
onClick={onClickVirtReal}
fontSize={0.008}
/>
{active?.calibrationQuat && !isCalibrating && (
<XR3DButton
position={[0.225, 0, 0]}
size={[0.08, 0.026]}
label="Limpar"
color="#dc2626"
onClick={onResetCalibration}
fontSize={0.008}
/>
)}
</group>
<Text position={[-0.24, -0.11, 0]} fontSize={0.007} color="#64748b" anchorX="left" maxWidth={0.28}>
{active ? `Peça ativa: ${active.fileName}` : 'Selecione uma peça na aba "Cena"'}
</Text>
{/* Coluna da direita: Cubo de views 3D interativo */}
<group position={[0.14, -0.01, 0.04]}>
<XRViewCube activeModelId={activeModelId} size={0.075} />
{/* Tripé dos eixos abaixo do cubo */}
<group position={[0, -0.06, -0.04]} scale={[0.035, 0.035, 0.035]}>
<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>
<Text position={[0, 0.065, -0.04]} fontSize={0.008} color="#cbd5e1" anchorX="center" anchorY="middle">
Cubo de Calibração
</Text>
</group>
</group>
);
}