Compare commits
84 Commits
45d09ab7a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd45e473d | |||
| fc10c77346 | |||
| c62101d17b | |||
| 9a3b59baf2 | |||
| 426bf13241 | |||
| 7f5b4427d7 | |||
| 97900b5ff7 | |||
| 6e80b23687 | |||
| 002682e6f3 | |||
| 230e373f8a | |||
| 10f00e921d | |||
| 11547f7bc7 | |||
| 0126d8d78c | |||
| b47fb872b5 | |||
| ba33180a00 | |||
| 66e30b2609 | |||
| dc64c83cc8 | |||
| a2ac72cfe7 | |||
| 126bc6f80d | |||
| 8154951fa5 | |||
| c798179560 | |||
| 43ce36e7a6 | |||
| 6372047b9d | |||
| 37bac18534 | |||
| 46ae916a4a | |||
| 7f2edbbb3c | |||
| 6954b9e30f | |||
| ba2531b440 | |||
| a03f3e8ba8 | |||
| e4a9c8623a | |||
| 7489b611b0 | |||
| 0ba00648e8 | |||
| 4dbfb7f277 | |||
| 451951b4ca | |||
| 966418659d | |||
| c0c4ad6e11 | |||
| 54a3f83375 | |||
| b6912bd070 | |||
| 5a08868b83 | |||
| 7ff4ed3176 | |||
| 153c91dfc9 | |||
| 8348262d45 | |||
| 540eb2f31f | |||
| c820808184 | |||
| 0f017d0e4b | |||
| 1e4bd78128 | |||
| d47fb9dd37 | |||
| b270ca0bfd | |||
| f36fd63bee | |||
| 9b7f237afc | |||
| aff19146f0 | |||
| c924531f82 | |||
| 30d5d7429f | |||
| 54d93c85d8 | |||
| a08ab50917 | |||
| 03411d9224 | |||
| 2e70d2e133 | |||
| 54e44dd737 | |||
| 70cdfac679 | |||
| 24417cb8a7 | |||
| e8f4e2b18a | |||
| f876e868d6 | |||
| 6590cc17c2 | |||
| 35881620b1 | |||
| b949a7c227 | |||
| 00854f0255 | |||
| 35ce901b74 | |||
| ac41cd99a0 | |||
| 0baed4a871 | |||
| 61591dc84e | |||
| cf4a317439 | |||
| 408eba84e0 | |||
| c16beadb32 | |||
| 7dff9bbeff | |||
| fffdd020f3 | |||
| 887e172526 | |||
| 6ef7c3ae8f | |||
| 883f022883 | |||
| 2e7402691a | |||
| 3c903c1ff1 | |||
| e390fd0f59 | |||
| f38bf58bd2 | |||
| de8558b81c | |||
| c953db0b26 |
@@ -1,47 +0,0 @@
|
||||
## Objetivo
|
||||
|
||||
Adicionar uma ferramenta de **corte de seção** (clipping planes) nos eixos X, Y e Z — padrão em viewers IFC/BIM. O usuário move sliders por eixo e a peça vai sendo cortada/revelada em tempo real. Implementada como **janela pop-up flutuante** sobre o viewer, com controle de opacidade da própria janela. Sem presets persistidos.
|
||||
|
||||
## UX
|
||||
|
||||
### Botões na barra `ViewerControls`
|
||||
- **"Cortes"** (ícone `Scissors`) — abre/fecha a janela pop-up. O estado dos cortes é independente da janela: fechar não reseta nada.
|
||||
- **Lixeira** ao lado, só fica habilitada quando há pelo menos um eixo de corte ativo. Clicar = `resetSection()` (desliga e zera os 3 eixos). Único caminho para zerar.
|
||||
- Indicador visual no botão "Cortes" quando há corte ativo (badge ou variante `default`).
|
||||
|
||||
### Pop-up (componente `SectionCutPanel`)
|
||||
- Janela flutuante sobre o viewer (canto superior-direito da área 3D), arrastável pelo header. Não-modal.
|
||||
- Estilo: `bg-card/X` + `backdrop-blur`, borda industrial, font-mono.
|
||||
- **Header**: título "Cortes de Seção", slider de **opacidade da janela** (20%–100%, default 90%), botão fechar (X).
|
||||
- **3 blocos idênticos** para X / Y / Z, cada um com controle individual:
|
||||
- Switch on/off do eixo.
|
||||
- Switch "Inverter" (flip do lado mantido).
|
||||
- Slider do nível de corte mapeado ao bbox mundial da peça ativa naquele eixo, valor em mm.
|
||||
- Mini-botões: **Min** · **Centro** · **Max** para saltar a posição.
|
||||
- Botão **"Recalcular limites"** (re-amostra o bbox depois de calibração/fine-tuning).
|
||||
- Sem botões de salvar/memória.
|
||||
- Fechar a janela apenas oculta os controles. Reabrir mostra exatamente o estado dos cortes que o usuário deixou.
|
||||
|
||||
## Comportamento técnico
|
||||
|
||||
- `gl.localClippingEnabled = true` no `onCreated` do `<Canvas>`.
|
||||
- Novos campos em `useModelStore` (não persistidos):
|
||||
- `sectionEnabled: { x: boolean; y: boolean; z: boolean }`
|
||||
- `sectionInvert: { x: boolean; y: boolean; z: boolean }`
|
||||
- `sectionLevel: { x: number; y: number; z: number }` (em metros, mundo)
|
||||
- `sectionPanelOpen: boolean`
|
||||
- `sectionPanelOpacity: number` (0.2–1)
|
||||
- Setters por eixo + `resetSection()` (zera enabled, invert e leva level ao centro do bbox).
|
||||
- Helper `hasActiveSection()` derivado de `sectionEnabled`.
|
||||
- Novo componente `<SectionClippingApplier>` montado dentro do `<Canvas>`:
|
||||
- Mantém 3 `THREE.Plane` reutilizáveis. A cada mudança nos campos do store, atualiza `plane.normal` (com sinal flipado se `invert`) e `plane.constant = -level` (axis-aligned, mundo).
|
||||
- Aplica `material.clippingPlanes = ativos` em cada `Mesh` dos grupos registrados em `modelTransforms`. Restaura `[]` ao desligar todos os eixos.
|
||||
- Limites dos sliders: bbox mundial agregado dos `getAllModelLocalGroups()`. Calculado quando o painel abre e quando o usuário aciona "Recalcular limites".
|
||||
|
||||
## Arquivos afetados
|
||||
|
||||
- `src/stores/useModelStore.ts` — novos campos e setters de seção + painel.
|
||||
- `src/components/three/ModelViewer.tsx` — `localClippingEnabled` no GL; monta `<SectionClippingApplier>`.
|
||||
- `src/components/SectionCutPanel.tsx` (novo) — janela pop-up flutuante com 3 sliders, switches, opacidade.
|
||||
- `src/components/ViewerControls.tsx` — botão "Cortes" + lixeira condicional.
|
||||
- `src/pages/Viewer.tsx` — monta `<SectionCutPanel />` na área do viewer.
|
||||
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -4,9 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- TODO: Set the document title to the name of your application -->
|
||||
<title>TrackSteelXR</title>
|
||||
<title>SteelXR</title>
|
||||
<meta name="description" content="VR tool inspection industrial Q.C.">
|
||||
<meta name="author" content="Lovable" />
|
||||
<link rel="icon" type="image/png" href="/iconeXR_transparente.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- TODO: Update og:title to match your application name -->
|
||||
|
||||
@@ -17,8 +19,8 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@Lovable" />
|
||||
<meta name="twitter:image" content="https://storage.googleapis.com/gpt-engineer-file-uploads/attachments/og-images/a850c4e4-56e7-4358-91d7-b33ddcd600ac?Expires=1772161962&GoogleAccessId=go-api-on-aws%40gpt-engineer-390607.iam.gserviceaccount.com&Signature=Q%2B%2F2EsHIygjuVQKvjQWWytqElhVjmehhRIdWJ1eYRremdUzoUIv36NuXp042g0lBzszDBa14EbkCTYykUEMpqrclTioA2035aKEY%2BR39jqYm7oBKXCm2rs2vopnW2ahZYf8SjX5SPqxwFzaO%2FvJt71rMU0A6PzihBNRa96ezpIhxGqzpbZe9zP4PtyRhg03aCp5sJytJp9bBOphzXb9YGeY58XWWqjAqEO1tctOGh25hZsYLgEU61wy3xnehDc8kZ%2FK0MpRpxuGibae5ypjDtGbXf9G5LjSWYecVY5pWOjnO6ZSo1CL8DaWtD%2FU3V0MszZ8f4SETk7fQAh0GR4b8Sg%3D%3D">
|
||||
<meta property="og:title" content="TrackSteelXR">
|
||||
<meta name="twitter:title" content="TrackSteelXR">
|
||||
<meta property="og:title" content="SteelXR">
|
||||
<meta name="twitter:title" content="SteelXR">
|
||||
<meta property="og:description" content="VR tool inspection industrial Q.C.">
|
||||
<meta name="twitter:description" content="VR tool inspection industrial Q.C.">
|
||||
</head>
|
||||
|
||||
|
After Width: | Height: | Size: 281 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vite_react_shadcn_ts",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
def remove_black_background(img_path, out_path, t_low=32, t_high=80):
|
||||
# Carregar imagem e garantir modo RGBA
|
||||
img = Image.open(img_path).convert("RGBA")
|
||||
data = np.array(img)
|
||||
|
||||
# Extrair canais
|
||||
r, g, b, a = data[:,:,0], data[:,:,1], data[:,:,2], data[:,:,3]
|
||||
|
||||
# O valor de brilho de cada pixel é dado pelo máximo dos canais R, G, B
|
||||
max_rgb = np.fmax(np.fmax(r, g), b)
|
||||
|
||||
# Criar nova imagem com o mesmo tamanho
|
||||
new_data = np.zeros_like(data)
|
||||
|
||||
# Calcular o novo alpha com base na rampa de threshold
|
||||
# Se max_rgb <= t_low -> alpha = 0
|
||||
# Se max_rgb >= t_high -> alpha = max_rgb (ou 255 se quisermos opacidade total)
|
||||
# Se t_low < max_rgb < t_high -> interpolação linear
|
||||
|
||||
new_alpha = np.zeros_like(max_rgb, dtype=float)
|
||||
|
||||
# Máscara para pixels totalmente opacos (ou usando o brilho original)
|
||||
mask_full = max_rgb >= t_high
|
||||
# Opcionalmente, definimos opacidade 255 para o miolo brilhante do logotipo para ficar bem nítido
|
||||
new_alpha[mask_full] = 255.0
|
||||
|
||||
# Máscara para pixels de transição (antialiasing)
|
||||
mask_trans = (max_rgb > t_low) & (max_rgb < t_high)
|
||||
new_alpha[mask_trans] = 255.0 * (max_rgb[mask_trans] - t_low) / (t_high - t_low)
|
||||
|
||||
# Converter alpha final para uint8
|
||||
final_alpha = np.clip(new_alpha, 0, 255).astype(np.uint8)
|
||||
|
||||
# Máscara de pixels visíveis
|
||||
mask_visible = final_alpha > 0
|
||||
|
||||
# Inicializar os canais de cores com zeros
|
||||
new_data[:,:,0] = 0
|
||||
new_data[:,:,1] = 0
|
||||
new_data[:,:,2] = 0
|
||||
new_data[:,:,3] = final_alpha
|
||||
|
||||
# Aplicar unmultiplying alpha nas cores para remover o halo escuro e clarear pixels de transição
|
||||
alpha_float = final_alpha.astype(float)
|
||||
for i in range(3):
|
||||
channel = data[:,:,i].astype(float)
|
||||
adjusted = np.zeros_like(channel)
|
||||
# Onde for visível, recuperamos a cor original.
|
||||
# Para evitar divisão por zero, usamos a máscara de pixels visíveis
|
||||
adjusted[mask_visible] = np.minimum(255, (channel[mask_visible] * 255.0 / np.maximum(1.0, max_rgb[mask_visible])))
|
||||
new_data[:,:,i] = adjusted.astype(np.uint8)
|
||||
|
||||
# Salvar a nova imagem
|
||||
out_img = Image.fromarray(new_data, "RGBA")
|
||||
out_img.save(out_path, "PNG")
|
||||
print(f"Processada com sucesso: {img_path} -> {out_path} (T_low: {t_low}, T_high: {t_high})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# O logotipo tem fundo com brilho aproximado de 28
|
||||
remove_black_background("logotipo_steelXR.png", "public/logotipo_steelXR_transparente.png", t_low=32, t_high=75)
|
||||
# O ícone tem fundo com brilho de 9
|
||||
remove_black_background("iconeXR.png", "public/iconeXR_transparente.png", t_low=12, t_high=50)
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -7,6 +7,7 @@ import Index from "./pages/Index";
|
||||
import Viewer from "./pages/Viewer";
|
||||
import XRSession from "./pages/XRSession";
|
||||
import Watch from "./pages/Watch";
|
||||
import MeetingRoom from "./pages/MeetingRoom";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import "@/lib/remoteLogger";
|
||||
|
||||
@@ -23,6 +24,8 @@ const App = () => (
|
||||
<Route path="/viewer" element={<Viewer />} />
|
||||
<Route path="/xr" element={<XRSession />} />
|
||||
<Route path="/watch/:code" element={<Watch />} />
|
||||
<Route path="/meeting" element={<MeetingRoom />} />
|
||||
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { addRecentFile } from '@/lib/recentFiles';
|
||||
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
||||
|
||||
const PRESETS: { label: string; url: string }[] = [
|
||||
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
|
||||
{ label: 'Demo IFC (SteelXR)', url: DEFAULT_URL },
|
||||
];
|
||||
|
||||
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ShieldCheck, Ruler, Scissors, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function LandingContent() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-12 py-12">
|
||||
{/* Hero Section */}
|
||||
<section className="text-center space-y-6 px-4">
|
||||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-foreground">
|
||||
Controle total da qualidade em obras metálicas e civis — <br className="hidden md:block" />
|
||||
<span className="text-primary">direto no canteiro, em Realidade Aumentada.</span>
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
visualize. Meça. inspire confiança.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="grid md:grid-cols-3 gap-6 px-4">
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Inspeção por Checklist Digital com Gravação de Voz
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Elimine planilhas de papel. Faça inspeções de qualidade diretamente no modelo 3D, com aprovação ou reprovação de cada item e anotações por voz — tudo rastreável e auditável.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Ruler className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Medições Precisas em AR com Snap Inteligente
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O controlador XR identifica automaticamente vértices, arestas e furos nos modelos IFC. O resultado são medições em milímetros reais, sem depender de trena ou cálculos manuais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Scissors className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Cortes de Seção Interativos para Análise Estrutural
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visualize o interior de estruturas metálicas com cortes dinâmicos nos eixos X, Y e Z — sem desmontar nada. Ideal para validar projetos complexos antes da montagem.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA Section */}
|
||||
<section className="px-4 text-center space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-gradient-to-b from-card to-muted/20 p-8 md:p-12">
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto text-base md:text-lg leading-relaxed">
|
||||
Atrasos por erros de medição, retrabalho por falhas de comunicação e perdas por não conformidade são o pesadelo de qualquer gestão de produção metalmecânica ou obra civil. O SteelXR coloca o modelo 3D IFC no canteiro — em AR — para que engenheiros e gestores tomem decisões precisas no momento certo. <span className="font-semibold text-foreground">Menos retrabalho, mais produtividade, entregas dentro do prazo.</span>
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<Button size="lg" className="gap-2">
|
||||
Começar Agora
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
Ver Demonstração
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'TrackSteelXR — Sessão ao vivo',
|
||||
title: 'SteelXR — Sessão ao vivo',
|
||||
text: 'Acompanhe a inspeção ao vivo:',
|
||||
url: link,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './three/viewCubeBus';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { getModelLocalGroup } from '@/lib/modelTransforms';
|
||||
import { Check, RotateCcw } from 'lucide-react';
|
||||
import { Check, RotateCcw, Maximize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** Builds a square canvas texture with a label centered on it. */
|
||||
@@ -296,6 +296,23 @@ export function ViewCube() {
|
||||
<RotateCcw className="h-2.5 w-2.5" /> reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Botão de Tela Cheia */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const container = document.getElementById("canvas-container");
|
||||
if (container) {
|
||||
container.requestFullscreen().catch((err) => {
|
||||
toast.error("Não foi possível entrar em tela cheia: " + err.message);
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-7 w-full items-center justify-center gap-1 rounded border border-primary/40 bg-background/40 font-mono text-[10px] uppercase tracking-widest text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Entrar em modo tela cheia"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" /> Tela Cheia
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors } from 'lucide-react';
|
||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors, Footprints } from 'lucide-react';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
@@ -31,6 +31,7 @@ export function ViewerControls() {
|
||||
wireframeThickness, setWireframeThickness,
|
||||
edgeThresholdAngle, setEdgeThresholdAngle,
|
||||
positionMode, setPositionMode,
|
||||
walkMode, setWalkMode,
|
||||
activeModelId, models,
|
||||
selectionMode, setSelectionMode,
|
||||
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
||||
@@ -268,6 +269,25 @@ export function ViewerControls() {
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Walk mode */}
|
||||
<Button
|
||||
variant={walkMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="gap-2 h-9"
|
||||
onClick={() => {
|
||||
if (!walkMode) {
|
||||
toast.info("Modo Caminhada: Use WASD para andar, Q/E para subir/descer e o mouse para olhar. ESC para sair.");
|
||||
}
|
||||
setWalkMode(!walkMode);
|
||||
}}
|
||||
title="Modo primeira pessoa (WASD mover, Q/E subir/descer)"
|
||||
>
|
||||
<Footprints className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">
|
||||
Caminhar
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Measure tool */}
|
||||
<div className="flex items-end gap-1.5">
|
||||
<Button
|
||||
|
||||
@@ -1,116 +1,230 @@
|
||||
import { useRef, RefObject } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import { useXR } from '@react-three/xr';
|
||||
import * as THREE from 'three';
|
||||
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Quest 3 thumbstick locomotion (rig-relative).
|
||||
* Mapeamento de Locomoção por Teletransporte Parabólico via Controle XR:
|
||||
*
|
||||
* Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard):
|
||||
* • Para frente (axes[1] < -0.7), libera (axis volta a ~0) → teletransporta
|
||||
* para o ponto onde o usuário está olhando (raycast da câmera). Se não
|
||||
* houver geometria atingida, dá um passo de 0.5 m na direção do olhar.
|
||||
* • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar.
|
||||
* • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig.
|
||||
*
|
||||
* NÃO lê grip ou trigger — esses são responsabilidade exclusiva de
|
||||
* XRGrabbable e XRControllerMeasure, respectivamente.
|
||||
* - Incline e segure o Thumbstick (analógico) de qualquer controle para frente.
|
||||
* Um arco parabólico surgirá projetado a partir da mira física do controle.
|
||||
* - Ao soltar o direcional, você se teletransporta para o local.
|
||||
* - Para entrar em escala real dentro do modelo, aponte diretamente para ele. A escala
|
||||
* será automaticamente definida para 1:1 ao pousar.
|
||||
*/
|
||||
interface Props {
|
||||
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
||||
rigRef: RefObject<THREE.Group>;
|
||||
rigRef: React.RefObject<THREE.Group>;
|
||||
}
|
||||
|
||||
const DEAD = 0.7;
|
||||
const RELEASE = 0.3;
|
||||
const SNAP_DEG = 30;
|
||||
const STEP_BACK = 0.3;
|
||||
const STEP_FWD_NO_HIT = 0.5;
|
||||
|
||||
export function ControllerLocomotion({ rigRef }: Props) {
|
||||
const { scene, camera } = useThree();
|
||||
const { camera, gl } = useThree();
|
||||
const session = useXR((s) => s.session);
|
||||
|
||||
const fwdPushed = useRef(false);
|
||||
const backLatched = useRef(false);
|
||||
const rotLatched = useRef<0 | -1 | 1>(0);
|
||||
const raycaster = useRef(new THREE.Raycaster());
|
||||
|
||||
useFrame(() => {
|
||||
const rig = rigRef.current;
|
||||
if (!rig || !session) return;
|
||||
const [arcLine] = useState(() => {
|
||||
const geom = new THREE.BufferGeometry();
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: '#06b6d4',
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
const line = new THREE.Line(geom, mat);
|
||||
line.visible = false;
|
||||
return line;
|
||||
});
|
||||
|
||||
let stickX = 0;
|
||||
// Refs visuais
|
||||
const reticleRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
// Estados temporários do teletransporte
|
||||
const lastTeleportTarget = useRef<THREE.Vector3 | null>(null);
|
||||
const lastTeleportHitModel = useRef<boolean>(false);
|
||||
|
||||
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||
const rig = rigRef.current;
|
||||
if (!rig || !session || !frame) {
|
||||
arcLine.visible = false;
|
||||
if (reticleRef.current) reticleRef.current.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceSpace = gl.xr.getReferenceSpace();
|
||||
if (!referenceSpace) return;
|
||||
|
||||
let activeSource: XRInputSource | null = null;
|
||||
let stickY = 0;
|
||||
|
||||
// Procura qualquer controle com analógico inclinado para a frente
|
||||
for (const src of session.inputSources) {
|
||||
const gp = src.gamepad;
|
||||
if (!gp || gp.axes.length < 4) continue;
|
||||
// Prefer left stick for locomotion
|
||||
if (src.handedness === 'left') {
|
||||
stickX = gp.axes[2] ?? 0;
|
||||
stickY = gp.axes[3] ?? 0;
|
||||
break;
|
||||
}
|
||||
if (src.handedness === 'right' && stickX === 0 && stickY === 0) {
|
||||
stickX = gp.axes[2] ?? 0;
|
||||
stickY = gp.axes[3] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snap-rotate (lateral) ──────────────────────────────────────
|
||||
if (Math.abs(stickX) > DEAD) {
|
||||
const dir: -1 | 1 = stickX > 0 ? 1 : -1;
|
||||
if (rotLatched.current !== dir) {
|
||||
rotLatched.current = dir;
|
||||
rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180;
|
||||
}
|
||||
} else if (Math.abs(stickX) < RELEASE) {
|
||||
rotLatched.current = 0;
|
||||
}
|
||||
|
||||
// ── Back step ──────────────────────────────────────────────────
|
||||
if (stickY > DEAD) {
|
||||
if (!backLatched.current) {
|
||||
backLatched.current = true;
|
||||
const headDir = new THREE.Vector3();
|
||||
camera.getWorldDirection(headDir);
|
||||
headDir.y = 0;
|
||||
if (headDir.lengthSq() > 1e-6) {
|
||||
headDir.normalize();
|
||||
rig.position.addScaledVector(headDir, -STEP_BACK);
|
||||
if (gp && gp.axes.length >= 4) {
|
||||
const sy = gp.axes[3] ?? 0; // Eixo Y vertical do joystick
|
||||
if (sy < -DEAD) {
|
||||
activeSource = src;
|
||||
stickY = sy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (stickY < RELEASE) {
|
||||
backLatched.current = false;
|
||||
}
|
||||
|
||||
// ── Forward → teleport on release ─────────────────────────────
|
||||
if (stickY < -DEAD) {
|
||||
// Se encontramos um controle ativo mirando/inclinado para frente
|
||||
if (activeSource && stickY < -DEAD) {
|
||||
fwdPushed.current = true;
|
||||
} else if (fwdPushed.current && stickY > -RELEASE) {
|
||||
fwdPushed.current = false;
|
||||
// Cast from camera forward to find target on real/virtual geometry
|
||||
|
||||
const origin = new THREE.Vector3();
|
||||
camera.getWorldPosition(origin);
|
||||
const dir = new THREE.Vector3();
|
||||
camera.getWorldDirection(dir);
|
||||
raycaster.current.set(origin, dir);
|
||||
raycaster.current.far = 30;
|
||||
const hits = raycaster.current.intersectObjects(scene.children, true);
|
||||
const hit = hits.find((h) => {
|
||||
const o = h.object;
|
||||
if (!(o instanceof THREE.Mesh)) return false;
|
||||
if (o.userData.__edgeLine) return false;
|
||||
return true;
|
||||
});
|
||||
const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT));
|
||||
const camWorld = new THREE.Vector3();
|
||||
camera.getWorldPosition(camWorld);
|
||||
const delta = new THREE.Vector3().subVectors(target, camWorld);
|
||||
delta.y = 0; // preserva altura do usuário
|
||||
rig.position.add(delta);
|
||||
|
||||
// Obtém pose do controle usando o targetRaySpace (mira física do controle)
|
||||
const ctrlPose = frame.getPose(activeSource.targetRaySpace, referenceSpace);
|
||||
if (ctrlPose) {
|
||||
const m = new THREE.Matrix4().fromArray(ctrlPose.transform.matrix);
|
||||
// Aplica a matriz de mundo do rig para alinhar a origem física com a locomoção do rig
|
||||
m.premultiply(rig.matrixWorld);
|
||||
origin.setFromMatrixPosition(m);
|
||||
const q = new THREE.Quaternion().setFromRotationMatrix(m);
|
||||
dir.set(0, 0, -1).applyQuaternion(q).normalize();
|
||||
} else {
|
||||
// Fallback para direção do olhar se pose falhar
|
||||
camera.getWorldPosition(origin);
|
||||
camera.getWorldDirection(dir);
|
||||
}
|
||||
|
||||
// Parâmetros da simulação do arco parabólico
|
||||
const points: THREE.Vector3[] = [];
|
||||
const currentPos = origin.clone();
|
||||
const velocity = dir.clone().multiplyScalar(7.5); // velocidade do tiro do arco
|
||||
const gravity = new THREE.Vector3(0, -9.8, 0);
|
||||
const dt = 0.025; // passo da física
|
||||
let hitPoint: THREE.Vector3 | null = null;
|
||||
let hitModel = false;
|
||||
const modelsObjects = getAllModelLocalGroups();
|
||||
|
||||
points.push(currentPos.clone());
|
||||
|
||||
// Executa a trajetória balística
|
||||
for (let i = 0; i < 35; i++) {
|
||||
const nextPos = currentPos.clone().addScaledVector(velocity, dt);
|
||||
velocity.addScaledVector(gravity, dt);
|
||||
|
||||
const segDir = new THREE.Vector3().subVectors(nextPos, currentPos);
|
||||
const segLen = segDir.length();
|
||||
|
||||
if (segLen > 0.001) {
|
||||
segDir.normalize();
|
||||
raycaster.current.set(currentPos, segDir);
|
||||
raycaster.current.far = segLen;
|
||||
|
||||
// 1. Verifica colisão com modelos 3D
|
||||
const hits = raycaster.current.intersectObjects(modelsObjects, true);
|
||||
const firstHit = hits.find(
|
||||
(h) => h.object instanceof THREE.Mesh && !h.object.userData.__edgeLine
|
||||
);
|
||||
|
||||
if (firstHit) {
|
||||
hitPoint = firstHit.point.clone();
|
||||
hitModel = true;
|
||||
points.push(hitPoint);
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. Verifica colisão com o chão/grid de calibração plano infinito em Y = gridY
|
||||
const gridY = useModelStore.getState().gridY;
|
||||
if (
|
||||
(currentPos.y >= gridY && nextPos.y <= gridY) ||
|
||||
(currentPos.y <= gridY && nextPos.y >= gridY)
|
||||
) {
|
||||
const t = (gridY - currentPos.y) / (nextPos.y - currentPos.y);
|
||||
hitPoint = new THREE.Vector3().lerpVectors(currentPos, nextPos, t);
|
||||
points.push(hitPoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
currentPos.copy(nextPos);
|
||||
points.push(currentPos.clone());
|
||||
}
|
||||
|
||||
const finalTarget = hitPoint ?? points[points.length - 1];
|
||||
lastTeleportTarget.current = finalTarget.clone();
|
||||
lastTeleportHitModel.current = hitModel;
|
||||
|
||||
// Atualiza a geometria do arco
|
||||
arcLine.geometry.setFromPoints(points);
|
||||
arcLine.geometry.attributes.position.needsUpdate = true;
|
||||
arcLine.geometry.computeBoundingSphere();
|
||||
arcLine.visible = true;
|
||||
|
||||
// Atualiza o retículo
|
||||
if (reticleRef.current) {
|
||||
const localTarget = finalTarget.clone();
|
||||
rig.worldToLocal(localTarget);
|
||||
reticleRef.current.position.copy(localTarget);
|
||||
reticleRef.current.visible = true;
|
||||
|
||||
// Visual premium: Rosa/Fúcsia para escala real (modelo), Ciano para o grid/chão
|
||||
const mat = reticleRef.current.material as THREE.MeshBasicMaterial;
|
||||
if (hitModel) {
|
||||
mat.color.set('#d946ef');
|
||||
reticleRef.current.scale.setScalar(1.2);
|
||||
} else {
|
||||
mat.color.set('#06b6d4');
|
||||
reticleRef.current.scale.setScalar(1.0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Quando soltar o analógico
|
||||
if (fwdPushed.current) {
|
||||
fwdPushed.current = false;
|
||||
|
||||
const targetWorld = lastTeleportTarget.current;
|
||||
const hitModel = lastTeleportHitModel.current;
|
||||
|
||||
if (targetWorld) {
|
||||
const camWorld = new THREE.Vector3();
|
||||
camera.getWorldPosition(camWorld);
|
||||
|
||||
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
|
||||
|
||||
if (hitModel) {
|
||||
// "Para entrar em escala real dentro do modelo, aponte para ele"
|
||||
useModelStore.getState().resetScale();
|
||||
// Ajusta o rig para que o pé fique no Y do modelo, mantendo a altura da câmera
|
||||
rig.position.y = targetWorld.y;
|
||||
delta.y = 0;
|
||||
toast.success("Entrando na peça em Escala Real (1:1)!");
|
||||
} else {
|
||||
delta.y = 0; // preserva a altura vertical do usuário
|
||||
}
|
||||
|
||||
rig.position.add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Oculta feixe e retículo
|
||||
arcLine.visible = false;
|
||||
if (reticleRef.current) reticleRef.current.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
{/* Arco parabólico premium */}
|
||||
<primitive object={arcLine} />
|
||||
|
||||
{/* Retículo de pouso */}
|
||||
<mesh ref={reticleRef} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.07, 0.09, 32]} />
|
||||
<meshBasicMaterial color="#06b6d4" transparent opacity={0.85} side={THREE.DoubleSide} depthWrite={false} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react';
|
||||
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera } from '@react-three/drei';
|
||||
import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera, PointerLockControls, Sky, Environment } from '@react-three/drei';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||
@@ -9,6 +9,19 @@ import { registerModelLocalGroup, unregisterModelLocalGroup, getModelWorldScaleF
|
||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function getColorForMaterialName(name: string): string {
|
||||
if (!name) return '#a1a1aa';
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const h = Math.abs(hash) % 360;
|
||||
const s = 65 + (Math.abs(hash >> 8) % 15);
|
||||
const l = 45 + (Math.abs(hash >> 16) % 10);
|
||||
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||
}
|
||||
|
||||
interface ModelViewerProps {
|
||||
url?: string; // legacy, ignored — uses store.models
|
||||
@@ -195,6 +208,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
||||
const setActive = useModelStore((s) => s.setActiveModel);
|
||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const ifcColorMode = useModelStore((s) => s.ifcColorMode);
|
||||
const fineTuning = sceneModel.fineTuning;
|
||||
|
||||
// Re-render when calibration state changes (so calQuat resets to identity during the flow).
|
||||
@@ -261,8 +275,17 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
||||
} 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);
|
||||
const root = findElementRoot(child);
|
||||
const matName = root?.userData?.materialName;
|
||||
const descName = root?.userData?.properties?.description || root?.userData?.description;
|
||||
if (ifcColorMode === 'material' && matName) {
|
||||
mat.color.set(getColorForMaterialName(matName));
|
||||
} else if (ifcColorMode === 'description' && descName) {
|
||||
mat.color.set(getColorForMaterialName(descName));
|
||||
} else {
|
||||
// Tint with the per-model color (subtle)
|
||||
mat.color.set(sceneModel.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
@@ -284,7 +307,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode, ifcColorMode]);
|
||||
|
||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||
@@ -293,6 +316,10 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
||||
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||
const renderFactor = scaleRatio?.factor ?? 1;
|
||||
|
||||
const rotationEuler = useMemo(() => {
|
||||
return new THREE.Euler(rotXRad, rotYRad, rotZRad, 'YXZ');
|
||||
}, [rotXRad, rotYRad, rotZRad]);
|
||||
|
||||
// Calibration quaternion (applied innermost, around center). We render with identity
|
||||
// while calibration is in progress for this model so face normals reflect the
|
||||
// un-calibrated frame; the result is committed at the end.
|
||||
@@ -362,7 +389,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
||||
{/* Rotation + scale around the geometry center */}
|
||||
<group
|
||||
ref={ref}
|
||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||
rotation={rotationEuler}
|
||||
scale={[s, s, s]}
|
||||
>
|
||||
{/* Calibration rotation (innermost, around center). */}
|
||||
@@ -422,7 +449,7 @@ function computeWorldPerPixel(
|
||||
worldPos: THREE.Vector3,
|
||||
): number {
|
||||
const ortho = camera as THREE.OrthographicCamera;
|
||||
if ((ortho as any).isOrthographicCamera) {
|
||||
if ('isOrthographicCamera' in ortho) {
|
||||
const visibleHeight = (ortho.top - ortho.bottom) / (ortho.zoom || 1);
|
||||
return visibleHeight / Math.max(1, size.height);
|
||||
}
|
||||
@@ -711,11 +738,13 @@ function SmartSnapHandler() {
|
||||
function HoverDetector() {
|
||||
const { camera, scene, gl } = useThree();
|
||||
const setHoverInfo = useModelStore((s) => s.setHoverInfo);
|
||||
const setHoverIfcProps = useModelStore((s) => s.setHoverIfcProps);
|
||||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastHitKey = useRef('');
|
||||
const lastHitExpressId = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const clearTimers = () => {
|
||||
@@ -741,11 +770,28 @@ function HoverDetector() {
|
||||
|
||||
if (!hit || !(hit.object instanceof THREE.Mesh)) {
|
||||
lastHitKey.current = '';
|
||||
lastHitExpressId.current = 0;
|
||||
setHoverInfo(null);
|
||||
setHoverIfcProps(null);
|
||||
clearTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Detecção de propriedades do IFC de forma ágil baseada em mudança de elemento
|
||||
const elementRoot = findElementRoot(hit.object);
|
||||
const expressID = elementRoot?.userData?.ifcId ?? 0;
|
||||
|
||||
if (expressID !== lastHitExpressId.current) {
|
||||
lastHitExpressId.current = expressID;
|
||||
const props = elementRoot?.userData?.properties;
|
||||
|
||||
if (props && (props.name || props.material || props.tag)) {
|
||||
setHoverIfcProps(props);
|
||||
} else {
|
||||
setHoverIfcProps(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Stability check – same approximate position for debounce (~3 mm)
|
||||
const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
|
||||
if (key === lastHitKey.current) return; // timer already running
|
||||
@@ -808,7 +854,9 @@ function HoverDetector() {
|
||||
|
||||
const onLeave = () => {
|
||||
lastHitKey.current = '';
|
||||
lastHitExpressId.current = 0;
|
||||
setHoverInfo(null);
|
||||
setHoverIfcProps(null);
|
||||
clearTimers();
|
||||
};
|
||||
|
||||
@@ -818,8 +866,9 @@ function HoverDetector() {
|
||||
gl.domElement.removeEventListener('pointermove', onMove);
|
||||
gl.domElement.removeEventListener('pointerleave', onLeave);
|
||||
clearTimers();
|
||||
setHoverIfcProps(null);
|
||||
};
|
||||
}, [camera, scene, gl, raycaster, mouse, setHoverInfo]);
|
||||
}, [camera, scene, gl, raycaster, mouse, setHoverInfo, setHoverIfcProps]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1176,7 +1225,7 @@ function SectionClippingApplier() {
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
}, [enabled.x, enabled.y, enabled.z, invert.x, invert.y, invert.z, level.x, level.y, level.z, planes]);
|
||||
}, [enabled.x, enabled.y, enabled.z, invert, invert.x, invert.y, invert.z, level, level.x, level.y, level.z, planes]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1330,10 +1379,149 @@ function PositionDragHandler() {
|
||||
|
||||
|
||||
|
||||
function WalkControls() {
|
||||
const walkMode = useModelStore(s => s.walkMode);
|
||||
const setWalkMode = useModelStore(s => s.setWalkMode);
|
||||
const gridY = useModelStore(s => s.gridY);
|
||||
const { camera } = useThree();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const controlsRef = useRef<any>(null);
|
||||
const keys = useRef({ w: false, a: false, s: false, d: false, q: false, e: false, shift: false });
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (walkMode && !initialized.current) {
|
||||
camera.position.set(0, gridY + 1.7, 5);
|
||||
// Give the camera an initial rotation looking forward
|
||||
camera.rotation.set(0, 0, 0);
|
||||
initialized.current = true;
|
||||
} else if (!walkMode) {
|
||||
initialized.current = false;
|
||||
}
|
||||
}, [walkMode, camera, gridY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (walkMode) {
|
||||
// Small timeout to allow canvas to render and be clickable
|
||||
const timer = setTimeout(() => {
|
||||
try { controlsRef.current?.lock(); } catch (e) { /* ignore */ }
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [walkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'KeyW') keys.current.w = true;
|
||||
if (e.code === 'KeyA') keys.current.a = true;
|
||||
if (e.code === 'KeyS') keys.current.s = true;
|
||||
if (e.code === 'KeyD') keys.current.d = true;
|
||||
if (e.code === 'KeyQ') keys.current.q = true;
|
||||
if (e.code === 'KeyE') keys.current.e = true;
|
||||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = true;
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'KeyW') keys.current.w = false;
|
||||
if (e.code === 'KeyA') keys.current.a = false;
|
||||
if (e.code === 'KeyS') keys.current.s = false;
|
||||
if (e.code === 'KeyD') keys.current.d = false;
|
||||
if (e.code === 'KeyQ') keys.current.q = false;
|
||||
if (e.code === 'KeyE') keys.current.e = false;
|
||||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = false;
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame((_, dt) => {
|
||||
if (!walkMode) return;
|
||||
const speed = keys.current.shift ? 6.0 : 2.5;
|
||||
const dir = new THREE.Vector3();
|
||||
|
||||
// Front vector (ignoring pitch to walk purely horizontally)
|
||||
const front = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
|
||||
front.y = 0;
|
||||
if (front.lengthSq() > 0.001) front.normalize();
|
||||
|
||||
// Right vector
|
||||
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
|
||||
right.y = 0;
|
||||
if (right.lengthSq() > 0.001) right.normalize();
|
||||
|
||||
if (keys.current.w) dir.add(front);
|
||||
if (keys.current.s) dir.sub(front);
|
||||
if (keys.current.a) dir.sub(right);
|
||||
if (keys.current.d) dir.add(right);
|
||||
|
||||
if (dir.lengthSq() > 0) {
|
||||
dir.normalize();
|
||||
camera.position.addScaledVector(dir, speed * dt);
|
||||
}
|
||||
|
||||
// Vertical movement (Q / E)
|
||||
if (keys.current.e) camera.position.y += speed * dt;
|
||||
if (keys.current.q) camera.position.y -= speed * dt;
|
||||
|
||||
// Clamp to prevent clipping through the ground
|
||||
if (camera.position.y < gridY + 0.2) {
|
||||
camera.position.y = gridY + 0.2;
|
||||
}
|
||||
});
|
||||
|
||||
if (!walkMode) return null;
|
||||
|
||||
return (
|
||||
<PointerLockControls
|
||||
ref={controlsRef}
|
||||
onUnlock={() => {
|
||||
setWalkMode(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WalkEnvironment() {
|
||||
const walkMode = useModelStore(s => s.walkMode);
|
||||
const gridY = useModelStore(s => s.gridY);
|
||||
if (!walkMode) return null;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Um belo cenário HDRI de parque que nos dá árvores, céu azul e nuvens à distância perfeitamente */}
|
||||
<Environment preset="park" background />
|
||||
|
||||
{/* O Piso Digital: Sólido, infinito e na altura exata da peça para que ela não pareça "voar" */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, gridY - 0.01, 0]} receiveShadow>
|
||||
<planeGeometry args={[1000, 1000]} />
|
||||
<meshStandardMaterial color="#3f662b" roughness={1} metalness={0.1} />
|
||||
</mesh>
|
||||
|
||||
{/* Grid sutil sobre a grama para dar noção de escala e movimento ao caminhar */}
|
||||
<Grid
|
||||
position={[0, gridY, 0]}
|
||||
args={[1000, 1000]}
|
||||
cellSize={1}
|
||||
cellThickness={0.5}
|
||||
cellColor="#4d7a34"
|
||||
sectionSize={10}
|
||||
sectionThickness={1}
|
||||
sectionColor="#2d4a1f"
|
||||
fadeDistance={50}
|
||||
fadeStrength={2}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
const positionMode = useModelStore((s) => s.positionMode);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||
const walkMode = useModelStore((s) => s.walkMode);
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||
@@ -1377,6 +1565,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
<SceneRefCapture />
|
||||
<ViewCubeAnimator />
|
||||
|
||||
<WalkControls />
|
||||
<WalkEnvironment />
|
||||
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
@@ -1385,8 +1576,8 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
maxDistance={50}
|
||||
minZoom={5}
|
||||
maxZoom={5000}
|
||||
enabled={!positionMode}
|
||||
ref={(c: any) => {
|
||||
enabled={!positionMode && !walkMode}
|
||||
ref={(c: unknown) => {
|
||||
mainControlsRef.current = c;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
detectCircularEdgeAtPoint3D,
|
||||
findNearestEdgeSegment3D
|
||||
} from './SmartMeasure';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const TRIG_ON = 0.7;
|
||||
const TRIG_OFF = 0.3;
|
||||
@@ -56,6 +57,7 @@ export function XRControllerMeasure() {
|
||||
const lTrigState = useRef(false);
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
|
||||
|
||||
const raycaster = useRef(new THREE.Raycaster());
|
||||
const tmpOrigin = useRef(new THREE.Vector3());
|
||||
const tmpDir = useRef(new THREE.Vector3());
|
||||
@@ -80,12 +82,22 @@ export function XRControllerMeasure() {
|
||||
// running them every frame at 72fps can stall the Quest right after
|
||||
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
|
||||
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
|
||||
const hitTestSourceRef = useRef<XRHitTestSource | null>(null);
|
||||
const hitTestRequestedRef = useRef<boolean>(false);
|
||||
|
||||
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||
const measureMode = useModelStore.getState().measureMode;
|
||||
const selectionMode = useModelStore.getState().selectionMode;
|
||||
|
||||
if (laserRef.current) laserRef.current.visible = false;
|
||||
if (tipRef.current) tipRef.current.visible = false;
|
||||
if ((!measureMode && !selectionMode) || !frame) return;
|
||||
if ((!measureMode && !selectionMode) || !frame) {
|
||||
if (hitTestRequestedRef.current) {
|
||||
hitTestSourceRef.current = null;
|
||||
hitTestRequestedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const session = frame.session;
|
||||
const refSpace = gl.xr.getReferenceSpace();
|
||||
@@ -119,6 +131,10 @@ export function XRControllerMeasure() {
|
||||
if (!pose) return;
|
||||
|
||||
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
|
||||
const xrRig = useModelStore.getState().xrRig;
|
||||
if (xrRig) {
|
||||
m.premultiply(xrRig.matrixWorld);
|
||||
}
|
||||
tmpOrigin.current.setFromMatrixPosition(m);
|
||||
tmpQuat.current.setFromRotationMatrix(m);
|
||||
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
|
||||
@@ -241,6 +257,7 @@ export function XRControllerMeasure() {
|
||||
// ── Update laser visual ───────────────────────────────────────────
|
||||
if (laserRef.current && tipRef.current) {
|
||||
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
|
||||
|
||||
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
|
||||
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
|
||||
positions.setXYZ(1, end.x, end.y, end.z);
|
||||
@@ -248,18 +265,18 @@ export function XRControllerMeasure() {
|
||||
laserGeom.current.computeBoundingSphere();
|
||||
|
||||
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
|
||||
|
||||
tipColor.current.set(color);
|
||||
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
|
||||
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
|
||||
|
||||
laserRef.current.visible = true;
|
||||
if (snappedPoint) {
|
||||
|
||||
const tipPos = snappedPoint;
|
||||
if (tipPos) {
|
||||
tipRef.current.visible = true;
|
||||
tipRef.current.position.copy(snappedPoint);
|
||||
// Tip scales with distance from controller. Snap "forte" (vértice,
|
||||
// aresta, furo) recebe um marcador ~3× maior do que uma superfície
|
||||
// genérica, para que o ponto destacado fique bem visível.
|
||||
const dist = tmpOrigin.current.distanceTo(snappedPoint);
|
||||
tipRef.current.position.copy(tipPos);
|
||||
const dist = tmpOrigin.current.distanceTo(tipPos);
|
||||
const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
|
||||
const factor = strongSnap ? 0.030 : 0.012;
|
||||
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
|
||||
@@ -271,9 +288,12 @@ export function XRControllerMeasure() {
|
||||
|
||||
// ── Right trigger: add point (measure) OR toggle selection ────────
|
||||
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
|
||||
|
||||
if (!trigState.current && trigVal > TRIG_ON) {
|
||||
trigState.current = true;
|
||||
|
||||
const st = useModelStore.getState();
|
||||
|
||||
if (st.selectionMode) {
|
||||
// Fresh raycast on the press itself (not every frame) for selection
|
||||
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
|
||||
@@ -314,25 +334,7 @@ export function XRControllerMeasure() {
|
||||
trigState.current = false;
|
||||
}
|
||||
|
||||
// ── Button A (undo) ──────────────────────────────────────────────
|
||||
const aBtn = gp?.buttons?.[4];
|
||||
const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0;
|
||||
if (!aState.current && aVal > BTN_ON) {
|
||||
aState.current = true;
|
||||
useModelStore.getState().undoLastMeasurement();
|
||||
} else if (aState.current && aVal < TRIG_OFF) {
|
||||
aState.current = false;
|
||||
}
|
||||
|
||||
// ── Button B (clear) ─────────────────────────────────────────────
|
||||
const bBtn = gp?.buttons?.[5];
|
||||
const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0;
|
||||
if (!bState.current && bVal > BTN_ON) {
|
||||
bState.current = true;
|
||||
useModelStore.getState().clearMeasurements();
|
||||
} else if (bState.current && bVal < TRIG_OFF) {
|
||||
bState.current = false;
|
||||
}
|
||||
// A and B physical buttons are now handled globally in XRHudInWorld.tsx for the Summon Menu.
|
||||
});
|
||||
|
||||
// Construct the laser Line object once so we can attach via <primitive>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useRef, ReactNode, useEffect } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useXR } from '@react-three/xr';
|
||||
import * as THREE from 'three';
|
||||
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { activeModelGroupRef } from './xrCalibrationBus';
|
||||
|
||||
interface XRGrabbableProps {
|
||||
/**
|
||||
@@ -21,6 +23,8 @@ interface SingleGrabRecord {
|
||||
hand: 'left' | 'right';
|
||||
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
||||
offsetWorld: THREE.Vector3;
|
||||
/** Altura Y de mundo da peça no momento em que o grab iniciou */
|
||||
startWorldY: number;
|
||||
}
|
||||
|
||||
interface DualGrabRecord {
|
||||
@@ -42,18 +46,18 @@ const _tmpVecR = new THREE.Vector3();
|
||||
const _tmpQuat = new THREE.Quaternion();
|
||||
|
||||
/**
|
||||
* Touch Plus grip mapping (Meta Quest 3):
|
||||
* Mapeamento Geral do Quest 3 para Manipulação do Modelo em AR:
|
||||
*
|
||||
* • One-hand grip → **PAN ONLY** (translação). A rotação e a escala
|
||||
* da peça NÃO mudam — o grip simples só arrasta.
|
||||
* • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme
|
||||
* (distância entre os controles). O zoom está sempre ligado.
|
||||
*
|
||||
* Trigger não interfere aqui (reservado para seleção/medição).
|
||||
* • Panorâmica (Pan) -> Segurar o botão Grip (lateral) em qualquer um dos controles
|
||||
* e mover a mão na direção que deseja arrastar (movimentação livre em 3D).
|
||||
* • Giro (Rotação) e Zoom (Escala) -> Apontar ambos os controles para a bounding box do modelo,
|
||||
* segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou
|
||||
* afastar/aproximar as mãos (zoom).
|
||||
*/
|
||||
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const grab = useControllerGrab();
|
||||
const session = useXR((s) => s.session);
|
||||
|
||||
const single = useRef<SingleGrabRecord | null>(null);
|
||||
const dual = useRef<DualGrabRecord | null>(null);
|
||||
@@ -70,10 +74,76 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
single.current = null;
|
||||
}, [scaleResetNonce]);
|
||||
|
||||
useFrame(() => {
|
||||
useEffect(() => {
|
||||
const currentGroup = groupRef.current;
|
||||
activeModelGroupRef.current = currentGroup;
|
||||
return () => {
|
||||
if (activeModelGroupRef.current === currentGroup) {
|
||||
activeModelGroupRef.current = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const syncGrabToStore = () => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
const snap: ControllerGrabSnapshot = grab.current;
|
||||
|
||||
const state = useModelStore.getState();
|
||||
const activeModelId = state.activeModelId;
|
||||
if (!activeModelId) return;
|
||||
|
||||
const activeModel = state.models.find(m => m.id === activeModelId);
|
||||
if (!activeModel) return;
|
||||
|
||||
const ft = { ...activeModel.fineTuning };
|
||||
const renderFactor = state.scaleRatio?.factor ?? 1;
|
||||
|
||||
// Model's matrix relative to XRGrabbable
|
||||
const posModel = new THREE.Vector3(ft.posX * renderFactor, ft.posY * renderFactor, ft.posZ * renderFactor);
|
||||
const rotModel = new THREE.Euler(
|
||||
(ft.rotX * Math.PI) / 180,
|
||||
(ft.rotY * Math.PI) / 180,
|
||||
(ft.rotZ * Math.PI) / 180,
|
||||
'YXZ'
|
||||
);
|
||||
const quatModel = new THREE.Quaternion().setFromEuler(rotModel);
|
||||
const scaleModel = new THREE.Vector3(ft.scale * renderFactor, ft.scale * renderFactor, ft.scale * renderFactor);
|
||||
|
||||
const mModel = new THREE.Matrix4().compose(posModel, quatModel, scaleModel);
|
||||
|
||||
// Grab matrix of XRGrabbable group
|
||||
const mGrab = new THREE.Matrix4().compose(group.position, group.quaternion, group.scale);
|
||||
|
||||
// Combined matrix
|
||||
const mCombined = new THREE.Matrix4().multiplyMatrices(mGrab, mModel);
|
||||
|
||||
const finalPos = new THREE.Vector3();
|
||||
const finalQuat = new THREE.Quaternion();
|
||||
const finalScale = new THREE.Vector3();
|
||||
mCombined.decompose(finalPos, finalQuat, finalScale);
|
||||
|
||||
// Reset grabbable group to identity so the transform is held entirely by fineTuning
|
||||
group.position.set(0, 0, 0);
|
||||
group.quaternion.identity();
|
||||
group.scale.set(1, 1, 1);
|
||||
|
||||
// Save to store
|
||||
const euler = new THREE.Euler().setFromQuaternion(finalQuat, 'YXZ');
|
||||
state.setFineTuning({
|
||||
posX: finalPos.x / renderFactor,
|
||||
posY: finalPos.y / renderFactor,
|
||||
posZ: finalPos.z / renderFactor,
|
||||
rotX: (euler.x * 180) / Math.PI,
|
||||
rotY: (euler.y * 180) / Math.PI,
|
||||
rotZ: (euler.z * 180) / Math.PI,
|
||||
scale: finalScale.x / renderFactor,
|
||||
});
|
||||
};
|
||||
|
||||
useFrame((_state, dt) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
const snap = grab.current;
|
||||
const L = snap.left;
|
||||
const R = snap.right;
|
||||
|
||||
@@ -87,7 +157,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
const lActive = L.isGrabbing && L.hasPose;
|
||||
const rActive = R.isGrabbing && R.hasPose;
|
||||
|
||||
// ─── Halo intensity (visual feedback for analog grip) ────────
|
||||
// Visual feedback para a pressão analógica do grip (halo)
|
||||
if (haloRef.current) {
|
||||
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
||||
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
||||
@@ -95,7 +165,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
haloRef.current.visible = maxGrip > 0.05;
|
||||
}
|
||||
|
||||
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
||||
// ─── Modo de Duas Mãos: Giro (Órbita) + Zoom ────────────────────
|
||||
if (lActive && rActive) {
|
||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||
@@ -107,44 +177,72 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
|
||||
if (!dual.current) {
|
||||
group.updateMatrixWorld();
|
||||
dual.current = {
|
||||
startGroupWorld: group.matrixWorld.clone(),
|
||||
startMid: midNow.clone(),
|
||||
startAxis: axisNow.clone(),
|
||||
startDist: distNow,
|
||||
startScale: group.scale.x,
|
||||
|
||||
// Se o usuário já estava segurando a peça com uma das mãos, transita para o modo dual imediatamente.
|
||||
// Caso contrário (peça solta), exige que ambos os controles apontem para a bounding box da peça.
|
||||
const wasSingleGrabbing = !!single.current;
|
||||
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const checkPointerCollision = (slot: typeof L) => {
|
||||
const origin = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
|
||||
const q = new THREE.Quaternion().setFromRotationMatrix(slot.gripWorld);
|
||||
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize();
|
||||
const ray = new THREE.Ray(origin, dir);
|
||||
return ray.intersectsBox(box);
|
||||
};
|
||||
single.current = null;
|
||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
||||
|
||||
if (wasSingleGrabbing || (checkPointerCollision(L) && checkPointerCollision(R))) {
|
||||
dual.current = {
|
||||
startGroupWorld: group.matrixWorld.clone(),
|
||||
startMid: midNow.clone(),
|
||||
startAxis: axisNow.clone(),
|
||||
startDist: distNow,
|
||||
startScale: group.scale.x,
|
||||
};
|
||||
single.current = null;
|
||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom) - ' + (wasSingleGrabbing ? 'from active single grab' : 'bounding box hit'));
|
||||
} else {
|
||||
// Se não apontavam para o modelo, ignora e permite o pan de uma mão (da primeira mão a engajar)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const d = dual.current;
|
||||
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
|
||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
||||
: 1;
|
||||
if (dual.current) {
|
||||
const d = dual.current;
|
||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||
|
||||
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
|
||||
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
||||
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
||||
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
||||
const m = new THREE.Matrix4().identity()
|
||||
.multiply(tBack)
|
||||
.multiply(rot)
|
||||
.multiply(scl)
|
||||
.multiply(tToOrigin)
|
||||
.multiply(d.startGroupWorld);
|
||||
// ZOOM: afasta as mãos para zoom in, aproxima para zoom out
|
||||
const scaleRatio = allowScale
|
||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
||||
: 1;
|
||||
|
||||
applyWorldMatrixToLocal(group, m);
|
||||
return;
|
||||
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
|
||||
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
||||
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
||||
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
||||
const m = new THREE.Matrix4().identity()
|
||||
.multiply(tBack)
|
||||
.multiply(rot)
|
||||
.multiply(scl)
|
||||
.multiply(tToOrigin)
|
||||
.multiply(d.startGroupWorld);
|
||||
|
||||
applyWorldMatrixToLocal(group, m);
|
||||
return;
|
||||
}
|
||||
} else if (dual.current) {
|
||||
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||
dual.current = null;
|
||||
// Se a outra mão ainda estiver segurando, NÃO sincronizamos/resetamos o group local ainda.
|
||||
// A transição de duas mãos para uma mão herdará o groupWorldPos correto de forma contínua.
|
||||
const activeHand = lActive ? 'left' : rActive ? 'right' : null;
|
||||
if (!activeHand) {
|
||||
syncGrabToStore();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── One-hand mode (PAN ONLY) ────────────────────────────────
|
||||
// ─── Modo de Uma Mão: Panorâmica Livre (Arrastar em 3D) ────────
|
||||
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
||||
if (activeHand) {
|
||||
const slot = activeHand === 'left' ? L : R;
|
||||
@@ -154,15 +252,33 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
group.updateMatrixWorld();
|
||||
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
||||
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||
single.current = { hand: activeHand, offsetWorld };
|
||||
|
||||
single.current = { hand: activeHand, offsetWorld, startWorldY: groupWorldPos.y };
|
||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — free 3D pan`);
|
||||
}
|
||||
|
||||
// Target world position = controller + initial offset
|
||||
// Função particular improvisada: rotacionar com o analógico horizontal durante o pan
|
||||
const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null;
|
||||
const gp = inputSource?.gamepad;
|
||||
if (gp && gp.axes.length >= 4) {
|
||||
const stickX = gp.axes[2]; // thumbstick horizontal
|
||||
const deadzone = 0.15;
|
||||
if (Math.abs(stickX) > deadzone) {
|
||||
const rotationSpeed = 1.5;
|
||||
const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt;
|
||||
const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle);
|
||||
group.quaternion.multiplyQuaternions(q, group.quaternion);
|
||||
|
||||
// Gira o offset em torno da mão
|
||||
single.current.offsetWorld.applyQuaternion(q);
|
||||
}
|
||||
}
|
||||
|
||||
// Calcula a nova posição no espaço de mundo (movimentação 3D livre)
|
||||
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
||||
|
||||
// Convert to parent-local position (preserve current rotation/scale)
|
||||
// Converte para coordenadas locais do pai
|
||||
if (group.parent) {
|
||||
group.parent.updateMatrixWorld();
|
||||
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
||||
@@ -170,7 +286,9 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
}
|
||||
group.position.copy(targetWorldPos);
|
||||
} else if (single.current) {
|
||||
console.log('[XR][grab] ● ONE-HAND end');
|
||||
single.current = null;
|
||||
syncGrabToStore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -185,7 +303,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert a desired world matrix to the group's local matrix (given its parent). */
|
||||
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
|
||||
if (group.parent) {
|
||||
group.parent.updateMatrixWorld();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, forwardRef } from 'react';
|
||||
import { useRef, useState, useEffect, forwardRef } from 'react';
|
||||
import { useFrame, useThree } from '@react-three/fiber';
|
||||
import { useXRInputSourceState, useXR } from '@react-three/xr';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -11,8 +11,17 @@ import {
|
||||
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
|
||||
stopRecording, captureScreenshot, formatElapsed,
|
||||
} from '@/hooks/useCanvasRecorder';
|
||||
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
xrCalibration,
|
||||
startXRCalibration,
|
||||
cancelXRCalibration,
|
||||
subscribeXRCalibration,
|
||||
} from './xrCalibrationBus';
|
||||
import { XRViewCube } from './XRViewCube';
|
||||
|
||||
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr';
|
||||
type Tab = 'scene' | 'tools' | 'calib' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr';
|
||||
|
||||
interface XRHudInWorldProps {
|
||||
freeMove: boolean;
|
||||
@@ -75,20 +84,72 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
||||
const headLockRef = useRef<THREE.Group>(null);
|
||||
const targetPos = useRef(new THREE.Vector3());
|
||||
const targetQuat = useRef(new THREE.Quaternion());
|
||||
const lastABtn = useRef(false);
|
||||
const lastButtonsState = useRef({
|
||||
left4: false, // X
|
||||
left5: false, // Y
|
||||
left3: false, // Menu (☰)
|
||||
right4: false, // A
|
||||
right5: false, // B
|
||||
});
|
||||
/** Cached offset from controller to panel at drag start. */
|
||||
const dragOffsetPos = useRef(new THREE.Vector3());
|
||||
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
||||
const dragInitialized = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
// Toggle the head-locked 3-button group via left X button (Quest Touch buttons[4])
|
||||
const gp = leftCtrl?.inputSource?.gamepad;
|
||||
if (gp) {
|
||||
const xBtn = gp.buttons[4];
|
||||
const pressed = !!xBtn?.pressed;
|
||||
if (pressed && !lastABtn.current) setHudVisible((v) => !v);
|
||||
lastABtn.current = pressed;
|
||||
// Escuta botões A, B, X, Y e Menu do esquerdo
|
||||
const gpLeft = leftCtrl?.inputSource?.gamepad;
|
||||
const gpRight = rightCtrl?.inputSource?.gamepad;
|
||||
|
||||
let xPressed = false;
|
||||
let yPressed = false;
|
||||
let menuPressed = false;
|
||||
let aPressed = false;
|
||||
let bPressed = false;
|
||||
|
||||
if (gpLeft) {
|
||||
// Esquerdo: Menu (3), X (4), Y (5)
|
||||
const btnMenu = gpLeft.buttons[3];
|
||||
const btnX = gpLeft.buttons[4];
|
||||
const btnY = gpLeft.buttons[5];
|
||||
|
||||
if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true;
|
||||
if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true;
|
||||
if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true;
|
||||
|
||||
lastButtonsState.current.left4 = !!btnX?.pressed;
|
||||
lastButtonsState.current.left5 = !!btnY?.pressed;
|
||||
lastButtonsState.current.left3 = !!btnMenu?.pressed;
|
||||
}
|
||||
|
||||
if (gpRight) {
|
||||
// Direito: A (4), B (5)
|
||||
const btnA = gpRight.buttons[4];
|
||||
const btnB = gpRight.buttons[5];
|
||||
|
||||
if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true;
|
||||
if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true;
|
||||
|
||||
lastButtonsState.current.right4 = !!btnA?.pressed;
|
||||
lastButtonsState.current.right5 = !!btnB?.pressed;
|
||||
}
|
||||
|
||||
// Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR)
|
||||
if (xPressed && open) {
|
||||
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
|
||||
setTimeout(() => navigate('/viewer'), 50);
|
||||
toast.success("Projeto fechado!");
|
||||
} else if (aPressed || bPressed || xPressed || yPressed) {
|
||||
// Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu
|
||||
setOpen(true);
|
||||
reanchorRequested.current = true;
|
||||
}
|
||||
|
||||
// Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente
|
||||
if (menuPressed) {
|
||||
setOpen(true);
|
||||
setTab('tools');
|
||||
reanchorRequested.current = true;
|
||||
}
|
||||
|
||||
// Floating panel:
|
||||
@@ -224,17 +285,19 @@ function FloatingPanel({
|
||||
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: 'scene', label: 'Cena', icon: '🧩' },
|
||||
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
|
||||
{ id: 'calib', label: 'Calibrar', icon: '🎯' },
|
||||
{ id: 'cuts', label: 'Cortes', icon: '✂' },
|
||||
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
||||
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
||||
{ id: 'share', label: 'Compart.', icon: '📡' },
|
||||
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
|
||||
];
|
||||
const W = 0.5, H = 0.36;
|
||||
const W = 0.54, H = 0.38;
|
||||
return (
|
||||
<XR3DPanel size={[W, H]}>
|
||||
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
|
||||
anchorX="left" anchorY="middle">
|
||||
TrackSteelXR · HUD AR
|
||||
SteelXR · HUD AR
|
||||
</Text>
|
||||
{/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel.
|
||||
Group origin is at the right edge with margin; buttons extend leftward. */}
|
||||
@@ -255,15 +318,17 @@ function FloatingPanel({
|
||||
<group position={[0, H / 2 - 0.045, 0.001]}>
|
||||
{tabs.map((t, i) => (
|
||||
<XR3DButton key={t.id}
|
||||
position={[-W / 2 + 0.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
|
||||
position={[-W / 2 + 0.0495 + i * 0.063, 0, 0]} size={[0.058, 0.020]}
|
||||
label={t.label} icon={t.icon} active={tab === t.id}
|
||||
onClick={() => setTab(t.id)} fontSize={0.0072} />
|
||||
onClick={() => setTab(t.id)} fontSize={0.0062} />
|
||||
))}
|
||||
</group>
|
||||
|
||||
<group position={[0, -0.012, 0.001]}>
|
||||
<group position={[0, -0.018, 0.001]}>
|
||||
{tab === 'scene' && <SceneTab />}
|
||||
{tab === 'tools' && <ToolsTab {...p} />}
|
||||
{tab === 'calib' && <CalibrationTab />}
|
||||
{tab === 'cuts' && <CutsTab />}
|
||||
{tab === 'inspection' && <InspectionTab />}
|
||||
{tab === 'capture' && <CaptureTab />}
|
||||
{tab === 'share' && <ShareTab {...p} />}
|
||||
@@ -273,6 +338,164 @@ function FloatingPanel({
|
||||
);
|
||||
}
|
||||
|
||||
interface AxisRange { min: number; max: number; }
|
||||
type Axis = 'x' | 'y' | 'z';
|
||||
|
||||
function computeBoundsByAxis(): Record<Axis, AxisRange> | null {
|
||||
const groups = getAllModelLocalGroups();
|
||||
if (groups.length === 0) return null;
|
||||
const box = new THREE.Box3();
|
||||
let has = false;
|
||||
for (const g of groups) {
|
||||
g.updateWorldMatrix(true, true);
|
||||
const b = new THREE.Box3().setFromObject(g);
|
||||
if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) {
|
||||
if (!has) { box.copy(b); has = true; } else box.union(b);
|
||||
}
|
||||
}
|
||||
if (!has) return null;
|
||||
return {
|
||||
x: { min: box.min.x, max: box.max.x },
|
||||
y: { min: box.min.y, max: box.max.y },
|
||||
z: { min: box.min.z, max: box.max.z },
|
||||
};
|
||||
}
|
||||
|
||||
const AXIS_COLOR: Record<Axis, string> = {
|
||||
x: '#ef4444',
|
||||
y: '#10b981',
|
||||
z: '#0ea5e9',
|
||||
};
|
||||
|
||||
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
|
||||
|
||||
function CutsTab() {
|
||||
const enabled = useModelStore((s) => s.sectionEnabled);
|
||||
const invert = useModelStore((s) => s.sectionInvert);
|
||||
const level = useModelStore((s) => s.sectionLevel);
|
||||
const setSectionEnabled = useModelStore((s) => s.setSectionEnabled);
|
||||
const setSectionInvert = useModelStore((s) => s.setSectionInvert);
|
||||
const setSectionLevel = useModelStore((s) => s.setSectionLevel);
|
||||
const activeModelId = useModelStore((s) => s.activeModelId);
|
||||
|
||||
const [bounds, setBounds] = useState<Record<Axis, AxisRange> | null>(null);
|
||||
const [step, setStep] = useState<number>(0.01); // 10mm por padrão
|
||||
|
||||
useEffect(() => {
|
||||
setBounds(computeBoundsByAxis());
|
||||
}, [activeModelId]);
|
||||
|
||||
const handleStepChange = (sVal: number) => {
|
||||
setStep(sVal);
|
||||
};
|
||||
|
||||
const handleAdjust = (axis: Axis, dir: 1 | -1) => {
|
||||
const range = bounds?.[axis];
|
||||
if (!range) return;
|
||||
const currentVal = level[axis];
|
||||
const newVal = Math.max(range.min, Math.min(range.max, currentVal + dir * step));
|
||||
setSectionLevel(axis, newVal);
|
||||
};
|
||||
|
||||
const handleSetPreset = (axis: Axis, type: 'min' | 'center' | 'max') => {
|
||||
const range = bounds?.[axis];
|
||||
if (!range) return;
|
||||
if (type === 'min') {
|
||||
setSectionLevel(axis, range.min);
|
||||
} else if (type === 'center') {
|
||||
setSectionLevel(axis, (range.min + range.max) / 2);
|
||||
} else if (type === 'max') {
|
||||
setSectionLevel(axis, range.max);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecalculate = () => {
|
||||
setBounds(computeBoundsByAxis());
|
||||
};
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cabeçalho / Passo */}
|
||||
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" anchorY="middle">
|
||||
Passo do Ajuste:
|
||||
</Text>
|
||||
<XR3DButton position={[-0.09, 0.115, 0]} size={[0.048, 0.02]}
|
||||
label="1 mm" active={step === 0.001} onClick={() => handleStepChange(0.001)} fontSize={0.007} />
|
||||
<XR3DButton position={[-0.038, 0.115, 0]} size={[0.048, 0.02]}
|
||||
label="10 mm" active={step === 0.01} onClick={() => handleStepChange(0.01)} fontSize={0.007} />
|
||||
<XR3DButton position={[0.014, 0.115, 0]} size={[0.048, 0.02]}
|
||||
label="50 mm" active={step === 0.05} onClick={() => handleStepChange(0.05)} fontSize={0.007} />
|
||||
|
||||
<XR3DButton position={[0.165, 0.115, 0]} size={[0.12, 0.02]}
|
||||
label="↺ Recalcular Limites" active={false} onClick={handleRecalculate} fontSize={0.007} />
|
||||
|
||||
{/* Controles dos Eixos */}
|
||||
{(['x', 'y', 'z'] as Axis[]).map((axis, idx) => {
|
||||
const yOffset = 0.055 - idx * 0.055;
|
||||
const range = bounds?.[axis];
|
||||
const minMM = range ? range.min * 1000 : 0;
|
||||
const maxMM = range ? range.max * 1000 : 100;
|
||||
const valMM = level[axis] * 1000;
|
||||
const isAxisEnabled = enabled[axis];
|
||||
const isAxisInverted = invert[axis];
|
||||
const isAxisDisabled = !range;
|
||||
|
||||
return (
|
||||
<group key={axis} position={[0, yOffset, 0]}>
|
||||
{/* Eixo label */}
|
||||
<Text position={[-0.24, 0, 0]} fontSize={0.014} color={AXIS_COLOR[axis]} anchorX="left" anchorY="middle">
|
||||
{AXIS_LABEL[axis]}
|
||||
</Text>
|
||||
|
||||
{/* Toggle Cortar */}
|
||||
<XR3DButton position={[-0.19, 0, 0]} size={[0.045, 0.022]}
|
||||
label={isAxisEnabled ? "ON" : "OFF"} active={isAxisEnabled}
|
||||
disabled={isAxisDisabled}
|
||||
onClick={() => setSectionEnabled(axis, !isAxisEnabled)}
|
||||
fontSize={0.0075} />
|
||||
|
||||
{/* Toggle Inverter */}
|
||||
<XR3DButton position={[-0.135, 0, 0]} size={[0.052, 0.022]}
|
||||
label={isAxisInverted ? "Invertido" : "Inverter"} active={isAxisInverted}
|
||||
disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => setSectionInvert(axis, !isAxisInverted)}
|
||||
fontSize={0.007} />
|
||||
|
||||
{/* Valor */}
|
||||
<Text position={[-0.09, 0, 0]} fontSize={0.0085} color={isAxisEnabled ? "#ffffff" : "#64748b"} anchorX="left" anchorY="middle">
|
||||
{isAxisDisabled ? "sem peça" : `${valMM.toFixed(1)} mm`}
|
||||
</Text>
|
||||
|
||||
{/* Menos/Mais */}
|
||||
<XR3DButton position={[0.01, 0, 0]} size={[0.024, 0.022]}
|
||||
label="−" disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => handleAdjust(axis, -1)}
|
||||
fontSize={0.01} />
|
||||
<XR3DButton position={[0.038, 0, 0]} size={[0.024, 0.022]}
|
||||
label="+" disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => handleAdjust(axis, 1)}
|
||||
fontSize={0.01} />
|
||||
|
||||
{/* Presets */}
|
||||
<XR3DButton position={[0.076, 0, 0]} size={[0.034, 0.022]}
|
||||
label="Min" disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => handleSetPreset(axis, 'min')}
|
||||
fontSize={0.007} />
|
||||
<XR3DButton position={[0.118, 0, 0]} size={[0.042, 0.022]}
|
||||
label="Centro" disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => handleSetPreset(axis, 'center')}
|
||||
fontSize={0.007} />
|
||||
<XR3DButton position={[0.162, 0, 0]} size={[0.036, 0.022]}
|
||||
label="Max" disabled={isAxisDisabled || !isAxisEnabled}
|
||||
onClick={() => handleSetPreset(axis, 'max')}
|
||||
fontSize={0.007} />
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneTab() {
|
||||
const models = useModelStore((s) => s.models);
|
||||
const activeId = useModelStore((s) => s.activeModelId);
|
||||
@@ -356,6 +579,7 @@ function ToolsTab(p: XRHudInWorldProps) {
|
||||
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
|
||||
const resetScale = useModelStore((s) => s.resetScale);
|
||||
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
||||
const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement);
|
||||
|
||||
return (
|
||||
<group>
|
||||
@@ -414,21 +638,24 @@ function ToolsTab(p: XRHudInWorldProps) {
|
||||
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
||||
return (
|
||||
<XR3DButton key={lbl}
|
||||
position={[-0.16 + i * 0.06, -0.14, 0]} size={[0.055, 0.022]} label={lbl}
|
||||
position={[-0.16 + i * 0.054, -0.14, 0]} size={[0.05, 0.022]} label={lbl}
|
||||
active={scaleRatio.label === lbl}
|
||||
onClick={() => setScaleRatio(preset)} />
|
||||
);
|
||||
})}
|
||||
<XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
|
||||
<XR3DButton position={[0.076, -0.14, 0]} size={[0.046, 0.022]}
|
||||
label="↺ Reset" color="#dc2626"
|
||||
onClick={resetScale} fontSize={0.0085} />
|
||||
<XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
|
||||
label="✕ Medidas" color="#dc2626"
|
||||
onClick={clearMeasurements} fontSize={0.0085} />
|
||||
onClick={resetScale} fontSize={0.0075} />
|
||||
<XR3DButton position={[0.126, -0.14, 0]} size={[0.05, 0.022]}
|
||||
label="⎌ Desfaz" color="#3b82f6"
|
||||
onClick={undoLastMeasurement} fontSize={0.0075} />
|
||||
<XR3DButton position={[0.18, -0.14, 0]} size={[0.05, 0.022]}
|
||||
label="✕ Limpa" color="#dc2626"
|
||||
onClick={clearMeasurements} fontSize={0.0075} />
|
||||
|
||||
{measureMode && (
|
||||
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
||||
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
|
||||
Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -444,11 +671,11 @@ function GridFloorRow() {
|
||||
const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow);
|
||||
const setGridY = useModelStore((s) => s.setGridY);
|
||||
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
|
||||
const placeAtFloor = () => {
|
||||
// Headset eye Y minus avg standing eye height (1.6 m)
|
||||
const floor = camera.position.y - 1.6;
|
||||
setGridY(floor);
|
||||
};
|
||||
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
|
||||
const setGridCalibMode = useModelStore((s) => s.setGridCalibMode);
|
||||
const gridLandingMode = useModelStore((s) => s.gridLandingMode);
|
||||
const setGridLandingMode = useModelStore((s) => s.setGridLandingMode);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Text position={[-0.24, -0.15, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||
@@ -462,8 +689,10 @@ function GridFloorRow() {
|
||||
onClick={() => nudgeGridY(-0.01)} />
|
||||
<XR3DButton position={[0.005, -0.175, 0]} size={[0.04, 0.022]} label="+1cm"
|
||||
onClick={() => nudgeGridY(0.01)} />
|
||||
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label="Pousar chão"
|
||||
onClick={placeAtFloor} />
|
||||
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label={gridLandingMode ? "Pousando" : "Pousar"}
|
||||
active={gridLandingMode} onClick={() => setGridLandingMode(!gridLandingMode)} />
|
||||
<XR3DButton position={[0.155, -0.175, 0]} size={[0.075, 0.022]} label={gridCalibMode ? "Calibrando" : "Calibrar"}
|
||||
active={gridCalibMode} onClick={() => setGridCalibMode(!gridCalibMode)} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -718,3 +947,203 @@ function RecIndicator() {
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CalibrationTab() {
|
||||
const activeModelId = useModelStore((s) => s.activeModelId);
|
||||
const models = useModelStore((s) => s.models);
|
||||
const setCalibrationStore = useModelStore((s) => s.setCalibration);
|
||||
const active = models.find((m) => m.id === activeModelId);
|
||||
|
||||
// Escuta o barramento de calibração do XR
|
||||
const [, force] = useState(0);
|
||||
useEffect(() => subscribeXRCalibration(() => force((t) => t + 1)), []);
|
||||
|
||||
const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
|
||||
const isDone = xrCalibration.step === 'done';
|
||||
|
||||
const isCubeMode = xrCalibration.alignType === 'cube';
|
||||
const isVirtRealMode = xrCalibration.alignType === 'virt-real';
|
||||
|
||||
const onClickCube = () => {
|
||||
if (isCalibrating && isCubeMode) {
|
||||
cancelXRCalibration();
|
||||
return;
|
||||
}
|
||||
if (isDone && isCubeMode) {
|
||||
cancelXRCalibration();
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
toast.error('Selecione uma peça antes de calibrar');
|
||||
return;
|
||||
}
|
||||
if (active.locked) {
|
||||
toast.error('Peça travada — destranque a peça para calibrar');
|
||||
return;
|
||||
}
|
||||
startXRCalibration(active.id, 'cube');
|
||||
};
|
||||
|
||||
const onClickVirtReal = () => {
|
||||
if (isCalibrating && isVirtRealMode) {
|
||||
cancelXRCalibration();
|
||||
return;
|
||||
}
|
||||
if (isDone && isVirtRealMode) {
|
||||
cancelXRCalibration();
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
toast.error('Selecione uma peça antes de calibrar');
|
||||
return;
|
||||
}
|
||||
if (active.locked) {
|
||||
toast.error('Peça travada — destranque a peça para calibrar');
|
||||
return;
|
||||
}
|
||||
startXRCalibration(active.id, 'virt-real');
|
||||
};
|
||||
|
||||
const onResetCalibration = () => {
|
||||
if (!active) return;
|
||||
setCalibrationStore(active.id, null);
|
||||
toast.success('Calibração removida');
|
||||
};
|
||||
|
||||
const STEP_HINTS_AR: Record<string, string> = {
|
||||
'await-cube-1': '1/3 - Clique numa face do Cubo à direita',
|
||||
'await-model-1': '1/3 - Aponte e clique na face correspondente da peça',
|
||||
'await-cube-2': '2/3 - Clique em outra face do Cubo',
|
||||
'await-model-2': '2/3 - Clique na face correspondente da peça',
|
||||
'await-cube-3': '3/3 (Verificar) - Clique em uma 3ª face do Cubo (opcional)',
|
||||
'await-model-3': '3/3 - Clique na face correspondente da peça',
|
||||
'await-real-1': '1/3 Real - Clique na 1ª superfície da peça REAL',
|
||||
'await-virtual-1': '1/3 Virtual - Clique na 1ª correspondente da peça VIRTUAL',
|
||||
'await-real-2': '2/3 Real - Clique na 2ª superfície da peça REAL',
|
||||
'await-virtual-2': '2/3 Virtual - Clique na 2ª correspondente da peça VIRTUAL',
|
||||
'await-real-3': '3/3 Real - Clique na 3ª superfície da peça REAL',
|
||||
'await-virtual-3': '3/3 Virtual - Clique na 3ª correspondente da peça VIRTUAL',
|
||||
'done': 'Calibração concluída com sucesso!',
|
||||
};
|
||||
|
||||
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Selecione "Cubo" ou "Virt/Real" para calibrar o modelo';
|
||||
|
||||
// Configurações dinâmicas de estados de botão
|
||||
let cubeLabel = 'Cubo';
|
||||
let cubeActive = false;
|
||||
let cubeColor = '#3b82f6';
|
||||
|
||||
let vrLabel = 'Virt/Real';
|
||||
let vrActive = false;
|
||||
let vrColor = '#3b82f6';
|
||||
|
||||
if (isCalibrating) {
|
||||
if (isCubeMode) {
|
||||
cubeLabel = 'Cancelar';
|
||||
cubeActive = true;
|
||||
cubeColor = '#d97706';
|
||||
} else {
|
||||
cubeColor = '#475569';
|
||||
}
|
||||
|
||||
if (isVirtRealMode) {
|
||||
vrLabel = 'Cancelar';
|
||||
vrActive = true;
|
||||
vrColor = '#d97706';
|
||||
} else {
|
||||
vrColor = '#475569';
|
||||
}
|
||||
} else if (isDone) {
|
||||
if (isCubeMode) {
|
||||
cubeLabel = 'Concluído';
|
||||
cubeActive = true;
|
||||
cubeColor = '#22c55e';
|
||||
}
|
||||
if (isVirtRealMode) {
|
||||
vrLabel = 'Concluído';
|
||||
vrActive = true;
|
||||
vrColor = '#22c55e';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Coluna da esquerda: Informações e Fluxo */}
|
||||
<Text position={[-0.24, 0.11, 0]} fontSize={0.012} color="#3b82f6" anchorX="left" anchorY="middle">
|
||||
Calibração 3D da Peça
|
||||
</Text>
|
||||
|
||||
<Text position={[-0.24, 0.07, 0]} fontSize={0.008} color="#cbd5e1" anchorX="left" anchorY="top" maxWidth={0.28}>
|
||||
Alinhe o modelo virtual 3D com a peça real usando o Cubo ou clique em 3 pontos de controle ("Virt/Real").
|
||||
</Text>
|
||||
|
||||
{/* Caixa de status do passo atual */}
|
||||
<group position={[-0.24, 0.00, 0]}>
|
||||
<mesh position={[0.13, 0, -0.0005]}>
|
||||
<planeGeometry args={[0.26, 0.05]} />
|
||||
<meshBasicMaterial color="#111827" transparent opacity={0.65} />
|
||||
</mesh>
|
||||
<Text position={[0.01, 0, 0.001]} fontSize={0.008} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
|
||||
{currentHint}
|
||||
</Text>
|
||||
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && isCubeMode && (
|
||||
<Text position={[0.01, -0.016, 0.001]} fontSize={0.007} color="#22c55e" anchorX="left" anchorY="middle">
|
||||
Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}°
|
||||
</Text>
|
||||
)}
|
||||
</group>
|
||||
|
||||
{/* Botões na parte inferior esquerda */}
|
||||
<group position={[-0.24, -0.06, 0]}>
|
||||
<XR3DButton
|
||||
position={[0.045, 0, 0]}
|
||||
size={[0.08, 0.026]}
|
||||
label={cubeLabel}
|
||||
active={cubeActive}
|
||||
color={cubeColor}
|
||||
onClick={onClickCube}
|
||||
fontSize={0.008}
|
||||
/>
|
||||
|
||||
<XR3DButton
|
||||
position={[0.135, 0, 0]}
|
||||
size={[0.08, 0.026]}
|
||||
label={vrLabel}
|
||||
active={vrActive}
|
||||
color={vrColor}
|
||||
onClick={onClickVirtReal}
|
||||
fontSize={0.008}
|
||||
/>
|
||||
|
||||
{active?.calibrationQuat && !isCalibrating && (
|
||||
<XR3DButton
|
||||
position={[0.225, 0, 0]}
|
||||
size={[0.08, 0.026]}
|
||||
label="Limpar"
|
||||
color="#dc2626"
|
||||
onClick={onResetCalibration}
|
||||
fontSize={0.008}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
|
||||
<Text position={[-0.24, -0.11, 0]} fontSize={0.007} color="#64748b" anchorX="left" maxWidth={0.28}>
|
||||
{active ? `Peça ativa: ${active.fileName}` : 'Selecione uma peça na aba "Cena"'}
|
||||
</Text>
|
||||
|
||||
{/* Coluna da direita: Cubo de views 3D interativo */}
|
||||
<group position={[0.14, -0.01, 0.04]}>
|
||||
<XRViewCube activeModelId={activeModelId} size={0.075} />
|
||||
{/* Tripé dos eixos abaixo do cubo */}
|
||||
<group position={[0, -0.06, -0.04]} scale={[0.035, 0.035, 0.035]}>
|
||||
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
|
||||
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
|
||||
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
|
||||
</group>
|
||||
<Text position={[0, 0.065, -0.04]} fontSize={0.008} color="#cbd5e1" anchorX="center" anchorY="middle">
|
||||
Cubo de Calibração
|
||||
</Text>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef, useMemo, useEffect, useState } from 'react';
|
||||
import { useFrame, ThreeEvent } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { pushXRCubeFace } from './xrCalibrationBus';
|
||||
import { getModelLocalGroup } from '@/lib/modelTransforms';
|
||||
|
||||
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
||||
const size = 256;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
||||
grad.addColorStop(0, accent ? '#1e3a8a' : '#111827');
|
||||
grad.addColorStop(1, accent ? '#0f172a' : '#030712');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 8;
|
||||
ctx.strokeRect(4, 4, size - 8, size - 8);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '700 52px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = '#3b82f6';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fillText(label, size / 2, size / 2);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.anisotropy = 4;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
|
||||
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
|
||||
{ label: 'DIR', dir: [ 1, 0, 0] },
|
||||
{ label: 'ESQ', dir: [-1, 0, 0] },
|
||||
{ label: 'TOPO', dir: [ 0, 1, 0] },
|
||||
{ label: 'BASE', dir: [ 0,-1, 0] },
|
||||
{ label: 'FRENTE', dir: [ 0, 0, 1] },
|
||||
{ label: 'ATRÁS', dir: [ 0, 0,-1] },
|
||||
];
|
||||
|
||||
export function XRViewCube({ activeModelId, size = 0.06 }: { activeModelId: string | null; size?: number }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
const materials = useMemo(() => {
|
||||
return FACE_DEFS.map((f, i) => {
|
||||
const tex = makeFaceTexture(f.label, false);
|
||||
const mat = new THREE.MeshBasicMaterial({ map: tex });
|
||||
mat.userData.baseTex = tex;
|
||||
mat.userData.hoverTex = makeFaceTexture(f.label, true);
|
||||
mat.userData.faceIndex = i;
|
||||
return mat;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
materials.forEach((m) => {
|
||||
m.userData.baseTex?.dispose?.();
|
||||
m.userData.hoverTex?.dispose?.();
|
||||
m.dispose();
|
||||
});
|
||||
};
|
||||
}, [materials]);
|
||||
|
||||
useEffect(() => {
|
||||
materials.forEach((m, i) => {
|
||||
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
||||
m.needsUpdate = true;
|
||||
});
|
||||
}, [hover, materials]);
|
||||
|
||||
const modelQuat = useMemo(() => new THREE.Quaternion(), []);
|
||||
|
||||
const getModelWorldQuat = (): THREE.Quaternion => {
|
||||
const g = getModelLocalGroup(activeModelId);
|
||||
if (g) {
|
||||
g.updateWorldMatrix(true, false);
|
||||
g.getWorldQuaternion(modelQuat);
|
||||
return modelQuat;
|
||||
}
|
||||
modelQuat.identity();
|
||||
return modelQuat;
|
||||
};
|
||||
|
||||
useFrame(() => {
|
||||
if (groupRef.current) {
|
||||
// Sincroniza a rotação do cubo no HUD AR com a rotação do modelo ativo no mundo AR.
|
||||
// Desta forma, o cubo reflete a orientação 3D da peça.
|
||||
groupRef.current.quaternion.copy(getModelWorldQuat());
|
||||
}
|
||||
});
|
||||
|
||||
const onClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
const idx = e.face?.materialIndex;
|
||||
if (idx == null) return;
|
||||
const def = FACE_DEFS[idx];
|
||||
if (!def) return;
|
||||
const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize();
|
||||
pushXRCubeFace(dirWorld);
|
||||
};
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh
|
||||
material={materials}
|
||||
onClick={onClick}
|
||||
onPointerOver={(e) => {
|
||||
e.stopPropagation();
|
||||
const idx = e.face?.materialIndex ?? null;
|
||||
setHover(idx);
|
||||
}}
|
||||
onPointerOut={(e) => {
|
||||
e.stopPropagation();
|
||||
setHover(null);
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[size, size, size]} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
|
||||
export type XRCalibrationStep =
|
||||
| 'idle'
|
||||
| 'await-cube-1'
|
||||
| 'await-model-1'
|
||||
| 'await-cube-2'
|
||||
| 'await-model-2'
|
||||
| 'await-cube-3' // opcional verificação
|
||||
| 'await-model-3'
|
||||
| 'await-real-1'
|
||||
| 'await-virtual-1'
|
||||
| 'await-real-2'
|
||||
| 'await-virtual-2'
|
||||
| 'await-real-3'
|
||||
| 'await-virtual-3'
|
||||
| 'done';
|
||||
|
||||
interface XRPair {
|
||||
cube: THREE.Vector3;
|
||||
model: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface XRCalState {
|
||||
step: XRCalibrationStep;
|
||||
modelId: string | null;
|
||||
alignType: 'cube' | 'virt-real' | null;
|
||||
pairs: XRPair[];
|
||||
realPoints: THREE.Vector3[];
|
||||
virtualPoints: THREE.Vector3[];
|
||||
pendingCube: THREE.Vector3 | null;
|
||||
progress: number;
|
||||
verifyErrorDeg: number;
|
||||
listeners: Set<() => void>;
|
||||
}
|
||||
|
||||
export const xrCalibration: XRCalState = {
|
||||
step: 'idle',
|
||||
modelId: null,
|
||||
alignType: null,
|
||||
pairs: [],
|
||||
realPoints: [],
|
||||
virtualPoints: [],
|
||||
pendingCube: null,
|
||||
progress: 0,
|
||||
verifyErrorDeg: NaN,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
function notify() {
|
||||
xrCalibration.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function subscribeXRCalibration(fn: () => void): () => void {
|
||||
xrCalibration.listeners.add(fn);
|
||||
return () => {
|
||||
xrCalibration.listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function startXRCalibration(modelId: string, type: 'cube' | 'virt-real' = 'cube') {
|
||||
xrCalibration.modelId = modelId;
|
||||
xrCalibration.alignType = type;
|
||||
xrCalibration.pairs = [];
|
||||
xrCalibration.realPoints = [];
|
||||
xrCalibration.virtualPoints = [];
|
||||
xrCalibration.pendingCube = null;
|
||||
xrCalibration.progress = 0;
|
||||
xrCalibration.verifyErrorDeg = NaN;
|
||||
|
||||
if (type === 'cube') {
|
||||
xrCalibration.step = 'await-cube-1';
|
||||
} else {
|
||||
xrCalibration.step = 'await-real-1';
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function cancelXRCalibration() {
|
||||
xrCalibration.step = 'idle';
|
||||
xrCalibration.modelId = null;
|
||||
xrCalibration.alignType = null;
|
||||
xrCalibration.pairs = [];
|
||||
xrCalibration.realPoints = [];
|
||||
xrCalibration.virtualPoints = [];
|
||||
xrCalibration.pendingCube = null;
|
||||
xrCalibration.progress = 0;
|
||||
xrCalibration.verifyErrorDeg = NaN;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function pushXRCubeFace(dirWorld: THREE.Vector3) {
|
||||
if (xrCalibration.step === 'await-cube-1') {
|
||||
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||
xrCalibration.step = 'await-model-1';
|
||||
} else if (xrCalibration.step === 'await-cube-2') {
|
||||
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||
xrCalibration.step = 'await-model-2';
|
||||
} else if (xrCalibration.step === 'await-cube-3') {
|
||||
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||
xrCalibration.step = 'await-model-3';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function pushXRRealPoint(pointWorld: THREE.Vector3) {
|
||||
if (xrCalibration.alignType !== 'virt-real') return;
|
||||
const p = pointWorld.clone();
|
||||
if (xrCalibration.step === 'await-real-1') {
|
||||
xrCalibration.realPoints[0] = p;
|
||||
xrCalibration.step = 'await-virtual-1';
|
||||
xrCalibration.progress = 0.16;
|
||||
} else if (xrCalibration.step === 'await-real-2') {
|
||||
xrCalibration.realPoints[1] = p;
|
||||
xrCalibration.step = 'await-virtual-2';
|
||||
xrCalibration.progress = 0.5;
|
||||
} else if (xrCalibration.step === 'await-real-3') {
|
||||
xrCalibration.realPoints[2] = p;
|
||||
xrCalibration.step = 'await-virtual-3';
|
||||
xrCalibration.progress = 0.83;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function pushXRVirtualPoint(pointLocalPivot: THREE.Vector3) {
|
||||
if (xrCalibration.alignType !== 'virt-real') return;
|
||||
const p = pointLocalPivot.clone();
|
||||
if (xrCalibration.step === 'await-virtual-1') {
|
||||
xrCalibration.virtualPoints[0] = p;
|
||||
xrCalibration.step = 'await-real-2';
|
||||
xrCalibration.progress = 0.33;
|
||||
} else if (xrCalibration.step === 'await-virtual-2') {
|
||||
xrCalibration.virtualPoints[1] = p;
|
||||
xrCalibration.step = 'await-real-3';
|
||||
xrCalibration.progress = 0.66;
|
||||
} else if (xrCalibration.step === 'await-virtual-3') {
|
||||
xrCalibration.virtualPoints[2] = p;
|
||||
xrCalibration.step = 'done';
|
||||
xrCalibration.progress = 1.0;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 {
|
||||
const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg));
|
||||
const axes = [
|
||||
new THREE.Vector3(1, 0, 0),
|
||||
new THREE.Vector3(-1, 0, 0),
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
new THREE.Vector3(0, -1, 0),
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
new THREE.Vector3(0, 0, -1),
|
||||
];
|
||||
let best = v;
|
||||
let bestDot = cosT;
|
||||
for (const a of axes) {
|
||||
const d = v.dot(a);
|
||||
if (d > bestDot) {
|
||||
bestDot = d;
|
||||
best = a.clone();
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function pushXRModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) {
|
||||
if (!xrCalibration.pendingCube) return;
|
||||
const cubeWorld = xrCalibration.pendingCube;
|
||||
const inv = calGroupWorldQuat.clone().invert();
|
||||
const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize();
|
||||
const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize();
|
||||
const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12);
|
||||
xrCalibration.pairs.push({ cube: cubeLocal, model: modelLocal });
|
||||
xrCalibration.pendingCube = null;
|
||||
|
||||
if (xrCalibration.step === 'await-model-1') {
|
||||
xrCalibration.step = 'await-cube-2';
|
||||
xrCalibration.progress = 0.33;
|
||||
} else if (xrCalibration.step === 'await-model-2') {
|
||||
xrCalibration.progress = 0.75;
|
||||
xrCalibration.step = 'await-cube-3';
|
||||
} else if (xrCalibration.step === 'await-model-3') {
|
||||
xrCalibration.progress = 1;
|
||||
xrCalibration.verifyErrorDeg = computeXRVerifyError(xrCalibration.pairs);
|
||||
xrCalibration.step = 'done';
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function computeXRCalibrationQuaternion(pairs: XRPair[]): THREE.Quaternion | null {
|
||||
if (pairs.length < 2) return null;
|
||||
const n1 = pairs[0].model.clone().normalize();
|
||||
const n2raw = pairs[1].model.clone();
|
||||
const n2 = n2raw.sub(n1.clone().multiplyScalar(n2raw.dot(n1)));
|
||||
if (n2.lengthSq() < 1e-6) return null;
|
||||
n2.normalize();
|
||||
const n3 = new THREE.Vector3().crossVectors(n1, n2);
|
||||
|
||||
const c1 = pairs[0].cube.clone().normalize();
|
||||
const c2raw = pairs[1].cube.clone();
|
||||
const c2 = c2raw.sub(c1.clone().multiplyScalar(c2raw.dot(c1)));
|
||||
if (c2.lengthSq() < 1e-6) return null;
|
||||
c2.normalize();
|
||||
const c3 = new THREE.Vector3().crossVectors(c1, c2);
|
||||
|
||||
const N = new THREE.Matrix4().makeBasis(n1, n2, n3);
|
||||
const C = new THREE.Matrix4().makeBasis(c1, c2, c3);
|
||||
const Nt = N.clone().transpose();
|
||||
const R = new THREE.Matrix4().multiplyMatrices(C, Nt);
|
||||
const q = new THREE.Quaternion().setFromRotationMatrix(R);
|
||||
return q;
|
||||
}
|
||||
|
||||
export function computeXRVerifyError(pairs: XRPair[]): number {
|
||||
if (pairs.length < 3) return NaN;
|
||||
const q = computeXRCalibrationQuaternion(pairs.slice(0, 2));
|
||||
if (!q) return NaN;
|
||||
const predicted = pairs[2].model.clone().applyQuaternion(q).normalize();
|
||||
const target = pairs[2].cube.clone().normalize();
|
||||
const cos = THREE.MathUtils.clamp(predicted.dot(target), -1, 1);
|
||||
return THREE.MathUtils.radToDeg(Math.acos(cos));
|
||||
}
|
||||
|
||||
export function computeVirtRealTransform(
|
||||
v: THREE.Vector3[],
|
||||
r: THREE.Vector3[]
|
||||
): { quaternion: THREE.Quaternion; position: THREE.Vector3 } | null {
|
||||
if (v.length < 3 || r.length < 3) return null;
|
||||
|
||||
const v1 = v[0];
|
||||
const v2 = v[1];
|
||||
const v3 = v[2];
|
||||
|
||||
const r1 = r[0];
|
||||
const r2 = r[1];
|
||||
const r3 = r[2];
|
||||
|
||||
// 1. Basis local virtual
|
||||
const xv = new THREE.Vector3().subVectors(v2, v1).normalize();
|
||||
const v3_proj = new THREE.Vector3().subVectors(v3, v1);
|
||||
const dot_v = v3_proj.dot(xv);
|
||||
const yv = v3_proj.clone().sub(xv.clone().multiplyScalar(dot_v));
|
||||
if (yv.lengthSq() < 1e-6) return null;
|
||||
yv.normalize();
|
||||
const zv = new THREE.Vector3().crossVectors(xv, yv).normalize();
|
||||
|
||||
// 2. Basis de mundo real
|
||||
const xr = new THREE.Vector3().subVectors(r2, r1).normalize();
|
||||
const r3_proj = new THREE.Vector3().subVectors(r3, r1);
|
||||
const dot_r = r3_proj.dot(xr);
|
||||
const yr = r3_proj.clone().sub(xr.clone().multiplyScalar(dot_r));
|
||||
if (yr.lengthSq() < 1e-6) return null;
|
||||
yr.normalize();
|
||||
const zr = new THREE.Vector3().crossVectors(xr, yr).normalize();
|
||||
|
||||
// 3. Matriz de rotação R que mapeia virtual para real
|
||||
const Mv = new THREE.Matrix4().makeBasis(xv, yv, zv);
|
||||
const Mr = new THREE.Matrix4().makeBasis(xr, yr, zr);
|
||||
const Mv_t = Mv.clone().transpose();
|
||||
const R = Mr.clone().multiply(Mv_t);
|
||||
|
||||
const quaternion = new THREE.Quaternion().setFromRotationMatrix(R);
|
||||
|
||||
// 4. Translação T = r1 - R * v1
|
||||
const rv1 = v1.clone().applyQuaternion(quaternion);
|
||||
const position = new THREE.Vector3().subVectors(r1, rv1);
|
||||
|
||||
return { quaternion, position };
|
||||
}
|
||||
|
||||
export const activeModelGroupRef = { current: null as THREE.Group | null };
|
||||
@@ -75,7 +75,7 @@ export function startRecording() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
||||
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -123,7 +123,7 @@ export function captureScreenshot(): string | null {
|
||||
// Trigger download
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useFrame, useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
|
||||
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
|
||||
export interface GrabState {
|
||||
/** Analog grip pressure (0..1) */
|
||||
@@ -108,6 +109,10 @@ export function useControllerGrab() {
|
||||
const pose = frame.getPose(source.gripSpace, referenceSpace);
|
||||
if (pose) {
|
||||
slot.gripWorld.fromArray(pose.transform.matrix);
|
||||
const xrRig = useModelStore.getState().xrRig;
|
||||
if (xrRig) {
|
||||
slot.gripWorld.premultiply(xrRig.matrixWorld);
|
||||
}
|
||||
slot.hasPose = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,53 @@ const WASM_PATH = 'https://unpkg.com/web-ifc@0.0.57/';
|
||||
/**
|
||||
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
|
||||
*/
|
||||
function getMaterialName(ifcApi: WebIFC.IfcAPI, modelID: number, matRef: any): string | null {
|
||||
if (!matRef) return null;
|
||||
const matId = matRef.value;
|
||||
if (!matId) return null;
|
||||
try {
|
||||
const matLine = ifcApi.GetLine(modelID, matId);
|
||||
if (!matLine) return null;
|
||||
|
||||
if (matLine.Name && matLine.Name.value) {
|
||||
return matLine.Name.value;
|
||||
}
|
||||
|
||||
if (matLine.Materials) {
|
||||
for (const mRef of matLine.Materials) {
|
||||
const name = getMaterialName(ifcApi, modelID, mRef);
|
||||
if (name) return name;
|
||||
}
|
||||
}
|
||||
|
||||
if (matLine.MaterialConstituents) {
|
||||
for (const mcRef of matLine.MaterialConstituents) {
|
||||
const mcLine = ifcApi.GetLine(modelID, mcRef.value);
|
||||
if (mcLine && mcLine.Material) {
|
||||
const name = getMaterialName(ifcApi, modelID, mcLine.Material);
|
||||
if (name) return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matLine.MaterialProfileSet) {
|
||||
return getMaterialName(ifcApi, modelID, matLine.MaterialProfileSet);
|
||||
}
|
||||
if (matLine.MaterialProfiles) {
|
||||
for (const mpRef of matLine.MaterialProfiles) {
|
||||
const mpLine = ifcApi.GetLine(modelID, mpRef.value);
|
||||
if (mpLine && mpLine.Material) {
|
||||
const name = getMaterialName(ifcApi, modelID, mpLine.Material);
|
||||
if (name) return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silencia erros de atributos inexistentes
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
|
||||
const ifcApi = new WebIFC.IfcAPI();
|
||||
ifcApi.SetWasmPath(WASM_PATH, true);
|
||||
@@ -15,6 +62,87 @@ export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene>
|
||||
const data = new Uint8Array(buffer);
|
||||
const modelID = ifcApi.OpenModel(data);
|
||||
|
||||
// Mapeamento de materiais
|
||||
const elementMaterialMap = new Map<number, string>();
|
||||
try {
|
||||
const rels = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
|
||||
for (let i = 0; i < rels.size(); i++) {
|
||||
const relId = rels.get(i);
|
||||
const rel = ifcApi.GetLine(modelID, relId);
|
||||
if (rel && rel.RelatedObjects && rel.RelatingMaterial) {
|
||||
const matName = getMaterialName(ifcApi, modelID, rel.RelatingMaterial);
|
||||
if (matName) {
|
||||
for (const objRef of rel.RelatedObjects) {
|
||||
elementMaterialMap.set(objRef.value, matName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[IFC Parser] Falha ao mapear materiais:', err);
|
||||
}
|
||||
|
||||
// Mapeamento de propriedades adicionais
|
||||
const elementPropertiesMap = new Map<number, Record<string, string>>();
|
||||
try {
|
||||
const relsProp = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
|
||||
for (let i = 0; i < relsProp.size(); i++) {
|
||||
const relId = relsProp.get(i);
|
||||
const rel = ifcApi.GetLine(modelID, relId);
|
||||
if (rel && rel.RelatedObjects && rel.RelatingPropertyDefinition) {
|
||||
const propDefId = rel.RelatingPropertyDefinition.value;
|
||||
const propDef = ifcApi.GetLine(modelID, propDefId);
|
||||
|
||||
if (propDef) {
|
||||
const props: Record<string, string> = {};
|
||||
|
||||
if (propDef.HasProperties) {
|
||||
for (const pRef of propDef.HasProperties) {
|
||||
const pLine = ifcApi.GetLine(modelID, pRef.value);
|
||||
if (pLine && pLine.Name) {
|
||||
const name = pLine.Name.value;
|
||||
let value = '';
|
||||
if (pLine.NominalValue) {
|
||||
value = String(pLine.NominalValue.value);
|
||||
}
|
||||
if (name && value) {
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (propDef.Quantities) {
|
||||
for (const qRef of propDef.Quantities) {
|
||||
const qLine = ifcApi.GetLine(modelID, qRef.value);
|
||||
if (qLine && qLine.Name) {
|
||||
const name = qLine.Name.value;
|
||||
let value = '';
|
||||
if (qLine.LengthValue !== undefined) value = `${qLine.LengthValue.toFixed(1)} mm`;
|
||||
else if (qLine.AreaValue !== undefined) value = `${qLine.AreaValue.toFixed(1)} m²`;
|
||||
else if (qLine.VolumeValue !== undefined) value = `${qLine.VolumeValue.toFixed(2)} m³`;
|
||||
else if (qLine.NominalValue) value = String(qLine.NominalValue.value);
|
||||
|
||||
if (name && value) {
|
||||
props[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(props).length > 0) {
|
||||
for (const objRef of rel.RelatedObjects) {
|
||||
const existing = elementPropertiesMap.get(objRef.value) ?? {};
|
||||
elementPropertiesMap.set(objRef.value, { ...existing, ...props });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[IFC Parser] Falha ao mapear propriedades:', err);
|
||||
}
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const materials: Map<number, THREE.MeshStandardMaterial> = new Map();
|
||||
|
||||
@@ -22,11 +150,38 @@ export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene>
|
||||
const placedGeometries = mesh.geometries;
|
||||
const expressID = (mesh as unknown as { expressID?: number }).expressID ?? 0;
|
||||
|
||||
// One Group per IfcProduct → represents a single "element" (beam, plate…).
|
||||
// userData is preserved by GLTFExporter as `extras` and survives reload.
|
||||
let name = '';
|
||||
let tag = '';
|
||||
let objectType = '';
|
||||
let description = '';
|
||||
try {
|
||||
const elementLine = ifcApi.GetLine(modelID, expressID);
|
||||
if (elementLine) {
|
||||
name = elementLine.Name?.value ?? '';
|
||||
tag = elementLine.Tag?.value ?? '';
|
||||
objectType = elementLine.ObjectType?.value ?? '';
|
||||
description = elementLine.Description?.value ?? '';
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const materialName = elementMaterialMap.get(expressID) ?? '';
|
||||
const extraProps = elementPropertiesMap.get(expressID) ?? {};
|
||||
|
||||
const elementGroup = new THREE.Group();
|
||||
elementGroup.name = `ifc_${expressID}`;
|
||||
elementGroup.userData = { ifcElement: true, ifcId: expressID };
|
||||
elementGroup.userData = {
|
||||
ifcElement: true,
|
||||
ifcId: expressID,
|
||||
materialName,
|
||||
properties: {
|
||||
name,
|
||||
tag,
|
||||
objectType,
|
||||
description,
|
||||
material: materialName,
|
||||
...extraProps
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < placedGeometries.size(); i++) {
|
||||
const placedGeometry = placedGeometries.get(i);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react';
|
||||
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Users, Info } from 'lucide-react';
|
||||
import { LandingContent } from '@/components/LandingContent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
@@ -20,12 +21,13 @@ const Index = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
|
||||
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
||||
const [showLanding, setShowLanding] = useState(false);
|
||||
|
||||
const [converting, setConverting] = useState(false);
|
||||
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
||||
const { model, addModel, models, maxModels, xrSupported, setXrSupported, ifcColorMode, setIfcColorMode } = useModelStore();
|
||||
|
||||
// --- Painel de Logs Remotos em Tempo Real ---
|
||||
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: any }[]>([]);
|
||||
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: unknown }[]>([]);
|
||||
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,22 +206,32 @@ const Index = () => {
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="hidden"
|
||||
title="Importar arquivo de modelo 3D"
|
||||
onChange={handleFileUpload} />
|
||||
|
||||
|
||||
{/* Logo area */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="mb-4 flex items-center justify-center gap-3">
|
||||
<Box className="h-10 w-10 text-primary" />
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||
TrackSteel<span className="text-primary">XR</span>
|
||||
</h1>
|
||||
<div className="mb-10 flex flex-col items-center justify-center w-full">
|
||||
<div className="relative flex items-center justify-center max-w-[280px] md:max-w-[320px] w-full px-4">
|
||||
<img
|
||||
src="/logotipo_steelXR_transparente.png"
|
||||
alt="SteelXR Logo"
|
||||
className="w-full h-auto object-contain drop-shadow-[0_0_20px_rgba(251,191,36,0.18)]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -right-8 md:-right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||
onClick={() => setShowLanding(!showLanding)}
|
||||
title="Sobre"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="font-mono text-sm uppercase tracking-widest text-muted-foreground">
|
||||
Inspeção de Qualidade Industrial
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showLanding && <LandingContent />}
|
||||
|
||||
{/* Main card */}
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{/* Import button */}
|
||||
@@ -253,6 +265,50 @@ const Index = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Escolha do Filtro de Cores (IFC) */}
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card/50 p-3.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs font-semibold text-foreground">
|
||||
Modo de Cores (IFC)
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Escolha como aplicar cores aos elementos do modelo
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 bg-slate-950/40 p-1 rounded-md border border-border/40 mt-1">
|
||||
<button
|
||||
onClick={() => setIfcColorMode('none')}
|
||||
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||
ifcColorMode === 'none'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Cor Única
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIfcColorMode('material')}
|
||||
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||
ifcColorMode === 'material'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Material
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIfcColorMode('description')}
|
||||
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||
ifcColorMode === 'description'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Descrição
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo buttons */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<CloudLoader />
|
||||
@@ -304,6 +360,15 @@ const Index = () => {
|
||||
Visualizar Modelo 3D
|
||||
</Button>
|
||||
|
||||
{/* Enter virtual meeting */}
|
||||
<Button
|
||||
className="h-14 w-full gap-3 text-base font-semibold bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white shadow-lg shadow-violet-500/20 hover:shadow-violet-500/35 border-0 transition-all duration-200"
|
||||
onClick={() => navigate('/meeting')}>
|
||||
|
||||
<Users className="h-5 w-5" />
|
||||
Reunião Virtual
|
||||
</Button>
|
||||
|
||||
{/* XR Status */}
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{xrSupported === null ?
|
||||
@@ -327,7 +392,7 @@ const Index = () => {
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
||||
TrackSteelXR v1.05 — Q.C. Inspection
|
||||
SteelXR v1.13 — Q.C. Inspection
|
||||
</p>
|
||||
|
||||
{showLogs && (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2, Users, Menu, Target } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useModelStore } from "@/stores/useModelStore";
|
||||
import { xrStore } from "@/stores/useXRStore";
|
||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||
import { ViewerControls } from "@/components/ViewerControls";
|
||||
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
||||
@@ -20,18 +22,180 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
|
||||
import { validateModelFile } from "@/lib/validateModelFile";
|
||||
import { convertIFCtoGLB } from "@/lib/convertIFC";
|
||||
import { mainCameraRef, mainControlsRef } from "@/components/three/viewCubeBus";
|
||||
|
||||
const Viewer = () => {
|
||||
const navigate = useNavigate();
|
||||
const { model, xrSupported, addModel, models, maxModels, scaleRatio } = useModelStore();
|
||||
const {
|
||||
model,
|
||||
xrSupported,
|
||||
addModel,
|
||||
models,
|
||||
maxModels,
|
||||
scaleRatio,
|
||||
fineTuning,
|
||||
sectionEnabled,
|
||||
sectionInvert,
|
||||
sectionLevel,
|
||||
hoverIfcProps
|
||||
} = useModelStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [converting, setConverting] = useState(false);
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRecenter = useCallback(() => {
|
||||
const camera = mainCameraRef.current;
|
||||
const controls = mainControlsRef.current;
|
||||
if (camera) {
|
||||
camera.position.set(2, 2, 2);
|
||||
camera.up.set(0, 1, 0);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
if (controls) {
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
toast.success("Visualização centralizada");
|
||||
}, []);
|
||||
|
||||
const exitFullscreen = useCallback(() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch((err) => {
|
||||
console.error("Erro ao sair de tela cheia:", err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const roomId = searchParams.get("room");
|
||||
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
||||
|
||||
// Se não houver modelo e não estivermos em Reunião, volta para a Home
|
||||
useEffect(() => {
|
||||
if (!model && !roomId) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [model, navigate]);
|
||||
}, [model, roomId, navigate]);
|
||||
|
||||
const sendMeetingUpdate = useCallback((ft: typeof fineTuning, secEn: typeof sectionEnabled, secLevel: typeof sectionLevel, secInv: typeof sectionInvert) => {
|
||||
if (!roomId || !channelRef.current) return;
|
||||
|
||||
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => secEn[ax]);
|
||||
const isSectionActive = !!enabledAxis;
|
||||
const sectionAxis = enabledAxis || 'y';
|
||||
const sectionLevelVal = isSectionActive ? secLevel[sectionAxis] : 0.0;
|
||||
const sectionInvertVal = isSectionActive ? secInv[sectionAxis] : false;
|
||||
|
||||
// Obtém dados da maquete ativa na store
|
||||
const activeModelStore = useModelStore.getState().model;
|
||||
|
||||
const payload = {
|
||||
rotation: [ft.rotX, ft.rotY, ft.rotZ],
|
||||
scale: ft.scale,
|
||||
sectionEnabled: isSectionActive,
|
||||
sectionAxis,
|
||||
sectionLevel: sectionLevelVal,
|
||||
sectionInvert: sectionInvertVal,
|
||||
modelUrl: activeModelStore?.url,
|
||||
modelFileName: activeModelStore?.fileName,
|
||||
modelFileSize: activeModelStore?.fileSize
|
||||
};
|
||||
|
||||
console.log("📡 [Viewer Meeting Sync] Enviando atualização de apresentação:", payload);
|
||||
channelRef.current.send({
|
||||
type: 'broadcast',
|
||||
event: 'presentation_update',
|
||||
payload
|
||||
});
|
||||
}, [roomId]);
|
||||
|
||||
// Conecta ao canal do Supabase se o Viewer foi aberto no modo Reunião
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
|
||||
const finalRoomId = roomId.trim().toUpperCase();
|
||||
console.log(`🔌 [Viewer Meeting Sync] Conectando ao canal da reunião: ${finalRoomId}`);
|
||||
|
||||
const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
|
||||
config: {
|
||||
broadcast: { self: false }
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
|
||||
channel.subscribe((status) => {
|
||||
console.log(`🔌 [Viewer Meeting Sync] Status do canal: ${status}`);
|
||||
if (status === 'SUBSCRIBED') {
|
||||
const state = useModelStore.getState();
|
||||
sendMeetingUpdate(state.fineTuning, state.sectionEnabled, state.sectionLevel, state.sectionInvert);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log(`🔌 [Viewer Meeting Sync] Desconectando do canal da reunião: ${finalRoomId}`);
|
||||
channel.unsubscribe();
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [roomId, sendMeetingUpdate]);
|
||||
|
||||
// Observa mudanças primitivas nas ferramentas 3D para propagação imediata via Broadcast
|
||||
const rotX = fineTuning.rotX;
|
||||
const rotY = fineTuning.rotY;
|
||||
const rotZ = fineTuning.rotZ;
|
||||
const scaleVal = fineTuning.scale;
|
||||
|
||||
const secEnabledX = sectionEnabled.x;
|
||||
const secEnabledY = sectionEnabled.y;
|
||||
const secEnabledZ = sectionEnabled.z;
|
||||
|
||||
const secLevelX = sectionLevel.x;
|
||||
const secLevelY = sectionLevel.y;
|
||||
const secLevelZ = sectionLevel.z;
|
||||
|
||||
const secInvertX = sectionInvert.x;
|
||||
const secInvertY = sectionInvert.y;
|
||||
const secInvertZ = sectionInvert.z;
|
||||
|
||||
const activeModelFileName = model?.fileName;
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId || !channelRef.current) return;
|
||||
sendMeetingUpdate(fineTuning, sectionEnabled, sectionLevel, sectionInvert);
|
||||
}, [
|
||||
roomId,
|
||||
rotX,
|
||||
rotY,
|
||||
rotZ,
|
||||
scaleVal,
|
||||
secEnabledX,
|
||||
secEnabledY,
|
||||
secEnabledZ,
|
||||
secLevelX,
|
||||
secLevelY,
|
||||
secLevelZ,
|
||||
secInvertX,
|
||||
secInvertY,
|
||||
secInvertZ,
|
||||
activeModelFileName,
|
||||
sendMeetingUpdate,
|
||||
fineTuning,
|
||||
sectionEnabled,
|
||||
sectionLevel,
|
||||
sectionInvert
|
||||
]);
|
||||
|
||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -91,14 +255,39 @@ const Viewer = () => {
|
||||
}
|
||||
}, [addModel, models.length, maxModels]);
|
||||
|
||||
if (!model) return null;
|
||||
// Removido o bloqueio para permitir carregamento de modelo na sala
|
||||
|
||||
const handleEnterXR = async () => {
|
||||
const [enteringXR, setEnteringXR] = useState(false);
|
||||
|
||||
const handleEnterXR = useCallback(() => {
|
||||
if (!xrSupported) {
|
||||
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
|
||||
return;
|
||||
}
|
||||
navigate("/xr");
|
||||
}, [xrSupported, navigate]);
|
||||
|
||||
const handleEnterMeeting = () => {
|
||||
// Coleta o eixo habilitado de seção no Viewer
|
||||
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => sectionEnabled[ax]);
|
||||
const isSectionActive = !!enabledAxis;
|
||||
const sectionAxis = enabledAxis || 'y';
|
||||
const sectionLevelVal = isSectionActive ? sectionLevel[sectionAxis] : 0.0;
|
||||
const sectionInvertVal = isSectionActive ? sectionInvert[sectionAxis] : false;
|
||||
|
||||
// Salva o estado atual da peça
|
||||
const initialMeetingState = {
|
||||
rotation: [fineTuning.rotX, fineTuning.rotY, fineTuning.rotZ],
|
||||
scale: fineTuning.scale,
|
||||
sectionEnabled: isSectionActive,
|
||||
sectionAxis,
|
||||
sectionLevel: sectionLevelVal,
|
||||
sectionInvert: sectionInvertVal
|
||||
};
|
||||
|
||||
localStorage.setItem('tsxr_initial_meeting_state', JSON.stringify(initialMeetingState));
|
||||
toast.success('Configurações da maquete salvas. Direcionando para a reunião...');
|
||||
navigate('/meeting');
|
||||
};
|
||||
|
||||
const canAddMore = models.length < maxModels;
|
||||
@@ -110,43 +299,126 @@ const Viewer = () => {
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="hidden"
|
||||
title="Selecionar arquivo de maquete para upload"
|
||||
placeholder="Upload de maquete"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/")}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={roomId ? "default" : "icon"}
|
||||
className={roomId ? "gap-1.5 px-3 text-xs font-mono h-9 hover:bg-slate-800" : ""}
|
||||
onClick={() => roomId ? navigate(`/meeting/${roomId}`) : navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{roomId && "Voltar para Sala"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="h-5 w-5 text-primary" />
|
||||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||||
TrackSteel<span className="text-primary">XR</span>
|
||||
Steel<span className="text-primary">XR</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 border-primary/30 hover:bg-primary/10 text-primary font-mono h-9"
|
||||
onClick={handleEnterMeeting}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Reunião Virtual
|
||||
</Button>
|
||||
<ShareButton />
|
||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
||||
<Glasses className="h-4 w-4" />
|
||||
Entrar em Modo XR
|
||||
<Button className="gap-2 glow-primary h-9" disabled={!xrSupported || enteringXR} onClick={handleEnterXR}>
|
||||
{enteringXR ? <Loader2 className="h-4 w-4 animate-spin" /> : <Glasses className="h-4 w-4" />}
|
||||
{enteringXR ? 'Iniciando AR…' : 'Entrar em Modo XR'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 3D Canvas with floating controls */}
|
||||
<div className="relative flex-1">
|
||||
<div className="relative flex-1 min-w-0" id="canvas-container">
|
||||
<ModelViewerCanvas />
|
||||
<ViewCube />
|
||||
<ViewerControls />
|
||||
<SectionCutPanel />
|
||||
{hoverIfcProps && (
|
||||
<div
|
||||
className={`absolute left-4 z-40 w-72 rounded-xl border border-primary/20 bg-background/80 p-3.5 shadow-2xl backdrop-blur-md transition-all duration-300 pointer-events-none select-none font-mono text-[11px] ${
|
||||
isFullscreen ? 'top-16' : 'top-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-1.5 border-b border-primary/10 pb-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="font-semibold text-primary uppercase text-[9px] tracking-wider font-sans">Propriedades IFC</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(hoverIfcProps).map(([key, value]) => {
|
||||
if (!value) return null;
|
||||
let displayKey = key;
|
||||
if (key === 'name') displayKey = 'Nome';
|
||||
else if (key === 'tag') displayKey = 'Marca';
|
||||
else if (key === 'material') displayKey = 'Material';
|
||||
else if (key === 'objectType') displayKey = 'Tipo';
|
||||
else if (key === 'description') displayKey = 'Descrição';
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<span className="text-[8px] text-muted-foreground uppercase tracking-widest">{displayKey}</span>
|
||||
<span className="text-foreground font-semibold truncate text-[11px] max-w-[250px]" title={value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isFullscreen ? (
|
||||
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background/80 border-primary/40 hover:bg-primary/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
|
||||
onClick={handleRecenter}
|
||||
>
|
||||
<Target className="h-4 w-4 text-primary animate-pulse" />
|
||||
Centralizar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background/80 border-destructive/40 hover:bg-destructive/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
|
||||
onClick={exitFullscreen}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-destructive" />
|
||||
Retornar (ESC)
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Botão de Expandir Sidebar (visível apenas se recolhido) */}
|
||||
{!sidebarOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="absolute right-4 top-[205px] z-30 h-8 w-8 rounded-md bg-background/80 border-primary/30 text-primary shadow-md hover:bg-primary/10"
|
||||
title="Expandir menu"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ViewCube />
|
||||
<ViewerControls />
|
||||
<SectionCutPanel />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<aside className="w-80 shrink-0 border-l bg-card">
|
||||
<aside className={`transition-all duration-300 shrink-0 border-l bg-card flex flex-col ${sidebarOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 pointer-events-none border-l-0 overflow-hidden'}`}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-5">
|
||||
<div>
|
||||
@@ -154,6 +426,15 @@ const Viewer = () => {
|
||||
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Cena · {models.length}/{maxModels} peças
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title="Recolher menu"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<SceneModelList />
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||
@@ -178,11 +459,17 @@ const Viewer = () => {
|
||||
<h2 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Peça selecionada
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
||||
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
|
||||
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
|
||||
</div>
|
||||
{model ? (
|
||||
<div className="space-y-3">
|
||||
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
||||
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
|
||||
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-muted p-4 text-center bg-muted/20">
|
||||
<p className="font-mono text-xs text-muted-foreground">Nenhuma peça ativa na cena</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -214,7 +501,7 @@ const Viewer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function InfoItem({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
||||
function InfoItem({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function Watch() {
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="border-b bg-card/50 backdrop-blur px-4 py-3 flex items-center justify-between">
|
||||
<Link to="/" className="font-mono text-sm font-bold text-foreground">
|
||||
TrackSteel<span className="text-primary">XR</span>
|
||||
Steel<span className="text-primary">XR</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">SALA</span>
|
||||
@@ -159,7 +159,7 @@ export default function Watch() {
|
||||
<footer className="border-t bg-card/50 backdrop-blur px-4 py-2 flex items-center justify-center gap-2">
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
Modo somente visualização · TrackSteelXR
|
||||
Modo somente visualização · SteelXR
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,12 @@ 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 { XR, useXR, XROrigin, useXRHitTest } from '@react-three/xr';
|
||||
import { xrStore as store } from '@/stores/useXRStore';
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
|
||||
import { ArrowLeft, Download, QrCode, Crosshair, Home, Glasses, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||
import { XRHud } from '@/components/XRHud';
|
||||
@@ -22,10 +23,20 @@ import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
|
||||
import { useDevKit } from '@/devkit/useDevKit';
|
||||
import { DevPanel } from '@/devkit/DevPanel';
|
||||
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||
import { VisibilityApplier } from '@/components/three/ModelViewer';
|
||||
import { VisibilityApplier, findElementRoot, getColorForMaterialName } from '@/components/three/ModelViewer';
|
||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import {
|
||||
xrCalibration,
|
||||
pushXRModelFaceNormal,
|
||||
computeXRCalibrationQuaternion,
|
||||
subscribeXRCalibration,
|
||||
pushXRRealPoint,
|
||||
pushXRVirtualPoint,
|
||||
computeVirtRealTransform,
|
||||
activeModelGroupRef,
|
||||
} from '@/components/three/xrCalibrationBus';
|
||||
|
||||
// --- Diagnóstico XR ---
|
||||
console.log('[XR] Inicializando store...');
|
||||
@@ -35,37 +46,7 @@ if (navigator.xr) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
// `store` is now imported from `@/stores/useXRStore`
|
||||
|
||||
// ─── XRModel ───────────────────────────────────────────
|
||||
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
|
||||
@@ -100,8 +81,16 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
||||
}, [sceneModel.url, sceneModel.fileName]);
|
||||
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
const calGroupRef = useRef<THREE.Group>(null);
|
||||
const localFrameRef = useRef<THREE.Group>(null);
|
||||
const topGroupRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Escuta mudanças de calibração para re-renderizar no AR
|
||||
const [, setCalTick] = useState(0);
|
||||
useEffect(() => subscribeXRCalibration(() => setCalTick(t => t + 1)), []);
|
||||
|
||||
useEffect(() => {
|
||||
const g = ref.current;
|
||||
const g = localFrameRef.current;
|
||||
if (!g) return;
|
||||
registerModelLocalGroup(sceneModel.id, g);
|
||||
return () => unregisterModelLocalGroup(sceneModel.id, g);
|
||||
@@ -115,6 +104,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
||||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||||
const checklist = useModelStore((s) => s.checklist);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const ifcColorMode = useModelStore((s) => s.ifcColorMode);
|
||||
|
||||
const modelInfo = useMemo(() => {
|
||||
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
||||
@@ -170,7 +160,16 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
||||
} else if (allApproved) {
|
||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||
} else {
|
||||
mat.color.set(sceneModel.color);
|
||||
const root = findElementRoot(child);
|
||||
const matName = root?.userData?.materialName;
|
||||
const descName = root?.userData?.properties?.description || root?.userData?.description;
|
||||
if (ifcColorMode === 'material' && matName) {
|
||||
mat.color.set(getColorForMaterialName(matName));
|
||||
} else if (ifcColorMode === 'description' && descName) {
|
||||
mat.color.set(getColorForMaterialName(descName));
|
||||
} else {
|
||||
mat.color.set(sceneModel.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
@@ -192,7 +191,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode, ifcColorMode]);
|
||||
|
||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||
@@ -201,24 +200,124 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
||||
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||
const renderFactor = scaleRatio?.factor ?? 1;
|
||||
|
||||
const rotationEuler = useMemo(() => {
|
||||
return new THREE.Euler(rotXRad, rotYRad, rotZRad, 'YXZ');
|
||||
}, [rotXRad, rotYRad, rotZRad]);
|
||||
|
||||
const calQuatArr = sceneModel.calibrationQuat;
|
||||
const isCalibratingThis = xrCalibration.modelId === sceneModel.id && xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
|
||||
const calQuat = useMemo(() => {
|
||||
if (isCalibratingThis) return new THREE.Quaternion();
|
||||
if (!calQuatArr) return new THREE.Quaternion();
|
||||
return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]);
|
||||
}, [calQuatArr, isCalibratingThis]);
|
||||
|
||||
if (!sceneModel.visible) return null;
|
||||
if (!scene) return null;
|
||||
|
||||
return (
|
||||
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
||||
<group
|
||||
ref={ref}
|
||||
userData={{ modelId: sceneModel.id }}
|
||||
position={[
|
||||
-modelInfo.center.x + fineTuning.posX,
|
||||
-modelInfo.center.y + fineTuning.posY,
|
||||
-modelInfo.center.z + fineTuning.posZ,
|
||||
]}
|
||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||
scale={[s, s, s]}
|
||||
>
|
||||
<primitive object={scene} />
|
||||
<XRLocalModelMeasurements modelId={sceneModel.id} />
|
||||
<group
|
||||
ref={topGroupRef}
|
||||
scale={[renderFactor, renderFactor, renderFactor]}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Calibração do Grid em AR (clonando os mesmos princípios do modo viewer)
|
||||
const gridCalibMode = useModelStore.getState().gridCalibMode;
|
||||
if (gridCalibMode && e.point) {
|
||||
useModelStore.setState({
|
||||
gridY: e.point.y - 0.001,
|
||||
gridAutoFollow: false,
|
||||
gridCalibMode: false,
|
||||
showGrid: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Calibração XR da Peça: captura normal da face do modelo em espaço de mundo.
|
||||
if (
|
||||
xrCalibration.modelId === sceneModel.id &&
|
||||
xrCalibration.alignType === 'cube' &&
|
||||
(xrCalibration.step === 'await-model-1' || xrCalibration.step === 'await-model-2' || xrCalibration.step === 'await-model-3') &&
|
||||
e.face && e.object
|
||||
) {
|
||||
const n = e.face.normal.clone();
|
||||
const nm = new THREE.Matrix3().getNormalMatrix(e.object.matrixWorld);
|
||||
n.applyMatrix3(nm).normalize();
|
||||
const wq = new THREE.Quaternion();
|
||||
(calGroupRef.current ?? e.object).getWorldQuaternion(wq);
|
||||
pushXRModelFaceNormal(n, wq);
|
||||
|
||||
// Aplica/atualiza calibração se tivermos >= 2 pares
|
||||
if (xrCalibration.pairs.length >= 2) {
|
||||
const q = computeXRCalibrationQuaternion(xrCalibration.pairs);
|
||||
if (q) {
|
||||
useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Calibração Virt/Real da Peça: captura os pontos virtuais.
|
||||
if (
|
||||
xrCalibration.modelId === sceneModel.id &&
|
||||
xrCalibration.alignType === 'virt-real' &&
|
||||
(xrCalibration.step === 'await-virtual-1' || xrCalibration.step === 'await-virtual-2' || xrCalibration.step === 'await-virtual-3') &&
|
||||
localFrameRef.current
|
||||
) {
|
||||
const localPoint = localFrameRef.current.worldToLocal(e.point.clone());
|
||||
const localPivotPoint = localPoint.clone().sub(modelInfo.center);
|
||||
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
|
||||
|
||||
if (stepNum === 3 && xrCalibration.realPoints.length >= 3 && topGroupRef.current) {
|
||||
const vPoints = [...xrCalibration.virtualPoints, localPivotPoint];
|
||||
const realWorldPoints = xrCalibration.realPoints;
|
||||
const parentPoints = realWorldPoints.map(rw =>
|
||||
topGroupRef.current!.worldToLocal(rw.clone())
|
||||
);
|
||||
|
||||
const res = computeVirtRealTransform(vPoints, parentPoints);
|
||||
if (res) {
|
||||
const { quaternion, position } = res;
|
||||
useModelStore.getState().setCalibration(sceneModel.id, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]);
|
||||
useModelStore.getState().setFineTuning({
|
||||
posX: position.x,
|
||||
posY: position.y,
|
||||
posZ: position.z,
|
||||
rotX: 0,
|
||||
rotY: 0,
|
||||
rotZ: 0,
|
||||
});
|
||||
pushXRVirtualPoint(localPivotPoint);
|
||||
toast.success("Peça virtual ajustada com sucesso na peça real!");
|
||||
} else {
|
||||
toast.error("Erro ao alinhar: pontos são colineares!");
|
||||
}
|
||||
} else {
|
||||
pushXRVirtualPoint(localPivotPoint);
|
||||
toast.success(`Ponto virtual ${stepNum} registrado!`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Translação */}
|
||||
<group position={[fineTuning.posX, fineTuning.posY, fineTuning.posZ]}>
|
||||
{/* Rotação e escala em volta do centro geométrico */}
|
||||
<group
|
||||
ref={ref}
|
||||
rotation={rotationEuler}
|
||||
scale={[s, s, s]}
|
||||
>
|
||||
{/* Rotação de calibração */}
|
||||
<group ref={calGroupRef} quaternion={calQuat}>
|
||||
{/* Sistema local do modelo com pivot no centro */}
|
||||
<group ref={localFrameRef} position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]} userData={{ modelId: sceneModel.id }}>
|
||||
<primitive object={scene} />
|
||||
<XRLocalModelMeasurements modelId={sceneModel.id} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
@@ -237,6 +336,239 @@ function XRBackgroundModels() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Component that renders a laser from the camera (headset) to the grid landing target. */
|
||||
function XRGridLandingLaser({ reticlePos }: { reticlePos: THREE.Vector3 | null }) {
|
||||
const { camera } = useThree();
|
||||
const geom = useMemo(() => new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]), []);
|
||||
const mat = useMemo(() => new THREE.LineBasicMaterial({ color: '#eab308', transparent: true, opacity: 0.6 }), []);
|
||||
const lineRef = useRef<THREE.Line>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (lineRef.current && reticlePos) {
|
||||
const posAttr = geom.attributes.position as THREE.BufferAttribute;
|
||||
// Laser origin slightly below eyes
|
||||
posAttr.setXYZ(0, camera.position.x, camera.position.y - 0.05, camera.position.z);
|
||||
posAttr.setXYZ(1, reticlePos.x, reticlePos.y, reticlePos.z);
|
||||
posAttr.needsUpdate = true;
|
||||
geom.computeBoundingSphere();
|
||||
lineRef.current.visible = true;
|
||||
} else if (lineRef.current) {
|
||||
lineRef.current.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
return <primitive ref={lineRef} object={new THREE.Line(geom, mat)} />;
|
||||
}
|
||||
|
||||
/** Handles physics surface hit-testing using WebXR for grid and active model landing. */
|
||||
function XRGridLandingHandler() {
|
||||
const gridLandingMode = useModelStore((s) => s.gridLandingMode);
|
||||
const gridY = useModelStore((s) => s.gridY);
|
||||
|
||||
const reticleRef = useRef<THREE.Group>(null);
|
||||
const [reticlePos, setReticlePos] = useState<THREE.Vector3 | null>(null);
|
||||
|
||||
// Continuous hit-test from viewer center
|
||||
useXRHitTest(
|
||||
gridLandingMode
|
||||
? (results, getWorldMatrix) => {
|
||||
if (results.length === 0) {
|
||||
setReticlePos(null);
|
||||
return;
|
||||
}
|
||||
const matrixHelper = new THREE.Matrix4();
|
||||
getWorldMatrix(matrixHelper, results[0]);
|
||||
const positionHelper = new THREE.Vector3().setFromMatrixPosition(matrixHelper);
|
||||
setReticlePos(positionHelper.clone());
|
||||
}
|
||||
: undefined,
|
||||
'viewer'
|
||||
);
|
||||
|
||||
// Trata a confirmação (clique/gatilho no óculos ou controle)
|
||||
const handleSelect = useCallback(() => {
|
||||
if (!gridLandingMode || !reticlePos) return;
|
||||
|
||||
const targetY = reticlePos.y;
|
||||
const deltaY = targetY - gridY;
|
||||
|
||||
// Desloca a peça ativa na mesma diferença (deltaY) para que ela "pouse junto"
|
||||
const state = useModelStore.getState();
|
||||
const activeModel = state.models.find(m => m.id === state.activeModelId);
|
||||
if (activeModel) {
|
||||
const ft = activeModel.fineTuning;
|
||||
state.setFineTuning({
|
||||
posY: ft.posY + deltaY,
|
||||
});
|
||||
}
|
||||
|
||||
// Atualiza o grid Y no store
|
||||
useModelStore.setState({
|
||||
gridY: targetY,
|
||||
gridAutoFollow: false,
|
||||
gridLandingMode: false,
|
||||
showGrid: true,
|
||||
});
|
||||
|
||||
toast.success("Grid e peça posicionados na superfície real!");
|
||||
}, [gridLandingMode, reticlePos, gridY]);
|
||||
|
||||
// Atualiza posição da retícula a cada frame
|
||||
useFrame(() => {
|
||||
if (reticleRef.current) {
|
||||
if (reticlePos && gridLandingMode) {
|
||||
reticleRef.current.visible = true;
|
||||
reticleRef.current.position.copy(reticlePos);
|
||||
} else {
|
||||
reticleRef.current.visible = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!gridLandingMode) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<XRGridLandingLaser reticlePos={reticlePos} />
|
||||
|
||||
{/* Retícula dourada premium */}
|
||||
<group ref={reticleRef}>
|
||||
<mesh onClick={handleSelect}>
|
||||
<ringGeometry args={[0.08, 0.1, 32]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.8} />
|
||||
</mesh>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.005, 0.015, 16]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.6} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<ringGeometry args={[0.11, 0.115, 64]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.3} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Plano invisível de click no fundo */}
|
||||
<mesh
|
||||
position={[0, 0, -2]}
|
||||
onClick={handleSelect}
|
||||
visible={false}
|
||||
>
|
||||
<planeGeometry args={[10, 10]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Component that renders a laser from the camera (headset) to the virtual/real calibration target. */
|
||||
function XRVirtRealCalibLaser({ reticlePos }: { reticlePos: THREE.Vector3 | null }) {
|
||||
const { camera } = useThree();
|
||||
const geom = useMemo(() => new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]), []);
|
||||
const mat = useMemo(() => new THREE.LineBasicMaterial({ color: '#eab308', transparent: true, opacity: 0.8, depthTest: false }), []);
|
||||
const lineRef = useRef<THREE.Line>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (lineRef.current && reticlePos) {
|
||||
const posAttr = geom.attributes.position as THREE.BufferAttribute;
|
||||
// Laser origin slightly below eyes
|
||||
posAttr.setXYZ(0, camera.position.x, camera.position.y - 0.05, camera.position.z);
|
||||
posAttr.setXYZ(1, reticlePos.x, reticlePos.y, reticlePos.z);
|
||||
posAttr.needsUpdate = true;
|
||||
geom.computeBoundingSphere();
|
||||
lineRef.current.visible = true;
|
||||
} else if (lineRef.current) {
|
||||
lineRef.current.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
return <primitive ref={lineRef} object={new THREE.Line(geom, mat)} />;
|
||||
}
|
||||
|
||||
/** Handles physics surface hit-testing using WebXR for virtual-real piece alignment. */
|
||||
function XRVirtRealCalibHandler() {
|
||||
const [, force] = useState(0);
|
||||
useEffect(() => subscribeXRCalibration(() => force(t => t + 1)), []);
|
||||
|
||||
const isRealStep = xrCalibration.alignType === 'virt-real' &&
|
||||
(xrCalibration.step === 'await-real-1' ||
|
||||
xrCalibration.step === 'await-real-2' ||
|
||||
xrCalibration.step === 'await-real-3');
|
||||
|
||||
const reticleRef = useRef<THREE.Group>(null);
|
||||
const [reticlePos, setReticlePos] = useState<THREE.Vector3 | null>(null);
|
||||
|
||||
// Continuous hit-test from viewer center
|
||||
useXRHitTest(
|
||||
isRealStep
|
||||
? (results, getWorldMatrix) => {
|
||||
if (results.length === 0) {
|
||||
setReticlePos(null);
|
||||
return;
|
||||
}
|
||||
const matrixHelper = new THREE.Matrix4();
|
||||
getWorldMatrix(matrixHelper, results[0]);
|
||||
const positionHelper = new THREE.Vector3().setFromMatrixPosition(matrixHelper);
|
||||
setReticlePos(positionHelper.clone());
|
||||
}
|
||||
: undefined,
|
||||
'viewer'
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (!isRealStep || !reticlePos) return;
|
||||
|
||||
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
|
||||
pushXRRealPoint(reticlePos.clone());
|
||||
toast.success(`Ponto real ${stepNum} registrado!`);
|
||||
}, [isRealStep, reticlePos]);
|
||||
|
||||
// Update reticle position
|
||||
useFrame(() => {
|
||||
if (reticleRef.current) {
|
||||
if (reticlePos && isRealStep) {
|
||||
reticleRef.current.visible = true;
|
||||
reticleRef.current.position.copy(reticlePos);
|
||||
} else {
|
||||
reticleRef.current.visible = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!isRealStep) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<XRVirtRealCalibLaser reticlePos={reticlePos} />
|
||||
|
||||
{/* Golden reticle */}
|
||||
<group ref={reticleRef}>
|
||||
<mesh onClick={handleSelect}>
|
||||
<ringGeometry args={[0.08, 0.1, 32]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.8} depthTest={false} />
|
||||
</mesh>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.005, 0.015, 16]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.6} depthTest={false} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<ringGeometry args={[0.11, 0.115, 64]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} transparent opacity={0.3} depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Tap plane to capture select events */}
|
||||
<mesh
|
||||
position={[0, 0, -2]}
|
||||
onClick={handleSelect}
|
||||
visible={false}
|
||||
>
|
||||
<planeGeometry args={[10, 10]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders the currently-active model (the one wrapped by grab/placement). */
|
||||
function XRActiveModel() {
|
||||
const models = useModelStore((s) => s.models);
|
||||
@@ -273,8 +605,8 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||
for (const source of inputSources) {
|
||||
const gp = source.gamepad;
|
||||
if (!gp) continue;
|
||||
const gripBtn = gp.buttons[2];
|
||||
const trigBtn = gp.buttons[1];
|
||||
const trigBtn = gp.buttons[0];
|
||||
const gripBtn = 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
|
||||
@@ -569,24 +901,110 @@ function XRSnapHandler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Desktop Aligner Fallback for Virt/Real Calibration ─
|
||||
function XRVirtRealDesktopAligner() {
|
||||
const { gl, camera, scene } = useThree();
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeXRCalibration(() => setTick(t => t + 1));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isAligning = xrCalibration.alignType === 'virt-real' &&
|
||||
(xrCalibration.step === 'await-real-1' ||
|
||||
xrCalibration.step === 'await-real-2' ||
|
||||
xrCalibration.step === 'await-real-3');
|
||||
if (!isAligning) return;
|
||||
if (gl.xr.isPresenting) return;
|
||||
|
||||
const mouse = new THREE.Vector2();
|
||||
const raycaster = new THREE.Raycaster();
|
||||
|
||||
const onClick = (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;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
|
||||
const activeModelId = useModelStore.getState().activeModelId;
|
||||
const hit = intersects.find(i => {
|
||||
if (i.object.userData?.__edgeLine) return false;
|
||||
let cur: THREE.Object3D | null = i.object;
|
||||
while (cur) {
|
||||
if (cur.userData?.modelId === activeModelId) return false;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const point = hit ? hit.point.clone() : new THREE.Vector3(0, 0, 0);
|
||||
pushXRRealPoint(point);
|
||||
|
||||
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
|
||||
toast.success(`Ponto real ${stepNum} registrado (Simulado)!`);
|
||||
};
|
||||
|
||||
gl.domElement.addEventListener('click', onClick);
|
||||
return () => gl.domElement.removeEventListener('click', onClick);
|
||||
}, [gl, camera, scene, tick]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── XR Grid ───────────────────────────────────────────
|
||||
function XRGrid() {
|
||||
const showGrid = useModelStore((s) => s.showGrid);
|
||||
const gridY = useModelStore((s) => s.gridY);
|
||||
const activeModelId = useModelStore((s) => s.activeModelId);
|
||||
const activeModel = useModelStore((s) => s.models.find(m => m.id === activeModelId));
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (!showGrid || !groupRef.current) return;
|
||||
|
||||
let px = 0;
|
||||
let pz = 0;
|
||||
let ry = 0;
|
||||
|
||||
if (activeModelGroupRef.current) {
|
||||
const g = activeModelGroupRef.current;
|
||||
const wp = new THREE.Vector3();
|
||||
g.getWorldPosition(wp);
|
||||
px = wp.x;
|
||||
pz = wp.z;
|
||||
|
||||
const wq = new THREE.Quaternion();
|
||||
g.getWorldQuaternion(wq);
|
||||
const euler = new THREE.Euler().setFromQuaternion(wq, 'YXZ');
|
||||
ry = euler.y;
|
||||
} else if (activeModel) {
|
||||
px = activeModel.fineTuning.posX;
|
||||
pz = activeModel.fineTuning.posZ;
|
||||
ry = (activeModel.fineTuning.rotY * Math.PI) / 180;
|
||||
}
|
||||
|
||||
groupRef.current.position.set(px, gridY, pz);
|
||||
groupRef.current.rotation.set(0, ry, 0);
|
||||
});
|
||||
|
||||
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}
|
||||
/>
|
||||
<group ref={groupRef}>
|
||||
<Grid
|
||||
infiniteGrid
|
||||
cellSize={0.01}
|
||||
sectionSize={0.1}
|
||||
cellThickness={0.5}
|
||||
sectionThickness={1}
|
||||
cellColor="#334155"
|
||||
sectionColor="#475569"
|
||||
fadeDistance={5}
|
||||
fadeStrength={1}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,33 +1062,54 @@ const XRSession = () => {
|
||||
const devkit = useDevKit();
|
||||
const [simXR, setSimXR] = useState(false);
|
||||
const effectiveInXR = inXR || simXR;
|
||||
// Indica que enterAR() foi chamado mas a sessão ainda não começou — previne auto-return prematuro
|
||||
const [isEnteringAR, setIsEnteringAR] = useState(false);
|
||||
const [hasClickedEnter, setHasClickedEnter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) navigate('/');
|
||||
}, [model, navigate]);
|
||||
|
||||
// Detecta quando a sessão XR de fato começa
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const session = state.session;
|
||||
if (session && !inXR) {
|
||||
console.log('[XR] ✅ Sessão AR ativa!');
|
||||
setInXR(true);
|
||||
setIsEnteringAR(false);
|
||||
setAnchorMode('manual');
|
||||
toast.success('Sessão AR iniciada!');
|
||||
session.addEventListener('end', () => {
|
||||
console.log('[XR] ❌ Sessão AR encerrada');
|
||||
setInXR(false);
|
||||
setHasClickedEnter(false);
|
||||
navigate('/viewer');
|
||||
});
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [inXR, setAnchorMode]);
|
||||
}, [inXR, setAnchorMode, navigate]);
|
||||
|
||||
// Flag que indica que o usuário pediu para entrar no AR (enterAR chamado).
|
||||
// Usada para bloquear o auto-return durante o onboarding do WebXR.
|
||||
useEffect(() => {
|
||||
// Se inXR virou true mas isEnteringAR é true, a sessão começou — limpa o flag
|
||||
if (inXR && isEnteringAR) {
|
||||
setIsEnteringAR(false);
|
||||
}
|
||||
}, [inXR, isEnteringAR]);
|
||||
|
||||
// Expõe setIsEnteringAR para o Viewer via window (permite marcar "entrando" antes de navegar)
|
||||
useEffect(() => {
|
||||
(window as unknown as { __setXrEntering?: (v: boolean) => void }).__setXrEntering = setIsEnteringAR;
|
||||
}, []);
|
||||
|
||||
const handleDownloadMarker = useCallback(async () => {
|
||||
const url = await generateMarkerDownloadURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'TrackSteelXR_Marker.png';
|
||||
a.download = 'SteelXR_Marker.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||
@@ -695,20 +1134,6 @@ const XRSession = () => {
|
||||
</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" />
|
||||
@@ -734,7 +1159,10 @@ const XRSession = () => {
|
||||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||||
|
||||
<group ref={rigRef}>
|
||||
<group ref={(el) => {
|
||||
(rigRef as React.MutableRefObject<THREE.Group | null>).current = el;
|
||||
useModelStore.getState().setXRRig(el);
|
||||
}}>
|
||||
<XROrigin />
|
||||
</group>
|
||||
|
||||
@@ -778,7 +1206,8 @@ const XRSession = () => {
|
||||
<XRControllerMeasure />
|
||||
<ControllerLocomotion rigRef={rigRef} />
|
||||
<VisibilityApplier />
|
||||
|
||||
<XRGridLandingHandler />
|
||||
<XRVirtRealCalibHandler />
|
||||
|
||||
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
|
||||
<XRHudInWorld
|
||||
@@ -833,15 +1262,93 @@ const XRSession = () => {
|
||||
)}
|
||||
|
||||
<XRSnapHandler />
|
||||
<XRVirtRealDesktopAligner />
|
||||
<XRGrid />
|
||||
<XRGridAutoFollower />
|
||||
</XR>
|
||||
</Canvas>
|
||||
|
||||
{/* Onboarding do AR */}
|
||||
{!effectiveInXR && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-6">
|
||||
<div className="w-full max-w-md border border-primary/20 bg-slate-900/95 rounded-2xl p-8 shadow-2xl text-center space-y-6 glow-primary">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 items-center justify-center flex rounded-full bg-primary/10 border border-primary/30 animate-pulse">
|
||||
<Glasses className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground font-mono">
|
||||
Pronto para entrar no <span className="text-primary">Modo AR</span>?
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você está prestes a carregar o modelo em escala real 1:1 no seu ambiente físico.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Informações da Peça */}
|
||||
<div className="rounded-lg border border-border bg-slate-950/50 p-4 text-left space-y-2 font-mono text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Modelo Ativo:</span>
|
||||
<span className="text-foreground truncate max-w-[200px]" title={model.fileName}>{model.fileName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tamanho:</span>
|
||||
<span className="text-foreground">{(model.fileSize / (1024 * 1024)).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dicas Rápidas */}
|
||||
<div className="text-left space-y-2.5 rounded-lg bg-primary/5 border border-primary/15 p-4 text-xs">
|
||||
<h3 className="font-semibold text-primary font-mono flex items-center gap-1.5">
|
||||
💡 Guia de Controles Rápidos:
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-muted-foreground list-disc pl-4">
|
||||
<li><strong>Teletransporte:</strong> Thumbstick para frente. Solte para teleportar.</li>
|
||||
<li><strong>Escala Real (1:1):</strong> Aponte o feixe para a peça e teletransporte nela.</li>
|
||||
<li><strong>Mover Peça:</strong> Segure o Grip de qualquer controle para arrastar.</li>
|
||||
<li><strong>Girar/Zoom:</strong> Ambas as miras na peça, segure ambos os Grips e mova as mãos.</li>
|
||||
<li><strong>Menu do App:</strong> Botões A/B/X/Y abrem/fecham o menu flutuante.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Ações */}
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
className="h-12 text-sm font-semibold glow-primary w-full gap-2 text-primary-foreground"
|
||||
disabled={hasClickedEnter}
|
||||
onClick={async () => {
|
||||
setHasClickedEnter(true);
|
||||
try {
|
||||
await store.enterAR();
|
||||
} catch (err) {
|
||||
console.error("[XR] Falha ao iniciar AR:", err);
|
||||
toast.error("Não foi possível iniciar o modo AR. Verifique se o Quest 3 está conectado.");
|
||||
setHasClickedEnter(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasClickedEnter ? <Loader2 className="h-5 w-5 animate-spin" /> : <Glasses className="h-5 w-5 animate-bounce" />}
|
||||
{hasClickedEnter ? 'Aguardando Permissão AR...' : 'Iniciar Visualização AR'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 text-sm font-medium border-muted-foreground/20 text-muted-foreground hover:bg-slate-800 hover:text-foreground w-full"
|
||||
onClick={() => navigate('/viewer')}
|
||||
>
|
||||
Voltar ao Viewer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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' }}>
|
||||
<div className={inXR ? 'hidden' : 'contents'}>
|
||||
<XRHud
|
||||
freeMove={freeMove}
|
||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||
@@ -857,7 +1364,7 @@ const XRSession = () => {
|
||||
{/* 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' }}>
|
||||
<div className="hidden">
|
||||
<ShareButton
|
||||
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
||||
onViewerCountChange={setLiveViewers}
|
||||
|
||||
@@ -4,18 +4,18 @@ import { sendRemoteLog } from '@/lib/remoteLogger';
|
||||
|
||||
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
||||
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
||||
const XRFEAT_KEY = 'xrFeatures';
|
||||
const XRFEAT_KEY = 'xrFeatures_v2';
|
||||
const CAMERA_MODE_KEY = 'tsxr_camera_mode_v1';
|
||||
|
||||
function loadCameraMode(): 'ortho' | 'persp' {
|
||||
try {
|
||||
const raw = localStorage.getItem(CAMERA_MODE_KEY);
|
||||
if (raw === 'persp' || raw === 'ortho') return raw;
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'ortho';
|
||||
}
|
||||
function saveCameraMode(m: 'ortho' | 'persp') {
|
||||
try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch {}
|
||||
try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export interface XRFeatureFlags {
|
||||
@@ -30,19 +30,25 @@ export interface XRFeatureFlags {
|
||||
bodyTracking: boolean;
|
||||
}
|
||||
const DEFAULT_XR_FEATURES: XRFeatureFlags = {
|
||||
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
|
||||
anchors: true, meshDetection: true, depthSensing: true, layers: true,
|
||||
bodyTracking: true,
|
||||
handTracking: true,
|
||||
planeDetection: true,
|
||||
hitTest: true,
|
||||
domOverlay: true,
|
||||
anchors: true,
|
||||
meshDetection: false,
|
||||
depthSensing: false,
|
||||
layers: false,
|
||||
bodyTracking: false,
|
||||
};
|
||||
function loadXRFeatures(): XRFeatureFlags {
|
||||
try {
|
||||
const raw = localStorage.getItem(XRFEAT_KEY);
|
||||
if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { ...DEFAULT_XR_FEATURES };
|
||||
}
|
||||
function saveXRFeatures(f: XRFeatureFlags) {
|
||||
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {}
|
||||
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; }
|
||||
@@ -50,7 +56,7 @@ function loadPlacements(): Record<string, StoredPlacement> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLACEMENT_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
return {};
|
||||
}
|
||||
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
|
||||
@@ -62,7 +68,7 @@ function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat
|
||||
calibrationQuat: calibrationQuat === undefined ? prev.calibrationQuat ?? null : calibrationQuat,
|
||||
};
|
||||
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Visibility (selection-based hide/isolate) persistence ────────────
|
||||
@@ -72,7 +78,7 @@ function loadVisibilityMap(): Record<string, StoredVisibility> {
|
||||
try {
|
||||
const raw = localStorage.getItem(VISIBILITY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
return {};
|
||||
}
|
||||
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
|
||||
@@ -80,7 +86,7 @@ function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<str
|
||||
const all = loadVisibilityMap();
|
||||
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
|
||||
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
|
||||
} catch {}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
|
||||
const v = loadVisibilityMap()[fileName];
|
||||
@@ -88,6 +94,36 @@ function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<
|
||||
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
|
||||
}
|
||||
|
||||
const IFC_MAT_COLORS_KEY = 'tsxr_ifc_mat_colors_v1';
|
||||
const IFC_COLOR_MODE_KEY = 'tsxr_ifc_color_mode_v1';
|
||||
export type IFCColorMode = 'none' | 'material' | 'description';
|
||||
|
||||
function loadIfcMaterialColors(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(IFC_MAT_COLORS_KEY);
|
||||
return raw !== 'false';
|
||||
} catch (e) { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
function saveIfcMaterialColors(enabled: boolean) {
|
||||
try { localStorage.setItem(IFC_MAT_COLORS_KEY, String(enabled)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadIfcColorMode(): IFCColorMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(IFC_COLOR_MODE_KEY);
|
||||
if (raw === 'none' || raw === 'material' || raw === 'description') {
|
||||
return raw as IFCColorMode;
|
||||
}
|
||||
const legacy = localStorage.getItem(IFC_MAT_COLORS_KEY);
|
||||
if (legacy === 'false') return 'none';
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'material';
|
||||
}
|
||||
function saveIfcColorMode(mode: IFCColorMode) {
|
||||
try { localStorage.setItem(IFC_COLOR_MODE_KEY, mode); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ScaleRatio {
|
||||
@@ -260,6 +296,9 @@ interface ModelStore {
|
||||
/** When true, the next click on a model face sets grid Y to that point. */
|
||||
gridCalibMode: boolean;
|
||||
setGridCalibMode: (b: boolean) => void;
|
||||
/** When true, the VR controller/headset hit-tests real world surfaces to place grid & models. */
|
||||
gridLandingMode: boolean;
|
||||
setGridLandingMode: (b: boolean) => void;
|
||||
|
||||
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
|
||||
sectionEnabled: { x: boolean; y: boolean; z: boolean };
|
||||
@@ -295,6 +334,8 @@ interface ModelStore {
|
||||
setMeasureMode: (on: boolean) => void;
|
||||
positionMode: boolean;
|
||||
setPositionMode: (on: boolean) => void;
|
||||
walkMode: boolean;
|
||||
setWalkMode: (on: boolean) => void;
|
||||
|
||||
// ── Selection (hide/isolate/export elements) ────────────
|
||||
selectionMode: boolean;
|
||||
@@ -334,6 +375,9 @@ interface ModelStore {
|
||||
hoverInfo: HoverInfo | null;
|
||||
setHoverInfo: (info: HoverInfo | null) => void;
|
||||
|
||||
hoverIfcProps: Record<string, string> | null;
|
||||
setHoverIfcProps: (props: Record<string, string> | null) => void;
|
||||
|
||||
screenshots: string[];
|
||||
addScreenshot: (dataUrl: string) => void;
|
||||
removeScreenshot: (index: number) => void;
|
||||
@@ -343,6 +387,14 @@ interface ModelStore {
|
||||
xrFeatures: XRFeatureFlags;
|
||||
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
|
||||
resetXRFeatures: () => void;
|
||||
|
||||
ifcMaterialColors: boolean;
|
||||
setIfcMaterialColors: (enabled: boolean) => void;
|
||||
ifcColorMode: IFCColorMode;
|
||||
setIfcColorMode: (mode: IFCColorMode) => void;
|
||||
|
||||
xrRig: THREE.Group | null;
|
||||
setXRRig: (rig: THREE.Group | null) => void;
|
||||
}
|
||||
|
||||
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
|
||||
@@ -355,6 +407,9 @@ function syncActive(state: Pick<ModelStore, 'models' | 'activeModelId'>): { mode
|
||||
}
|
||||
|
||||
export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
xrRig: null,
|
||||
setXRRig: (rig) => set({ xrRig: rig }),
|
||||
|
||||
// ── Multi-model ─────────────
|
||||
models: [],
|
||||
activeModelId: null,
|
||||
@@ -555,6 +610,8 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
|
||||
gridCalibMode: false,
|
||||
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
|
||||
gridLandingMode: false,
|
||||
setGridLandingMode: (gridLandingMode) => set({ gridLandingMode }),
|
||||
|
||||
sectionEnabled: { x: false, y: false, z: false },
|
||||
sectionInvert: { x: false, y: false, z: false },
|
||||
@@ -600,9 +657,14 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
||||
|
||||
measureMode: false,
|
||||
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode })),
|
||||
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode, walkMode: false })),
|
||||
positionMode: false,
|
||||
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode })),
|
||||
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode, walkMode: false })),
|
||||
walkMode: false,
|
||||
setWalkMode: (walkMode) => {
|
||||
set({ walkMode });
|
||||
if (walkMode) set({ positionMode: false, measureMode: false, selectionMode: false, cameraMode: 'persp' });
|
||||
},
|
||||
|
||||
// ── Selection state ─────────────────────────────────────
|
||||
selectionMode: false,
|
||||
@@ -749,6 +811,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
hoverInfo: null,
|
||||
setHoverInfo: (hoverInfo) => set({ hoverInfo }),
|
||||
|
||||
hoverIfcProps: null,
|
||||
setHoverIfcProps: (hoverIfcProps) => set({ hoverIfcProps }),
|
||||
|
||||
screenshots: [],
|
||||
addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })),
|
||||
removeScreenshot: (index) => set((state) => ({
|
||||
@@ -767,4 +832,29 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
saveXRFeatures(next);
|
||||
set({ xrFeatures: next });
|
||||
},
|
||||
ifcMaterialColors: loadIfcMaterialColors(),
|
||||
setIfcMaterialColors: (enabled: boolean) => {
|
||||
saveIfcMaterialColors(enabled);
|
||||
set((state) => {
|
||||
const mode = enabled ? 'material' : 'none';
|
||||
saveIfcColorMode(mode);
|
||||
return { ifcMaterialColors: enabled, ifcColorMode: mode };
|
||||
});
|
||||
},
|
||||
ifcColorMode: loadIfcColorMode(),
|
||||
setIfcColorMode: (mode: IFCColorMode) => {
|
||||
saveIfcColorMode(mode);
|
||||
const enabled = mode === 'material';
|
||||
saveIfcMaterialColors(enabled);
|
||||
set({ ifcColorMode: mode, ifcMaterialColors: enabled });
|
||||
},
|
||||
}));
|
||||
|
||||
// Autodetecção global de suporte a WebXR
|
||||
if (typeof navigator !== 'undefined' && navigator.xr) {
|
||||
navigator.xr.isSessionSupported('immersive-ar')
|
||||
.then((supported) => useModelStore.getState().setXrSupported(supported))
|
||||
.catch(() => useModelStore.getState().setXrSupported(false));
|
||||
} else {
|
||||
useModelStore.getState().setXrSupported(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createXRStore } from '@react-three/xr';
|
||||
|
||||
function loadXRFeatures() {
|
||||
const defaults = {
|
||||
handTracking: true,
|
||||
planeDetection: true,
|
||||
hitTest: true,
|
||||
domOverlay: true,
|
||||
anchors: true,
|
||||
meshDetection: false,
|
||||
depthSensing: false,
|
||||
layers: false,
|
||||
bodyTracking: false,
|
||||
lightEstimation: false,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem('xrFeatures_v2');
|
||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch (err) {
|
||||
console.warn('[XR] Falha ao ler xrFeatures do localStorage:', err);
|
||||
}
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
# TrackSteelXR — Descritivo Publicitário para Landing Page
|
||||
|
||||
---
|
||||
|
||||
## Título de Alto Impacto
|
||||
|
||||
**Inspeção de obras em tempo real com Realidade Aumentada: meça, valide e decida — sem sair do canteiro.**
|
||||
|
||||
---
|
||||
|
||||
## 3 Benefícios Principais
|
||||
|
||||
### 1. Medições Precisas em AR com Snap Inteligente
|
||||
Vertex snap, detecção automática de arestas e furos em modelos IFC. O controlador XR identifica automaticamente vértices, arestas e furos — medições em milímetros reais sem trena ou cálculos manuais. Após 1 segundo de focalização, a medição é registrada automaticamente (dwell detection).
|
||||
|
||||
### 2. Inspeção por Checklist Digital com Gravação de Voz
|
||||
Elimine planilhas de papel. Faça inspeções de qualidade diretamente no modelo 3D, com aprovação ou reprovação de cada item, anotações por texto e gravação de voz — tudo rastreável e auditável. Relatório PDF completo gerado em segundos.
|
||||
|
||||
### 3. Cortes de Seção Interativos para Análise Estrutural
|
||||
Visualize o interior de estruturas metálicas com cortes dinâmicos nos eixos X, Y e Z — sem desmontar nada. Ideal para validar projetos complexos antes da montagem.
|
||||
|
||||
---
|
||||
|
||||
## Análise Resumida de Todos os Recursos
|
||||
|
||||
### Visualização 3D e Realidade Aumentada
|
||||
|
||||
- Renderização de modelos IFC nativamente via web-ifc (WASM)
|
||||
- Suporte a Meta Quest com controle por gatilho (trigger), botões A/B e snap toggle
|
||||
- Laser visual colorido que indica tipo de snap (verde=aresta, âmbar=furo, azul=vértice)
|
||||
- Modo Perspective e Orthographic comutável sem perda de enquadramento
|
||||
- Walk mode em primeira pessoa com teclado (WASD + Q/E para elevação)
|
||||
- Grid infinito auto-follow que se ajusta à posição Y da peça
|
||||
|
||||
### Sistema de Medição
|
||||
|
||||
- Snap inteligente a vértices, arestas e centros de furos circulares
|
||||
- Detecção automática de furos por circle-fit em geometria e por face
|
||||
- Hover label mostra diâmetro de furo ou comprimento de aresta em tempo real
|
||||
- Lock-on a alvos para medição contínua com interpolação suave
|
||||
- Dwell detection: 1 segundo de focalização registra medição automaticamente
|
||||
- Suporte a Undo/redo com teclas Z e Escape
|
||||
|
||||
### Inspeção e Checklist
|
||||
|
||||
- Checklist por item com status: Pendente, Aprovado, Reprovado
|
||||
- Anotações em texto livre por item
|
||||
- Gravação de voz por item (MediaRecorder API com áudio WebM)
|
||||
- Barra de progresso visual e contadores por status
|
||||
- Reset completo do checklist
|
||||
|
||||
### Cortes de Seção
|
||||
|
||||
- Cortes independentes nos eixos X, Y e Z
|
||||
- Slider de posicionamento com range dinâmico (baseado nos limites reais da peça)
|
||||
- Inversão de direção do corte por eixo
|
||||
- Botões de atalho: Min, Centro, Max
|
||||
- Opacidade configurável da janela de corte
|
||||
|
||||
### Calibração e Posicionamento
|
||||
|
||||
- Calibração em 3 pontos para alinhamento com referência real
|
||||
- Ajuste fino de posição (X, Y, Z) e rotação (X, Y, Z) por modelo
|
||||
- Suporte a múltiplos modelos simultâneos com controles independentes
|
||||
- Exportação de seleção para GLB
|
||||
|
||||
### Relatórios e Compartilhamento
|
||||
|
||||
- Relatório PDF completo com: info do modelo, ajustes finos, checklist, medições e screenshots
|
||||
- Screenshots da cena 3D com anotações
|
||||
- Compartilhamento via WebRTC (broadcast/source com signaling)
|
||||
- Logging remoto para diagnóstico
|
||||
|
||||
---
|
||||
|
||||
## Diferenciais do Mercado Concorrente
|
||||
|
||||
| Concorrentes Tradicionais | TrackSteelXR |
|
||||
|---------------------------|--------------|
|
||||
| Software de escritório (AutoCAD, Revit) | Visualização **no canteiro** em AR |
|
||||
| Trena e planilha manual | **Snap inteligente** que identifica geometria automaticamente |
|
||||
| Relatório em papel ou Excel | **PDF automático** com evidências visuais |
|
||||
| Medição por estimativa | **Milímetros reais** com calibração |
|
||||
| Acesso restrito a profissionais | **XR controller** — mãos livres, mobilidade total |
|
||||
| Sem suporte a IFC | **web-ifc WASM** nativo para IFC direto |
|
||||
| Checklists em papel | **Checklist digital** com voz, texto e status |
|
||||
| Sem cortes dinâmicos | **Cortes interativos** X/Y/Z em tempo real |
|
||||
|
||||
---
|
||||
|
||||
## Inovações Tecnológicas
|
||||
|
||||
1. **Pipeline IFC → WebGL nativo**: Parsing de arquivos IFC (IFC2x3, IFC4) via web-ifc (WASM), conversão em Three.js sem servidor — roda 100% no browser.
|
||||
|
||||
2. **Smart Snap 3D com dwell detection**: O sistema detecta automaticamente vértices, arestas e furos circulares por raycasting + geometria. Após 1 segundo de focalização, a medição é registrada automaticamente — zero cliques desnecessários.
|
||||
|
||||
3. **XR Controller com laser visual colorido**: Meta Quest integrado com laser que muda de cor conforme o tipo de snap (verde=aresta, âmbar=furo, azul=vértice), snap toggle por gatilho esquerdo, medição por gatilho direito, undo/clear por botões A/B.
|
||||
|
||||
4. **Section cuts em tempo real com clipping planes**: Utiliza THREE.Plane clipping para cortes per-model, com range dinâmico calculado a partir dos bounds reais da peça.
|
||||
|
||||
5. **Relatório PDF com evidências visuais**: jsPDF gera documento completo com screenshots da cena 3D, checklist com status coloridos e tabela de medições — em uma única ação.
|
||||
|
||||
6. **WebRTC broadcast para colaboração remota**: Sinalização + broadcast/source para compartilhar a visão AR com outros participantes em tempo real.
|
||||
|
||||
7. **Calibração por 3 pontos com quaternions**: Sistema de calibração que captura 3 pares de normais (world + local) e calcula o quaternion de alinhamento para posicionar o modelo no espaço real.
|
||||
|
||||
8. **Walk mode com física de colisão simples**: Navegação em primeira pessoa com detecção de limites da peça para orientação no espaço 3D.
|
||||
|
||||
---
|
||||
|
||||
## Benefícios Incontestáveis
|
||||
|
||||
- **Elimina retrabalho**: Validação dimensional no canteiro antes da montagem — erros corrigidos na hora, não no escritório.
|
||||
- **Precisão em milímetros**: Snap inteligente a geometria real do modelo IFC — não há estimativas, há dados.
|
||||
- **Rastreabilidade total**: Cada item do checklist tem status, nota, áudio e timestamp. Relatório PDF serve como documento de entrega ao cliente.
|
||||
- **Mobilidade sem precedentes**: Meta Quest + controllers = profissionais com as mãos livres para manusear a peça enquanto inspecionam.
|
||||
- **Zero dependência de servidor**: IFC roda no browser via WASM. Sem backend, sem instalação — um link funciona.
|
||||
- **Decisões em segundos**: Dwell detection registra medições automaticamente. O operador olha, focaliza 1 segundo, e o dado está salvo.
|
||||
- **Validação visual antes da compra**: Cliente vê o relatório com screenshots e sabe exatamente o que está comprando.
|
||||
|
||||
---
|
||||
|
||||
## Parágrafo Final — A Dor da Gestão
|
||||
|
||||
Atrasos por erros de medição, retrabalho por falhas de comunicação e perdas por não conformidade são o pesadelo de qualquer gestão de produção metalmecânica ou obra civil. O **TrackSteelXR** coloca o modelo 3D IFC no canteiro — em Realidade Aumentada — para que engenheiros e gestores tomem decisões precisas no momento certo. **Menos retrabalho, mais produtividade, entregas dentro do prazo.** E um relatório PDF com evidências visuais que transforma inspeção em documento de entrega.
|
||||
|
||||
**Seu próximo projeto já pode começar com a certeza de que cada peça foi validada — não por planilha, mas por realidade.**
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
],
|
||||
@@ -18,7 +19,7 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": false,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@@ -30,4 +30,14 @@ else
|
||||
echo -e "${YELLOW}ℹ️ Diretório não é um repositório Git. Pulando sincronização Git.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}🏁 Ciclo concluído com sucesso. O webhook do Coolify iniciará o deploy na VPS.${NC}\n"
|
||||
# 2. Acionar deploy diretamente no Coolify
|
||||
echo -e "${YELLOW}⚙️ Acionando deploy direto no Coolify...${NC}"
|
||||
DEPLOY_RESULT=$(docker exec coolify php artisan tinker --execute="print_r(queue_application_deployment(application: App\Models\Application::find(38), deployment_uuid: new Visus\Cuid2\Cuid2, force_rebuild: false));" 2>&1)
|
||||
|
||||
if echo "$DEPLOY_RESULT" | grep -q "queued"; then
|
||||
echo -e "${GREEN}✅ Deploy enfileirado com sucesso no Coolify!${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Falha ao enfileirar deploy no Coolify. Detalhes: $DEPLOY_RESULT${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}🏁 Ciclo concluído com sucesso.${NC}\n"
|
||||
|
||||