import { useRef, useState, forwardRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { useXRInputSourceState } from '@react-three/xr'; 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'; type Tab = 'scene' | 'tools' | '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 void }>( function WristToggle({ open, onToggle }, ref) { return ( { e.stopPropagation(); onToggle(); }}> {open ? '✕ Fechar' : '☰ Menu'} ); }, ); export function XRHudInWorld(props: XRHudInWorldProps) { const { camera } = useThree(); const leftCtrl = useXRInputSourceState('controller', 'left'); const rightCtrl = useXRInputSourceState('controller', 'right'); const [open, setOpen] = useState(true); const [tab, setTab] = useState('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(null); const wristRef = useRef(null); const headLockRef = useRef(null); const targetPos = useRef(new THREE.Vector3()); const targetQuat = useRef(new THREE.Quaternion()); const lastABtn = useRef(false); /** 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(() => { // Toggle via left A button (Quest Touch buttons[4]) const gp = leftCtrl?.inputSource?.gamepad; if (gp) { const aBtn = gp.buttons[4]; const pressed = !!aBtn?.pressed; if (pressed && !lastABtn.current) setOpen((v) => !v); lastABtn.current = pressed; } // 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 ( <> setOpen((v) => !v)} /> {/* Head-locked fallback: always-visible quick controls in lower-left of FOV */} { setOpen((v) => !v); reanchorRequested.current = true; }} fontSize={0.0085} /> setShowGrid(!showGrid)} fontSize={0.0085} /> {open && ( setPinned((v) => !v)} dragging={dragging} onToggleDrag={() => setDragging((v) => !v)} onRecenter={() => { reanchorRequested.current = true; }} {...props} /> )} ); } 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: 'inspection', label: 'Inspeção', icon: '✓' }, { id: 'capture', label: 'Captura', icon: '🎥' }, { id: 'share', label: 'Compart.', icon: '📡' }, { id: 'webxr', label: 'WebXR', icon: '⚙' }, ]; const W = 0.5, H = 0.36; return ( TrackSteelXR · HUD AR {/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel. Group origin is at the right edge with margin; buttons extend leftward. */} {/* Trazer (recenter) — rightmost, half-width 0.029 from origin */} {/* Mover (drag with right controller) */} {/* Pin */} {tabs.map((t, i) => ( setTab(t.id)} fontSize={0.0072} /> ))} {tab === 'scene' && } {tab === 'tools' && } {tab === 'inspection' && } {tab === 'capture' && } {tab === 'share' && } {tab === 'webxr' && } ); } 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 ( Nenhuma peça na cena. Saia do AR para carregar arquivos no Viewer. ); } return ( Toque no nome para tornar a peça ativa {models.slice(0, 5).map((m, i) => { const y = 0.085 - i * 0.04; const isActive = m.id === activeId; return ( { e.stopPropagation(); setActive(m.id); }}> {m.fileName} toggleVisible(m.id)} /> toggleLocked(m.id)} /> duplicate(m.id)} /> remove(m.id)} /> ); })} ); } 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 scaleRatio = useModelStore((s) => s.scaleRatio); const setScaleRatio = useModelStore((s) => s.setScaleRatio); const resetScale = useModelStore((s) => s.resetScale); const clearMeasurements = useModelStore((s) => s.clearMeasurements); return ( Render setRenderMode('solid')} /> setRenderMode('wireframe')} /> setRenderMode('edges')} /> Toggles setShowGrid(!showGrid)} /> setMeasureMode(!measureMode)} /> Opacidade {Math.round(opacity * 100)}% setOpacity(Math.max(0, opacity - 0.25))} /> setOpacity(Math.max(0, opacity - 0.05))} /> setOpacity(Math.min(1, opacity + 0.05))} /> setOpacity(Math.min(1, opacity + 0.25))} /> setOpacity(1)} /> Escala {scaleRatio.label} {SCALE_LABELS.map((lbl, i) => { const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!; return ( setScaleRatio(preset)} /> ); })} {/* Reset Escala — devolve a peça ao tamanho 1:1 (cancela qualquer escala manual feita com duas mãos). */} {/* Limpa todos os labels de medição. */} {/* Legenda dos controles para Medir */} {measureMode && ( Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap )} {/* Grid floor controls */} ); } 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 placeAtFloor = () => { // Headset eye Y minus avg standing eye height (1.6 m) const floor = camera.position.y - 1.6; setGridY(floor); }; return ( Grid Y {(gridY * 1000).toFixed(0)}mm {gridAutoFollow ? '(auto)' : '(travado)'} setGridAutoFollow(true)} /> setGridAutoFollow(false)} /> nudgeGridY(-0.01)} /> nudgeGridY(0.01)} /> ); } 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 ( {label} setFineTuning({ [axis]: val - step * 10 })} /> setFineTuning({ [axis]: val - step })} /> {display} setFineTuning({ [axis]: val + step })} /> setFineTuning({ [axis]: val + step * 10 })} /> ); }; return ( ); } function InspectionTab() { const checklist = useModelStore((s) => s.checklist); const setStatus = useModelStore((s) => s.setChecklistItemStatus); return ( Checklist de inspeção {checklist.map((item, i) => { const y = 0.075 - i * 0.045; const color = item.status === 'approved' ? '#22c55e' : item.status === 'rejected' ? '#dc2626' : '#94a3b8'; return ( {item.label} setStatus(item.id, 'approved')} /> setStatus(item.id, 'rejected')} /> ); })} ); } function ShareTab(p: XRHudInWorldProps) { const isLive = !!p.liveCode; return ( {isLive ? '● TRANSMITINDO AO VIVO' : '○ Transmissão desligada'} {isLive ? ( <> Código da sala {p.liveCode} {p.liveViewers ?? 0} espectador(es) conectado(s) Os espectadores veem o ponto de vista 3D do operador, sem a câmera real (privacidade). p.onStopLive?.()} /> ) : ( <> Inicie uma transmissão WebRTC para que cliente/supervisor acompanhem ao vivo (até 5 viewers, link público). p.onStartLive?.()} /> {getBroadcastSource() ? '✓ Mirror opaco ativo' : 'Mirror será criado ao iniciar'} )} ); } 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 ( Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest. Mudanças entram em vigor ao sair e re-entrar no AR. {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 ( setXRFeature(it.k, !on)} fontSize={0.0085} /> ); })} ); } 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 ( Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar). {statusLabel} {/* Foto */} {/* Iniciar / Parar gravação */} {rec.status === 'idle' ? ( ) : ( )} {/* Pause / Resume */} {rec.status === 'recording' && ( )} {rec.status === 'paused' && ( )} Foto → .png | Vídeo → .webm | Arquivos vão para a pasta Downloads do navegador. ); } 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 ( {label} ); }