Reverted to commit c93e56640d
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.6",
|
"version": "1.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
|
import XRSession from "./pages/XRSession";
|
||||||
import Watch from "./pages/Watch";
|
import Watch from "./pages/Watch";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import "@/lib/remoteLogger";
|
import "@/lib/remoteLogger";
|
||||||
@@ -20,6 +21,7 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
|
<Route path="/xr" element={<XRSession />} />
|
||||||
<Route path="/watch/:code" element={<Watch />} />
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { RotateCw, RefreshCw, Move, HelpCircle } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export function ViewCube() {
|
|
||||||
const { activeModelId, models, setFineTuning } = useModelStore();
|
|
||||||
const activeModel = models.find((m) => m.id === activeModelId);
|
|
||||||
|
|
||||||
// Estados locais para controlar uma rotação interativa ou visualização do cubo
|
|
||||||
const [hoveredFace, setHoveredFace] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!activeModelId || !activeModel) return null;
|
|
||||||
|
|
||||||
const alignToFace = (face: string) => {
|
|
||||||
let rotX = 0;
|
|
||||||
let rotY = 0;
|
|
||||||
let rotZ = 0;
|
|
||||||
|
|
||||||
switch (face) {
|
|
||||||
case 'TOPO':
|
|
||||||
rotX = 0;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
case 'FRENTE':
|
|
||||||
rotX = 90;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
case 'DIREITA':
|
|
||||||
rotX = 90;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = -90;
|
|
||||||
break;
|
|
||||||
case 'ESQUERDA':
|
|
||||||
rotX = 90;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 90;
|
|
||||||
break;
|
|
||||||
case 'ATRÁS':
|
|
||||||
rotX = -90;
|
|
||||||
rotY = 180;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
case 'BASE':
|
|
||||||
rotX = 180;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
|
||||||
toast.success(`Alinhado para vista ${face}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ajusta a rotação para o múltiplo de 90 graus mais próximo
|
|
||||||
const snapToNearest90 = () => {
|
|
||||||
const ft = activeModel.fineTuning;
|
|
||||||
const rotX = Math.round(ft.rotX / 90) * 90;
|
|
||||||
const rotY = Math.round(ft.rotY / 90) * 90;
|
|
||||||
const rotZ = Math.round(ft.rotZ / 90) * 90;
|
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
|
||||||
toast.success(`Snap Ortogonal (90°): [${rotX}°, ${rotY}°, ${rotZ}°]`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAllTransforms = () => {
|
|
||||||
setFineTuning({ posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 });
|
|
||||||
toast.success('Posição e rotação restauradas para a origem');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 z-20 flex flex-col items-center gap-3 rounded-xl border border-white/10 bg-black/50 p-4 shadow-2xl backdrop-blur-md select-none text-white w-40">
|
|
||||||
{/* Título do Widget */}
|
|
||||||
<span className="font-mono text-[9px] font-semibold text-muted-foreground uppercase tracking-widest text-center">
|
|
||||||
Alinhamento 3D
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Cubo 3D Visual */}
|
|
||||||
<div className="relative h-20 w-20 flex items-center justify-center my-2 group">
|
|
||||||
{/* Container do Cubo Isométrico */}
|
|
||||||
<div className="h-14 w-14 transition-transform duration-500 ease-out viewcube-wrapper">
|
|
||||||
{/* TOPO (Z+) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('TOPO')}
|
|
||||||
onMouseEnter={() => setHoveredFace('TOPO')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-top ${
|
|
||||||
hoveredFace === 'TOPO' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-800/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
TOPO
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FRENTE (Y-) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('FRENTE')}
|
|
||||||
onMouseEnter={() => setHoveredFace('FRENTE')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-front ${
|
|
||||||
hoveredFace === 'FRENTE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-700/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
FRENTE
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DIREITA (X+) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('DIREITA')}
|
|
||||||
onMouseEnter={() => setHoveredFace('DIREITA')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-right ${
|
|
||||||
hoveredFace === 'DIREITA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
DIR
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ESQUERDA (X-) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('ESQUERDA')}
|
|
||||||
onMouseEnter={() => setHoveredFace('ESQUERDA')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-left ${
|
|
||||||
hoveredFace === 'ESQUERDA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
ESQ
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ATRÁS (Y+) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('ATRÁS')}
|
|
||||||
onMouseEnter={() => setHoveredFace('ATRÁS')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-back ${
|
|
||||||
hoveredFace === 'ATRÁS' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-750/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
ATRÁS
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BASE (Z-) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('BASE')}
|
|
||||||
onMouseEnter={() => setHoveredFace('BASE')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-bottom ${
|
|
||||||
hoveredFace === 'BASE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-900/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
BASE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botões Rápidos de Alinhamento */}
|
|
||||||
<div className="flex flex-col gap-1.5 w-full mt-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={snapToNearest90}
|
|
||||||
title="Corrige a orientação aproximando para o múltiplo de 90° mais próximo"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-3 w-3 text-primary animate-pulse" />
|
|
||||||
<span>Alinhar 90° (Snap)</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={resetAllTransforms}
|
|
||||||
title="Zera a posição e a rotação da peça atual"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3 text-destructive" />
|
|
||||||
<span>Resetar Origem</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info compacta */}
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[8px] text-muted-foreground mt-0.5 select-none justify-center">
|
|
||||||
<span>X: {Math.round(activeModel.fineTuning.rotX)}°</span>
|
|
||||||
<span>Y: {Math.round(activeModel.fineTuning.rotY)}°</span>
|
|
||||||
<span>Z: {Math.round(activeModel.fineTuning.rotZ)}°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } from 'lucide-react';
|
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -20,16 +20,6 @@ const WIRE_COLORS = [
|
|||||||
{ label: 'Branco', value: '#e2e8f0' },
|
{ label: 'Branco', value: '#e2e8f0' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const colorClassMap: Record<string, string> = {
|
|
||||||
'#8899aa': 'bg-[#8899aa]',
|
|
||||||
'#ef4444': 'bg-red-500',
|
|
||||||
'#22c55e': 'bg-green-500',
|
|
||||||
'#3b82f6': 'bg-blue-500',
|
|
||||||
'#eab308': 'bg-yellow-500',
|
|
||||||
'#f97316': 'bg-orange-500',
|
|
||||||
'#e2e8f0': 'bg-slate-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ViewerControls() {
|
export function ViewerControls() {
|
||||||
const {
|
const {
|
||||||
opacity, setOpacity, renderMode, setRenderMode,
|
opacity, setOpacity, renderMode, setRenderMode,
|
||||||
@@ -44,9 +34,6 @@ export function ViewerControls() {
|
|||||||
selectionMode, setSelectionMode,
|
selectionMode, setSelectionMode,
|
||||||
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
||||||
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
||||||
desktopCameraMode, setDesktopCameraMode,
|
|
||||||
xrEnvironment, setXrEnvironment,
|
|
||||||
xrScaleMode, setXrScaleMode,
|
|
||||||
} = useModelStore();
|
} = useModelStore();
|
||||||
const activeModel = models.find(m => m.id === activeModelId);
|
const activeModel = models.find(m => m.id === activeModelId);
|
||||||
const hasSelection = selectedElementKeys.size > 0;
|
const hasSelection = selectedElementKeys.size > 0;
|
||||||
@@ -125,13 +112,13 @@ export function ViewerControls() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 h-9"
|
className="gap-2 h-9"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
||||||
setRenderMode(next);
|
setRenderMode(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">
|
||||||
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wireframe' : 'Bordas'}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -141,7 +128,7 @@ export function ViewerControls() {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2 h-9">
|
<Button variant="outline" size="sm" className="gap-2 h-9">
|
||||||
<Palette className="h-3.5 w-3.5" />
|
<Palette className="h-3.5 w-3.5" />
|
||||||
<div className={`h-3 w-3 rounded-full border border-muted-foreground/30 ${colorClassMap[wireframeColor] || 'bg-[#8899aa]'}`} />
|
<div className="h-3 w-3 rounded-full border border-muted-foreground/30" style={{ backgroundColor: wireframeColor }} />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-56 space-y-3" side="top" align="start">
|
<PopoverContent className="w-56 space-y-3" side="top" align="start">
|
||||||
@@ -151,7 +138,8 @@ export function ViewerControls() {
|
|||||||
{WIRE_COLORS.map((c) => (
|
{WIRE_COLORS.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.value}
|
key={c.value}
|
||||||
className={`h-6 w-6 rounded-full border-2 transition-all ${colorClassMap[c.value] || 'bg-[#8899aa]'} ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
|
className={`h-6 w-6 rounded-full border-2 transition-all ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
onClick={() => setWireframeColor(c.value)}
|
onClick={() => setWireframeColor(c.value)}
|
||||||
title={c.label}
|
title={c.label}
|
||||||
/>
|
/>
|
||||||
@@ -293,74 +281,6 @@ export function ViewerControls() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seletor de Câmera (Orbit vs Walk) */}
|
|
||||||
<Button
|
|
||||||
variant={desktopCameraMode === 'walk' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 h-9"
|
|
||||||
onClick={() => {
|
|
||||||
const next = desktopCameraMode === 'orbit' ? 'walk' : 'orbit';
|
|
||||||
setDesktopCameraMode(next);
|
|
||||||
if (next === 'walk') {
|
|
||||||
toast.info("Passeio 3D Ativo: clique no visualizador para prender o mouse. WASD = andar, Q/E = subir/descer, ESC = soltar mouse.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Alternar entre câmera orbital e passeio em primeira pessoa"
|
|
||||||
>
|
|
||||||
{desktopCameraMode === 'walk' ? <Footprints className="h-3.5 w-3.5" /> : <Compass className="h-3.5 w-3.5" />}
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{desktopCameraMode === 'walk' ? 'Passeio 3D' : 'Câmera'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Seletor de Cenário 2D */}
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2 h-9" title="Alterar cenário de fundo do visualizador">
|
|
||||||
<Map className="h-3.5 w-3.5" />
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{xrEnvironment === 'passthrough' ? 'Estúdio' : xrEnvironment === 'office' ? 'Escritório' : 'Campo Aberto'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-56 space-y-2" side="top" align="start">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className="font-mono text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1">
|
|
||||||
Cenário de Fundo
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'passthrough' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => setXrEnvironment('passthrough')}
|
|
||||||
>
|
|
||||||
🌌 Estúdio (Padrão)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'office' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('office');
|
|
||||||
setXrScaleMode('tabletop');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🏢 Escritório (VR Mesa)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'field' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('field');
|
|
||||||
setXrScaleMode('realscale');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🌳 Campo Aberto (VR Grama)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Screenshot */}
|
{/* Screenshot */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ const helpItems = [
|
|||||||
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sólido / Bordas',
|
title: 'Sólido / Wire / Bordas',
|
||||||
icon: '🔲',
|
icon: '🔲',
|
||||||
desc: 'Alterna modos de renderização: Sólido (preenchido) e Bordas (destaque visual de arestas com transparência).',
|
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Medir',
|
title: 'Medir',
|
||||||
@@ -345,12 +345,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
||||||
setRenderMode(next);
|
setRenderMode(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||||
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
|
||||||
import { PointerLockControls } from '@react-three/drei';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
|
||||||
|
|
||||||
export function DesktopFirstPersonControls() {
|
|
||||||
const { camera } = useThree();
|
|
||||||
const activeMode = useModelStore((s) => s.desktopCameraMode);
|
|
||||||
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
|
|
||||||
|
|
||||||
// Armazena o estado atual das teclas de locomoção
|
|
||||||
const keys = useRef({
|
|
||||||
w: false,
|
|
||||||
a: false,
|
|
||||||
s: false,
|
|
||||||
d: false,
|
|
||||||
arrowup: false,
|
|
||||||
arrowleft: false,
|
|
||||||
arrowdown: false,
|
|
||||||
arrowright: false,
|
|
||||||
q: false, // voar para baixo
|
|
||||||
e: false, // voar para cima
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeMode !== 'walk') return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const k = e.key.toLowerCase();
|
|
||||||
if (k in keys.current) {
|
|
||||||
keys.current[k as keyof typeof keys.current] = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
const k = e.key.toLowerCase();
|
|
||||||
if (k in keys.current) {
|
|
||||||
keys.current[k as keyof typeof keys.current] = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
|
||||||
};
|
|
||||||
}, [activeMode]);
|
|
||||||
|
|
||||||
// Define posições iniciais amigáveis ao alternar para o modo passeio
|
|
||||||
const lastMode = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeMode === 'walk' && lastMode.current !== 'walk') {
|
|
||||||
if (xrEnvironment === 'office') {
|
|
||||||
// Posiciona o operador em pé na ponta da mesa
|
|
||||||
camera.position.set(0, 1.4, 1.3);
|
|
||||||
camera.lookAt(0, 0.85, 0);
|
|
||||||
} else {
|
|
||||||
// Posiciona no campo aberto em escala real
|
|
||||||
camera.position.set(0, 1.65, 3);
|
|
||||||
camera.lookAt(0, 1.0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastMode.current = activeMode;
|
|
||||||
}, [activeMode, xrEnvironment, camera]);
|
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
|
||||||
if (activeMode !== 'walk') return;
|
|
||||||
|
|
||||||
const speed = 2.0 * delta; // Metros por frame adaptativo
|
|
||||||
const rotSpeed = 1.5 * delta;
|
|
||||||
|
|
||||||
const direction = new THREE.Vector3();
|
|
||||||
const side = new THREE.Vector3();
|
|
||||||
|
|
||||||
// Obtém vetor de olhar da câmera
|
|
||||||
camera.getWorldDirection(direction);
|
|
||||||
direction.y = 0; // Trava o vetor na horizontal para não voar para cima/baixo ao olhar
|
|
||||||
direction.normalize();
|
|
||||||
|
|
||||||
// Obtém vetor lateral
|
|
||||||
side.crossVectors(camera.up, direction).normalize();
|
|
||||||
|
|
||||||
// 1. Leituras do teclado
|
|
||||||
let moveFwd = 0;
|
|
||||||
let moveSide = 0;
|
|
||||||
let moveUp = 0;
|
|
||||||
|
|
||||||
if (keys.current.w || keys.current.arrowup) moveFwd += 1;
|
|
||||||
if (keys.current.s || keys.current.arrowdown) moveFwd -= 1;
|
|
||||||
if (keys.current.d || keys.current.arrowright) moveSide -= 1;
|
|
||||||
if (keys.current.a || keys.current.arrowleft) moveSide += 1;
|
|
||||||
if (keys.current.e) moveUp += 1;
|
|
||||||
if (keys.current.q) moveUp -= 1;
|
|
||||||
|
|
||||||
camera.position.addScaledVector(direction, moveFwd * speed);
|
|
||||||
camera.position.addScaledVector(side, moveSide * speed);
|
|
||||||
camera.position.y += moveUp * speed;
|
|
||||||
|
|
||||||
// 2. Leituras da Gamepad API (Xbox / PlayStation)
|
|
||||||
const gamepads = navigator.getGamepads();
|
|
||||||
const gp = gamepads[0] || gamepads[1];
|
|
||||||
if (gp) {
|
|
||||||
const deadzone = 0.15;
|
|
||||||
|
|
||||||
// Analógico Esquerdo (Locomoção)
|
|
||||||
const stickLX = gp.axes[0]; // Eixo lateral
|
|
||||||
const stickLY = gp.axes[1]; // Eixo frente/trás
|
|
||||||
|
|
||||||
if (Math.abs(stickLY) > deadzone) {
|
|
||||||
camera.position.addScaledVector(direction, -stickLY * speed);
|
|
||||||
}
|
|
||||||
if (Math.abs(stickLX) > deadzone) {
|
|
||||||
camera.position.addScaledVector(side, -stickLX * speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analógico Direito (Rotação da Câmera)
|
|
||||||
const stickRX = gp.axes[2]; // Rotação Y
|
|
||||||
const stickRY = gp.axes[3]; // Olhar X cima/baixo
|
|
||||||
|
|
||||||
if (Math.abs(stickRX) > deadzone) {
|
|
||||||
camera.rotation.y -= stickRX * rotSpeed * 1.6;
|
|
||||||
}
|
|
||||||
if (Math.abs(stickRY) > deadzone) {
|
|
||||||
// Rotaciona verticalmente
|
|
||||||
camera.rotation.x -= stickRY * rotSpeed * 1.6;
|
|
||||||
// Limita ângulo vertical para evitar inverter a visão (entre -80° e 80°)
|
|
||||||
camera.rotation.x = Math.max(-1.4, Math.min(1.4, camera.rotation.x));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suporta tanto o mapeamento de Triggers (RT/LT - botões 7/6) quanto de Grips/Bumpers (RB/LB - botões 5/4) para subir e descer a altura da câmera
|
|
||||||
const triggerLT = gp.buttons[6];
|
|
||||||
const triggerRT = gp.buttons[7];
|
|
||||||
const bumperLB = gp.buttons[4];
|
|
||||||
const bumperRB = gp.buttons[5];
|
|
||||||
|
|
||||||
const valUp = Math.max(
|
|
||||||
triggerRT ? (triggerRT.value || (triggerRT.pressed ? 1 : 0)) : 0,
|
|
||||||
bumperRB ? (bumperRB.value || (bumperRB.pressed ? 1 : 0)) : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const valDown = Math.max(
|
|
||||||
triggerLT ? (triggerLT.value || (triggerLT.pressed ? 1 : 0)) : 0,
|
|
||||||
bumperLB ? (bumperLB.value || (bumperLB.pressed ? 1 : 0)) : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (valUp > 0.1) {
|
|
||||||
camera.position.y += valUp * speed;
|
|
||||||
}
|
|
||||||
if (valDown > 0.1) {
|
|
||||||
camera.position.y -= valDown * speed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trava de colisão com o chão/mesa para manter realismo
|
|
||||||
const minHeight = xrEnvironment === 'office' ? 0.9 : 0.1;
|
|
||||||
if (camera.position.y < minHeight) {
|
|
||||||
camera.position.y = minHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeMode !== 'walk') return null;
|
|
||||||
|
|
||||||
return <PointerLockControls makeDefault />;
|
|
||||||
}
|
|
||||||
@@ -3,38 +3,12 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
|||||||
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore, type SceneModel, type HoverInfo } from '@/stores/useModelStore';
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
// WebXR imersivo
|
|
||||||
import { XR, useXR, XROrigin } from '@react-three/xr';
|
|
||||||
import { xrStore } from '@/lib/xrStore';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
XRBackgroundModels,
|
|
||||||
XRActiveModel,
|
|
||||||
XRMeasurementOverlay,
|
|
||||||
XRSnapHandler,
|
|
||||||
XRGrid,
|
|
||||||
XRGridAutoFollower,
|
|
||||||
XREnvironmentSelector,
|
|
||||||
} from './XRSceneComponents';
|
|
||||||
import { XRGrabbable } from './XRGrabbable';
|
|
||||||
import { DesktopFirstPersonControls } from './DesktopFirstPersonControls';
|
|
||||||
import { XRHitTestPlacement } from './XRHitTestPlacement';
|
|
||||||
import { XRControllerMeasure } from './XRControllerMeasure';
|
|
||||||
import { ControllerLocomotion } from './ControllerLocomotion';
|
|
||||||
import { XRHudInWorld } from './XRHudInWorld';
|
|
||||||
import { XRBroadcastMirror } from './XRBroadcastMirror';
|
|
||||||
import { XRHud } from '@/components/XRHud';
|
|
||||||
import { ShareButton } from '@/components/ShareButton';
|
|
||||||
import { useDevKit } from '@/devkit/useDevKit';
|
|
||||||
import { DevPanel } from '@/devkit/DevPanel';
|
|
||||||
import { FakeControllers } from '@/devkit/FakeControllers';
|
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
url?: string; // legacy, ignored — uses store.models
|
||||||
}
|
}
|
||||||
@@ -61,15 +35,6 @@ export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | nu
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findModelId(obj: THREE.Object3D | null): string | undefined {
|
|
||||||
let cur: THREE.Object3D | null = obj;
|
|
||||||
while (cur) {
|
|
||||||
if (cur.userData?.modelId) return cur.userData.modelId as string;
|
|
||||||
cur = cur.parent;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasIfcAncestor(obj: THREE.Object3D): boolean {
|
function hasIfcAncestor(obj: THREE.Object3D): boolean {
|
||||||
let cur = obj.parent;
|
let cur = obj.parent;
|
||||||
while (cur) {
|
while (cur) {
|
||||||
@@ -196,13 +161,18 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
originalColors.current.set(mat, mat.color.clone());
|
originalColors.current.set(mat, mat.color.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
|
if (renderMode === 'edges' && !measureMode) {
|
||||||
|
mat.visible = false;
|
||||||
|
} else {
|
||||||
mat.visible = true;
|
mat.visible = true;
|
||||||
const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity;
|
const targetOpacity = measureMode ? 0.25 : opacity;
|
||||||
mat.transparent = targetOpacity < 1;
|
mat.transparent = targetOpacity < 1;
|
||||||
mat.opacity = targetOpacity;
|
mat.opacity = targetOpacity;
|
||||||
mat.wireframe = false;
|
mat.wireframe = renderMode === 'wireframe';
|
||||||
if (hasRejected) {
|
if (renderMode === 'wireframe') {
|
||||||
|
mat.wireframeLinewidth = wireframeThickness;
|
||||||
|
mat.color.set(wireframeColor);
|
||||||
|
} else if (hasRejected) {
|
||||||
mat.color.setHSL(0, 0.7, 0.5);
|
mat.color.setHSL(0, 0.7, 0.5);
|
||||||
} else if (allApproved) {
|
} else if (allApproved) {
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||||
@@ -210,6 +180,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
// Tint with the per-model color (subtle)
|
// Tint with the per-model color (subtle)
|
||||||
mat.color.set(sceneModel.color);
|
mat.color.set(sceneModel.color);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
mat.needsUpdate = true;
|
mat.needsUpdate = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -217,8 +188,8 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
||||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||||
const lineMat = new THREE.LineBasicMaterial({
|
const lineMat = new THREE.LineBasicMaterial({
|
||||||
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
|
color: measureMode ? '#00f3ff' : wireframeColor,
|
||||||
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
|
linewidth: measureMode ? 2 : wireframeThickness,
|
||||||
});
|
});
|
||||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||||||
lineSegments.userData.__edgeLine = true;
|
lineSegments.userData.__edgeLine = true;
|
||||||
@@ -384,28 +355,16 @@ function useLabelDistanceFactor(position: [number, number, number]): number {
|
|||||||
const dist = camera.position.distanceTo(v);
|
const dist = camera.position.distanceTo(v);
|
||||||
const perspCam = camera as THREE.PerspectiveCamera;
|
const perspCam = camera as THREE.PerspectiveCamera;
|
||||||
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
|
||||||
const height = size.height || 1;
|
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
|
||||||
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / height;
|
|
||||||
// distanceFactor scales the html so it stays ~140 px tall at this distance
|
// distanceFactor scales the html so it stays ~140 px tall at this distance
|
||||||
ref.current = isNaN(worldPerPixel) || !isFinite(worldPerPixel) ? 1.5 : Math.max(0.05, worldPerPixel * 140);
|
ref.current = Math.max(0.05, worldPerPixel * 140);
|
||||||
});
|
});
|
||||||
return ref.current;
|
return ref.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DynamicLabel({ position, text, borderClass, textClass }: { position: [number, number, number]; text: string; borderClass: string; textClass: string }) {
|
|
||||||
const dfDynamic = useLabelDistanceFactor(position);
|
|
||||||
return (
|
|
||||||
<Html position={position} center distanceFactor={dfDynamic} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
|
|
||||||
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
|
|
||||||
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
|
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
|
||||||
|
const dfDynamic = useLabelDistanceFactor(position);
|
||||||
|
const df = fixed ? dfDynamic : 1.5;
|
||||||
const borderClass = variant === 'success'
|
const borderClass = variant === 'success'
|
||||||
? 'border-success/60'
|
? 'border-success/60'
|
||||||
: variant === 'accent'
|
: variant === 'accent'
|
||||||
@@ -416,11 +375,8 @@ function MeasurementLabel({ position, text, variant, fixed = false }: { position
|
|||||||
: variant === 'accent'
|
: variant === 'accent'
|
||||||
? 'text-amber-400'
|
? 'text-amber-400'
|
||||||
: 'text-primary';
|
: 'text-primary';
|
||||||
|
return (
|
||||||
return fixed ? (
|
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
|
||||||
<DynamicLabel position={position} text={text} borderClass={borderClass} textClass={textClass} />
|
|
||||||
) : (
|
|
||||||
<Html position={position} center distanceFactor={1.5} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
|
|
||||||
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
|
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
|
||||||
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
|
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
|
||||||
{text}
|
{text}
|
||||||
@@ -486,43 +442,6 @@ function ModelMeasurements({ modelId }: { modelId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
|
|
||||||
* world-frame measurements that aren't attached to a specific model. */
|
|
||||||
function HoverLabelOverlay({ hoverInfo }: { hoverInfo: HoverInfo }) {
|
|
||||||
const position = hoverInfo.position;
|
|
||||||
const text = hoverInfo.type === 'hole'
|
|
||||||
? `Ø ${hoverInfo.value.toFixed(1)}`
|
|
||||||
: `${hoverInfo.value.toFixed(1)} mm`;
|
|
||||||
const variant = hoverInfo.type === 'hole' ? 'accent' : 'primary';
|
|
||||||
|
|
||||||
const dfDynamic = useLabelDistanceFactor(position);
|
|
||||||
|
|
||||||
const borderClass = variant === 'accent'
|
|
||||||
? 'border-amber-400/70'
|
|
||||||
: 'border-primary/60';
|
|
||||||
const textClass = variant === 'accent'
|
|
||||||
? 'text-amber-400'
|
|
||||||
: 'text-primary';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Html
|
|
||||||
position={position}
|
|
||||||
center
|
|
||||||
distanceFactor={dfDynamic}
|
|
||||||
zIndexRange={[100, 0]}
|
|
||||||
style={{
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
|
|
||||||
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
|
/** Renders snap indicator, hover tooltip, pending point and any *legacy*
|
||||||
* world-frame measurements that aren't attached to a specific model. */
|
* world-frame measurements that aren't attached to a specific model. */
|
||||||
function MeasurementOverlay() {
|
function MeasurementOverlay() {
|
||||||
@@ -541,7 +460,14 @@ function MeasurementOverlay() {
|
|||||||
|
|
||||||
{/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
|
{/* Hover auto-detect tooltip (works both inside and outside measure mode) */}
|
||||||
{hoverInfo && (
|
{hoverInfo && (
|
||||||
<HoverLabelOverlay hoverInfo={hoverInfo} />
|
<MeasurementLabel
|
||||||
|
position={hoverInfo.position}
|
||||||
|
text={hoverInfo.type === 'hole'
|
||||||
|
? `Ø ${hoverInfo.value.toFixed(1)}`
|
||||||
|
: `${hoverInfo.value.toFixed(1)} mm`}
|
||||||
|
variant={hoverInfo.type === 'hole' ? 'accent' : 'primary'}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending first point (always in world coords) */}
|
{/* Pending first point (always in world coords) */}
|
||||||
@@ -630,9 +556,7 @@ function SmartSnapHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bestHole) {
|
if (bestHole) {
|
||||||
// Find the measurement we matched
|
setSnapPoint(bestHole);
|
||||||
const matched = st.measurements.find(m => m.pointA.x === bestHole.x && m.pointA.y === bestHole.y && m.pointA.z === bestHole.z);
|
|
||||||
setSnapPoint({ ...bestHole, modelId: matched?.modelId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,8 +567,7 @@ function SmartSnapHandler() {
|
|||||||
10
|
10
|
||||||
);
|
);
|
||||||
if (snap) {
|
if (snap) {
|
||||||
const hitModelId = findModelId(hit.object);
|
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
|
||||||
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId });
|
|
||||||
} else {
|
} else {
|
||||||
setSnapPoint(null);
|
setSnapPoint(null);
|
||||||
}
|
}
|
||||||
@@ -712,13 +635,11 @@ function HoverDetector() {
|
|||||||
|
|
||||||
// 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
|
// 1) Try circle fit from edge vertices around the hit (catches holes on flat faces)
|
||||||
const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
|
const circ = detectCircularEdgeAtPoint(hit.object, hit.point, camera, canvasSize, 50);
|
||||||
const hitModelId = findModelId(hit.object);
|
|
||||||
if (circ) {
|
if (circ) {
|
||||||
setHoverInfo({
|
setHoverInfo({
|
||||||
type: 'hole',
|
type: 'hole',
|
||||||
position: [circ.center.x, circ.center.y, circ.center.z],
|
position: [circ.center.x, circ.center.y, circ.center.z],
|
||||||
value: circ.diameterMM,
|
value: circ.diameterMM,
|
||||||
modelId: hitModelId,
|
|
||||||
});
|
});
|
||||||
} else if (hit.faceIndex !== undefined && (() => {
|
} else if (hit.faceIndex !== undefined && (() => {
|
||||||
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
|
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
|
||||||
@@ -727,7 +648,6 @@ function HoverDetector() {
|
|||||||
type: 'hole',
|
type: 'hole',
|
||||||
position: [hole.center.x, hole.center.y, hole.center.z],
|
position: [hole.center.x, hole.center.y, hole.center.z],
|
||||||
value: hole.diameterMM,
|
value: hole.diameterMM,
|
||||||
modelId: hitModelId,
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -742,7 +662,6 @@ function HoverDetector() {
|
|||||||
type: 'edge',
|
type: 'edge',
|
||||||
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
|
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
|
||||||
value: edge.lengthMM,
|
value: edge.lengthMM,
|
||||||
modelId: hitModelId,
|
|
||||||
endpoints: {
|
endpoints: {
|
||||||
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
|
a: { x: edge.a.x, y: edge.a.y, z: edge.a.z },
|
||||||
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
|
b: { x: edge.b.x, y: edge.b.y, z: edge.b.z },
|
||||||
@@ -826,7 +745,7 @@ function MeasureClickHandler() {
|
|||||||
|
|
||||||
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
|
// Prefer snap (vertex snap, or hole-center snap from SmartSnapHandler)
|
||||||
if (st.snapPoint) {
|
if (st.snapPoint) {
|
||||||
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z, modelId: st.snapPoint.modelId });
|
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,8 +764,7 @@ function MeasureClickHandler() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const hitModelId = findModelId(hit.object);
|
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||||||
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1094,8 +1012,8 @@ function PositionDragHandler() {
|
|||||||
const ft = active.fineTuning;
|
const ft = active.fineTuning;
|
||||||
const factor = st.scaleRatio?.factor ?? 1;
|
const factor = st.scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
if (button === 0) {
|
if (button === 2) {
|
||||||
// Left button (Trigger in Quest emulated mouse): rotate (unifies with AR / VR standard)
|
// Right button: rotate
|
||||||
const sens = 0.4; // deg per pixel
|
const sens = 0.4; // deg per pixel
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
||||||
@@ -1122,8 +1040,8 @@ function PositionDragHandler() {
|
|||||||
} else {
|
} else {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
||||||
}
|
}
|
||||||
} else if (button === 2 && shiftKey) {
|
} else if (button === 0 && shiftKey) {
|
||||||
// Shift+Right button: depth (Z in camera space)
|
// Shift+left: depth (Z in camera space)
|
||||||
const worldDelta = dy * pixelsPerWorldUnit;
|
const worldDelta = dy * pixelsPerWorldUnit;
|
||||||
const camDir = new THREE.Vector3();
|
const camDir = new THREE.Vector3();
|
||||||
camera.getWorldDirection(camDir);
|
camera.getWorldDirection(camDir);
|
||||||
@@ -1134,7 +1052,7 @@ function PositionDragHandler() {
|
|||||||
posZ: ft.posZ + move.z,
|
posZ: ft.posZ + move.z,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Right button (Grip in Quest emulated mouse): translate in camera plane (matches AR pan-only grip)
|
// Left: translate in camera plane
|
||||||
const right = new THREE.Vector3();
|
const right = new THREE.Vector3();
|
||||||
const up = new THREE.Vector3();
|
const up = new THREE.Vector3();
|
||||||
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
||||||
@@ -1176,78 +1094,30 @@ function PositionDragHandler() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface SceneInsideXRProps {
|
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||||
placementMode: boolean;
|
|
||||||
setPlacementMode: (v: boolean) => void;
|
|
||||||
snapToPlanes: boolean;
|
|
||||||
setSnapToPlanes: (v: boolean) => void;
|
|
||||||
allowScale: boolean;
|
|
||||||
setAllowScale: (v: boolean) => void;
|
|
||||||
liveCode: string | null;
|
|
||||||
setLiveCode: (v: string | null) => void;
|
|
||||||
liveViewers: number;
|
|
||||||
setLiveViewers: (v: number) => void;
|
|
||||||
freeMove: boolean;
|
|
||||||
setFreeMove: (v: boolean) => void;
|
|
||||||
simXR: boolean;
|
|
||||||
setSimXR: (v: boolean) => void;
|
|
||||||
onPresentationChange: (v: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SceneInsideXR({
|
|
||||||
placementMode,
|
|
||||||
setPlacementMode,
|
|
||||||
snapToPlanes,
|
|
||||||
setSnapToPlanes,
|
|
||||||
allowScale,
|
|
||||||
setAllowScale,
|
|
||||||
liveCode,
|
|
||||||
setLiveCode,
|
|
||||||
liveViewers,
|
|
||||||
setLiveViewers,
|
|
||||||
freeMove,
|
|
||||||
setFreeMove,
|
|
||||||
simXR,
|
|
||||||
setSimXR,
|
|
||||||
onPresentationChange,
|
|
||||||
}: SceneInsideXRProps) {
|
|
||||||
const isPresenting = useXR((state) => !!state.session);
|
|
||||||
const positionMode = useModelStore((s) => s.positionMode);
|
const positionMode = useModelStore((s) => s.positionMode);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
const models = useModelStore((s) => s.models);
|
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||||
const activeModel = models.find(m => m.id === activeId);
|
|
||||||
const isActiveLocked = !!activeModel?.locked;
|
|
||||||
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
|
|
||||||
const desktopCameraMode = useModelStore((s) => s.desktopCameraMode);
|
|
||||||
|
|
||||||
const rigRef = useRef<THREE.Group>(null);
|
|
||||||
const devkit = useDevKit();
|
|
||||||
const effectiveInXR = isPresenting || simXR;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onPresentationChange(isPresenting);
|
|
||||||
}, [isPresenting, onPresentationChange]);
|
|
||||||
|
|
||||||
// Monitorar início e fim da sessão
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPresenting) {
|
|
||||||
toast.success('Sessão AR iniciada!');
|
|
||||||
}
|
|
||||||
}, [isPresenting]);
|
|
||||||
|
|
||||||
if (!effectiveInXR) {
|
|
||||||
// Modo 2D normal (não imersivo)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Canvas
|
||||||
{xrEnvironment === 'passthrough' && (
|
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||||
<>
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||||
|
frameloop="always"
|
||||||
|
className="!bg-background"
|
||||||
|
onPointerMissed={(e) => {
|
||||||
|
if (useModelStore.getState().selectionMode) {
|
||||||
|
(e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCreated={({ gl }) => {
|
||||||
|
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
const maxRatio = isApple ? 2 : 1.5;
|
||||||
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
||||||
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XREnvironmentSelector />
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<SceneModels />
|
<SceneModels />
|
||||||
@@ -1266,7 +1136,6 @@ function SceneInsideXR({
|
|||||||
<VisibilityApplier />
|
<VisibilityApplier />
|
||||||
<SceneRefCapture />
|
<SceneRefCapture />
|
||||||
|
|
||||||
{desktopCameraMode === 'orbit' ? (
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
makeDefault
|
makeDefault
|
||||||
enableDamping
|
enableDamping
|
||||||
@@ -1275,189 +1144,7 @@ function SceneInsideXR({
|
|||||||
maxDistance={50}
|
maxDistance={50}
|
||||||
enabled={!positionMode}
|
enabled={!positionMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<DesktopFirstPersonControls />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modo AR imersivo (ou SimXR)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{xrEnvironment === 'passthrough' && (
|
|
||||||
<>
|
|
||||||
<ambientLight intensity={0.8} />
|
|
||||||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
|
||||||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XREnvironmentSelector />
|
|
||||||
|
|
||||||
<group ref={rigRef}>
|
|
||||||
<XROrigin />
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{simXR ? (
|
|
||||||
<group position={[0, 1.0, -1.2]}>
|
|
||||||
<XRBackgroundModels />
|
|
||||||
<XRGrabbable
|
|
||||||
allowScale={allowScale}
|
|
||||||
lockedActive={isActiveLocked}
|
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
|
||||||
>
|
|
||||||
<XRActiveModel />
|
|
||||||
</XRGrabbable>
|
|
||||||
</group>
|
|
||||||
) : (
|
|
||||||
<XRHitTestPlacement
|
|
||||||
placementMode={placementMode}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onPlace={() => {
|
|
||||||
setPlacementMode(false);
|
|
||||||
toast.success('Modelo posicionado na superfície!');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XRBackgroundModels />
|
|
||||||
<XRGrabbable
|
|
||||||
allowScale={allowScale}
|
|
||||||
lockedActive={isActiveLocked}
|
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
|
||||||
>
|
|
||||||
<XRActiveModel />
|
|
||||||
</XRGrabbable>
|
|
||||||
</XRHitTestPlacement>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XRMeasurementOverlay />
|
|
||||||
<XRControllerMeasure />
|
|
||||||
<ControllerLocomotion rigRef={rigRef} />
|
|
||||||
<VisibilityApplier />
|
|
||||||
<SceneRefCapture />
|
|
||||||
|
|
||||||
<XRHudInWorld
|
|
||||||
freeMove={freeMove}
|
|
||||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
|
||||||
placementMode={placementMode}
|
|
||||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
|
||||||
allowScale={allowScale}
|
|
||||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
|
||||||
liveCode={liveCode}
|
|
||||||
liveViewers={liveViewers}
|
|
||||||
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
|
|
||||||
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<XRBroadcastMirror enabled />
|
|
||||||
|
|
||||||
{devkit && simXR && <FakeControllers />}
|
|
||||||
{simXR && (
|
|
||||||
<OrbitControls
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={0.05}
|
|
||||||
maxDistance={50}
|
|
||||||
target={[0, 1.0, -1.2]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XRSnapHandler />
|
|
||||||
<XRGrid />
|
|
||||||
<XRGridAutoFollower />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|
||||||
const [placementMode, setPlacementMode] = useState(true);
|
|
||||||
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
|
||||||
const [allowScale, setAllowScale] = useState(true);
|
|
||||||
const [liveCode, setLiveCode] = useState<string | null>(null);
|
|
||||||
const [liveViewers, setLiveViewers] = useState(0);
|
|
||||||
const [freeMove, setFreeMove] = useState(true);
|
|
||||||
const [simXR, setSimXR] = useState(false);
|
|
||||||
const [isPresenting, setIsPresenting] = useState(false);
|
|
||||||
|
|
||||||
const devkit = useDevKit();
|
|
||||||
const effectiveInXR = isPresenting || simXR;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
<Canvas
|
|
||||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
|
||||||
frameloop="always"
|
|
||||||
className="!bg-background"
|
|
||||||
onPointerMissed={(e) => {
|
|
||||||
if (useModelStore.getState().selectionMode) {
|
|
||||||
(e.target as HTMLCanvasElement).dispatchEvent(new CustomEvent('tsxr-selection-miss', { detail: e }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCreated={({ gl }) => {
|
|
||||||
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
|
|
||||||
const maxRatio = isApple ? 2 : 1.5;
|
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XR store={xrStore}>
|
|
||||||
<SceneInsideXR
|
|
||||||
placementMode={placementMode}
|
|
||||||
setPlacementMode={setPlacementMode}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
setSnapToPlanes={setSnapToPlanes}
|
|
||||||
allowScale={allowScale}
|
|
||||||
setAllowScale={setAllowScale}
|
|
||||||
liveCode={liveCode}
|
|
||||||
setLiveCode={setLiveCode}
|
|
||||||
liveViewers={liveViewers}
|
|
||||||
setLiveViewers={setLiveViewers}
|
|
||||||
freeMove={freeMove}
|
|
||||||
setFreeMove={setFreeMove}
|
|
||||||
simXR={simXR}
|
|
||||||
setSimXR={setSimXR}
|
|
||||||
onPresentationChange={setIsPresenting}
|
|
||||||
/>
|
|
||||||
</XR>
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
{/* Floating HUD overlay - visível apenas fora do headset passthrough */}
|
|
||||||
{effectiveInXR && !isPresenting && (
|
|
||||||
<div className="absolute inset-0 pointer-events-none z-30">
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<XRHud
|
|
||||||
freeMove={freeMove}
|
|
||||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
|
||||||
placementMode={placementMode}
|
|
||||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
|
||||||
allowScale={allowScale}
|
|
||||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Compartilhar botões ocultos para WebRTC e HUD de fora */}
|
|
||||||
<div className="hidden">
|
|
||||||
<ShareButton
|
|
||||||
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
|
||||||
onViewerCountChange={setLiveViewers}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DevPanel se devkit habilitado */}
|
|
||||||
{devkit && (
|
|
||||||
<div className="absolute inset-0 pointer-events-none z-40">
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,31 +26,6 @@ function isPickableModelMesh(o: THREE.Object3D): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findModelId(obj: THREE.Object3D): string | undefined {
|
|
||||||
let cur: THREE.Object3D | null = obj;
|
|
||||||
while (cur) {
|
|
||||||
if (cur.userData?.modelId) return cur.userData.modelId as string;
|
|
||||||
cur = cur.parent;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
|
|
||||||
if (!session) return;
|
|
||||||
try {
|
|
||||||
for (const source of session.inputSources) {
|
|
||||||
if (source.handedness === handedness) {
|
|
||||||
const gp = source.gamepad;
|
|
||||||
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
|
|
||||||
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[XR][Haptic] erro ao vibrar controle:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-controller trigger driven measurement for AR.
|
* Right-controller trigger driven measurement for AR.
|
||||||
*
|
*
|
||||||
@@ -90,7 +65,6 @@ export function XRControllerMeasure() {
|
|||||||
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
|
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
|
||||||
const dwellStart = useRef(0);
|
const dwellStart = useRef(0);
|
||||||
const dwellFired = useRef(false);
|
const dwellFired = useRef(false);
|
||||||
const lastSnapKind = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Throttling: raycast + snap analysis are heavy on dense IFC models and
|
// Throttling: raycast + snap analysis are heavy on dense IFC models and
|
||||||
// running them every frame at 72fps can stall the Quest right after
|
// running them every frame at 72fps can stall the Quest right after
|
||||||
@@ -203,12 +177,6 @@ export function XRControllerMeasure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Haptic feedback on snap state changes ────────────────────────
|
|
||||||
if (snappedPoint && snapKind !== 'surface' && lastSnapKind.current !== snapKind) {
|
|
||||||
triggerHaptic(session, 'right', 0.4, 15);
|
|
||||||
}
|
|
||||||
lastSnapKind.current = snappedPoint ? snapKind : null;
|
|
||||||
|
|
||||||
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
|
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
|
||||||
const measureModeNow = useModelStore.getState().measureMode;
|
const measureModeNow = useModelStore.getState().measureMode;
|
||||||
const nowT = performance.now();
|
const nowT = performance.now();
|
||||||
@@ -221,18 +189,15 @@ export function XRControllerMeasure() {
|
|||||||
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
|
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
|
||||||
dwellFired.current = true;
|
dwellFired.current = true;
|
||||||
if (measureModeNow) {
|
if (measureModeNow) {
|
||||||
const hitModelId = hit ? findModelId(hit.object) : undefined;
|
|
||||||
useModelStore.getState().registerHoverMeasurement({
|
useModelStore.getState().registerHoverMeasurement({
|
||||||
type: hoverDetected.kind,
|
type: hoverDetected.kind,
|
||||||
value: hoverDetected.value,
|
value: hoverDetected.value,
|
||||||
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
|
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
|
||||||
modelId: hitModelId,
|
|
||||||
endpoints: hoverDetected.endpoints && {
|
endpoints: hoverDetected.endpoints && {
|
||||||
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
|
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
|
||||||
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
|
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
triggerHaptic(session, 'right', 0.6, 50);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -305,13 +270,10 @@ export function XRControllerMeasure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (snappedPoint) {
|
} else if (snappedPoint) {
|
||||||
const hitModelId = hit ? findModelId(hit.object) : undefined;
|
|
||||||
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
|
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
|
||||||
st.addMeasurePoint({
|
st.addMeasurePoint({
|
||||||
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
||||||
modelId: hitModelId,
|
|
||||||
});
|
});
|
||||||
triggerHaptic(session, 'right', 0.8, 25);
|
|
||||||
}
|
}
|
||||||
} else if (trigState.current && trigVal < TRIG_OFF) {
|
} else if (trigState.current && trigVal < TRIG_OFF) {
|
||||||
trigState.current = false;
|
trigState.current = false;
|
||||||
@@ -323,7 +285,6 @@ export function XRControllerMeasure() {
|
|||||||
if (!aState.current && aVal > BTN_ON) {
|
if (!aState.current && aVal > BTN_ON) {
|
||||||
aState.current = true;
|
aState.current = true;
|
||||||
useModelStore.getState().undoLastMeasurement();
|
useModelStore.getState().undoLastMeasurement();
|
||||||
triggerHaptic(session, 'right', 0.5, 30);
|
|
||||||
} else if (aState.current && aVal < TRIG_OFF) {
|
} else if (aState.current && aVal < TRIG_OFF) {
|
||||||
aState.current = false;
|
aState.current = false;
|
||||||
}
|
}
|
||||||
@@ -334,7 +295,6 @@ export function XRControllerMeasure() {
|
|||||||
if (!bState.current && bVal > BTN_ON) {
|
if (!bState.current && bVal > BTN_ON) {
|
||||||
bState.current = true;
|
bState.current = true;
|
||||||
useModelStore.getState().clearMeasurements();
|
useModelStore.getState().clearMeasurements();
|
||||||
triggerHaptic(session, 'right', 0.7, 60);
|
|
||||||
} else if (bState.current && bVal < TRIG_OFF) {
|
} else if (bState.current && bVal < TRIG_OFF) {
|
||||||
bState.current = false;
|
bState.current = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { useRef, ReactNode, useEffect } from 'react';
|
import { useRef, ReactNode, useEffect } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
|
|
||||||
if (!session) return;
|
|
||||||
try {
|
|
||||||
for (const source of session.inputSources) {
|
|
||||||
if (source.handedness === handedness) {
|
|
||||||
const gp = source.gamepad;
|
|
||||||
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
|
|
||||||
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[XR][Haptic] erro ao vibrar controle no grab:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface XRGrabbableProps {
|
interface XRGrabbableProps {
|
||||||
/**
|
/**
|
||||||
* Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
|
* Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
|
||||||
@@ -75,7 +59,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const dual = useRef<DualGrabRecord | null>(null);
|
const dual = useRef<DualGrabRecord | null>(null);
|
||||||
const haloRef = useRef<THREE.Mesh>(null);
|
const haloRef = useRef<THREE.Mesh>(null);
|
||||||
const everGrabbed = useRef(false);
|
const everGrabbed = useRef(false);
|
||||||
const pendingReset = useRef(false);
|
|
||||||
|
|
||||||
// Reset scale to 1 (position/rotation kept) whenever resetScale() is called.
|
// Reset scale to 1 (position/rotation kept) whenever resetScale() is called.
|
||||||
const scaleResetNonce = useModelStore((s) => s.scaleResetNonce);
|
const scaleResetNonce = useModelStore((s) => s.scaleResetNonce);
|
||||||
@@ -87,21 +70,9 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
single.current = null;
|
single.current = null;
|
||||||
}, [scaleResetNonce]);
|
}, [scaleResetNonce]);
|
||||||
|
|
||||||
const { gl } = useThree();
|
useFrame(() => {
|
||||||
|
|
||||||
useFrame((_state) => {
|
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
if (pendingReset.current) {
|
|
||||||
group.position.set(0, 0, 0);
|
|
||||||
group.rotation.set(0, 0, 0);
|
|
||||||
group.scale.set(1, 1, 1);
|
|
||||||
pendingReset.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = gl.xr.getSession();
|
|
||||||
const snap: ControllerGrabSnapshot = grab.current;
|
const snap: ControllerGrabSnapshot = grab.current;
|
||||||
const L = snap.left;
|
const L = snap.left;
|
||||||
const R = snap.right;
|
const R = snap.right;
|
||||||
@@ -124,50 +95,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitGrabTransforms = () => {
|
|
||||||
const activeId = useModelStore.getState().activeModelId;
|
|
||||||
if (!activeId) return;
|
|
||||||
const active = useModelStore.getState().models.find(m => m.id === activeId);
|
|
||||||
if (!active) return;
|
|
||||||
|
|
||||||
const ft = { ...active.fineTuning };
|
|
||||||
|
|
||||||
// 1. ESCALA DO MODELO: Lê o fator de escala do filho imediato para converter a translação do mundo real
|
|
||||||
const child = group.children[0];
|
|
||||||
const finalScaleFactor = child ? child.scale.x : 1.0;
|
|
||||||
|
|
||||||
ft.posX += group.position.x / finalScaleFactor;
|
|
||||||
ft.posY += group.position.y / finalScaleFactor;
|
|
||||||
ft.posZ += group.position.z / finalScaleFactor;
|
|
||||||
|
|
||||||
// 2. ROTAÇÃO TRIDIMENSIONAL: Combina quaternions de rotação para evitar gimbal lock ao soltar
|
|
||||||
const currentQuat = new THREE.Quaternion().setFromEuler(
|
|
||||||
new THREE.Euler(
|
|
||||||
(active.fineTuning.rotX * Math.PI) / 180,
|
|
||||||
(active.fineTuning.rotY * Math.PI) / 180,
|
|
||||||
(active.fineTuning.rotZ * Math.PI) / 180,
|
|
||||||
'XYZ'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const finalQuat = group.quaternion.clone().multiply(currentQuat);
|
|
||||||
const finalEuler = new THREE.Euler().setFromQuaternion(finalQuat, 'XYZ');
|
|
||||||
|
|
||||||
ft.rotX = (finalEuler.x * 180) / Math.PI;
|
|
||||||
ft.rotY = (finalEuler.y * 180) / Math.PI;
|
|
||||||
ft.rotZ = (finalEuler.z * 180) / Math.PI;
|
|
||||||
|
|
||||||
// 3. ESCALA LOCAL DO GRAB: Multiplica a escala anterior
|
|
||||||
ft.scale = (ft.scale ?? 1) * group.scale.x;
|
|
||||||
|
|
||||||
useModelStore.getState().setFineTuning(ft);
|
|
||||||
|
|
||||||
// Sinaliza reset pendente para o próximo frame
|
|
||||||
pendingReset.current = true;
|
|
||||||
|
|
||||||
triggerHaptic(session, 'left', 0.25, 12);
|
|
||||||
triggerHaptic(session, 'right', 0.25, 12);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
||||||
if (lActive && rActive) {
|
if (lActive && rActive) {
|
||||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
@@ -189,13 +116,12 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
};
|
};
|
||||||
single.current = null;
|
single.current = null;
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
triggerHaptic(session, 'left', 0.5, 30);
|
|
||||||
triggerHaptic(session, 'right', 0.5, 30);
|
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = dual.current;
|
const d = dual.current;
|
||||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
|
// ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita).
|
||||||
const scaleRatio = allowScale
|
const scaleRatio = allowScale
|
||||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
||||||
: 1;
|
: 1;
|
||||||
@@ -215,7 +141,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
return;
|
return;
|
||||||
} else if (dual.current) {
|
} else if (dual.current) {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND end');
|
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||||
commitGrabTransforms();
|
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +156,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||||
single.current = { hand: activeHand, offsetWorld };
|
single.current = { hand: activeHand, offsetWorld };
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
triggerHaptic(session, activeHand, 0.45, 25);
|
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +168,8 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
||||||
targetWorldPos.applyMatrix4(invParent);
|
targetWorldPos.applyMatrix4(invParent);
|
||||||
}
|
}
|
||||||
|
|
||||||
group.position.copy(targetWorldPos);
|
group.position.copy(targetWorldPos);
|
||||||
} else if (single.current) {
|
} else if (single.current) {
|
||||||
console.log('[XR][grab] ● ONE-HAND end');
|
|
||||||
commitGrabTransforms();
|
|
||||||
single.current = null;
|
single.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
stopRecording, captureScreenshot, formatElapsed,
|
stopRecording, captureScreenshot, formatElapsed,
|
||||||
} from '@/hooks/useCanvasRecorder';
|
} from '@/hooks/useCanvasRecorder';
|
||||||
|
|
||||||
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'env';
|
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr';
|
||||||
|
|
||||||
interface XRHudInWorldProps {
|
interface XRHudInWorldProps {
|
||||||
freeMove: boolean;
|
freeMove: boolean;
|
||||||
@@ -53,15 +53,11 @@ const WristToggle = forwardRef<THREE.Group, { open: boolean; onToggle: () => voi
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function XRHudInWorld(props: XRHudInWorldProps) {
|
export function XRHudInWorld(props: XRHudInWorldProps) {
|
||||||
const { camera, gl } = useThree();
|
const { camera } = useThree();
|
||||||
const leftCtrl = useXRInputSourceState('controller', 'left');
|
const leftCtrl = useXRInputSourceState('controller', 'left');
|
||||||
const rightCtrl = useXRInputSourceState('controller', 'right');
|
const rightCtrl = useXRInputSourceState('controller', 'right');
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
const open = useModelStore((s) => s.xrHudOpen);
|
const [tab, setTab] = useState<Tab>('scene');
|
||||||
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. */
|
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */
|
||||||
const [pinned, setPinned] = useState(true);
|
const [pinned, setPinned] = useState(true);
|
||||||
/** While true, the panel is glued to the right controller (drag mode). */
|
/** While true, the panel is glued to the right controller (drag mode). */
|
||||||
@@ -74,32 +70,20 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
const headLockRef = useRef<THREE.Group>(null);
|
const headLockRef = useRef<THREE.Group>(null);
|
||||||
const targetPos = useRef(new THREE.Vector3());
|
const targetPos = useRef(new THREE.Vector3());
|
||||||
const targetQuat = useRef(new THREE.Quaternion());
|
const targetQuat = useRef(new THREE.Quaternion());
|
||||||
const [showQuickMenu, setShowQuickMenu] = useState(true);
|
const lastABtn = useRef(false);
|
||||||
const lastXBtn = useRef(false);
|
|
||||||
const lastYBtn = useRef(false);
|
|
||||||
/** Cached offset from controller to panel at drag start. */
|
/** Cached offset from controller to panel at drag start. */
|
||||||
const dragOffsetPos = useRef(new THREE.Vector3());
|
const dragOffsetPos = useRef(new THREE.Vector3());
|
||||||
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
||||||
const dragInitialized = useRef(false);
|
const dragInitialized = useRef(false);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
|
// Toggle via left A button (Quest Touch buttons[4])
|
||||||
const gp = leftCtrl?.inputSource?.gamepad;
|
const gp = leftCtrl?.inputSource?.gamepad;
|
||||||
if (gp) {
|
if (gp) {
|
||||||
// Toggle quick menu via left X button (Quest Touch buttons[4] on left controller)
|
const aBtn = gp.buttons[4];
|
||||||
const xBtn = gp.buttons[4];
|
const pressed = !!aBtn?.pressed;
|
||||||
const pressedX = !!xBtn?.pressed;
|
if (pressed && !lastABtn.current) setOpen((v) => !v);
|
||||||
if (pressedX && !lastXBtn.current) {
|
lastABtn.current = pressed;
|
||||||
setShowQuickMenu((prev) => !prev);
|
|
||||||
}
|
|
||||||
lastXBtn.current = pressedX;
|
|
||||||
|
|
||||||
// Toggle main HUD via left Y button (Quest Touch buttons[5] on left controller)
|
|
||||||
const yBtn = gp.buttons[5];
|
|
||||||
const pressedY = !!yBtn?.pressed;
|
|
||||||
if (pressedY && !lastYBtn.current) {
|
|
||||||
setOpen(!open);
|
|
||||||
}
|
|
||||||
lastYBtn.current = pressedY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating panel:
|
// Floating panel:
|
||||||
@@ -166,11 +150,11 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Head-locked fallback toggle — always visible in lower-right of FOV,
|
// Head-locked fallback toggle — always visible in lower-left of FOV,
|
||||||
// so the user can reopen the menu even without controllers or if the
|
// so the user can reopen the menu even without controllers or if the
|
||||||
// panel is positioned out of view.
|
// panel is positioned out of view.
|
||||||
if (headLockRef.current) {
|
if (headLockRef.current) {
|
||||||
const offset = new THREE.Vector3(0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
|
const offset = new THREE.Vector3(-0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
|
||||||
headLockRef.current.position.copy(camera.position).add(offset);
|
headLockRef.current.position.copy(camera.position).add(offset);
|
||||||
headLockRef.current.quaternion.copy(camera.quaternion);
|
headLockRef.current.quaternion.copy(camera.quaternion);
|
||||||
}
|
}
|
||||||
@@ -181,21 +165,16 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen(!open)} />
|
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
|
||||||
{/* Head-locked fallback: always-visible quick controls in lower-right of FOV */}
|
{/* Head-locked fallback: always-visible quick controls in lower-left of FOV */}
|
||||||
<group ref={headLockRef} renderOrder={999} visible={showQuickMenu}>
|
<group ref={headLockRef} renderOrder={999}>
|
||||||
<XR3DButton position={[0, 0.026, 0]} size={[0.07, 0.022]}
|
<XR3DButton position={[0, 0.018, 0]} size={[0.07, 0.022]}
|
||||||
label={open ? '✕ Menu' : '☰ Menu'} active={open}
|
label={open ? '✕ Menu' : '☰ Menu'} active={open}
|
||||||
onClick={() => { setOpen(!open); reanchorRequested.current = true; }}
|
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }}
|
||||||
fontSize={0.0085} />
|
fontSize={0.0085} />
|
||||||
<XR3DButton position={[0, 0, 0]} size={[0.07, 0.022]}
|
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]}
|
||||||
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
||||||
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
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 />
|
<RecIndicator />
|
||||||
</group>
|
</group>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -233,7 +212,7 @@ function FloatingPanel({
|
|||||||
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
||||||
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
||||||
{ id: 'share', label: 'Compart.', icon: '📡' },
|
{ id: 'share', label: 'Compart.', icon: '📡' },
|
||||||
{ id: 'env', label: 'Ambiente', icon: '🏝' },
|
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
|
||||||
];
|
];
|
||||||
const W = 0.5, H = 0.36;
|
const W = 0.5, H = 0.36;
|
||||||
return (
|
return (
|
||||||
@@ -273,7 +252,7 @@ function FloatingPanel({
|
|||||||
{tab === 'inspection' && <InspectionTab />}
|
{tab === 'inspection' && <InspectionTab />}
|
||||||
{tab === 'capture' && <CaptureTab />}
|
{tab === 'capture' && <CaptureTab />}
|
||||||
{tab === 'share' && <ShareTab {...p} />}
|
{tab === 'share' && <ShareTab {...p} />}
|
||||||
{tab === 'env' && <EnvTab />}
|
{tab === 'webxr' && <WebXRTab />}
|
||||||
</group>
|
</group>
|
||||||
</XR3DPanel>
|
</XR3DPanel>
|
||||||
);
|
);
|
||||||
@@ -363,125 +342,77 @@ function ToolsTab(p: XRHudInWorldProps) {
|
|||||||
const resetScale = useModelStore((s) => s.resetScale);
|
const resetScale = useModelStore((s) => s.resetScale);
|
||||||
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
||||||
|
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
|
||||||
const models = useModelStore((s) => s.models);
|
|
||||||
const activeModel = models.find((m) => m.id === activeId);
|
|
||||||
const setFineTuning = useModelStore((s) => s.setFineTuning);
|
|
||||||
|
|
||||||
const alignToFace = (face: string) => {
|
|
||||||
let rotX = 0, rotY = 0, rotZ = 0;
|
|
||||||
if (face === 'FRENTE') { rotX = 90; }
|
|
||||||
else if (face === 'LADO') { rotX = 90; rotY = 90; }
|
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapTo90 = () => {
|
|
||||||
if (!activeModel) return;
|
|
||||||
const ft = activeModel.fineTuning;
|
|
||||||
setFineTuning({
|
|
||||||
rotX: Math.round(ft.rotX / 90) * 90,
|
|
||||||
rotY: Math.round(ft.rotY / 90) * 90,
|
|
||||||
rotZ: Math.round(ft.rotZ / 90) * 90,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetRot = () => {
|
|
||||||
setFineTuning({ rotX: 0, rotY: 0, rotZ: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Fileira 1: Render */}
|
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
|
||||||
<Text position={[-0.24, 0.125, 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"
|
||||||
<XR3DButton position={[-0.16, 0.105, 0]} size={[0.075, 0.022]} label="Sólido"
|
|
||||||
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
||||||
<XR3DButton position={[-0.08, 0.105, 0]} size={[0.075, 0.022]} label="Bordas"
|
<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')} />
|
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
|
||||||
|
|
||||||
{/* Fileira 2: Toggles */}
|
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
|
||||||
<Text position={[-0.24, 0.075, 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'}`}
|
||||||
<XR3DButton position={[-0.16, 0.055, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
|
|
||||||
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
|
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
|
||||||
<XR3DButton position={[-0.08, 0.055, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
|
<XR3DButton position={[-0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
|
||||||
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
|
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
|
||||||
<XR3DButton position={[0.0, 0.055, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
|
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
|
||||||
active={p.snapToPlanes} onClick={p.onToggleSnap} />
|
active={p.snapToPlanes} onClick={p.onToggleSnap} />
|
||||||
<XR3DButton position={[0.08, 0.055, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
|
||||||
label={p.placementMode ? 'Posic…' : 'Reposic.'}
|
label={p.placementMode ? 'Posic…' : 'Reposic.'}
|
||||||
active={p.placementMode} onClick={p.onTogglePlacement} />
|
active={p.placementMode} onClick={p.onTogglePlacement} />
|
||||||
<XR3DButton position={[0.16, 0.055, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
|
||||||
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
|
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
|
||||||
active={p.allowScale} onClick={p.onToggleAllowScale} />
|
active={p.allowScale} onClick={p.onToggleAllowScale} />
|
||||||
|
|
||||||
{/* Fileira 3: Selecionar */}
|
{/* Selecionar — paridade com a tela inicial */}
|
||||||
<Text position={[-0.24, 0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
|
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
|
||||||
<XR3DButton position={[-0.16, 0.005, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
|
||||||
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
|
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
|
||||||
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
|
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
|
||||||
<XR3DButton position={[-0.08, 0.005, 0]} size={[0.075, 0.022]} label="Esconder"
|
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
|
||||||
onClick={hideSelectedElements} />
|
onClick={hideSelectedElements} />
|
||||||
<XR3DButton position={[0.0, 0.005, 0]} size={[0.075, 0.022]} label="Isolar"
|
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
|
||||||
onClick={isolateSelectedElements} />
|
onClick={isolateSelectedElements} />
|
||||||
<XR3DButton position={[0.08, 0.005, 0]} size={[0.075, 0.022]} label="Mostrar"
|
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
|
||||||
active={hasHidden} onClick={showAllElements} />
|
active={hasHidden} onClick={showAllElements} />
|
||||||
|
|
||||||
{/* Fileira 4: Opacidade */}
|
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||||
<Text position={[-0.24, -0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
|
||||||
Opacidade {Math.round(opacity * 100)}%
|
Opacidade {Math.round(opacity * 100)}%
|
||||||
</Text>
|
</Text>
|
||||||
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="−25%"
|
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="−25%"
|
||||||
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
|
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
|
||||||
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="−5%"
|
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="−5%"
|
||||||
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
|
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
|
||||||
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
|
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
|
||||||
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
|
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
|
||||||
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
|
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
|
||||||
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
|
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
|
||||||
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
|
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
|
||||||
onClick={() => setOpacity(1)} />
|
onClick={() => setOpacity(1)} />
|
||||||
|
|
||||||
{/* Fileira 5: Escala */}
|
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||||
<Text position={[-0.24, -0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
|
||||||
Escala {scaleRatio.label}
|
Escala {scaleRatio.label}
|
||||||
</Text>
|
</Text>
|
||||||
{SCALE_LABELS.map((lbl, i) => {
|
{SCALE_LABELS.map((lbl, i) => {
|
||||||
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
||||||
return (
|
return (
|
||||||
<XR3DButton key={lbl}
|
<XR3DButton key={lbl}
|
||||||
position={[-0.16 + i * 0.06, -0.095, 0]} size={[0.055, 0.022]} label={lbl}
|
position={[-0.16 + i * 0.06, -0.14, 0]} size={[0.055, 0.022]} label={lbl}
|
||||||
active={scaleRatio.label === lbl}
|
active={scaleRatio.label === lbl}
|
||||||
onClick={() => setScaleRatio(preset)} />
|
onClick={() => setScaleRatio(preset)} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<XR3DButton position={[0.10, -0.095, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
|
||||||
label="↺ Reset" color="#dc2626"
|
label="↺ Reset" color="#dc2626"
|
||||||
onClick={resetScale} fontSize={0.0085} />
|
onClick={resetScale} fontSize={0.0085} />
|
||||||
<XR3DButton position={[0.17, -0.095, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
|
||||||
label="✕ Medidas" color="#dc2626"
|
label="✕ Medidas" color="#dc2626"
|
||||||
onClick={clearMeasurements} fontSize={0.0085} />
|
onClick={clearMeasurements} fontSize={0.0085} />
|
||||||
|
|
||||||
{/* Fileira 6: Rotação / Alinhamento Ortogonal */}
|
|
||||||
<Text position={[-0.24, -0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
|
||||||
Alinhar Rotação
|
|
||||||
</Text>
|
|
||||||
<XR3DButton position={[-0.16, -0.145, 0]} size={[0.075, 0.022]}
|
|
||||||
label="Snap 90°" active={false}
|
|
||||||
onClick={snapTo90} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[-0.08, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Topo" active={false}
|
|
||||||
onClick={() => alignToFace('TOPO')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[-0.025, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Frente" active={false}
|
|
||||||
onClick={() => alignToFace('FRENTE')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[0.03, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Lado" active={false}
|
|
||||||
onClick={() => alignToFace('LADO')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[0.12, -0.145, 0]} size={[0.09, 0.022]}
|
|
||||||
label="Zerar Rot." color="#dc2626"
|
|
||||||
onClick={resetRot} fontSize={0.0075} />
|
|
||||||
|
|
||||||
{measureMode && (
|
{measureMode && (
|
||||||
<Text position={[-0.24, -0.175, 0]} fontSize={0.0055} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
<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
|
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -645,79 +576,45 @@ function ShareTab(p: XRHudInWorldProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EnvTab() {
|
function WebXRTab() {
|
||||||
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
|
const xrFeatures = useModelStore((s) => s.xrFeatures);
|
||||||
const setXrEnvironment = useModelStore((s) => s.setXrEnvironment);
|
const setXRFeature = useModelStore((s) => s.setXRFeature);
|
||||||
const xrScaleMode = useModelStore((s) => s.xrScaleMode);
|
const reset = useModelStore((s) => s.resetXRFeatures);
|
||||||
const setXrScaleMode = useModelStore((s) => s.setXrScaleMode);
|
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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<Text position={[-0.24, 0.11, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
|
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
||||||
Cenário Imersivo (VR / AR)
|
Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text position={[-0.24, 0.095, 0]} fontSize={0.0075} color="#f59e0b" anchorX="left" maxWidth={0.48}>
|
||||||
<XR3DButton
|
Mudanças entram em vigor ao sair e re-entrar no AR.
|
||||||
position={[-0.14, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Passthrough (AR)"
|
|
||||||
active={xrEnvironment === 'passthrough'}
|
|
||||||
onClick={() => setXrEnvironment('passthrough')}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.0, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Escritório (VR)"
|
|
||||||
active={xrEnvironment === 'office'}
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('office');
|
|
||||||
setXrScaleMode('tabletop');
|
|
||||||
}}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.14, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Campo Aberto (VR)"
|
|
||||||
active={xrEnvironment === 'field'}
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('field');
|
|
||||||
setXrScaleMode('realscale');
|
|
||||||
}}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text position={[-0.24, 0.02, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
|
|
||||||
Escala de Apresentação do IFC
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<XR3DButton
|
|
||||||
position={[-0.08, -0.015, 0]}
|
|
||||||
size={[0.18, 0.026]}
|
|
||||||
label="Escala Real 1:1 (Caminhar)"
|
|
||||||
active={xrScaleMode === 'realscale'}
|
|
||||||
onClick={() => setXrScaleMode('realscale')}
|
|
||||||
fontSize={0.0085}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.11, -0.015, 0]}
|
|
||||||
size={[0.18, 0.026]}
|
|
||||||
label="Modo Maquete (Mesa)"
|
|
||||||
active={xrScaleMode === 'tabletop'}
|
|
||||||
onClick={() => setXrScaleMode('tabletop')}
|
|
||||||
fontSize={0.0085}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text position={[-0.24, -0.065, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Passthrough: veja o mundo real à sua volta (Realidade Aumentada).
|
|
||||||
</Text>
|
|
||||||
<Text position={[-0.24, -0.085, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Escritório: maquete interativa reduzida em cima de uma mesa virtual de reunião.
|
|
||||||
</Text>
|
|
||||||
<Text position={[-0.24, -0.105, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Campo Aberto: terreno gramado em escala real para explorar estruturas e galpões.
|
|
||||||
</Text>
|
</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>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,606 +0,0 @@
|
|||||||
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
|
||||||
import { Grid, Billboard, Sky, Line } from '@react-three/drei';
|
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
|
||||||
import { findNearestVertex } from './SmartMeasure';
|
|
||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
|
||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
|
||||||
|
|
||||||
function findModelId(obj: THREE.Object3D): string | undefined {
|
|
||||||
let cur: THREE.Object3D | null = obj;
|
|
||||||
while (cur) {
|
|
||||||
if (cur.userData?.modelId) return cur.userData.modelId as string;
|
|
||||||
cur = cur.parent;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── XRModel ───────────────────────────────────────────
|
|
||||||
export function XRModel({ sceneModel }: { sceneModel: SceneModel }) {
|
|
||||||
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
|
||||||
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
|
|
||||||
if (isIfc) {
|
|
||||||
fetch(sceneModel.url)
|
|
||||||
.then((res) => res.arrayBuffer())
|
|
||||||
.then((buf) => parseIFCtoThree(buf))
|
|
||||||
.then((threeScene) => {
|
|
||||||
if (active) setRawScene(threeScene);
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('[XRModel] IFC parsing error', err));
|
|
||||||
} else {
|
|
||||||
const loader = new GLTFLoader();
|
|
||||||
loader.load(
|
|
||||||
sceneModel.url,
|
|
||||||
(gltf) => {
|
|
||||||
if (active) setRawScene(gltf.scene);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
(err) => console.error('[XRModel] GLTF loading error', err)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [sceneModel.url, sceneModel.fileName]);
|
|
||||||
|
|
||||||
const ref = useRef<THREE.Group>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const g = ref.current;
|
|
||||||
if (!g) return;
|
|
||||||
registerModelLocalGroup(sceneModel.id, g);
|
|
||||||
return () => unregisterModelLocalGroup(sceneModel.id, g);
|
|
||||||
}, [sceneModel.id]);
|
|
||||||
|
|
||||||
const fineTuning = sceneModel.fineTuning;
|
|
||||||
const opacity = useModelStore((s) => s.opacity);
|
|
||||||
const renderMode = useModelStore((s) => s.renderMode);
|
|
||||||
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
|
||||||
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
|
||||||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
|
||||||
const checklist = useModelStore((s) => s.checklist);
|
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
|
||||||
|
|
||||||
const modelInfo = useMemo(() => {
|
|
||||||
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
|
||||||
const size = new THREE.Vector3();
|
|
||||||
const center = new THREE.Vector3();
|
|
||||||
box.getSize(size);
|
|
||||||
box.getCenter(center);
|
|
||||||
return { size, center };
|
|
||||||
}, [scene]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scene) return;
|
|
||||||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
|
||||||
const allApproved = checklist.every(i => i.status === 'approved');
|
|
||||||
|
|
||||||
scene.traverse((child) => {
|
|
||||||
if (child instanceof THREE.Mesh) {
|
|
||||||
if (Array.isArray(child.material)) {
|
|
||||||
child.material = child.material.map(m => m.clone());
|
|
||||||
} else {
|
|
||||||
child.material = child.material.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
const toRemove: THREE.Object3D[] = [];
|
|
||||||
child.children.forEach(c => {
|
|
||||||
if (c.userData.__edgeLine) toRemove.push(c);
|
|
||||||
});
|
|
||||||
toRemove.forEach(c => {
|
|
||||||
if (c instanceof THREE.LineSegments) {
|
|
||||||
c.geometry.dispose();
|
|
||||||
(c.material as THREE.Material).dispose();
|
|
||||||
}
|
|
||||||
child.remove(c);
|
|
||||||
});
|
|
||||||
|
|
||||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
||||||
materials.forEach((mat: THREE.Material) => {
|
|
||||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
|
||||||
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
|
|
||||||
mat.visible = true;
|
|
||||||
const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity;
|
|
||||||
mat.transparent = targetOpacity < 1;
|
|
||||||
mat.opacity = targetOpacity;
|
|
||||||
mat.wireframe = false;
|
|
||||||
if (hasRejected) {
|
|
||||||
mat.color.setHSL(0, 0.7, 0.5);
|
|
||||||
} else if (allApproved) {
|
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
|
||||||
} else {
|
|
||||||
mat.color.set(sceneModel.color);
|
|
||||||
}
|
|
||||||
mat.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
|
||||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
|
||||||
const lineMat = new THREE.LineBasicMaterial({
|
|
||||||
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
|
|
||||||
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
|
|
||||||
});
|
|
||||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
|
||||||
lineSegments.userData.__edgeLine = true;
|
|
||||||
child.add(lineSegments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
|
||||||
|
|
||||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
|
||||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
|
||||||
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
|
||||||
const s = fineTuning.scale ?? 1;
|
|
||||||
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
|
||||||
const renderFactor = scaleRatio?.factor ?? 1;
|
|
||||||
const xrScaleMode = useModelStore((st) => st.xrScaleMode);
|
|
||||||
const isTabletop = xrScaleMode === 'tabletop';
|
|
||||||
|
|
||||||
if (!sceneModel.visible) return null;
|
|
||||||
if (!scene) return null;
|
|
||||||
|
|
||||||
// Escala final: se for tabletop, escala de maquete proporcional ao tamanho do objeto
|
|
||||||
const maxDim = Math.max(modelInfo.size.x, modelInfo.size.y, modelInfo.size.z);
|
|
||||||
const finalScaleFactor = isTabletop
|
|
||||||
? (maxDim > 0 ? 0.5 / maxDim : 0.05)
|
|
||||||
: renderFactor;
|
|
||||||
|
|
||||||
// Posição local: no tabletop, eleva-se o modelo para que sua base coincida com Y=0 da origem do pai (topo da mesa)
|
|
||||||
const localPos: [number, number, number] = isTabletop
|
|
||||||
? [
|
|
||||||
-modelInfo.center.x + fineTuning.posX,
|
|
||||||
(-modelInfo.center.y + (modelInfo.size.y / 2)) + fineTuning.posY,
|
|
||||||
-modelInfo.center.z + fineTuning.posZ,
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
-modelInfo.center.x + fineTuning.posX,
|
|
||||||
-modelInfo.center.y + fineTuning.posY,
|
|
||||||
-modelInfo.center.z + fineTuning.posZ,
|
|
||||||
];
|
|
||||||
|
|
||||||
const parentPos: [number, number, number] = isTabletop ? [0, 0.85, 0] : [0, 0, 0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group scale={[finalScaleFactor, finalScaleFactor, finalScaleFactor]} position={parentPos}>
|
|
||||||
<group
|
|
||||||
ref={ref}
|
|
||||||
userData={{ modelId: sceneModel.id }}
|
|
||||||
position={localPos}
|
|
||||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
|
||||||
scale={[s, s, s]}
|
|
||||||
>
|
|
||||||
<primitive object={scene} />
|
|
||||||
<XRLocalModelMeasurements modelId={sceneModel.id} />
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRBackgroundModels() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{models.filter(m => m.id !== activeId).map((m) => (
|
|
||||||
<XRModel key={m.id} sceneModel={m} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRActiveModel() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
|
||||||
const active = models.find(m => m.id === activeId);
|
|
||||||
if (!active) return null;
|
|
||||||
return <XRModel sceneModel={active} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ControllerFineTuning ──────────────────────────────
|
|
||||||
export function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|
||||||
const { setFineTuning } = useModelStore();
|
|
||||||
const session = useThree((s) => s.gl.xr.getSession());
|
|
||||||
const isActiveLocked = useModelStore((s) => {
|
|
||||||
const a = s.models.find(m => m.id === s.activeModelId);
|
|
||||||
return !!a?.locked;
|
|
||||||
});
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (!session || isActiveLocked) return;
|
|
||||||
const inputSources = session.inputSources;
|
|
||||||
if (!inputSources) return;
|
|
||||||
|
|
||||||
let leftAxes: number[] | null = null;
|
|
||||||
let rightAxes: number[] | null = null;
|
|
||||||
let leftTrig = 0;
|
|
||||||
let rightTrig = 0;
|
|
||||||
let gripHeld = false;
|
|
||||||
let anyGripHeld = false;
|
|
||||||
|
|
||||||
for (const source of inputSources) {
|
|
||||||
const gp = source.gamepad;
|
|
||||||
if (!gp) continue;
|
|
||||||
const gripBtn = gp.buttons[2];
|
|
||||||
const trigBtn = gp.buttons[1];
|
|
||||||
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (gripBtn?.pressed) gripHeld = true;
|
|
||||||
if (gripVal > 0.3) anyGripHeld = true;
|
|
||||||
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
|
||||||
leftAxes = [gp.axes[2], gp.axes[3]];
|
|
||||||
leftTrig = trigVal;
|
|
||||||
}
|
|
||||||
if (source.handedness === 'right' && gp.axes.length >= 4) {
|
|
||||||
rightAxes = [gp.axes[2], gp.axes[3]];
|
|
||||||
rightTrig = trigVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anyGripHeld) return;
|
|
||||||
if (!freeMove && !gripHeld) return;
|
|
||||||
|
|
||||||
const baseStep = 0.001;
|
|
||||||
const baseRot = 0.1;
|
|
||||||
const deadzone = 0.15;
|
|
||||||
const speedL = 0.3 + leftTrig * 2.7;
|
|
||||||
const speedR = 0.3 + rightTrig * 2.7;
|
|
||||||
const curve = (v: number) => Math.sign(v) * v * v;
|
|
||||||
|
|
||||||
const modelStore = useModelStore.getState();
|
|
||||||
const ft = { ...modelStore.fineTuning };
|
|
||||||
|
|
||||||
if (leftAxes) {
|
|
||||||
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
|
||||||
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
|
||||||
}
|
|
||||||
if (rightAxes) {
|
|
||||||
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
|
||||||
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFineTuning(ft);
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
|
||||||
const texture = useMemo(() => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 256;
|
|
||||||
canvas.height = 64;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
|
||||||
ctx.beginPath();
|
|
||||||
const x = 0, y = 0, width = 256, height = 64, radius = 12;
|
|
||||||
ctx.moveTo(x + radius, y);
|
|
||||||
ctx.lineTo(x + width - radius, y);
|
|
||||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
||||||
ctx.lineTo(x + width, y + height - radius);
|
|
||||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
||||||
ctx.lineTo(x + radius, y + height);
|
|
||||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
||||||
ctx.lineTo(x, y + radius);
|
|
||||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.font = 'bold 24px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(text, 128, 32);
|
|
||||||
}
|
|
||||||
const tex = new THREE.CanvasTexture(canvas);
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
return tex;
|
|
||||||
}, [text, color]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Billboard follow={true}>
|
|
||||||
<mesh renderOrder={999}>
|
|
||||||
<planeGeometry args={[0.08, 0.02]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
map={texture}
|
|
||||||
transparent
|
|
||||||
depthTest={false}
|
|
||||||
side={THREE.DoubleSide}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
</Billboard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
|
|
||||||
return (
|
|
||||||
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
|
||||||
const measurements = useModelStore((s) => s.measurements);
|
|
||||||
const modelMeasurements = useMemo(() => {
|
|
||||||
return measurements.filter(m => m.modelId === modelId);
|
|
||||||
}, [measurements, modelId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{modelMeasurements.map((m) => {
|
|
||||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
|
||||||
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
|
||||||
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
|
||||||
const isHole = m.kind === 'hole';
|
|
||||||
const color = isHole ? '#f59e0b' : '#3b82f6';
|
|
||||||
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group key={m.id}>
|
|
||||||
{!isHole && (
|
|
||||||
<>
|
|
||||||
<mesh position={a}>
|
|
||||||
<sphereGeometry args={[0.003, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#22c55e" />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={b}>
|
|
||||||
<sphereGeometry args={[0.003, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#22c55e" />
|
|
||||||
</mesh>
|
|
||||||
<XRSafeLine a={a} b={b} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<group position={mid}>
|
|
||||||
<XRMeasurementText text={text} color={color} />
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRMeasurementOverlay() {
|
|
||||||
const measurements = useModelStore((s) => s.measurements);
|
|
||||||
const measurePoints = useModelStore((s) => s.measurePoints);
|
|
||||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
|
||||||
|
|
||||||
const globalMeasurements = useMemo(() => {
|
|
||||||
return measurements.filter(m => !m.modelId);
|
|
||||||
}, [measurements]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{measureMode && snapPoint && (
|
|
||||||
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
||||||
<ringGeometry args={[0.003, 0.005, 24]} />
|
|
||||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
{measurePoints.length === 1 && (
|
|
||||||
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
|
|
||||||
<sphereGeometry args={[0.003, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#e8a838" />
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
{globalMeasurements.map((m) => {
|
|
||||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
|
||||||
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
|
||||||
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
|
||||||
const isHole = m.kind === 'hole';
|
|
||||||
const color = isHole ? '#f59e0b' : '#3b82f6';
|
|
||||||
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group key={m.id}>
|
|
||||||
{!isHole && (
|
|
||||||
<>
|
|
||||||
<mesh position={a}>
|
|
||||||
<sphereGeometry args={[0.003, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#22c55e" />
|
|
||||||
</mesh>
|
|
||||||
<mesh position={b}>
|
|
||||||
<sphereGeometry args={[0.003, 16, 16]} />
|
|
||||||
<meshBasicMaterial color="#22c55e" />
|
|
||||||
</mesh>
|
|
||||||
<XRSafeLine a={a} b={b} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<group position={mid}>
|
|
||||||
<XRMeasurementText text={text} color={color} />
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRSnapHandler() {
|
|
||||||
const { camera, scene, gl } = useThree();
|
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
|
||||||
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
|
||||||
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
|
||||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
|
||||||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
|
||||||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!measureMode) return;
|
|
||||||
const onMove = (e: MouseEvent) => {
|
|
||||||
const rect = gl.domElement.getBoundingClientRect();
|
|
||||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
||||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
||||||
};
|
|
||||||
gl.domElement.addEventListener('pointermove', onMove);
|
|
||||||
return () => gl.domElement.removeEventListener('pointermove', onMove);
|
|
||||||
}, [gl, mouse, measureMode]);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
|
|
||||||
if (!measureMode) { setSnapPoint(null); return; }
|
|
||||||
raycaster.setFromCamera(mouse, camera);
|
|
||||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
||||||
const hit = intersects.find(i => {
|
|
||||||
const obj = i.object;
|
|
||||||
if (obj instanceof THREE.GridHelper) return false;
|
|
||||||
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
|
|
||||||
if (obj.userData.__edgeLine) return false;
|
|
||||||
return obj instanceof THREE.Mesh;
|
|
||||||
});
|
|
||||||
if (hit && hit.object instanceof THREE.Mesh) {
|
|
||||||
const canvas = gl.domElement;
|
|
||||||
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
|
|
||||||
const hitModelId = findModelId(hit.object);
|
|
||||||
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId } : null);
|
|
||||||
} else {
|
|
||||||
setSnapPoint(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!measureMode) return;
|
|
||||||
if (gl.xr.isPresenting) return;
|
|
||||||
const onClick = (e: MouseEvent) => {
|
|
||||||
if (snapPoint) {
|
|
||||||
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z, modelId: snapPoint.modelId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rect = gl.domElement.getBoundingClientRect();
|
|
||||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
||||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
||||||
raycaster.setFromCamera(mouse, camera);
|
|
||||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
||||||
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
|
|
||||||
if (hit) {
|
|
||||||
const hitModelId = findModelId(hit.object);
|
|
||||||
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
gl.domElement.addEventListener('click', onClick);
|
|
||||||
return () => gl.domElement.removeEventListener('click', onClick);
|
|
||||||
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
|
|
||||||
return () => { gl.domElement.style.cursor = 'grab'; };
|
|
||||||
}, [measureMode, gl]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRGrid() {
|
|
||||||
const showGrid = useModelStore((s) => s.showGrid);
|
|
||||||
const gridY = useModelStore((s) => s.gridY);
|
|
||||||
if (!showGrid) return null;
|
|
||||||
return (
|
|
||||||
<Grid
|
|
||||||
position={[0, gridY, 0]}
|
|
||||||
infiniteGrid
|
|
||||||
cellSize={0.01}
|
|
||||||
sectionSize={0.1}
|
|
||||||
cellThickness={0.5}
|
|
||||||
sectionThickness={1}
|
|
||||||
cellColor="#334155"
|
|
||||||
sectionColor="#475569"
|
|
||||||
fadeDistance={5}
|
|
||||||
fadeStrength={1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRGridAutoFollower() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
|
||||||
const { scene } = useThree();
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useModelStore.getState().gridAutoFollow) return;
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
if (!useModelStore.getState().gridAutoFollow) return;
|
|
||||||
const box = new THREE.Box3();
|
|
||||||
let has = false;
|
|
||||||
scene.traverse((obj) => {
|
|
||||||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
|
||||||
if (obj.geometry instanceof THREE.SphereGeometry) return;
|
|
||||||
if (obj.geometry instanceof THREE.RingGeometry) return;
|
|
||||||
if (obj.userData.__edgeLine) return;
|
|
||||||
obj.updateWorldMatrix(true, false);
|
|
||||||
const b = new THREE.Box3().setFromObject(obj);
|
|
||||||
if (isFinite(b.min.y)) {
|
|
||||||
if (!has) { box.copy(b); has = true; }
|
|
||||||
else box.union(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
|
|
||||||
}, 150);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [models, scene]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XREnvironmentSelector() {
|
|
||||||
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
|
|
||||||
|
|
||||||
if (xrEnvironment === 'passthrough') return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Luzes adicionais para ambientes imersivos virtuais */}
|
|
||||||
<ambientLight intensity={0.5} />
|
|
||||||
<directionalLight position={[5, 10, 5]} intensity={1.0} castShadow />
|
|
||||||
|
|
||||||
{/* Cenário Escritório */}
|
|
||||||
{xrEnvironment === 'office' && (
|
|
||||||
<group>
|
|
||||||
{/* Carpete do escritório */}
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
|
||||||
<planeGeometry args={[50, 50]} />
|
|
||||||
<meshStandardMaterial color="#263238" roughness={0.95} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Tampo da Mesa */}
|
|
||||||
<mesh position={[0, 0.825, 0]} receiveShadow castShadow>
|
|
||||||
<cylinderGeometry args={[0.7, 0.7, 0.05, 32]} />
|
|
||||||
<meshStandardMaterial color="#3e2723" roughness={0.6} metalness={0.1} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Perna da Mesa */}
|
|
||||||
<mesh position={[0, 0.4, 0]} castShadow>
|
|
||||||
<cylinderGeometry args={[0.08, 0.08, 0.8, 16]} />
|
|
||||||
<meshStandardMaterial color="#333333" roughness={0.4} metalness={0.8} />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
{/* Base da perna */}
|
|
||||||
<mesh position={[0, 0.01, 0]} castShadow>
|
|
||||||
<cylinderGeometry args={[0.3, 0.3, 0.02, 24]} />
|
|
||||||
<meshStandardMaterial color="#222222" roughness={0.3} metalness={0.8} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cenário Campo Aberto */}
|
|
||||||
{xrEnvironment === 'field' && (
|
|
||||||
<group>
|
|
||||||
<Sky distance={450000} sunPosition={[10, 5, 10]} inclination={0} azimuth={0.25} />
|
|
||||||
{/* Chão de grama */}
|
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
|
||||||
<planeGeometry args={[1000, 1000]} />
|
|
||||||
<meshStandardMaterial color="#1b5e20" roughness={0.9} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -92,39 +92,4 @@
|
|||||||
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
|
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewcube-wrapper {
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transform: rotateX(-30deg) rotateY(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-top {
|
|
||||||
transform: rotateX(90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-front {
|
|
||||||
transform: translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-right {
|
|
||||||
transform: rotateY(90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-left {
|
|
||||||
transform: rotateY(-90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-back {
|
|
||||||
transform: rotateY(180deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-bottom {
|
|
||||||
transform: rotateX(-90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { createXRStore } from '@react-three/xr';
|
|
||||||
|
|
||||||
function loadXRFeatures() {
|
|
||||||
const defaults = {
|
|
||||||
handTracking: true,
|
|
||||||
planeDetection: true,
|
|
||||||
hitTest: true,
|
|
||||||
domOverlay: true,
|
|
||||||
anchors: true,
|
|
||||||
meshDetection: true,
|
|
||||||
depthSensing: true,
|
|
||||||
layers: true,
|
|
||||||
bodyTracking: true,
|
|
||||||
lightEstimation: true,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('xrFeatures');
|
|
||||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
|
||||||
} catch {}
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _xrf = loadXRFeatures();
|
|
||||||
|
|
||||||
export const xrStore = createXRStore({
|
|
||||||
hand: { left: true, right: true },
|
|
||||||
controller: { left: true, right: true },
|
|
||||||
handTracking: _xrf.handTracking,
|
|
||||||
planeDetection: _xrf.planeDetection,
|
|
||||||
hitTest: _xrf.hitTest,
|
|
||||||
domOverlay: _xrf.domOverlay,
|
|
||||||
anchors: _xrf.anchors,
|
|
||||||
meshDetection: _xrf.meshDetection,
|
|
||||||
depthSensing: _xrf.depthSensing,
|
|
||||||
layers: _xrf.layers,
|
|
||||||
bodyTracking: _xrf.bodyTracking,
|
|
||||||
});
|
|
||||||
+1
-3
@@ -204,8 +204,6 @@ const Index = () => {
|
|||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_EXTENSIONS}
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
title="Importar arquivo de modelo 3D"
|
|
||||||
aria-label="Importar arquivo de modelo 3D"
|
|
||||||
onChange={handleFileUpload} />
|
onChange={handleFileUpload} />
|
||||||
|
|
||||||
|
|
||||||
@@ -329,7 +327,7 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
||||||
TrackSteelXR v1.06 — Q.C. Inspection
|
TrackSteelXR v1.05 — Q.C. Inspection
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
|
|||||||
+5
-10
@@ -4,7 +4,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useModelStore } from "@/stores/useModelStore";
|
import { useModelStore } from "@/stores/useModelStore";
|
||||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||||
import { ViewerControls } from "@/components/ViewerControls";
|
import { ViewerControls } from "@/components/ViewerControls";
|
||||||
import { ViewCube } from "@/components/ViewCube";
|
|
||||||
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
@@ -93,12 +92,11 @@ const Viewer = () => {
|
|||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
const handleEnterXR = async () => {
|
const handleEnterXR = async () => {
|
||||||
try {
|
if (!xrSupported) {
|
||||||
await xrStore.enterAR();
|
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
|
||||||
} catch (e) {
|
return;
|
||||||
console.error('[XR] Falha ao entrar no AR:', e);
|
|
||||||
toast.error("Não foi possível iniciar a sessão AR. Certifique-se de que está usando um dispositivo compatível (como Meta Quest 3).");
|
|
||||||
}
|
}
|
||||||
|
navigate("/xr");
|
||||||
};
|
};
|
||||||
|
|
||||||
const canAddMore = models.length < maxModels;
|
const canAddMore = models.length < maxModels;
|
||||||
@@ -110,8 +108,6 @@ const Viewer = () => {
|
|||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_EXTENSIONS}
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
title="Adicionar arquivo de modelo 3D"
|
|
||||||
aria-label="Adicionar arquivo de modelo 3D"
|
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -133,7 +129,7 @@ const Viewer = () => {
|
|||||||
<ShareButton />
|
<ShareButton />
|
||||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
||||||
<Glasses className="h-4 w-4" />
|
<Glasses className="h-4 w-4" />
|
||||||
Iniciar AR
|
Entrar em Modo XR
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -143,7 +139,6 @@ const Viewer = () => {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<ModelViewerCanvas />
|
<ModelViewerCanvas />
|
||||||
<ViewerControls />
|
<ViewerControls />
|
||||||
<ViewCube />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
|
|||||||
@@ -0,0 +1,875 @@
|
|||||||
|
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { useGLTF, Grid, Html, Line, OrbitControls, Text } from '@react-three/drei';
|
||||||
|
import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||||
|
import { XRHud } from '@/components/XRHud';
|
||||||
|
import { ShareButton } from '@/components/ShareButton';
|
||||||
|
import { XRHudInWorld } from '@/components/three/XRHudInWorld';
|
||||||
|
import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror';
|
||||||
|
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
||||||
|
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
||||||
|
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
||||||
|
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
|
||||||
|
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
|
||||||
|
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
|
||||||
|
import { useDevKit } from '@/devkit/useDevKit';
|
||||||
|
import { DevPanel } from '@/devkit/DevPanel';
|
||||||
|
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||||
|
import { VisibilityApplier } from '@/components/three/ModelViewer';
|
||||||
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
|
// --- Diagnóstico XR ---
|
||||||
|
console.log('[XR] Inicializando store...');
|
||||||
|
if (navigator.xr) {
|
||||||
|
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
||||||
|
console.log('[XR] immersive-ar suportado:', supported);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lê flags de feature WebXR do localStorage (default: todas ON).
|
||||||
|
// O usuário liga/desliga pelo HUD AR (aba WebXR) para diagnosticar qual
|
||||||
|
// feature ativa o grid de segurança do Quest. Mudanças exigem sair e re-entrar do AR.
|
||||||
|
function loadXRFeatures() {
|
||||||
|
const defaults = {
|
||||||
|
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
|
||||||
|
anchors: true, meshDetection: true, depthSensing: true, layers: true,
|
||||||
|
bodyTracking: true, lightEstimation: true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('xrFeatures');
|
||||||
|
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||||
|
} catch {}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
const _xrf = loadXRFeatures();
|
||||||
|
console.log('[XR] Features:', _xrf);
|
||||||
|
|
||||||
|
const store = createXRStore({
|
||||||
|
hand: { left: true, right: true },
|
||||||
|
controller: { left: true, right: true },
|
||||||
|
handTracking: _xrf.handTracking,
|
||||||
|
planeDetection: _xrf.planeDetection,
|
||||||
|
hitTest: _xrf.hitTest,
|
||||||
|
domOverlay: _xrf.domOverlay,
|
||||||
|
anchors: _xrf.anchors,
|
||||||
|
meshDetection: _xrf.meshDetection,
|
||||||
|
depthSensing: _xrf.depthSensing,
|
||||||
|
layers: _xrf.layers,
|
||||||
|
bodyTracking: _xrf.bodyTracking,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── XRModel ───────────────────────────────────────────
|
||||||
|
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
|
||||||
|
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
||||||
|
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
|
||||||
|
if (isIfc) {
|
||||||
|
fetch(sceneModel.url)
|
||||||
|
.then((res) => res.arrayBuffer())
|
||||||
|
.then((buf) => parseIFCtoThree(buf))
|
||||||
|
.then((threeScene) => {
|
||||||
|
if (active) setRawScene(threeScene);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('[XRModel] IFC parsing error', err));
|
||||||
|
} else {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load(
|
||||||
|
sceneModel.url,
|
||||||
|
(gltf) => {
|
||||||
|
if (active) setRawScene(gltf.scene);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(err) => console.error('[XRModel] GLTF loading error', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [sceneModel.url, sceneModel.fileName]);
|
||||||
|
|
||||||
|
const ref = useRef<THREE.Group>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const g = ref.current;
|
||||||
|
if (!g) return;
|
||||||
|
registerModelLocalGroup(sceneModel.id, g);
|
||||||
|
return () => unregisterModelLocalGroup(sceneModel.id, g);
|
||||||
|
}, [sceneModel.id]);
|
||||||
|
|
||||||
|
const fineTuning = sceneModel.fineTuning;
|
||||||
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
|
const renderMode = useModelStore((s) => s.renderMode);
|
||||||
|
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
||||||
|
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
||||||
|
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||||||
|
const checklist = useModelStore((s) => s.checklist);
|
||||||
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
|
|
||||||
|
const modelInfo = useMemo(() => {
|
||||||
|
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
||||||
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
box.getSize(size);
|
||||||
|
box.getCenter(center);
|
||||||
|
return { size, center };
|
||||||
|
}, [scene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scene) return;
|
||||||
|
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||||
|
const allApproved = checklist.every(i => i.status === 'approved');
|
||||||
|
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material = child.material.map(m => m.clone());
|
||||||
|
} else {
|
||||||
|
child.material = child.material.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRemove: THREE.Object3D[] = [];
|
||||||
|
child.children.forEach(c => {
|
||||||
|
if (c.userData.__edgeLine) toRemove.push(c);
|
||||||
|
});
|
||||||
|
toRemove.forEach(c => {
|
||||||
|
if (c instanceof THREE.LineSegments) {
|
||||||
|
c.geometry.dispose();
|
||||||
|
(c.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
child.remove(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
materials.forEach((mat: THREE.Material) => {
|
||||||
|
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||||
|
if (renderMode === 'edges' && !measureMode) {
|
||||||
|
mat.visible = false;
|
||||||
|
} else {
|
||||||
|
mat.visible = true;
|
||||||
|
const targetOpacity = measureMode ? 0.25 : opacity;
|
||||||
|
mat.transparent = targetOpacity < 1;
|
||||||
|
mat.opacity = targetOpacity;
|
||||||
|
mat.wireframe = renderMode === 'wireframe';
|
||||||
|
if (renderMode === 'wireframe') {
|
||||||
|
mat.wireframeLinewidth = wireframeThickness;
|
||||||
|
mat.color.set(wireframeColor);
|
||||||
|
} else if (hasRejected) {
|
||||||
|
mat.color.setHSL(0, 0.7, 0.5);
|
||||||
|
} else if (allApproved) {
|
||||||
|
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||||
|
} else {
|
||||||
|
mat.color.set(sceneModel.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
||||||
|
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||||
|
const lineMat = new THREE.LineBasicMaterial({
|
||||||
|
color: measureMode ? '#00f3ff' : wireframeColor,
|
||||||
|
linewidth: measureMode ? 2 : wireframeThickness,
|
||||||
|
});
|
||||||
|
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||||||
|
lineSegments.userData.__edgeLine = true;
|
||||||
|
child.add(lineSegments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
||||||
|
|
||||||
|
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||||
|
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
||||||
|
const s = fineTuning.scale ?? 1;
|
||||||
|
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||||
|
const renderFactor = scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
|
if (!sceneModel.visible) return null;
|
||||||
|
if (!scene) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
||||||
|
<group
|
||||||
|
ref={ref}
|
||||||
|
userData={{ modelId: sceneModel.id }}
|
||||||
|
position={[
|
||||||
|
-modelInfo.center.x + fineTuning.posX,
|
||||||
|
-modelInfo.center.y + fineTuning.posY,
|
||||||
|
-modelInfo.center.z + fineTuning.posZ,
|
||||||
|
]}
|
||||||
|
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||||
|
scale={[s, s, s]}
|
||||||
|
>
|
||||||
|
<primitive object={scene} />
|
||||||
|
<XRLocalModelMeasurements modelId={sceneModel.id} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders all NON-active models as static background (placement + grab apply only to active). */
|
||||||
|
function XRBackgroundModels() {
|
||||||
|
const models = useModelStore((s) => s.models);
|
||||||
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{models.filter(m => m.id !== activeId).map((m) => (
|
||||||
|
<XRModel key={m.id} sceneModel={m} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the currently-active model (the one wrapped by grab/placement). */
|
||||||
|
function XRActiveModel() {
|
||||||
|
const models = useModelStore((s) => s.models);
|
||||||
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
|
const active = models.find(m => m.id === activeId);
|
||||||
|
if (!active) return null;
|
||||||
|
return <XRModel sceneModel={active} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ControllerFineTuning ──────────────────────────────
|
||||||
|
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
||||||
|
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
||||||
|
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
|
||||||
|
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||||
|
const { setFineTuning } = useModelStore();
|
||||||
|
const session = useXR((s) => s.session);
|
||||||
|
const isActiveLocked = useModelStore((s) => {
|
||||||
|
const a = s.models.find(m => m.id === s.activeModelId);
|
||||||
|
return !!a?.locked;
|
||||||
|
});
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (!session || isActiveLocked) return;
|
||||||
|
const inputSources = session.inputSources;
|
||||||
|
if (!inputSources) return;
|
||||||
|
|
||||||
|
let leftAxes: number[] | null = null;
|
||||||
|
let rightAxes: number[] | null = null;
|
||||||
|
let leftTrig = 0;
|
||||||
|
let rightTrig = 0;
|
||||||
|
let gripHeld = false;
|
||||||
|
let anyGripHeld = false;
|
||||||
|
|
||||||
|
for (const source of inputSources) {
|
||||||
|
const gp = source.gamepad;
|
||||||
|
if (!gp) continue;
|
||||||
|
const gripBtn = gp.buttons[2];
|
||||||
|
const trigBtn = gp.buttons[1];
|
||||||
|
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||||
|
if (gripBtn?.pressed) gripHeld = true;
|
||||||
|
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
|
||||||
|
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||||
|
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
||||||
|
leftAxes = [gp.axes[2], gp.axes[3]];
|
||||||
|
leftTrig = trigVal;
|
||||||
|
}
|
||||||
|
if (source.handedness === 'right' && gp.axes.length >= 4) {
|
||||||
|
rightAxes = [gp.axes[2], gp.axes[3]];
|
||||||
|
rightTrig = trigVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab takes priority — joystick is disabled while grabbing
|
||||||
|
if (anyGripHeld) return;
|
||||||
|
// In freeMove mode, joystick always moves. Otherwise requires grip — but
|
||||||
|
// grip is reserved for grab now, so freeMove=false essentially disables.
|
||||||
|
if (!freeMove && !gripHeld) return;
|
||||||
|
|
||||||
|
const baseStep = 0.001;
|
||||||
|
const baseRot = 0.1;
|
||||||
|
const deadzone = 0.15;
|
||||||
|
// Trigger modulation: 0 → 0.3×, 1 → 3×
|
||||||
|
const speedL = 0.3 + leftTrig * 2.7;
|
||||||
|
const speedR = 0.3 + rightTrig * 2.7;
|
||||||
|
// Quadratic acceleration on thumbstick value
|
||||||
|
const curve = (v: number) => Math.sign(v) * v * v;
|
||||||
|
|
||||||
|
const modelStore = useModelStore.getState();
|
||||||
|
const ft = { ...modelStore.fineTuning };
|
||||||
|
|
||||||
|
if (leftAxes) {
|
||||||
|
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
|
||||||
|
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
||||||
|
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
||||||
|
}
|
||||||
|
if (rightAxes) {
|
||||||
|
// Right: rotY (horizontal) / Y height (vertical, up = up)
|
||||||
|
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
||||||
|
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFineTuning(ft);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente de renderização de texto compatível e seguro para WebXR
|
||||||
|
function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
||||||
|
const texture = useMemo(() => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 256;
|
||||||
|
canvas.height = 64;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
// Limpa e desenha fundo semi-transparente
|
||||||
|
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
||||||
|
ctx.beginPath();
|
||||||
|
// Desenha retângulo arredondado manualmente
|
||||||
|
const x = 0, y = 0, width = 256, height = 64, radius = 12;
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Desenha borda sutil
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Texto
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = 'bold 24px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, 128, 32);
|
||||||
|
}
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return tex;
|
||||||
|
}, [text, color]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh renderOrder={999}>
|
||||||
|
<planeGeometry args={[0.08, 0.02]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={texture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente de linha nativa seguro para WebXR
|
||||||
|
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
|
||||||
|
const points = useMemo(() => [
|
||||||
|
new THREE.Vector3(a[0], a[1], a[2]),
|
||||||
|
new THREE.Vector3(b[0], b[1], b[2])
|
||||||
|
], [a, b]);
|
||||||
|
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
return new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<line geometry={geometry}>
|
||||||
|
<lineBasicMaterial color="#22c55e" linewidth={2} />
|
||||||
|
</line>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente para renderizar medições que pertencem a um modelo específico
|
||||||
|
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
||||||
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
|
const modelMeasurements = useMemo(() => {
|
||||||
|
return measurements.filter(m => m.modelId === modelId);
|
||||||
|
}, [measurements, modelId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{modelMeasurements.map((m) => {
|
||||||
|
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||||
|
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||||
|
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
||||||
|
const isHole = m.kind === 'hole';
|
||||||
|
const color = isHole ? '#f59e0b' : '#3b82f6';
|
||||||
|
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={m.id}>
|
||||||
|
{!isHole && (
|
||||||
|
<>
|
||||||
|
<mesh position={a}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#22c55e" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={b}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#22c55e" />
|
||||||
|
</mesh>
|
||||||
|
<XRSafeLine a={a} b={b} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<group position={mid}>
|
||||||
|
<XRMeasurementText text={text} color={color} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Measurement overlay (reused from ModelViewer) ─────
|
||||||
|
function XRMeasurementOverlay() {
|
||||||
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
|
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||||
|
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||||
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
|
|
||||||
|
const globalMeasurements = useMemo(() => {
|
||||||
|
return measurements.filter(m => !m.modelId);
|
||||||
|
}, [measurements]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{measureMode && snapPoint && (
|
||||||
|
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.003, 0.005, 24]} />
|
||||||
|
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
{measurePoints.length === 1 && (
|
||||||
|
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#e8a838" />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
{globalMeasurements.map((m) => {
|
||||||
|
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||||
|
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||||
|
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
||||||
|
const isHole = m.kind === 'hole';
|
||||||
|
const color = isHole ? '#f59e0b' : '#3b82f6';
|
||||||
|
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={m.id}>
|
||||||
|
{!isHole && (
|
||||||
|
<>
|
||||||
|
<mesh position={a}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#22c55e" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={b}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#22c55e" />
|
||||||
|
</mesh>
|
||||||
|
<XRSafeLine a={a} b={b} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<group position={mid}>
|
||||||
|
<XRMeasurementText text={text} color={color} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Snap handler for XR raycasting ────────────────────
|
||||||
|
function XRSnapHandler() {
|
||||||
|
const { camera, scene, gl } = useThree();
|
||||||
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
|
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
||||||
|
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
||||||
|
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||||
|
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||||
|
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!measureMode) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const rect = gl.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
};
|
||||||
|
gl.domElement.addEventListener('pointermove', onMove);
|
||||||
|
return () => gl.domElement.removeEventListener('pointermove', onMove);
|
||||||
|
}, [gl, mouse, measureMode]);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
// Em AR imersivo, a medição é feita pelo XRControllerMeasure (gatilho do controle).
|
||||||
|
// Evita raycast pesado por frame que travava o Quest ao ativar "Medir".
|
||||||
|
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
|
||||||
|
if (!measureMode) { setSnapPoint(null); return; }
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||||
|
const hit = intersects.find(i => {
|
||||||
|
const obj = i.object;
|
||||||
|
if (obj instanceof THREE.GridHelper) return false;
|
||||||
|
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
|
||||||
|
if (obj.userData.__edgeLine) return false;
|
||||||
|
return obj instanceof THREE.Mesh;
|
||||||
|
});
|
||||||
|
if (hit && hit.object instanceof THREE.Mesh) {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
|
||||||
|
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
|
||||||
|
} else {
|
||||||
|
setSnapPoint(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Click to measure
|
||||||
|
useEffect(() => {
|
||||||
|
if (!measureMode) return;
|
||||||
|
if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (snapPoint) {
|
||||||
|
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = gl.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||||
|
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
|
||||||
|
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||||||
|
};
|
||||||
|
gl.domElement.addEventListener('click', onClick);
|
||||||
|
return () => gl.domElement.removeEventListener('click', onClick);
|
||||||
|
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
|
||||||
|
return () => { gl.domElement.style.cursor = 'grab'; };
|
||||||
|
}, [measureMode, gl]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── XR Grid ───────────────────────────────────────────
|
||||||
|
function XRGrid() {
|
||||||
|
const showGrid = useModelStore((s) => s.showGrid);
|
||||||
|
const gridY = useModelStore((s) => s.gridY);
|
||||||
|
if (!showGrid) return null;
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
position={[0, gridY, 0]}
|
||||||
|
infiniteGrid
|
||||||
|
cellSize={0.01}
|
||||||
|
sectionSize={0.1}
|
||||||
|
cellThickness={0.5}
|
||||||
|
sectionThickness={1}
|
||||||
|
cellColor="#334155"
|
||||||
|
sectionColor="#475569"
|
||||||
|
fadeDistance={5}
|
||||||
|
fadeStrength={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-follow grid to model bottom when enabled. */
|
||||||
|
function XRGridAutoFollower() {
|
||||||
|
const models = useModelStore((s) => s.models);
|
||||||
|
const { scene } = useThree();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useModelStore.getState().gridAutoFollow) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
if (!useModelStore.getState().gridAutoFollow) return;
|
||||||
|
const box = new THREE.Box3();
|
||||||
|
let has = false;
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||||
|
if (obj.geometry instanceof THREE.SphereGeometry) return;
|
||||||
|
if (obj.geometry instanceof THREE.RingGeometry) return;
|
||||||
|
if (obj.userData.__edgeLine) return;
|
||||||
|
obj.updateWorldMatrix(true, false);
|
||||||
|
const b = new THREE.Box3().setFromObject(obj);
|
||||||
|
if (isFinite(b.min.y)) {
|
||||||
|
if (!has) { box.copy(b); has = true; }
|
||||||
|
else box.union(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [models, scene]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
|
||||||
|
|
||||||
|
// ─── XRSession Page ────────────────────────────────────
|
||||||
|
const XRSession = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { model, anchorMode, setAnchorMode } = useModelStore();
|
||||||
|
const isActiveLocked = useModelStore((s) => {
|
||||||
|
const a = s.models.find(m => m.id === s.activeModelId);
|
||||||
|
return !!a?.locked;
|
||||||
|
});
|
||||||
|
const [inXR, setInXR] = useState(false);
|
||||||
|
const [freeMove, setFreeMove] = useState(true);
|
||||||
|
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
||||||
|
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||||
|
// Zoom por grip duplo — toggle real, padrão ligado.
|
||||||
|
const [allowScale, setAllowScale] = useState(true);
|
||||||
|
const [liveCode, setLiveCode] = useState<string | null>(null);
|
||||||
|
const [liveViewers, setLiveViewers] = useState(0);
|
||||||
|
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
|
||||||
|
const rigRef = useRef<THREE.Group>(null);
|
||||||
|
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
||||||
|
const devkit = useDevKit();
|
||||||
|
const [simXR, setSimXR] = useState(false);
|
||||||
|
const effectiveInXR = inXR || simXR;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) navigate('/');
|
||||||
|
}, [model, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = store.subscribe((state) => {
|
||||||
|
const session = state.session;
|
||||||
|
if (session && !inXR) {
|
||||||
|
console.log('[XR] ✅ Sessão AR ativa!');
|
||||||
|
setInXR(true);
|
||||||
|
setAnchorMode('manual');
|
||||||
|
toast.success('Sessão AR iniciada!');
|
||||||
|
session.addEventListener('end', () => {
|
||||||
|
console.log('[XR] ❌ Sessão AR encerrada');
|
||||||
|
setInXR(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [inXR, setAnchorMode]);
|
||||||
|
|
||||||
|
const handleDownloadMarker = useCallback(async () => {
|
||||||
|
const url = await generateMarkerDownloadURL();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'TrackSteelXR_Marker.png';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!model) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
|
||||||
|
<Home className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||||||
|
Modo <span className="text-primary">XR Imersivo</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!inXR && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono text-xs">Marcador</span>
|
||||||
|
</Button>
|
||||||
|
<Button className="gap-2 glow-primary" onClick={() => {
|
||||||
|
console.log('[XR] Botão AR clicado');
|
||||||
|
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
||||||
|
}}>
|
||||||
|
Iniciar Sessão AR
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{inXR && (
|
||||||
|
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||||
|
XR Ativo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 3D Canvas */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Canvas
|
||||||
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||||
|
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
|
||||||
|
className="!bg-transparent"
|
||||||
|
onCreated={({ gl }) => {
|
||||||
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
|
gl.setClearColor(0x000000, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XR store={store}>
|
||||||
|
<ambientLight intensity={0.8} />
|
||||||
|
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||||||
|
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||||||
|
|
||||||
|
<group ref={rigRef}>
|
||||||
|
<XROrigin />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{effectiveInXR ? (
|
||||||
|
<>
|
||||||
|
{/* In XR (or SimXR): all models share the same placement origin so
|
||||||
|
switching active doesn't make others appear to "disappear".
|
||||||
|
Only the ACTIVE model receives grab transforms. */}
|
||||||
|
{simXR ? (
|
||||||
|
<group position={[0, 1.0, -1.2]}>
|
||||||
|
<XRBackgroundModels />
|
||||||
|
<XRGrabbable
|
||||||
|
allowScale={allowScale}
|
||||||
|
lockedActive={isActiveLocked}
|
||||||
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
|
>
|
||||||
|
<XRActiveModel />
|
||||||
|
</XRGrabbable>
|
||||||
|
</group>
|
||||||
|
) : (
|
||||||
|
<XRHitTestPlacement
|
||||||
|
placementMode={placementMode}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onPlace={() => {
|
||||||
|
setPlacementMode(false);
|
||||||
|
toast.success('Modelo posicionado na superfície!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XRBackgroundModels />
|
||||||
|
<XRGrabbable
|
||||||
|
allowScale={allowScale}
|
||||||
|
lockedActive={isActiveLocked}
|
||||||
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
|
>
|
||||||
|
<XRActiveModel />
|
||||||
|
</XRGrabbable>
|
||||||
|
</XRHitTestPlacement>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<XRMeasurementOverlay />
|
||||||
|
<XRControllerMeasure />
|
||||||
|
<ControllerLocomotion rigRef={rigRef} />
|
||||||
|
<VisibilityApplier />
|
||||||
|
|
||||||
|
|
||||||
|
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
|
||||||
|
<XRHudInWorld
|
||||||
|
freeMove={freeMove}
|
||||||
|
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||||
|
placementMode={placementMode}
|
||||||
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
|
liveCode={liveCode}
|
||||||
|
liveViewers={liveViewers}
|
||||||
|
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
|
||||||
|
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Opaque mirror canvas used as the WebRTC source while in AR */}
|
||||||
|
<XRBroadcastMirror enabled />
|
||||||
|
|
||||||
|
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
|
||||||
|
{devkit && simXR && <FakeControllers />}
|
||||||
|
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
|
||||||
|
{simXR && (
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
target={[0, 1.0, -1.2]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop preview (no headset): show all models with OrbitControls */}
|
||||||
|
<group position={[0, 0, 0]}>
|
||||||
|
<XRBackgroundModels />
|
||||||
|
<XRActiveModel />
|
||||||
|
</group>
|
||||||
|
<XRMeasurementOverlay />
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
target={[0, 0, 0]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<XRSnapHandler />
|
||||||
|
<XRGrid />
|
||||||
|
<XRGridAutoFollower />
|
||||||
|
</XR>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
{/* Floating DOM HUD overlay — only visible OUTSIDE passthrough.
|
||||||
|
Inside AR, the DOM is occluded by the headset compositor; the
|
||||||
|
in-world XRHudInWorld replaces it. */}
|
||||||
|
<div style={{ display: inXR ? 'none' : 'contents' }}>
|
||||||
|
<XRHud
|
||||||
|
freeMove={freeMove}
|
||||||
|
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||||
|
placementMode={placementMode}
|
||||||
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden ShareButton instance — registers window.__trackSteelStartLive
|
||||||
|
so the in-world Share tab can trigger broadcasts. Also keeps live
|
||||||
|
state in sync with the in-XR HUD. */}
|
||||||
|
<div style={{ display: 'none' }}>
|
||||||
|
<ShareButton
|
||||||
|
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
||||||
|
onViewerCountChange={setLiveViewers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
|
||||||
|
{devkit && (
|
||||||
|
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default XRSession;
|
||||||
@@ -127,13 +127,12 @@ const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899'];
|
|||||||
|
|
||||||
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||||
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
||||||
type RenderMode = 'solid' | 'edges';
|
type RenderMode = 'solid' | 'wireframe' | 'edges';
|
||||||
|
|
||||||
export interface MeasurePoint {
|
export interface MeasurePoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
modelId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Measurement {
|
export interface Measurement {
|
||||||
@@ -156,7 +155,6 @@ export interface HoverInfo {
|
|||||||
value: number; // mm (diameter or length)
|
value: number; // mm (diameter or length)
|
||||||
/** For edges: the two endpoints in world coords. */
|
/** For edges: the two endpoints in world coords. */
|
||||||
endpoints?: { a: MeasurePoint; b: MeasurePoint };
|
endpoints?: { a: MeasurePoint; b: MeasurePoint };
|
||||||
modelId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
@@ -299,20 +297,6 @@ interface ModelStore {
|
|||||||
xrFeatures: XRFeatureFlags;
|
xrFeatures: XRFeatureFlags;
|
||||||
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
|
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
|
||||||
resetXRFeatures: () => void;
|
resetXRFeatures: () => void;
|
||||||
|
|
||||||
// ── WebXR HUD states persistent cache ────
|
|
||||||
xrHudOpen: boolean;
|
|
||||||
setXrHudOpen: (open: boolean) => void;
|
|
||||||
xrHudTab: string;
|
|
||||||
setXrHudTab: (tab: string) => void;
|
|
||||||
|
|
||||||
// ── WebXR Cenários Imersivos e Escalonamento ────
|
|
||||||
xrEnvironment: 'passthrough' | 'office' | 'field';
|
|
||||||
setXrEnvironment: (env: 'passthrough' | 'office' | 'field') => void;
|
|
||||||
xrScaleMode: 'tabletop' | 'realscale';
|
|
||||||
setXrScaleMode: (mode: 'tabletop' | 'realscale') => void;
|
|
||||||
desktopCameraMode: 'orbit' | 'walk';
|
|
||||||
setDesktopCameraMode: (mode: 'orbit' | 'walk') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
|
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
|
||||||
@@ -580,7 +564,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
const factor = state.scaleRatio?.factor ?? 1;
|
const factor = state.scaleRatio?.factor ?? 1;
|
||||||
const distanceMM = distanceWorldMM / factor;
|
const distanceMM = distanceWorldMM / factor;
|
||||||
const modelId = a.modelId ?? b.modelId ?? state.activeModelId ?? undefined;
|
const modelId = state.activeModelId ?? undefined;
|
||||||
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
||||||
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
||||||
const attached = !!(aConv && bConv);
|
const attached = !!(aConv && bConv);
|
||||||
@@ -618,7 +602,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
return { measurements: state.measurements.slice(0, -1) };
|
return { measurements: state.measurements.slice(0, -1) };
|
||||||
}),
|
}),
|
||||||
registerHoverMeasurement: (info) => set((state) => {
|
registerHoverMeasurement: (info) => set((state) => {
|
||||||
const modelId = info.modelId ?? state.activeModelId ?? undefined;
|
const modelId = state.activeModelId ?? undefined;
|
||||||
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
|
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
|
||||||
const conv = modelId ? worldToModelLocal(modelId, p) : null;
|
const conv = modelId ? worldToModelLocal(modelId, p) : null;
|
||||||
return conv ? { point: conv, attached: true } : { point: p, attached: false };
|
return conv ? { point: conv, attached: true } : { point: p, attached: false };
|
||||||
@@ -687,16 +671,4 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
saveXRFeatures(next);
|
saveXRFeatures(next);
|
||||||
set({ xrFeatures: next });
|
set({ xrFeatures: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
xrHudOpen: true,
|
|
||||||
setXrHudOpen: (open) => set({ xrHudOpen: open }),
|
|
||||||
xrHudTab: 'scene',
|
|
||||||
setXrHudTab: (tab) => set({ xrHudTab: tab }),
|
|
||||||
|
|
||||||
xrEnvironment: 'passthrough',
|
|
||||||
setXrEnvironment: (env) => set({ xrEnvironment: env }),
|
|
||||||
xrScaleMode: 'realscale',
|
|
||||||
setXrScaleMode: (mode) => set({ xrScaleMode: mode }),
|
|
||||||
desktopCameraMode: 'orbit',
|
|
||||||
setDesktopCameraMode: (mode) => set({ desktopCameraMode: mode }),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user