Files
SteelXR/src/components/three/XRHudInWorld.tsx
T
gpt-engineer-app[bot] e9227f51bf Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-14 18:27:18 +00:00

426 lines
18 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, 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';
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'];
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 [open, setOpen] = 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);
/** 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 targetPos = useRef(new THREE.Vector3());
const targetQuat = useRef(new THREE.Quaternion());
const lastABtn = 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) {
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;
}
}
});
return (
<>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{open && (
<group ref={panelRef}>
<FloatingPanel
tab={tab}
setTab={setTab}
pinned={pinned}
onTogglePin={() => setPinned((v) => !v)}
onRecenter={() => { reanchorRequested.current = true; }}
{...props}
/>
</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]}>
<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>
<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>
<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.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 scaleRatio = useModelStore((s) => s.scaleRatio);
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
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={`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} />
<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)} />
<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((sp) => sp.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 })} />
<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>
);
}