1150 lines
47 KiB
TypeScript
1150 lines
47 KiB
TypeScript
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>
|
||
);
|
||
}
|