diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx new file mode 100644 index 0000000..51599cd --- /dev/null +++ b/src/components/three/XRHudInWorld.tsx @@ -0,0 +1,430 @@ +import { useRef, useState, useEffect } from 'react'; +import { forwardRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { useXRInputSourceState } from '@react-three/xr'; +import * as THREE from 'three'; +import { XR3DButton, XR3DPanel } from './XRPanel3D'; +import { Text } from '@react-three/drei'; +import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore'; +import { getBroadcastSource } from '@/lib/webrtc/broadcastSource'; + +type Tab = 'scene' | 'tools' | 'transform' | 'inspection' | 'share'; + +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']; + +/** + * In-world AR HUD: a floating panel anchored in front of the user with all + * tools, plus a tiny "wrist toggle" attached to the left controller. Works + * inside the WebXR passthrough, where DOM overlays are invisible. + */ +export function XRHudInWorld(props: XRHudInWorldProps) { + const { camera } = useThree(); + const leftCtrl = useXRInputSourceState('controller', 'left'); + const [open, setOpen] = useState(true); + const [tab, setTab] = useState('scene'); + + const panelRef = useRef(null); + const wristRef = useRef(null); + const targetPos = useRef(new THREE.Vector3()); + const targetQuat = useRef(new THREE.Quaternion()); + + // Toggle panel via left A button (buttons[4] on Quest Touch) + const lastABtn = useRef(false); + useFrame(() => { + 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; + } + + // Position the floating panel ~70cm in front of the camera, with damping. + if (panelRef.current && open) { + const fwd = new THREE.Vector3(0, -0.05, -0.7).applyQuaternion(camera.quaternion); + targetPos.current.copy(camera.position).add(fwd); + // Face camera (yaw only, keep panel upright) + const euler = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ'); + targetQuat.current.setFromEuler(new THREE.Euler(0, euler.y, 0, 'YXZ')); + panelRef.current.position.lerp(targetPos.current, 0.12); + panelRef.current.quaternion.slerp(targetQuat.current, 0.18); + } + + // Position wrist toggle above the 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; + } + } + }); + + return ( + <> + setOpen((v) => !v)} /> + {open && ( + + + + )} + + ); +} + +const WristToggleReal = forwardRef void }>( + function WristToggleReal({ open, onToggle }, ref) { + return ( + + { e.stopPropagation(); onToggle(); }} + onPointerOver={(e) => e.stopPropagation()} + > + + + + + + + + + {open ? '✕ Fechar' : '☰ Menu'} + + + ); + } +); + +// ───────────────────────────────────────────────────────────────────────────── +function FloatingPanel({ tab, setTab, ...p }: { tab: Tab; setTab: (t: Tab) => void } & XRHudInWorldProps) { + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: 'scene', label: 'Cena', icon: '🧩' }, + { id: 'tools', label: 'Ferram.', icon: '🛠' }, + { id: 'transform', label: 'Mover', icon: '↔' }, + { id: 'inspection', label: 'Inspeção', icon: '✓' }, + { id: 'share', label: 'Compart.', icon: '📡' }, + ]; + + const W = 0.5, H = 0.36; + return ( + + {/* Title */} + + TrackSteelXR · HUD AR + + + Botão A (esq) abre/fecha + + + {/* Tabs row */} + + {tabs.map((t, i) => ( + setTab(t.id)} + fontSize={0.0075} + /> + ))} + + + {/* Content area */} + + {tab === 'scene' && } + {tab === 'tools' && } + {tab === 'transform' && } + {tab === 'inspection' && } + {tab === 'share' && } + + + ); +} + +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. + + ); + } + + return ( + + + Toque para selecionar peça ativa + + {models.slice(0, 5).map((m, i) => { + const y = 0.085 - i * 0.04; + const isActive = m.id === activeId; + return ( + + {/* Color dot */} + + + + + {/* Name (tappable to make active) */} + { e.stopPropagation(); setActive(m.id); }}> + + + + + + {m.fileName} + + + {/* Action buttons */} + 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); + + return ( + + {/* Render mode */} + Render + setRenderMode('solid')} /> + setRenderMode('wireframe')} /> + setRenderMode('edges')} /> + + {/* Toggles */} + Toggles + setShowGrid(!showGrid)} /> + setMeasureMode(!measureMode)} /> + + + + + {/* Opacity slider buttons */} + + 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)} /> + + {/* Scale presets */} + + Escala {scaleRatio.label} + + {SCALE_LABELS.map((lbl, i) => { + const preset = SCALE_PRESETS.find(p => p.label === lbl)!; + return ( + setScaleRatio(preset)} /> + ); + })} + + ); +} + +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 } as Partial)} /> + setFineTuning({ [axis]: val - step } as Partial)} /> + + {display} + + setFineTuning({ [axis]: val + step } as Partial)} /> + setFineTuning({ [axis]: val + step * 10 } as Partial)} /> + + ); + }; + + 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?.()} /> + + )} + {!isLive && ( + <> + + 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'} + + + )} + + ); +}