Compare commits

..

25 Commits

Author SHA1 Message Date
admtracksteel 13c1ad315d 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:37:52 2026-05-24 18:37:52 +00:00
admtracksteel 2bce52fe54 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:28:15 2026-05-24 18:28:15 +00:00
admtracksteel ee6318859e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:16:19 2026-05-24 18:16:19 +00:00
admtracksteel f01c0f6d01 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 17:12:43 2026-05-24 17:12:43 +00:00
admtracksteel 06556c7a45 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 16:24:21 2026-05-24 16:24:21 +00:00
admtracksteel 00a8cee511 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 16:22:27 2026-05-24 16:22:27 +00:00
admtracksteel febb533f5e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 14:10:16 2026-05-24 14:10:17 +00:00
admtracksteel 678d04cc95 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:56:57 2026-05-24 13:56:57 +00:00
admtracksteel 84b8a452a1 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:56:02 2026-05-24 13:56:02 +00:00
admtracksteel fd9082510f 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:09:17 2026-05-24 13:09:17 +00:00
admtracksteel b5a92083f9 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:04:12 2026-05-24 13:04:12 +00:00
admtracksteel b16158656a 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:01:22 2026-05-24 13:01:22 +00:00
admtracksteel 377346893e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 12:38:19 2026-05-24 12:38:19 +00:00
admtracksteel 6046d655df 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:10:48 2026-05-24 02:10:48 +00:00
admtracksteel c8d51e0e1b 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:00:33 2026-05-24 02:00:33 +00:00
admtracksteel c839653a97 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 01:33:36 2026-05-24 01:33:36 +00:00
admtracksteel b7cd422068 🚀 Auto-deploy: melhoria no snap e medição AR em 23/05/2026 23:26:57 2026-05-23 23:26:57 +00:00
admtracksteel c93e56640d 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:38:44 2026-05-22 21:38:44 +00:00
admtracksteel c85dc85936 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:20:32 2026-05-22 21:20:32 +00:00
admtracksteel 63ad53edfb 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:07:04 2026-05-22 21:07:04 +00:00
admtracksteel 8610ec67f7 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:56:13 2026-05-22 20:56:13 +00:00
admtracksteel d8ca2b2e6c 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:51:44 2026-05-22 20:51:44 +00:00
admtracksteel d64d77dba6 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:41:05 2026-05-22 20:41:05 +00:00
admtracksteel 4c43d4c289 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:30:38 2026-05-22 20:30:38 +00:00
admtracksteel 5f24e5c85b 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:17:13 2026-05-22 20:17:13 +00:00
20 changed files with 2236 additions and 1005 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -2
View File
@@ -5,9 +5,9 @@ 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";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -20,7 +20,6 @@ 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>
+191
View File
@@ -0,0 +1,191 @@
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>
);
}
+86 -6
View File
@@ -1,4 +1,4 @@
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react'; import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } 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,6 +20,16 @@ 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,
@@ -34,6 +44,9 @@ 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;
@@ -112,13 +125,13 @@ export function ViewerControls() {
size="sm" size="sm"
className="gap-2 h-9" className="gap-2 h-9"
onClick={() => { onClick={() => {
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid'; const next = renderMode === 'solid' ? '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' : renderMode === 'wireframe' ? 'Wireframe' : 'Bordas'} {renderMode === 'solid' ? 'Sólido' : 'Bordas'}
</span> </span>
</Button> </Button>
@@ -128,7 +141,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" style={{ backgroundColor: wireframeColor }} /> <div className={`h-3 w-3 rounded-full border border-muted-foreground/30 ${colorClassMap[wireframeColor] || 'bg-[#8899aa]'}`} />
</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">
@@ -138,8 +151,7 @@ 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 ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`} 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'}`}
style={{ backgroundColor: c.value }}
onClick={() => setWireframeColor(c.value)} onClick={() => setWireframeColor(c.value)}
title={c.label} title={c.label}
/> />
@@ -281,6 +293,74 @@ 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
+4 -4
View File
@@ -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 / Wire / Bordas', title: 'Sólido / Bordas',
icon: '🔲', icon: '🔲',
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).', desc: 'Alterna modos de renderização: Sólido (preenchido) e Bordas (destaque visual de arestas com transparência).',
}, },
{ {
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' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid'; const next = renderMode === 'solid' ? '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' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'} {renderMode === 'solid' ? 'Sólido' : 'Bordas'}
</Button> </Button>
<Button <Button
@@ -0,0 +1,167 @@
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 />;
}
+439 -92
View File
@@ -1,11 +1,39 @@
import { Suspense, useRef, useMemo, useEffect, type ReactNode } from 'react'; import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber'; 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 } from '@/stores/useModelStore'; import { useModelStore, type SceneModel, type HoverInfo } 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 { 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
@@ -33,6 +61,15 @@ 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) {
@@ -73,9 +110,36 @@ function LoadingFallback() {
} }
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) { function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
const { scene: rawScene } = useGLTF(sceneModel.url); const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
// Clone scene per instance so multiple GLBModels with same url don't share materials/transforms const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
const scene = useMemo(() => rawScene.clone(true), [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('[GLBModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[GLBModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
const ref = useRef<THREE.Group>(null); const ref = useRef<THREE.Group>(null);
const opacity = useModelStore((s) => s.opacity); const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode); const renderMode = useModelStore((s) => s.renderMode);
@@ -85,9 +149,11 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const checklist = useModelStore((s) => s.checklist); const checklist = useModelStore((s) => s.checklist);
const setActive = useModelStore((s) => s.setActiveModel); const setActive = useModelStore((s) => s.setActiveModel);
const selectionMode = useModelStore((s) => s.selectionMode); const selectionMode = useModelStore((s) => s.selectionMode);
const measureMode = useModelStore((s) => s.measureMode);
const fineTuning = sceneModel.fineTuning; const fineTuning = sceneModel.fineTuning;
const modelInfo = useMemo(() => { const modelInfo = useMemo(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
const box = new THREE.Box3().setFromObject(scene); const box = new THREE.Box3().setFromObject(scene);
const size = new THREE.Vector3(); const size = new THREE.Vector3();
const center = new THREE.Vector3(); const center = new THREE.Vector3();
@@ -99,6 +165,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map()); const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
useEffect(() => { useEffect(() => {
if (!scene) return;
const hasRejected = checklist.some(i => i.status === 'rejected'); const hasRejected = checklist.some(i => i.status === 'rejected');
const allApproved = checklist.every(i => i.status === 'approved'); const allApproved = checklist.every(i => i.status === 'approved');
@@ -129,34 +196,29 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
originalColors.current.set(mat, mat.color.clone()); originalColors.current.set(mat, mat.color.clone());
} }
if (renderMode === 'edges') { const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
mat.visible = false; 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 { } else {
mat.visible = true; // Tint with the per-model color (subtle)
mat.transparent = opacity < 1; mat.color.set(sceneModel.color);
mat.opacity = opacity;
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 {
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
}
} }
mat.needsUpdate = true; mat.needsUpdate = true;
} }
}); });
if (renderMode === 'edges' && 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: wireframeColor, color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
linewidth: wireframeThickness, linewidth: (renderMode === 'edges' || 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;
@@ -164,7 +226,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
} }
} }
}); });
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]); }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180; const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180; const rotYRad = (fineTuning.rotY * Math.PI) / 180;
@@ -174,6 +236,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const renderFactor = scaleRatio?.factor ?? 1; const renderFactor = scaleRatio?.factor ?? 1;
if (!sceneModel.visible) return null; if (!sceneModel.visible) return null;
if (!scene) return null;
// Determine dominant local axis of the model (longest bbox side) for axial roll // Determine dominant local axis of the model (longest bbox side) for axial roll
const dominantAxis: 'x' | 'y' | 'z' = const dominantAxis: 'x' | 'y' | 'z' =
@@ -321,16 +384,28 @@ 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 worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height; const height = size.height || 1;
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 = Math.max(0.05, worldPerPixel * 140); ref.current = isNaN(worldPerPixel) || !isFinite(worldPerPixel) ? 1.5 : Math.max(0.05, worldPerPixel * 140);
}); });
return ref.current; return ref.current;
} }
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) { function DynamicLabel({ position, text, borderClass, textClass }: { position: [number, number, number]; text: string; borderClass: string; textClass: string }) {
const dfDynamic = useLabelDistanceFactor(position); const dfDynamic = useLabelDistanceFactor(position);
const df = fixed ? dfDynamic : 1.5; 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 }) {
const borderClass = variant === 'success' const borderClass = variant === 'success'
? 'border-success/60' ? 'border-success/60'
: variant === 'accent' : variant === 'accent'
@@ -341,8 +416,11 @@ function MeasurementLabel({ position, text, variant, fixed = false }: { position
: variant === 'accent' : variant === 'accent'
? 'text-amber-400' ? 'text-amber-400'
: 'text-primary'; : 'text-primary';
return (
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}> return fixed ? (
<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}
@@ -408,6 +486,43 @@ 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() {
@@ -426,14 +541,7 @@ 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 && (
<MeasurementLabel <HoverLabelOverlay hoverInfo={hoverInfo} />
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) */}
@@ -522,7 +630,9 @@ function SmartSnapHandler() {
} }
} }
if (bestHole) { if (bestHole) {
setSnapPoint(bestHole); // Find the measurement we matched
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;
} }
@@ -533,7 +643,8 @@ function SmartSnapHandler() {
10 10
); );
if (snap) { if (snap) {
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z }); const hitModelId = findModelId(hit.object);
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId });
} else { } else {
setSnapPoint(null); setSnapPoint(null);
} }
@@ -601,11 +712,13 @@ 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);
@@ -614,6 +727,7 @@ 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;
} }
@@ -628,6 +742,7 @@ 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 },
@@ -711,7 +826,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 }); st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z, modelId: st.snapPoint.modelId });
return; return;
} }
@@ -730,7 +845,8 @@ function MeasureClickHandler() {
return true; return true;
}); });
if (hit) { if (hit) {
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); const hitModelId = findModelId(hit.object);
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
} }
}; };
@@ -802,7 +918,7 @@ function SelectionHandler() {
/** Walks the scene each time visibility/selection state changes and applies /** Walks the scene each time visibility/selection state changes and applies
* per-element visibility + emissive highlight for selected elements. */ * per-element visibility + emissive highlight for selected elements. */
function VisibilityApplier() { export function VisibilityApplier() {
const { scene } = useThree(); const { scene } = useThree();
const nonce = useModelStore((s) => s.visibilityNonce); const nonce = useModelStore((s) => s.visibilityNonce);
const models = useModelStore((s) => s.models); const models = useModelStore((s) => s.models);
@@ -978,8 +1094,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 === 2) { if (button === 0) {
// Right button: rotate // Left button (Trigger in Quest emulated mouse): rotate (unifies with AR / VR standard)
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 });
@@ -1006,8 +1122,8 @@ function PositionDragHandler() {
} else { } else {
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta }); useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
} }
} else if (button === 0 && shiftKey) { } else if (button === 2 && shiftKey) {
// Shift+left: depth (Z in camera space) // Shift+Right button: 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);
@@ -1018,7 +1134,7 @@ function PositionDragHandler() {
posZ: ft.posZ + move.z, posZ: ft.posZ + move.z,
}); });
} else { } else {
// Left: translate in camera plane // Right button (Grip in Quest emulated mouse): translate in camera plane (matches AR pan-only grip)
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());
@@ -1060,57 +1176,288 @@ function PositionDragHandler() {
export function ModelViewerCanvas({ url }: ModelViewerProps) { interface SceneInsideXRProps {
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 measureMode = useModelStore((s) => s.measureMode); const activeId = useModelStore((s) => s.activeModelId);
const selectionMode = useModelStore((s) => s.selectionMode); const models = useModelStore((s) => s.models);
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 (
<>
{xrEnvironment === 'passthrough' && (
<>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
</>
)}
<XREnvironmentSelector />
<Suspense fallback={<LoadingFallback />}>
<SceneModels />
</Suspense>
<MeasurementOverlay />
<MeasureClickHandler />
<SmartSnapHandler />
<HoverDetector />
<GridLayer />
<GridAutoFollower />
<PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
{desktopCameraMode === 'orbit' ? (
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
enabled={!positionMode}
/>
) : (
<DesktopFirstPersonControls />
)}
</>
);
}
// Modo AR imersivo (ou SimXR)
return ( return (
<Canvas <>
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }} {xrEnvironment === 'passthrough' && (
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }} <>
frameloop="always" <ambientLight intensity={0.8} />
className="!bg-background" <directionalLight position={[3, 5, 3]} intensity={1.2} />
onPointerMissed={(e) => { <directionalLight position={[-3, 3, -3]} intensity={0.4} />
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} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
<Suspense fallback={<LoadingFallback />}>
<SceneModels />
</Suspense>
<MeasurementOverlay /> <XREnvironmentSelector />
<MeasureClickHandler />
<SmartSnapHandler />
<HoverDetector />
<GridLayer /> <group ref={rigRef}>
<GridAutoFollower /> <XROrigin />
</group>
<PositionDragHandler /> {simXR ? (
<SelectionHandler /> <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 /> <VisibilityApplier />
<SceneRefCapture /> <SceneRefCapture />
<OrbitControls <XRHudInWorld
makeDefault freeMove={freeMove}
enableDamping onToggleFreeMove={() => setFreeMove(!freeMove)}
dampingFactor={0.1} placementMode={placementMode}
minDistance={0.05} onTogglePlacement={() => setPlacementMode(!placementMode)}
maxDistance={50} snapToPlanes={snapToPlanes}
enabled={!positionMode} 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?.()}
/> />
</Canvas> <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>
{/* 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>
); );
} }
+41 -13
View File
@@ -252,8 +252,16 @@ export function findNearestEdgeSegment(
} }
}); });
// If no edge lines, generate from EdgesGeometry // If no edge lines, fetch from cache or generate and store in cache
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15); let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position; const posAttr = geo.attributes.position;
if (!posAttr) return null; if (!posAttr) return null;
@@ -298,8 +306,6 @@ export function findNearestEdgeSegment(
} }
} }
if (!edgeLines) geo.dispose();
if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) { if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) {
return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB }; return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB };
} }
@@ -321,7 +327,16 @@ export function detectCircularEdgeAtPoint(
mesh.children.forEach(c => { mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c; if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
}); });
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position; const posAttr = geo.attributes.position;
if (!posAttr) return null; if (!posAttr) return null;
@@ -347,7 +362,6 @@ export function detectCircularEdgeAtPoint(
seen.add(key); seen.add(key);
verts.push(tmp.clone()); verts.push(tmp.clone());
} }
if (!edgeLines) geo.dispose();
if (verts.length < 6) return null; if (verts.length < 6) return null;
@@ -497,7 +511,15 @@ export function findNearestEdgeSegment3D(
} }
}); });
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15); let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position; const posAttr = geo.attributes.position;
if (!posAttr) return null; if (!posAttr) return null;
@@ -525,8 +547,6 @@ export function findNearestEdgeSegment3D(
} }
} }
if (!edgeLines) geo.dispose();
if (bestIndex !== -1 && bestDistSq <= thresholdSq) { if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix); const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix); const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
@@ -546,7 +566,16 @@ export function detectCircularEdgeAtPoint3D(
mesh.children.forEach(c => { mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c; if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
}); });
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position; const posAttr = geo.attributes.position;
if (!posAttr) return null; if (!posAttr) return null;
@@ -573,7 +602,6 @@ export function detectCircularEdgeAtPoint3D(
seen.add(key); seen.add(key);
verts.push(tmp.clone().applyMatrix4(matrix)); verts.push(tmp.clone().applyMatrix4(matrix));
} }
if (!edgeLines) geo.dispose();
if (verts.length < 6) return null; if (verts.length < 6) return null;
@@ -618,8 +646,8 @@ export function detectCircularEdgeAtPoint3D(
export function resolveSnap3D( export function resolveSnap3D(
mesh: THREE.Mesh, mesh: THREE.Mesh,
hitPoint: THREE.Vector3, hitPoint: THREE.Vector3,
vertexThresholdMeters: number = 0.035, vertexThresholdMeters: number = 0.07,
edgeThresholdMeters: number = 0.05 edgeThresholdMeters: number = 0.09
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } { ): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters); const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
if (v) return { point: v, type: 'vertex' }; if (v) return { point: v, type: 'vertex' };
+105 -66
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { useFrame, useThree } from '@react-three/fiber'; import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore'; import { useModelStore } from '@/stores/useModelStore';
import { sendRemoteLog } from '@/lib/remoteLogger';
import { import {
resolveSnap, resolveSnap,
detectCircularEdgeAtPoint, detectCircularEdgeAtPoint,
@@ -16,6 +17,40 @@ const TRIG_OFF = 0.3;
const BTN_ON = 0.6; const BTN_ON = 0.6;
const MAX_RAY = 10; // meters const MAX_RAY = 10; // meters
function isPickableModelMesh(o: THREE.Object3D): boolean {
let cur: THREE.Object3D | null = o;
while (cur) {
if (cur.userData?.modelId) return true;
cur = cur.parent;
}
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.
* *
@@ -55,22 +90,18 @@ 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
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz. // toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
// Trigger/A/B/L-trigger polling continues every frame for responsiveness. // Trigger/A/B/L-trigger polling continues every frame for responsiveness.
const heavyFrame = useRef(0);
const cachedSnappedPoint = useRef<THREE.Vector3 | null>(null);
const cachedSnapKind = useRef<'vertex' | 'edge' | 'surface' | 'hole'>('surface');
useFrame((_state, _dt, frame: XRFrame | undefined) => { useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode; const measureMode = useModelStore.getState().measureMode;
const selectionMode = useModelStore.getState().selectionMode; const selectionMode = useModelStore.getState().selectionMode;
if (laserRef.current) laserRef.current.visible = false; if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false; if (tipRef.current) tipRef.current.visible = false;
if ((!measureMode && !selectionMode) || !frame) return; if ((!measureMode && !selectionMode) || !frame) return;
const doHeavy = (heavyFrame.current++ % 3) === 0;
const session = frame.session; const session = frame.session;
const refSpace = gl.xr.getReferenceSpace(); const refSpace = gl.xr.getReferenceSpace();
@@ -115,72 +146,69 @@ export function XRControllerMeasure() {
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface'; let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null; let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
if (doHeavy) { const hits = raycaster.current.intersectObjects(scene.children, true);
const hits = raycaster.current.intersectObjects(scene.children, true); const hit = hits.find((h) => {
const hit = hits.find((h) => { const o = h.object;
const o = h.object; if (!isPickableModelMesh(o)) return false;
if (!(o instanceof THREE.Mesh)) return false; if (o.userData.__edgeLine) return false;
if (o.userData.__edgeLine) return false; if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false; if (o.geometry instanceof THREE.RingGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false; if (o.geometry instanceof THREE.PlaneGeometry) return false;
if (o.geometry instanceof THREE.PlaneGeometry) return false; return true;
return true; });
});
if (hit && hit.object instanceof THREE.Mesh) { if (hit && hit.object instanceof THREE.Mesh) {
// Snap to existing registered hole centers first (within ~4cm) const dist = tmpOrigin.current.distanceTo(hit.point);
const existing = useModelStore.getState().measurements; const vertexThreshold = Math.max(0.06, dist * 0.08);
let bestHoleCenter: THREE.Vector3 | null = null; const edgeThreshold = Math.max(0.08, dist * 0.10);
let bestHoleDist = Infinity;
for (const m of existing) {
if (m.kind !== 'hole') continue;
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
const d = hit.point.distanceTo(c);
if (d < 0.04 && d < bestHoleDist) {
bestHoleDist = d;
bestHoleCenter = c;
}
}
if (snapEnabled && bestHoleCenter) { // Snap to existing registered hole centers first
snappedPoint = bestHoleCenter; const existing = useModelStore.getState().measurements;
snapKind = 'hole'; let bestHoleCenter: THREE.Vector3 | null = null;
} else if (snapEnabled) { let bestHoleDist = Infinity;
// Try detecting a circular edge (hole) at hit (8cm radius) for (const m of existing) {
const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, 0.08); if (m.kind !== 'hole') continue;
if (circle) { const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
snappedPoint = circle.center; const d = hit.point.distanceTo(c);
snapKind = 'hole'; if (d < 0.05 && d < bestHoleDist) {
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center }; bestHoleDist = d;
} else { bestHoleCenter = c;
// Snap 3D físico puro (3.5cm para vértices, 5cm para arestas)
const snap = resolveSnap3D(hit.object, hit.point, 0.035, 0.05);
snappedPoint = snap.point;
snapKind = snap.type;
if (snap.type === 'edge') {
const seg = findNearestEdgeSegment3D(hit.object, hit.point, 0.05);
if (seg) {
const lenMM = seg.a.distanceTo(seg.b) * 1000;
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
}
}
}
} else {
snappedPoint = hit.point.clone();
snapKind = 'surface';
} }
} }
// Cache for non-heavy frames so the laser + trigger still have a target.
// Sticky: se conseguimos um snap "forte" (vertex/edge/hole), preservamos if (snapEnabled && bestHoleCenter) {
// ele entre frames leves para evitar cintilação. snappedPoint = bestHoleCenter;
cachedSnappedPoint.current = snappedPoint ? snappedPoint.clone() : null; snapKind = 'hole';
cachedSnapKind.current = snapKind; } else if (snapEnabled) {
} else { const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, Math.max(0.08, dist * 0.12));
// Light frame: reuse the last computed snap so visuals stay smooth if (circle) {
snappedPoint = cachedSnappedPoint.current ? cachedSnappedPoint.current.clone() : null; snappedPoint = circle.center;
snapKind = cachedSnapKind.current; snapKind = 'hole';
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
} else {
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
snappedPoint = snap.point;
snapKind = snap.type;
if (snap.type === 'edge') {
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
if (seg) {
const lenMM = seg.a.distanceTo(seg.b) * 1000;
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
}
}
}
} else {
snappedPoint = hit.point.clone();
snapKind = 'surface';
}
} }
// ── 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();
@@ -193,15 +221,18 @@ 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 {
@@ -235,6 +266,8 @@ export function XRControllerMeasure() {
const factor = strongSnap ? 0.030 : 0.012; const factor = strongSnap ? 0.030 : 0.012;
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor); const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
tipRef.current.scale.setScalar(s); tipRef.current.scale.setScalar(s);
} else {
tipRef.current.visible = false;
} }
} }
@@ -248,7 +281,7 @@ export function XRControllerMeasure() {
const triggerHits = raycaster.current.intersectObjects(scene.children, true); const triggerHits = raycaster.current.intersectObjects(scene.children, true);
const triggerHit = triggerHits.find((h) => { const triggerHit = triggerHits.find((h) => {
const o = h.object; const o = h.object;
if (!(o instanceof THREE.Mesh)) return false; if (!isPickableModelMesh(o)) return false;
if (o.userData.__edgeLine) return false; if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false; if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false; if (o.geometry instanceof THREE.RingGeometry) return false;
@@ -272,9 +305,13 @@ 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);
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;
@@ -286,6 +323,7 @@ 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;
} }
@@ -296,6 +334,7 @@ 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;
} }
+82 -3
View File
@@ -1,9 +1,25 @@
import { useRef, ReactNode, useEffect } from 'react'; import { useRef, ReactNode, useEffect } from 'react';
import { useFrame } from '@react-three/fiber'; import { useFrame, useThree } 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.
@@ -59,6 +75,7 @@ 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);
@@ -70,9 +87,21 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
single.current = null; single.current = null;
}, [scaleResetNonce]); }, [scaleResetNonce]);
useFrame(() => { const { gl } = useThree();
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;
@@ -95,6 +124,50 @@ 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);
@@ -116,12 +189,13 @@ 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;
@@ -141,6 +215,7 @@ 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;
} }
@@ -156,6 +231,7 @@ 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`);
} }
@@ -168,8 +244,11 @@ 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;
} }
}); });
+186 -83
View File
@@ -11,7 +11,7 @@ import {
stopRecording, captureScreenshot, formatElapsed, stopRecording, captureScreenshot, formatElapsed,
} from '@/hooks/useCanvasRecorder'; } from '@/hooks/useCanvasRecorder';
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr'; type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'env';
interface XRHudInWorldProps { interface XRHudInWorldProps {
freeMove: boolean; freeMove: boolean;
@@ -53,11 +53,15 @@ const WristToggle = forwardRef<THREE.Group, { open: boolean; onToggle: () => voi
); );
export function XRHudInWorld(props: XRHudInWorldProps) { export function XRHudInWorld(props: XRHudInWorldProps) {
const { camera } = useThree(); const { camera, gl } = 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 [tab, setTab] = useState<Tab>('scene'); const open = useModelStore((s) => s.xrHudOpen);
const setOpen = useModelStore((s) => s.setXrHudOpen);
const tab = useModelStore((s) => s.xrHudTab) as Tab;
const setTab = useModelStore((s) => s.setXrHudTab) as unknown as (t: Tab) => void;
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */ /** 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). */
@@ -70,20 +74,32 @@ 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 lastABtn = useRef(false); const [showQuickMenu, setShowQuickMenu] = useState(true);
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) {
const aBtn = gp.buttons[4]; // Toggle quick menu via left X button (Quest Touch buttons[4] on left controller)
const pressed = !!aBtn?.pressed; const xBtn = gp.buttons[4];
if (pressed && !lastABtn.current) setOpen((v) => !v); const pressedX = !!xBtn?.pressed;
lastABtn.current = pressed; if (pressedX && !lastXBtn.current) {
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:
@@ -150,11 +166,11 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
} }
} }
// Head-locked fallback toggle — always visible in lower-left of FOV, // Head-locked fallback toggle — always visible in lower-right 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);
} }
@@ -165,16 +181,21 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
return ( return (
<> <>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} /> <WristToggle ref={wristRef} open={open} onToggle={() => setOpen(!open)} />
{/* Head-locked fallback: always-visible quick controls in lower-left of FOV */} {/* Head-locked fallback: always-visible quick controls in lower-right of FOV */}
<group ref={headLockRef} renderOrder={999}> <group ref={headLockRef} renderOrder={999} visible={showQuickMenu}>
<XR3DButton position={[0, 0.018, 0]} size={[0.07, 0.022]} <XR3DButton position={[0, 0.026, 0]} size={[0.07, 0.022]}
label={open ? '✕ Menu' : '☰ Menu'} active={open} label={open ? '✕ Menu' : '☰ Menu'} active={open}
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }} onClick={() => { setOpen(!open); reanchorRequested.current = true; }}
fontSize={0.0085} /> fontSize={0.0085} />
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]} <XR3DButton position={[0, 0, 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 && (
@@ -212,7 +233,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: 'webxr', label: 'WebXR', icon: '' }, { id: 'env', label: 'Ambiente', icon: '🏝' },
]; ];
const W = 0.5, H = 0.36; const W = 0.5, H = 0.36;
return ( return (
@@ -252,7 +273,7 @@ function FloatingPanel({
{tab === 'inspection' && <InspectionTab />} {tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />} {tab === 'capture' && <CaptureTab />}
{tab === 'share' && <ShareTab {...p} />} {tab === 'share' && <ShareTab {...p} />}
{tab === 'webxr' && <WebXRTab />} {tab === 'env' && <EnvTab />}
</group> </group>
</XR3DPanel> </XR3DPanel>
); );
@@ -342,77 +363,125 @@ 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>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text> {/* Fileira 1: Render */}
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido" <Text position={[-0.24, 0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
<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.085, 0]} size={[0.075, 0.022]} label="Wire" <XR3DButton position={[-0.08, 0.105, 0]} size={[0.075, 0.022]} label="Bordas"
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')} />
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text> {/* Fileira 2: Toggles */}
<XR3DButton position={[-0.16, 0.025, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`} <Text position={[-0.24, 0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
<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.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`} <XR3DButton position={[-0.08, 0.055, 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.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`} <XR3DButton position={[0.0, 0.055, 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.025, 0]} size={[0.075, 0.022]} <XR3DButton position={[0.08, 0.055, 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.025, 0]} size={[0.075, 0.022]} <XR3DButton position={[0.16, 0.055, 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} />
{/* Selecionar — paridade com a tela inicial */} {/* Fileira 3: Selecionar */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text> <Text position={[-0.24, 0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]} <XR3DButton position={[-0.16, 0.005, 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.03, 0]} size={[0.075, 0.022]} label="Esconder" <XR3DButton position={[-0.08, 0.005, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} /> onClick={hideSelectedElements} />
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar" <XR3DButton position={[0.0, 0.005, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} /> onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar" <XR3DButton position={[0.08, 0.005, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} /> active={hasHidden} onClick={showAllElements} />
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left"> {/* Fileira 4: Opacidade */}
<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.085, 0]} size={[0.05, 0.022]} label="25%" <XR3DButton position={[-0.16, -0.045, 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.085, 0]} size={[0.05, 0.022]} label="5%" <XR3DButton position={[-0.105, -0.045, 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.085, 0]} size={[0.05, 0.022]} label="+5%" <XR3DButton position={[-0.05, -0.045, 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.085, 0]} size={[0.05, 0.022]} label="+25%" <XR3DButton position={[0.005, -0.045, 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.085, 0]} size={[0.05, 0.022]} label="100%" <XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} /> onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left"> {/* Fileira 5: Escala */}
<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.14, 0]} size={[0.055, 0.022]} label={lbl} position={[-0.16 + i * 0.06, -0.095, 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.14, 0]} size={[0.062, 0.022]} <XR3DButton position={[0.10, -0.095, 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.14, 0]} size={[0.062, 0.022]} <XR3DButton position={[0.17, -0.095, 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.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}> <Text position={[-0.24, -0.175, 0]} fontSize={0.0055} 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>
)} )}
@@ -576,45 +645,79 @@ function ShareTab(p: XRHudInWorldProps) {
); );
} }
function WebXRTab() { function EnvTab() {
const xrFeatures = useModelStore((s) => s.xrFeatures); const xrEnvironment = useModelStore((s) => s.xrEnvironment);
const setXRFeature = useModelStore((s) => s.setXRFeature); const setXrEnvironment = useModelStore((s) => s.setXrEnvironment);
const reset = useModelStore((s) => s.resetXRFeatures); const xrScaleMode = useModelStore((s) => s.xrScaleMode);
const keys: { k: keyof typeof xrFeatures; label: string }[] = [ const setXrScaleMode = useModelStore((s) => s.setXrScaleMode);
{ 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.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}> <Text position={[-0.24, 0.11, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest. Cenário Imersivo (VR / AR)
</Text> </Text>
<Text position={[-0.24, 0.095, 0]} fontSize={0.0075} color="#f59e0b" anchorX="left" maxWidth={0.48}>
Mudanças entram em vigor ao sair e re-entrar no AR. <XR3DButton
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>
); );
} }
+606
View File
@@ -0,0 +1,606 @@
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>
);
}
+35
View File
@@ -92,4 +92,39 @@
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;
}
} }
+9 -5
View File
@@ -7,10 +7,7 @@ const WASM_PATH = 'https://unpkg.com/web-ifc@0.0.57/';
/** /**
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter. * Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
*/ */
export async function convertIFCtoGLB( export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
buffer: ArrayBuffer,
fileName: string
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
const ifcApi = new WebIFC.IfcAPI(); const ifcApi = new WebIFC.IfcAPI();
ifcApi.SetWasmPath(WASM_PATH, true); ifcApi.SetWasmPath(WASM_PATH, true);
await ifcApi.Init(); await ifcApi.Init();
@@ -92,6 +89,14 @@ export async function convertIFCtoGLB(
}); });
ifcApi.CloseModel(modelID); ifcApi.CloseModel(modelID);
return scene;
}
export async function convertIFCtoGLB(
buffer: ArrayBuffer,
fileName: string
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
const scene = await parseIFCtoThree(buffer);
// Export to GLB // Export to GLB
const exporter = new GLTFExporter(); const exporter = new GLTFExporter();
@@ -108,7 +113,6 @@ export async function convertIFCtoGLB(
const blob = new Blob([glb], { type: 'model/gltf-binary' }); const blob = new Blob([glb], { type: 'model/gltf-binary' });
// Cleanup // Cleanup
materials.forEach((m) => m.dispose());
scene.traverse((child) => { scene.traverse((child) => {
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
child.geometry.dispose(); child.geometry.dispose();
+69
View File
@@ -0,0 +1,69 @@
import { supabase } from '@/integrations/supabase/client';
const logBuffer: any[] = [];
let channelSubscribed = false;
const logChannel = supabase.channel('global-app-logs', {
config: { broadcast: { self: true, ack: false } },
});
logChannel.subscribe((status) => {
console.log(`[RemoteLogger] Status do canal de log: ${status}`);
if (status === 'SUBSCRIBED') {
channelSubscribed = true;
console.log(`[RemoteLogger] Inscrito com sucesso. Enviando ${logBuffer.length} logs acumulados...`);
// Envia o buffer acumulado
while (logBuffer.length > 0) {
const payload = logBuffer.shift();
logChannel.send({
type: 'broadcast',
event: 'log',
payload,
}).catch(err => {
console.error('[RemoteLogger] Erro ao enviar log em buffer:', err);
});
}
}
});
export function sendRemoteLog(level: 'info' | 'warn' | 'error', message: string, data?: any) {
const consoleFn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
consoleFn(`[RemoteLog] [${level}] ${message}`, data || '');
const payload = {
level,
message,
data: data ? JSON.parse(JSON.stringify(data)) : null,
timestamp: new Date().toISOString(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'NodeJS',
};
if (channelSubscribed) {
logChannel.send({
type: 'broadcast',
event: 'log',
payload,
}).catch(err => {
console.error('[RemoteLogger] Falha ao enviar log remoto, enfileirando...', err);
logBuffer.push(payload);
});
} else {
logBuffer.push(payload);
}
}
// Captura global de erros em tempo de execução
if (typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
sendRemoteLog('error', `Global error: ${event.message} at ${event.filename}:${event.lineno}`, {
colno: event.colno,
stack: event.error?.stack,
});
});
window.addEventListener('unhandledrejection', (event) => {
sendRemoteLog('error', `Unhandled Promise rejection: ${event.reason}`, {
reason: event.reason?.stack || event.reason,
});
});
}
+37
View File
@@ -0,0 +1,37 @@
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,
});
+126 -8
View File
@@ -4,6 +4,7 @@ import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lu
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore'; import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { supabase } from '@/integrations/supabase/client';
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB'; import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC'; import { convertIFCtoGLB } from '@/lib/convertIFC';
import { validateModelFile } from '@/lib/validateModelFile'; import { validateModelFile } from '@/lib/validateModelFile';
@@ -11,6 +12,8 @@ import { CloudLoader } from '@/components/CloudLoader';
import { SceneModelList } from '@/components/SceneModelList'; import { SceneModelList } from '@/components/SceneModelList';
import { RecentFilesList } from '@/components/RecentFilesList'; import { RecentFilesList } from '@/components/RecentFilesList';
import { addRecentFile } from '@/lib/recentFiles'; import { addRecentFile } from '@/lib/recentFiles';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
const Index = () => { const Index = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -21,6 +24,32 @@ const Index = () => {
const [converting, setConverting] = useState(false); const [converting, setConverting] = useState(false);
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore(); const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
// --- Painel de Logs Remotos em Tempo Real ---
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: any }[]>([]);
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
useEffect(() => {
if (!showLogs) return;
const logChannel = supabase.channel('global-app-logs-viewer');
logChannel.on('broadcast', { event: 'log' }, ({ payload }) => {
setDebugLogs(prev => [payload, ...prev].slice(0, 100));
}).subscribe();
return () => {
logChannel.unsubscribe();
};
}, [showLogs]);
const [ifcConvert, setIfcConvert] = useState(() => {
const saved = localStorage.getItem('tsxr_ifc_convert');
return saved !== 'false';
});
const handleToggleIfcConvert = (checked: boolean) => {
setIfcConvert(checked);
localStorage.setItem('tsxr_ifc_convert', checked ? 'true' : 'false');
};
useEffect(() => { useEffect(() => {
if (navigator.xr) { if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-ar').then(setXrSupported).catch(() => setXrSupported(false)); navigator.xr.isSessionSupported('immersive-ar').then(setXrSupported).catch(() => setXrSupported(false));
@@ -74,8 +103,19 @@ const Index = () => {
if (!sigCheck.ok) { if (!sigCheck.ok) {
toast.error(sigCheck.reason!); toast.error(sigCheck.reason!);
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail); if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
setConverting(false);
return; return;
} }
if (ext === 'ifc' && !ifcConvert) {
const url = URL.createObjectURL(file);
addModel({ fileName: file.name, fileSize: file.size, url });
addRecentFile(file.name, file).catch(() => {});
toast.success(`Modelo "${file.name}" carregado de forma nativa!`);
setConverting(false);
return;
}
let result; let result;
if (ext === 'ifc') { if (ext === 'ifc') {
result = await convertIFCtoGLB(buffer, file.name); result = await convertIFCtoGLB(buffer, file.name);
@@ -93,7 +133,7 @@ const Index = () => {
} finally { } finally {
setConverting(false); setConverting(false);
} }
}, [addModel]); }, [addModel, ifcConvert]);
// Carregar da Nuvem agora é encapsulado em <CloudLoader /> // Carregar da Nuvem agora é encapsulado em <CloudLoader />
@@ -102,6 +142,14 @@ const Index = () => {
try { try {
const response = await fetch('/models/demo250hp.ifc'); const response = await fetch('/models/demo250hp.ifc');
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
if (!ifcConvert) {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
addModel({ fileName: 'demo250hp.ifc', fileSize: blob.size, url });
addRecentFile('demo250hp.ifc', blob).catch(() => {});
toast.success('Modelo demo "250 HP" (IFC nativo) carregado!');
return;
}
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc'); const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
const url = URL.createObjectURL(result.blob); const url = URL.createObjectURL(result.blob);
addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
@@ -109,17 +157,25 @@ const Index = () => {
toast.success('Modelo demo "250 HP" (IFC) carregado!'); toast.success('Modelo demo "250 HP" (IFC) carregado!');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error('Falha ao converter modelo demo IFC'); toast.error('Falha ao processar modelo demo IFC');
} finally { } finally {
setLoadingDemoIFC(false); setLoadingDemoIFC(false);
} }
}, [addModel]); }, [addModel, ifcConvert]);
const handleLoadDemoCotovelo = useCallback(async () => { const handleLoadDemoCotovelo = useCallback(async () => {
setLoadingDemoCotovelo(true); setLoadingDemoCotovelo(true);
try { try {
const response = await fetch('/models/B126-COTOVELO.ifc'); const response = await fetch('/models/B126-COTOVELO.ifc');
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
if (!ifcConvert) {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
addModel({ fileName: 'B126-COTOVELO.ifc', fileSize: blob.size, url });
addRecentFile('B126-COTOVELO.ifc', blob).catch(() => {});
toast.success('Modelo demo "B126 Cotovelo" (IFC nativo) carregado!');
return;
}
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc'); const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
const url = URL.createObjectURL(result.blob); const url = URL.createObjectURL(result.blob);
addModel({ fileName: result.fileName, fileSize: result.fileSize, url }); addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
@@ -127,15 +183,15 @@ const Index = () => {
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!'); toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error('Falha ao converter modelo demo Cotovelo'); toast.error('Falha ao processar modelo demo Cotovelo');
} finally { } finally {
setLoadingDemoCotovelo(false); setLoadingDemoCotovelo(false);
} }
}, [addModel]); }, [addModel, ifcConvert]);
const handleEnterViewer = () => { const handleEnterViewer = () => {
if (!model) { if (!model) {
toast.error('Importe um modelo GLB primeiro'); toast.error('Importe um modelo 3D primeiro');
return; return;
} }
navigate('/viewer'); navigate('/viewer');
@@ -148,6 +204,8 @@ 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} />
@@ -175,11 +233,28 @@ const Index = () => {
{converting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Upload className="h-8 w-8" />} {converting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Upload className="h-8 w-8" />}
<div className="text-center"> <div className="text-center">
<p className="text-sm font-semibold">{converting ? 'Convertendo modelo…' : 'Importar Modelo 3D'}</p> <p className="text-sm font-semibold">{converting ? 'Carregando modelo…' : 'Importar Modelo 3D'}</p>
<p className="text-xs text-muted-foreground">GLB · OBJ · STL · IFC Escala 1:1</p> <p className="text-xs text-muted-foreground">GLB · OBJ · STL · IFC Escala 1:1</p>
</div> </div>
</Button> </Button>
{/* Toggle IFC Conversion */}
<div className="flex items-center justify-between rounded-lg border border-border bg-card/50 p-3.5">
<div className="flex flex-col gap-0.5">
<Label htmlFor="ifc-convert-toggle" className="text-xs font-semibold cursor-pointer text-foreground">
Conversão IFC GLB
</Label>
<p className="text-[10px] text-muted-foreground">
{ifcConvert ? 'Otimizado para AR (conversão automática)' : 'Modo nativo (carregamento bruto IFC)'}
</p>
</div>
<Switch
id="ifc-convert-toggle"
checked={ifcConvert}
onCheckedChange={handleToggleIfcConvert}
/>
</div>
{/* Demo buttons */} {/* Demo buttons */}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<CloudLoader /> <CloudLoader />
@@ -254,8 +329,51 @@ 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.01 Q.C. Inspection TrackSteelXR v1.06 Q.C. Inspection
</p> </p>
{showLogs && (
<div className="fixed bottom-4 right-4 z-50 flex h-[400px] w-[500px] flex-col rounded-lg border border-border bg-slate-950/95 p-4 shadow-2xl backdrop-blur font-mono text-xs text-slate-200">
<div className="flex items-center justify-between border-b border-border pb-2 mb-2">
<span className="font-bold text-primary flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-ping" />
Console de Logs Remotos (Quest 3)
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px]" onClick={() => setDebugLogs([])}>
Limpar
</Button>
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px] text-destructive" onClick={() => setShowLogs(false)}>
Fechar
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{debugLogs.length === 0 ? (
<p className="text-slate-500 text-center mt-12">Aguardando logs do headset Quest 3...</p>
) : (
debugLogs.map((log, i) => (
<div key={i} className={`p-2 rounded border ${
log.level === 'error' ? 'bg-red-950/40 border-red-500/30 text-red-300' :
log.level === 'warn' ? 'bg-amber-950/40 border-amber-500/30 text-amber-300' :
'bg-slate-900/60 border-slate-700/30 text-slate-300'
}`}>
<div className="flex justify-between text-[9px] text-slate-400 mb-1">
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="uppercase font-bold">{log.level}</span>
</div>
<p className="break-all whitespace-pre-wrap">{log.message}</p>
{log.data && (
<pre className="mt-1 text-[10px] bg-black/40 p-1 rounded overflow-x-auto max-h-24 text-slate-400">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
)}
</div>); </div>);
}; };
+10 -5
View File
@@ -4,6 +4,7 @@ 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";
@@ -92,11 +93,12 @@ const Viewer = () => {
if (!model) return null; if (!model) return null;
const handleEnterXR = async () => { const handleEnterXR = async () => {
if (!xrSupported) { try {
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3."); await xrStore.enterAR();
return; } catch (e) {
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;
@@ -108,6 +110,8 @@ 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}
/> />
@@ -129,7 +133,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" />
Entrar em Modo XR Iniciar AR
</Button> </Button>
</div> </div>
</header> </header>
@@ -139,6 +143,7 @@ const Viewer = () => {
<div className="relative flex-1"> <div className="relative flex-1">
<ModelViewerCanvas /> <ModelViewerCanvas />
<ViewerControls /> <ViewerControls />
<ViewCube />
</div> </div>
{/* Side panel */} {/* Side panel */}
-713
View File
@@ -1,713 +0,0 @@
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';
// --- 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 { scene: rawScene } = useGLTF(sceneModel.url);
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
const ref = useRef<THREE.Group>(null);
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 modelInfo = useMemo(() => {
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(() => {
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') {
mat.visible = false;
} else {
mat.visible = true;
mat.transparent = opacity < 1;
mat.opacity = opacity;
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' && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness });
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
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;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
ref={ref}
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} />
</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;
}
// ─── 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);
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>
)}
{measurements.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>
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
</>
)}
<group position={mid}>
<mesh position={[0, 0, 0]} renderOrder={998}>
<planeGeometry args={[0.09, 0.024]} />
<meshBasicMaterial color="#0b1220" transparent opacity={0.85} depthTest={false} />
</mesh>
<Text
position={[0, 0, 0.001]}
fontSize={0.012}
color={color}
anchorX="center"
anchorY="middle"
renderOrder={999}
material-depthTest={false}
>
{text}
</Text>
</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;
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} />
{/* 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;
+41 -4
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { worldToModelLocal } from '@/lib/modelTransforms'; import { worldToModelLocal } from '@/lib/modelTransforms';
import { sendRemoteLog } from '@/lib/remoteLogger';
// ── Persistência de placement (fineTuning + escala) e flags WebXR ───── // ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
const PLACEMENT_KEY = 'tsxr_placements_v1'; const PLACEMENT_KEY = 'tsxr_placements_v1';
@@ -126,12 +127,13 @@ 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' | 'wireframe' | 'edges'; type RenderMode = 'solid' | '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 {
@@ -154,6 +156,7 @@ 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 {
@@ -296,6 +299,20 @@ 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. */
@@ -553,6 +570,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
}), }),
measurePoints: [], measurePoints: [],
addMeasurePoint: (p) => set((state) => { addMeasurePoint: (p) => set((state) => {
sendRemoteLog('info', `addMeasurePoint acionado: ponto atual ${state.measurePoints.length + 1}`, p);
const points = [...state.measurePoints, p]; const points = [...state.measurePoints, p];
if (points.length === 2) { if (points.length === 2) {
const [a, b] = points; const [a, b] = points;
@@ -562,11 +580,17 @@ 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;
// Convert world points to active model's local frame so they follow the model. 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);
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
distanceWorldMM,
distanceMM,
factor,
modelId,
attached
});
const measurement: Measurement = { const measurement: Measurement = {
id: `m_${Date.now()}`, id: `m_${Date.now()}`,
pointA: attached ? aConv! : a, pointA: attached ? aConv! : a,
@@ -574,6 +598,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
distanceMM, distanceMM,
modelId: attached ? modelId : undefined, modelId: attached ? modelId : undefined,
}; };
sendRemoteLog('info', `addMeasurePoint: medição gerada com sucesso`, measurement);
return { measurePoints: [], measurements: [...state.measurements, measurement] }; return { measurePoints: [], measurements: [...state.measurements, measurement] };
} }
return { measurePoints: points }; return { measurePoints: points };
@@ -593,7 +618,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 = state.activeModelId ?? undefined; const modelId = info.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 };
@@ -662,4 +687,16 @@ 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 }),
})); }));