bf74c96e4a
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
584 lines
25 KiB
TypeScript
584 lines
25 KiB
TypeScript
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<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 [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);
|
||
/** 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 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 (
|
||
<>
|
||
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
|
||
{/* Head-locked fallback: always-visible quick controls in lower-left of FOV */}
|
||
<group ref={headLockRef} renderOrder={999}>
|
||
<XR3DButton position={[0, 0.018, 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.008, 0]} size={[0.07, 0.022]}
|
||
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
||
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
||
</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: '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 (
|
||
<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>
|
||
{/* 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.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 === 'inspection' && <InspectionTab />}
|
||
{tab === 'share' && <ShareTab {...p} />}
|
||
{tab === 'webxr' && <WebXRTab />}
|
||
</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)} />
|
||
);
|
||
})}
|
||
|
||
{/* Grid floor controls */}
|
||
<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 placeAtFloor = () => {
|
||
// Headset eye Y minus avg standing eye height (1.6 m)
|
||
const floor = camera.position.y - 1.6;
|
||
setGridY(floor);
|
||
};
|
||
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="Pousar chão"
|
||
onClick={placeAtFloor} />
|
||
</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>
|
||
);
|
||
}
|