Files
SteelXR2/src/components/three/XRHudInWorld.tsx
T

713 lines
30 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';
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, gl } = useThree();
const leftCtrl = useXRInputSourceState('controller', 'left');
const rightCtrl = useXRInputSourceState('controller', 'right');
const open = useModelStore((s) => s.xrHudOpen);
const setOpen = useModelStore((s) => s.setXrHudOpen);
const tab = useModelStore((s) => s.xrHudTab) as Tab;
const setTab = useModelStore((s) => s.setXrHudTab) as unknown as (t: Tab) => void;
/** 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(!open);
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-right 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(!open)} />
{/* Head-locked fallback: always-visible quick controls in lower-right of FOV */}
<group ref={headLockRef} renderOrder={999}>
<XR3DButton position={[0, 0.026, 0]} size={[0.07, 0.022]}
label={open ? '✕ Menu' : '☰ Menu'} active={open}
onClick={() => { setOpen(!open); reanchorRequested.current = true; }}
fontSize={0.0085} />
<XR3DButton position={[0, 0, 0]} size={[0.07, 0.022]}
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
<XR3DButton position={[0, -0.026, 0]} size={[0.07, 0.022]}
label="✕ Sair AR" active={false}
onClick={() => {
gl.xr.getSession()?.end();
}} fontSize={0.0085} />
<RecIndicator />
</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.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
label={t.label} icon={t.icon} active={tab === t.id}
onClick={() => setTab(t.id)} fontSize={0.0072} />
))}
</group>
<group position={[0, -0.012, 0.001]}>
{tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />}
{tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />}
{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 selectionMode = useModelStore((s) => s.selectionMode);
const setSelectionMode = useModelStore((s) => s.setSelectionMode);
const selectedCount = useModelStore((s) => s.selectedElementKeys.size);
const hideSelectedElements = useModelStore((s) => s.hideSelectedElements);
const isolateSelectedElements = useModelStore((s) => s.isolateSelectedElements);
const showAllElements = useModelStore((s) => s.showAllElements);
const hasHidden = useModelStore((s) =>
s.hiddenElementKeys.size > 0 || s.isolatedElementKeys !== null
);
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 (
<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="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={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
{/* Selecionar — paridade com a tela inicial */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} />
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} />
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.115, 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.14, 0]} size={[0.055, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
<XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
label="↺ Reset" color="#dc2626"
onClick={resetScale} fontSize={0.0085} />
<XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} />
{measureMode && (
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
</Text>
)}
<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>
);
}
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 (
<group>
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar).
</Text>
<Text position={[0, 0.075, 0]} fontSize={0.014} color={statusColor}
anchorX="center" anchorY="middle">
{statusLabel}
</Text>
{/* Foto */}
<XR3DButton position={[-0.13, 0.02, 0]} size={[0.18, 0.038]}
label="📸 Tirar Foto" color="#3b82f6"
onClick={onPhoto} fontSize={0.011} />
{/* Iniciar / Parar gravação */}
{rec.status === 'idle' ? (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="● Iniciar Gravação" color="#dc2626"
onClick={startRecording} fontSize={0.011} />
) : (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="■ Parar e Salvar" color="#dc2626" active
onClick={stopRecording} fontSize={0.011} />
)}
{/* Pause / Resume */}
{rec.status === 'recording' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="❚❚ Pausar" color="#f59e0b"
onClick={pauseRecording} fontSize={0.011} />
)}
{rec.status === 'paused' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="▶ Retomar" color="#22c55e" active
onClick={resumeRecording} fontSize={0.011} />
)}
<Text position={[-0.24, -0.105, 0]} fontSize={0.0075} color="#64748b"
anchorX="left" maxWidth={0.48}>
Foto .png | Vídeo .webm | Arquivos vão para a pasta Downloads do navegador.
</Text>
</group>
);
}
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 (
<group position={[0.09, 0.018, 0]}>
<mesh>
<planeGeometry args={[0.078, 0.022]} />
<meshBasicMaterial color="#0a0a0a" transparent opacity={0.9} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color={color}
anchorX="center" anchorY="middle">
{label}
</Text>
</group>
);
}