Files
SteelXR/src/components/XRHud.tsx
T
gpt-engineer-app[bot] 58169481d3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-14 12:51:50 +00:00

396 lines
17 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,
} 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 { 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: 'Agarrar a peça (Grip)',
icon: '✊',
desc: 'Aproxime o controle e APERTE O GRIP LATERAL — a peça vira extensão da sua mão (mover + girar em 6 eixos). Solte o grip para fixar na posição.',
},
{
title: 'Two-Hand (girar/escalar)',
icon: '🤲',
desc: 'Aperte o grip dos DOIS controles ao mesmo tempo. Gire os punhos para rotacionar a peça pelo eixo entre as mãos. Aproxime/afaste para escalar (apenas se o toggle "Escala" estiver ON).',
},
{
title: 'Posicionar (Hit-Test)',
icon: '🎯',
desc: 'Aponte para uma superfície real e toque para posicionar o modelo. O retículo verde indica onde o modelo será colocado.',
},
{
title: 'Snap (Planos)',
icon: '🧲',
desc: 'Quando ativado, o modelo se alinha automaticamente a planos detectados (chão, parede, mesa). O retículo fica azul quando snap está ativo.',
},
{
title: 'Joystick (ajuste fino)',
icon: '🕹️',
desc: 'Esquerdo: X (lateral) / Z (frente-trás). Direito: rotação Y / altura Y. Aperte o TRIGGER para acelerar (leve = passo fino, forte = passo rápido). O joystick é desativado enquanto você segura o grip (que tem prioridade para agarrar).',
},
{
title: 'Grid',
icon: '📐',
desc: 'Exibe/oculta a grade de referência para auxiliar no alinhamento visual do modelo com o ambiente.',
},
{
title: 'Sólido / Wire / Bordas',
icon: '🔲',
desc: 'Alterna entre modos de renderização: Sólido (preenchido), Wireframe (malha de arame), Bordas (apenas contornos).',
},
{
title: 'Medir',
icon: '📏',
desc: 'Ative e toque em dois pontos no modelo para medir a distância entre eles em milímetros. O snap de vértice auxilia na precisão.',
},
{
title: 'Screenshot',
icon: '📸',
desc: 'Captura uma imagem da visão AR atual. As capturas ficam salvas na galeria do viewer.',
},
{
title: 'Inspeção',
icon: '📋',
desc: 'Abre o checklist de inspeção para aprovar ou rejeitar itens como dimensões, furos, soldas e posição das placas.',
},
{
title: 'Opacidade',
icon: '👁️',
desc: 'Ajusta a transparência do modelo de 0% (invisível) a 100% (opaco) para visualizar através da peça.',
},
{
title: 'Posição (mm)',
icon: '↔️',
desc: 'Ajuste fino da posição do modelo nos eixos X, Y e Z com incrementos de 1mm usando os botões +/.',
},
{
title: 'Rotação X° / Y° / Z°',
icon: '🔄',
desc: 'Rotaciona o modelo ao redor de cada eixo com incrementos de 0.1°. X = inclinação frontal, Y = giro lateral, Z = tombamento.',
},
{
title: 'Botões A/B (Touch Plus)',
icon: '🎮',
desc: 'Atualmente reservados para o sistema (menu Meta). O fluxo natural usa Grip para agarrar e Trigger para acelerar o joystick.',
},
];
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,
} = useModelStore();
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">
{/* 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>
)}
<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>
</>
);
}