Files
SteelXR2/src/components/XRHud.tsx
T
gpt-engineer-app[bot] d14b5bf89f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:08:39 +00:00

434 lines
19 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 { useState } from 'react';
import {
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
MousePointerClick, EyeOff, Focus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useModelStore } from '@/stores/useModelStore';
import { InspectionChecklist } from '@/components/InspectionChecklist';
import { ScaleSelector } from '@/components/ScaleSelector';
import { CloudLoader } from '@/components/CloudLoader';
import { ShareButton } from '@/components/ShareButton';
import { SceneModelList } from '@/components/SceneModelList';
import { toast } from 'sonner';
const POSITION_STEP = 0.001;
const ROTATION_STEP = 0.1;
interface XRHudProps {
freeMove: boolean;
onToggleFreeMove: () => void;
placementMode?: boolean;
onTogglePlacement?: () => void;
snapToPlanes?: boolean;
onToggleSnap?: () => void;
allowScale?: boolean;
onToggleAllowScale?: () => void;
}
const helpItems = [
{
title: 'Grip (1 mão) — Pan',
icon: '✊',
desc: 'Aperte UM grip lateral e mova o controle: a peça é arrastada (translação) sem rotacionar. Solte para fixar.',
},
{
title: 'Grip (2 mãos) — Zoom + Orbit',
icon: '🤲',
desc: 'Aperte o grip dos DOIS controles. AFASTE/APROXIME as mãos para dar ZOOM. Gire os punhos em direções opostas para ORBITAR a peça pelo eixo entre as mãos.',
},
{
title: 'Trigger — Selecionar / Medir',
icon: '🔫',
desc: 'O gatilho NÃO move a peça. Ele só age dentro dos modos Selecionar, Medir ou Posicionar — escolhendo elementos, marcando pontos ou colocando o modelo na superfície.',
},
{
title: 'Thumbstick — Locomoção',
icon: '🕹️',
desc: 'Para FRENTE e solte: teletransporta para o ponto apontado. Para os LADOS: snap-rotate de ±30° na câmera. Para TRÁS: passo de 0,3 m no sentido oposto ao olhar.',
},
{
title: 'Posicionar (Hit-Test)',
icon: '🎯',
desc: 'Ative para reposicionar o modelo. Aponte o controle para uma superfície real e aperte o TRIGGER no retículo para fixar o modelo ali.',
},
{
title: 'Snap (Planos)',
icon: '🧲',
desc: 'Quando ON, o modelo se alinha automaticamente aos planos detectados (chão, parede, mesa). Retículo azul indica snap ativo.',
},
{
title: 'Grid',
icon: '📐',
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
},
{
title: 'Sólido / Wire / Bordas',
icon: '🔲',
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
},
{
title: 'Medir',
icon: '📏',
desc: 'Ative e toque o TRIGGER em dois pontos da peça para medir a distância em milímetros. O snap de vértice auxilia a precisão.',
},
{
title: 'Screenshot',
icon: '📸',
desc: 'Captura uma imagem da visão AR atual. As capturas ficam na galeria do viewer.',
},
{
title: 'Inspeção',
icon: '📋',
desc: 'Abre o checklist para aprovar ou rejeitar itens (dimensões, furos, soldas, posição das chapas).',
},
{
title: 'Opacidade',
icon: '👁️',
desc: 'Ajusta a transparência do modelo de 0% a 100% para ver através da peça.',
},
{
title: 'Ajuste fino (Posição/Rotação)',
icon: '↔️',
desc: 'Use os botões +/ do painel para ajustes finos em milímetros (posição) e décimos de grau (rotação). O joystick não faz mais ajuste fino — ele agora é só locomoção.',
},
{
title: 'Botões A/B (Touch Plus direito)',
icon: '🎮',
desc: 'No modo Medir: A = desfazer último ponto; B = limpar todas as medições. O botão Menu (esquerdo) abre o menu Universal do Quest.',
},
];
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement, snapToPlanes = true, onToggleSnap, allowScale = false, onToggleAllowScale }: XRHudProps) {
const [expanded, setExpanded] = useState(false);
const [checklistOpen, setChecklistOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const {
opacity, setOpacity,
renderMode, setRenderMode,
showGrid, setShowGrid,
measureMode, setMeasureMode,
measurements, clearMeasurements, measurePoints,
fineTuning, setFineTuning, resetFineTuning,
addScreenshot,
checklist,
selectionMode, setSelectionMode,
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements,
} = useModelStore();
const hasSelection = selectedElementKeys.size > 0;
const hasHidden = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
const approved = checklist.filter(i => i.status === 'approved').length;
const rejected = checklist.filter(i => i.status === 'rejected').length;
const total = checklist.length;
const handleScreenshot = () => {
const canvas = document.querySelector('canvas');
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
addScreenshot(dataUrl);
toast.success('Screenshot capturado!');
};
return (
<>
{/* ── Slide-out checklist panel ── */}
<div
className={`absolute top-0 right-0 z-30 h-full w-80 max-w-[85vw] transition-transform duration-300 ease-in-out ${
checklistOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="h-full border-l bg-card/95 backdrop-blur-md shadow-2xl flex flex-col">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4 text-primary" />
<span className="font-mono text-xs font-semibold text-foreground">Inspeção</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] text-muted-foreground">
{approved + rejected}/{total}
</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setChecklistOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-4">
<InspectionChecklist />
</div>
</ScrollArea>
</div>
</div>
{/* ── Slide-out help panel ── */}
<div
className={`absolute top-0 left-0 z-30 h-full w-80 max-w-[85vw] transition-transform duration-300 ease-in-out ${
helpOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="h-full border-r bg-card/95 backdrop-blur-md shadow-2xl flex flex-col">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<HelpCircle className="h-4 w-4 text-primary" />
<span className="font-mono text-xs font-semibold text-foreground">Ajuda Ferramentas AR</span>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setHelpOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{helpItems.map((item) => (
<div key={item.title} className="rounded-lg border bg-muted/30 p-3 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm">{item.icon}</span>
<span className="font-mono text-[11px] font-semibold text-foreground">{item.title}</span>
</div>
<p className="text-[10px] leading-relaxed text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
{/* ── Bottom HUD ── */}
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none p-3">
<div className="pointer-events-auto mx-auto max-w-lg space-y-2">
{/* Expanded panel */}
{expanded && (
<div className="rounded-xl border bg-card/90 backdrop-blur-md p-3 shadow-2xl space-y-3 animate-in slide-in-from-bottom-2 duration-200 max-h-[60vh] overflow-y-auto">
{/* Scene models list */}
<SceneModelList compact />
{/* Opacity */}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<Eye className="h-3 w-3 text-primary" />
<span className="font-mono text-[10px] text-muted-foreground">Opacidade</span>
</div>
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
</div>
<Slider
value={[opacity * 100]}
min={0} max={100} step={5}
onValueChange={([v]) => setOpacity(v / 100)}
/>
</div>
{/* Fine Tuning */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Move className="h-3 w-3 text-primary" />
<span className="font-mono text-[10px] text-muted-foreground">Posição (mm)</span>
</div>
<Button variant="ghost" size="sm" className="h-5 text-[9px] px-1.5 gap-0.5" onClick={resetFineTuning}>
<RefreshCw className="h-2.5 w-2.5" /> Reset
</Button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{(['posX', 'posY', 'posZ'] as const).map((axis) => (
<div key={axis} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1">
<span className="font-mono text-[9px] text-muted-foreground w-4">{axis.slice(-1).toUpperCase()}</span>
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
onClick={() => setFineTuning({ [axis]: fineTuning[axis] - POSITION_STEP })}></Button>
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
{(fineTuning[axis] * 1000).toFixed(1)}
</span>
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
onClick={() => setFineTuning({ [axis]: fineTuning[axis] + POSITION_STEP })}>+</Button>
</div>
))}
</div>
{/* Rotation X, Y, Z */}
<div className="flex items-center gap-1.5 mt-1">
<RotateCw className="h-3 w-3 text-primary" />
<span className="font-mono text-[10px] text-muted-foreground">Rotação</span>
</div>
<div className="grid grid-cols-3 gap-1.5">
{(['rotX', 'rotY', 'rotZ'] as const).map((axis) => (
<div key={axis} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1">
<span className="font-mono text-[9px] text-muted-foreground w-4">{axis.slice(-1).toUpperCase()}°</span>
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
onClick={() => setFineTuning({ [axis]: fineTuning[axis] - ROTATION_STEP })}></Button>
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
{fineTuning[axis].toFixed(1)}
</span>
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
onClick={() => setFineTuning({ [axis]: fineTuning[axis] + ROTATION_STEP })}>+</Button>
</div>
))}
</div>
</div>
</div>
)}
{/* Main toolbar */}
<div className="flex items-center justify-between rounded-xl border bg-card/90 backdrop-blur-md p-2 shadow-2xl">
<div className="flex items-center gap-1.5 flex-wrap">
{/* Help button */}
<Button variant={helpOpen ? 'default' : 'outline'} size="icon" className="h-8 w-8" onClick={() => setHelpOpen(!helpOpen)} title="Ajuda">
<HelpCircle className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
{/* Hit-test placement toggle */}
{onTogglePlacement && (
<Button
variant={placementMode ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onTogglePlacement}
>
<Crosshair className="h-3.5 w-3.5" />
{placementMode ? 'Posicionar…' : 'Reposicionar'}
</Button>
)}
{/* Snap to planes toggle */}
{onToggleSnap && (
<Button
variant={snapToPlanes ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleSnap}
title="Snap automático em planos detectados"
>
<Magnet className="h-3.5 w-3.5" />
{snapToPlanes ? 'Snap ON' : 'Snap'}
</Button>
)}
{/* Two-hand scale toggle */}
{onToggleAllowScale && (
<Button
variant={allowScale ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleAllowScale}
title="Permite escalar a peça com gesto de duas mãos (afastando os controles)"
>
<Maximize2 className="h-3.5 w-3.5" />
{allowScale ? 'Escala ON' : 'Escala'}
</Button>
)}
{/* Scale presets */}
<ScaleSelector variant="compact" />
{/* Cloud loader (acessível também no AR) */}
<CloudLoader compact />
{/* Share live screen */}
<ShareButton variant="compact" />
<Button
variant={freeMove ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleFreeMove}
>
<Grip className="h-3.5 w-3.5" />
{freeMove ? 'Mover ON' : 'Mover'}
</Button>
<Button
variant={showGrid ? 'default' : 'outline'}
size="icon" className="h-8 w-8"
onClick={() => setShowGrid(!showGrid)}
title="Grid"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
variant={renderMode !== 'solid' ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => {
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
setRenderMode(next);
}}
>
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
</Button>
<Button
variant={measureMode ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => setMeasureMode(!measureMode)}
>
<Ruler className="h-3.5 w-3.5" />
{measureMode
? measurePoints.length === 0 ? 'Pt A…' : 'Pt B…'
: 'Medir'}
</Button>
{measurements.length > 0 && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={clearMeasurements} title="Limpar medições">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
{/* Selection */}
<Button
variant={selectionMode ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => setSelectionMode(!selectionMode)}
title="Selecionar elementos (viga, chapa…) — gatilho direito alterna"
>
<MousePointerClick className="h-3.5 w-3.5" />
{selectionMode ? (hasSelection ? `${selectedElementKeys.size} sel.` : 'Selec…') : 'Selec'}
</Button>
{hasSelection && (
<>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={hideSelectedElements} title="Esconder">
<EyeOff className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={isolateSelectedElements} title="Isolar">
<Focus className="h-3.5 w-3.5 text-primary" />
</Button>
</>
)}
{hasHidden && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={showAllElements} title="Mostrar tudo">
<Eye className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleScreenshot} title="Screenshot">
<Camera className="h-3.5 w-3.5" />
</Button>
{/* Checklist toggle */}
<Button
variant={checklistOpen ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono relative"
onClick={() => setChecklistOpen(!checklistOpen)}
>
<ClipboardCheck className="h-3.5 w-3.5" />
Inspeção
{(approved + rejected) > 0 && (
<span className={`ml-1 font-mono text-[9px] ${rejected > 0 ? 'text-destructive' : 'text-primary'}`}>
{approved + rejected}/{total}
</span>
)}
</Button>
</div>
</div>
</div>
</div>
</>
);
}