Compare commits

...

93 Commits

Author SHA1 Message Date
gpt-engineer-app[bot] 45d09ab7a3 Adicionou cortes de seção
X-Lovable-Edit-ID: edt-e77adaba-a2c2-4436-843c-ec9c5c701de0
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:55:09 +00:00
gpt-engineer-app[bot] ce86a280ba Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:51 +00:00
gpt-engineer-app[bot] 976d6f42da Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:41 +00:00
gpt-engineer-app[bot] ac30db8241 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:33 +00:00
gpt-engineer-app[bot] bdd5a92c17 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:15 +00:00
gpt-engineer-app[bot] e032f1f3ad Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:07 +00:00
gpt-engineer-app[bot] 1222d284e3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:59 +00:00
gpt-engineer-app[bot] 7208e2bc56 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:23 +00:00
gpt-engineer-app[bot] 7efac5448b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:09 +00:00
gpt-engineer-app[bot] eb34692fdd Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:02 +00:00
gpt-engineer-app[bot] 2754260962 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:52 +00:00
gpt-engineer-app[bot] 93c693f139 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:40 +00:00
gpt-engineer-app[bot] 649d4ecdca Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:30 +00:00
gpt-engineer-app[bot] bc55e5edd7 Adicionou Calibrar Grid
X-Lovable-Edit-ID: edt-eb99423f-57d2-4db3-95a2-c0c5ad524efa
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:36 +00:00
gpt-engineer-app[bot] 5790df1541 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:23 +00:00
gpt-engineer-app[bot] 083c9d83ae Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:12 +00:00
gpt-engineer-app[bot] c3d0f6c01d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:01 +00:00
gpt-engineer-app[bot] d02afa1797 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:47 +00:00
gpt-engineer-app[bot] a769d51d9f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:18 +00:00
gpt-engineer-app[bot] 15f2bdfbb2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:03 +00:00
gpt-engineer-app[bot] 06f0b68756 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:56 +00:00
gpt-engineer-app[bot] 69b9a6b97e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:48 +00:00
gpt-engineer-app[bot] 515cb97fbd Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:35 +00:00
gpt-engineer-app[bot] 0150bad2a8 Ajustou grid à face inferior
X-Lovable-Edit-ID: edt-9adbb18a-f106-4dcf-a001-1173b3d4655d
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:45:58 +00:00
gpt-engineer-app[bot] 6887a24f0d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:45:50 +00:00
gpt-engineer-app[bot] 15734e3713 Corrigiu snap e label do modelo
X-Lovable-Edit-ID: edt-b48b9eb0-fdf4-48af-bbd5-4fbbc7d06e5d
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:38 +00:00
gpt-engineer-app[bot] 92e91f288f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:26 +00:00
gpt-engineer-app[bot] e11fd411eb Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:20 +00:00
gpt-engineer-app[bot] 123229c66b Sincronizou cubo ao quaternion
X-Lovable-Edit-ID: edt-89f942ba-4eaa-4ce5-aae9-570b432a9c90
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:25 +00:00
gpt-engineer-app[bot] 885ea304bf Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:11 +00:00
gpt-engineer-app[bot] e444a3a20f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:04 +00:00
gpt-engineer-app[bot] d110dcb32c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:30:42 +00:00
gpt-engineer-app[bot] 5d5a637816 Added camera switcher to Canvas
X-Lovable-Edit-ID: edt-20061032-6ed9-4916-9fa5-f4792809dfa9
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:40 +00:00
gpt-engineer-app[bot] ff7f69191d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:24 +00:00
gpt-engineer-app[bot] ba87af34f4 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:12 +00:00
gpt-engineer-app[bot] 8ffb67689f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:00 +00:00
gpt-engineer-app[bot] cbeb186a60 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:46 +00:00
gpt-engineer-app[bot] 0fc3783eb9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:35 +00:00
gpt-engineer-app[bot] be95b150b2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:27 +00:00
gpt-engineer-app[bot] c285736ff2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:16 +00:00
gpt-engineer-app[bot] 2317df9aac Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:02 +00:00
gpt-engineer-app[bot] bb50821ec5 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:52 +00:00
gpt-engineer-app[bot] 075ad7aeb8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:38 +00:00
gpt-engineer-app[bot] 7ff09b918b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:31 +00:00
gpt-engineer-app[bot] f3c958b78c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:23 +00:00
gpt-engineer-app[bot] 8c4483ecf6 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:17 +00:00
gpt-engineer-app[bot] 3bf90dafd7 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:23:29 +00:00
gpt-engineer-app[bot] 60af63393a Moved hooks before early returns
X-Lovable-Edit-ID: edt-c703c880-46fb-435e-95fc-c26b2f4ac503
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:11:43 +00:00
gpt-engineer-app[bot] 704b878691 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:11:36 +00:00
gpt-engineer-app[bot] ade8119590 Ajustou cubo para 70%
X-Lovable-Edit-ID: edt-aa853db0-95f5-4dd2-83b8-186372652765
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:41 +00:00
gpt-engineer-app[bot] 4b2baa3fb4 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:25 +00:00
gpt-engineer-app[bot] 4942030721 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:10 +00:00
gpt-engineer-app[bot] eee5b80d34 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:58 +00:00
gpt-engineer-app[bot] 9b450d2c35 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:18 +00:00
gpt-engineer-app[bot] b3ddfb2939 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:01 +00:00
gpt-engineer-app[bot] ad318ea52e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:52 +00:00
gpt-engineer-app[bot] 5e7ff33a3e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:43 +00:00
gpt-engineer-app[bot] ce642ff505 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:21 +00:00
gpt-engineer-app[bot] ca12fffbf6 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:50 +00:00
gpt-engineer-app[bot] d6f684fc3c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:39 +00:00
gpt-engineer-app[bot] 49fb9cff15 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:31 +00:00
gpt-engineer-app[bot] 936e631b96 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:20 +00:00
gpt-engineer-app[bot] 27c12d34f9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:10 +00:00
gpt-engineer-app[bot] 37828d75f2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:00 +00:00
gpt-engineer-app[bot] 6757100f92 Adicionou ViewCube em Viewer
X-Lovable-Edit-ID: edt-9f7240de-a34f-415d-9dd8-de7b20a1f746
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:23 +00:00
gpt-engineer-app[bot] a19fcdf792 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:12 +00:00
gpt-engineer-app[bot] f1ed0248d0 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:00 +00:00
gpt-engineer-app[bot] faec7d3e4a Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:45 +00:00
gpt-engineer-app[bot] ef2199b972 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:27 +00:00
gpt-engineer-app[bot] 8b70b16fde Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:20 +00:00
gpt-engineer-app[bot] 7d8520fd32 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:13 +00:00
gpt-engineer-app[bot] 634baa4e1f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:46 +00:00
gpt-engineer-app[bot] 5f9f5b5bc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:37 +00:00
gpt-engineer-app[bot] 29c6c765d3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:23 +00:00
gpt-engineer-app[bot] f4adaf88f9 Adicionou bloqueio via cadeado
X-Lovable-Edit-ID: edt-0a8ebb1f-ee9d-4adb-a6db-b4d3150b9bbe
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:34:24 +00:00
gpt-engineer-app[bot] cbfeee3078 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:34:13 +00:00
gpt-engineer-app[bot] 3b42d44ff7 Atualizou visual de bordas
X-Lovable-Edit-ID: edt-f5acd6ab-7778-44a9-9aae-716f987b2f62
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:57 +00:00
gpt-engineer-app[bot] 000d4d05e8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:44 +00:00
gpt-engineer-app[bot] e5e1eac733 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:29 +00:00
gpt-engineer-app[bot] 4cf157b7cd Adicionou botão Sair AR
X-Lovable-Edit-ID: edt-f602c6ac-5eb0-4c32-9ea0-9d1b4f559559
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:22:31 +00:00
gpt-engineer-app[bot] dda94bd107 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:22:23 +00:00
gpt-engineer-app[bot] 5bea0ff9ee Ajustou cota e estabilizou snap
X-Lovable-Edit-ID: edt-c6d88e59-2709-4850-9df5-a42a0e7fce71
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:50 +00:00
gpt-engineer-app[bot] 7918f5f0b1 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:31 +00:00
gpt-engineer-app[bot] 1d788cdca3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:21 +00:00
gpt-engineer-app[bot] 7203127a1d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:03 +00:00
gpt-engineer-app[bot] f603823c91 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:16:14 +00:00
gpt-engineer-app[bot] 718da6ecc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:16:07 +00:00
gpt-engineer-app[bot] e2f2eb41db Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:15:36 +00:00
gpt-engineer-app[bot] 52ef40f8fb Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:15:14 +00:00
gpt-engineer-app[bot] 9970571fad Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:14:51 +00:00
gpt-engineer-app[bot] d58d506af8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:13:44 +00:00
gpt-engineer-app[bot] aeecd4d7ec Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:12:18 +00:00
gpt-engineer-app[bot] 79b95ce3c8 Reverted to commit c93e56640d 2026-05-24 18:56:17 +00:00
22 changed files with 2452 additions and 2037 deletions
+38 -72
View File
@@ -1,81 +1,47 @@
## Objetivo
Alinhar o comportamento dos controles Touch Plus (Meta Quest 3) ao padrão pedido:
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.
- **Grip** = único responsável por mover a peça (pan, zoom, orbit).
- **Trigger** = apenas seleção, medição e ações pontuais (nunca move/rotaciona a peça).
- **Thumbstick** = navegação do usuário (teletransporte/recuar/girar a câmera), nunca mais ajuste fino do modelo.
- **Botão Menu** = mantém comportamento nativo (não tocar).
## UX
## Mapeamento alvo
### 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`).
```text
Mão Botão Ação
─────────────────────────────────────────────────────────
Esquerda/Direita Grip (1 mão) Pan da peça (translação 6DOF, sem rotação)
Ambas as mãos Grip + afastar Zoom uniforme (escala)
Ambas as mãos Grip + giro oposto Orbitar/rotacionar a peça
Direita Trigger Selecionar componente / adicionar ponto de medida
Esquerda Trigger Alternar snap de vértice (já existe)
Direita Botão A Desfazer última medida (já existe)
Direita Botão B Limpar medidas (já existe)
Thumbstick Frente + soltar Teletransporte para o ponto apontado
Thumbstick Lados Snap-rotate da câmera (±30°)
Thumbstick Trás Recuar o usuário (passo)
```
### 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.
## Mudanças no código
## Comportamento técnico
### 1. `src/components/three/XRGrabbable.tsx`
- `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.21)
- 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".
- **One-hand grip**: passar a aplicar **apenas translação** (pan). Hoje aplica matriz completa do grip → leva rotação junto. Vou capturar o offset de posição e atualizar só `group.position`, mantendo `quaternion` e `scale`.
- **Two-hand grip**: manter rotação pelo eixo entre as mãos + **forçar `allowScale = true` sempre** (zoom passa a vir do grip, não depende mais do toggle do HUD).
- Remover a prop `allowScale` da assinatura (ou ignorar e tratar como `true`).
## Arquivos afetados
### 2. `src/pages/XRSession.tsx`
- Remover o estado `allowScale` e os toggles correspondentes nas duas instâncias de `<XRGrabbable>`.
- Substituir `<ControllerFineTuning />` por um novo `<ControllerLocomotion />` (ver item 4) — o joystick não deve mais editar `fineTuning` do modelo.
- Garantir que `XRHitTestPlacement` só consome `onSelect` (trigger) quando `placementMode === true`; fora disso o trigger é roteado integralmente para `XRControllerMeasure`.
### 3. `src/components/XRHud.tsx` e `src/components/three/XRHudInWorld.tsx`
- Remover botão/toggle “Permitir escala” (`allowScale`) — agora é sempre via grip duplo.
- Remover/relabelar o toggle “Movimento livre” (`freeMove`) já que o joystick deixa de ajustar o modelo; manter apenas se ainda fizer sentido para outra função (caso não, remover prop também de `XRSession.tsx`).
### 4. Novo `src/components/three/ControllerLocomotion.tsx`
Componente que substitui `ControllerFineTuning`. Lê `useXR().session` e implementa:
- **Joystick esquerdo (ou ambos, configurável)**:
- `axes[1] < -0.7` mantido por ~200 ms → dispara teleport: raycast a partir do controle direito contra `scene.children` no plano horizontal mais próximo; aplica `xrOrigin.position` para reposicionar.
- `axes[1] > 0.7` → recua o `XROrigin` em passos de 0.3 m por trigger.
- `axes[0]` cruzando ±0.7 (com histerese) → snap-rotate de ±30° no `XROrigin.rotation.y`.
- Usar a API `XROrigin` já montada em `XRSession.tsx` (referência via `useRef` ou store) para escrever pose.
- Desligar tudo se `lockedActive` (modelo travado) — locomoção do usuário ainda funciona, só não move o modelo.
### 5. `src/components/three/XRControllerMeasure.tsx`
- Sem mudanças funcionais — já usa apenas trigger e botões A/B. Apenas confirmar que o early-return `(!measureMode && !selectionMode)` continua: quando nenhum dos modos está ativo, o trigger não dispara nada relacionado à medição (e não interfere em `XRHitTestPlacement`, que respeita `placementMode`).
### 6. `src/components/three/XRHitTestPlacement.tsx`
- Garantir que o handler `onSelect` (trigger) verifica `placementMode` antes de colocar o modelo. Hoje o tap-plane invisível pode estar capturando o select mesmo fora do placement — adicionar guarda explícita e `visible={placementMode}`.
## Validação
1. **Desktop SimXR** (devkit): pressionar Q/W (grip simulado) move a peça; pressionar A/S (trigger simulado) não muda pose nenhuma.
2. **Quest 3 real (passthrough)**:
- Grip único arrasta a peça sem rotacionar.
- Dois grips afastando = zoom; dois grips em rotação oposta = orbit.
- Trigger direito fora dos modos medir/selecionar/colocar = nada acontece.
- Trigger direito em modo Selecionar = seleciona componente IFC.
- Trigger direito em modo Medir = adiciona ponto com snap.
- Joystick esquerdo para frente = teletransporte; lados = snap-rotate; trás = recua.
3. Logs `[XR][grab]` continuam mostrando início/fim de one-hand e two-hand.
## Fora do escopo
- Não mexer no botão Menu (Universal Menu do Quest é nativo).
- Não alterar handlers DOM/mouse do modo desktop (Viewer).
- Não tocar em RLS, store de dados ou backend.
- `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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "1.0.6",
"version": "1.0.4",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import Viewer from "./pages/Viewer";
import XRSession from "./pages/XRSession";
import Watch from "./pages/Watch";
import NotFound from "./pages/NotFound";
import "@/lib/remoteLogger";
@@ -20,6 +21,7 @@ const App = () => (
<Routes>
<Route path="/" element={<Index />} />
<Route path="/viewer" element={<Viewer />} />
<Route path="/xr" element={<XRSession />} />
<Route path="/watch/:code" element={<Watch />} />
<Route path="*" element={<NotFound />} />
</Routes>
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { X, RefreshCw, Scissors } from 'lucide-react';
import * as THREE from 'three';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { useModelStore } from '@/stores/useModelStore';
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
type Axis = 'x' | 'y' | 'z';
interface AxisRange { min: number; max: number; }
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: 'text-red-400',
y: 'text-emerald-400',
z: 'text-sky-400',
};
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
export function SectionCutPanel() {
const open = useModelStore((s) => s.sectionPanelOpen);
const opacity = useModelStore((s) => s.sectionPanelOpacity);
const setOpen = useModelStore((s) => s.setSectionPanelOpen);
const setOpacity = useModelStore((s) => s.setSectionPanelOpacity);
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);
// Compute bounds when the panel opens or active model changes.
useEffect(() => {
if (!open) return;
// Defer one tick to let scene finish updating world matrices.
const id = setTimeout(() => setBounds(computeBoundsByAxis()), 50);
return () => clearTimeout(id);
}, [open, activeModelId]);
// Drag-to-move logic.
const [pos, setPos] = useState<{ x: number; y: number }>({ x: 16, y: 16 });
const dragRef = useRef<{ ox: number; oy: number; sx: number; sy: number } | null>(null);
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (!dragRef.current) return;
setPos({
x: Math.max(8, dragRef.current.sx + (e.clientX - dragRef.current.ox)),
y: Math.max(8, dragRef.current.sy + (e.clientY - dragRef.current.oy)),
});
};
const onUp = () => { dragRef.current = null; };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, []);
const recompute = () => setBounds(computeBoundsByAxis());
if (!open) return null;
return (
<div
className="absolute z-30 w-[320px] rounded-lg border bg-card shadow-2xl backdrop-blur-md"
style={{ right: pos.x, top: pos.y, opacity }}
>
{/* Header (drag handle) */}
<div
className="flex cursor-move items-center justify-between gap-2 border-b px-3 py-2 select-none"
onPointerDown={(e) => {
dragRef.current = { ox: e.clientX, oy: e.clientY, sx: pos.x, sy: pos.y };
}}
>
<div className="flex items-center gap-2">
<Scissors className="h-3.5 w-3.5 text-primary" />
<span className="font-mono text-xs font-semibold uppercase tracking-widest text-foreground">
Cortes de Seção
</span>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpen(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Window opacity */}
<div className="border-b px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Opacidade da janela
</span>
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
</div>
<Slider
value={[Math.round(opacity * 100)]}
min={20}
max={100}
step={5}
onValueChange={([v]) => setOpacity(v / 100)}
/>
</div>
{/* Per-axis controls */}
<div className="space-y-3 px-3 py-3">
{(['x', 'y', 'z'] as Axis[]).map((axis) => {
const range = bounds?.[axis];
const minMM = range ? range.min * 1000 : 0;
const maxMM = range ? range.max * 1000 : 100;
const valMM = level[axis] * 1000;
const disabled = !range;
return (
<div key={axis} className="rounded-md border bg-muted/30 p-2.5">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-bold ${AXIS_COLOR[axis]}`}>
{AXIS_LABEL[axis]}
</span>
<Switch
checked={enabled[axis]}
onCheckedChange={(c) => setSectionEnabled(axis, c)}
disabled={disabled}
/>
</div>
<label className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-muted-foreground">Inverter</span>
<Switch
checked={invert[axis]}
onCheckedChange={(c) => setSectionInvert(axis, c)}
disabled={disabled || !enabled[axis]}
/>
</label>
</div>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">
{disabled ? '— sem peça' : `${minMM.toFixed(0)}${maxMM.toFixed(0)} mm`}
</span>
<span className="font-mono text-[10px] text-foreground">
{disabled ? '—' : `${valMM.toFixed(1)} mm`}
</span>
</div>
<Slider
value={[range ? Math.min(maxMM, Math.max(minMM, valMM)) : 0]}
min={minMM}
max={maxMM}
step={Math.max(0.1, (maxMM - minMM) / 1000)}
onValueChange={([v]) => setSectionLevel(axis, v / 1000)}
disabled={disabled || !enabled[axis]}
/>
<div className="mt-2 grid grid-cols-3 gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.min)}
>
Min
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, (range.min + range.max) / 2)}
>
Centro
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.max)}
>
Max
</Button>
</div>
</div>
);
})}
</div>
<div className="border-t px-3 py-2">
<Button variant="outline" size="sm" className="w-full gap-2 h-8" onClick={recompute}>
<RefreshCw className="h-3.5 w-3.5" />
<span className="font-mono text-[11px]">Recalcular limites</span>
</Button>
</div>
</div>
);
}
+278 -168
View File
@@ -1,191 +1,301 @@
import { useState, useEffect } from 'react';
import { useRef, useMemo, useEffect, useState } from 'react';
import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import {
mainCameraRef,
mainControlsRef,
requestView,
calibration,
startCalibration,
cancelCalibration,
pushCubeFace,
subscribeCalibration,
} from './three/viewCubeBus';
import { useModelStore } from '@/stores/useModelStore';
import { Button } from '@/components/ui/button';
import { RotateCw, RefreshCw, Move, HelpCircle } from 'lucide-react';
import { getModelLocalGroup } from '@/lib/modelTransforms';
import { Check, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
export function ViewCube() {
const { activeModelId, models, setFineTuning } = useModelStore();
const activeModel = models.find((m) => m.id === activeModelId);
/** Builds a square canvas texture with a label centered on it. */
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 ? '#1a2742' : '#101725');
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, size - 6, size - 6);
ctx.fillStyle = '#e6faff';
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = '#00f3ff';
ctx.shadowBlur = 18;
ctx.fillText(label, size / 2, size / 2);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 4;
tex.needsUpdate = true;
return tex;
}
// Estados locais para controlar uma rotação interativa ou visualização do cubo
const [hoveredFace, setHoveredFace] = useState<string | null>(null);
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] },
];
if (!activeModelId || !activeModel) return null;
function CubeMesh({ calibrating, activeModelId }: { calibrating: boolean; activeModelId: string | null }) {
const meshRef = useRef<THREE.Mesh>(null);
const groupRef = useRef<THREE.Group>(null);
const { camera: localCam } = useThree();
const [hover, setHover] = useState<number | null>(null);
const alignToFace = (face: string) => {
let rotX = 0;
let rotY = 0;
let rotZ = 0;
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;
});
}, []);
switch (face) {
case 'TOPO':
rotX = 0;
rotY = 0;
rotZ = 0;
break;
case 'FRENTE':
rotX = 90;
rotY = 0;
rotZ = 0;
break;
case 'DIREITA':
rotX = 90;
rotY = 0;
rotZ = -90;
break;
case 'ESQUERDA':
rotX = 90;
rotY = 0;
rotZ = 90;
break;
case 'ATRÁS':
rotX = -90;
rotY = 180;
rotZ = 0;
break;
case 'BASE':
rotX = 180;
rotY = 0;
rotZ = 0;
break;
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]);
// Reusable temp objects for the per-frame model-quaternion sync.
const modelQuat = useMemo(() => new THREE.Quaternion(), []);
/** Returns the active model's current world quaternion (fineTuning rotation
* + calibration). Used to keep the cube glued to the piece's pose. */
const getModelWorldQuat = (): THREE.Quaternion => {
const g = getModelLocalGroup(activeModelId);
if (g) {
g.updateWorldMatrix(true, false);
g.getWorldQuaternion(modelQuat);
return modelQuat;
}
setFineTuning({ rotX, rotY, rotZ });
toast.success(`Alinhado para vista ${face}`);
modelQuat.identity();
return modelQuat;
};
// Ajusta a rotação para o múltiplo de 90 graus mais próximo
const snapToNearest90 = () => {
const ft = activeModel.fineTuning;
const rotX = Math.round(ft.rotX / 90) * 90;
const rotY = Math.round(ft.rotY / 90) * 90;
const rotZ = Math.round(ft.rotZ / 90) * 90;
setFineTuning({ rotX, rotY, rotZ });
toast.success(`Snap Ortogonal (90°): [${rotX}°, ${rotY}°, ${rotZ}°]`);
useFrame(() => {
const main = mainCameraRef.current;
const controls = mainControlsRef.current;
if (!main) return;
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
const dir = new THREE.Vector3().subVectors(main.position, target).normalize();
localCam.position.copy(dir.multiplyScalar(3));
localCam.up.copy(main.up);
localCam.lookAt(0, 0, 0);
// Sync the cube group orientation with the active model so it always
// mirrors the piece's pose (rotation from Posicionar, calibration, etc).
if (groupRef.current) {
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;
// Convert the local cube-face direction to world space using the model's
// current world quaternion — this keeps cube ↔ model coherent after any
// rotation/translation/reset of the piece.
const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize();
if (calibrating) {
pushCubeFace(dirWorld);
} else {
requestView(dirWorld);
}
};
const resetAllTransforms = () => {
setFineTuning({ posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 });
toast.success('Posição e rotação restauradas para a origem');
// 70% of previous 1.4 → ~0.98
const SIZE = 0.98;
return (
<group ref={groupRef}>
<mesh
ref={meshRef}
material={materials}
onClick={onClick}
onPointerMove={(e) => {
e.stopPropagation();
const idx = e.face?.materialIndex ?? null;
setHover(idx);
}}
onPointerOut={() => setHover(null)}
>
<boxGeometry args={[SIZE, SIZE, SIZE]} />
</mesh>
<AxisTripod />
</group>
);
}
function AxisTripod() {
return (
<group position={[-1.2, -1.2, 0]}>
<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>
);
}
const STEP_HINTS: Record<string, string> = {
'await-cube-1': '1/3 — clique numa face do cubo',
'await-model-1': '1/3 — clique na face correspondente da peça',
'await-cube-2': '2/3 — clique noutra face do cubo',
'await-model-2': '2/3 — clique na face correspondente da peça',
'await-cube-3': '3/3 (verificação) — clique numa terceira face do cubo (opcional)',
'await-model-3': '3/3 (verificação) — clique na face correspondente da peça',
'done': 'Calibração concluída',
};
export function ViewCube() {
const [, force] = useState(0);
useEffect(() => subscribeCalibration(() => force(t => t + 1)), []);
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);
const isCalibrating = calibration.step !== 'idle' && calibration.step !== 'done';
const isDone = calibration.step === 'done';
const progress = isDone ? 1 : calibration.progress;
const onClickCalibrar = () => {
const store = useModelStore.getState();
if (isCalibrating) {
// Cancel mid-flow — restore previous camera mode if we forced ortho.
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
cancelCalibration();
toast.info('Calibração cancelada');
return;
}
if (isDone) {
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
cancelCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque o cadeado para calibrar');
return;
}
// Force orthographic view for accurate face picking.
(window as any).__tsxr_prevCamMode = store.cameraMode;
if (store.cameraMode !== 'ortho') store.setCameraMode('ortho');
startCalibration(active.id);
toast('Calibração em vista ortogonal — clique numa face do cubo, depois na face correspondente da peça', { duration: 4000 });
};
const onResetCalibration = () => {
if (!active) return;
setCalibrationStore(active.id, null);
toast.success('Calibração removida');
};
return (
<div className="absolute top-4 right-4 z-20 flex flex-col items-center gap-3 rounded-xl border border-white/10 bg-black/50 p-4 shadow-2xl backdrop-blur-md select-none text-white w-40">
{/* Título do Widget */}
<span className="font-mono text-[9px] font-semibold text-muted-foreground uppercase tracking-widest text-center">
Alinhamento 3D
</span>
<div
className="absolute right-3 top-3 z-20 w-[120px] flex flex-col items-stretch gap-1.5"
style={{ pointerEvents: 'auto' }}
>
<div
className="h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
title="Cubo de vistas — clique numa face para girar"
>
<Canvas
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
gl={{ antialias: true, alpha: true }}
dpr={[1, 2]}
>
<ambientLight intensity={1} />
<CubeMesh calibrating={isCalibrating} activeModelId={activeModelId} />
</Canvas>
</div>
{/* Cubo 3D Visual */}
<div className="relative h-20 w-20 flex items-center justify-center my-2 group">
{/* Container do Cubo Isométrico */}
<div className="h-14 w-14 transition-transform duration-500 ease-out viewcube-wrapper">
{/* TOPO (Z+) */}
<div
onClick={() => alignToFace('TOPO')}
onMouseEnter={() => setHoveredFace('TOPO')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-top ${
hoveredFace === 'TOPO' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-800/80 text-zinc-300'
}`}
>
TOPO
</div>
{/* Calibrar button with progress fill */}
<button
type="button"
onClick={onClickCalibrar}
className={[
'relative h-7 w-full overflow-hidden rounded border font-mono text-[10px] uppercase tracking-widest transition-colors',
isDone
? 'border-success/60 bg-success/20 text-success'
: isCalibrating
? 'border-amber-400/60 bg-amber-400/10 text-amber-300'
: 'border-primary/40 bg-background/40 text-primary hover:bg-primary/10',
].join(' ')}
title={isCalibrating ? 'Cancelar calibração' : 'Calibrar orientação da peça com o cubo'}
>
{/* progress bar */}
<span
className={[
'absolute inset-y-0 left-0 transition-all duration-200',
isDone ? 'bg-success/40' : 'bg-primary/30',
].join(' ')}
style={{ width: `${Math.round(progress * 100)}%` }}
/>
<span className="relative z-10 flex items-center justify-center gap-1">
{isDone ? <Check className="h-3 w-3" /> : null}
{isDone ? 'Calibrado' : isCalibrating ? 'Cancelar' : 'Calibrar'}
</span>
</button>
{/* FRENTE (Y-) */}
<div
onClick={() => alignToFace('FRENTE')}
onMouseEnter={() => setHoveredFace('FRENTE')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-front ${
hoveredFace === 'FRENTE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-700/80 text-zinc-300'
}`}
>
FRENTE
</div>
{/* DIREITA (X+) */}
<div
onClick={() => alignToFace('DIREITA')}
onMouseEnter={() => setHoveredFace('DIREITA')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-right ${
hoveredFace === 'DIREITA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
}`}
>
DIR
</div>
{/* ESQUERDA (X-) */}
<div
onClick={() => alignToFace('ESQUERDA')}
onMouseEnter={() => setHoveredFace('ESQUERDA')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-left ${
hoveredFace === 'ESQUERDA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
}`}
>
ESQ
</div>
{/* ATRÁS (Y+) */}
<div
onClick={() => alignToFace('ATRÁS')}
onMouseEnter={() => setHoveredFace('ATRÁS')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-back ${
hoveredFace === 'ATRÁS' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-750/80 text-zinc-300'
}`}
>
ATRÁS
</div>
{/* BASE (Z-) */}
<div
onClick={() => alignToFace('BASE')}
onMouseEnter={() => setHoveredFace('BASE')}
onMouseLeave={() => setHoveredFace(null)}
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-bottom ${
hoveredFace === 'BASE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-900/80 text-zinc-300'
}`}
>
BASE
</div>
{/* Step hint while calibrating */}
{(isCalibrating || isDone) && (
<div className="rounded border border-primary/30 bg-background/80 px-2 py-1 text-center font-mono text-[9px] leading-tight text-foreground/90 backdrop-blur-sm">
{STEP_HINTS[calibration.step] ?? ''}
{isDone && Number.isFinite(calibration.verifyErrorDeg) && (
<div className="mt-0.5 text-success">
erro {calibration.verifyErrorDeg.toFixed(1)}°
</div>
)}
</div>
</div>
)}
{/* Botões Rápidos de Alinhamento */}
<div className="flex flex-col gap-1.5 w-full mt-1">
<Button
variant="outline"
size="sm"
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
onClick={snapToNearest90}
title="Corrige a orientação aproximando para o múltiplo de 90° mais próximo"
{/* Reset existing calibration */}
{!isCalibrating && active?.calibrationQuat && (
<button
type="button"
onClick={onResetCalibration}
className="flex h-6 w-full items-center justify-center gap-1 rounded border border-muted-foreground/30 bg-background/40 font-mono text-[9px] uppercase tracking-widest text-muted-foreground hover:text-foreground hover:border-muted-foreground/60"
title="Remover calibração salva"
>
<RotateCw className="h-3 w-3 text-primary animate-pulse" />
<span>Alinhar 90° (Snap)</span>
</Button>
<Button
variant="outline"
size="sm"
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
onClick={resetAllTransforms}
title="Zera a posição e a rotação da peça atual"
>
<RefreshCw className="h-3 w-3 text-destructive" />
<span>Resetar Origem</span>
</Button>
</div>
{/* Info compacta */}
<div className="flex items-center gap-1.5 font-mono text-[8px] text-muted-foreground mt-0.5 select-none justify-center">
<span>X: {Math.round(activeModel.fineTuning.rotX)}°</span>
<span>Y: {Math.round(activeModel.fineTuning.rotY)}°</span>
<span>Z: {Math.round(activeModel.fineTuning.rotZ)}°</span>
</div>
<RotateCcw className="h-2.5 w-2.5" /> reset
</button>
)}
</div>
);
}
+89 -95
View File
@@ -1,4 +1,4 @@
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } from 'lucide-react';
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors } from 'lucide-react';
import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -11,25 +11,16 @@ import * as THREE from 'three';
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
const WIRE_COLORS = [
{ label: 'Cinza', value: '#8899aa' },
{ label: 'Ciano (radiante)', value: '#00f3ff' },
{ label: 'Vermelho', value: '#ef4444' },
{ label: 'Verde', value: '#22c55e' },
{ label: 'Azul', value: '#3b82f6' },
{ label: 'Amarelo', value: '#eab308' },
{ label: 'Laranja', value: '#f97316' },
{ label: 'Branco', value: '#e2e8f0' },
{ label: 'Cinza', value: '#8899aa' },
];
const colorClassMap: Record<string, string> = {
'#8899aa': 'bg-[#8899aa]',
'#ef4444': 'bg-red-500',
'#22c55e': 'bg-green-500',
'#3b82f6': 'bg-blue-500',
'#eab308': 'bg-yellow-500',
'#f97316': 'bg-orange-500',
'#e2e8f0': 'bg-slate-200',
};
export function ViewerControls() {
const {
opacity, setOpacity, renderMode, setRenderMode,
@@ -44,10 +35,11 @@ export function ViewerControls() {
selectionMode, setSelectionMode,
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
desktopCameraMode, setDesktopCameraMode,
xrEnvironment, setXrEnvironment,
xrScaleMode, setXrScaleMode,
cameraMode, setCameraMode,
gridCalibMode, setGridCalibMode, setGridAutoFollow,
sectionPanelOpen, setSectionPanelOpen, sectionEnabled, resetSection,
} = useModelStore();
const sectionActive = sectionEnabled.x || sectionEnabled.y || sectionEnabled.z;
const activeModel = models.find(m => m.id === activeModelId);
const hasSelection = selectedElementKeys.size > 0;
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
@@ -117,31 +109,100 @@ export function ViewerControls() {
<span className="font-mono text-xs">Grid</span>
</Button>
{/* Scale presets */}
<ScaleSelector />
{/* Grid calibration: click a face on the piece to anchor the grid there */}
<Button
variant={renderMode !== 'solid' ? 'default' : 'outline'}
variant={gridCalibMode ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
disabled={!activeModelId}
onClick={() => {
const next = renderMode === 'solid' ? 'edges' : 'solid';
setRenderMode(next);
const next = !gridCalibMode;
setGridCalibMode(next);
if (next) {
setGridAutoFollow(false);
toast.info('Calibrar Grid: clique numa face da peça');
} else {
toast.dismiss();
}
}}
title="Posiciona o grid na altura de uma face da peça"
>
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
<LayoutGrid className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
{gridCalibMode ? 'Clique na face…' : 'Calibrar Grid'}
</span>
</Button>
{/* Wireframe/Edges settings popover */}
{renderMode !== 'solid' && (
{/* Section cuts (X/Y/Z clipping planes) */}
<div className="flex items-end gap-1.5">
<Button
variant={sectionPanelOpen || sectionActive ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setSectionPanelOpen(!sectionPanelOpen)}
disabled={!activeModelId}
title="Cortes de seção em X / Y / Z"
>
<Scissors className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
Cortes{sectionActive ? ' •' : ''}
</span>
</Button>
{sectionActive && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => { resetSection(); toast.success('Cortes resetados'); }}
title="Resetar todos os cortes"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
{/* Scale presets */}
<ScaleSelector />
<Button
variant={renderMode === 'edges' ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setRenderMode(renderMode === 'edges' ? 'solid' : 'edges')}
>
<Box className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{renderMode === 'edges' ? 'Bordas' : 'Sólido'}
</span>
</Button>
{/* Camera projection toggle (Ortho ⇄ Persp) */}
<Button
variant="outline"
size="sm"
className="gap-2 h-9"
onClick={() => setCameraMode(cameraMode === 'ortho' ? 'persp' : 'ortho')}
title={cameraMode === 'ortho'
? 'Vista ortogonal (CAD) — clique para perspectiva'
: 'Vista em perspectiva — clique para ortogonal'}
>
{cameraMode === 'ortho'
? <Square className="h-3.5 w-3.5" />
: <Box className="h-3.5 w-3.5" />}
<span className="font-mono text-xs">
{cameraMode === 'ortho' ? 'Ortogonal' : 'Perspectiva'}
</span>
</Button>
{/* Edges color/thickness/threshold popover */}
{renderMode === 'edges' && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 h-9">
<Palette className="h-3.5 w-3.5" />
<div className={`h-3 w-3 rounded-full border border-muted-foreground/30 ${colorClassMap[wireframeColor] || 'bg-[#8899aa]'}`} />
<div className="h-3 w-3 rounded-full border border-muted-foreground/30" style={{ backgroundColor: wireframeColor }} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 space-y-3" side="top" align="start">
@@ -151,7 +212,8 @@ export function ViewerControls() {
{WIRE_COLORS.map((c) => (
<button
key={c.value}
className={`h-6 w-6 rounded-full border-2 transition-all ${colorClassMap[c.value] || 'bg-[#8899aa]'} ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
className={`h-6 w-6 rounded-full border-2 transition-all ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
style={{ backgroundColor: c.value }}
onClick={() => setWireframeColor(c.value)}
title={c.label}
/>
@@ -293,74 +355,6 @@ export function ViewerControls() {
)}
</div>
{/* Seletor de Câmera (Orbit vs Walk) */}
<Button
variant={desktopCameraMode === 'walk' ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => {
const next = desktopCameraMode === 'orbit' ? 'walk' : 'orbit';
setDesktopCameraMode(next);
if (next === 'walk') {
toast.info("Passeio 3D Ativo: clique no visualizador para prender o mouse. WASD = andar, Q/E = subir/descer, ESC = soltar mouse.");
}
}}
title="Alternar entre câmera orbital e passeio em primeira pessoa"
>
{desktopCameraMode === 'walk' ? <Footprints className="h-3.5 w-3.5" /> : <Compass className="h-3.5 w-3.5" />}
<span className="font-mono text-xs">
{desktopCameraMode === 'walk' ? 'Passeio 3D' : 'Câmera'}
</span>
</Button>
{/* Seletor de Cenário 2D */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 h-9" title="Alterar cenário de fundo do visualizador">
<Map className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{xrEnvironment === 'passthrough' ? 'Estúdio' : xrEnvironment === 'office' ? 'Escritório' : 'Campo Aberto'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 space-y-2" side="top" align="start">
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1">
Cenário de Fundo
</span>
<Button
variant={xrEnvironment === 'passthrough' ? 'default' : 'ghost'}
size="sm"
className="justify-start font-mono text-xs h-8"
onClick={() => setXrEnvironment('passthrough')}
>
🌌 Estúdio (Padrão)
</Button>
<Button
variant={xrEnvironment === 'office' ? 'default' : 'ghost'}
size="sm"
className="justify-start font-mono text-xs h-8"
onClick={() => {
setXrEnvironment('office');
setXrScaleMode('tabletop');
}}
>
🏢 Escritório (VR Mesa)
</Button>
<Button
variant={xrEnvironment === 'field' ? 'default' : 'ghost'}
size="sm"
className="justify-start font-mono text-xs h-8"
onClick={() => {
setXrEnvironment('field');
setXrScaleMode('realscale');
}}
>
🌳 Campo Aberto (VR Grama)
</Button>
</div>
</PopoverContent>
</Popover>
{/* Screenshot */}
<Button
+6 -9
View File
@@ -67,9 +67,9 @@ const helpItems = [
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
},
{
title: 'Sólido / Bordas',
title: 'Sólido / Wire / Bordas',
icon: '🔲',
desc: 'Alterna modos de renderização: Sólido (preenchido) e Bordas (destaque visual de arestas com transparência).',
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
},
{
title: 'Medir',
@@ -342,15 +342,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
</Button>
<Button
variant={renderMode !== 'solid' ? 'default' : 'outline'}
variant={renderMode === 'edges' ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => {
const next = renderMode === 'solid' ? 'edges' : 'solid';
setRenderMode(next);
}}
onClick={() => setRenderMode(renderMode === 'edges' ? 'solid' : 'edges')}
>
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
<Box className="h-3.5 w-3.5" />
{renderMode === 'edges' ? 'Bordas' : 'Sólido'}
</Button>
<Button
@@ -1,167 +0,0 @@
import { useEffect, useRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { PointerLockControls } from '@react-three/drei';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
export function DesktopFirstPersonControls() {
const { camera } = useThree();
const activeMode = useModelStore((s) => s.desktopCameraMode);
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
// Armazena o estado atual das teclas de locomoção
const keys = useRef({
w: false,
a: false,
s: false,
d: false,
arrowup: false,
arrowleft: false,
arrowdown: false,
arrowright: false,
q: false, // voar para baixo
e: false, // voar para cima
});
useEffect(() => {
if (activeMode !== 'walk') return;
const handleKeyDown = (e: KeyboardEvent) => {
const k = e.key.toLowerCase();
if (k in keys.current) {
keys.current[k as keyof typeof keys.current] = true;
}
};
const handleKeyUp = (e: KeyboardEvent) => {
const k = e.key.toLowerCase();
if (k in keys.current) {
keys.current[k as keyof typeof keys.current] = false;
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [activeMode]);
// Define posições iniciais amigáveis ao alternar para o modo passeio
const lastMode = useRef<string | null>(null);
useEffect(() => {
if (activeMode === 'walk' && lastMode.current !== 'walk') {
if (xrEnvironment === 'office') {
// Posiciona o operador em pé na ponta da mesa
camera.position.set(0, 1.4, 1.3);
camera.lookAt(0, 0.85, 0);
} else {
// Posiciona no campo aberto em escala real
camera.position.set(0, 1.65, 3);
camera.lookAt(0, 1.0, 0);
}
}
lastMode.current = activeMode;
}, [activeMode, xrEnvironment, camera]);
useFrame((state, delta) => {
if (activeMode !== 'walk') return;
const speed = 2.0 * delta; // Metros por frame adaptativo
const rotSpeed = 1.5 * delta;
const direction = new THREE.Vector3();
const side = new THREE.Vector3();
// Obtém vetor de olhar da câmera
camera.getWorldDirection(direction);
direction.y = 0; // Trava o vetor na horizontal para não voar para cima/baixo ao olhar
direction.normalize();
// Obtém vetor lateral
side.crossVectors(camera.up, direction).normalize();
// 1. Leituras do teclado
let moveFwd = 0;
let moveSide = 0;
let moveUp = 0;
if (keys.current.w || keys.current.arrowup) moveFwd += 1;
if (keys.current.s || keys.current.arrowdown) moveFwd -= 1;
if (keys.current.d || keys.current.arrowright) moveSide -= 1;
if (keys.current.a || keys.current.arrowleft) moveSide += 1;
if (keys.current.e) moveUp += 1;
if (keys.current.q) moveUp -= 1;
camera.position.addScaledVector(direction, moveFwd * speed);
camera.position.addScaledVector(side, moveSide * speed);
camera.position.y += moveUp * speed;
// 2. Leituras da Gamepad API (Xbox / PlayStation)
const gamepads = navigator.getGamepads();
const gp = gamepads[0] || gamepads[1];
if (gp) {
const deadzone = 0.15;
// Analógico Esquerdo (Locomoção)
const stickLX = gp.axes[0]; // Eixo lateral
const stickLY = gp.axes[1]; // Eixo frente/trás
if (Math.abs(stickLY) > deadzone) {
camera.position.addScaledVector(direction, -stickLY * speed);
}
if (Math.abs(stickLX) > deadzone) {
camera.position.addScaledVector(side, -stickLX * speed);
}
// Analógico Direito (Rotação da Câmera)
const stickRX = gp.axes[2]; // Rotação Y
const stickRY = gp.axes[3]; // Olhar X cima/baixo
if (Math.abs(stickRX) > deadzone) {
camera.rotation.y -= stickRX * rotSpeed * 1.6;
}
if (Math.abs(stickRY) > deadzone) {
// Rotaciona verticalmente
camera.rotation.x -= stickRY * rotSpeed * 1.6;
// Limita ângulo vertical para evitar inverter a visão (entre -80° e 80°)
camera.rotation.x = Math.max(-1.4, Math.min(1.4, camera.rotation.x));
}
// Suporta tanto o mapeamento de Triggers (RT/LT - botões 7/6) quanto de Grips/Bumpers (RB/LB - botões 5/4) para subir e descer a altura da câmera
const triggerLT = gp.buttons[6];
const triggerRT = gp.buttons[7];
const bumperLB = gp.buttons[4];
const bumperRB = gp.buttons[5];
const valUp = Math.max(
triggerRT ? (triggerRT.value || (triggerRT.pressed ? 1 : 0)) : 0,
bumperRB ? (bumperRB.value || (bumperRB.pressed ? 1 : 0)) : 0
);
const valDown = Math.max(
triggerLT ? (triggerLT.value || (triggerLT.pressed ? 1 : 0)) : 0,
bumperLB ? (bumperLB.value || (bumperLB.pressed ? 1 : 0)) : 0
);
if (valUp > 0.1) {
camera.position.y += valUp * speed;
}
if (valDown > 0.1) {
camera.position.y -= valDown * speed;
}
}
// Trava de colisão com o chão/mesa para manter realismo
const minHeight = xrEnvironment === 'office' ? 0.9 : 0.1;
if (camera.position.y < minHeight) {
camera.position.y = minHeight;
}
});
if (activeMode !== 'walk') return null;
return <PointerLockControls makeDefault />;
}
File diff suppressed because it is too large Load Diff
+22 -8
View File
@@ -270,7 +270,7 @@ export function findNearestEdgeSegment(
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
let bestDist = Infinity;
let bestMid: THREE.Vector3 | null = null;
@@ -340,7 +340,7 @@ export function detectCircularEdgeAtPoint(
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const hitScreen = worldPoint.clone().project(camera);
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
@@ -443,6 +443,16 @@ const _ab = new THREE.Vector3();
const _ap = new THREE.Vector3();
const _closest = new THREE.Vector3();
function closestPointOnSegment3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3, target: THREE.Vector3): THREE.Vector3 {
_ab.subVectors(b, a);
_ap.subVectors(p, a);
const abLenSq = _ab.lengthSq();
if (abLenSq < 1e-6) return target.copy(a);
const t = THREE.MathUtils.clamp(_ap.dot(_ab) / abLenSq, 0, 1);
return target.copy(a).addScaledVector(_ab, t);
}
function pointToSegmentDistSq3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number {
_ab.subVectors(b, a);
_ap.subVectors(p, a);
@@ -503,7 +513,7 @@ export function findNearestEdgeSegment3D(
mesh: THREE.Mesh,
worldPoint: THREE.Vector3,
thresholdMeters: number = 0.05
): { midpoint: THREE.Vector3; lengthMM: number; a: THREE.Vector3; b: THREE.Vector3 } | null {
): { midpoint: THREE.Vector3; snapPoint: THREE.Vector3; lengthMM: number; a: THREE.Vector3; b: THREE.Vector3 } | null {
let edgeLines: THREE.LineSegments | null = null;
mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
@@ -523,12 +533,13 @@ export function findNearestEdgeSegment3D(
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
let bestDistSq = Infinity;
let bestIndex = -1;
const bestClosestLocal = new THREE.Vector3();
const thresholdSq = thresholdMeters * thresholdMeters;
const segCount = posAttr.count / 2;
@@ -539,11 +550,13 @@ export function findNearestEdgeSegment3D(
aLocal.fromBufferAttribute(posAttr, i * 2);
bLocal.fromBufferAttribute(posAttr, i * 2 + 1);
const distSq = pointToSegmentDistSq3D(localPoint, aLocal, bLocal);
closestPointOnSegment3D(localPoint, aLocal, bLocal, _closest);
const distSq = localPoint.distanceToSquared(_closest);
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIndex = i;
bestClosestLocal.copy(_closest);
}
}
@@ -551,8 +564,9 @@ export function findNearestEdgeSegment3D(
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
const midpoint = a.clone().add(b).multiplyScalar(0.5);
const snapPoint = bestClosestLocal.clone().applyMatrix4(matrix);
const lengthMM = a.distanceTo(b) * 1000;
return { midpoint, lengthMM, a, b };
return { midpoint, snapPoint, lengthMM, a, b };
}
return null;
}
@@ -579,7 +593,7 @@ export function detectCircularEdgeAtPoint3D(
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
@@ -652,6 +666,6 @@ export function resolveSnap3D(
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
if (v) return { point: v, type: 'vertex' };
const e = findNearestEdgeSegment3D(mesh, hitPoint, edgeThresholdMeters);
if (e) return { point: e.midpoint, type: 'edge' };
if (e) return { point: e.snapPoint, type: 'edge' };
return { point: hitPoint.clone(), type: 'surface' };
}
+31 -36
View File
@@ -26,8 +26,8 @@ function isPickableModelMesh(o: THREE.Object3D): boolean {
return false;
}
function findModelId(obj: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = obj;
function getModelIdFromObject(o: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = o;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
@@ -35,22 +35,6 @@ function findModelId(obj: THREE.Object3D): string | undefined {
return undefined;
}
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
if (!session) return;
try {
for (const source of session.inputSources) {
if (source.handedness === handedness) {
const gp = source.gamepad;
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
}
}
}
} catch (e) {
console.warn('[XR][Haptic] erro ao vibrar controle:', e);
}
}
/**
* Right-controller trigger driven measurement for AR.
*
@@ -85,12 +69,12 @@ export function XRControllerMeasure() {
);
const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false }));
const tipColor = useRef(new THREE.Color('#22c55e'));
const lockedSnap = useRef<{ point: THREE.Vector3; kind: 'vertex' | 'edge' | 'hole'; modelId?: string; lastSeen: number } | null>(null);
// Dwell detection for hover-based smart measurement (1 s)
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
const dwellStart = useRef(0);
const dwellFired = useRef(false);
const lastSnapKind = useRef<string | null>(null);
// Throttling: raycast + snap analysis are heavy on dense IFC models and
// running them every frame at 72fps can stall the Quest right after
@@ -144,11 +128,14 @@ export function XRControllerMeasure() {
let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; modelId?: string; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
let hitModelId: string | undefined;
const nowT = performance.now();
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 (!isPickableModelMesh(o)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
@@ -158,9 +145,10 @@ export function XRControllerMeasure() {
});
if (hit && hit.object instanceof THREE.Mesh) {
hitModelId = getModelIdFromObject(hit.object);
const dist = tmpOrigin.current.distanceTo(hit.point);
const vertexThreshold = Math.max(0.06, dist * 0.08);
const edgeThreshold = Math.max(0.08, dist * 0.10);
const vertexThreshold = Math.max(0.08, dist * 0.12);
const edgeThreshold = Math.max(0.10, dist * 0.14);
// Snap to existing registered hole centers first
const existing = useModelStore.getState().measurements;
@@ -184,7 +172,7 @@ export function XRControllerMeasure() {
if (circle) {
snappedPoint = circle.center;
snapKind = 'hole';
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center, modelId: hitModelId };
} else {
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
snappedPoint = snap.point;
@@ -193,7 +181,7 @@ export function XRControllerMeasure() {
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
if (seg) {
const lenMM = seg.a.distanceTo(seg.b) * 1000;
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, modelId: hitModelId, endpoints: { a: seg.a, b: seg.b } };
}
}
}
@@ -203,15 +191,27 @@ export function XRControllerMeasure() {
}
}
// ── Haptic feedback on snap state changes ────────────────────────
if (snappedPoint && snapKind !== 'surface' && lastSnapKind.current !== snapKind) {
triggerHaptic(session, 'right', 0.4, 15);
const strongSnapKind = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole' ? snapKind : null;
if (snappedPoint && strongSnapKind) {
const locked = lockedSnap.current;
const sameTarget = locked && locked.kind === strongSnapKind && locked.modelId === hitModelId;
if (sameTarget && locked.point.distanceTo(snappedPoint) < (snapKind === 'edge' ? 0.08 : 0.12)) {
if (snapKind === 'edge') locked.point.lerp(snappedPoint, 0.25);
snappedPoint = locked.point.clone();
locked.lastSeen = nowT;
} else {
lockedSnap.current = { point: snappedPoint.clone(), kind: strongSnapKind, modelId: hitModelId, lastSeen: nowT };
}
} else if (snappedPoint && lockedSnap.current && lockedSnap.current.point.distanceTo(snappedPoint) < 0.12 && nowT - lockedSnap.current.lastSeen < 600) {
snapKind = lockedSnap.current.kind;
snappedPoint = lockedSnap.current.point.clone();
lockedSnap.current.lastSeen = nowT;
} else if (!snappedPoint || (lockedSnap.current && nowT - lockedSnap.current.lastSeen > 600)) {
lockedSnap.current = null;
}
lastSnapKind.current = snappedPoint ? snapKind : null;
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
const measureModeNow = useModelStore.getState().measureMode;
const nowT = performance.now();
if (snappedPoint && hoverDetected) {
const dist = dwellPos.current.distanceTo(snappedPoint);
if (dist > 0.005) {
@@ -221,18 +221,16 @@ export function XRControllerMeasure() {
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
dwellFired.current = true;
if (measureModeNow) {
const hitModelId = hit ? findModelId(hit.object) : undefined;
useModelStore.getState().registerHoverMeasurement({
type: hoverDetected.kind,
value: hoverDetected.value,
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
modelId: hitModelId,
endpoints: hoverDetected.endpoints && {
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
},
modelId: hoverDetected.modelId,
});
triggerHaptic(session, 'right', 0.6, 50);
}
}
} else {
@@ -281,6 +279,7 @@ export function XRControllerMeasure() {
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
const triggerHit = triggerHits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (!isPickableModelMesh(o)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
@@ -305,13 +304,11 @@ export function XRControllerMeasure() {
}
}
} else if (snappedPoint) {
const hitModelId = hit ? findModelId(hit.object) : undefined;
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
st.addMeasurePoint({
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
modelId: hitModelId,
});
triggerHaptic(session, 'right', 0.8, 25);
}
} else if (trigState.current && trigVal < TRIG_OFF) {
trigState.current = false;
@@ -323,7 +320,6 @@ export function XRControllerMeasure() {
if (!aState.current && aVal > BTN_ON) {
aState.current = true;
useModelStore.getState().undoLastMeasurement();
triggerHaptic(session, 'right', 0.5, 30);
} else if (aState.current && aVal < TRIG_OFF) {
aState.current = false;
}
@@ -334,7 +330,6 @@ export function XRControllerMeasure() {
if (!bState.current && bVal > BTN_ON) {
bState.current = true;
useModelStore.getState().clearMeasurements();
triggerHaptic(session, 'right', 0.7, 60);
} else if (bState.current && bVal < TRIG_OFF) {
bState.current = false;
}
+3 -82
View File
@@ -1,25 +1,9 @@
import { useRef, ReactNode, useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
import { useModelStore } from '@/stores/useModelStore';
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
if (!session) return;
try {
for (const source of session.inputSources) {
if (source.handedness === handedness) {
const gp = source.gamepad;
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
}
}
}
} catch (e) {
console.warn('[XR][Haptic] erro ao vibrar controle no grab:', e);
}
}
interface XRGrabbableProps {
/**
* Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
@@ -75,7 +59,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const dual = useRef<DualGrabRecord | null>(null);
const haloRef = useRef<THREE.Mesh>(null);
const everGrabbed = useRef(false);
const pendingReset = useRef(false);
// Reset scale to 1 (position/rotation kept) whenever resetScale() is called.
const scaleResetNonce = useModelStore((s) => s.scaleResetNonce);
@@ -87,21 +70,9 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
single.current = null;
}, [scaleResetNonce]);
const { gl } = useThree();
useFrame((_state) => {
useFrame(() => {
const group = groupRef.current;
if (!group) return;
if (pendingReset.current) {
group.position.set(0, 0, 0);
group.rotation.set(0, 0, 0);
group.scale.set(1, 1, 1);
pendingReset.current = false;
return;
}
const session = gl.xr.getSession();
const snap: ControllerGrabSnapshot = grab.current;
const L = snap.left;
const R = snap.right;
@@ -124,50 +95,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
haloRef.current.visible = maxGrip > 0.05;
}
const commitGrabTransforms = () => {
const activeId = useModelStore.getState().activeModelId;
if (!activeId) return;
const active = useModelStore.getState().models.find(m => m.id === activeId);
if (!active) return;
const ft = { ...active.fineTuning };
// 1. ESCALA DO MODELO: Lê o fator de escala do filho imediato para converter a translação do mundo real
const child = group.children[0];
const finalScaleFactor = child ? child.scale.x : 1.0;
ft.posX += group.position.x / finalScaleFactor;
ft.posY += group.position.y / finalScaleFactor;
ft.posZ += group.position.z / finalScaleFactor;
// 2. ROTAÇÃO TRIDIMENSIONAL: Combina quaternions de rotação para evitar gimbal lock ao soltar
const currentQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(
(active.fineTuning.rotX * Math.PI) / 180,
(active.fineTuning.rotY * Math.PI) / 180,
(active.fineTuning.rotZ * Math.PI) / 180,
'XYZ'
)
);
const finalQuat = group.quaternion.clone().multiply(currentQuat);
const finalEuler = new THREE.Euler().setFromQuaternion(finalQuat, 'XYZ');
ft.rotX = (finalEuler.x * 180) / Math.PI;
ft.rotY = (finalEuler.y * 180) / Math.PI;
ft.rotZ = (finalEuler.z * 180) / Math.PI;
// 3. ESCALA LOCAL DO GRAB: Multiplica a escala anterior
ft.scale = (ft.scale ?? 1) * group.scale.x;
useModelStore.getState().setFineTuning(ft);
// Sinaliza reset pendente para o próximo frame
pendingReset.current = true;
triggerHaptic(session, 'left', 0.25, 12);
triggerHaptic(session, 'right', 0.25, 12);
};
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
if (lActive && rActive) {
_tmpVecL.setFromMatrixPosition(L.gripWorld);
@@ -189,13 +116,12 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
};
single.current = null;
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
triggerHaptic(session, 'left', 0.5, 30);
triggerHaptic(session, 'right', 0.5, 30);
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
}
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;
@@ -215,7 +141,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
return;
} else if (dual.current) {
console.log('[XR][grab] ◆ TWO-HAND end');
commitGrabTransforms();
dual.current = null;
}
@@ -231,7 +156,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
single.current = { hand: activeHand, offsetWorld };
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
triggerHaptic(session, activeHand, 0.45, 25);
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
}
@@ -244,11 +168,8 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
targetWorldPos.applyMatrix4(invParent);
}
group.position.copy(targetWorldPos);
} else if (single.current) {
console.log('[XR][grab] ● ONE-HAND end');
commitGrabTransforms();
single.current = null;
}
});
+97 -185
View File
@@ -1,6 +1,7 @@
import { useRef, useState, forwardRef } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { useXRInputSourceState } from '@react-three/xr';
import { useXRInputSourceState, useXR } from '@react-three/xr';
import { useNavigate } from 'react-router-dom';
import * as THREE from 'three';
import { Text } from '@react-three/drei';
import { XR3DButton, XR3DPanel } from './XRPanel3D';
@@ -11,7 +12,7 @@ import {
stopRecording, captureScreenshot, formatElapsed,
} from '@/hooks/useCanvasRecorder';
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'env';
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr';
interface XRHudInWorldProps {
freeMove: boolean;
@@ -53,15 +54,15 @@ const WristToggle = forwardRef<THREE.Group, { open: boolean; onToggle: () => voi
);
export function XRHudInWorld(props: XRHudInWorldProps) {
const { camera, gl } = useThree();
const { camera } = useThree();
const leftCtrl = useXRInputSourceState('controller', 'left');
const rightCtrl = useXRInputSourceState('controller', 'right');
const open = useModelStore((s) => s.xrHudOpen);
const setOpen = useModelStore((s) => s.setXrHudOpen);
const tab = useModelStore((s) => s.xrHudTab) as Tab;
const setTab = useModelStore((s) => s.setXrHudTab) as unknown as (t: Tab) => void;
const xrSession = useXR((s) => s.session);
const navigate = useNavigate();
const [open, setOpen] = useState(true);
/** Visibility of the small head-locked floating button group (toggled by X button). */
const [hudVisible, setHudVisible] = useState(true);
const [tab, setTab] = useState<Tab>('scene');
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */
const [pinned, setPinned] = useState(true);
/** While true, the panel is glued to the right controller (drag mode). */
@@ -74,32 +75,20 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
const headLockRef = useRef<THREE.Group>(null);
const targetPos = useRef(new THREE.Vector3());
const targetQuat = useRef(new THREE.Quaternion());
const [showQuickMenu, setShowQuickMenu] = useState(true);
const lastXBtn = useRef(false);
const lastYBtn = useRef(false);
const lastABtn = useRef(false);
/** 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) {
// Toggle quick menu via left X button (Quest Touch buttons[4] on left controller)
const xBtn = gp.buttons[4];
const pressedX = !!xBtn?.pressed;
if (pressedX && !lastXBtn.current) {
setShowQuickMenu((prev) => !prev);
}
lastXBtn.current = pressedX;
// Toggle main HUD via left Y button (Quest Touch buttons[5] on left controller)
const yBtn = gp.buttons[5];
const pressedY = !!yBtn?.pressed;
if (pressedY && !lastYBtn.current) {
setOpen(!open);
}
lastYBtn.current = pressedY;
const pressed = !!xBtn?.pressed;
if (pressed && !lastABtn.current) setHudVisible((v) => !v);
lastABtn.current = pressed;
}
// Floating panel:
@@ -166,11 +155,11 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
}
}
// Head-locked fallback toggle — always visible in lower-right of FOV,
// Head-locked fallback toggle — always visible in lower-left of FOV,
// so the user can reopen the menu even without controllers or if the
// panel is positioned out of view.
if (headLockRef.current) {
const offset = new THREE.Vector3(0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
const offset = new THREE.Vector3(-0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
headLockRef.current.position.copy(camera.position).add(offset);
headLockRef.current.quaternion.copy(camera.quaternion);
}
@@ -181,21 +170,26 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
return (
<>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen(!open)} />
{/* Head-locked fallback: always-visible quick controls in lower-right of FOV */}
<group ref={headLockRef} renderOrder={999} visible={showQuickMenu}>
<XR3DButton position={[0, 0.026, 0]} size={[0.07, 0.022]}
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{/* Head-locked fallback: floating quick-controls in lower-left of FOV.
Toggled on/off by the left controller X button. */}
<group ref={headLockRef} renderOrder={999} visible={hudVisible}>
<XR3DButton position={[0, 0.030, 0]} size={[0.07, 0.022]}
label={open ? '✕ Menu' : '☰ Menu'} active={open}
onClick={() => { setOpen(!open); reanchorRequested.current = true; }}
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }}
fontSize={0.0085} />
<XR3DButton position={[0, 0, 0]} size={[0.07, 0.022]}
<XR3DButton position={[0, 0.004, 0]} size={[0.07, 0.022]}
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
<XR3DButton position={[0, -0.026, 0]} size={[0.07, 0.022]}
label=" Sair AR" active={false}
<XR3DButton position={[0, -0.022, 0]} size={[0.07, 0.022]}
label=" Sair AR"
onClick={() => {
gl.xr.getSession()?.end();
}} fontSize={0.0085} />
// End the XR session while preserving placement/state in the store,
// then return to the Viewer. Re-entering AR restores the same state.
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
setTimeout(() => navigate('/viewer'), 50);
}}
fontSize={0.0085} />
<RecIndicator />
</group>
{open && (
@@ -233,7 +227,7 @@ function FloatingPanel({
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
{ id: 'capture', label: 'Captura', icon: '🎥' },
{ id: 'share', label: 'Compart.', icon: '📡' },
{ id: 'env', label: 'Ambiente', icon: '🏝' },
{ id: 'webxr', label: 'WebXR', icon: '' },
];
const W = 0.5, H = 0.36;
return (
@@ -273,7 +267,7 @@ function FloatingPanel({
{tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />}
{tab === 'share' && <ShareTab {...p} />}
{tab === 'env' && <EnvTab />}
{tab === 'webxr' && <WebXRTab />}
</group>
</XR3DPanel>
);
@@ -363,125 +357,77 @@ function ToolsTab(p: XRHudInWorldProps) {
const resetScale = useModelStore((s) => s.resetScale);
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
const activeId = useModelStore((s) => s.activeModelId);
const models = useModelStore((s) => s.models);
const activeModel = models.find((m) => m.id === activeId);
const setFineTuning = useModelStore((s) => s.setFineTuning);
const alignToFace = (face: string) => {
let rotX = 0, rotY = 0, rotZ = 0;
if (face === 'FRENTE') { rotX = 90; }
else if (face === 'LADO') { rotX = 90; rotY = 90; }
setFineTuning({ rotX, rotY, rotZ });
};
const snapTo90 = () => {
if (!activeModel) return;
const ft = activeModel.fineTuning;
setFineTuning({
rotX: Math.round(ft.rotX / 90) * 90,
rotY: Math.round(ft.rotY / 90) * 90,
rotZ: Math.round(ft.rotZ / 90) * 90,
});
};
const resetRot = () => {
setFineTuning({ rotX: 0, rotY: 0, rotZ: 0 });
};
return (
<group>
{/* Fileira 1: Render */}
<Text position={[-0.24, 0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
<XR3DButton position={[-0.16, 0.105, 0]} size={[0.075, 0.022]} label="Sólido"
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
<XR3DButton position={[-0.08, 0.105, 0]} size={[0.075, 0.022]} label="Bordas"
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Wire"
active={renderMode === 'wireframe'} onClick={() => setRenderMode('wireframe')} />
<XR3DButton position={[0.0, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
{/* Fileira 2: Toggles */}
<Text position={[-0.24, 0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
<XR3DButton position={[-0.16, 0.055, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
<XR3DButton position={[-0.16, 0.025, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
<XR3DButton position={[-0.08, 0.055, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
<XR3DButton position={[-0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
<XR3DButton position={[0.0, 0.055, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
active={p.snapToPlanes} onClick={p.onToggleSnap} />
<XR3DButton position={[0.08, 0.055, 0]} size={[0.075, 0.022]}
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
<XR3DButton position={[0.16, 0.055, 0]} size={[0.075, 0.022]}
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
{/* Fileira 3: Selecionar */}
<Text position={[-0.24, 0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, 0.005, 0]} size={[0.075, 0.022]}
{/* Selecionar — paridade com a tela inicial */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
<XR3DButton position={[-0.08, 0.005, 0]} size={[0.075, 0.022]} label="Esconder"
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} />
<XR3DButton position={[0.0, 0.005, 0]} size={[0.075, 0.022]} label="Isolar"
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, 0.005, 0]} size={[0.075, 0.022]} label="Mostrar"
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} />
{/* Fileira 4: Opacidade */}
<Text position={[-0.24, -0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="25%"
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="5%"
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
{/* Fileira 5: Escala */}
<Text position={[-0.24, -0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label}
</Text>
{SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
return (
<XR3DButton key={lbl}
position={[-0.16 + i * 0.06, -0.095, 0]} size={[0.055, 0.022]} label={lbl}
position={[-0.16 + i * 0.06, -0.14, 0]} size={[0.055, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
<XR3DButton position={[0.10, -0.095, 0]} size={[0.062, 0.022]}
<XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
label="↺ Reset" color="#dc2626"
onClick={resetScale} fontSize={0.0085} />
<XR3DButton position={[0.17, -0.095, 0]} size={[0.062, 0.022]}
<XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} />
{/* Fileira 6: Rotação / Alinhamento Ortogonal */}
<Text position={[-0.24, -0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Alinhar Rotação
</Text>
<XR3DButton position={[-0.16, -0.145, 0]} size={[0.075, 0.022]}
label="Snap 90°" active={false}
onClick={snapTo90} fontSize={0.0075} />
<XR3DButton position={[-0.08, -0.145, 0]} size={[0.05, 0.022]}
label="Topo" active={false}
onClick={() => alignToFace('TOPO')} fontSize={0.0075} />
<XR3DButton position={[-0.025, -0.145, 0]} size={[0.05, 0.022]}
label="Frente" active={false}
onClick={() => alignToFace('FRENTE')} fontSize={0.0075} />
<XR3DButton position={[0.03, -0.145, 0]} size={[0.05, 0.022]}
label="Lado" active={false}
onClick={() => alignToFace('LADO')} fontSize={0.0075} />
<XR3DButton position={[0.12, -0.145, 0]} size={[0.09, 0.022]}
label="Zerar Rot." color="#dc2626"
onClick={resetRot} fontSize={0.0075} />
{measureMode && (
<Text position={[-0.24, -0.175, 0]} fontSize={0.0055} color="#a3e635" anchorX="left" maxWidth={0.5}>
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
</Text>
)}
@@ -645,79 +591,45 @@ function ShareTab(p: XRHudInWorldProps) {
);
}
function EnvTab() {
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
const setXrEnvironment = useModelStore((s) => s.setXrEnvironment);
const xrScaleMode = useModelStore((s) => s.xrScaleMode);
const setXrScaleMode = useModelStore((s) => s.setXrScaleMode);
function WebXRTab() {
const xrFeatures = useModelStore((s) => s.xrFeatures);
const setXRFeature = useModelStore((s) => s.setXRFeature);
const reset = useModelStore((s) => s.resetXRFeatures);
const keys: { k: keyof typeof xrFeatures; label: string }[] = [
{ k: 'handTracking', label: 'Hand' },
{ k: 'planeDetection', label: 'Planes' },
{ k: 'hitTest', label: 'HitTest' },
{ k: 'domOverlay', label: 'DOM Ovl' },
{ k: 'anchors', label: 'Anchors' },
{ k: 'meshDetection', label: 'Mesh' },
{ k: 'depthSensing', label: 'Depth' },
{ k: 'layers', label: 'Layers' },
{ k: 'bodyTracking', label: 'Body' },
];
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
Cenário Imersivo (VR / AR)
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest.
</Text>
<XR3DButton
position={[-0.14, 0.075, 0]}
size={[0.13, 0.026]}
label="Passthrough (AR)"
active={xrEnvironment === 'passthrough'}
onClick={() => setXrEnvironment('passthrough')}
fontSize={0.008}
/>
<XR3DButton
position={[0.0, 0.075, 0]}
size={[0.13, 0.026]}
label="Escritório (VR)"
active={xrEnvironment === 'office'}
onClick={() => {
setXrEnvironment('office');
setXrScaleMode('tabletop');
}}
fontSize={0.008}
/>
<XR3DButton
position={[0.14, 0.075, 0]}
size={[0.13, 0.026]}
label="Campo Aberto (VR)"
active={xrEnvironment === 'field'}
onClick={() => {
setXrEnvironment('field');
setXrScaleMode('realscale');
}}
fontSize={0.008}
/>
<Text position={[-0.24, 0.02, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
Escala de Apresentação do IFC
</Text>
<XR3DButton
position={[-0.08, -0.015, 0]}
size={[0.18, 0.026]}
label="Escala Real 1:1 (Caminhar)"
active={xrScaleMode === 'realscale'}
onClick={() => setXrScaleMode('realscale')}
fontSize={0.0085}
/>
<XR3DButton
position={[0.11, -0.015, 0]}
size={[0.18, 0.026]}
label="Modo Maquete (Mesa)"
active={xrScaleMode === 'tabletop'}
onClick={() => setXrScaleMode('tabletop')}
fontSize={0.0085}
/>
<Text position={[-0.24, -0.065, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Passthrough: veja o mundo real à sua volta (Realidade Aumentada).
</Text>
<Text position={[-0.24, -0.085, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Escritório: maquete interativa reduzida em cima de uma mesa virtual de reunião.
</Text>
<Text position={[-0.24, -0.105, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Campo Aberto: terreno gramado em escala real para explorar estruturas e galpões.
<Text position={[-0.24, 0.095, 0]} fontSize={0.0075} color="#f59e0b" anchorX="left" maxWidth={0.48}>
Mudanças entram em vigor ao sair e re-entrar no AR.
</Text>
{keys.map((it, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const x = -0.18 + col * 0.14;
const y = 0.05 - row * 0.045;
const on = xrFeatures[it.k];
return (
<XR3DButton key={it.k}
position={[x, y, 0]} size={[0.125, 0.034]}
label={`${it.label} ${on ? 'ON' : 'OFF'}`}
active={on} onClick={() => setXRFeature(it.k, !on)}
fontSize={0.0085} />
);
})}
<XR3DButton position={[0, -0.14, 0]} size={[0.18, 0.028]}
label="↺ Resetar (todas ON)" onClick={reset} fontSize={0.009} />
</group>
);
}
-606
View File
@@ -1,606 +0,0 @@
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Grid, Billboard, Sky, Line } from '@react-three/drei';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex } from './SmartMeasure';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
function findModelId(obj: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = obj;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
}
return undefined;
}
// ─── XRModel ───────────────────────────────────────────
export function XRModel({ sceneModel }: { sceneModel: SceneModel }) {
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
useEffect(() => {
let active = true;
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
if (isIfc) {
fetch(sceneModel.url)
.then((res) => res.arrayBuffer())
.then((buf) => parseIFCtoThree(buf))
.then((threeScene) => {
if (active) setRawScene(threeScene);
})
.catch((err) => console.error('[XRModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[XRModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
const ref = useRef<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(sceneModel.id, g);
return () => unregisterModelLocalGroup(sceneModel.id, g);
}, [sceneModel.id]);
const fineTuning = sceneModel.fineTuning;
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
const wireframeColor = useModelStore((s) => s.wireframeColor);
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
const checklist = useModelStore((s) => s.checklist);
const measureMode = useModelStore((s) => s.measureMode);
const modelInfo = useMemo(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
const box = new THREE.Box3().setFromObject(scene);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
return { size, center };
}, [scene]);
useEffect(() => {
if (!scene) return;
const hasRejected = checklist.some(i => i.status === 'rejected');
const allApproved = checklist.every(i => i.status === 'approved');
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (Array.isArray(child.material)) {
child.material = child.material.map(m => m.clone());
} else {
child.material = child.material.clone();
}
const toRemove: THREE.Object3D[] = [];
child.children.forEach(c => {
if (c.userData.__edgeLine) toRemove.push(c);
});
toRemove.forEach(c => {
if (c instanceof THREE.LineSegments) {
c.geometry.dispose();
(c.material as THREE.Material).dispose();
}
child.remove(c);
});
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
mat.visible = true;
const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
mat.wireframe = false;
if (hasRejected) {
mat.color.setHSL(0, 0.7, 0.5);
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
mat.color.set(sceneModel.color);
}
mat.needsUpdate = true;
}
});
if ((renderMode === 'edges' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
const s = fineTuning.scale ?? 1;
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
const xrScaleMode = useModelStore((st) => st.xrScaleMode);
const isTabletop = xrScaleMode === 'tabletop';
if (!sceneModel.visible) return null;
if (!scene) return null;
// Escala final: se for tabletop, escala de maquete proporcional ao tamanho do objeto
const maxDim = Math.max(modelInfo.size.x, modelInfo.size.y, modelInfo.size.z);
const finalScaleFactor = isTabletop
? (maxDim > 0 ? 0.5 / maxDim : 0.05)
: renderFactor;
// Posição local: no tabletop, eleva-se o modelo para que sua base coincida com Y=0 da origem do pai (topo da mesa)
const localPos: [number, number, number] = isTabletop
? [
-modelInfo.center.x + fineTuning.posX,
(-modelInfo.center.y + (modelInfo.size.y / 2)) + fineTuning.posY,
-modelInfo.center.z + fineTuning.posZ,
]
: [
-modelInfo.center.x + fineTuning.posX,
-modelInfo.center.y + fineTuning.posY,
-modelInfo.center.z + fineTuning.posZ,
];
const parentPos: [number, number, number] = isTabletop ? [0, 0.85, 0] : [0, 0, 0];
return (
<group scale={[finalScaleFactor, finalScaleFactor, finalScaleFactor]} position={parentPos}>
<group
ref={ref}
userData={{ modelId: sceneModel.id }}
position={localPos}
rotation={[rotXRad, rotYRad, rotZRad]}
scale={[s, s, s]}
>
<primitive object={scene} />
<XRLocalModelMeasurements modelId={sceneModel.id} />
</group>
</group>
);
}
export function XRBackgroundModels() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
return (
<>
{models.filter(m => m.id !== activeId).map((m) => (
<XRModel key={m.id} sceneModel={m} />
))}
</>
);
}
export function XRActiveModel() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const active = models.find(m => m.id === activeId);
if (!active) return null;
return <XRModel sceneModel={active} />;
}
// ─── ControllerFineTuning ──────────────────────────────
export function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
const { setFineTuning } = useModelStore();
const session = useThree((s) => s.gl.xr.getSession());
const isActiveLocked = useModelStore((s) => {
const a = s.models.find(m => m.id === s.activeModelId);
return !!a?.locked;
});
useFrame(() => {
if (!session || isActiveLocked) return;
const inputSources = session.inputSources;
if (!inputSources) return;
let leftAxes: number[] | null = null;
let rightAxes: number[] | null = null;
let leftTrig = 0;
let rightTrig = 0;
let gripHeld = false;
let anyGripHeld = false;
for (const source of inputSources) {
const gp = source.gamepad;
if (!gp) continue;
const gripBtn = gp.buttons[2];
const trigBtn = gp.buttons[1];
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
if (gripBtn?.pressed) gripHeld = true;
if (gripVal > 0.3) anyGripHeld = true;
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
if (source.handedness === 'left' && gp.axes.length >= 4) {
leftAxes = [gp.axes[2], gp.axes[3]];
leftTrig = trigVal;
}
if (source.handedness === 'right' && gp.axes.length >= 4) {
rightAxes = [gp.axes[2], gp.axes[3]];
rightTrig = trigVal;
}
}
if (anyGripHeld) return;
if (!freeMove && !gripHeld) return;
const baseStep = 0.001;
const baseRot = 0.1;
const deadzone = 0.15;
const speedL = 0.3 + leftTrig * 2.7;
const speedR = 0.3 + rightTrig * 2.7;
const curve = (v: number) => Math.sign(v) * v * v;
const modelStore = useModelStore.getState();
const ft = { ...modelStore.fineTuning };
if (leftAxes) {
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
}
if (rightAxes) {
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
}
setFineTuning(ft);
});
return null;
}
function XRMeasurementText({ text, color }: { text: string; color: string }) {
const texture = useMemo(() => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
ctx.beginPath();
const x = 0, y = 0, width = 256, height = 64, radius = 12;
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.stroke();
ctx.fillStyle = color;
ctx.font = 'bold 24px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 32);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}, [text, color]);
return (
<Billboard follow={true}>
<mesh renderOrder={999}>
<planeGeometry args={[0.08, 0.02]} />
<meshBasicMaterial
map={texture}
transparent
depthTest={false}
side={THREE.DoubleSide}
/>
</mesh>
</Billboard>
);
}
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
return (
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
);
}
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
const measurements = useModelStore((s) => s.measurements);
const modelMeasurements = useMemo(() => {
return measurements.filter(m => m.modelId === modelId);
}, [measurements, modelId]);
return (
<>
{modelMeasurements.map((m) => {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<mesh position={a}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<mesh position={b}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</group>
);
})}
</>
);
}
export function XRMeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
const snapPoint = useModelStore((s) => s.snapPoint);
const measureMode = useModelStore((s) => s.measureMode);
const globalMeasurements = useMemo(() => {
return measurements.filter(m => !m.modelId);
}, [measurements]);
return (
<>
{measureMode && snapPoint && (
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
</mesh>
)}
{measurePoints.length === 1 && (
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#e8a838" />
</mesh>
)}
{globalMeasurements.map((m) => {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<mesh position={a}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<mesh position={b}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</group>
);
})}
</>
);
}
export function XRSnapHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
useEffect(() => {
if (!measureMode) return;
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
};
gl.domElement.addEventListener('pointermove', onMove);
return () => gl.domElement.removeEventListener('pointermove', onMove);
}, [gl, mouse, measureMode]);
useFrame(() => {
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
if (!measureMode) { setSnapPoint(null); return; }
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
if (obj.userData.__edgeLine) return false;
return obj instanceof THREE.Mesh;
});
if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
const hitModelId = findModelId(hit.object);
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z, modelId: hitModelId } : null);
} else {
setSnapPoint(null);
}
});
useEffect(() => {
if (!measureMode) return;
if (gl.xr.isPresenting) return;
const onClick = (e: MouseEvent) => {
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z, modelId: snapPoint.modelId });
return;
}
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
if (hit) {
const hitModelId = findModelId(hit.object);
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z, modelId: hitModelId });
}
};
gl.domElement.addEventListener('click', onClick);
return () => gl.domElement.removeEventListener('click', onClick);
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
useEffect(() => {
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
return () => { gl.domElement.style.cursor = 'grab'; };
}, [measureMode, gl]);
return null;
}
export function XRGrid() {
const showGrid = useModelStore((s) => s.showGrid);
const gridY = useModelStore((s) => s.gridY);
if (!showGrid) return null;
return (
<Grid
position={[0, gridY, 0]}
infiniteGrid
cellSize={0.01}
sectionSize={0.1}
cellThickness={0.5}
sectionThickness={1}
cellColor="#334155"
sectionColor="#475569"
fadeDistance={5}
fadeStrength={1}
/>
);
}
export function XRGridAutoFollower() {
const models = useModelStore((s) => s.models);
const { scene } = useThree();
useEffect(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const id = setTimeout(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const box = new THREE.Box3();
let has = false;
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
obj.updateWorldMatrix(true, false);
const b = new THREE.Box3().setFromObject(obj);
if (isFinite(b.min.y)) {
if (!has) { box.copy(b); has = true; }
else box.union(b);
}
}
});
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
}, 150);
return () => clearTimeout(id);
}, [models, scene]);
return null;
}
export function XREnvironmentSelector() {
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
if (xrEnvironment === 'passthrough') return null;
return (
<group>
{/* Luzes adicionais para ambientes imersivos virtuais */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 10, 5]} intensity={1.0} castShadow />
{/* Cenário Escritório */}
{xrEnvironment === 'office' && (
<group>
{/* Carpete do escritório */}
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[50, 50]} />
<meshStandardMaterial color="#263238" roughness={0.95} />
</mesh>
{/* Tampo da Mesa */}
<mesh position={[0, 0.825, 0]} receiveShadow castShadow>
<cylinderGeometry args={[0.7, 0.7, 0.05, 32]} />
<meshStandardMaterial color="#3e2723" roughness={0.6} metalness={0.1} />
</mesh>
{/* Perna da Mesa */}
<mesh position={[0, 0.4, 0]} castShadow>
<cylinderGeometry args={[0.08, 0.08, 0.8, 16]} />
<meshStandardMaterial color="#333333" roughness={0.4} metalness={0.8} />
</mesh>
{/* Base da perna */}
<mesh position={[0, 0.01, 0]} castShadow>
<cylinderGeometry args={[0.3, 0.3, 0.02, 24]} />
<meshStandardMaterial color="#222222" roughness={0.3} metalness={0.8} />
</mesh>
</group>
)}
{/* Cenário Campo Aberto */}
{xrEnvironment === 'field' && (
<group>
<Sky distance={450000} sunPosition={[10, 5, 10]} inclination={0} azimuth={0.25} />
{/* Chão de grama */}
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[1000, 1000]} />
<meshStandardMaterial color="#1b5e20" roughness={0.9} />
</mesh>
</group>
)}
</group>
);
}
+226
View File
@@ -0,0 +1,226 @@
import * as THREE from 'three';
/**
* Shared bus between the main 3D viewer and the floating ViewCube widget.
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
* - viewAnim: smooth camera animation when a face is clicked
* - calibration: orchestrates a 4-click (+ optional 2-click verify) procedure
* to align the active model's intrinsic axes to the world axes.
*/
export const mainCameraRef: { current: THREE.Camera | null } = { current: null };
export const mainControlsRef: { current: any | null } = { current: null };
// ── View animation ────────────────────────────────────────────────
export interface ViewAnim {
active: boolean;
startPos: THREE.Vector3;
endPos: THREE.Vector3;
startUp: THREE.Vector3;
endUp: THREE.Vector3;
t: number;
duration: number;
}
export const viewAnim: ViewAnim = {
active: false,
startPos: new THREE.Vector3(),
endPos: new THREE.Vector3(),
startUp: new THREE.Vector3(0, 1, 0),
endUp: new THREE.Vector3(0, 1, 0),
t: 0,
duration: 0.5,
};
export function requestView(dir: THREE.Vector3) {
const cam = mainCameraRef.current;
const controls = mainControlsRef.current;
if (!cam || !controls) return;
const target: THREE.Vector3 = controls.target ?? new THREE.Vector3();
const distance = cam.position.distanceTo(target);
const d = dir.clone().normalize();
const endPos = target.clone().add(d.multiplyScalar(distance));
const up = new THREE.Vector3(0, 1, 0);
if (Math.abs(dir.y) > 0.99) {
up.set(0, 0, dir.y > 0 ? -1 : 1);
}
viewAnim.startPos.copy(cam.position);
viewAnim.endPos.copy(endPos);
viewAnim.startUp.copy(cam.up);
viewAnim.endUp.copy(up);
viewAnim.t = 0;
viewAnim.active = true;
}
// ── Calibration state machine ─────────────────────────────────────
export type CalibrationStep =
| 'idle'
| 'await-cube-1'
| 'await-model-1'
| 'await-cube-2'
| 'await-model-2'
| 'await-cube-3' // optional verification
| 'await-model-3'
| 'done';
interface Pair { cube: THREE.Vector3; model: THREE.Vector3 }
interface CalState {
step: CalibrationStep;
modelId: string | null;
pairs: Pair[];
pendingCube: THREE.Vector3 | null;
/** 0..1 progress for the button visual. */
progress: number;
/** Last verification angular error, in degrees. NaN until verify pair captured. */
verifyErrorDeg: number;
/** Listener notified on every state change so the UI re-renders. */
listeners: Set<() => void>;
}
export const calibration: CalState = {
step: 'idle',
modelId: null,
pairs: [],
pendingCube: null,
progress: 0,
verifyErrorDeg: NaN,
listeners: new Set(),
};
function notify() { calibration.listeners.forEach(fn => { try { fn(); } catch {} }); }
export function subscribeCalibration(fn: () => void): () => void {
calibration.listeners.add(fn);
return () => { calibration.listeners.delete(fn); };
}
export function startCalibration(modelId: string) {
calibration.step = 'await-cube-1';
calibration.modelId = modelId;
calibration.pairs = [];
calibration.pendingCube = null;
calibration.progress = 0;
calibration.verifyErrorDeg = NaN;
notify();
}
export function cancelCalibration() {
calibration.step = 'idle';
calibration.modelId = null;
calibration.pairs = [];
calibration.pendingCube = null;
calibration.progress = 0;
calibration.verifyErrorDeg = NaN;
notify();
}
/** Called when user clicks a cube face during calibration. */
export function pushCubeFace(dirWorld: THREE.Vector3) {
if (calibration.step === 'await-cube-1') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-1';
} else if (calibration.step === 'await-cube-2') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-2';
} else if (calibration.step === 'await-cube-3') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-3';
} else {
return;
}
// Auto-rotate the main camera to look straight at the picked cube face
// so the user sees the corresponding model face head-on (especially helpful
// in orthographic mode where this guarantees a perpendicular click).
try { requestView(dirWorld.clone()); } catch {}
notify();
}
/** Snap a unit vector to the nearest principal axis (±X/±Y/±Z) when within
* `maxDeg` degrees of it. Returns the snapped vector or the input unchanged. */
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;
}
/**
* Called when user clicks a model face during calibration.
* @param normalWorld world-space face normal of the clicked face
* @param calGroupWorldQuat current world quaternion of the calibration group
* (used to map both cube and model directions into the same local frame).
*/
export function pushModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) {
if (!calibration.pendingCube) return;
const cubeWorld = calibration.pendingCube;
const inv = calGroupWorldQuat.clone().invert();
const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize();
const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize();
// Snap near-axial picks (chamfered or slightly off faces) to the closest
// principal axis. This dramatically improves robustness when picking faces
// from an orthographic head-on view.
const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12);
calibration.pairs.push({ cube: cubeLocal, model: modelLocal });
calibration.pendingCube = null;
if (calibration.step === 'await-model-1') {
calibration.step = 'await-cube-2';
calibration.progress = 0.33;
} else if (calibration.step === 'await-model-2') {
calibration.progress = 0.75;
calibration.step = 'await-cube-3';
} else if (calibration.step === 'await-model-3') {
calibration.progress = 1;
calibration.verifyErrorDeg = computeVerifyError(calibration.pairs);
calibration.step = 'done';
}
notify();
}
/** Computes a quaternion R such that R*pairs[i].model ≈ pairs[i].cube using
* Gram-Schmidt to ortho-normalize both bases. Returns null if degenerate. */
export function computeCalibrationQuaternion(pairs: Pair[]): THREE.Quaternion | null {
if (pairs.length < 2) return null;
const n1 = pairs[0].model.clone().normalize();
const n2raw = pairs[1].model.clone();
let 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();
let 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);
// R = C * Nᵀ where columns of N are (n1,n2,n3) and C are (c1,c2,c3).
// In three.js Matrix4: makeBasis sets columns.
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;
}
/** Predicted angular error of the verify pair, in degrees. */
export function computeVerifyError(pairs: Pair[]): number {
if (pairs.length < 3) return NaN;
const q = computeCalibrationQuaternion(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));
}
-35
View File
@@ -92,39 +92,4 @@
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
background-size: 20px 20px;
}
.viewcube-wrapper {
transform-style: preserve-3d;
transform: rotateX(-30deg) rotateY(45deg);
}
.viewcube-face-top {
transform: rotateX(90deg) translateZ(28px);
backface-visibility: hidden;
}
.viewcube-face-front {
transform: translateZ(28px);
backface-visibility: hidden;
}
.viewcube-face-right {
transform: rotateY(90deg) translateZ(28px);
backface-visibility: hidden;
}
.viewcube-face-left {
transform: rotateY(-90deg) translateZ(28px);
backface-visibility: hidden;
}
.viewcube-face-back {
transform: rotateY(180deg) translateZ(28px);
backface-visibility: hidden;
}
.viewcube-face-bottom {
transform: rotateX(-90deg) translateZ(28px);
backface-visibility: hidden;
}
}
+26
View File
@@ -22,6 +22,10 @@ export function getModelLocalGroup(modelId: string | null | undefined): THREE.Ob
return localGroups.get(modelId);
}
export function getAllModelLocalGroups(): THREE.Object3D[] {
return Array.from(localGroups.values());
}
export function worldToModelLocal(
modelId: string | null | undefined,
p: { x: number; y: number; z: number },
@@ -33,3 +37,25 @@ export function worldToModelLocal(
g.worldToLocal(v);
return { x: v.x, y: v.y, z: v.z };
}
export function modelLocalToWorld(
modelId: string | null | undefined,
p: { x: number; y: number; z: number },
): { x: number; y: number; z: number } | null {
const g = getModelLocalGroup(modelId);
if (!g) return null;
g.updateWorldMatrix(true, false);
const v = new THREE.Vector3(p.x, p.y, p.z);
g.localToWorld(v);
return { x: v.x, y: v.y, z: v.z };
}
export function getModelWorldScaleFactor(modelId: string | null | undefined): number {
const g = getModelLocalGroup(modelId);
if (!g) return 1;
g.updateWorldMatrix(true, false);
const s = new THREE.Vector3();
g.getWorldScale(s);
const factor = (Math.abs(s.x) + Math.abs(s.y) + Math.abs(s.z)) / 3;
return Number.isFinite(factor) && factor > 1e-6 ? factor : 1;
}
-37
View File
@@ -1,37 +0,0 @@
import { createXRStore } from '@react-three/xr';
function loadXRFeatures() {
const defaults = {
handTracking: true,
planeDetection: true,
hitTest: true,
domOverlay: true,
anchors: true,
meshDetection: true,
depthSensing: true,
layers: true,
bodyTracking: true,
lightEstimation: true,
};
try {
const raw = localStorage.getItem('xrFeatures');
if (raw) return { ...defaults, ...JSON.parse(raw) };
} catch {}
return defaults;
}
const _xrf = loadXRFeatures();
export const xrStore = createXRStore({
hand: { left: true, right: true },
controller: { left: true, right: true },
handTracking: _xrf.handTracking,
planeDetection: _xrf.planeDetection,
hitTest: _xrf.hitTest,
domOverlay: _xrf.domOverlay,
anchors: _xrf.anchors,
meshDetection: _xrf.meshDetection,
depthSensing: _xrf.depthSensing,
layers: _xrf.layers,
bodyTracking: _xrf.bodyTracking,
});
+1 -3
View File
@@ -204,8 +204,6 @@ const Index = () => {
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
title="Importar arquivo de modelo 3D"
aria-label="Importar arquivo de modelo 3D"
onChange={handleFileUpload} />
@@ -329,7 +327,7 @@ const Index = () => {
{/* Footer */}
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
TrackSteelXR v1.06 Q.C. Inspection
TrackSteelXR v1.05 Q.C. Inspection
</p>
{showLogs && (
+9 -10
View File
@@ -4,11 +4,12 @@ import { Button } from "@/components/ui/button";
import { useModelStore } from "@/stores/useModelStore";
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
import { ViewerControls } from "@/components/ViewerControls";
import { ViewCube } from "@/components/ViewCube";
import { InspectionChecklist } from "@/components/InspectionChecklist";
import { FineTuningControls } from "@/components/FineTuningControls";
import { MeasurementsList } from "@/components/MeasurementsList";
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
import { ViewCube } from "@/components/ViewCube";
import { SectionCutPanel } from "@/components/SectionCutPanel";
import { ShareButton } from "@/components/ShareButton";
import { CloudLoader } from "@/components/CloudLoader";
import { SceneModelList } from "@/components/SceneModelList";
@@ -93,12 +94,11 @@ const Viewer = () => {
if (!model) return null;
const handleEnterXR = async () => {
try {
await xrStore.enterAR();
} catch (e) {
console.error('[XR] Falha ao entrar no AR:', e);
toast.error("Não foi possível iniciar a sessão AR. Certifique-se de que está usando um dispositivo compatível (como Meta Quest 3).");
if (!xrSupported) {
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
return;
}
navigate("/xr");
};
const canAddMore = models.length < maxModels;
@@ -110,8 +110,6 @@ const Viewer = () => {
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
title="Adicionar arquivo de modelo 3D"
aria-label="Adicionar arquivo de modelo 3D"
onChange={handleFileUpload}
/>
@@ -133,7 +131,7 @@ const Viewer = () => {
<ShareButton />
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
<Glasses className="h-4 w-4" />
Iniciar AR
Entrar em Modo XR
</Button>
</div>
</header>
@@ -142,8 +140,9 @@ const Viewer = () => {
{/* 3D Canvas with floating controls */}
<div className="relative flex-1">
<ModelViewerCanvas />
<ViewerControls />
<ViewCube />
<ViewerControls />
<SectionCutPanel />
</div>
{/* Side panel */}
+876
View File
@@ -0,0 +1,876 @@
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useGLTF, Grid, Html, Line, OrbitControls, Text } from '@react-three/drei';
import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
import { XRHud } from '@/components/XRHud';
import { ShareButton } from '@/components/ShareButton';
import { XRHudInWorld } from '@/components/three/XRHudInWorld';
import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror';
import { findNearestVertex } from '@/components/three/SmartMeasure';
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
import { XRGrabbable } from '@/components/three/XRGrabbable';
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
import { useDevKit } from '@/devkit/useDevKit';
import { DevPanel } from '@/devkit/DevPanel';
import { FakeControllers } from '@/devkit/FakeControllers';
import { VisibilityApplier } from '@/components/three/ModelViewer';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// --- Diagnóstico XR ---
console.log('[XR] Inicializando store...');
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
console.log('[XR] immersive-ar suportado:', supported);
});
}
// Lê flags de feature WebXR do localStorage (default: todas ON).
// O usuário liga/desliga pelo HUD AR (aba WebXR) para diagnosticar qual
// feature ativa o grid de segurança do Quest. Mudanças exigem sair e re-entrar do AR.
function loadXRFeatures() {
const defaults = {
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
anchors: true, meshDetection: true, depthSensing: true, layers: true,
bodyTracking: true, lightEstimation: true,
};
try {
const raw = localStorage.getItem('xrFeatures');
if (raw) return { ...defaults, ...JSON.parse(raw) };
} catch {}
return defaults;
}
const _xrf = loadXRFeatures();
console.log('[XR] Features:', _xrf);
const store = createXRStore({
hand: { left: true, right: true },
controller: { left: true, right: true },
handTracking: _xrf.handTracking,
planeDetection: _xrf.planeDetection,
hitTest: _xrf.hitTest,
domOverlay: _xrf.domOverlay,
anchors: _xrf.anchors,
meshDetection: _xrf.meshDetection,
depthSensing: _xrf.depthSensing,
layers: _xrf.layers,
bodyTracking: _xrf.bodyTracking,
});
// ─── XRModel ───────────────────────────────────────────
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
useEffect(() => {
let active = true;
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
if (isIfc) {
fetch(sceneModel.url)
.then((res) => res.arrayBuffer())
.then((buf) => parseIFCtoThree(buf))
.then((threeScene) => {
if (active) setRawScene(threeScene);
})
.catch((err) => console.error('[XRModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[XRModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
const ref = useRef<THREE.Group>(null);
useEffect(() => {
const g = ref.current;
if (!g) return;
registerModelLocalGroup(sceneModel.id, g);
return () => unregisterModelLocalGroup(sceneModel.id, g);
}, [sceneModel.id]);
const fineTuning = sceneModel.fineTuning;
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
const wireframeColor = useModelStore((s) => s.wireframeColor);
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
const checklist = useModelStore((s) => s.checklist);
const measureMode = useModelStore((s) => s.measureMode);
const modelInfo = useMemo(() => {
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
const box = new THREE.Box3().setFromObject(scene);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
return { size, center };
}, [scene]);
useEffect(() => {
if (!scene) return;
const hasRejected = checklist.some(i => i.status === 'rejected');
const allApproved = checklist.every(i => i.status === 'approved');
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (Array.isArray(child.material)) {
child.material = child.material.map(m => m.clone());
} else {
child.material = child.material.clone();
}
const toRemove: THREE.Object3D[] = [];
child.children.forEach(c => {
if (c.userData.__edgeLine) toRemove.push(c);
});
toRemove.forEach(c => {
if (c instanceof THREE.LineSegments) {
c.geometry.dispose();
(c.material as THREE.Material).dispose();
}
child.remove(c);
});
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (renderMode === 'edges' && !measureMode) {
mat.visible = false;
} else {
mat.visible = true;
const targetOpacity = measureMode ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
mat.wireframe = renderMode === 'wireframe';
if (renderMode === 'wireframe') {
mat.wireframeLinewidth = wireframeThickness;
mat.color.set(wireframeColor);
} else if (hasRejected) {
mat.color.setHSL(0, 0.7, 0.5);
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
mat.color.set(sceneModel.color);
}
}
mat.needsUpdate = true;
}
});
if ((renderMode === 'edges' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: measureMode ? '#00f3ff' : wireframeColor,
linewidth: measureMode ? 2 : wireframeThickness,
toneMapped: false,
transparent: true,
opacity: 0.95,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
child.add(lineSegments);
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
const s = fineTuning.scale ?? 1;
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
if (!sceneModel.visible) return null;
if (!scene) return null;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
ref={ref}
userData={{ modelId: sceneModel.id }}
position={[
-modelInfo.center.x + fineTuning.posX,
-modelInfo.center.y + fineTuning.posY,
-modelInfo.center.z + fineTuning.posZ,
]}
rotation={[rotXRad, rotYRad, rotZRad]}
scale={[s, s, s]}
>
<primitive object={scene} />
<XRLocalModelMeasurements modelId={sceneModel.id} />
</group>
</group>
);
}
/** Renders all NON-active models as static background (placement + grab apply only to active). */
function XRBackgroundModels() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
return (
<>
{models.filter(m => m.id !== activeId).map((m) => (
<XRModel key={m.id} sceneModel={m} />
))}
</>
);
}
/** Renders the currently-active model (the one wrapped by grab/placement). */
function XRActiveModel() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const active = models.find(m => m.id === activeId);
if (!active) return null;
return <XRModel sceneModel={active} />;
}
// ─── ControllerFineTuning ──────────────────────────────
// Joystick for slow numeric adjustments. Disabled while any grip is held
// (grab takes priority). Trigger pressure modulates speed: light = fine,
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
const { setFineTuning } = useModelStore();
const session = useXR((s) => s.session);
const isActiveLocked = useModelStore((s) => {
const a = s.models.find(m => m.id === s.activeModelId);
return !!a?.locked;
});
useFrame(() => {
if (!session || isActiveLocked) return;
const inputSources = session.inputSources;
if (!inputSources) return;
let leftAxes: number[] | null = null;
let rightAxes: number[] | null = null;
let leftTrig = 0;
let rightTrig = 0;
let gripHeld = false;
let anyGripHeld = false;
for (const source of inputSources) {
const gp = source.gamepad;
if (!gp) continue;
const gripBtn = gp.buttons[2];
const trigBtn = gp.buttons[1];
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
if (gripBtn?.pressed) gripHeld = true;
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
if (source.handedness === 'left' && gp.axes.length >= 4) {
leftAxes = [gp.axes[2], gp.axes[3]];
leftTrig = trigVal;
}
if (source.handedness === 'right' && gp.axes.length >= 4) {
rightAxes = [gp.axes[2], gp.axes[3]];
rightTrig = trigVal;
}
}
// Grab takes priority — joystick is disabled while grabbing
if (anyGripHeld) return;
// In freeMove mode, joystick always moves. Otherwise requires grip — but
// grip is reserved for grab now, so freeMove=false essentially disables.
if (!freeMove && !gripHeld) return;
const baseStep = 0.001;
const baseRot = 0.1;
const deadzone = 0.15;
// Trigger modulation: 0 → 0.3×, 1 → 3×
const speedL = 0.3 + leftTrig * 2.7;
const speedR = 0.3 + rightTrig * 2.7;
// Quadratic acceleration on thumbstick value
const curve = (v: number) => Math.sign(v) * v * v;
const modelStore = useModelStore.getState();
const ft = { ...modelStore.fineTuning };
if (leftAxes) {
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
}
if (rightAxes) {
// Right: rotY (horizontal) / Y height (vertical, up = up)
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
}
setFineTuning(ft);
});
return null;
}
// Componente de renderização de texto compatível e seguro para WebXR
function XRMeasurementText({ text, color }: { text: string; color: string }) {
const texture = useMemo(() => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
// Limpa e desenha fundo semi-transparente
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
ctx.beginPath();
// Desenha retângulo arredondado manualmente
const x = 0, y = 0, width = 256, height = 64, radius = 12;
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
// Desenha borda sutil
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.stroke();
// Texto
ctx.fillStyle = color;
ctx.font = 'bold 24px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 32);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}, [text, color]);
return (
<mesh renderOrder={999}>
<planeGeometry args={[0.08, 0.02]} />
<meshBasicMaterial
map={texture}
transparent
depthTest={false}
side={THREE.DoubleSide}
/>
</mesh>
);
}
// Componente de linha nativa seguro para WebXR
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
const points = useMemo(() => [
new THREE.Vector3(a[0], a[1], a[2]),
new THREE.Vector3(b[0], b[1], b[2])
], [a, b]);
const line = useMemo(() => {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: '#22c55e' });
return new THREE.Line(geometry, material);
}, [points]);
return <primitive object={line} />;
}
// Componente para renderizar medições que pertencem a um modelo específico
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
const measurements = useModelStore((s) => s.measurements);
const modelMeasurements = useMemo(() => {
return measurements.filter(m => m.modelId === modelId);
}, [measurements, modelId]);
return (
<>
{modelMeasurements.map((m) => {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<mesh position={a}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<mesh position={b}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</group>
);
})}
</>
);
}
// ─── Measurement overlay (reused from ModelViewer) ─────
function XRMeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
const snapPoint = useModelStore((s) => s.snapPoint);
const measureMode = useModelStore((s) => s.measureMode);
const globalMeasurements = useMemo(() => {
return measurements.filter(m => !m.modelId);
}, [measurements]);
return (
<>
{measureMode && snapPoint && (
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
</mesh>
)}
{measurePoints.length === 1 && (
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#e8a838" />
</mesh>
)}
{globalMeasurements.map((m) => {
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
const isHole = m.kind === 'hole';
const color = isHole ? '#f59e0b' : '#3b82f6';
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
return (
<group key={m.id}>
{!isHole && (
<>
<mesh position={a}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<mesh position={b}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color="#22c55e" />
</mesh>
<XRSafeLine a={a} b={b} />
</>
)}
<group position={mid}>
<XRMeasurementText text={text} color={color} />
</group>
</group>
);
})}
</>
);
}
// ─── Snap handler for XR raycasting ────────────────────
function XRSnapHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
useEffect(() => {
if (!measureMode) return;
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
};
gl.domElement.addEventListener('pointermove', onMove);
return () => gl.domElement.removeEventListener('pointermove', onMove);
}, [gl, mouse, measureMode]);
useFrame(() => {
// Em AR imersivo, a medição é feita pelo XRControllerMeasure (gatilho do controle).
// Evita raycast pesado por frame que travava o Quest ao ativar "Medir".
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
if (!measureMode) { setSnapPoint(null); return; }
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
if (obj.userData.__edgeLine) return false;
return obj instanceof THREE.Mesh;
});
if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
} else {
setSnapPoint(null);
}
});
// Click to measure
useEffect(() => {
if (!measureMode) return;
if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo
const onClick = (e: MouseEvent) => {
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
return;
}
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
};
gl.domElement.addEventListener('click', onClick);
return () => gl.domElement.removeEventListener('click', onClick);
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
useEffect(() => {
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
return () => { gl.domElement.style.cursor = 'grab'; };
}, [measureMode, gl]);
return null;
}
// ─── XR Grid ───────────────────────────────────────────
function XRGrid() {
const showGrid = useModelStore((s) => s.showGrid);
const gridY = useModelStore((s) => s.gridY);
if (!showGrid) return null;
return (
<Grid
position={[0, gridY, 0]}
infiniteGrid
cellSize={0.01}
sectionSize={0.1}
cellThickness={0.5}
sectionThickness={1}
cellColor="#334155"
sectionColor="#475569"
fadeDistance={5}
fadeStrength={1}
/>
);
}
/** Auto-follow grid to model bottom when enabled. */
function XRGridAutoFollower() {
const models = useModelStore((s) => s.models);
const { scene } = useThree();
useEffect(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const id = setTimeout(() => {
if (!useModelStore.getState().gridAutoFollow) return;
const box = new THREE.Box3();
let has = false;
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
obj.updateWorldMatrix(true, false);
const b = new THREE.Box3().setFromObject(obj);
if (isFinite(b.min.y)) {
if (!has) { box.copy(b); has = true; }
else box.union(b);
}
}
});
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
}, 150);
return () => clearTimeout(id);
}, [models, scene]);
return null;
}
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
// ─── XRSession Page ────────────────────────────────────
const XRSession = () => {
const navigate = useNavigate();
const { model, anchorMode, setAnchorMode } = useModelStore();
const isActiveLocked = useModelStore((s) => {
const a = s.models.find(m => m.id === s.activeModelId);
return !!a?.locked;
});
const [inXR, setInXR] = useState(false);
const [freeMove, setFreeMove] = useState(true);
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
const [snapToPlanes, setSnapToPlanes] = useState(true);
// Zoom por grip duplo — toggle real, padrão ligado.
const [allowScale, setAllowScale] = useState(true);
const [liveCode, setLiveCode] = useState<string | null>(null);
const [liveViewers, setLiveViewers] = useState(0);
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
const rigRef = useRef<THREE.Group>(null);
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
const devkit = useDevKit();
const [simXR, setSimXR] = useState(false);
const effectiveInXR = inXR || simXR;
useEffect(() => {
if (!model) navigate('/');
}, [model, navigate]);
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const session = state.session;
if (session && !inXR) {
console.log('[XR] ✅ Sessão AR ativa!');
setInXR(true);
setAnchorMode('manual');
toast.success('Sessão AR iniciada!');
session.addEventListener('end', () => {
console.log('[XR] ❌ Sessão AR encerrada');
setInXR(false);
});
}
});
return unsubscribe;
}, [inXR, setAnchorMode]);
const handleDownloadMarker = useCallback(async () => {
const url = await generateMarkerDownloadURL();
const a = document.createElement('a');
a.href = url;
a.download = 'TrackSteelXR_Marker.png';
a.click();
URL.revokeObjectURL(url);
toast.success('Marcador baixado — Imprima em 15×15cm');
}, []);
if (!model) return null;
return (
<div className="flex h-screen flex-col bg-background">
{/* Header */}
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
<ArrowLeft className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
<Home className="h-5 w-5" />
</Button>
<h1 className="font-mono text-sm font-semibold text-foreground">
Modo <span className="text-primary">XR Imersivo</span>
</h1>
</div>
<div className="flex items-center gap-2">
{!inXR && (
<>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
<Download className="h-3.5 w-3.5" />
<span className="font-mono text-xs">Marcador</span>
</Button>
<Button className="gap-2 glow-primary" onClick={() => {
console.log('[XR] Botão AR clicado');
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
}}>
Iniciar Sessão AR
</Button>
</>
)}
{inXR && (
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
XR Ativo
</span>
)}
</div>
</header>
{/* 3D Canvas */}
<div className="flex-1 relative">
<Canvas
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
className="!bg-transparent"
onCreated={({ gl }) => {
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
gl.setClearColor(0x000000, 0);
}}
>
<XR store={store}>
<ambientLight intensity={0.8} />
<directionalLight position={[3, 5, 3]} intensity={1.2} />
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
<group ref={rigRef}>
<XROrigin />
</group>
{effectiveInXR ? (
<>
{/* In XR (or SimXR): all models share the same placement origin so
switching active doesn't make others appear to "disappear".
Only the ACTIVE model receives grab transforms. */}
{simXR ? (
<group position={[0, 1.0, -1.2]}>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</group>
) : (
<XRHitTestPlacement
placementMode={placementMode}
snapToPlanes={snapToPlanes}
onPlace={() => {
setPlacementMode(false);
toast.success('Modelo posicionado na superfície!');
}}
>
<XRBackgroundModels />
<XRGrabbable
allowScale={allowScale}
lockedActive={isActiveLocked}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRActiveModel />
</XRGrabbable>
</XRHitTestPlacement>
)}
<XRMeasurementOverlay />
<XRControllerMeasure />
<ControllerLocomotion rigRef={rigRef} />
<VisibilityApplier />
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
<XRHudInWorld
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
liveCode={liveCode}
liveViewers={liveViewers}
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
/>
{/* Opaque mirror canvas used as the WebRTC source while in AR */}
<XRBroadcastMirror enabled />
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
{devkit && simXR && <FakeControllers />}
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
{simXR && (
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
target={[0, 1.0, -1.2]}
/>
)}
</>
) : (
<>
{/* Desktop preview (no headset): show all models with OrbitControls */}
<group position={[0, 0, 0]}>
<XRBackgroundModels />
<XRActiveModel />
</group>
<XRMeasurementOverlay />
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
target={[0, 0, 0]}
/>
</>
)}
<XRSnapHandler />
<XRGrid />
<XRGridAutoFollower />
</XR>
</Canvas>
{/* Floating DOM HUD overlay — only visible OUTSIDE passthrough.
Inside AR, the DOM is occluded by the headset compositor; the
in-world XRHudInWorld replaces it. */}
<div style={{ display: inXR ? 'none' : 'contents' }}>
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
/>
</div>
{/* Hidden ShareButton instance — registers window.__trackSteelStartLive
so the in-world Share tab can trigger broadcasts. Also keeps live
state in sync with the in-XR HUD. */}
<div style={{ display: 'none' }}>
<ShareButton
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
onViewerCountChange={setLiveViewers}
/>
</div>
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
{devkit && (
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
)}
</div>
</div>
);
};
export default XRSession;
+123 -55
View File
@@ -1,10 +1,22 @@
import { create } from 'zustand';
import { worldToModelLocal } from '@/lib/modelTransforms';
import { getModelWorldScaleFactor, worldToModelLocal } from '@/lib/modelTransforms';
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 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 {}
return 'ortho';
}
function saveCameraMode(m: 'ortho' | 'persp') {
try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch {}
}
export interface XRFeatureFlags {
handTracking: boolean;
@@ -33,7 +45,7 @@ function saveXRFeatures(f: XRFeatureFlags) {
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {}
}
interface StoredPlacement { fineTuning: FineTuning; }
interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; }
function loadPlacements(): Record<string, StoredPlacement> {
try {
const raw = localStorage.getItem(PLACEMENT_KEY);
@@ -41,10 +53,14 @@ function loadPlacements(): Record<string, StoredPlacement> {
} catch {}
return {};
}
function savePlacement(fileName: string, fineTuning: FineTuning) {
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
try {
const all = loadPlacements();
all[fileName] = { fineTuning };
const prev = all[fileName] ?? { fineTuning };
all[fileName] = {
fineTuning,
calibrationQuat: calibrationQuat === undefined ? prev.calibrationQuat ?? null : calibrationQuat,
};
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
} catch {}
}
@@ -120,6 +136,8 @@ export interface SceneModel {
visible: boolean;
locked: boolean;
fineTuning: FineTuning;
/** Local rotation (xyzw) that aligns the model's intrinsic axes to world axes. */
calibrationQuat: [number, number, number, number] | null;
}
const MAX_MODELS = 5;
@@ -127,12 +145,13 @@ const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899'];
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
type InspectionResult = 'pending' | 'approved' | 'rejected';
type RenderMode = 'solid' | 'edges';
type RenderMode = 'solid' | 'wireframe' | 'edges';
export interface MeasurePoint {
x: number;
y: number;
z: number;
/** Model hit by this point, used so saved dimensions attach to the right piece. */
modelId?: string;
}
@@ -154,9 +173,9 @@ export interface HoverInfo {
type: 'hole' | 'edge';
position: [number, number, number];
value: number; // mm (diameter or length)
modelId?: string;
/** For edges: the two endpoints in world coords. */
endpoints?: { a: MeasurePoint; b: MeasurePoint };
modelId?: string;
}
export interface ChecklistItem {
@@ -190,6 +209,9 @@ interface ModelStore {
clearAllModels: () => void;
maxModels: number;
/** Calibração (rotação local) ↔ ViewCube. Aplicada antes do fineTuning rotation. */
setCalibration: (id: string, quat: [number, number, number, number] | null) => void;
// ── Legacy single-model accessors (proxy to active) ─────────────
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
@@ -220,6 +242,10 @@ interface ModelStore {
renderMode: RenderMode;
setRenderMode: (mode: RenderMode) => void;
/** Camera projection used in the viewer. Ortho is the default for CAD-style work. */
cameraMode: 'ortho' | 'persp';
setCameraMode: (m: 'ortho' | 'persp') => void;
showGrid: boolean;
setShowGrid: (show: boolean) => void;
@@ -231,6 +257,24 @@ interface ModelStore {
setGridAutoFollow: (b: boolean) => void;
/** Nudge grid by delta meters and disable auto-follow. */
nudgeGridY: (delta: number) => void;
/** When true, the next click on a model face sets grid Y to that point. */
gridCalibMode: boolean;
setGridCalibMode: (b: boolean) => void;
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
sectionEnabled: { x: boolean; y: boolean; z: boolean };
sectionInvert: { x: boolean; y: boolean; z: boolean };
/** Cut level in meters (world coordinates) per axis. */
sectionLevel: { x: number; y: number; z: number };
sectionPanelOpen: boolean;
/** Opacity of the floating section panel (0.2 1). */
sectionPanelOpacity: number;
setSectionEnabled: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionInvert: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionLevel: (axis: 'x' | 'y' | 'z', v: number) => void;
setSectionPanelOpen: (open: boolean) => void;
setSectionPanelOpacity: (o: number) => void;
resetSection: () => void;
wireframeColor: string;
setWireframeColor: (color: string) => void;
@@ -299,20 +343,6 @@ interface ModelStore {
xrFeatures: XRFeatureFlags;
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
resetXRFeatures: () => void;
// ── WebXR HUD states persistent cache ────
xrHudOpen: boolean;
setXrHudOpen: (open: boolean) => void;
xrHudTab: string;
setXrHudTab: (tab: string) => void;
// ── WebXR Cenários Imersivos e Escalonamento ────
xrEnvironment: 'passthrough' | 'office' | 'field';
setXrEnvironment: (env: 'passthrough' | 'office' | 'field') => void;
xrScaleMode: 'tabletop' | 'realscale';
setXrScaleMode: (mode: 'tabletop' | 'realscale') => void;
desktopCameraMode: 'orbit' | 'walk';
setDesktopCameraMode: (mode: 'orbit' | 'walk') => void;
}
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
@@ -343,6 +373,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const fineTuning: FineTuning = saved?.fineTuning
? { ...saved.fineTuning }
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
const calibrationQuat = saved?.calibrationQuat ?? null;
const newModel: SceneModel = {
id,
fileName: m.fileName,
@@ -352,6 +383,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
visible: true,
locked: false,
fineTuning,
calibrationQuat,
};
const models = [...state.models, newModel];
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
@@ -412,6 +444,15 @@ export const useModelStore = create<ModelStore>((set, get) => ({
clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }),
setCalibration: (id, quat) => set((state) => {
const models = state.models.map(m => {
if (m.id !== id) return m;
savePlacement(m.fileName, m.fineTuning, quat);
return { ...m, calibrationQuat: quat };
});
return { models };
}),
// ── Legacy single-model proxies ─────────────
model: null,
setModel: (m) => {
@@ -425,14 +466,18 @@ export const useModelStore = create<ModelStore>((set, get) => ({
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
color: MODEL_COLORS[0], visible: true, locked: false,
fineTuning: { ...defaultFineTuning },
calibrationQuat: null,
};
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
},
fineTuning: { ...defaultFineTuning },
setFineTuning: (ft) => set((state) => {
const newFT = { ...state.fineTuning, ...ft };
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
// Lock guard: when the active model is locked, ignore any movement/scale change.
if (activeBefore?.locked) return {};
const newFT = { ...state.fineTuning, ...ft };
const models = activeId
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: newFT } : m)
: state.models;
@@ -442,8 +487,10 @@ export const useModelStore = create<ModelStore>((set, get) => ({
return { fineTuning: newFT, models };
}),
resetFineTuning: () => set((state) => {
const reset = { ...defaultFineTuning };
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const reset = { ...defaultFineTuning };
const models = activeId
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: reset } : m)
: state.models;
@@ -462,11 +509,17 @@ export const useModelStore = create<ModelStore>((set, get) => ({
setXrSupported: (xrSupported) => set({ xrSupported }),
scaleRatio: SCALE_PRESETS.find(p => p.label === '1:1')!,
setScaleRatio: (scaleRatio) => set({ scaleRatio }),
setScaleRatio: (scaleRatio) => set((state) => {
const activeBefore = state.models.find(m => m.id === state.activeModelId);
if (activeBefore?.locked) return {};
return { scaleRatio };
}),
scaleResetNonce: 0,
resetScale: () => set((state) => {
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
const models = activeId
? state.models.map(m => m.id === activeId
? { ...m, fineTuning: { ...m.fineTuning, scale: 1 } }
@@ -489,6 +542,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
renderMode: 'solid',
setRenderMode: (renderMode) => set({ renderMode }),
cameraMode: loadCameraMode(),
setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); },
showGrid: false,
setShowGrid: (showGrid) => set({ showGrid }),
@@ -497,11 +553,29 @@ export const useModelStore = create<ModelStore>((set, get) => ({
gridAutoFollow: true,
setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }),
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
gridCalibMode: false,
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
wireframeColor: '#8899aa',
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
sectionPanelOpen: false,
sectionPanelOpacity: 0.9,
setSectionEnabled: (axis, on) => set((s) => ({ sectionEnabled: { ...s.sectionEnabled, [axis]: on } })),
setSectionInvert: (axis, on) => set((s) => ({ sectionInvert: { ...s.sectionInvert, [axis]: on } })),
setSectionLevel: (axis, v) => set((s) => ({ sectionLevel: { ...s.sectionLevel, [axis]: v } })),
setSectionPanelOpen: (sectionPanelOpen) => set({ sectionPanelOpen }),
setSectionPanelOpacity: (sectionPanelOpacity) => set({ sectionPanelOpacity }),
resetSection: () => set({
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
}),
wireframeColor: '#00f3ff',
setWireframeColor: (wireframeColor) => set({ wireframeColor }),
wireframeThickness: 1,
wireframeThickness: 2,
setWireframeThickness: (wireframeThickness) => set({ wireframeThickness }),
edgeThresholdAngle: 15,
@@ -574,27 +648,25 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const points = [...state.measurePoints, p];
if (points.length === 2) {
const [a, b] = points;
const dx = (a.x - b.x) * 1000;
const dy = (a.y - b.y) * 1000;
const dz = (a.z - b.z) * 1000;
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
const factor = state.scaleRatio?.factor ?? 1;
const distanceMM = distanceWorldMM / factor;
const modelId = a.modelId ?? b.modelId ?? state.activeModelId ?? undefined;
const modelId = (a.modelId && a.modelId === b.modelId ? a.modelId : state.activeModelId) ?? undefined;
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
const attached = !!(aConv && bConv);
const pa = attached ? aConv! : a;
const pb = attached ? bConv! : b;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const distanceMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
distanceWorldMM,
distanceMM,
factor,
modelId,
attached
});
const measurement: Measurement = {
id: `m_${Date.now()}`,
pointA: attached ? aConv! : a,
pointB: attached ? bConv! : b,
pointA: pa,
pointB: pb,
distanceMM,
modelId: attached ? modelId : undefined,
};
@@ -619,6 +691,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
}),
registerHoverMeasurement: (info) => set((state) => {
const modelId = info.modelId ?? state.activeModelId ?? undefined;
const modelScale = modelId ? getModelWorldScaleFactor(modelId) : 1;
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
const conv = modelId ? worldToModelLocal(modelId, p) : null;
return conv ? { point: conv, attached: true } : { point: p, attached: false };
@@ -629,13 +702,14 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const cw: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
const c = toLocal(cw);
attached = c.attached;
const valueMM = attached ? info.value / modelScale : info.value;
measurement = {
id: `m_${Date.now()}`,
pointA: c.point,
pointB: c.point,
distanceMM: info.value,
distanceMM: valueMM,
kind: 'hole',
label: `Ø ${info.value.toFixed(1)}`,
label: `Ø ${valueMM.toFixed(1)}`,
modelId: attached ? modelId : undefined,
};
} else {
@@ -644,13 +718,19 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const a = toLocal(aw);
const b = toLocal(bw);
attached = a.attached && b.attached;
const pa = attached ? a.point : aw;
const pb = attached ? b.point : bw;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const valueMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
measurement = {
id: `m_${Date.now()}`,
pointA: attached ? a.point : aw,
pointB: attached ? b.point : bw,
distanceMM: info.value,
pointA: pa,
pointB: pb,
distanceMM: valueMM,
kind: 'edge',
label: `${info.value.toFixed(1)} mm`,
label: `${valueMM.toFixed(1)} mm`,
modelId: attached ? modelId : undefined,
};
}
@@ -687,16 +767,4 @@ export const useModelStore = create<ModelStore>((set, get) => ({
saveXRFeatures(next);
set({ xrFeatures: next });
},
xrHudOpen: true,
setXrHudOpen: (open) => set({ xrHudOpen: open }),
xrHudTab: 'scene',
setXrHudTab: (tab) => set({ xrHudTab: tab }),
xrEnvironment: 'passthrough',
setXrEnvironment: (env) => set({ xrEnvironment: env }),
xrScaleMode: 'realscale',
setXrScaleMode: (mode) => set({ xrScaleMode: mode }),
desktopCameraMode: 'orbit',
setDesktopCameraMode: (mode) => set({ desktopCameraMode: mode }),
}));