Compare commits
177 Commits
13c1ad315d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd45e473d | |||
| fc10c77346 | |||
| c62101d17b | |||
| 9a3b59baf2 | |||
| 426bf13241 | |||
| 7f5b4427d7 | |||
| 97900b5ff7 | |||
| 6e80b23687 | |||
| 002682e6f3 | |||
| 230e373f8a | |||
| 10f00e921d | |||
| 11547f7bc7 | |||
| 0126d8d78c | |||
| b47fb872b5 | |||
| ba33180a00 | |||
| 66e30b2609 | |||
| dc64c83cc8 | |||
| a2ac72cfe7 | |||
| 126bc6f80d | |||
| 8154951fa5 | |||
| c798179560 | |||
| 43ce36e7a6 | |||
| 6372047b9d | |||
| 37bac18534 | |||
| 46ae916a4a | |||
| 7f2edbbb3c | |||
| 6954b9e30f | |||
| ba2531b440 | |||
| a03f3e8ba8 | |||
| e4a9c8623a | |||
| 7489b611b0 | |||
| 0ba00648e8 | |||
| 4dbfb7f277 | |||
| 451951b4ca | |||
| 966418659d | |||
| c0c4ad6e11 | |||
| 54a3f83375 | |||
| b6912bd070 | |||
| 5a08868b83 | |||
| 7ff4ed3176 | |||
| 153c91dfc9 | |||
| 8348262d45 | |||
| 540eb2f31f | |||
| c820808184 | |||
| 0f017d0e4b | |||
| 1e4bd78128 | |||
| d47fb9dd37 | |||
| b270ca0bfd | |||
| f36fd63bee | |||
| 9b7f237afc | |||
| aff19146f0 | |||
| c924531f82 | |||
| 30d5d7429f | |||
| 54d93c85d8 | |||
| a08ab50917 | |||
| 03411d9224 | |||
| 2e70d2e133 | |||
| 54e44dd737 | |||
| 70cdfac679 | |||
| 24417cb8a7 | |||
| e8f4e2b18a | |||
| f876e868d6 | |||
| 6590cc17c2 | |||
| 35881620b1 | |||
| b949a7c227 | |||
| 00854f0255 | |||
| 35ce901b74 | |||
| ac41cd99a0 | |||
| 0baed4a871 | |||
| 61591dc84e | |||
| cf4a317439 | |||
| 408eba84e0 | |||
| c16beadb32 | |||
| 7dff9bbeff | |||
| fffdd020f3 | |||
| 887e172526 | |||
| 6ef7c3ae8f | |||
| 883f022883 | |||
| 2e7402691a | |||
| 3c903c1ff1 | |||
| e390fd0f59 | |||
| f38bf58bd2 | |||
| de8558b81c | |||
| c953db0b26 | |||
| 45d09ab7a3 | |||
| ce86a280ba | |||
| 976d6f42da | |||
| ac30db8241 | |||
| bdd5a92c17 | |||
| e032f1f3ad | |||
| 1222d284e3 | |||
| 7208e2bc56 | |||
| 7efac5448b | |||
| eb34692fdd | |||
| 2754260962 | |||
| 93c693f139 | |||
| 649d4ecdca | |||
| bc55e5edd7 | |||
| 5790df1541 | |||
| 083c9d83ae | |||
| c3d0f6c01d | |||
| d02afa1797 | |||
| a769d51d9f | |||
| 15f2bdfbb2 | |||
| 06f0b68756 | |||
| 69b9a6b97e | |||
| 515cb97fbd | |||
| 0150bad2a8 | |||
| 6887a24f0d | |||
| 15734e3713 | |||
| 92e91f288f | |||
| e11fd411eb | |||
| 123229c66b | |||
| 885ea304bf | |||
| e444a3a20f | |||
| d110dcb32c | |||
| 5d5a637816 | |||
| ff7f69191d | |||
| ba87af34f4 | |||
| 8ffb67689f | |||
| cbeb186a60 | |||
| 0fc3783eb9 | |||
| be95b150b2 | |||
| c285736ff2 | |||
| 2317df9aac | |||
| bb50821ec5 | |||
| 075ad7aeb8 | |||
| 7ff09b918b | |||
| f3c958b78c | |||
| 8c4483ecf6 | |||
| 3bf90dafd7 | |||
| 60af63393a | |||
| 704b878691 | |||
| ade8119590 | |||
| 4b2baa3fb4 | |||
| 4942030721 | |||
| eee5b80d34 | |||
| 9b450d2c35 | |||
| b3ddfb2939 | |||
| ad318ea52e | |||
| 5e7ff33a3e | |||
| ce642ff505 | |||
| ca12fffbf6 | |||
| d6f684fc3c | |||
| 49fb9cff15 | |||
| 936e631b96 | |||
| 27c12d34f9 | |||
| 37828d75f2 | |||
| 6757100f92 | |||
| a19fcdf792 | |||
| f1ed0248d0 | |||
| faec7d3e4a | |||
| ef2199b972 | |||
| 8b70b16fde | |||
| 7d8520fd32 | |||
| 634baa4e1f | |||
| 5f9f5b5bc8 | |||
| 29c6c765d3 | |||
| f4adaf88f9 | |||
| cbfeee3078 | |||
| 3b42d44ff7 | |||
| 000d4d05e8 | |||
| e5e1eac733 | |||
| 4cf157b7cd | |||
| dda94bd107 | |||
| 5bea0ff9ee | |||
| 7918f5f0b1 | |||
| 1d788cdca3 | |||
| 7203127a1d | |||
| f603823c91 | |||
| 718da6ecc8 | |||
| e2f2eb41db | |||
| 52ef40f8fb | |||
| 9970571fad | |||
| d58d506af8 | |||
| aeecd4d7ec | |||
| 79b95ce3c8 |
@@ -1,81 +0,0 @@
|
|||||||
## Objetivo
|
|
||||||
|
|
||||||
Alinhar o comportamento dos controles Touch Plus (Meta Quest 3) ao padrão pedido:
|
|
||||||
|
|
||||||
- **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).
|
|
||||||
|
|
||||||
## Mapeamento alvo
|
|
||||||
|
|
||||||
```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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mudanças no código
|
|
||||||
|
|
||||||
### 1. `src/components/three/XRGrabbable.tsx`
|
|
||||||
|
|
||||||
- **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`).
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -4,9 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- TODO: Set the document title to the name of your application -->
|
<!-- TODO: Set the document title to the name of your application -->
|
||||||
<title>TrackSteelXR</title>
|
<title>SteelXR</title>
|
||||||
<meta name="description" content="VR tool inspection industrial Q.C.">
|
<meta name="description" content="VR tool inspection industrial Q.C.">
|
||||||
<meta name="author" content="Lovable" />
|
<meta name="author" content="Lovable" />
|
||||||
|
<link rel="icon" type="image/png" href="/iconeXR_transparente.png" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- TODO: Update og:title to match your application name -->
|
<!-- TODO: Update og:title to match your application name -->
|
||||||
|
|
||||||
@@ -17,8 +19,8 @@
|
|||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@Lovable" />
|
<meta name="twitter:site" content="@Lovable" />
|
||||||
<meta name="twitter:image" content="https://storage.googleapis.com/gpt-engineer-file-uploads/attachments/og-images/a850c4e4-56e7-4358-91d7-b33ddcd600ac?Expires=1772161962&GoogleAccessId=go-api-on-aws%40gpt-engineer-390607.iam.gserviceaccount.com&Signature=Q%2B%2F2EsHIygjuVQKvjQWWytqElhVjmehhRIdWJ1eYRremdUzoUIv36NuXp042g0lBzszDBa14EbkCTYykUEMpqrclTioA2035aKEY%2BR39jqYm7oBKXCm2rs2vopnW2ahZYf8SjX5SPqxwFzaO%2FvJt71rMU0A6PzihBNRa96ezpIhxGqzpbZe9zP4PtyRhg03aCp5sJytJp9bBOphzXb9YGeY58XWWqjAqEO1tctOGh25hZsYLgEU61wy3xnehDc8kZ%2FK0MpRpxuGibae5ypjDtGbXf9G5LjSWYecVY5pWOjnO6ZSo1CL8DaWtD%2FU3V0MszZ8f4SETk7fQAh0GR4b8Sg%3D%3D">
|
<meta name="twitter:image" content="https://storage.googleapis.com/gpt-engineer-file-uploads/attachments/og-images/a850c4e4-56e7-4358-91d7-b33ddcd600ac?Expires=1772161962&GoogleAccessId=go-api-on-aws%40gpt-engineer-390607.iam.gserviceaccount.com&Signature=Q%2B%2F2EsHIygjuVQKvjQWWytqElhVjmehhRIdWJ1eYRremdUzoUIv36NuXp042g0lBzszDBa14EbkCTYykUEMpqrclTioA2035aKEY%2BR39jqYm7oBKXCm2rs2vopnW2ahZYf8SjX5SPqxwFzaO%2FvJt71rMU0A6PzihBNRa96ezpIhxGqzpbZe9zP4PtyRhg03aCp5sJytJp9bBOphzXb9YGeY58XWWqjAqEO1tctOGh25hZsYLgEU61wy3xnehDc8kZ%2FK0MpRpxuGibae5ypjDtGbXf9G5LjSWYecVY5pWOjnO6ZSo1CL8DaWtD%2FU3V0MszZ8f4SETk7fQAh0GR4b8Sg%3D%3D">
|
||||||
<meta property="og:title" content="TrackSteelXR">
|
<meta property="og:title" content="SteelXR">
|
||||||
<meta name="twitter:title" content="TrackSteelXR">
|
<meta name="twitter:title" content="SteelXR">
|
||||||
<meta property="og:description" content="VR tool inspection industrial Q.C.">
|
<meta property="og:description" content="VR tool inspection industrial Q.C.">
|
||||||
<meta name="twitter:description" content="VR tool inspection industrial Q.C.">
|
<meta name="twitter:description" content="VR tool inspection industrial Q.C.">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 281 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.6",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from PIL import Image
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def remove_black_background(img_path, out_path, t_low=32, t_high=80):
|
||||||
|
# Carregar imagem e garantir modo RGBA
|
||||||
|
img = Image.open(img_path).convert("RGBA")
|
||||||
|
data = np.array(img)
|
||||||
|
|
||||||
|
# Extrair canais
|
||||||
|
r, g, b, a = data[:,:,0], data[:,:,1], data[:,:,2], data[:,:,3]
|
||||||
|
|
||||||
|
# O valor de brilho de cada pixel é dado pelo máximo dos canais R, G, B
|
||||||
|
max_rgb = np.fmax(np.fmax(r, g), b)
|
||||||
|
|
||||||
|
# Criar nova imagem com o mesmo tamanho
|
||||||
|
new_data = np.zeros_like(data)
|
||||||
|
|
||||||
|
# Calcular o novo alpha com base na rampa de threshold
|
||||||
|
# Se max_rgb <= t_low -> alpha = 0
|
||||||
|
# Se max_rgb >= t_high -> alpha = max_rgb (ou 255 se quisermos opacidade total)
|
||||||
|
# Se t_low < max_rgb < t_high -> interpolação linear
|
||||||
|
|
||||||
|
new_alpha = np.zeros_like(max_rgb, dtype=float)
|
||||||
|
|
||||||
|
# Máscara para pixels totalmente opacos (ou usando o brilho original)
|
||||||
|
mask_full = max_rgb >= t_high
|
||||||
|
# Opcionalmente, definimos opacidade 255 para o miolo brilhante do logotipo para ficar bem nítido
|
||||||
|
new_alpha[mask_full] = 255.0
|
||||||
|
|
||||||
|
# Máscara para pixels de transição (antialiasing)
|
||||||
|
mask_trans = (max_rgb > t_low) & (max_rgb < t_high)
|
||||||
|
new_alpha[mask_trans] = 255.0 * (max_rgb[mask_trans] - t_low) / (t_high - t_low)
|
||||||
|
|
||||||
|
# Converter alpha final para uint8
|
||||||
|
final_alpha = np.clip(new_alpha, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
# Máscara de pixels visíveis
|
||||||
|
mask_visible = final_alpha > 0
|
||||||
|
|
||||||
|
# Inicializar os canais de cores com zeros
|
||||||
|
new_data[:,:,0] = 0
|
||||||
|
new_data[:,:,1] = 0
|
||||||
|
new_data[:,:,2] = 0
|
||||||
|
new_data[:,:,3] = final_alpha
|
||||||
|
|
||||||
|
# Aplicar unmultiplying alpha nas cores para remover o halo escuro e clarear pixels de transição
|
||||||
|
alpha_float = final_alpha.astype(float)
|
||||||
|
for i in range(3):
|
||||||
|
channel = data[:,:,i].astype(float)
|
||||||
|
adjusted = np.zeros_like(channel)
|
||||||
|
# Onde for visível, recuperamos a cor original.
|
||||||
|
# Para evitar divisão por zero, usamos a máscara de pixels visíveis
|
||||||
|
adjusted[mask_visible] = np.minimum(255, (channel[mask_visible] * 255.0 / np.maximum(1.0, max_rgb[mask_visible])))
|
||||||
|
new_data[:,:,i] = adjusted.astype(np.uint8)
|
||||||
|
|
||||||
|
# Salvar a nova imagem
|
||||||
|
out_img = Image.fromarray(new_data, "RGBA")
|
||||||
|
out_img.save(out_path, "PNG")
|
||||||
|
print(f"Processada com sucesso: {img_path} -> {out_path} (T_low: {t_low}, T_high: {t_high})")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# O logotipo tem fundo com brilho aproximado de 28
|
||||||
|
remove_black_background("logotipo_steelXR.png", "public/logotipo_steelXR_transparente.png", t_low=32, t_high=75)
|
||||||
|
# O ícone tem fundo com brilho de 9
|
||||||
|
remove_black_background("iconeXR.png", "public/iconeXR_transparente.png", t_low=12, t_high=50)
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 205 KiB |
@@ -5,7 +5,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
|
import XRSession from "./pages/XRSession";
|
||||||
import Watch from "./pages/Watch";
|
import Watch from "./pages/Watch";
|
||||||
|
import MeetingRoom from "./pages/MeetingRoom";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import "@/lib/remoteLogger";
|
import "@/lib/remoteLogger";
|
||||||
|
|
||||||
@@ -20,7 +22,10 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
|
<Route path="/xr" element={<XRSession />} />
|
||||||
<Route path="/watch/:code" element={<Watch />} />
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
|
<Route path="/meeting" element={<MeetingRoom />} />
|
||||||
|
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { addRecentFile } from '@/lib/recentFiles';
|
|||||||
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
||||||
|
|
||||||
const PRESETS: { label: string; url: string }[] = [
|
const PRESETS: { label: string; url: string }[] = [
|
||||||
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
|
{ label: 'Demo IFC (SteelXR)', url: DEFAULT_URL },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
|
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { ShieldCheck, Ruler, Scissors, ArrowRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function LandingContent() {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-4xl mx-auto space-y-12 py-12">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="text-center space-y-6 px-4">
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-foreground">
|
||||||
|
Controle total da qualidade em obras metálicas e civis — <br className="hidden md:block" />
|
||||||
|
<span className="text-primary">direto no canteiro, em Realidade Aumentada.</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
visualize. Meça. inspire confiança.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<section className="grid md:grid-cols-3 gap-6 px-4">
|
||||||
|
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Inspeção por Checklist Digital com Gravação de Voz
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Elimine planilhas de papel. Faça inspeções de qualidade diretamente no modelo 3D, com aprovação ou reprovação de cada item e anotações por voz — tudo rastreável e auditável.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Ruler className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Medições Precisas em AR com Snap Inteligente
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
O controlador XR identifica automaticamente vértices, arestas e furos nos modelos IFC. O resultado são medições em milímetros reais, sem depender de trena ou cálculos manuais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border bg-card p-6 space-y-4 hover:border-primary/50 transition-colors">
|
||||||
|
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Scissors className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Cortes de Seção Interativos para Análise Estrutural
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Visualize o interior de estruturas metálicas com cortes dinâmicos nos eixos X, Y e Z — sem desmontar nada. Ideal para validar projetos complexos antes da montagem.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Final CTA Section */}
|
||||||
|
<section className="px-4 text-center space-y-6">
|
||||||
|
<div className="rounded-2xl border border-border bg-gradient-to-b from-card to-muted/20 p-8 md:p-12">
|
||||||
|
<p className="text-muted-foreground max-w-2xl mx-auto text-base md:text-lg leading-relaxed">
|
||||||
|
Atrasos por erros de medição, retrabalho por falhas de comunicação e perdas por não conformidade são o pesadelo de qualquer gestão de produção metalmecânica ou obra civil. O SteelXR coloca o modelo 3D IFC no canteiro — em AR — para que engenheiros e gestores tomem decisões precisas no momento certo. <span className="font-semibold text-foreground">Menos retrabalho, mais produtividade, entregas dentro do prazo.</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex justify-center gap-4">
|
||||||
|
<Button size="lg" className="gap-2">
|
||||||
|
Começar Agora
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" variant="outline" className="gap-2">
|
||||||
|
Ver Demonstração
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
|
|||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: 'TrackSteelXR — Sessão ao vivo',
|
title: 'SteelXR — Sessão ao vivo',
|
||||||
text: 'Acompanhe a inspeção ao vivo:',
|
text: 'Acompanhe a inspeção ao vivo:',
|
||||||
url: link,
|
url: link,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,191 +1,318 @@
|
|||||||
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 { useModelStore } from '@/stores/useModelStore';
|
||||||
import { Button } from '@/components/ui/button';
|
import { getModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
import { RotateCw, RefreshCw, Move, HelpCircle } from 'lucide-react';
|
import { Check, RotateCcw, Maximize2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function ViewCube() {
|
/** Builds a square canvas texture with a label centered on it. */
|
||||||
const { activeModelId, models, setFineTuning } = useModelStore();
|
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
||||||
const activeModel = models.find((m) => m.id === activeModelId);
|
const size = 256;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
// Estados locais para controlar uma rotação interativa ou visualização do cubo
|
c.width = size;
|
||||||
const [hoveredFace, setHoveredFace] = useState<string | null>(null);
|
c.height = size;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
if (!activeModelId || !activeModel) return null;
|
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
||||||
|
grad.addColorStop(0, accent ? '#1a2742' : '#101725');
|
||||||
const alignToFace = (face: string) => {
|
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
|
||||||
let rotX = 0;
|
ctx.fillStyle = grad;
|
||||||
let rotY = 0;
|
ctx.fillRect(0, 0, size, size);
|
||||||
let rotZ = 0;
|
ctx.strokeStyle = '#00f3ff';
|
||||||
|
ctx.lineWidth = 6;
|
||||||
switch (face) {
|
ctx.strokeRect(3, 3, size - 6, size - 6);
|
||||||
case 'TOPO':
|
ctx.fillStyle = '#e6faff';
|
||||||
rotX = 0;
|
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
|
||||||
rotY = 0;
|
ctx.textAlign = 'center';
|
||||||
rotZ = 0;
|
ctx.textBaseline = 'middle';
|
||||||
break;
|
ctx.shadowColor = '#00f3ff';
|
||||||
case 'FRENTE':
|
ctx.shadowBlur = 18;
|
||||||
rotX = 90;
|
ctx.fillText(label, size / 2, size / 2);
|
||||||
rotY = 0;
|
const tex = new THREE.CanvasTexture(c);
|
||||||
rotZ = 0;
|
tex.anisotropy = 4;
|
||||||
break;
|
tex.needsUpdate = true;
|
||||||
case 'DIREITA':
|
return tex;
|
||||||
rotX = 90;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = -90;
|
|
||||||
break;
|
|
||||||
case 'ESQUERDA':
|
|
||||||
rotX = 90;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 90;
|
|
||||||
break;
|
|
||||||
case 'ATRÁS':
|
|
||||||
rotX = -90;
|
|
||||||
rotY = 180;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
case 'BASE':
|
|
||||||
rotX = 180;
|
|
||||||
rotY = 0;
|
|
||||||
rotZ = 0;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
|
||||||
toast.success(`Alinhado para vista ${face}`);
|
{ 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] },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 materials = useMemo(() => {
|
||||||
|
return FACE_DEFS.map((f, i) => {
|
||||||
|
const tex = makeFaceTexture(f.label, false);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: tex });
|
||||||
|
mat.userData.baseTex = tex;
|
||||||
|
mat.userData.hoverTex = makeFaceTexture(f.label, true);
|
||||||
|
mat.userData.faceIndex = i;
|
||||||
|
return mat;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
materials.forEach((m) => {
|
||||||
|
m.userData.baseTex?.dispose?.();
|
||||||
|
m.userData.hoverTex?.dispose?.();
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
materials.forEach((m, i) => {
|
||||||
|
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
||||||
|
m.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [hover, materials]);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
modelQuat.identity();
|
||||||
|
return modelQuat;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ajusta a rotação para o múltiplo de 90 graus mais próximo
|
useFrame(() => {
|
||||||
const snapToNearest90 = () => {
|
const main = mainCameraRef.current;
|
||||||
const ft = activeModel.fineTuning;
|
const controls = mainControlsRef.current;
|
||||||
const rotX = Math.round(ft.rotX / 90) * 90;
|
if (!main) return;
|
||||||
const rotY = Math.round(ft.rotY / 90) * 90;
|
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
|
||||||
const rotZ = Math.round(ft.rotZ / 90) * 90;
|
const dir = new THREE.Vector3().subVectors(main.position, target).normalize();
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
localCam.position.copy(dir.multiplyScalar(3));
|
||||||
toast.success(`Snap Ortogonal (90°): [${rotX}°, ${rotY}°, ${rotZ}°]`);
|
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 = () => {
|
// 70% of previous 1.4 → ~0.98
|
||||||
setFineTuning({ posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 });
|
const SIZE = 0.98;
|
||||||
toast.success('Posição e rotação restauradas para a origem');
|
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 (
|
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">
|
<div
|
||||||
{/* Título do Widget */}
|
className="absolute right-3 top-3 z-20 w-[120px] flex flex-col items-stretch gap-1.5"
|
||||||
<span className="font-mono text-[9px] font-semibold text-muted-foreground uppercase tracking-widest text-center">
|
style={{ pointerEvents: 'auto' }}
|
||||||
Alinhamento 3D
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Cubo 3D Visual */}
|
{/* Step hint while calibrating */}
|
||||||
<div className="relative h-20 w-20 flex items-center justify-center my-2 group">
|
{(isCalibrating || isDone) && (
|
||||||
{/* Container do Cubo Isométrico */}
|
<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">
|
||||||
<div className="h-14 w-14 transition-transform duration-500 ease-out viewcube-wrapper">
|
{STEP_HINTS[calibration.step] ?? ''}
|
||||||
{/* TOPO (Z+) */}
|
{isDone && Number.isFinite(calibration.verifyErrorDeg) && (
|
||||||
<div
|
<div className="mt-0.5 text-success">
|
||||||
onClick={() => alignToFace('TOPO')}
|
erro {calibration.verifyErrorDeg.toFixed(1)}°
|
||||||
onMouseEnter={() => setHoveredFace('TOPO')}
|
</div>
|
||||||
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 ${
|
</div>
|
||||||
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'
|
)}
|
||||||
}`}
|
|
||||||
|
{/* 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"
|
||||||
>
|
>
|
||||||
TOPO
|
<RotateCcw className="h-2.5 w-2.5" /> reset
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* FRENTE (Y-) */}
|
{/* Botão de Tela Cheia */}
|
||||||
<div
|
<button
|
||||||
onClick={() => alignToFace('FRENTE')}
|
type="button"
|
||||||
onMouseEnter={() => setHoveredFace('FRENTE')}
|
onClick={() => {
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
const container = document.getElementById("canvas-container");
|
||||||
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 ${
|
if (container) {
|
||||||
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'
|
container.requestFullscreen().catch((err) => {
|
||||||
}`}
|
toast.error("Não foi possível entrar em tela cheia: " + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex h-7 w-full items-center justify-center gap-1 rounded border border-primary/40 bg-background/40 font-mono text-[10px] uppercase tracking-widest text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
title="Entrar em modo tela cheia"
|
||||||
>
|
>
|
||||||
FRENTE
|
<Maximize2 className="h-3 w-3" /> Tela Cheia
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* DIREITA (X+) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('DIREITA')}
|
|
||||||
onMouseEnter={() => setHoveredFace('DIREITA')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-right ${
|
|
||||||
hoveredFace === 'DIREITA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
DIR
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ESQUERDA (X-) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('ESQUERDA')}
|
|
||||||
onMouseEnter={() => setHoveredFace('ESQUERDA')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-left ${
|
|
||||||
hoveredFace === 'ESQUERDA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
ESQ
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ATRÁS (Y+) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('ATRÁS')}
|
|
||||||
onMouseEnter={() => setHoveredFace('ATRÁS')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-back ${
|
|
||||||
hoveredFace === 'ATRÁS' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-750/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
ATRÁS
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BASE (Z-) */}
|
|
||||||
<div
|
|
||||||
onClick={() => alignToFace('BASE')}
|
|
||||||
onMouseEnter={() => setHoveredFace('BASE')}
|
|
||||||
onMouseLeave={() => setHoveredFace(null)}
|
|
||||||
className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-bottom ${
|
|
||||||
hoveredFace === 'BASE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-900/80 text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
BASE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botões Rápidos de Alinhamento */}
|
|
||||||
<div className="flex flex-col gap-1.5 w-full mt-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={snapToNearest90}
|
|
||||||
title="Corrige a orientação aproximando para o múltiplo de 90° mais próximo"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-3 w-3 text-primary animate-pulse" />
|
|
||||||
<span>Alinhar 90° (Snap)</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-[10px] h-7 gap-1 bg-white/5 border-white/10 hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={resetAllTransforms}
|
|
||||||
title="Zera a posição e a rotação da peça atual"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3 text-destructive" />
|
|
||||||
<span>Resetar Origem</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info compacta */}
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[8px] text-muted-foreground mt-0.5 select-none justify-center">
|
|
||||||
<span>X: {Math.round(activeModel.fineTuning.rotX)}°</span>
|
|
||||||
<span>Y: {Math.round(activeModel.fineTuning.rotY)}°</span>
|
|
||||||
<span>Z: {Math.round(activeModel.fineTuning.rotZ)}°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } from 'lucide-react';
|
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors, Footprints } from 'lucide-react';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -11,25 +11,16 @@ import * as THREE from 'three';
|
|||||||
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
|
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
|
||||||
|
|
||||||
const WIRE_COLORS = [
|
const WIRE_COLORS = [
|
||||||
{ label: 'Cinza', value: '#8899aa' },
|
{ label: 'Ciano (radiante)', value: '#00f3ff' },
|
||||||
{ label: 'Vermelho', value: '#ef4444' },
|
{ label: 'Vermelho', value: '#ef4444' },
|
||||||
{ label: 'Verde', value: '#22c55e' },
|
{ label: 'Verde', value: '#22c55e' },
|
||||||
{ label: 'Azul', value: '#3b82f6' },
|
{ label: 'Azul', value: '#3b82f6' },
|
||||||
{ label: 'Amarelo', value: '#eab308' },
|
{ label: 'Amarelo', value: '#eab308' },
|
||||||
{ label: 'Laranja', value: '#f97316' },
|
{ label: 'Laranja', value: '#f97316' },
|
||||||
{ label: 'Branco', value: '#e2e8f0' },
|
{ 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() {
|
export function ViewerControls() {
|
||||||
const {
|
const {
|
||||||
opacity, setOpacity, renderMode, setRenderMode,
|
opacity, setOpacity, renderMode, setRenderMode,
|
||||||
@@ -40,14 +31,16 @@ export function ViewerControls() {
|
|||||||
wireframeThickness, setWireframeThickness,
|
wireframeThickness, setWireframeThickness,
|
||||||
edgeThresholdAngle, setEdgeThresholdAngle,
|
edgeThresholdAngle, setEdgeThresholdAngle,
|
||||||
positionMode, setPositionMode,
|
positionMode, setPositionMode,
|
||||||
|
walkMode, setWalkMode,
|
||||||
activeModelId, models,
|
activeModelId, models,
|
||||||
selectionMode, setSelectionMode,
|
selectionMode, setSelectionMode,
|
||||||
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
||||||
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
||||||
desktopCameraMode, setDesktopCameraMode,
|
cameraMode, setCameraMode,
|
||||||
xrEnvironment, setXrEnvironment,
|
gridCalibMode, setGridCalibMode, setGridAutoFollow,
|
||||||
xrScaleMode, setXrScaleMode,
|
sectionPanelOpen, setSectionPanelOpen, sectionEnabled, resetSection,
|
||||||
} = useModelStore();
|
} = useModelStore();
|
||||||
|
const sectionActive = sectionEnabled.x || sectionEnabled.y || sectionEnabled.z;
|
||||||
const activeModel = models.find(m => m.id === activeModelId);
|
const activeModel = models.find(m => m.id === activeModelId);
|
||||||
const hasSelection = selectedElementKeys.size > 0;
|
const hasSelection = selectedElementKeys.size > 0;
|
||||||
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
||||||
@@ -117,31 +110,100 @@ export function ViewerControls() {
|
|||||||
<span className="font-mono text-xs">Grid</span>
|
<span className="font-mono text-xs">Grid</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Scale presets */}
|
{/* Grid calibration: click a face on the piece to anchor the grid there */}
|
||||||
<ScaleSelector />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
variant={gridCalibMode ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 h-9"
|
className="gap-2 h-9"
|
||||||
|
disabled={!activeModelId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
const next = !gridCalibMode;
|
||||||
setRenderMode(next);
|
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">
|
<span className="font-mono text-xs">
|
||||||
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
{gridCalibMode ? 'Clique na face…' : 'Calibrar Grid'}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Wireframe/Edges settings popover */}
|
{/* Section cuts (X/Y/Z clipping planes) */}
|
||||||
{renderMode !== 'solid' && (
|
<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>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2 h-9">
|
<Button variant="outline" size="sm" className="gap-2 h-9">
|
||||||
<Palette className="h-3.5 w-3.5" />
|
<Palette className="h-3.5 w-3.5" />
|
||||||
<div className={`h-3 w-3 rounded-full border border-muted-foreground/30 ${colorClassMap[wireframeColor] || 'bg-[#8899aa]'}`} />
|
<div className="h-3 w-3 rounded-full border border-muted-foreground/30" style={{ backgroundColor: wireframeColor }} />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-56 space-y-3" side="top" align="start">
|
<PopoverContent className="w-56 space-y-3" side="top" align="start">
|
||||||
@@ -151,7 +213,8 @@ export function ViewerControls() {
|
|||||||
{WIRE_COLORS.map((c) => (
|
{WIRE_COLORS.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.value}
|
key={c.value}
|
||||||
className={`h-6 w-6 rounded-full border-2 transition-all ${colorClassMap[c.value] || 'bg-[#8899aa]'} ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
|
className={`h-6 w-6 rounded-full border-2 transition-all ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
onClick={() => setWireframeColor(c.value)}
|
onClick={() => setWireframeColor(c.value)}
|
||||||
title={c.label}
|
title={c.label}
|
||||||
/>
|
/>
|
||||||
@@ -206,6 +269,25 @@ export function ViewerControls() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Walk mode */}
|
||||||
|
<Button
|
||||||
|
variant={walkMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 h-9"
|
||||||
|
onClick={() => {
|
||||||
|
if (!walkMode) {
|
||||||
|
toast.info("Modo Caminhada: Use WASD para andar, Q/E para subir/descer e o mouse para olhar. ESC para sair.");
|
||||||
|
}
|
||||||
|
setWalkMode(!walkMode);
|
||||||
|
}}
|
||||||
|
title="Modo primeira pessoa (WASD mover, Q/E subir/descer)"
|
||||||
|
>
|
||||||
|
<Footprints className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
Caminhar
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Measure tool */}
|
{/* Measure tool */}
|
||||||
<div className="flex items-end gap-1.5">
|
<div className="flex items-end gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -293,74 +375,6 @@ export function ViewerControls() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seletor de Câmera (Orbit vs Walk) */}
|
|
||||||
<Button
|
|
||||||
variant={desktopCameraMode === 'walk' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 h-9"
|
|
||||||
onClick={() => {
|
|
||||||
const next = desktopCameraMode === 'orbit' ? 'walk' : 'orbit';
|
|
||||||
setDesktopCameraMode(next);
|
|
||||||
if (next === 'walk') {
|
|
||||||
toast.info("Passeio 3D Ativo: clique no visualizador para prender o mouse. WASD = andar, Q/E = subir/descer, ESC = soltar mouse.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Alternar entre câmera orbital e passeio em primeira pessoa"
|
|
||||||
>
|
|
||||||
{desktopCameraMode === 'walk' ? <Footprints className="h-3.5 w-3.5" /> : <Compass className="h-3.5 w-3.5" />}
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{desktopCameraMode === 'walk' ? 'Passeio 3D' : 'Câmera'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Seletor de Cenário 2D */}
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2 h-9" title="Alterar cenário de fundo do visualizador">
|
|
||||||
<Map className="h-3.5 w-3.5" />
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{xrEnvironment === 'passthrough' ? 'Estúdio' : xrEnvironment === 'office' ? 'Escritório' : 'Campo Aberto'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-56 space-y-2" side="top" align="start">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className="font-mono text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1">
|
|
||||||
Cenário de Fundo
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'passthrough' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => setXrEnvironment('passthrough')}
|
|
||||||
>
|
|
||||||
🌌 Estúdio (Padrão)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'office' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('office');
|
|
||||||
setXrScaleMode('tabletop');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🏢 Escritório (VR Mesa)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={xrEnvironment === 'field' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
className="justify-start font-mono text-xs h-8"
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('field');
|
|
||||||
setXrScaleMode('realscale');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🌳 Campo Aberto (VR Grama)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Screenshot */}
|
{/* Screenshot */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ const helpItems = [
|
|||||||
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sólido / Bordas',
|
title: 'Sólido / Wire / Bordas',
|
||||||
icon: '🔲',
|
icon: '🔲',
|
||||||
desc: 'Alterna modos de renderização: Sólido (preenchido) e Bordas (destaque visual de arestas com transparência).',
|
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Medir',
|
title: 'Medir',
|
||||||
@@ -342,15 +342,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
variant={renderMode === 'edges' ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
onClick={() => {
|
onClick={() => setRenderMode(renderMode === 'edges' ? 'solid' : 'edges')}
|
||||||
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
|
||||||
setRenderMode(next);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
<Box className="h-3.5 w-3.5" />
|
||||||
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
{renderMode === 'edges' ? 'Bordas' : 'Sólido'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,116 +1,230 @@
|
|||||||
import { useRef, RefObject } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import { useXR } from '@react-three/xr';
|
import { useXR } from '@react-three/xr';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest 3 thumbstick locomotion (rig-relative).
|
* Mapeamento de Locomoção por Teletransporte Parabólico via Controle XR:
|
||||||
*
|
*
|
||||||
* Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard):
|
* - Incline e segure o Thumbstick (analógico) de qualquer controle para frente.
|
||||||
* • Para frente (axes[1] < -0.7), libera (axis volta a ~0) → teletransporta
|
* Um arco parabólico surgirá projetado a partir da mira física do controle.
|
||||||
* para o ponto onde o usuário está olhando (raycast da câmera). Se não
|
* - Ao soltar o direcional, você se teletransporta para o local.
|
||||||
* houver geometria atingida, dá um passo de 0.5 m na direção do olhar.
|
* - Para entrar em escala real dentro do modelo, aponte diretamente para ele. A escala
|
||||||
* • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar.
|
* será automaticamente definida para 1:1 ao pousar.
|
||||||
* • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig.
|
|
||||||
*
|
|
||||||
* NÃO lê grip ou trigger — esses são responsabilidade exclusiva de
|
|
||||||
* XRGrabbable e XRControllerMeasure, respectivamente.
|
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
||||||
rigRef: RefObject<THREE.Group>;
|
rigRef: React.RefObject<THREE.Group>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEAD = 0.7;
|
const DEAD = 0.7;
|
||||||
const RELEASE = 0.3;
|
const RELEASE = 0.3;
|
||||||
const SNAP_DEG = 30;
|
|
||||||
const STEP_BACK = 0.3;
|
|
||||||
const STEP_FWD_NO_HIT = 0.5;
|
|
||||||
|
|
||||||
export function ControllerLocomotion({ rigRef }: Props) {
|
export function ControllerLocomotion({ rigRef }: Props) {
|
||||||
const { scene, camera } = useThree();
|
const { camera, gl } = useThree();
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
|
|
||||||
const fwdPushed = useRef(false);
|
const fwdPushed = useRef(false);
|
||||||
const backLatched = useRef(false);
|
|
||||||
const rotLatched = useRef<0 | -1 | 1>(0);
|
|
||||||
const raycaster = useRef(new THREE.Raycaster());
|
const raycaster = useRef(new THREE.Raycaster());
|
||||||
|
|
||||||
useFrame(() => {
|
const [arcLine] = useState(() => {
|
||||||
const rig = rigRef.current;
|
const geom = new THREE.BufferGeometry();
|
||||||
if (!rig || !session) return;
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
color: '#06b6d4',
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
const line = new THREE.Line(geom, mat);
|
||||||
|
line.visible = false;
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
let stickX = 0;
|
// Refs visuais
|
||||||
|
const reticleRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
// Estados temporários do teletransporte
|
||||||
|
const lastTeleportTarget = useRef<THREE.Vector3 | null>(null);
|
||||||
|
const lastTeleportHitModel = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||||
|
const rig = rigRef.current;
|
||||||
|
if (!rig || !session || !frame) {
|
||||||
|
arcLine.visible = false;
|
||||||
|
if (reticleRef.current) reticleRef.current.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceSpace = gl.xr.getReferenceSpace();
|
||||||
|
if (!referenceSpace) return;
|
||||||
|
|
||||||
|
let activeSource: XRInputSource | null = null;
|
||||||
let stickY = 0;
|
let stickY = 0;
|
||||||
|
|
||||||
|
// Procura qualquer controle com analógico inclinado para a frente
|
||||||
for (const src of session.inputSources) {
|
for (const src of session.inputSources) {
|
||||||
const gp = src.gamepad;
|
const gp = src.gamepad;
|
||||||
if (!gp || gp.axes.length < 4) continue;
|
if (gp && gp.axes.length >= 4) {
|
||||||
// Prefer left stick for locomotion
|
const sy = gp.axes[3] ?? 0; // Eixo Y vertical do joystick
|
||||||
if (src.handedness === 'left') {
|
if (sy < -DEAD) {
|
||||||
stickX = gp.axes[2] ?? 0;
|
activeSource = src;
|
||||||
stickY = gp.axes[3] ?? 0;
|
stickY = sy;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (src.handedness === 'right' && stickX === 0 && stickY === 0) {
|
|
||||||
stickX = gp.axes[2] ?? 0;
|
|
||||||
stickY = gp.axes[3] ?? 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Snap-rotate (lateral) ──────────────────────────────────────
|
// Se encontramos um controle ativo mirando/inclinado para frente
|
||||||
if (Math.abs(stickX) > DEAD) {
|
if (activeSource && stickY < -DEAD) {
|
||||||
const dir: -1 | 1 = stickX > 0 ? 1 : -1;
|
|
||||||
if (rotLatched.current !== dir) {
|
|
||||||
rotLatched.current = dir;
|
|
||||||
rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180;
|
|
||||||
}
|
|
||||||
} else if (Math.abs(stickX) < RELEASE) {
|
|
||||||
rotLatched.current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Back step ──────────────────────────────────────────────────
|
|
||||||
if (stickY > DEAD) {
|
|
||||||
if (!backLatched.current) {
|
|
||||||
backLatched.current = true;
|
|
||||||
const headDir = new THREE.Vector3();
|
|
||||||
camera.getWorldDirection(headDir);
|
|
||||||
headDir.y = 0;
|
|
||||||
if (headDir.lengthSq() > 1e-6) {
|
|
||||||
headDir.normalize();
|
|
||||||
rig.position.addScaledVector(headDir, -STEP_BACK);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (stickY < RELEASE) {
|
|
||||||
backLatched.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Forward → teleport on release ─────────────────────────────
|
|
||||||
if (stickY < -DEAD) {
|
|
||||||
fwdPushed.current = true;
|
fwdPushed.current = true;
|
||||||
} else if (fwdPushed.current && stickY > -RELEASE) {
|
|
||||||
fwdPushed.current = false;
|
|
||||||
// Cast from camera forward to find target on real/virtual geometry
|
|
||||||
const origin = new THREE.Vector3();
|
const origin = new THREE.Vector3();
|
||||||
camera.getWorldPosition(origin);
|
|
||||||
const dir = new THREE.Vector3();
|
const dir = new THREE.Vector3();
|
||||||
|
|
||||||
|
// Obtém pose do controle usando o targetRaySpace (mira física do controle)
|
||||||
|
const ctrlPose = frame.getPose(activeSource.targetRaySpace, referenceSpace);
|
||||||
|
if (ctrlPose) {
|
||||||
|
const m = new THREE.Matrix4().fromArray(ctrlPose.transform.matrix);
|
||||||
|
// Aplica a matriz de mundo do rig para alinhar a origem física com a locomoção do rig
|
||||||
|
m.premultiply(rig.matrixWorld);
|
||||||
|
origin.setFromMatrixPosition(m);
|
||||||
|
const q = new THREE.Quaternion().setFromRotationMatrix(m);
|
||||||
|
dir.set(0, 0, -1).applyQuaternion(q).normalize();
|
||||||
|
} else {
|
||||||
|
// Fallback para direção do olhar se pose falhar
|
||||||
|
camera.getWorldPosition(origin);
|
||||||
camera.getWorldDirection(dir);
|
camera.getWorldDirection(dir);
|
||||||
raycaster.current.set(origin, dir);
|
}
|
||||||
raycaster.current.far = 30;
|
|
||||||
const hits = raycaster.current.intersectObjects(scene.children, true);
|
// Parâmetros da simulação do arco parabólico
|
||||||
const hit = hits.find((h) => {
|
const points: THREE.Vector3[] = [];
|
||||||
const o = h.object;
|
const currentPos = origin.clone();
|
||||||
if (!(o instanceof THREE.Mesh)) return false;
|
const velocity = dir.clone().multiplyScalar(7.5); // velocidade do tiro do arco
|
||||||
if (o.userData.__edgeLine) return false;
|
const gravity = new THREE.Vector3(0, -9.8, 0);
|
||||||
return true;
|
const dt = 0.025; // passo da física
|
||||||
});
|
let hitPoint: THREE.Vector3 | null = null;
|
||||||
const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT));
|
let hitModel = false;
|
||||||
|
const modelsObjects = getAllModelLocalGroups();
|
||||||
|
|
||||||
|
points.push(currentPos.clone());
|
||||||
|
|
||||||
|
// Executa a trajetória balística
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
const nextPos = currentPos.clone().addScaledVector(velocity, dt);
|
||||||
|
velocity.addScaledVector(gravity, dt);
|
||||||
|
|
||||||
|
const segDir = new THREE.Vector3().subVectors(nextPos, currentPos);
|
||||||
|
const segLen = segDir.length();
|
||||||
|
|
||||||
|
if (segLen > 0.001) {
|
||||||
|
segDir.normalize();
|
||||||
|
raycaster.current.set(currentPos, segDir);
|
||||||
|
raycaster.current.far = segLen;
|
||||||
|
|
||||||
|
// 1. Verifica colisão com modelos 3D
|
||||||
|
const hits = raycaster.current.intersectObjects(modelsObjects, true);
|
||||||
|
const firstHit = hits.find(
|
||||||
|
(h) => h.object instanceof THREE.Mesh && !h.object.userData.__edgeLine
|
||||||
|
);
|
||||||
|
|
||||||
|
if (firstHit) {
|
||||||
|
hitPoint = firstHit.point.clone();
|
||||||
|
hitModel = true;
|
||||||
|
points.push(hitPoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verifica colisão com o chão/grid de calibração plano infinito em Y = gridY
|
||||||
|
const gridY = useModelStore.getState().gridY;
|
||||||
|
if (
|
||||||
|
(currentPos.y >= gridY && nextPos.y <= gridY) ||
|
||||||
|
(currentPos.y <= gridY && nextPos.y >= gridY)
|
||||||
|
) {
|
||||||
|
const t = (gridY - currentPos.y) / (nextPos.y - currentPos.y);
|
||||||
|
hitPoint = new THREE.Vector3().lerpVectors(currentPos, nextPos, t);
|
||||||
|
points.push(hitPoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos.copy(nextPos);
|
||||||
|
points.push(currentPos.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalTarget = hitPoint ?? points[points.length - 1];
|
||||||
|
lastTeleportTarget.current = finalTarget.clone();
|
||||||
|
lastTeleportHitModel.current = hitModel;
|
||||||
|
|
||||||
|
// Atualiza a geometria do arco
|
||||||
|
arcLine.geometry.setFromPoints(points);
|
||||||
|
arcLine.geometry.attributes.position.needsUpdate = true;
|
||||||
|
arcLine.geometry.computeBoundingSphere();
|
||||||
|
arcLine.visible = true;
|
||||||
|
|
||||||
|
// Atualiza o retículo
|
||||||
|
if (reticleRef.current) {
|
||||||
|
const localTarget = finalTarget.clone();
|
||||||
|
rig.worldToLocal(localTarget);
|
||||||
|
reticleRef.current.position.copy(localTarget);
|
||||||
|
reticleRef.current.visible = true;
|
||||||
|
|
||||||
|
// Visual premium: Rosa/Fúcsia para escala real (modelo), Ciano para o grid/chão
|
||||||
|
const mat = reticleRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
if (hitModel) {
|
||||||
|
mat.color.set('#d946ef');
|
||||||
|
reticleRef.current.scale.setScalar(1.2);
|
||||||
|
} else {
|
||||||
|
mat.color.set('#06b6d4');
|
||||||
|
reticleRef.current.scale.setScalar(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Quando soltar o analógico
|
||||||
|
if (fwdPushed.current) {
|
||||||
|
fwdPushed.current = false;
|
||||||
|
|
||||||
|
const targetWorld = lastTeleportTarget.current;
|
||||||
|
const hitModel = lastTeleportHitModel.current;
|
||||||
|
|
||||||
|
if (targetWorld) {
|
||||||
const camWorld = new THREE.Vector3();
|
const camWorld = new THREE.Vector3();
|
||||||
camera.getWorldPosition(camWorld);
|
camera.getWorldPosition(camWorld);
|
||||||
const delta = new THREE.Vector3().subVectors(target, camWorld);
|
|
||||||
delta.y = 0; // preserva altura do usuário
|
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
|
||||||
|
|
||||||
|
if (hitModel) {
|
||||||
|
// "Para entrar em escala real dentro do modelo, aponte para ele"
|
||||||
|
useModelStore.getState().resetScale();
|
||||||
|
// Ajusta o rig para que o pé fique no Y do modelo, mantendo a altura da câmera
|
||||||
|
rig.position.y = targetWorld.y;
|
||||||
|
delta.y = 0;
|
||||||
|
toast.success("Entrando na peça em Escala Real (1:1)!");
|
||||||
|
} else {
|
||||||
|
delta.y = 0; // preserva a altura vertical do usuário
|
||||||
|
}
|
||||||
|
|
||||||
rig.position.add(delta);
|
rig.position.add(delta);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oculta feixe e retículo
|
||||||
|
arcLine.visible = false;
|
||||||
|
if (reticleRef.current) reticleRef.current.visible = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return (
|
||||||
|
<>
|
||||||
|
{/* Arco parabólico premium */}
|
||||||
|
<primitive object={arcLine} />
|
||||||
|
|
||||||
|
{/* Retículo de pouso */}
|
||||||
|
<mesh ref={reticleRef} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.07, 0.09, 32]} />
|
||||||
|
<meshBasicMaterial color="#06b6d4" transparent opacity={0.85} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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 />;
|
|
||||||
}
|
|
||||||
@@ -270,7 +270,7 @@ export function findNearestEdgeSegment(
|
|||||||
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
|
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
|
||||||
const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
|
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 bestDist = Infinity;
|
||||||
let bestMid: THREE.Vector3 | null = null;
|
let bestMid: THREE.Vector3 | null = null;
|
||||||
@@ -340,7 +340,7 @@ export function detectCircularEdgeAtPoint(
|
|||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
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 hitScreen = worldPoint.clone().project(camera);
|
||||||
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
|
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 _ap = new THREE.Vector3();
|
||||||
const _closest = 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 {
|
function pointToSegmentDistSq3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number {
|
||||||
_ab.subVectors(b, a);
|
_ab.subVectors(b, a);
|
||||||
_ap.subVectors(p, a);
|
_ap.subVectors(p, a);
|
||||||
@@ -503,7 +513,7 @@ export function findNearestEdgeSegment3D(
|
|||||||
mesh: THREE.Mesh,
|
mesh: THREE.Mesh,
|
||||||
worldPoint: THREE.Vector3,
|
worldPoint: THREE.Vector3,
|
||||||
thresholdMeters: number = 0.05
|
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;
|
let edgeLines: THREE.LineSegments | null = null;
|
||||||
mesh.children.forEach(c => {
|
mesh.children.forEach(c => {
|
||||||
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
|
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
|
||||||
@@ -523,12 +533,13 @@ export function findNearestEdgeSegment3D(
|
|||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
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 invMatrix = new THREE.Matrix4().copy(matrix).invert();
|
||||||
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
||||||
|
|
||||||
let bestDistSq = Infinity;
|
let bestDistSq = Infinity;
|
||||||
let bestIndex = -1;
|
let bestIndex = -1;
|
||||||
|
const bestClosestLocal = new THREE.Vector3();
|
||||||
const thresholdSq = thresholdMeters * thresholdMeters;
|
const thresholdSq = thresholdMeters * thresholdMeters;
|
||||||
|
|
||||||
const segCount = posAttr.count / 2;
|
const segCount = posAttr.count / 2;
|
||||||
@@ -539,11 +550,13 @@ export function findNearestEdgeSegment3D(
|
|||||||
aLocal.fromBufferAttribute(posAttr, i * 2);
|
aLocal.fromBufferAttribute(posAttr, i * 2);
|
||||||
bLocal.fromBufferAttribute(posAttr, i * 2 + 1);
|
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) {
|
if (distSq < bestDistSq) {
|
||||||
bestDistSq = distSq;
|
bestDistSq = distSq;
|
||||||
bestIndex = i;
|
bestIndex = i;
|
||||||
|
bestClosestLocal.copy(_closest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,8 +564,9 @@ export function findNearestEdgeSegment3D(
|
|||||||
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
|
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
|
||||||
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
|
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
|
||||||
const midpoint = a.clone().add(b).multiplyScalar(0.5);
|
const midpoint = a.clone().add(b).multiplyScalar(0.5);
|
||||||
|
const snapPoint = bestClosestLocal.clone().applyMatrix4(matrix);
|
||||||
const lengthMM = a.distanceTo(b) * 1000;
|
const lengthMM = a.distanceTo(b) * 1000;
|
||||||
return { midpoint, lengthMM, a, b };
|
return { midpoint, snapPoint, lengthMM, a, b };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -579,7 +593,7 @@ export function detectCircularEdgeAtPoint3D(
|
|||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
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 invMatrix = new THREE.Matrix4().copy(matrix).invert();
|
||||||
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
|
||||||
|
|
||||||
@@ -652,6 +666,6 @@ export function resolveSnap3D(
|
|||||||
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
|
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
|
||||||
if (v) return { point: v, type: 'vertex' };
|
if (v) return { point: v, type: 'vertex' };
|
||||||
const e = findNearestEdgeSegment3D(mesh, hitPoint, edgeThresholdMeters);
|
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' };
|
return { point: hitPoint.clone(), type: 'surface' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
detectCircularEdgeAtPoint3D,
|
detectCircularEdgeAtPoint3D,
|
||||||
findNearestEdgeSegment3D
|
findNearestEdgeSegment3D
|
||||||
} from './SmartMeasure';
|
} from './SmartMeasure';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const TRIG_ON = 0.7;
|
const TRIG_ON = 0.7;
|
||||||
const TRIG_OFF = 0.3;
|
const TRIG_OFF = 0.3;
|
||||||
@@ -26,8 +27,8 @@ function isPickableModelMesh(o: THREE.Object3D): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findModelId(obj: THREE.Object3D): string | undefined {
|
function getModelIdFromObject(o: THREE.Object3D): string | undefined {
|
||||||
let cur: THREE.Object3D | null = obj;
|
let cur: THREE.Object3D | null = o;
|
||||||
while (cur) {
|
while (cur) {
|
||||||
if (cur.userData?.modelId) return cur.userData.modelId as string;
|
if (cur.userData?.modelId) return cur.userData.modelId as string;
|
||||||
cur = cur.parent;
|
cur = cur.parent;
|
||||||
@@ -35,22 +36,6 @@ function findModelId(obj: THREE.Object3D): string | undefined {
|
|||||||
return 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.
|
* Right-controller trigger driven measurement for AR.
|
||||||
*
|
*
|
||||||
@@ -72,6 +57,7 @@ export function XRControllerMeasure() {
|
|||||||
const lTrigState = useRef(false);
|
const lTrigState = useRef(false);
|
||||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
const raycaster = useRef(new THREE.Raycaster());
|
const raycaster = useRef(new THREE.Raycaster());
|
||||||
const tmpOrigin = useRef(new THREE.Vector3());
|
const tmpOrigin = useRef(new THREE.Vector3());
|
||||||
const tmpDir = useRef(new THREE.Vector3());
|
const tmpDir = useRef(new THREE.Vector3());
|
||||||
@@ -85,23 +71,33 @@ export function XRControllerMeasure() {
|
|||||||
);
|
);
|
||||||
const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false }));
|
const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false }));
|
||||||
const tipColor = useRef(new THREE.Color('#22c55e'));
|
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)
|
// Dwell detection for hover-based smart measurement (1 s)
|
||||||
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
|
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
|
||||||
const dwellStart = useRef(0);
|
const dwellStart = useRef(0);
|
||||||
const dwellFired = useRef(false);
|
const dwellFired = useRef(false);
|
||||||
const lastSnapKind = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Throttling: raycast + snap analysis are heavy on dense IFC models and
|
// Throttling: raycast + snap analysis are heavy on dense IFC models and
|
||||||
// running them every frame at 72fps can stall the Quest right after
|
// running them every frame at 72fps can stall the Quest right after
|
||||||
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
|
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
|
||||||
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
|
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
|
||||||
|
const hitTestSourceRef = useRef<XRHitTestSource | null>(null);
|
||||||
|
const hitTestRequestedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||||
const measureMode = useModelStore.getState().measureMode;
|
const measureMode = useModelStore.getState().measureMode;
|
||||||
const selectionMode = useModelStore.getState().selectionMode;
|
const selectionMode = useModelStore.getState().selectionMode;
|
||||||
|
|
||||||
if (laserRef.current) laserRef.current.visible = false;
|
if (laserRef.current) laserRef.current.visible = false;
|
||||||
if (tipRef.current) tipRef.current.visible = false;
|
if (tipRef.current) tipRef.current.visible = false;
|
||||||
if ((!measureMode && !selectionMode) || !frame) return;
|
if ((!measureMode && !selectionMode) || !frame) {
|
||||||
|
if (hitTestRequestedRef.current) {
|
||||||
|
hitTestSourceRef.current = null;
|
||||||
|
hitTestRequestedRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = frame.session;
|
const session = frame.session;
|
||||||
const refSpace = gl.xr.getReferenceSpace();
|
const refSpace = gl.xr.getReferenceSpace();
|
||||||
@@ -135,6 +131,10 @@ export function XRControllerMeasure() {
|
|||||||
if (!pose) return;
|
if (!pose) return;
|
||||||
|
|
||||||
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
|
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
|
||||||
|
const xrRig = useModelStore.getState().xrRig;
|
||||||
|
if (xrRig) {
|
||||||
|
m.premultiply(xrRig.matrixWorld);
|
||||||
|
}
|
||||||
tmpOrigin.current.setFromMatrixPosition(m);
|
tmpOrigin.current.setFromMatrixPosition(m);
|
||||||
tmpQuat.current.setFromRotationMatrix(m);
|
tmpQuat.current.setFromRotationMatrix(m);
|
||||||
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
|
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
|
||||||
@@ -144,11 +144,14 @@ export function XRControllerMeasure() {
|
|||||||
|
|
||||||
let snappedPoint: THREE.Vector3 | null = null;
|
let snappedPoint: THREE.Vector3 | null = null;
|
||||||
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
|
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
|
||||||
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
|
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; 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 hits = raycaster.current.intersectObjects(scene.children, true);
|
||||||
const hit = hits.find((h) => {
|
const hit = hits.find((h) => {
|
||||||
const o = h.object;
|
const o = h.object;
|
||||||
|
if (!(o instanceof THREE.Mesh)) return false;
|
||||||
if (!isPickableModelMesh(o)) return false;
|
if (!isPickableModelMesh(o)) return false;
|
||||||
if (o.userData.__edgeLine) return false;
|
if (o.userData.__edgeLine) return false;
|
||||||
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
||||||
@@ -158,9 +161,10 @@ export function XRControllerMeasure() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hit && hit.object instanceof THREE.Mesh) {
|
if (hit && hit.object instanceof THREE.Mesh) {
|
||||||
|
hitModelId = getModelIdFromObject(hit.object);
|
||||||
const dist = tmpOrigin.current.distanceTo(hit.point);
|
const dist = tmpOrigin.current.distanceTo(hit.point);
|
||||||
const vertexThreshold = Math.max(0.06, dist * 0.08);
|
const vertexThreshold = Math.max(0.08, dist * 0.12);
|
||||||
const edgeThreshold = Math.max(0.08, dist * 0.10);
|
const edgeThreshold = Math.max(0.10, dist * 0.14);
|
||||||
|
|
||||||
// Snap to existing registered hole centers first
|
// Snap to existing registered hole centers first
|
||||||
const existing = useModelStore.getState().measurements;
|
const existing = useModelStore.getState().measurements;
|
||||||
@@ -184,7 +188,7 @@ export function XRControllerMeasure() {
|
|||||||
if (circle) {
|
if (circle) {
|
||||||
snappedPoint = circle.center;
|
snappedPoint = circle.center;
|
||||||
snapKind = 'hole';
|
snapKind = 'hole';
|
||||||
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
|
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center, modelId: hitModelId };
|
||||||
} else {
|
} else {
|
||||||
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
|
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
|
||||||
snappedPoint = snap.point;
|
snappedPoint = snap.point;
|
||||||
@@ -193,7 +197,7 @@ export function XRControllerMeasure() {
|
|||||||
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
|
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
|
||||||
if (seg) {
|
if (seg) {
|
||||||
const lenMM = seg.a.distanceTo(seg.b) * 1000;
|
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 +207,27 @@ export function XRControllerMeasure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Haptic feedback on snap state changes ────────────────────────
|
const strongSnapKind = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole' ? snapKind : null;
|
||||||
if (snappedPoint && snapKind !== 'surface' && lastSnapKind.current !== snapKind) {
|
if (snappedPoint && strongSnapKind) {
|
||||||
triggerHaptic(session, 'right', 0.4, 15);
|
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 ─────
|
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
|
||||||
const measureModeNow = useModelStore.getState().measureMode;
|
const measureModeNow = useModelStore.getState().measureMode;
|
||||||
const nowT = performance.now();
|
|
||||||
if (snappedPoint && hoverDetected) {
|
if (snappedPoint && hoverDetected) {
|
||||||
const dist = dwellPos.current.distanceTo(snappedPoint);
|
const dist = dwellPos.current.distanceTo(snappedPoint);
|
||||||
if (dist > 0.005) {
|
if (dist > 0.005) {
|
||||||
@@ -221,18 +237,16 @@ export function XRControllerMeasure() {
|
|||||||
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
|
} else if (!dwellFired.current && nowT - dwellStart.current > 1000) {
|
||||||
dwellFired.current = true;
|
dwellFired.current = true;
|
||||||
if (measureModeNow) {
|
if (measureModeNow) {
|
||||||
const hitModelId = hit ? findModelId(hit.object) : undefined;
|
|
||||||
useModelStore.getState().registerHoverMeasurement({
|
useModelStore.getState().registerHoverMeasurement({
|
||||||
type: hoverDetected.kind,
|
type: hoverDetected.kind,
|
||||||
value: hoverDetected.value,
|
value: hoverDetected.value,
|
||||||
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
|
position: [hoverDetected.position.x, hoverDetected.position.y, hoverDetected.position.z],
|
||||||
modelId: hitModelId,
|
|
||||||
endpoints: hoverDetected.endpoints && {
|
endpoints: hoverDetected.endpoints && {
|
||||||
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
|
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
|
||||||
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
|
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
|
||||||
},
|
},
|
||||||
|
modelId: hoverDetected.modelId,
|
||||||
});
|
});
|
||||||
triggerHaptic(session, 'right', 0.6, 50);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -243,6 +257,7 @@ export function XRControllerMeasure() {
|
|||||||
// ── Update laser visual ───────────────────────────────────────────
|
// ── Update laser visual ───────────────────────────────────────────
|
||||||
if (laserRef.current && tipRef.current) {
|
if (laserRef.current && tipRef.current) {
|
||||||
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
|
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
|
||||||
|
|
||||||
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
|
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
|
||||||
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
|
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
|
||||||
positions.setXYZ(1, end.x, end.y, end.z);
|
positions.setXYZ(1, end.x, end.y, end.z);
|
||||||
@@ -250,18 +265,18 @@ export function XRControllerMeasure() {
|
|||||||
laserGeom.current.computeBoundingSphere();
|
laserGeom.current.computeBoundingSphere();
|
||||||
|
|
||||||
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
|
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
|
||||||
|
|
||||||
tipColor.current.set(color);
|
tipColor.current.set(color);
|
||||||
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
|
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
|
||||||
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
|
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
|
||||||
|
|
||||||
laserRef.current.visible = true;
|
laserRef.current.visible = true;
|
||||||
if (snappedPoint) {
|
|
||||||
|
const tipPos = snappedPoint;
|
||||||
|
if (tipPos) {
|
||||||
tipRef.current.visible = true;
|
tipRef.current.visible = true;
|
||||||
tipRef.current.position.copy(snappedPoint);
|
tipRef.current.position.copy(tipPos);
|
||||||
// Tip scales with distance from controller. Snap "forte" (vértice,
|
const dist = tmpOrigin.current.distanceTo(tipPos);
|
||||||
// aresta, furo) recebe um marcador ~3× maior do que uma superfície
|
|
||||||
// genérica, para que o ponto destacado fique bem visível.
|
|
||||||
const dist = tmpOrigin.current.distanceTo(snappedPoint);
|
|
||||||
const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
|
const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
|
||||||
const factor = strongSnap ? 0.030 : 0.012;
|
const factor = strongSnap ? 0.030 : 0.012;
|
||||||
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
|
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
|
||||||
@@ -273,14 +288,18 @@ export function XRControllerMeasure() {
|
|||||||
|
|
||||||
// ── Right trigger: add point (measure) OR toggle selection ────────
|
// ── Right trigger: add point (measure) OR toggle selection ────────
|
||||||
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
|
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
|
||||||
|
|
||||||
if (!trigState.current && trigVal > TRIG_ON) {
|
if (!trigState.current && trigVal > TRIG_ON) {
|
||||||
trigState.current = true;
|
trigState.current = true;
|
||||||
|
|
||||||
const st = useModelStore.getState();
|
const st = useModelStore.getState();
|
||||||
|
|
||||||
if (st.selectionMode) {
|
if (st.selectionMode) {
|
||||||
// Fresh raycast on the press itself (not every frame) for selection
|
// Fresh raycast on the press itself (not every frame) for selection
|
||||||
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
|
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
|
||||||
const triggerHit = triggerHits.find((h) => {
|
const triggerHit = triggerHits.find((h) => {
|
||||||
const o = h.object;
|
const o = h.object;
|
||||||
|
if (!(o instanceof THREE.Mesh)) return false;
|
||||||
if (!isPickableModelMesh(o)) return false;
|
if (!isPickableModelMesh(o)) return false;
|
||||||
if (o.userData.__edgeLine) return false;
|
if (o.userData.__edgeLine) return false;
|
||||||
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
||||||
@@ -305,39 +324,17 @@ export function XRControllerMeasure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (snappedPoint) {
|
} else if (snappedPoint) {
|
||||||
const hitModelId = hit ? findModelId(hit.object) : undefined;
|
|
||||||
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
|
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
|
||||||
st.addMeasurePoint({
|
st.addMeasurePoint({
|
||||||
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
||||||
modelId: hitModelId,
|
modelId: hitModelId,
|
||||||
});
|
});
|
||||||
triggerHaptic(session, 'right', 0.8, 25);
|
|
||||||
}
|
}
|
||||||
} else if (trigState.current && trigVal < TRIG_OFF) {
|
} else if (trigState.current && trigVal < TRIG_OFF) {
|
||||||
trigState.current = false;
|
trigState.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Button A (undo) ──────────────────────────────────────────────
|
// A and B physical buttons are now handled globally in XRHudInWorld.tsx for the Summon Menu.
|
||||||
const aBtn = gp?.buttons?.[4];
|
|
||||||
const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (!aState.current && aVal > BTN_ON) {
|
|
||||||
aState.current = true;
|
|
||||||
useModelStore.getState().undoLastMeasurement();
|
|
||||||
triggerHaptic(session, 'right', 0.5, 30);
|
|
||||||
} else if (aState.current && aVal < TRIG_OFF) {
|
|
||||||
aState.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Button B (clear) ─────────────────────────────────────────────
|
|
||||||
const bBtn = gp?.buttons?.[5];
|
|
||||||
const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (!bState.current && bVal > BTN_ON) {
|
|
||||||
bState.current = true;
|
|
||||||
useModelStore.getState().clearMeasurements();
|
|
||||||
triggerHaptic(session, 'right', 0.7, 60);
|
|
||||||
} else if (bState.current && bVal < TRIG_OFF) {
|
|
||||||
bState.current = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construct the laser Line object once so we can attach via <primitive>
|
// Construct the laser Line object once so we can attach via <primitive>
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import { useRef, ReactNode, useEffect } from 'react';
|
import { useRef, ReactNode, useEffect } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { useXR } from '@react-three/xr';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { activeModelGroupRef } from './xrCalibrationBus';
|
||||||
function triggerHaptic(session: XRSession | null, handedness: 'left' | 'right', intensity = 0.5, duration = 40) {
|
|
||||||
if (!session) return;
|
|
||||||
try {
|
|
||||||
for (const source of session.inputSources) {
|
|
||||||
if (source.handedness === handedness) {
|
|
||||||
const gp = source.gamepad;
|
|
||||||
if (gp && gp.hapticActuators && gp.hapticActuators.length > 0) {
|
|
||||||
gp.hapticActuators[0].pulse(intensity, duration).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[XR][Haptic] erro ao vibrar controle no grab:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface XRGrabbableProps {
|
interface XRGrabbableProps {
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +23,8 @@ interface SingleGrabRecord {
|
|||||||
hand: 'left' | 'right';
|
hand: 'left' | 'right';
|
||||||
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
||||||
offsetWorld: THREE.Vector3;
|
offsetWorld: THREE.Vector3;
|
||||||
|
/** Altura Y de mundo da peça no momento em que o grab iniciou */
|
||||||
|
startWorldY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DualGrabRecord {
|
interface DualGrabRecord {
|
||||||
@@ -58,24 +46,23 @@ const _tmpVecR = new THREE.Vector3();
|
|||||||
const _tmpQuat = new THREE.Quaternion();
|
const _tmpQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Touch Plus grip mapping (Meta Quest 3):
|
* Mapeamento Geral do Quest 3 para Manipulação do Modelo em AR:
|
||||||
*
|
*
|
||||||
* • One-hand grip → **PAN ONLY** (translação). A rotação e a escala
|
* • Panorâmica (Pan) -> Segurar o botão Grip (lateral) em qualquer um dos controles
|
||||||
* da peça NÃO mudam — o grip simples só arrasta.
|
* e mover a mão na direção que deseja arrastar (movimentação livre em 3D).
|
||||||
* • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme
|
* • Giro (Rotação) e Zoom (Escala) -> Apontar ambos os controles para a bounding box do modelo,
|
||||||
* (distância entre os controles). O zoom está sempre ligado.
|
* segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou
|
||||||
*
|
* afastar/aproximar as mãos (zoom).
|
||||||
* Trigger não interfere aqui (reservado para seleção/medição).
|
|
||||||
*/
|
*/
|
||||||
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const grab = useControllerGrab();
|
const grab = useControllerGrab();
|
||||||
|
const session = useXR((s) => s.session);
|
||||||
|
|
||||||
const single = useRef<SingleGrabRecord | null>(null);
|
const single = useRef<SingleGrabRecord | null>(null);
|
||||||
const dual = useRef<DualGrabRecord | null>(null);
|
const dual = useRef<DualGrabRecord | null>(null);
|
||||||
const haloRef = useRef<THREE.Mesh>(null);
|
const haloRef = useRef<THREE.Mesh>(null);
|
||||||
const everGrabbed = useRef(false);
|
const everGrabbed = useRef(false);
|
||||||
const pendingReset = useRef(false);
|
|
||||||
|
|
||||||
// Reset scale to 1 (position/rotation kept) whenever resetScale() is called.
|
// Reset scale to 1 (position/rotation kept) whenever resetScale() is called.
|
||||||
const scaleResetNonce = useModelStore((s) => s.scaleResetNonce);
|
const scaleResetNonce = useModelStore((s) => s.scaleResetNonce);
|
||||||
@@ -87,22 +74,76 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
single.current = null;
|
single.current = null;
|
||||||
}, [scaleResetNonce]);
|
}, [scaleResetNonce]);
|
||||||
|
|
||||||
const { gl } = useThree();
|
useEffect(() => {
|
||||||
|
const currentGroup = groupRef.current;
|
||||||
|
activeModelGroupRef.current = currentGroup;
|
||||||
|
return () => {
|
||||||
|
if (activeModelGroupRef.current === currentGroup) {
|
||||||
|
activeModelGroupRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
useFrame((_state) => {
|
const syncGrabToStore = () => {
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
if (pendingReset.current) {
|
const state = useModelStore.getState();
|
||||||
group.position.set(0, 0, 0);
|
const activeModelId = state.activeModelId;
|
||||||
group.rotation.set(0, 0, 0);
|
if (!activeModelId) return;
|
||||||
group.scale.set(1, 1, 1);
|
|
||||||
pendingReset.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = gl.xr.getSession();
|
const activeModel = state.models.find(m => m.id === activeModelId);
|
||||||
const snap: ControllerGrabSnapshot = grab.current;
|
if (!activeModel) return;
|
||||||
|
|
||||||
|
const ft = { ...activeModel.fineTuning };
|
||||||
|
const renderFactor = state.scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
|
// Model's matrix relative to XRGrabbable
|
||||||
|
const posModel = new THREE.Vector3(ft.posX * renderFactor, ft.posY * renderFactor, ft.posZ * renderFactor);
|
||||||
|
const rotModel = new THREE.Euler(
|
||||||
|
(ft.rotX * Math.PI) / 180,
|
||||||
|
(ft.rotY * Math.PI) / 180,
|
||||||
|
(ft.rotZ * Math.PI) / 180,
|
||||||
|
'YXZ'
|
||||||
|
);
|
||||||
|
const quatModel = new THREE.Quaternion().setFromEuler(rotModel);
|
||||||
|
const scaleModel = new THREE.Vector3(ft.scale * renderFactor, ft.scale * renderFactor, ft.scale * renderFactor);
|
||||||
|
|
||||||
|
const mModel = new THREE.Matrix4().compose(posModel, quatModel, scaleModel);
|
||||||
|
|
||||||
|
// Grab matrix of XRGrabbable group
|
||||||
|
const mGrab = new THREE.Matrix4().compose(group.position, group.quaternion, group.scale);
|
||||||
|
|
||||||
|
// Combined matrix
|
||||||
|
const mCombined = new THREE.Matrix4().multiplyMatrices(mGrab, mModel);
|
||||||
|
|
||||||
|
const finalPos = new THREE.Vector3();
|
||||||
|
const finalQuat = new THREE.Quaternion();
|
||||||
|
const finalScale = new THREE.Vector3();
|
||||||
|
mCombined.decompose(finalPos, finalQuat, finalScale);
|
||||||
|
|
||||||
|
// Reset grabbable group to identity so the transform is held entirely by fineTuning
|
||||||
|
group.position.set(0, 0, 0);
|
||||||
|
group.quaternion.identity();
|
||||||
|
group.scale.set(1, 1, 1);
|
||||||
|
|
||||||
|
// Save to store
|
||||||
|
const euler = new THREE.Euler().setFromQuaternion(finalQuat, 'YXZ');
|
||||||
|
state.setFineTuning({
|
||||||
|
posX: finalPos.x / renderFactor,
|
||||||
|
posY: finalPos.y / renderFactor,
|
||||||
|
posZ: finalPos.z / renderFactor,
|
||||||
|
rotX: (euler.x * 180) / Math.PI,
|
||||||
|
rotY: (euler.y * 180) / Math.PI,
|
||||||
|
rotZ: (euler.z * 180) / Math.PI,
|
||||||
|
scale: finalScale.x / renderFactor,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useFrame((_state, dt) => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
const snap = grab.current;
|
||||||
const L = snap.left;
|
const L = snap.left;
|
||||||
const R = snap.right;
|
const R = snap.right;
|
||||||
|
|
||||||
@@ -116,7 +157,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const lActive = L.isGrabbing && L.hasPose;
|
const lActive = L.isGrabbing && L.hasPose;
|
||||||
const rActive = R.isGrabbing && R.hasPose;
|
const rActive = R.isGrabbing && R.hasPose;
|
||||||
|
|
||||||
// ─── Halo intensity (visual feedback for analog grip) ────────
|
// Visual feedback para a pressão analógica do grip (halo)
|
||||||
if (haloRef.current) {
|
if (haloRef.current) {
|
||||||
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
||||||
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
||||||
@@ -124,51 +165,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitGrabTransforms = () => {
|
// ─── Modo de Duas Mãos: Giro (Órbita) + Zoom ────────────────────
|
||||||
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) {
|
if (lActive && rActive) {
|
||||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||||
@@ -180,6 +177,21 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
|
|
||||||
if (!dual.current) {
|
if (!dual.current) {
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
|
|
||||||
|
// Se o usuário já estava segurando a peça com uma das mãos, transita para o modo dual imediatamente.
|
||||||
|
// Caso contrário (peça solta), exige que ambos os controles apontem para a bounding box da peça.
|
||||||
|
const wasSingleGrabbing = !!single.current;
|
||||||
|
|
||||||
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
|
const checkPointerCollision = (slot: typeof L) => {
|
||||||
|
const origin = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
|
||||||
|
const q = new THREE.Quaternion().setFromRotationMatrix(slot.gripWorld);
|
||||||
|
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize();
|
||||||
|
const ray = new THREE.Ray(origin, dir);
|
||||||
|
return ray.intersectsBox(box);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wasSingleGrabbing || (checkPointerCollision(L) && checkPointerCollision(R))) {
|
||||||
dual.current = {
|
dual.current = {
|
||||||
startGroupWorld: group.matrixWorld.clone(),
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
startMid: midNow.clone(),
|
startMid: midNow.clone(),
|
||||||
@@ -189,13 +201,18 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
};
|
};
|
||||||
single.current = null;
|
single.current = null;
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
triggerHaptic(session, 'left', 0.5, 30);
|
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom) - ' + (wasSingleGrabbing ? 'from active single grab' : 'bounding box hit'));
|
||||||
triggerHaptic(session, 'right', 0.5, 30);
|
} else {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
// Se não apontavam para o modelo, ignora e permite o pan de uma mão (da primeira mão a engajar)
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dual.current) {
|
||||||
const d = dual.current;
|
const d = dual.current;
|
||||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
|
|
||||||
|
// ZOOM: afasta as mãos para zoom in, aproxima para zoom out
|
||||||
const scaleRatio = allowScale
|
const scaleRatio = allowScale
|
||||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
||||||
: 1;
|
: 1;
|
||||||
@@ -213,13 +230,19 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
|
|
||||||
applyWorldMatrixToLocal(group, m);
|
applyWorldMatrixToLocal(group, m);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
} else if (dual.current) {
|
} else if (dual.current) {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND end');
|
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||||
commitGrabTransforms();
|
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
|
// Se a outra mão ainda estiver segurando, NÃO sincronizamos/resetamos o group local ainda.
|
||||||
|
// A transição de duas mãos para uma mão herdará o groupWorldPos correto de forma contínua.
|
||||||
|
const activeHand = lActive ? 'left' : rActive ? 'right' : null;
|
||||||
|
if (!activeHand) {
|
||||||
|
syncGrabToStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── One-hand mode (PAN ONLY) ────────────────────────────────
|
// ─── Modo de Uma Mão: Panorâmica Livre (Arrastar em 3D) ────────
|
||||||
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
||||||
if (activeHand) {
|
if (activeHand) {
|
||||||
const slot = activeHand === 'left' ? L : R;
|
const slot = activeHand === 'left' ? L : R;
|
||||||
@@ -229,27 +252,43 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
||||||
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||||
single.current = { hand: activeHand, offsetWorld };
|
|
||||||
|
single.current = { hand: activeHand, offsetWorld, startWorldY: groupWorldPos.y };
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
triggerHaptic(session, activeHand, 0.45, 25);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — free 3D pan`);
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target world position = controller + initial offset
|
// Função particular improvisada: rotacionar com o analógico horizontal durante o pan
|
||||||
|
const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null;
|
||||||
|
const gp = inputSource?.gamepad;
|
||||||
|
if (gp && gp.axes.length >= 4) {
|
||||||
|
const stickX = gp.axes[2]; // thumbstick horizontal
|
||||||
|
const deadzone = 0.15;
|
||||||
|
if (Math.abs(stickX) > deadzone) {
|
||||||
|
const rotationSpeed = 1.5;
|
||||||
|
const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt;
|
||||||
|
const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle);
|
||||||
|
group.quaternion.multiplyQuaternions(q, group.quaternion);
|
||||||
|
|
||||||
|
// Gira o offset em torno da mão
|
||||||
|
single.current.offsetWorld.applyQuaternion(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcula a nova posição no espaço de mundo (movimentação 3D livre)
|
||||||
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
||||||
|
|
||||||
// Convert to parent-local position (preserve current rotation/scale)
|
// Converte para coordenadas locais do pai
|
||||||
if (group.parent) {
|
if (group.parent) {
|
||||||
group.parent.updateMatrixWorld();
|
group.parent.updateMatrixWorld();
|
||||||
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
||||||
targetWorldPos.applyMatrix4(invParent);
|
targetWorldPos.applyMatrix4(invParent);
|
||||||
}
|
}
|
||||||
|
|
||||||
group.position.copy(targetWorldPos);
|
group.position.copy(targetWorldPos);
|
||||||
} else if (single.current) {
|
} else if (single.current) {
|
||||||
console.log('[XR][grab] ● ONE-HAND end');
|
console.log('[XR][grab] ● ONE-HAND end');
|
||||||
commitGrabTransforms();
|
|
||||||
single.current = null;
|
single.current = null;
|
||||||
|
syncGrabToStore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,7 +303,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert a desired world matrix to the group's local matrix (given its parent). */
|
|
||||||
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
|
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
|
||||||
if (group.parent) {
|
if (group.parent) {
|
||||||
group.parent.updateMatrixWorld();
|
group.parent.updateMatrixWorld();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState, forwardRef } from 'react';
|
import { useRef, useState, useEffect, forwardRef } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
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 * as THREE from 'three';
|
||||||
import { Text } from '@react-three/drei';
|
import { Text } from '@react-three/drei';
|
||||||
import { XR3DButton, XR3DPanel } from './XRPanel3D';
|
import { XR3DButton, XR3DPanel } from './XRPanel3D';
|
||||||
@@ -10,8 +11,17 @@ import {
|
|||||||
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
|
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
|
||||||
stopRecording, captureScreenshot, formatElapsed,
|
stopRecording, captureScreenshot, formatElapsed,
|
||||||
} from '@/hooks/useCanvasRecorder';
|
} from '@/hooks/useCanvasRecorder';
|
||||||
|
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
xrCalibration,
|
||||||
|
startXRCalibration,
|
||||||
|
cancelXRCalibration,
|
||||||
|
subscribeXRCalibration,
|
||||||
|
} from './xrCalibrationBus';
|
||||||
|
import { XRViewCube } from './XRViewCube';
|
||||||
|
|
||||||
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'env';
|
type Tab = 'scene' | 'tools' | 'calib' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr';
|
||||||
|
|
||||||
interface XRHudInWorldProps {
|
interface XRHudInWorldProps {
|
||||||
freeMove: boolean;
|
freeMove: boolean;
|
||||||
@@ -53,15 +63,15 @@ const WristToggle = forwardRef<THREE.Group, { open: boolean; onToggle: () => voi
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function XRHudInWorld(props: XRHudInWorldProps) {
|
export function XRHudInWorld(props: XRHudInWorldProps) {
|
||||||
const { camera, gl } = useThree();
|
const { camera } = useThree();
|
||||||
const leftCtrl = useXRInputSourceState('controller', 'left');
|
const leftCtrl = useXRInputSourceState('controller', 'left');
|
||||||
const rightCtrl = useXRInputSourceState('controller', 'right');
|
const rightCtrl = useXRInputSourceState('controller', 'right');
|
||||||
|
const xrSession = useXR((s) => s.session);
|
||||||
const open = useModelStore((s) => s.xrHudOpen);
|
const navigate = useNavigate();
|
||||||
const setOpen = useModelStore((s) => s.setXrHudOpen);
|
const [open, setOpen] = useState(true);
|
||||||
const tab = useModelStore((s) => s.xrHudTab) as Tab;
|
/** Visibility of the small head-locked floating button group (toggled by X button). */
|
||||||
const setTab = useModelStore((s) => s.setXrHudTab) as unknown as (t: Tab) => void;
|
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. */
|
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */
|
||||||
const [pinned, setPinned] = useState(true);
|
const [pinned, setPinned] = useState(true);
|
||||||
/** While true, the panel is glued to the right controller (drag mode). */
|
/** While true, the panel is glued to the right controller (drag mode). */
|
||||||
@@ -74,32 +84,72 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
const headLockRef = useRef<THREE.Group>(null);
|
const headLockRef = useRef<THREE.Group>(null);
|
||||||
const targetPos = useRef(new THREE.Vector3());
|
const targetPos = useRef(new THREE.Vector3());
|
||||||
const targetQuat = useRef(new THREE.Quaternion());
|
const targetQuat = useRef(new THREE.Quaternion());
|
||||||
const [showQuickMenu, setShowQuickMenu] = useState(true);
|
const lastButtonsState = useRef({
|
||||||
const lastXBtn = useRef(false);
|
left4: false, // X
|
||||||
const lastYBtn = useRef(false);
|
left5: false, // Y
|
||||||
|
left3: false, // Menu (☰)
|
||||||
|
right4: false, // A
|
||||||
|
right5: false, // B
|
||||||
|
});
|
||||||
/** Cached offset from controller to panel at drag start. */
|
/** Cached offset from controller to panel at drag start. */
|
||||||
const dragOffsetPos = useRef(new THREE.Vector3());
|
const dragOffsetPos = useRef(new THREE.Vector3());
|
||||||
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
||||||
const dragInitialized = useRef(false);
|
const dragInitialized = useRef(false);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const gp = leftCtrl?.inputSource?.gamepad;
|
// Escuta botões A, B, X, Y e Menu do esquerdo
|
||||||
if (gp) {
|
const gpLeft = leftCtrl?.inputSource?.gamepad;
|
||||||
// Toggle quick menu via left X button (Quest Touch buttons[4] on left controller)
|
const gpRight = rightCtrl?.inputSource?.gamepad;
|
||||||
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)
|
let xPressed = false;
|
||||||
const yBtn = gp.buttons[5];
|
let yPressed = false;
|
||||||
const pressedY = !!yBtn?.pressed;
|
let menuPressed = false;
|
||||||
if (pressedY && !lastYBtn.current) {
|
let aPressed = false;
|
||||||
setOpen(!open);
|
let bPressed = false;
|
||||||
|
|
||||||
|
if (gpLeft) {
|
||||||
|
// Esquerdo: Menu (3), X (4), Y (5)
|
||||||
|
const btnMenu = gpLeft.buttons[3];
|
||||||
|
const btnX = gpLeft.buttons[4];
|
||||||
|
const btnY = gpLeft.buttons[5];
|
||||||
|
|
||||||
|
if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true;
|
||||||
|
if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true;
|
||||||
|
if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true;
|
||||||
|
|
||||||
|
lastButtonsState.current.left4 = !!btnX?.pressed;
|
||||||
|
lastButtonsState.current.left5 = !!btnY?.pressed;
|
||||||
|
lastButtonsState.current.left3 = !!btnMenu?.pressed;
|
||||||
}
|
}
|
||||||
lastYBtn.current = pressedY;
|
|
||||||
|
if (gpRight) {
|
||||||
|
// Direito: A (4), B (5)
|
||||||
|
const btnA = gpRight.buttons[4];
|
||||||
|
const btnB = gpRight.buttons[5];
|
||||||
|
|
||||||
|
if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true;
|
||||||
|
if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true;
|
||||||
|
|
||||||
|
lastButtonsState.current.right4 = !!btnA?.pressed;
|
||||||
|
lastButtonsState.current.right5 = !!btnB?.pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR)
|
||||||
|
if (xPressed && open) {
|
||||||
|
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
|
||||||
|
setTimeout(() => navigate('/viewer'), 50);
|
||||||
|
toast.success("Projeto fechado!");
|
||||||
|
} else if (aPressed || bPressed || xPressed || yPressed) {
|
||||||
|
// Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu
|
||||||
|
setOpen(true);
|
||||||
|
reanchorRequested.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente
|
||||||
|
if (menuPressed) {
|
||||||
|
setOpen(true);
|
||||||
|
setTab('tools');
|
||||||
|
reanchorRequested.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating panel:
|
// Floating panel:
|
||||||
@@ -166,11 +216,11 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Head-locked fallback toggle — always visible in lower-right of FOV,
|
// Head-locked fallback toggle — always visible in lower-left of FOV,
|
||||||
// so the user can reopen the menu even without controllers or if the
|
// so the user can reopen the menu even without controllers or if the
|
||||||
// panel is positioned out of view.
|
// panel is positioned out of view.
|
||||||
if (headLockRef.current) {
|
if (headLockRef.current) {
|
||||||
const offset = new THREE.Vector3(0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
|
const offset = new THREE.Vector3(-0.18, -0.18, -0.5).applyQuaternion(camera.quaternion);
|
||||||
headLockRef.current.position.copy(camera.position).add(offset);
|
headLockRef.current.position.copy(camera.position).add(offset);
|
||||||
headLockRef.current.quaternion.copy(camera.quaternion);
|
headLockRef.current.quaternion.copy(camera.quaternion);
|
||||||
}
|
}
|
||||||
@@ -181,21 +231,26 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen(!open)} />
|
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
|
||||||
{/* Head-locked fallback: always-visible quick controls in lower-right of FOV */}
|
{/* Head-locked fallback: floating quick-controls in lower-left of FOV.
|
||||||
<group ref={headLockRef} renderOrder={999} visible={showQuickMenu}>
|
Toggled on/off by the left controller X button. */}
|
||||||
<XR3DButton position={[0, 0.026, 0]} size={[0.07, 0.022]}
|
<group ref={headLockRef} renderOrder={999} visible={hudVisible}>
|
||||||
|
<XR3DButton position={[0, 0.030, 0]} size={[0.07, 0.022]}
|
||||||
label={open ? '✕ Menu' : '☰ Menu'} active={open}
|
label={open ? '✕ Menu' : '☰ Menu'} active={open}
|
||||||
onClick={() => { setOpen(!open); reanchorRequested.current = true; }}
|
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }}
|
||||||
fontSize={0.0085} />
|
fontSize={0.0085} />
|
||||||
<XR3DButton position={[0, 0, 0]} size={[0.07, 0.022]}
|
<XR3DButton position={[0, 0.004, 0]} size={[0.07, 0.022]}
|
||||||
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
|
||||||
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
|
||||||
<XR3DButton position={[0, -0.026, 0]} size={[0.07, 0.022]}
|
<XR3DButton position={[0, -0.022, 0]} size={[0.07, 0.022]}
|
||||||
label="✕ Sair AR" active={false}
|
label="⏏ Sair AR"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
gl.xr.getSession()?.end();
|
// End the XR session while preserving placement/state in the store,
|
||||||
}} fontSize={0.0085} />
|
// 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 />
|
<RecIndicator />
|
||||||
</group>
|
</group>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -230,17 +285,19 @@ function FloatingPanel({
|
|||||||
const tabs: { id: Tab; label: string; icon: string }[] = [
|
const tabs: { id: Tab; label: string; icon: string }[] = [
|
||||||
{ id: 'scene', label: 'Cena', icon: '🧩' },
|
{ id: 'scene', label: 'Cena', icon: '🧩' },
|
||||||
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
|
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
|
||||||
|
{ id: 'calib', label: 'Calibrar', icon: '🎯' },
|
||||||
|
{ id: 'cuts', label: 'Cortes', icon: '✂' },
|
||||||
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
|
||||||
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
{ id: 'capture', label: 'Captura', icon: '🎥' },
|
||||||
{ id: 'share', label: 'Compart.', icon: '📡' },
|
{ id: 'share', label: 'Compart.', icon: '📡' },
|
||||||
{ id: 'env', label: 'Ambiente', icon: '🏝' },
|
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
|
||||||
];
|
];
|
||||||
const W = 0.5, H = 0.36;
|
const W = 0.54, H = 0.38;
|
||||||
return (
|
return (
|
||||||
<XR3DPanel size={[W, H]}>
|
<XR3DPanel size={[W, H]}>
|
||||||
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
|
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
|
||||||
anchorX="left" anchorY="middle">
|
anchorX="left" anchorY="middle">
|
||||||
TrackSteelXR · HUD AR
|
SteelXR · HUD AR
|
||||||
</Text>
|
</Text>
|
||||||
{/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel.
|
{/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel.
|
||||||
Group origin is at the right edge with margin; buttons extend leftward. */}
|
Group origin is at the right edge with margin; buttons extend leftward. */}
|
||||||
@@ -261,24 +318,184 @@ function FloatingPanel({
|
|||||||
<group position={[0, H / 2 - 0.045, 0.001]}>
|
<group position={[0, H / 2 - 0.045, 0.001]}>
|
||||||
{tabs.map((t, i) => (
|
{tabs.map((t, i) => (
|
||||||
<XR3DButton key={t.id}
|
<XR3DButton key={t.id}
|
||||||
position={[-W / 2 + 0.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
|
position={[-W / 2 + 0.0495 + i * 0.063, 0, 0]} size={[0.058, 0.020]}
|
||||||
label={t.label} icon={t.icon} active={tab === t.id}
|
label={t.label} icon={t.icon} active={tab === t.id}
|
||||||
onClick={() => setTab(t.id)} fontSize={0.0072} />
|
onClick={() => setTab(t.id)} fontSize={0.0062} />
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<group position={[0, -0.012, 0.001]}>
|
<group position={[0, -0.018, 0.001]}>
|
||||||
{tab === 'scene' && <SceneTab />}
|
{tab === 'scene' && <SceneTab />}
|
||||||
{tab === 'tools' && <ToolsTab {...p} />}
|
{tab === 'tools' && <ToolsTab {...p} />}
|
||||||
|
{tab === 'calib' && <CalibrationTab />}
|
||||||
|
{tab === 'cuts' && <CutsTab />}
|
||||||
{tab === 'inspection' && <InspectionTab />}
|
{tab === 'inspection' && <InspectionTab />}
|
||||||
{tab === 'capture' && <CaptureTab />}
|
{tab === 'capture' && <CaptureTab />}
|
||||||
{tab === 'share' && <ShareTab {...p} />}
|
{tab === 'share' && <ShareTab {...p} />}
|
||||||
{tab === 'env' && <EnvTab />}
|
{tab === 'webxr' && <WebXRTab />}
|
||||||
</group>
|
</group>
|
||||||
</XR3DPanel>
|
</XR3DPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AxisRange { min: number; max: number; }
|
||||||
|
type Axis = 'x' | 'y' | 'z';
|
||||||
|
|
||||||
|
function computeBoundsByAxis(): Record<Axis, AxisRange> | null {
|
||||||
|
const groups = getAllModelLocalGroups();
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
const box = new THREE.Box3();
|
||||||
|
let has = false;
|
||||||
|
for (const g of groups) {
|
||||||
|
g.updateWorldMatrix(true, true);
|
||||||
|
const b = new THREE.Box3().setFromObject(g);
|
||||||
|
if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) {
|
||||||
|
if (!has) { box.copy(b); has = true; } else box.union(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!has) return null;
|
||||||
|
return {
|
||||||
|
x: { min: box.min.x, max: box.max.x },
|
||||||
|
y: { min: box.min.y, max: box.max.y },
|
||||||
|
z: { min: box.min.z, max: box.max.z },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AXIS_COLOR: Record<Axis, string> = {
|
||||||
|
x: '#ef4444',
|
||||||
|
y: '#10b981',
|
||||||
|
z: '#0ea5e9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
|
||||||
|
|
||||||
|
function CutsTab() {
|
||||||
|
const enabled = useModelStore((s) => s.sectionEnabled);
|
||||||
|
const invert = useModelStore((s) => s.sectionInvert);
|
||||||
|
const level = useModelStore((s) => s.sectionLevel);
|
||||||
|
const setSectionEnabled = useModelStore((s) => s.setSectionEnabled);
|
||||||
|
const setSectionInvert = useModelStore((s) => s.setSectionInvert);
|
||||||
|
const setSectionLevel = useModelStore((s) => s.setSectionLevel);
|
||||||
|
const activeModelId = useModelStore((s) => s.activeModelId);
|
||||||
|
|
||||||
|
const [bounds, setBounds] = useState<Record<Axis, AxisRange> | null>(null);
|
||||||
|
const [step, setStep] = useState<number>(0.01); // 10mm por padrão
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBounds(computeBoundsByAxis());
|
||||||
|
}, [activeModelId]);
|
||||||
|
|
||||||
|
const handleStepChange = (sVal: number) => {
|
||||||
|
setStep(sVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdjust = (axis: Axis, dir: 1 | -1) => {
|
||||||
|
const range = bounds?.[axis];
|
||||||
|
if (!range) return;
|
||||||
|
const currentVal = level[axis];
|
||||||
|
const newVal = Math.max(range.min, Math.min(range.max, currentVal + dir * step));
|
||||||
|
setSectionLevel(axis, newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPreset = (axis: Axis, type: 'min' | 'center' | 'max') => {
|
||||||
|
const range = bounds?.[axis];
|
||||||
|
if (!range) return;
|
||||||
|
if (type === 'min') {
|
||||||
|
setSectionLevel(axis, range.min);
|
||||||
|
} else if (type === 'center') {
|
||||||
|
setSectionLevel(axis, (range.min + range.max) / 2);
|
||||||
|
} else if (type === 'max') {
|
||||||
|
setSectionLevel(axis, range.max);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecalculate = () => {
|
||||||
|
setBounds(computeBoundsByAxis());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Cabeçalho / Passo */}
|
||||||
|
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" anchorY="middle">
|
||||||
|
Passo do Ajuste:
|
||||||
|
</Text>
|
||||||
|
<XR3DButton position={[-0.09, 0.115, 0]} size={[0.048, 0.02]}
|
||||||
|
label="1 mm" active={step === 0.001} onClick={() => handleStepChange(0.001)} fontSize={0.007} />
|
||||||
|
<XR3DButton position={[-0.038, 0.115, 0]} size={[0.048, 0.02]}
|
||||||
|
label="10 mm" active={step === 0.01} onClick={() => handleStepChange(0.01)} fontSize={0.007} />
|
||||||
|
<XR3DButton position={[0.014, 0.115, 0]} size={[0.048, 0.02]}
|
||||||
|
label="50 mm" active={step === 0.05} onClick={() => handleStepChange(0.05)} fontSize={0.007} />
|
||||||
|
|
||||||
|
<XR3DButton position={[0.165, 0.115, 0]} size={[0.12, 0.02]}
|
||||||
|
label="↺ Recalcular Limites" active={false} onClick={handleRecalculate} fontSize={0.007} />
|
||||||
|
|
||||||
|
{/* Controles dos Eixos */}
|
||||||
|
{(['x', 'y', 'z'] as Axis[]).map((axis, idx) => {
|
||||||
|
const yOffset = 0.055 - idx * 0.055;
|
||||||
|
const range = bounds?.[axis];
|
||||||
|
const minMM = range ? range.min * 1000 : 0;
|
||||||
|
const maxMM = range ? range.max * 1000 : 100;
|
||||||
|
const valMM = level[axis] * 1000;
|
||||||
|
const isAxisEnabled = enabled[axis];
|
||||||
|
const isAxisInverted = invert[axis];
|
||||||
|
const isAxisDisabled = !range;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={axis} position={[0, yOffset, 0]}>
|
||||||
|
{/* Eixo label */}
|
||||||
|
<Text position={[-0.24, 0, 0]} fontSize={0.014} color={AXIS_COLOR[axis]} anchorX="left" anchorY="middle">
|
||||||
|
{AXIS_LABEL[axis]}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Toggle Cortar */}
|
||||||
|
<XR3DButton position={[-0.19, 0, 0]} size={[0.045, 0.022]}
|
||||||
|
label={isAxisEnabled ? "ON" : "OFF"} active={isAxisEnabled}
|
||||||
|
disabled={isAxisDisabled}
|
||||||
|
onClick={() => setSectionEnabled(axis, !isAxisEnabled)}
|
||||||
|
fontSize={0.0075} />
|
||||||
|
|
||||||
|
{/* Toggle Inverter */}
|
||||||
|
<XR3DButton position={[-0.135, 0, 0]} size={[0.052, 0.022]}
|
||||||
|
label={isAxisInverted ? "Invertido" : "Inverter"} active={isAxisInverted}
|
||||||
|
disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => setSectionInvert(axis, !isAxisInverted)}
|
||||||
|
fontSize={0.007} />
|
||||||
|
|
||||||
|
{/* Valor */}
|
||||||
|
<Text position={[-0.09, 0, 0]} fontSize={0.0085} color={isAxisEnabled ? "#ffffff" : "#64748b"} anchorX="left" anchorY="middle">
|
||||||
|
{isAxisDisabled ? "sem peça" : `${valMM.toFixed(1)} mm`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Menos/Mais */}
|
||||||
|
<XR3DButton position={[0.01, 0, 0]} size={[0.024, 0.022]}
|
||||||
|
label="−" disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => handleAdjust(axis, -1)}
|
||||||
|
fontSize={0.01} />
|
||||||
|
<XR3DButton position={[0.038, 0, 0]} size={[0.024, 0.022]}
|
||||||
|
label="+" disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => handleAdjust(axis, 1)}
|
||||||
|
fontSize={0.01} />
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<XR3DButton position={[0.076, 0, 0]} size={[0.034, 0.022]}
|
||||||
|
label="Min" disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => handleSetPreset(axis, 'min')}
|
||||||
|
fontSize={0.007} />
|
||||||
|
<XR3DButton position={[0.118, 0, 0]} size={[0.042, 0.022]}
|
||||||
|
label="Centro" disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => handleSetPreset(axis, 'center')}
|
||||||
|
fontSize={0.007} />
|
||||||
|
<XR3DButton position={[0.162, 0, 0]} size={[0.036, 0.022]}
|
||||||
|
label="Max" disabled={isAxisDisabled || !isAxisEnabled}
|
||||||
|
onClick={() => handleSetPreset(axis, 'max')}
|
||||||
|
fontSize={0.007} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SceneTab() {
|
function SceneTab() {
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
@@ -362,127 +579,83 @@ function ToolsTab(p: XRHudInWorldProps) {
|
|||||||
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
|
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
|
||||||
const resetScale = useModelStore((s) => s.resetScale);
|
const resetScale = useModelStore((s) => s.resetScale);
|
||||||
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
||||||
|
const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
|
||||||
const models = useModelStore((s) => s.models);
|
|
||||||
const activeModel = models.find((m) => m.id === activeId);
|
|
||||||
const setFineTuning = useModelStore((s) => s.setFineTuning);
|
|
||||||
|
|
||||||
const alignToFace = (face: string) => {
|
|
||||||
let rotX = 0, rotY = 0, rotZ = 0;
|
|
||||||
if (face === 'FRENTE') { rotX = 90; }
|
|
||||||
else if (face === 'LADO') { rotX = 90; rotY = 90; }
|
|
||||||
setFineTuning({ rotX, rotY, rotZ });
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapTo90 = () => {
|
|
||||||
if (!activeModel) return;
|
|
||||||
const ft = activeModel.fineTuning;
|
|
||||||
setFineTuning({
|
|
||||||
rotX: Math.round(ft.rotX / 90) * 90,
|
|
||||||
rotY: Math.round(ft.rotY / 90) * 90,
|
|
||||||
rotZ: Math.round(ft.rotZ / 90) * 90,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetRot = () => {
|
|
||||||
setFineTuning({ rotX: 0, rotY: 0, rotZ: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Fileira 1: Render */}
|
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
|
||||||
<Text position={[-0.24, 0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
|
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
|
||||||
<XR3DButton position={[-0.16, 0.105, 0]} size={[0.075, 0.022]} label="Sólido"
|
|
||||||
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
||||||
<XR3DButton position={[-0.08, 0.105, 0]} size={[0.075, 0.022]} label="Bordas"
|
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Wire"
|
||||||
|
active={renderMode === 'wireframe'} onClick={() => setRenderMode('wireframe')} />
|
||||||
|
<XR3DButton position={[0.0, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
|
||||||
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
|
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
|
||||||
|
|
||||||
{/* Fileira 2: Toggles */}
|
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
|
||||||
<Text position={[-0.24, 0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
|
<XR3DButton position={[-0.16, 0.025, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
|
||||||
<XR3DButton position={[-0.16, 0.055, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
|
|
||||||
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
|
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
|
||||||
<XR3DButton position={[-0.08, 0.055, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
|
<XR3DButton position={[-0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
|
||||||
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
|
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
|
||||||
<XR3DButton position={[0.0, 0.055, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
|
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
|
||||||
active={p.snapToPlanes} onClick={p.onToggleSnap} />
|
active={p.snapToPlanes} onClick={p.onToggleSnap} />
|
||||||
<XR3DButton position={[0.08, 0.055, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
|
||||||
label={p.placementMode ? 'Posic…' : 'Reposic.'}
|
label={p.placementMode ? 'Posic…' : 'Reposic.'}
|
||||||
active={p.placementMode} onClick={p.onTogglePlacement} />
|
active={p.placementMode} onClick={p.onTogglePlacement} />
|
||||||
<XR3DButton position={[0.16, 0.055, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
|
||||||
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
|
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
|
||||||
active={p.allowScale} onClick={p.onToggleAllowScale} />
|
active={p.allowScale} onClick={p.onToggleAllowScale} />
|
||||||
|
|
||||||
{/* Fileira 3: Selecionar */}
|
{/* Selecionar — paridade com a tela inicial */}
|
||||||
<Text position={[-0.24, 0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
|
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
|
||||||
<XR3DButton position={[-0.16, 0.005, 0]} size={[0.075, 0.022]}
|
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
|
||||||
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
|
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
|
||||||
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
|
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
|
||||||
<XR3DButton position={[-0.08, 0.005, 0]} size={[0.075, 0.022]} label="Esconder"
|
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
|
||||||
onClick={hideSelectedElements} />
|
onClick={hideSelectedElements} />
|
||||||
<XR3DButton position={[0.0, 0.005, 0]} size={[0.075, 0.022]} label="Isolar"
|
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
|
||||||
onClick={isolateSelectedElements} />
|
onClick={isolateSelectedElements} />
|
||||||
<XR3DButton position={[0.08, 0.005, 0]} size={[0.075, 0.022]} label="Mostrar"
|
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
|
||||||
active={hasHidden} onClick={showAllElements} />
|
active={hasHidden} onClick={showAllElements} />
|
||||||
|
|
||||||
{/* Fileira 4: Opacidade */}
|
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||||
<Text position={[-0.24, -0.025, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
|
||||||
Opacidade {Math.round(opacity * 100)}%
|
Opacidade {Math.round(opacity * 100)}%
|
||||||
</Text>
|
</Text>
|
||||||
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="−25%"
|
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="−25%"
|
||||||
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
|
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
|
||||||
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="−5%"
|
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="−5%"
|
||||||
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
|
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
|
||||||
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
|
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
|
||||||
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
|
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
|
||||||
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
|
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
|
||||||
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
|
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
|
||||||
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
|
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
|
||||||
onClick={() => setOpacity(1)} />
|
onClick={() => setOpacity(1)} />
|
||||||
|
|
||||||
{/* Fileira 5: Escala */}
|
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||||
<Text position={[-0.24, -0.075, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
|
||||||
Escala {scaleRatio.label}
|
Escala {scaleRatio.label}
|
||||||
</Text>
|
</Text>
|
||||||
{SCALE_LABELS.map((lbl, i) => {
|
{SCALE_LABELS.map((lbl, i) => {
|
||||||
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
||||||
return (
|
return (
|
||||||
<XR3DButton key={lbl}
|
<XR3DButton key={lbl}
|
||||||
position={[-0.16 + i * 0.06, -0.095, 0]} size={[0.055, 0.022]} label={lbl}
|
position={[-0.16 + i * 0.054, -0.14, 0]} size={[0.05, 0.022]} label={lbl}
|
||||||
active={scaleRatio.label === lbl}
|
active={scaleRatio.label === lbl}
|
||||||
onClick={() => setScaleRatio(preset)} />
|
onClick={() => setScaleRatio(preset)} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<XR3DButton position={[0.10, -0.095, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.076, -0.14, 0]} size={[0.046, 0.022]}
|
||||||
label="↺ Reset" color="#dc2626"
|
label="↺ Reset" color="#dc2626"
|
||||||
onClick={resetScale} fontSize={0.0085} />
|
onClick={resetScale} fontSize={0.0075} />
|
||||||
<XR3DButton position={[0.17, -0.095, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.126, -0.14, 0]} size={[0.05, 0.022]}
|
||||||
label="✕ Medidas" color="#dc2626"
|
label="⎌ Desfaz" color="#3b82f6"
|
||||||
onClick={clearMeasurements} fontSize={0.0085} />
|
onClick={undoLastMeasurement} fontSize={0.0075} />
|
||||||
|
<XR3DButton position={[0.18, -0.14, 0]} size={[0.05, 0.022]}
|
||||||
{/* Fileira 6: Rotação / Alinhamento Ortogonal */}
|
label="✕ Limpa" color="#dc2626"
|
||||||
<Text position={[-0.24, -0.125, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
onClick={clearMeasurements} fontSize={0.0075} />
|
||||||
Alinhar Rotação
|
|
||||||
</Text>
|
|
||||||
<XR3DButton position={[-0.16, -0.145, 0]} size={[0.075, 0.022]}
|
|
||||||
label="Snap 90°" active={false}
|
|
||||||
onClick={snapTo90} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[-0.08, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Topo" active={false}
|
|
||||||
onClick={() => alignToFace('TOPO')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[-0.025, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Frente" active={false}
|
|
||||||
onClick={() => alignToFace('FRENTE')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[0.03, -0.145, 0]} size={[0.05, 0.022]}
|
|
||||||
label="Lado" active={false}
|
|
||||||
onClick={() => alignToFace('LADO')} fontSize={0.0075} />
|
|
||||||
<XR3DButton position={[0.12, -0.145, 0]} size={[0.09, 0.022]}
|
|
||||||
label="Zerar Rot." color="#dc2626"
|
|
||||||
onClick={resetRot} fontSize={0.0075} />
|
|
||||||
|
|
||||||
{measureMode && (
|
{measureMode && (
|
||||||
<Text position={[-0.24, -0.175, 0]} fontSize={0.0055} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
||||||
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
|
Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -498,11 +671,11 @@ function GridFloorRow() {
|
|||||||
const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow);
|
const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow);
|
||||||
const setGridY = useModelStore((s) => s.setGridY);
|
const setGridY = useModelStore((s) => s.setGridY);
|
||||||
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
|
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
|
||||||
const placeAtFloor = () => {
|
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
|
||||||
// Headset eye Y minus avg standing eye height (1.6 m)
|
const setGridCalibMode = useModelStore((s) => s.setGridCalibMode);
|
||||||
const floor = camera.position.y - 1.6;
|
const gridLandingMode = useModelStore((s) => s.gridLandingMode);
|
||||||
setGridY(floor);
|
const setGridLandingMode = useModelStore((s) => s.setGridLandingMode);
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<Text position={[-0.24, -0.15, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
<Text position={[-0.24, -0.15, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
|
||||||
@@ -516,8 +689,10 @@ function GridFloorRow() {
|
|||||||
onClick={() => nudgeGridY(-0.01)} />
|
onClick={() => nudgeGridY(-0.01)} />
|
||||||
<XR3DButton position={[0.005, -0.175, 0]} size={[0.04, 0.022]} label="+1cm"
|
<XR3DButton position={[0.005, -0.175, 0]} size={[0.04, 0.022]} label="+1cm"
|
||||||
onClick={() => nudgeGridY(0.01)} />
|
onClick={() => nudgeGridY(0.01)} />
|
||||||
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label="Pousar chão"
|
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label={gridLandingMode ? "Pousando" : "Pousar"}
|
||||||
onClick={placeAtFloor} />
|
active={gridLandingMode} onClick={() => setGridLandingMode(!gridLandingMode)} />
|
||||||
|
<XR3DButton position={[0.155, -0.175, 0]} size={[0.075, 0.022]} label={gridCalibMode ? "Calibrando" : "Calibrar"}
|
||||||
|
active={gridCalibMode} onClick={() => setGridCalibMode(!gridCalibMode)} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -645,79 +820,45 @@ function ShareTab(p: XRHudInWorldProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EnvTab() {
|
function WebXRTab() {
|
||||||
const xrEnvironment = useModelStore((s) => s.xrEnvironment);
|
const xrFeatures = useModelStore((s) => s.xrFeatures);
|
||||||
const setXrEnvironment = useModelStore((s) => s.setXrEnvironment);
|
const setXRFeature = useModelStore((s) => s.setXRFeature);
|
||||||
const xrScaleMode = useModelStore((s) => s.xrScaleMode);
|
const reset = useModelStore((s) => s.resetXRFeatures);
|
||||||
const setXrScaleMode = useModelStore((s) => s.setXrScaleMode);
|
const keys: { k: keyof typeof xrFeatures; label: string }[] = [
|
||||||
|
{ k: 'handTracking', label: 'Hand' },
|
||||||
|
{ k: 'planeDetection', label: 'Planes' },
|
||||||
|
{ k: 'hitTest', label: 'HitTest' },
|
||||||
|
{ k: 'domOverlay', label: 'DOM Ovl' },
|
||||||
|
{ k: 'anchors', label: 'Anchors' },
|
||||||
|
{ k: 'meshDetection', label: 'Mesh' },
|
||||||
|
{ k: 'depthSensing', label: 'Depth' },
|
||||||
|
{ k: 'layers', label: 'Layers' },
|
||||||
|
{ k: 'bodyTracking', label: 'Body' },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<Text position={[-0.24, 0.11, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
|
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
||||||
Cenário Imersivo (VR / AR)
|
Desligue uma feature por vez para descobrir qual ativa o grid de segurança do Quest.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text position={[-0.24, 0.095, 0]} fontSize={0.0075} color="#f59e0b" anchorX="left" maxWidth={0.48}>
|
||||||
<XR3DButton
|
Mudanças entram em vigor ao sair e re-entrar no AR.
|
||||||
position={[-0.14, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Passthrough (AR)"
|
|
||||||
active={xrEnvironment === 'passthrough'}
|
|
||||||
onClick={() => setXrEnvironment('passthrough')}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.0, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Escritório (VR)"
|
|
||||||
active={xrEnvironment === 'office'}
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('office');
|
|
||||||
setXrScaleMode('tabletop');
|
|
||||||
}}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.14, 0.075, 0]}
|
|
||||||
size={[0.13, 0.026]}
|
|
||||||
label="Campo Aberto (VR)"
|
|
||||||
active={xrEnvironment === 'field'}
|
|
||||||
onClick={() => {
|
|
||||||
setXrEnvironment('field');
|
|
||||||
setXrScaleMode('realscale');
|
|
||||||
}}
|
|
||||||
fontSize={0.008}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text position={[-0.24, 0.02, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left">
|
|
||||||
Escala de Apresentação do IFC
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<XR3DButton
|
|
||||||
position={[-0.08, -0.015, 0]}
|
|
||||||
size={[0.18, 0.026]}
|
|
||||||
label="Escala Real 1:1 (Caminhar)"
|
|
||||||
active={xrScaleMode === 'realscale'}
|
|
||||||
onClick={() => setXrScaleMode('realscale')}
|
|
||||||
fontSize={0.0085}
|
|
||||||
/>
|
|
||||||
<XR3DButton
|
|
||||||
position={[0.11, -0.015, 0]}
|
|
||||||
size={[0.18, 0.026]}
|
|
||||||
label="Modo Maquete (Mesa)"
|
|
||||||
active={xrScaleMode === 'tabletop'}
|
|
||||||
onClick={() => setXrScaleMode('tabletop')}
|
|
||||||
fontSize={0.0085}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text position={[-0.24, -0.065, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Passthrough: veja o mundo real à sua volta (Realidade Aumentada).
|
|
||||||
</Text>
|
|
||||||
<Text position={[-0.24, -0.085, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Escritório: maquete interativa reduzida em cima de uma mesa virtual de reunião.
|
|
||||||
</Text>
|
|
||||||
<Text position={[-0.24, -0.105, 0]} fontSize={0.007} color="#94a3b8" anchorX="left" maxWidth={0.48}>
|
|
||||||
• Campo Aberto: terreno gramado em escala real para explorar estruturas e galpões.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{keys.map((it, i) => {
|
||||||
|
const col = i % 3;
|
||||||
|
const row = Math.floor(i / 3);
|
||||||
|
const x = -0.18 + col * 0.14;
|
||||||
|
const y = 0.05 - row * 0.045;
|
||||||
|
const on = xrFeatures[it.k];
|
||||||
|
return (
|
||||||
|
<XR3DButton key={it.k}
|
||||||
|
position={[x, y, 0]} size={[0.125, 0.034]}
|
||||||
|
label={`${it.label} ${on ? 'ON' : 'OFF'}`}
|
||||||
|
active={on} onClick={() => setXRFeature(it.k, !on)}
|
||||||
|
fontSize={0.0085} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<XR3DButton position={[0, -0.14, 0]} size={[0.18, 0.028]}
|
||||||
|
label="↺ Resetar (todas ON)" onClick={reset} fontSize={0.009} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -806,3 +947,203 @@ function RecIndicator() {
|
|||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CalibrationTab() {
|
||||||
|
const activeModelId = useModelStore((s) => s.activeModelId);
|
||||||
|
const models = useModelStore((s) => s.models);
|
||||||
|
const setCalibrationStore = useModelStore((s) => s.setCalibration);
|
||||||
|
const active = models.find((m) => m.id === activeModelId);
|
||||||
|
|
||||||
|
// Escuta o barramento de calibração do XR
|
||||||
|
const [, force] = useState(0);
|
||||||
|
useEffect(() => subscribeXRCalibration(() => force((t) => t + 1)), []);
|
||||||
|
|
||||||
|
const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
|
||||||
|
const isDone = xrCalibration.step === 'done';
|
||||||
|
|
||||||
|
const isCubeMode = xrCalibration.alignType === 'cube';
|
||||||
|
const isVirtRealMode = xrCalibration.alignType === 'virt-real';
|
||||||
|
|
||||||
|
const onClickCube = () => {
|
||||||
|
if (isCalibrating && isCubeMode) {
|
||||||
|
cancelXRCalibration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDone && isCubeMode) {
|
||||||
|
cancelXRCalibration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active) {
|
||||||
|
toast.error('Selecione uma peça antes de calibrar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.locked) {
|
||||||
|
toast.error('Peça travada — destranque a peça para calibrar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startXRCalibration(active.id, 'cube');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickVirtReal = () => {
|
||||||
|
if (isCalibrating && isVirtRealMode) {
|
||||||
|
cancelXRCalibration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDone && isVirtRealMode) {
|
||||||
|
cancelXRCalibration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active) {
|
||||||
|
toast.error('Selecione uma peça antes de calibrar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.locked) {
|
||||||
|
toast.error('Peça travada — destranque a peça para calibrar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startXRCalibration(active.id, 'virt-real');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResetCalibration = () => {
|
||||||
|
if (!active) return;
|
||||||
|
setCalibrationStore(active.id, null);
|
||||||
|
toast.success('Calibração removida');
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_HINTS_AR: Record<string, string> = {
|
||||||
|
'await-cube-1': '1/3 - Clique numa face do Cubo à direita',
|
||||||
|
'await-model-1': '1/3 - Aponte e clique na face correspondente da peça',
|
||||||
|
'await-cube-2': '2/3 - Clique em outra face do Cubo',
|
||||||
|
'await-model-2': '2/3 - Clique na face correspondente da peça',
|
||||||
|
'await-cube-3': '3/3 (Verificar) - Clique em uma 3ª face do Cubo (opcional)',
|
||||||
|
'await-model-3': '3/3 - Clique na face correspondente da peça',
|
||||||
|
'await-real-1': '1/3 Real - Clique na 1ª superfície da peça REAL',
|
||||||
|
'await-virtual-1': '1/3 Virtual - Clique na 1ª correspondente da peça VIRTUAL',
|
||||||
|
'await-real-2': '2/3 Real - Clique na 2ª superfície da peça REAL',
|
||||||
|
'await-virtual-2': '2/3 Virtual - Clique na 2ª correspondente da peça VIRTUAL',
|
||||||
|
'await-real-3': '3/3 Real - Clique na 3ª superfície da peça REAL',
|
||||||
|
'await-virtual-3': '3/3 Virtual - Clique na 3ª correspondente da peça VIRTUAL',
|
||||||
|
'done': 'Calibração concluída com sucesso!',
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Selecione "Cubo" ou "Virt/Real" para calibrar o modelo';
|
||||||
|
|
||||||
|
// Configurações dinâmicas de estados de botão
|
||||||
|
let cubeLabel = 'Cubo';
|
||||||
|
let cubeActive = false;
|
||||||
|
let cubeColor = '#3b82f6';
|
||||||
|
|
||||||
|
let vrLabel = 'Virt/Real';
|
||||||
|
let vrActive = false;
|
||||||
|
let vrColor = '#3b82f6';
|
||||||
|
|
||||||
|
if (isCalibrating) {
|
||||||
|
if (isCubeMode) {
|
||||||
|
cubeLabel = 'Cancelar';
|
||||||
|
cubeActive = true;
|
||||||
|
cubeColor = '#d97706';
|
||||||
|
} else {
|
||||||
|
cubeColor = '#475569';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVirtRealMode) {
|
||||||
|
vrLabel = 'Cancelar';
|
||||||
|
vrActive = true;
|
||||||
|
vrColor = '#d97706';
|
||||||
|
} else {
|
||||||
|
vrColor = '#475569';
|
||||||
|
}
|
||||||
|
} else if (isDone) {
|
||||||
|
if (isCubeMode) {
|
||||||
|
cubeLabel = 'Concluído';
|
||||||
|
cubeActive = true;
|
||||||
|
cubeColor = '#22c55e';
|
||||||
|
}
|
||||||
|
if (isVirtRealMode) {
|
||||||
|
vrLabel = 'Concluído';
|
||||||
|
vrActive = true;
|
||||||
|
vrColor = '#22c55e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Coluna da esquerda: Informações e Fluxo */}
|
||||||
|
<Text position={[-0.24, 0.11, 0]} fontSize={0.012} color="#3b82f6" anchorX="left" anchorY="middle">
|
||||||
|
Calibração 3D da Peça
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text position={[-0.24, 0.07, 0]} fontSize={0.008} color="#cbd5e1" anchorX="left" anchorY="top" maxWidth={0.28}>
|
||||||
|
Alinhe o modelo virtual 3D com a peça real usando o Cubo ou clique em 3 pontos de controle ("Virt/Real").
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Caixa de status do passo atual */}
|
||||||
|
<group position={[-0.24, 0.00, 0]}>
|
||||||
|
<mesh position={[0.13, 0, -0.0005]}>
|
||||||
|
<planeGeometry args={[0.26, 0.05]} />
|
||||||
|
<meshBasicMaterial color="#111827" transparent opacity={0.65} />
|
||||||
|
</mesh>
|
||||||
|
<Text position={[0.01, 0, 0.001]} fontSize={0.008} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
|
||||||
|
{currentHint}
|
||||||
|
</Text>
|
||||||
|
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && isCubeMode && (
|
||||||
|
<Text position={[0.01, -0.016, 0.001]} fontSize={0.007} color="#22c55e" anchorX="left" anchorY="middle">
|
||||||
|
Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}°
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Botões na parte inferior esquerda */}
|
||||||
|
<group position={[-0.24, -0.06, 0]}>
|
||||||
|
<XR3DButton
|
||||||
|
position={[0.045, 0, 0]}
|
||||||
|
size={[0.08, 0.026]}
|
||||||
|
label={cubeLabel}
|
||||||
|
active={cubeActive}
|
||||||
|
color={cubeColor}
|
||||||
|
onClick={onClickCube}
|
||||||
|
fontSize={0.008}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<XR3DButton
|
||||||
|
position={[0.135, 0, 0]}
|
||||||
|
size={[0.08, 0.026]}
|
||||||
|
label={vrLabel}
|
||||||
|
active={vrActive}
|
||||||
|
color={vrColor}
|
||||||
|
onClick={onClickVirtReal}
|
||||||
|
fontSize={0.008}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{active?.calibrationQuat && !isCalibrating && (
|
||||||
|
<XR3DButton
|
||||||
|
position={[0.225, 0, 0]}
|
||||||
|
size={[0.08, 0.026]}
|
||||||
|
label="Limpar"
|
||||||
|
color="#dc2626"
|
||||||
|
onClick={onResetCalibration}
|
||||||
|
fontSize={0.008}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<Text position={[-0.24, -0.11, 0]} fontSize={0.007} color="#64748b" anchorX="left" maxWidth={0.28}>
|
||||||
|
{active ? `Peça ativa: ${active.fileName}` : 'Selecione uma peça na aba "Cena"'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Coluna da direita: Cubo de views 3D interativo */}
|
||||||
|
<group position={[0.14, -0.01, 0.04]}>
|
||||||
|
<XRViewCube activeModelId={activeModelId} size={0.075} />
|
||||||
|
{/* Tripé dos eixos abaixo do cubo */}
|
||||||
|
<group position={[0, -0.06, -0.04]} scale={[0.035, 0.035, 0.035]}>
|
||||||
|
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
|
||||||
|
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
|
||||||
|
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
|
||||||
|
</group>
|
||||||
|
<Text position={[0, 0.065, -0.04]} fontSize={0.008} color="#cbd5e1" anchorX="center" anchorY="middle">
|
||||||
|
Cubo de Calibração
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useRef, useMemo, useEffect, useState } from 'react';
|
||||||
|
import { useFrame, ThreeEvent } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { pushXRCubeFace } from './xrCalibrationBus';
|
||||||
|
import { getModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
|
|
||||||
|
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
|
||||||
|
const size = 256;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = size;
|
||||||
|
c.height = size;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, size);
|
||||||
|
grad.addColorStop(0, accent ? '#1e3a8a' : '#111827');
|
||||||
|
grad.addColorStop(1, accent ? '#0f172a' : '#030712');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.strokeRect(4, 4, size - 8, size - 8);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '700 52px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.shadowColor = '#3b82f6';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.fillText(label, size / 2, size / 2);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.anisotropy = 4;
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACE_DEFS: { label: string; dir: [number, number, number] }[] = [
|
||||||
|
{ label: 'DIR', dir: [ 1, 0, 0] },
|
||||||
|
{ label: 'ESQ', dir: [-1, 0, 0] },
|
||||||
|
{ label: 'TOPO', dir: [ 0, 1, 0] },
|
||||||
|
{ label: 'BASE', dir: [ 0,-1, 0] },
|
||||||
|
{ label: 'FRENTE', dir: [ 0, 0, 1] },
|
||||||
|
{ label: 'ATRÁS', dir: [ 0, 0,-1] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function XRViewCube({ activeModelId, size = 0.06 }: { activeModelId: string | null; size?: number }) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const materials = useMemo(() => {
|
||||||
|
return FACE_DEFS.map((f, i) => {
|
||||||
|
const tex = makeFaceTexture(f.label, false);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ map: tex });
|
||||||
|
mat.userData.baseTex = tex;
|
||||||
|
mat.userData.hoverTex = makeFaceTexture(f.label, true);
|
||||||
|
mat.userData.faceIndex = i;
|
||||||
|
return mat;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
materials.forEach((m) => {
|
||||||
|
m.userData.baseTex?.dispose?.();
|
||||||
|
m.userData.hoverTex?.dispose?.();
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
materials.forEach((m, i) => {
|
||||||
|
m.map = i === hover ? m.userData.hoverTex : m.userData.baseTex;
|
||||||
|
m.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [hover, materials]);
|
||||||
|
|
||||||
|
const modelQuat = useMemo(() => new THREE.Quaternion(), []);
|
||||||
|
|
||||||
|
const getModelWorldQuat = (): THREE.Quaternion => {
|
||||||
|
const g = getModelLocalGroup(activeModelId);
|
||||||
|
if (g) {
|
||||||
|
g.updateWorldMatrix(true, false);
|
||||||
|
g.getWorldQuaternion(modelQuat);
|
||||||
|
return modelQuat;
|
||||||
|
}
|
||||||
|
modelQuat.identity();
|
||||||
|
return modelQuat;
|
||||||
|
};
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
// Sincroniza a rotação do cubo no HUD AR com a rotação do modelo ativo no mundo AR.
|
||||||
|
// Desta forma, o cubo reflete a orientação 3D da peça.
|
||||||
|
groupRef.current.quaternion.copy(getModelWorldQuat());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClick = (e: ThreeEvent<MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = e.face?.materialIndex;
|
||||||
|
if (idx == null) return;
|
||||||
|
const def = FACE_DEFS[idx];
|
||||||
|
if (!def) return;
|
||||||
|
const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize();
|
||||||
|
pushXRCubeFace(dirWorld);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<mesh
|
||||||
|
material={materials}
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerOver={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = e.face?.materialIndex ?? null;
|
||||||
|
setHover(idx);
|
||||||
|
}}
|
||||||
|
onPointerOut={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHover(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[size, size, size]} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
|
export type XRCalibrationStep =
|
||||||
|
| 'idle'
|
||||||
|
| 'await-cube-1'
|
||||||
|
| 'await-model-1'
|
||||||
|
| 'await-cube-2'
|
||||||
|
| 'await-model-2'
|
||||||
|
| 'await-cube-3' // opcional verificação
|
||||||
|
| 'await-model-3'
|
||||||
|
| 'await-real-1'
|
||||||
|
| 'await-virtual-1'
|
||||||
|
| 'await-real-2'
|
||||||
|
| 'await-virtual-2'
|
||||||
|
| 'await-real-3'
|
||||||
|
| 'await-virtual-3'
|
||||||
|
| 'done';
|
||||||
|
|
||||||
|
interface XRPair {
|
||||||
|
cube: THREE.Vector3;
|
||||||
|
model: THREE.Vector3;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface XRCalState {
|
||||||
|
step: XRCalibrationStep;
|
||||||
|
modelId: string | null;
|
||||||
|
alignType: 'cube' | 'virt-real' | null;
|
||||||
|
pairs: XRPair[];
|
||||||
|
realPoints: THREE.Vector3[];
|
||||||
|
virtualPoints: THREE.Vector3[];
|
||||||
|
pendingCube: THREE.Vector3 | null;
|
||||||
|
progress: number;
|
||||||
|
verifyErrorDeg: number;
|
||||||
|
listeners: Set<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xrCalibration: XRCalState = {
|
||||||
|
step: 'idle',
|
||||||
|
modelId: null,
|
||||||
|
alignType: null,
|
||||||
|
pairs: [],
|
||||||
|
realPoints: [],
|
||||||
|
virtualPoints: [],
|
||||||
|
pendingCube: null,
|
||||||
|
progress: 0,
|
||||||
|
verifyErrorDeg: NaN,
|
||||||
|
listeners: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
xrCalibration.listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeXRCalibration(fn: () => void): () => void {
|
||||||
|
xrCalibration.listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
xrCalibration.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startXRCalibration(modelId: string, type: 'cube' | 'virt-real' = 'cube') {
|
||||||
|
xrCalibration.modelId = modelId;
|
||||||
|
xrCalibration.alignType = type;
|
||||||
|
xrCalibration.pairs = [];
|
||||||
|
xrCalibration.realPoints = [];
|
||||||
|
xrCalibration.virtualPoints = [];
|
||||||
|
xrCalibration.pendingCube = null;
|
||||||
|
xrCalibration.progress = 0;
|
||||||
|
xrCalibration.verifyErrorDeg = NaN;
|
||||||
|
|
||||||
|
if (type === 'cube') {
|
||||||
|
xrCalibration.step = 'await-cube-1';
|
||||||
|
} else {
|
||||||
|
xrCalibration.step = 'await-real-1';
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelXRCalibration() {
|
||||||
|
xrCalibration.step = 'idle';
|
||||||
|
xrCalibration.modelId = null;
|
||||||
|
xrCalibration.alignType = null;
|
||||||
|
xrCalibration.pairs = [];
|
||||||
|
xrCalibration.realPoints = [];
|
||||||
|
xrCalibration.virtualPoints = [];
|
||||||
|
xrCalibration.pendingCube = null;
|
||||||
|
xrCalibration.progress = 0;
|
||||||
|
xrCalibration.verifyErrorDeg = NaN;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushXRCubeFace(dirWorld: THREE.Vector3) {
|
||||||
|
if (xrCalibration.step === 'await-cube-1') {
|
||||||
|
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||||
|
xrCalibration.step = 'await-model-1';
|
||||||
|
} else if (xrCalibration.step === 'await-cube-2') {
|
||||||
|
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||||
|
xrCalibration.step = 'await-model-2';
|
||||||
|
} else if (xrCalibration.step === 'await-cube-3') {
|
||||||
|
xrCalibration.pendingCube = dirWorld.clone().normalize();
|
||||||
|
xrCalibration.step = 'await-model-3';
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushXRRealPoint(pointWorld: THREE.Vector3) {
|
||||||
|
if (xrCalibration.alignType !== 'virt-real') return;
|
||||||
|
const p = pointWorld.clone();
|
||||||
|
if (xrCalibration.step === 'await-real-1') {
|
||||||
|
xrCalibration.realPoints[0] = p;
|
||||||
|
xrCalibration.step = 'await-virtual-1';
|
||||||
|
xrCalibration.progress = 0.16;
|
||||||
|
} else if (xrCalibration.step === 'await-real-2') {
|
||||||
|
xrCalibration.realPoints[1] = p;
|
||||||
|
xrCalibration.step = 'await-virtual-2';
|
||||||
|
xrCalibration.progress = 0.5;
|
||||||
|
} else if (xrCalibration.step === 'await-real-3') {
|
||||||
|
xrCalibration.realPoints[2] = p;
|
||||||
|
xrCalibration.step = 'await-virtual-3';
|
||||||
|
xrCalibration.progress = 0.83;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushXRVirtualPoint(pointLocalPivot: THREE.Vector3) {
|
||||||
|
if (xrCalibration.alignType !== 'virt-real') return;
|
||||||
|
const p = pointLocalPivot.clone();
|
||||||
|
if (xrCalibration.step === 'await-virtual-1') {
|
||||||
|
xrCalibration.virtualPoints[0] = p;
|
||||||
|
xrCalibration.step = 'await-real-2';
|
||||||
|
xrCalibration.progress = 0.33;
|
||||||
|
} else if (xrCalibration.step === 'await-virtual-2') {
|
||||||
|
xrCalibration.virtualPoints[1] = p;
|
||||||
|
xrCalibration.step = 'await-real-3';
|
||||||
|
xrCalibration.progress = 0.66;
|
||||||
|
} else if (xrCalibration.step === 'await-virtual-3') {
|
||||||
|
xrCalibration.virtualPoints[2] = p;
|
||||||
|
xrCalibration.step = 'done';
|
||||||
|
xrCalibration.progress = 1.0;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 {
|
||||||
|
const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg));
|
||||||
|
const axes = [
|
||||||
|
new THREE.Vector3(1, 0, 0),
|
||||||
|
new THREE.Vector3(-1, 0, 0),
|
||||||
|
new THREE.Vector3(0, 1, 0),
|
||||||
|
new THREE.Vector3(0, -1, 0),
|
||||||
|
new THREE.Vector3(0, 0, 1),
|
||||||
|
new THREE.Vector3(0, 0, -1),
|
||||||
|
];
|
||||||
|
let best = v;
|
||||||
|
let bestDot = cosT;
|
||||||
|
for (const a of axes) {
|
||||||
|
const d = v.dot(a);
|
||||||
|
if (d > bestDot) {
|
||||||
|
bestDot = d;
|
||||||
|
best = a.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushXRModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) {
|
||||||
|
if (!xrCalibration.pendingCube) return;
|
||||||
|
const cubeWorld = xrCalibration.pendingCube;
|
||||||
|
const inv = calGroupWorldQuat.clone().invert();
|
||||||
|
const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize();
|
||||||
|
const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize();
|
||||||
|
const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12);
|
||||||
|
xrCalibration.pairs.push({ cube: cubeLocal, model: modelLocal });
|
||||||
|
xrCalibration.pendingCube = null;
|
||||||
|
|
||||||
|
if (xrCalibration.step === 'await-model-1') {
|
||||||
|
xrCalibration.step = 'await-cube-2';
|
||||||
|
xrCalibration.progress = 0.33;
|
||||||
|
} else if (xrCalibration.step === 'await-model-2') {
|
||||||
|
xrCalibration.progress = 0.75;
|
||||||
|
xrCalibration.step = 'await-cube-3';
|
||||||
|
} else if (xrCalibration.step === 'await-model-3') {
|
||||||
|
xrCalibration.progress = 1;
|
||||||
|
xrCalibration.verifyErrorDeg = computeXRVerifyError(xrCalibration.pairs);
|
||||||
|
xrCalibration.step = 'done';
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeXRCalibrationQuaternion(pairs: XRPair[]): THREE.Quaternion | null {
|
||||||
|
if (pairs.length < 2) return null;
|
||||||
|
const n1 = pairs[0].model.clone().normalize();
|
||||||
|
const n2raw = pairs[1].model.clone();
|
||||||
|
const n2 = n2raw.sub(n1.clone().multiplyScalar(n2raw.dot(n1)));
|
||||||
|
if (n2.lengthSq() < 1e-6) return null;
|
||||||
|
n2.normalize();
|
||||||
|
const n3 = new THREE.Vector3().crossVectors(n1, n2);
|
||||||
|
|
||||||
|
const c1 = pairs[0].cube.clone().normalize();
|
||||||
|
const c2raw = pairs[1].cube.clone();
|
||||||
|
const c2 = c2raw.sub(c1.clone().multiplyScalar(c2raw.dot(c1)));
|
||||||
|
if (c2.lengthSq() < 1e-6) return null;
|
||||||
|
c2.normalize();
|
||||||
|
const c3 = new THREE.Vector3().crossVectors(c1, c2);
|
||||||
|
|
||||||
|
const N = new THREE.Matrix4().makeBasis(n1, n2, n3);
|
||||||
|
const C = new THREE.Matrix4().makeBasis(c1, c2, c3);
|
||||||
|
const Nt = N.clone().transpose();
|
||||||
|
const R = new THREE.Matrix4().multiplyMatrices(C, Nt);
|
||||||
|
const q = new THREE.Quaternion().setFromRotationMatrix(R);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeXRVerifyError(pairs: XRPair[]): number {
|
||||||
|
if (pairs.length < 3) return NaN;
|
||||||
|
const q = computeXRCalibrationQuaternion(pairs.slice(0, 2));
|
||||||
|
if (!q) return NaN;
|
||||||
|
const predicted = pairs[2].model.clone().applyQuaternion(q).normalize();
|
||||||
|
const target = pairs[2].cube.clone().normalize();
|
||||||
|
const cos = THREE.MathUtils.clamp(predicted.dot(target), -1, 1);
|
||||||
|
return THREE.MathUtils.radToDeg(Math.acos(cos));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeVirtRealTransform(
|
||||||
|
v: THREE.Vector3[],
|
||||||
|
r: THREE.Vector3[]
|
||||||
|
): { quaternion: THREE.Quaternion; position: THREE.Vector3 } | null {
|
||||||
|
if (v.length < 3 || r.length < 3) return null;
|
||||||
|
|
||||||
|
const v1 = v[0];
|
||||||
|
const v2 = v[1];
|
||||||
|
const v3 = v[2];
|
||||||
|
|
||||||
|
const r1 = r[0];
|
||||||
|
const r2 = r[1];
|
||||||
|
const r3 = r[2];
|
||||||
|
|
||||||
|
// 1. Basis local virtual
|
||||||
|
const xv = new THREE.Vector3().subVectors(v2, v1).normalize();
|
||||||
|
const v3_proj = new THREE.Vector3().subVectors(v3, v1);
|
||||||
|
const dot_v = v3_proj.dot(xv);
|
||||||
|
const yv = v3_proj.clone().sub(xv.clone().multiplyScalar(dot_v));
|
||||||
|
if (yv.lengthSq() < 1e-6) return null;
|
||||||
|
yv.normalize();
|
||||||
|
const zv = new THREE.Vector3().crossVectors(xv, yv).normalize();
|
||||||
|
|
||||||
|
// 2. Basis de mundo real
|
||||||
|
const xr = new THREE.Vector3().subVectors(r2, r1).normalize();
|
||||||
|
const r3_proj = new THREE.Vector3().subVectors(r3, r1);
|
||||||
|
const dot_r = r3_proj.dot(xr);
|
||||||
|
const yr = r3_proj.clone().sub(xr.clone().multiplyScalar(dot_r));
|
||||||
|
if (yr.lengthSq() < 1e-6) return null;
|
||||||
|
yr.normalize();
|
||||||
|
const zr = new THREE.Vector3().crossVectors(xr, yr).normalize();
|
||||||
|
|
||||||
|
// 3. Matriz de rotação R que mapeia virtual para real
|
||||||
|
const Mv = new THREE.Matrix4().makeBasis(xv, yv, zv);
|
||||||
|
const Mr = new THREE.Matrix4().makeBasis(xr, yr, zr);
|
||||||
|
const Mv_t = Mv.clone().transpose();
|
||||||
|
const R = Mr.clone().multiply(Mv_t);
|
||||||
|
|
||||||
|
const quaternion = new THREE.Quaternion().setFromRotationMatrix(R);
|
||||||
|
|
||||||
|
// 4. Translação T = r1 - R * v1
|
||||||
|
const rv1 = v1.clone().applyQuaternion(quaternion);
|
||||||
|
const position = new THREE.Vector3().subVectors(r1, rv1);
|
||||||
|
|
||||||
|
return { quaternion, position };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeModelGroupRef = { current: null as THREE.Group | null };
|
||||||
@@ -75,7 +75,7 @@ export function startRecording() {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@@ -123,7 +123,7 @@ export function captureScreenshot(): string | null {
|
|||||||
// Trigger download
|
// Trigger download
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useFrame, useThree } from '@react-three/fiber';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
|
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
|
||||||
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
|
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
export interface GrabState {
|
export interface GrabState {
|
||||||
/** Analog grip pressure (0..1) */
|
/** Analog grip pressure (0..1) */
|
||||||
@@ -108,6 +109,10 @@ export function useControllerGrab() {
|
|||||||
const pose = frame.getPose(source.gripSpace, referenceSpace);
|
const pose = frame.getPose(source.gripSpace, referenceSpace);
|
||||||
if (pose) {
|
if (pose) {
|
||||||
slot.gripWorld.fromArray(pose.transform.matrix);
|
slot.gripWorld.fromArray(pose.transform.matrix);
|
||||||
|
const xrRig = useModelStore.getState().xrRig;
|
||||||
|
if (xrRig) {
|
||||||
|
slot.gripWorld.premultiply(xrRig.matrixWorld);
|
||||||
|
}
|
||||||
slot.hasPose = true;
|
slot.hasPose = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,39 +92,4 @@
|
|||||||
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
|
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewcube-wrapper {
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transform: rotateX(-30deg) rotateY(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-top {
|
|
||||||
transform: rotateX(90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-front {
|
|
||||||
transform: translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-right {
|
|
||||||
transform: rotateY(90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-left {
|
|
||||||
transform: rotateY(-90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-back {
|
|
||||||
transform: rotateY(180deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewcube-face-bottom {
|
|
||||||
transform: rotateX(-90deg) translateZ(28px);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,53 @@ const WASM_PATH = 'https://unpkg.com/web-ifc@0.0.57/';
|
|||||||
/**
|
/**
|
||||||
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
|
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
|
||||||
*/
|
*/
|
||||||
|
function getMaterialName(ifcApi: WebIFC.IfcAPI, modelID: number, matRef: any): string | null {
|
||||||
|
if (!matRef) return null;
|
||||||
|
const matId = matRef.value;
|
||||||
|
if (!matId) return null;
|
||||||
|
try {
|
||||||
|
const matLine = ifcApi.GetLine(modelID, matId);
|
||||||
|
if (!matLine) return null;
|
||||||
|
|
||||||
|
if (matLine.Name && matLine.Name.value) {
|
||||||
|
return matLine.Name.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matLine.Materials) {
|
||||||
|
for (const mRef of matLine.Materials) {
|
||||||
|
const name = getMaterialName(ifcApi, modelID, mRef);
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matLine.MaterialConstituents) {
|
||||||
|
for (const mcRef of matLine.MaterialConstituents) {
|
||||||
|
const mcLine = ifcApi.GetLine(modelID, mcRef.value);
|
||||||
|
if (mcLine && mcLine.Material) {
|
||||||
|
const name = getMaterialName(ifcApi, modelID, mcLine.Material);
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matLine.MaterialProfileSet) {
|
||||||
|
return getMaterialName(ifcApi, modelID, matLine.MaterialProfileSet);
|
||||||
|
}
|
||||||
|
if (matLine.MaterialProfiles) {
|
||||||
|
for (const mpRef of matLine.MaterialProfiles) {
|
||||||
|
const mpLine = ifcApi.GetLine(modelID, mpRef.value);
|
||||||
|
if (mpLine && mpLine.Material) {
|
||||||
|
const name = getMaterialName(ifcApi, modelID, mpLine.Material);
|
||||||
|
if (name) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silencia erros de atributos inexistentes
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
|
export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
|
||||||
const ifcApi = new WebIFC.IfcAPI();
|
const ifcApi = new WebIFC.IfcAPI();
|
||||||
ifcApi.SetWasmPath(WASM_PATH, true);
|
ifcApi.SetWasmPath(WASM_PATH, true);
|
||||||
@@ -15,6 +62,87 @@ export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene>
|
|||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
const modelID = ifcApi.OpenModel(data);
|
const modelID = ifcApi.OpenModel(data);
|
||||||
|
|
||||||
|
// Mapeamento de materiais
|
||||||
|
const elementMaterialMap = new Map<number, string>();
|
||||||
|
try {
|
||||||
|
const rels = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
|
||||||
|
for (let i = 0; i < rels.size(); i++) {
|
||||||
|
const relId = rels.get(i);
|
||||||
|
const rel = ifcApi.GetLine(modelID, relId);
|
||||||
|
if (rel && rel.RelatedObjects && rel.RelatingMaterial) {
|
||||||
|
const matName = getMaterialName(ifcApi, modelID, rel.RelatingMaterial);
|
||||||
|
if (matName) {
|
||||||
|
for (const objRef of rel.RelatedObjects) {
|
||||||
|
elementMaterialMap.set(objRef.value, matName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[IFC Parser] Falha ao mapear materiais:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeamento de propriedades adicionais
|
||||||
|
const elementPropertiesMap = new Map<number, Record<string, string>>();
|
||||||
|
try {
|
||||||
|
const relsProp = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
|
||||||
|
for (let i = 0; i < relsProp.size(); i++) {
|
||||||
|
const relId = relsProp.get(i);
|
||||||
|
const rel = ifcApi.GetLine(modelID, relId);
|
||||||
|
if (rel && rel.RelatedObjects && rel.RelatingPropertyDefinition) {
|
||||||
|
const propDefId = rel.RelatingPropertyDefinition.value;
|
||||||
|
const propDef = ifcApi.GetLine(modelID, propDefId);
|
||||||
|
|
||||||
|
if (propDef) {
|
||||||
|
const props: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (propDef.HasProperties) {
|
||||||
|
for (const pRef of propDef.HasProperties) {
|
||||||
|
const pLine = ifcApi.GetLine(modelID, pRef.value);
|
||||||
|
if (pLine && pLine.Name) {
|
||||||
|
const name = pLine.Name.value;
|
||||||
|
let value = '';
|
||||||
|
if (pLine.NominalValue) {
|
||||||
|
value = String(pLine.NominalValue.value);
|
||||||
|
}
|
||||||
|
if (name && value) {
|
||||||
|
props[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propDef.Quantities) {
|
||||||
|
for (const qRef of propDef.Quantities) {
|
||||||
|
const qLine = ifcApi.GetLine(modelID, qRef.value);
|
||||||
|
if (qLine && qLine.Name) {
|
||||||
|
const name = qLine.Name.value;
|
||||||
|
let value = '';
|
||||||
|
if (qLine.LengthValue !== undefined) value = `${qLine.LengthValue.toFixed(1)} mm`;
|
||||||
|
else if (qLine.AreaValue !== undefined) value = `${qLine.AreaValue.toFixed(1)} m²`;
|
||||||
|
else if (qLine.VolumeValue !== undefined) value = `${qLine.VolumeValue.toFixed(2)} m³`;
|
||||||
|
else if (qLine.NominalValue) value = String(qLine.NominalValue.value);
|
||||||
|
|
||||||
|
if (name && value) {
|
||||||
|
props[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(props).length > 0) {
|
||||||
|
for (const objRef of rel.RelatedObjects) {
|
||||||
|
const existing = elementPropertiesMap.get(objRef.value) ?? {};
|
||||||
|
elementPropertiesMap.set(objRef.value, { ...existing, ...props });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[IFC Parser] Falha ao mapear propriedades:', err);
|
||||||
|
}
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
const materials: Map<number, THREE.MeshStandardMaterial> = new Map();
|
const materials: Map<number, THREE.MeshStandardMaterial> = new Map();
|
||||||
|
|
||||||
@@ -22,11 +150,38 @@ export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene>
|
|||||||
const placedGeometries = mesh.geometries;
|
const placedGeometries = mesh.geometries;
|
||||||
const expressID = (mesh as unknown as { expressID?: number }).expressID ?? 0;
|
const expressID = (mesh as unknown as { expressID?: number }).expressID ?? 0;
|
||||||
|
|
||||||
// One Group per IfcProduct → represents a single "element" (beam, plate…).
|
let name = '';
|
||||||
// userData is preserved by GLTFExporter as `extras` and survives reload.
|
let tag = '';
|
||||||
|
let objectType = '';
|
||||||
|
let description = '';
|
||||||
|
try {
|
||||||
|
const elementLine = ifcApi.GetLine(modelID, expressID);
|
||||||
|
if (elementLine) {
|
||||||
|
name = elementLine.Name?.value ?? '';
|
||||||
|
tag = elementLine.Tag?.value ?? '';
|
||||||
|
objectType = elementLine.ObjectType?.value ?? '';
|
||||||
|
description = elementLine.Description?.value ?? '';
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const materialName = elementMaterialMap.get(expressID) ?? '';
|
||||||
|
const extraProps = elementPropertiesMap.get(expressID) ?? {};
|
||||||
|
|
||||||
const elementGroup = new THREE.Group();
|
const elementGroup = new THREE.Group();
|
||||||
elementGroup.name = `ifc_${expressID}`;
|
elementGroup.name = `ifc_${expressID}`;
|
||||||
elementGroup.userData = { ifcElement: true, ifcId: expressID };
|
elementGroup.userData = {
|
||||||
|
ifcElement: true,
|
||||||
|
ifcId: expressID,
|
||||||
|
materialName,
|
||||||
|
properties: {
|
||||||
|
name,
|
||||||
|
tag,
|
||||||
|
objectType,
|
||||||
|
description,
|
||||||
|
material: materialName,
|
||||||
|
...extraProps
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < placedGeometries.size(); i++) {
|
for (let i = 0; i < placedGeometries.size(); i++) {
|
||||||
const placedGeometry = placedGeometries.get(i);
|
const placedGeometry = placedGeometries.get(i);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function getModelLocalGroup(modelId: string | null | undefined): THREE.Ob
|
|||||||
return localGroups.get(modelId);
|
return localGroups.get(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllModelLocalGroups(): THREE.Object3D[] {
|
||||||
|
return Array.from(localGroups.values());
|
||||||
|
}
|
||||||
|
|
||||||
export function worldToModelLocal(
|
export function worldToModelLocal(
|
||||||
modelId: string | null | undefined,
|
modelId: string | null | undefined,
|
||||||
p: { x: number; y: number; z: number },
|
p: { x: number; y: number; z: number },
|
||||||
@@ -33,3 +37,25 @@ export function worldToModelLocal(
|
|||||||
g.worldToLocal(v);
|
g.worldToLocal(v);
|
||||||
return { x: v.x, y: v.y, z: v.z };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react';
|
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Users, Info } from 'lucide-react';
|
||||||
|
import { LandingContent } from '@/components/LandingContent';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -20,12 +21,13 @@ const Index = () => {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
|
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
|
||||||
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
||||||
|
const [showLanding, setShowLanding] = useState(false);
|
||||||
|
|
||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
const { model, addModel, models, maxModels, xrSupported, setXrSupported, ifcColorMode, setIfcColorMode } = useModelStore();
|
||||||
|
|
||||||
// --- Painel de Logs Remotos em Tempo Real ---
|
// --- Painel de Logs Remotos em Tempo Real ---
|
||||||
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: any }[]>([]);
|
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: unknown }[]>([]);
|
||||||
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
|
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -205,23 +207,31 @@ const Index = () => {
|
|||||||
accept={ACCEPTED_EXTENSIONS}
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
title="Importar arquivo de modelo 3D"
|
title="Importar arquivo de modelo 3D"
|
||||||
aria-label="Importar arquivo de modelo 3D"
|
|
||||||
onChange={handleFileUpload} />
|
onChange={handleFileUpload} />
|
||||||
|
|
||||||
|
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<div className="mb-12 text-center">
|
<div className="mb-10 flex flex-col items-center justify-center w-full">
|
||||||
<div className="mb-4 flex items-center justify-center gap-3">
|
<div className="relative flex items-center justify-center max-w-[280px] md:max-w-[320px] w-full px-4">
|
||||||
<Box className="h-10 w-10 text-primary" />
|
<img
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
src="/logotipo_steelXR_transparente.png"
|
||||||
TrackSteel<span className="text-primary">XR</span>
|
alt="SteelXR Logo"
|
||||||
</h1>
|
className="w-full h-auto object-contain drop-shadow-[0_0_20px_rgba(251,191,36,0.18)]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-8 md:-right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||||
|
onClick={() => setShowLanding(!showLanding)}
|
||||||
|
title="Sobre"
|
||||||
|
>
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-mono text-sm uppercase tracking-widest text-muted-foreground">
|
|
||||||
Inspeção de Qualidade Industrial
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showLanding && <LandingContent />}
|
||||||
|
|
||||||
{/* Main card */}
|
{/* Main card */}
|
||||||
<div className="w-full max-w-md space-y-4">
|
<div className="w-full max-w-md space-y-4">
|
||||||
{/* Import button */}
|
{/* Import button */}
|
||||||
@@ -255,6 +265,50 @@ const Index = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Escolha do Filtro de Cores (IFC) */}
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card/50 p-3.5">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label className="text-xs font-semibold text-foreground">
|
||||||
|
Modo de Cores (IFC)
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Escolha como aplicar cores aos elementos do modelo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1 bg-slate-950/40 p-1 rounded-md border border-border/40 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIfcColorMode('none')}
|
||||||
|
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||||
|
ifcColorMode === 'none'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Cor Única
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIfcColorMode('material')}
|
||||||
|
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||||
|
ifcColorMode === 'material'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Material
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIfcColorMode('description')}
|
||||||
|
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
|
||||||
|
ifcColorMode === 'description'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Descrição
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Demo buttons */}
|
{/* Demo buttons */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<CloudLoader />
|
<CloudLoader />
|
||||||
@@ -306,6 +360,15 @@ const Index = () => {
|
|||||||
Visualizar Modelo 3D
|
Visualizar Modelo 3D
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Enter virtual meeting */}
|
||||||
|
<Button
|
||||||
|
className="h-14 w-full gap-3 text-base font-semibold bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white shadow-lg shadow-violet-500/20 hover:shadow-violet-500/35 border-0 transition-all duration-200"
|
||||||
|
onClick={() => navigate('/meeting')}>
|
||||||
|
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Reunião Virtual
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* XR Status */}
|
{/* XR Status */}
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
{xrSupported === null ?
|
{xrSupported === null ?
|
||||||
@@ -329,7 +392,7 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
||||||
TrackSteelXR v1.06 — Q.C. Inspection
|
SteelXR v1.13 — Q.C. Inspection
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucide-react";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2, Users, Menu, Target } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useModelStore } from "@/stores/useModelStore";
|
import { useModelStore } from "@/stores/useModelStore";
|
||||||
|
import { xrStore } from "@/stores/useXRStore";
|
||||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||||
import { ViewerControls } from "@/components/ViewerControls";
|
import { ViewerControls } from "@/components/ViewerControls";
|
||||||
import { ViewCube } from "@/components/ViewCube";
|
|
||||||
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
||||||
|
import { ViewCube } from "@/components/ViewCube";
|
||||||
|
import { SectionCutPanel } from "@/components/SectionCutPanel";
|
||||||
import { ShareButton } from "@/components/ShareButton";
|
import { ShareButton } from "@/components/ShareButton";
|
||||||
import { CloudLoader } from "@/components/CloudLoader";
|
import { CloudLoader } from "@/components/CloudLoader";
|
||||||
import { SceneModelList } from "@/components/SceneModelList";
|
import { SceneModelList } from "@/components/SceneModelList";
|
||||||
@@ -19,18 +22,180 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
|
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
|
||||||
import { validateModelFile } from "@/lib/validateModelFile";
|
import { validateModelFile } from "@/lib/validateModelFile";
|
||||||
import { convertIFCtoGLB } from "@/lib/convertIFC";
|
import { convertIFCtoGLB } from "@/lib/convertIFC";
|
||||||
|
import { mainCameraRef, mainControlsRef } from "@/components/three/viewCubeBus";
|
||||||
|
|
||||||
const Viewer = () => {
|
const Viewer = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { model, xrSupported, addModel, models, maxModels, scaleRatio } = useModelStore();
|
const {
|
||||||
|
model,
|
||||||
|
xrSupported,
|
||||||
|
addModel,
|
||||||
|
models,
|
||||||
|
maxModels,
|
||||||
|
scaleRatio,
|
||||||
|
fineTuning,
|
||||||
|
sectionEnabled,
|
||||||
|
sectionInvert,
|
||||||
|
sectionLevel,
|
||||||
|
hoverIfcProps
|
||||||
|
} = useModelStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model) {
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRecenter = useCallback(() => {
|
||||||
|
const camera = mainCameraRef.current;
|
||||||
|
const controls = mainControlsRef.current;
|
||||||
|
if (camera) {
|
||||||
|
camera.position.set(2, 2, 2);
|
||||||
|
camera.up.set(0, 1, 0);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
if (controls) {
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
toast.success("Visualização centralizada");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exitFullscreen = useCallback(() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen().catch((err) => {
|
||||||
|
console.error("Erro ao sair de tela cheia:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const roomId = searchParams.get("room");
|
||||||
|
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
||||||
|
|
||||||
|
// Se não houver modelo e não estivermos em Reunião, volta para a Home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model && !roomId) {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [model, navigate]);
|
}, [model, roomId, navigate]);
|
||||||
|
|
||||||
|
const sendMeetingUpdate = useCallback((ft: typeof fineTuning, secEn: typeof sectionEnabled, secLevel: typeof sectionLevel, secInv: typeof sectionInvert) => {
|
||||||
|
if (!roomId || !channelRef.current) return;
|
||||||
|
|
||||||
|
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => secEn[ax]);
|
||||||
|
const isSectionActive = !!enabledAxis;
|
||||||
|
const sectionAxis = enabledAxis || 'y';
|
||||||
|
const sectionLevelVal = isSectionActive ? secLevel[sectionAxis] : 0.0;
|
||||||
|
const sectionInvertVal = isSectionActive ? secInv[sectionAxis] : false;
|
||||||
|
|
||||||
|
// Obtém dados da maquete ativa na store
|
||||||
|
const activeModelStore = useModelStore.getState().model;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
rotation: [ft.rotX, ft.rotY, ft.rotZ],
|
||||||
|
scale: ft.scale,
|
||||||
|
sectionEnabled: isSectionActive,
|
||||||
|
sectionAxis,
|
||||||
|
sectionLevel: sectionLevelVal,
|
||||||
|
sectionInvert: sectionInvertVal,
|
||||||
|
modelUrl: activeModelStore?.url,
|
||||||
|
modelFileName: activeModelStore?.fileName,
|
||||||
|
modelFileSize: activeModelStore?.fileSize
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📡 [Viewer Meeting Sync] Enviando atualização de apresentação:", payload);
|
||||||
|
channelRef.current.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'presentation_update',
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
// Conecta ao canal do Supabase se o Viewer foi aberto no modo Reunião
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
const finalRoomId = roomId.trim().toUpperCase();
|
||||||
|
console.log(`🔌 [Viewer Meeting Sync] Conectando ao canal da reunião: ${finalRoomId}`);
|
||||||
|
|
||||||
|
const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
|
||||||
|
config: {
|
||||||
|
broadcast: { self: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
channelRef.current = channel;
|
||||||
|
|
||||||
|
channel.subscribe((status) => {
|
||||||
|
console.log(`🔌 [Viewer Meeting Sync] Status do canal: ${status}`);
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
const state = useModelStore.getState();
|
||||||
|
sendMeetingUpdate(state.fineTuning, state.sectionEnabled, state.sectionLevel, state.sectionInvert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log(`🔌 [Viewer Meeting Sync] Desconectando do canal da reunião: ${finalRoomId}`);
|
||||||
|
channel.unsubscribe();
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [roomId, sendMeetingUpdate]);
|
||||||
|
|
||||||
|
// Observa mudanças primitivas nas ferramentas 3D para propagação imediata via Broadcast
|
||||||
|
const rotX = fineTuning.rotX;
|
||||||
|
const rotY = fineTuning.rotY;
|
||||||
|
const rotZ = fineTuning.rotZ;
|
||||||
|
const scaleVal = fineTuning.scale;
|
||||||
|
|
||||||
|
const secEnabledX = sectionEnabled.x;
|
||||||
|
const secEnabledY = sectionEnabled.y;
|
||||||
|
const secEnabledZ = sectionEnabled.z;
|
||||||
|
|
||||||
|
const secLevelX = sectionLevel.x;
|
||||||
|
const secLevelY = sectionLevel.y;
|
||||||
|
const secLevelZ = sectionLevel.z;
|
||||||
|
|
||||||
|
const secInvertX = sectionInvert.x;
|
||||||
|
const secInvertY = sectionInvert.y;
|
||||||
|
const secInvertZ = sectionInvert.z;
|
||||||
|
|
||||||
|
const activeModelFileName = model?.fileName;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roomId || !channelRef.current) return;
|
||||||
|
sendMeetingUpdate(fineTuning, sectionEnabled, sectionLevel, sectionInvert);
|
||||||
|
}, [
|
||||||
|
roomId,
|
||||||
|
rotX,
|
||||||
|
rotY,
|
||||||
|
rotZ,
|
||||||
|
scaleVal,
|
||||||
|
secEnabledX,
|
||||||
|
secEnabledY,
|
||||||
|
secEnabledZ,
|
||||||
|
secLevelX,
|
||||||
|
secLevelY,
|
||||||
|
secLevelZ,
|
||||||
|
secInvertX,
|
||||||
|
secInvertY,
|
||||||
|
secInvertZ,
|
||||||
|
activeModelFileName,
|
||||||
|
sendMeetingUpdate,
|
||||||
|
fineTuning,
|
||||||
|
sectionEnabled,
|
||||||
|
sectionLevel,
|
||||||
|
sectionInvert
|
||||||
|
]);
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -90,15 +255,39 @@ const Viewer = () => {
|
|||||||
}
|
}
|
||||||
}, [addModel, models.length, maxModels]);
|
}, [addModel, models.length, maxModels]);
|
||||||
|
|
||||||
if (!model) return null;
|
// Removido o bloqueio para permitir carregamento de modelo na sala
|
||||||
|
|
||||||
const handleEnterXR = async () => {
|
const [enteringXR, setEnteringXR] = useState(false);
|
||||||
try {
|
|
||||||
await xrStore.enterAR();
|
const handleEnterXR = useCallback(() => {
|
||||||
} catch (e) {
|
if (!xrSupported) {
|
||||||
console.error('[XR] Falha ao entrar no AR:', e);
|
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
|
||||||
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).");
|
return;
|
||||||
}
|
}
|
||||||
|
navigate("/xr");
|
||||||
|
}, [xrSupported, navigate]);
|
||||||
|
|
||||||
|
const handleEnterMeeting = () => {
|
||||||
|
// Coleta o eixo habilitado de seção no Viewer
|
||||||
|
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => sectionEnabled[ax]);
|
||||||
|
const isSectionActive = !!enabledAxis;
|
||||||
|
const sectionAxis = enabledAxis || 'y';
|
||||||
|
const sectionLevelVal = isSectionActive ? sectionLevel[sectionAxis] : 0.0;
|
||||||
|
const sectionInvertVal = isSectionActive ? sectionInvert[sectionAxis] : false;
|
||||||
|
|
||||||
|
// Salva o estado atual da peça
|
||||||
|
const initialMeetingState = {
|
||||||
|
rotation: [fineTuning.rotX, fineTuning.rotY, fineTuning.rotZ],
|
||||||
|
scale: fineTuning.scale,
|
||||||
|
sectionEnabled: isSectionActive,
|
||||||
|
sectionAxis,
|
||||||
|
sectionLevel: sectionLevelVal,
|
||||||
|
sectionInvert: sectionInvertVal
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('tsxr_initial_meeting_state', JSON.stringify(initialMeetingState));
|
||||||
|
toast.success('Configurações da maquete salvas. Direcionando para a reunião...');
|
||||||
|
navigate('/meeting');
|
||||||
};
|
};
|
||||||
|
|
||||||
const canAddMore = models.length < maxModels;
|
const canAddMore = models.length < maxModels;
|
||||||
@@ -110,44 +299,126 @@ const Viewer = () => {
|
|||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_EXTENSIONS}
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
title="Adicionar arquivo de modelo 3D"
|
title="Selecionar arquivo de maquete para upload"
|
||||||
aria-label="Adicionar arquivo de modelo 3D"
|
placeholder="Upload de maquete"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/")}>
|
<Button
|
||||||
<ArrowLeft className="h-5 w-5" />
|
variant="ghost"
|
||||||
|
size={roomId ? "default" : "icon"}
|
||||||
|
className={roomId ? "gap-1.5 px-3 text-xs font-mono h-9 hover:bg-slate-800" : ""}
|
||||||
|
onClick={() => roomId ? navigate(`/meeting/${roomId}`) : navigate("/")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{roomId && "Voltar para Sala"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Box className="h-5 w-5 text-primary" />
|
<Box className="h-5 w-5 text-primary" />
|
||||||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||||||
TrackSteel<span className="text-primary">XR</span>
|
Steel<span className="text-primary">XR</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-primary/30 hover:bg-primary/10 text-primary font-mono h-9"
|
||||||
|
onClick={handleEnterMeeting}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Reunião Virtual
|
||||||
|
</Button>
|
||||||
<ShareButton />
|
<ShareButton />
|
||||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
<Button className="gap-2 glow-primary h-9" disabled={!xrSupported || enteringXR} onClick={handleEnterXR}>
|
||||||
<Glasses className="h-4 w-4" />
|
{enteringXR ? <Loader2 className="h-4 w-4 animate-spin" /> : <Glasses className="h-4 w-4" />}
|
||||||
Iniciar AR
|
{enteringXR ? 'Iniciando AR…' : 'Entrar em Modo XR'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 3D Canvas with floating controls */}
|
{/* 3D Canvas with floating controls */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 min-w-0" id="canvas-container">
|
||||||
<ModelViewerCanvas />
|
<ModelViewerCanvas />
|
||||||
<ViewerControls />
|
{hoverIfcProps && (
|
||||||
|
<div
|
||||||
|
className={`absolute left-4 z-40 w-72 rounded-xl border border-primary/20 bg-background/80 p-3.5 shadow-2xl backdrop-blur-md transition-all duration-300 pointer-events-none select-none font-mono text-[11px] ${
|
||||||
|
isFullscreen ? 'top-16' : 'top-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-primary/10 pb-1.5">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||||
|
<span className="font-semibold text-primary uppercase text-[9px] tracking-wider font-sans">Propriedades IFC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(hoverIfcProps).map(([key, value]) => {
|
||||||
|
if (!value) return null;
|
||||||
|
let displayKey = key;
|
||||||
|
if (key === 'name') displayKey = 'Nome';
|
||||||
|
else if (key === 'tag') displayKey = 'Marca';
|
||||||
|
else if (key === 'material') displayKey = 'Material';
|
||||||
|
else if (key === 'objectType') displayKey = 'Tipo';
|
||||||
|
else if (key === 'description') displayKey = 'Descrição';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[8px] text-muted-foreground uppercase tracking-widest">{displayKey}</span>
|
||||||
|
<span className="text-foreground font-semibold truncate text-[11px] max-w-[250px]" title={value}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-background/80 border-primary/40 hover:bg-primary/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
|
||||||
|
onClick={handleRecenter}
|
||||||
|
>
|
||||||
|
<Target className="h-4 w-4 text-primary animate-pulse" />
|
||||||
|
Centralizar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 bg-background/80 border-destructive/40 hover:bg-destructive/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
|
||||||
|
onClick={exitFullscreen}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 text-destructive" />
|
||||||
|
Retornar (ESC)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Botão de Expandir Sidebar (visível apenas se recolhido) */}
|
||||||
|
{!sidebarOpen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="absolute right-4 top-[205px] z-30 h-8 w-8 rounded-md bg-background/80 border-primary/30 text-primary shadow-md hover:bg-primary/10"
|
||||||
|
title="Expandir menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<ViewCube />
|
<ViewCube />
|
||||||
|
<ViewerControls />
|
||||||
|
<SectionCutPanel />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
<aside className="w-80 shrink-0 border-l bg-card">
|
<aside className={`transition-all duration-300 shrink-0 border-l bg-card flex flex-col ${sidebarOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 pointer-events-none border-l-0 overflow-hidden'}`}>
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-4 space-y-5">
|
<div className="p-4 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -155,6 +426,15 @@ const Viewer = () => {
|
|||||||
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
Cena · {models.length}/{maxModels} peças
|
Cena · {models.length}/{maxModels} peças
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
title="Recolher menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<SceneModelList />
|
<SceneModelList />
|
||||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
@@ -179,11 +459,17 @@ const Viewer = () => {
|
|||||||
<h2 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<h2 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
Peça selecionada
|
Peça selecionada
|
||||||
</h2>
|
</h2>
|
||||||
|
{model ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
||||||
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
|
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
|
||||||
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
|
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted p-4 text-center bg-muted/20">
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">Nenhuma peça ativa na cena</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -215,7 +501,7 @@ const Viewer = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function InfoItem({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
function InfoItem({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function Watch() {
|
|||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<header className="border-b bg-card/50 backdrop-blur px-4 py-3 flex items-center justify-between">
|
<header className="border-b bg-card/50 backdrop-blur px-4 py-3 flex items-center justify-between">
|
||||||
<Link to="/" className="font-mono text-sm font-bold text-foreground">
|
<Link to="/" className="font-mono text-sm font-bold text-foreground">
|
||||||
TrackSteel<span className="text-primary">XR</span>
|
Steel<span className="text-primary">XR</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">SALA</span>
|
<span className="font-mono text-[10px] text-muted-foreground">SALA</span>
|
||||||
@@ -159,7 +159,7 @@ export default function Watch() {
|
|||||||
<footer className="border-t bg-card/50 backdrop-blur px-4 py-2 flex items-center justify-center gap-2">
|
<footer className="border-t bg-card/50 backdrop-blur px-4 py-2 flex items-center justify-center gap-2">
|
||||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
Modo somente visualização · TrackSteelXR
|
Modo somente visualização · SteelXR
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { worldToModelLocal } from '@/lib/modelTransforms';
|
import { getModelWorldScaleFactor, worldToModelLocal } from '@/lib/modelTransforms';
|
||||||
import { sendRemoteLog } from '@/lib/remoteLogger';
|
import { sendRemoteLog } from '@/lib/remoteLogger';
|
||||||
|
|
||||||
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
||||||
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
||||||
const XRFEAT_KEY = 'xrFeatures';
|
const XRFEAT_KEY = 'xrFeatures_v2';
|
||||||
|
const CAMERA_MODE_KEY = 'tsxr_camera_mode_v1';
|
||||||
|
|
||||||
|
function loadCameraMode(): 'ortho' | 'persp' {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CAMERA_MODE_KEY);
|
||||||
|
if (raw === 'persp' || raw === 'ortho') return raw;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 'ortho';
|
||||||
|
}
|
||||||
|
function saveCameraMode(m: 'ortho' | 'persp') {
|
||||||
|
try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export interface XRFeatureFlags {
|
export interface XRFeatureFlags {
|
||||||
handTracking: boolean;
|
handTracking: boolean;
|
||||||
@@ -18,35 +30,45 @@ export interface XRFeatureFlags {
|
|||||||
bodyTracking: boolean;
|
bodyTracking: boolean;
|
||||||
}
|
}
|
||||||
const DEFAULT_XR_FEATURES: XRFeatureFlags = {
|
const DEFAULT_XR_FEATURES: XRFeatureFlags = {
|
||||||
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
|
handTracking: true,
|
||||||
anchors: true, meshDetection: true, depthSensing: true, layers: true,
|
planeDetection: true,
|
||||||
bodyTracking: true,
|
hitTest: true,
|
||||||
|
domOverlay: true,
|
||||||
|
anchors: true,
|
||||||
|
meshDetection: false,
|
||||||
|
depthSensing: false,
|
||||||
|
layers: false,
|
||||||
|
bodyTracking: false,
|
||||||
};
|
};
|
||||||
function loadXRFeatures(): XRFeatureFlags {
|
function loadXRFeatures(): XRFeatureFlags {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(XRFEAT_KEY);
|
const raw = localStorage.getItem(XRFEAT_KEY);
|
||||||
if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) };
|
if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) };
|
||||||
} catch {}
|
} catch (e) { /* ignore */ }
|
||||||
return { ...DEFAULT_XR_FEATURES };
|
return { ...DEFAULT_XR_FEATURES };
|
||||||
}
|
}
|
||||||
function saveXRFeatures(f: XRFeatureFlags) {
|
function saveXRFeatures(f: XRFeatureFlags) {
|
||||||
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {}
|
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoredPlacement { fineTuning: FineTuning; }
|
interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; }
|
||||||
function loadPlacements(): Record<string, StoredPlacement> {
|
function loadPlacements(): Record<string, StoredPlacement> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PLACEMENT_KEY);
|
const raw = localStorage.getItem(PLACEMENT_KEY);
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return JSON.parse(raw);
|
||||||
} catch {}
|
} catch (e) { /* ignore */ }
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function savePlacement(fileName: string, fineTuning: FineTuning) {
|
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
|
||||||
try {
|
try {
|
||||||
const all = loadPlacements();
|
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));
|
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
|
||||||
} catch {}
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Visibility (selection-based hide/isolate) persistence ────────────
|
// ── Visibility (selection-based hide/isolate) persistence ────────────
|
||||||
@@ -56,7 +78,7 @@ function loadVisibilityMap(): Record<string, StoredVisibility> {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(VISIBILITY_KEY);
|
const raw = localStorage.getItem(VISIBILITY_KEY);
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return JSON.parse(raw);
|
||||||
} catch {}
|
} catch (e) { /* ignore */ }
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
|
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
|
||||||
@@ -64,7 +86,7 @@ function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<str
|
|||||||
const all = loadVisibilityMap();
|
const all = loadVisibilityMap();
|
||||||
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
|
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
|
||||||
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
|
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
|
||||||
} catch {}
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
|
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
|
||||||
const v = loadVisibilityMap()[fileName];
|
const v = loadVisibilityMap()[fileName];
|
||||||
@@ -72,6 +94,36 @@ function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<
|
|||||||
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
|
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IFC_MAT_COLORS_KEY = 'tsxr_ifc_mat_colors_v1';
|
||||||
|
const IFC_COLOR_MODE_KEY = 'tsxr_ifc_color_mode_v1';
|
||||||
|
export type IFCColorMode = 'none' | 'material' | 'description';
|
||||||
|
|
||||||
|
function loadIfcMaterialColors(): boolean {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(IFC_MAT_COLORS_KEY);
|
||||||
|
return raw !== 'false';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function saveIfcMaterialColors(enabled: boolean) {
|
||||||
|
try { localStorage.setItem(IFC_MAT_COLORS_KEY, String(enabled)); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadIfcColorMode(): IFCColorMode {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(IFC_COLOR_MODE_KEY);
|
||||||
|
if (raw === 'none' || raw === 'material' || raw === 'description') {
|
||||||
|
return raw as IFCColorMode;
|
||||||
|
}
|
||||||
|
const legacy = localStorage.getItem(IFC_MAT_COLORS_KEY);
|
||||||
|
if (legacy === 'false') return 'none';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 'material';
|
||||||
|
}
|
||||||
|
function saveIfcColorMode(mode: IFCColorMode) {
|
||||||
|
try { localStorage.setItem(IFC_COLOR_MODE_KEY, mode); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface ScaleRatio {
|
export interface ScaleRatio {
|
||||||
@@ -120,6 +172,8 @@ export interface SceneModel {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
fineTuning: FineTuning;
|
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;
|
const MAX_MODELS = 5;
|
||||||
@@ -127,12 +181,13 @@ const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899'];
|
|||||||
|
|
||||||
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||||
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
||||||
type RenderMode = 'solid' | 'edges';
|
type RenderMode = 'solid' | 'wireframe' | 'edges';
|
||||||
|
|
||||||
export interface MeasurePoint {
|
export interface MeasurePoint {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
|
/** Model hit by this point, used so saved dimensions attach to the right piece. */
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +209,9 @@ export interface HoverInfo {
|
|||||||
type: 'hole' | 'edge';
|
type: 'hole' | 'edge';
|
||||||
position: [number, number, number];
|
position: [number, number, number];
|
||||||
value: number; // mm (diameter or length)
|
value: number; // mm (diameter or length)
|
||||||
|
modelId?: string;
|
||||||
/** For edges: the two endpoints in world coords. */
|
/** For edges: the two endpoints in world coords. */
|
||||||
endpoints?: { a: MeasurePoint; b: MeasurePoint };
|
endpoints?: { a: MeasurePoint; b: MeasurePoint };
|
||||||
modelId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
@@ -190,6 +245,9 @@ interface ModelStore {
|
|||||||
clearAllModels: () => void;
|
clearAllModels: () => void;
|
||||||
maxModels: number;
|
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) ─────────────
|
// ── Legacy single-model accessors (proxy to active) ─────────────
|
||||||
model: ModelInfo | null;
|
model: ModelInfo | null;
|
||||||
setModel: (model: ModelInfo | null) => void;
|
setModel: (model: ModelInfo | null) => void;
|
||||||
@@ -220,6 +278,10 @@ interface ModelStore {
|
|||||||
renderMode: RenderMode;
|
renderMode: RenderMode;
|
||||||
setRenderMode: (mode: RenderMode) => void;
|
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;
|
showGrid: boolean;
|
||||||
setShowGrid: (show: boolean) => void;
|
setShowGrid: (show: boolean) => void;
|
||||||
|
|
||||||
@@ -231,6 +293,27 @@ interface ModelStore {
|
|||||||
setGridAutoFollow: (b: boolean) => void;
|
setGridAutoFollow: (b: boolean) => void;
|
||||||
/** Nudge grid by delta meters and disable auto-follow. */
|
/** Nudge grid by delta meters and disable auto-follow. */
|
||||||
nudgeGridY: (delta: number) => void;
|
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;
|
||||||
|
/** When true, the VR controller/headset hit-tests real world surfaces to place grid & models. */
|
||||||
|
gridLandingMode: boolean;
|
||||||
|
setGridLandingMode: (b: boolean) => void;
|
||||||
|
|
||||||
|
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
|
||||||
|
sectionEnabled: { x: boolean; y: boolean; z: boolean };
|
||||||
|
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;
|
wireframeColor: string;
|
||||||
setWireframeColor: (color: string) => void;
|
setWireframeColor: (color: string) => void;
|
||||||
@@ -251,6 +334,8 @@ interface ModelStore {
|
|||||||
setMeasureMode: (on: boolean) => void;
|
setMeasureMode: (on: boolean) => void;
|
||||||
positionMode: boolean;
|
positionMode: boolean;
|
||||||
setPositionMode: (on: boolean) => void;
|
setPositionMode: (on: boolean) => void;
|
||||||
|
walkMode: boolean;
|
||||||
|
setWalkMode: (on: boolean) => void;
|
||||||
|
|
||||||
// ── Selection (hide/isolate/export elements) ────────────
|
// ── Selection (hide/isolate/export elements) ────────────
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
@@ -290,6 +375,9 @@ interface ModelStore {
|
|||||||
hoverInfo: HoverInfo | null;
|
hoverInfo: HoverInfo | null;
|
||||||
setHoverInfo: (info: HoverInfo | null) => void;
|
setHoverInfo: (info: HoverInfo | null) => void;
|
||||||
|
|
||||||
|
hoverIfcProps: Record<string, string> | null;
|
||||||
|
setHoverIfcProps: (props: Record<string, string> | null) => void;
|
||||||
|
|
||||||
screenshots: string[];
|
screenshots: string[];
|
||||||
addScreenshot: (dataUrl: string) => void;
|
addScreenshot: (dataUrl: string) => void;
|
||||||
removeScreenshot: (index: number) => void;
|
removeScreenshot: (index: number) => void;
|
||||||
@@ -300,19 +388,13 @@ interface ModelStore {
|
|||||||
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
|
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
|
||||||
resetXRFeatures: () => void;
|
resetXRFeatures: () => void;
|
||||||
|
|
||||||
// ── WebXR HUD states persistent cache ────
|
ifcMaterialColors: boolean;
|
||||||
xrHudOpen: boolean;
|
setIfcMaterialColors: (enabled: boolean) => void;
|
||||||
setXrHudOpen: (open: boolean) => void;
|
ifcColorMode: IFCColorMode;
|
||||||
xrHudTab: string;
|
setIfcColorMode: (mode: IFCColorMode) => void;
|
||||||
setXrHudTab: (tab: string) => void;
|
|
||||||
|
|
||||||
// ── WebXR Cenários Imersivos e Escalonamento ────
|
xrRig: THREE.Group | null;
|
||||||
xrEnvironment: 'passthrough' | 'office' | 'field';
|
setXRRig: (rig: THREE.Group | null) => void;
|
||||||
setXrEnvironment: (env: 'passthrough' | 'office' | 'field') => void;
|
|
||||||
xrScaleMode: 'tabletop' | 'realscale';
|
|
||||||
setXrScaleMode: (mode: 'tabletop' | 'realscale') => void;
|
|
||||||
desktopCameraMode: 'orbit' | 'walk';
|
|
||||||
setDesktopCameraMode: (mode: 'orbit' | 'walk') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
|
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
|
||||||
@@ -325,6 +407,9 @@ function syncActive(state: Pick<ModelStore, 'models' | 'activeModelId'>): { mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useModelStore = create<ModelStore>((set, get) => ({
|
export const useModelStore = create<ModelStore>((set, get) => ({
|
||||||
|
xrRig: null,
|
||||||
|
setXRRig: (rig) => set({ xrRig: rig }),
|
||||||
|
|
||||||
// ── Multi-model ─────────────
|
// ── Multi-model ─────────────
|
||||||
models: [],
|
models: [],
|
||||||
activeModelId: null,
|
activeModelId: null,
|
||||||
@@ -343,6 +428,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const fineTuning: FineTuning = saved?.fineTuning
|
const fineTuning: FineTuning = saved?.fineTuning
|
||||||
? { ...saved.fineTuning }
|
? { ...saved.fineTuning }
|
||||||
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
|
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
|
||||||
|
const calibrationQuat = saved?.calibrationQuat ?? null;
|
||||||
const newModel: SceneModel = {
|
const newModel: SceneModel = {
|
||||||
id,
|
id,
|
||||||
fileName: m.fileName,
|
fileName: m.fileName,
|
||||||
@@ -352,6 +438,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
visible: true,
|
visible: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
fineTuning,
|
fineTuning,
|
||||||
|
calibrationQuat,
|
||||||
};
|
};
|
||||||
const models = [...state.models, newModel];
|
const models = [...state.models, newModel];
|
||||||
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
|
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
|
||||||
@@ -412,6 +499,15 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
|
|
||||||
clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }),
|
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 ─────────────
|
// ── Legacy single-model proxies ─────────────
|
||||||
model: null,
|
model: null,
|
||||||
setModel: (m) => {
|
setModel: (m) => {
|
||||||
@@ -425,14 +521,18 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
|
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
|
||||||
color: MODEL_COLORS[0], visible: true, locked: false,
|
color: MODEL_COLORS[0], visible: true, locked: false,
|
||||||
fineTuning: { ...defaultFineTuning },
|
fineTuning: { ...defaultFineTuning },
|
||||||
|
calibrationQuat: null,
|
||||||
};
|
};
|
||||||
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
|
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
fineTuning: { ...defaultFineTuning },
|
fineTuning: { ...defaultFineTuning },
|
||||||
setFineTuning: (ft) => set((state) => {
|
setFineTuning: (ft) => set((state) => {
|
||||||
const newFT = { ...state.fineTuning, ...ft };
|
|
||||||
const activeId = state.activeModelId;
|
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
|
const models = activeId
|
||||||
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: newFT } : m)
|
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: newFT } : m)
|
||||||
: state.models;
|
: state.models;
|
||||||
@@ -442,8 +542,10 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
return { fineTuning: newFT, models };
|
return { fineTuning: newFT, models };
|
||||||
}),
|
}),
|
||||||
resetFineTuning: () => set((state) => {
|
resetFineTuning: () => set((state) => {
|
||||||
const reset = { ...defaultFineTuning };
|
|
||||||
const activeId = state.activeModelId;
|
const activeId = state.activeModelId;
|
||||||
|
const activeBefore = state.models.find(m => m.id === activeId);
|
||||||
|
if (activeBefore?.locked) return {};
|
||||||
|
const reset = { ...defaultFineTuning };
|
||||||
const models = activeId
|
const models = activeId
|
||||||
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: reset } : m)
|
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: reset } : m)
|
||||||
: state.models;
|
: state.models;
|
||||||
@@ -462,11 +564,17 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
setXrSupported: (xrSupported) => set({ xrSupported }),
|
setXrSupported: (xrSupported) => set({ xrSupported }),
|
||||||
|
|
||||||
scaleRatio: SCALE_PRESETS.find(p => p.label === '1:1')!,
|
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,
|
scaleResetNonce: 0,
|
||||||
resetScale: () => set((state) => {
|
resetScale: () => set((state) => {
|
||||||
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
|
|
||||||
const activeId = state.activeModelId;
|
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
|
const models = activeId
|
||||||
? state.models.map(m => m.id === activeId
|
? state.models.map(m => m.id === activeId
|
||||||
? { ...m, fineTuning: { ...m.fineTuning, scale: 1 } }
|
? { ...m, fineTuning: { ...m.fineTuning, scale: 1 } }
|
||||||
@@ -489,6 +597,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
renderMode: 'solid',
|
renderMode: 'solid',
|
||||||
setRenderMode: (renderMode) => set({ renderMode }),
|
setRenderMode: (renderMode) => set({ renderMode }),
|
||||||
|
|
||||||
|
cameraMode: loadCameraMode(),
|
||||||
|
setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); },
|
||||||
|
|
||||||
showGrid: false,
|
showGrid: false,
|
||||||
setShowGrid: (showGrid) => set({ showGrid }),
|
setShowGrid: (showGrid) => set({ showGrid }),
|
||||||
|
|
||||||
@@ -497,11 +608,31 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
gridAutoFollow: true,
|
gridAutoFollow: true,
|
||||||
setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }),
|
setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }),
|
||||||
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
|
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
|
||||||
|
gridCalibMode: false,
|
||||||
|
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
|
||||||
|
gridLandingMode: false,
|
||||||
|
setGridLandingMode: (gridLandingMode) => set({ gridLandingMode }),
|
||||||
|
|
||||||
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 }),
|
setWireframeColor: (wireframeColor) => set({ wireframeColor }),
|
||||||
|
|
||||||
wireframeThickness: 1,
|
wireframeThickness: 2,
|
||||||
setWireframeThickness: (wireframeThickness) => set({ wireframeThickness }),
|
setWireframeThickness: (wireframeThickness) => set({ wireframeThickness }),
|
||||||
|
|
||||||
edgeThresholdAngle: 15,
|
edgeThresholdAngle: 15,
|
||||||
@@ -526,9 +657,14 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
||||||
|
|
||||||
measureMode: false,
|
measureMode: false,
|
||||||
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode })),
|
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode, walkMode: false })),
|
||||||
positionMode: false,
|
positionMode: false,
|
||||||
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode })),
|
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode, walkMode: false })),
|
||||||
|
walkMode: false,
|
||||||
|
setWalkMode: (walkMode) => {
|
||||||
|
set({ walkMode });
|
||||||
|
if (walkMode) set({ positionMode: false, measureMode: false, selectionMode: false, cameraMode: 'persp' });
|
||||||
|
},
|
||||||
|
|
||||||
// ── Selection state ─────────────────────────────────────
|
// ── Selection state ─────────────────────────────────────
|
||||||
selectionMode: false,
|
selectionMode: false,
|
||||||
@@ -574,27 +710,25 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const points = [...state.measurePoints, p];
|
const points = [...state.measurePoints, p];
|
||||||
if (points.length === 2) {
|
if (points.length === 2) {
|
||||||
const [a, b] = points;
|
const [a, b] = points;
|
||||||
const dx = (a.x - b.x) * 1000;
|
const modelId = (a.modelId && a.modelId === b.modelId ? a.modelId : state.activeModelId) ?? undefined;
|
||||||
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 aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
||||||
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
||||||
const attached = !!(aConv && bConv);
|
const attached = !!(aConv && bConv);
|
||||||
|
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`, {
|
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
|
||||||
distanceWorldMM,
|
|
||||||
distanceMM,
|
distanceMM,
|
||||||
factor,
|
|
||||||
modelId,
|
modelId,
|
||||||
attached
|
attached
|
||||||
});
|
});
|
||||||
const measurement: Measurement = {
|
const measurement: Measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: attached ? aConv! : a,
|
pointA: pa,
|
||||||
pointB: attached ? bConv! : b,
|
pointB: pb,
|
||||||
distanceMM,
|
distanceMM,
|
||||||
modelId: attached ? modelId : undefined,
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
@@ -619,6 +753,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
registerHoverMeasurement: (info) => set((state) => {
|
registerHoverMeasurement: (info) => set((state) => {
|
||||||
const modelId = info.modelId ?? state.activeModelId ?? undefined;
|
const modelId = info.modelId ?? state.activeModelId ?? undefined;
|
||||||
|
const modelScale = modelId ? getModelWorldScaleFactor(modelId) : 1;
|
||||||
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
|
const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => {
|
||||||
const conv = modelId ? worldToModelLocal(modelId, p) : null;
|
const conv = modelId ? worldToModelLocal(modelId, p) : null;
|
||||||
return conv ? { point: conv, attached: true } : { point: p, attached: false };
|
return conv ? { point: conv, attached: true } : { point: p, attached: false };
|
||||||
@@ -629,13 +764,14 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const cw: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
|
const cw: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
|
||||||
const c = toLocal(cw);
|
const c = toLocal(cw);
|
||||||
attached = c.attached;
|
attached = c.attached;
|
||||||
|
const valueMM = attached ? info.value / modelScale : info.value;
|
||||||
measurement = {
|
measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: c.point,
|
pointA: c.point,
|
||||||
pointB: c.point,
|
pointB: c.point,
|
||||||
distanceMM: info.value,
|
distanceMM: valueMM,
|
||||||
kind: 'hole',
|
kind: 'hole',
|
||||||
label: `Ø ${info.value.toFixed(1)}`,
|
label: `Ø ${valueMM.toFixed(1)}`,
|
||||||
modelId: attached ? modelId : undefined,
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -644,13 +780,19 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const a = toLocal(aw);
|
const a = toLocal(aw);
|
||||||
const b = toLocal(bw);
|
const b = toLocal(bw);
|
||||||
attached = a.attached && b.attached;
|
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 = {
|
measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: attached ? a.point : aw,
|
pointA: pa,
|
||||||
pointB: attached ? b.point : bw,
|
pointB: pb,
|
||||||
distanceMM: info.value,
|
distanceMM: valueMM,
|
||||||
kind: 'edge',
|
kind: 'edge',
|
||||||
label: `${info.value.toFixed(1)} mm`,
|
label: `${valueMM.toFixed(1)} mm`,
|
||||||
modelId: attached ? modelId : undefined,
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -669,6 +811,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
hoverInfo: null,
|
hoverInfo: null,
|
||||||
setHoverInfo: (hoverInfo) => set({ hoverInfo }),
|
setHoverInfo: (hoverInfo) => set({ hoverInfo }),
|
||||||
|
|
||||||
|
hoverIfcProps: null,
|
||||||
|
setHoverIfcProps: (hoverIfcProps) => set({ hoverIfcProps }),
|
||||||
|
|
||||||
screenshots: [],
|
screenshots: [],
|
||||||
addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })),
|
addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })),
|
||||||
removeScreenshot: (index) => set((state) => ({
|
removeScreenshot: (index) => set((state) => ({
|
||||||
@@ -687,16 +832,29 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
saveXRFeatures(next);
|
saveXRFeatures(next);
|
||||||
set({ xrFeatures: next });
|
set({ xrFeatures: next });
|
||||||
},
|
},
|
||||||
|
ifcMaterialColors: loadIfcMaterialColors(),
|
||||||
xrHudOpen: true,
|
setIfcMaterialColors: (enabled: boolean) => {
|
||||||
setXrHudOpen: (open) => set({ xrHudOpen: open }),
|
saveIfcMaterialColors(enabled);
|
||||||
xrHudTab: 'scene',
|
set((state) => {
|
||||||
setXrHudTab: (tab) => set({ xrHudTab: tab }),
|
const mode = enabled ? 'material' : 'none';
|
||||||
|
saveIfcColorMode(mode);
|
||||||
xrEnvironment: 'passthrough',
|
return { ifcMaterialColors: enabled, ifcColorMode: mode };
|
||||||
setXrEnvironment: (env) => set({ xrEnvironment: env }),
|
});
|
||||||
xrScaleMode: 'realscale',
|
},
|
||||||
setXrScaleMode: (mode) => set({ xrScaleMode: mode }),
|
ifcColorMode: loadIfcColorMode(),
|
||||||
desktopCameraMode: 'orbit',
|
setIfcColorMode: (mode: IFCColorMode) => {
|
||||||
setDesktopCameraMode: (mode) => set({ desktopCameraMode: mode }),
|
saveIfcColorMode(mode);
|
||||||
|
const enabled = mode === 'material';
|
||||||
|
saveIfcMaterialColors(enabled);
|
||||||
|
set({ ifcColorMode: mode, ifcMaterialColors: enabled });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Autodetecção global de suporte a WebXR
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.xr) {
|
||||||
|
navigator.xr.isSessionSupported('immersive-ar')
|
||||||
|
.then((supported) => useModelStore.getState().setXrSupported(supported))
|
||||||
|
.catch(() => useModelStore.getState().setXrSupported(false));
|
||||||
|
} else {
|
||||||
|
useModelStore.getState().setXrSupported(false);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ function loadXRFeatures() {
|
|||||||
hitTest: true,
|
hitTest: true,
|
||||||
domOverlay: true,
|
domOverlay: true,
|
||||||
anchors: true,
|
anchors: true,
|
||||||
meshDetection: true,
|
meshDetection: false,
|
||||||
depthSensing: true,
|
depthSensing: false,
|
||||||
layers: true,
|
layers: false,
|
||||||
bodyTracking: true,
|
bodyTracking: false,
|
||||||
lightEstimation: true,
|
lightEstimation: false,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('xrFeatures');
|
const raw = localStorage.getItem('xrFeatures_v2');
|
||||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
console.warn('[XR] Falha ao ler xrFeatures do localStorage:', err);
|
||||||
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# TrackSteelXR — Descritivo Publicitário para Landing Page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Título de Alto Impacto
|
||||||
|
|
||||||
|
**Inspeção de obras em tempo real com Realidade Aumentada: meça, valide e decida — sem sair do canteiro.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 Benefícios Principais
|
||||||
|
|
||||||
|
### 1. Medições Precisas em AR com Snap Inteligente
|
||||||
|
Vertex snap, detecção automática de arestas e furos em modelos IFC. O controlador XR identifica automaticamente vértices, arestas e furos — medições em milímetros reais sem trena ou cálculos manuais. Após 1 segundo de focalização, a medição é registrada automaticamente (dwell detection).
|
||||||
|
|
||||||
|
### 2. Inspeção por Checklist Digital com Gravação de Voz
|
||||||
|
Elimine planilhas de papel. Faça inspeções de qualidade diretamente no modelo 3D, com aprovação ou reprovação de cada item, anotações por texto e gravação de voz — tudo rastreável e auditável. Relatório PDF completo gerado em segundos.
|
||||||
|
|
||||||
|
### 3. Cortes de Seção Interativos para Análise Estrutural
|
||||||
|
Visualize o interior de estruturas metálicas com cortes dinâmicos nos eixos X, Y e Z — sem desmontar nada. Ideal para validar projetos complexos antes da montagem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Análise Resumida de Todos os Recursos
|
||||||
|
|
||||||
|
### Visualização 3D e Realidade Aumentada
|
||||||
|
|
||||||
|
- Renderização de modelos IFC nativamente via web-ifc (WASM)
|
||||||
|
- Suporte a Meta Quest com controle por gatilho (trigger), botões A/B e snap toggle
|
||||||
|
- Laser visual colorido que indica tipo de snap (verde=aresta, âmbar=furo, azul=vértice)
|
||||||
|
- Modo Perspective e Orthographic comutável sem perda de enquadramento
|
||||||
|
- Walk mode em primeira pessoa com teclado (WASD + Q/E para elevação)
|
||||||
|
- Grid infinito auto-follow que se ajusta à posição Y da peça
|
||||||
|
|
||||||
|
### Sistema de Medição
|
||||||
|
|
||||||
|
- Snap inteligente a vértices, arestas e centros de furos circulares
|
||||||
|
- Detecção automática de furos por circle-fit em geometria e por face
|
||||||
|
- Hover label mostra diâmetro de furo ou comprimento de aresta em tempo real
|
||||||
|
- Lock-on a alvos para medição contínua com interpolação suave
|
||||||
|
- Dwell detection: 1 segundo de focalização registra medição automaticamente
|
||||||
|
- Suporte a Undo/redo com teclas Z e Escape
|
||||||
|
|
||||||
|
### Inspeção e Checklist
|
||||||
|
|
||||||
|
- Checklist por item com status: Pendente, Aprovado, Reprovado
|
||||||
|
- Anotações em texto livre por item
|
||||||
|
- Gravação de voz por item (MediaRecorder API com áudio WebM)
|
||||||
|
- Barra de progresso visual e contadores por status
|
||||||
|
- Reset completo do checklist
|
||||||
|
|
||||||
|
### Cortes de Seção
|
||||||
|
|
||||||
|
- Cortes independentes nos eixos X, Y e Z
|
||||||
|
- Slider de posicionamento com range dinâmico (baseado nos limites reais da peça)
|
||||||
|
- Inversão de direção do corte por eixo
|
||||||
|
- Botões de atalho: Min, Centro, Max
|
||||||
|
- Opacidade configurável da janela de corte
|
||||||
|
|
||||||
|
### Calibração e Posicionamento
|
||||||
|
|
||||||
|
- Calibração em 3 pontos para alinhamento com referência real
|
||||||
|
- Ajuste fino de posição (X, Y, Z) e rotação (X, Y, Z) por modelo
|
||||||
|
- Suporte a múltiplos modelos simultâneos com controles independentes
|
||||||
|
- Exportação de seleção para GLB
|
||||||
|
|
||||||
|
### Relatórios e Compartilhamento
|
||||||
|
|
||||||
|
- Relatório PDF completo com: info do modelo, ajustes finos, checklist, medições e screenshots
|
||||||
|
- Screenshots da cena 3D com anotações
|
||||||
|
- Compartilhamento via WebRTC (broadcast/source com signaling)
|
||||||
|
- Logging remoto para diagnóstico
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diferenciais do Mercado Concorrente
|
||||||
|
|
||||||
|
| Concorrentes Tradicionais | TrackSteelXR |
|
||||||
|
|---------------------------|--------------|
|
||||||
|
| Software de escritório (AutoCAD, Revit) | Visualização **no canteiro** em AR |
|
||||||
|
| Trena e planilha manual | **Snap inteligente** que identifica geometria automaticamente |
|
||||||
|
| Relatório em papel ou Excel | **PDF automático** com evidências visuais |
|
||||||
|
| Medição por estimativa | **Milímetros reais** com calibração |
|
||||||
|
| Acesso restrito a profissionais | **XR controller** — mãos livres, mobilidade total |
|
||||||
|
| Sem suporte a IFC | **web-ifc WASM** nativo para IFC direto |
|
||||||
|
| Checklists em papel | **Checklist digital** com voz, texto e status |
|
||||||
|
| Sem cortes dinâmicos | **Cortes interativos** X/Y/Z em tempo real |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inovações Tecnológicas
|
||||||
|
|
||||||
|
1. **Pipeline IFC → WebGL nativo**: Parsing de arquivos IFC (IFC2x3, IFC4) via web-ifc (WASM), conversão em Three.js sem servidor — roda 100% no browser.
|
||||||
|
|
||||||
|
2. **Smart Snap 3D com dwell detection**: O sistema detecta automaticamente vértices, arestas e furos circulares por raycasting + geometria. Após 1 segundo de focalização, a medição é registrada automaticamente — zero cliques desnecessários.
|
||||||
|
|
||||||
|
3. **XR Controller com laser visual colorido**: Meta Quest integrado com laser que muda de cor conforme o tipo de snap (verde=aresta, âmbar=furo, azul=vértice), snap toggle por gatilho esquerdo, medição por gatilho direito, undo/clear por botões A/B.
|
||||||
|
|
||||||
|
4. **Section cuts em tempo real com clipping planes**: Utiliza THREE.Plane clipping para cortes per-model, com range dinâmico calculado a partir dos bounds reais da peça.
|
||||||
|
|
||||||
|
5. **Relatório PDF com evidências visuais**: jsPDF gera documento completo com screenshots da cena 3D, checklist com status coloridos e tabela de medições — em uma única ação.
|
||||||
|
|
||||||
|
6. **WebRTC broadcast para colaboração remota**: Sinalização + broadcast/source para compartilhar a visão AR com outros participantes em tempo real.
|
||||||
|
|
||||||
|
7. **Calibração por 3 pontos com quaternions**: Sistema de calibração que captura 3 pares de normais (world + local) e calcula o quaternion de alinhamento para posicionar o modelo no espaço real.
|
||||||
|
|
||||||
|
8. **Walk mode com física de colisão simples**: Navegação em primeira pessoa com detecção de limites da peça para orientação no espaço 3D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benefícios Incontestáveis
|
||||||
|
|
||||||
|
- **Elimina retrabalho**: Validação dimensional no canteiro antes da montagem — erros corrigidos na hora, não no escritório.
|
||||||
|
- **Precisão em milímetros**: Snap inteligente a geometria real do modelo IFC — não há estimativas, há dados.
|
||||||
|
- **Rastreabilidade total**: Cada item do checklist tem status, nota, áudio e timestamp. Relatório PDF serve como documento de entrega ao cliente.
|
||||||
|
- **Mobilidade sem precedentes**: Meta Quest + controllers = profissionais com as mãos livres para manusear a peça enquanto inspecionam.
|
||||||
|
- **Zero dependência de servidor**: IFC roda no browser via WASM. Sem backend, sem instalação — um link funciona.
|
||||||
|
- **Decisões em segundos**: Dwell detection registra medições automaticamente. O operador olha, focaliza 1 segundo, e o dado está salvo.
|
||||||
|
- **Validação visual antes da compra**: Cliente vê o relatório com screenshots e sabe exatamente o que está comprando.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parágrafo Final — A Dor da Gestão
|
||||||
|
|
||||||
|
Atrasos por erros de medição, retrabalho por falhas de comunicação e perdas por não conformidade são o pesadelo de qualquer gestão de produção metalmecânica ou obra civil. O **TrackSteelXR** coloca o modelo 3D IFC no canteiro — em Realidade Aumentada — para que engenheiros e gestores tomem decisões precisas no momento certo. **Menos retrabalho, mais produtividade, entregas dentro do prazo.** E um relatório PDF com evidências visuais que transforma inspeção em documento de entrega.
|
||||||
|
|
||||||
|
**Seu próximo projeto já pode começar com a certeza de que cada peça foi validada — não por planilha, mas por realidade.**
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": [
|
"types": [
|
||||||
"vitest/globals"
|
"vitest/globals"
|
||||||
],
|
],
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023"],
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"lib": ["ES2022"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,14 @@ else
|
|||||||
echo -e "${YELLOW}ℹ️ Diretório não é um repositório Git. Pulando sincronização Git.${NC}"
|
echo -e "${YELLOW}ℹ️ Diretório não é um repositório Git. Pulando sincronização Git.${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}🏁 Ciclo concluído com sucesso. O webhook do Coolify iniciará o deploy na VPS.${NC}\n"
|
# 2. Acionar deploy diretamente no Coolify
|
||||||
|
echo -e "${YELLOW}⚙️ Acionando deploy direto no Coolify...${NC}"
|
||||||
|
DEPLOY_RESULT=$(docker exec coolify php artisan tinker --execute="print_r(queue_application_deployment(application: App\Models\Application::find(38), deployment_uuid: new Visus\Cuid2\Cuid2, force_rebuild: false));" 2>&1)
|
||||||
|
|
||||||
|
if echo "$DEPLOY_RESULT" | grep -q "queued"; then
|
||||||
|
echo -e "${GREEN}✅ Deploy enfileirado com sucesso no Coolify!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Falha ao enfileirar deploy no Coolify. Detalhes: $DEPLOY_RESULT${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}🏁 Ciclo concluído com sucesso.${NC}\n"
|
||||||
|
|||||||