Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 18:03:41 +00:00
parent 7532aa93a6
commit 4a8fd8b005
+430
View File
@@ -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<Tab>('scene');
const panelRef = useRef<THREE.Group>(null);
const wristRef = useRef<THREE.Group>(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 (
<>
<WristToggleReal ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{open && (
<group ref={panelRef}>
<FloatingPanel tab={tab} setTab={setTab} {...props} />
</group>
)}
</>
);
}
const WristToggleReal = forwardRef<THREE.Group, { open: boolean; onToggle: () => void }>(
function WristToggleReal({ open, onToggle }, ref) {
return (
<group ref={ref}>
<mesh
onClick={(e) => { e.stopPropagation(); onToggle(); }}
onPointerOver={(e) => e.stopPropagation()}
>
<planeGeometry args={[0.06, 0.028]} />
<meshBasicMaterial color={open ? '#3b82f6' : '#1a1a1a'} transparent opacity={0.95} />
</mesh>
<mesh position={[0, 0, -0.0005]}>
<planeGeometry args={[0.064, 0.032]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color="#ffffff" anchorX="center" anchorY="middle">
{open ? '✕ Fechar' : '☰ Menu'}
</Text>
</group>
);
}
);
// ─────────────────────────────────────────────────────────────────────────────
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 (
<XR3DPanel size={[W, H]}>
{/* Title */}
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
anchorX="left" anchorY="middle">
TrackSteelXR · HUD AR
</Text>
<Text position={[W / 2 - 0.012, H / 2 - 0.018, 0.002]} fontSize={0.008} color="#94a3b8"
anchorX="right" anchorY="middle">
Botão A (esq) abre/fecha
</Text>
{/* Tabs row */}
<group position={[0, H / 2 - 0.045, 0.001]}>
{tabs.map((t, i) => (
<XR3DButton
key={t.id}
position={[-W / 2 + 0.045 + i * 0.092, 0, 0]}
size={[0.085, 0.022]}
label={t.label}
icon={t.icon}
active={tab === t.id}
onClick={() => setTab(t.id)}
fontSize={0.0075}
/>
))}
</group>
{/* Content area */}
<group position={[0, -0.012, 0.001]}>
{tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />}
{tab === 'transform' && <TransformTab />}
{tab === 'inspection' && <InspectionTab />}
{tab === 'share' && <ShareTab {...p} />}
</group>
</XR3DPanel>
);
}
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.01} color="#94a3b8" anchorX="center" anchorY="middle">
Nenhuma peça na cena. Saia do AR para carregar arquivos.
</Text>
);
}
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Toque para selecionar 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]}>
{/* Color dot */}
<mesh position={[-0.235, 0, 0.001]}>
<circleGeometry args={[0.005, 16]} />
<meshBasicMaterial color={m.color} />
</mesh>
{/* Name (tappable to make active) */}
<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>
{/* Action buttons */}
<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 scaleRatio = useModelStore((s) => s.scaleRatio);
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
return (
<group>
{/* Render mode */}
<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')} />
{/* Toggles */}
<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={`Escala ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]} label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
{/* Opacity slider buttons */}
<Text position={[-0.24, -0.02, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
{/* Scale presets */}
<Text position={[-0.24, -0.085, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label}
</Text>
{SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find(p => p.label === lbl)!;
return (
<XR3DButton key={lbl}
position={[-0.16 + i * 0.06, -0.11, 0]} size={[0.055, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
</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 } as Partial<typeof fineTuning>)} />
<XR3DButton position={[-0.05, 0, 0]} size={[0.04, 0.024]} label=""
onClick={() => setFineTuning({ [axis]: val - step } as Partial<typeof fineTuning>)} />
<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 } as Partial<typeof fineTuning>)} />
<XR3DButton position={[0.10, 0, 0]} size={[0.04, 0.024]} label="++"
onClick={() => setFineTuning({ [axis]: val + step * 10 } as Partial<typeof fineTuning>)} />
</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?.()} />
</>
)}
{!isLive && (
<>
<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>
);
}