d14b5bf89f
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
434 lines
19 KiB
TypeScript
434 lines
19 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
} |