Compare commits

...

219 Commits

Author SHA1 Message Date
admtracksteel 7bd45e473d 🚀 Auto-deploy: melhoria no snap e medição AR em 11/06/2026 14:55:19 2026-06-11 14:55:19 +00:00
admtracksteel fc10c77346 🚀 Auto-deploy: melhoria no snap e medição AR em 11/06/2026 14:07:02 2026-06-11 14:07:02 +00:00
admtracksteel c62101d17b 🚀 Auto-deploy: melhoria no snap e medição AR em 10/06/2026 23:57:45 2026-06-10 23:57:45 +00:00
admtracksteel 9a3b59baf2 🚀 Auto-deploy: melhoria no snap e medição AR em 10/06/2026 20:53:22 2026-06-10 20:53:22 +00:00
admtracksteel 426bf13241 🚀 Auto-deploy: melhoria no snap e medição AR em 03/06/2026 10:31:54 2026-06-03 10:31:54 +00:00
admtracksteel 7f5b4427d7 🚀 Auto-deploy: melhoria no snap e medição AR em 03/06/2026 10:25:47 2026-06-03 10:25:47 +00:00
admtracksteel 97900b5ff7 🚀 Auto-deploy: melhoria no snap e medição AR em 03/06/2026 10:19:21 2026-06-03 10:19:21 +00:00
admtracksteel 6e80b23687 🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:42:49 2026-06-02 21:42:49 +00:00
admtracksteel 002682e6f3 🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:35:17 2026-06-02 21:35:17 +00:00
admtracksteel 230e373f8a 🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:33:41 2026-06-02 21:33:41 +00:00
admtracksteel 10f00e921d 🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:32:29 2026-06-02 21:32:29 +00:00
admtracksteel 11547f7bc7 🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:25:43 2026-06-02 21:25:43 +00:00
admtracksteel 0126d8d78c 🚀 Auto-deploy: melhoria no snap e medição AR em 01/06/2026 01:00:37 2026-06-01 01:00:37 +00:00
admtracksteel b47fb872b5 🚀 Auto-deploy: melhoria no snap e medição AR em 01/06/2026 00:41:59 2026-06-01 00:41:59 +00:00
admtracksteel ba33180a00 🚀 Auto-deploy: melhoria no snap e medição AR em 01/06/2026 00:12:12 2026-06-01 00:12:12 +00:00
admtracksteel 66e30b2609 🚀 Auto-deploy: melhoria no snap e medição AR em 01/06/2026 00:04:21 2026-06-01 00:04:21 +00:00
admtracksteel dc64c83cc8 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 23:55:14 2026-05-31 23:55:14 +00:00
admtracksteel a2ac72cfe7 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 23:46:46 2026-05-31 23:46:46 +00:00
admtracksteel 126bc6f80d 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 23:34:10 2026-05-31 23:34:10 +00:00
admtracksteel 8154951fa5 🔧 Fix: modo AR travando em loading — timeout de segurança, feedback visual no botão e auto-return retardado
- Viewer: loading state no botão 'Entrar em Modo XR' com Loader2 e texto 'Iniciando AR...'
- Viewer: timeout de 15s que limpa o loading e mostra toast de erro se não entrar
- Viewer: marca __setXrEntering(true) antes de navegar para bloquear auto-return prematuro
- XRSession: delay do auto-return aumentado de 500ms para 2000ms
- XRSession: novo flag isEnteringAR que previne navegação prematura para /viewer
- XRSession: expõe __setXrEntering via window para Viewer setar o flag
- Versão: v1.10 → v1.11

[31/05/2026 17:33:29]
2026-05-31 17:33:29 +00:00
admtracksteel c798179560 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 01:24:49 2026-05-31 01:24:49 +00:00
admtracksteel 43ce36e7a6 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 01:18:12 2026-05-31 01:18:12 +00:00
admtracksteel 6372047b9d 🚀 Auto-deploy: melhoria no snap e medição AR em 31/05/2026 01:08:46 2026-05-31 01:08:46 +00:00
admtracksteel 37bac18534 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 14:52:07 2026-05-30 14:52:07 +00:00
admtracksteel 46ae916a4a 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 14:37:42 2026-05-30 14:37:42 +00:00
admtracksteel 7f2edbbb3c 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 14:03:35 2026-05-30 14:03:35 +00:00
admtracksteel 6954b9e30f 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 13:38:01 2026-05-30 13:38:01 +00:00
admtracksteel ba2531b440 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 13:30:39 2026-05-30 13:30:39 +00:00
admtracksteel a03f3e8ba8 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 12:32:57 2026-05-30 12:32:57 +00:00
admtracksteel e4a9c8623a 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 12:24:45 2026-05-30 12:24:45 +00:00
admtracksteel 7489b611b0 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 11:48:45 2026-05-30 11:48:45 +00:00
admtracksteel 0ba00648e8 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 11:26:28 2026-05-30 11:26:28 +00:00
admtracksteel 4dbfb7f277 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 11:19:42 2026-05-30 11:19:42 +00:00
admtracksteel 451951b4ca 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 11:11:42 2026-05-30 11:11:42 +00:00
admtracksteel 966418659d 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:57:29 2026-05-30 10:57:29 +00:00
admtracksteel c0c4ad6e11 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:56:12 2026-05-30 10:56:12 +00:00
admtracksteel 54a3f83375 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:42:44 2026-05-30 10:42:44 +00:00
admtracksteel b6912bd070 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:30:57 2026-05-30 10:30:57 +00:00
admtracksteel 5a08868b83 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:28:12 2026-05-30 10:28:12 +00:00
admtracksteel 7ff4ed3176 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:13:44 2026-05-30 10:13:44 +00:00
admtracksteel 153c91dfc9 🚀 Auto-deploy: melhoria no snap e medição AR em 30/05/2026 10:10:48 2026-05-30 10:10:48 +00:00
admtracksteel 8348262d45 🚀 Auto-deploy: melhoria no snap e medição AR em 29/05/2026 21:04:55 2026-05-29 21:04:55 +00:00
admtracksteel 540eb2f31f 🚀 Auto-deploy: melhoria no snap e medição AR em 29/05/2026 20:54:50 2026-05-29 20:54:50 +00:00
admtracksteel c820808184 🚀 Auto-deploy: melhoria no snap e medição AR em 29/05/2026 20:46:55 2026-05-29 20:46:55 +00:00
admtracksteel 0f017d0e4b 🚀 Auto-deploy: melhoria no snap e medição AR em 29/05/2026 20:32:41 2026-05-29 20:32:41 +00:00
admtracksteel 1e4bd78128 🚀 Auto-deploy: melhoria no snap e medição AR em 28/05/2026 21:57:52 2026-05-28 21:57:52 +00:00
admtracksteel d47fb9dd37 🚀 Auto-deploy: melhoria no snap e medição AR em 28/05/2026 21:40:54 2026-05-28 21:40:54 +00:00
admtracksteel b270ca0bfd 🚀 Auto-deploy: melhoria no snap e medição AR em 28/05/2026 21:29:49 2026-05-28 21:29:49 +00:00
admtracksteel f36fd63bee 🚀 Auto-deploy: melhoria no snap e medição AR em 28/05/2026 21:17:57 2026-05-28 21:17:57 +00:00
admtracksteel 9b7f237afc 🚀 Auto-deploy: melhoria no snap e medição AR em 28/05/2026 21:16:09 2026-05-28 21:16:09 +00:00
admtracksteel aff19146f0 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 18:53:07 2026-05-25 18:53:07 +00:00
admtracksteel c924531f82 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 18:01:14 2026-05-25 18:01:14 +00:00
admtracksteel 30d5d7429f 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 17:59:04 2026-05-25 17:59:04 +00:00
admtracksteel 54d93c85d8 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 17:15:39 2026-05-25 17:15:39 +00:00
admtracksteel a08ab50917 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 16:51:02 2026-05-25 16:51:02 +00:00
admtracksteel 03411d9224 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 16:24:11 2026-05-25 16:24:11 +00:00
admtracksteel 2e70d2e133 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 14:40:21 2026-05-25 14:40:21 +00:00
admtracksteel 54e44dd737 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 12:44:07 2026-05-25 12:44:07 +00:00
admtracksteel 70cdfac679 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 12:35:52 2026-05-25 12:35:52 +00:00
admtracksteel 24417cb8a7 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 12:34:30 2026-05-25 12:34:30 +00:00
admtracksteel e8f4e2b18a 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 12:29:47 2026-05-25 12:29:47 +00:00
admtracksteel f876e868d6 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:54:52 2026-05-25 11:54:52 +00:00
admtracksteel 6590cc17c2 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:49:06 2026-05-25 11:49:06 +00:00
admtracksteel 35881620b1 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:46:40 2026-05-25 11:46:40 +00:00
admtracksteel b949a7c227 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:35:38 2026-05-25 11:35:38 +00:00
admtracksteel 00854f0255 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:17:05 2026-05-25 11:17:05 +00:00
admtracksteel 35ce901b74 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:15:46 2026-05-25 11:15:46 +00:00
admtracksteel ac41cd99a0 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 11:09:55 2026-05-25 11:09:55 +00:00
admtracksteel 0baed4a871 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 01:28:33 2026-05-25 01:28:33 +00:00
admtracksteel 61591dc84e 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 01:16:07 2026-05-25 01:16:07 +00:00
admtracksteel cf4a317439 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 01:15:05 2026-05-25 01:15:05 +00:00
admtracksteel 408eba84e0 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 01:10:53 2026-05-25 01:10:53 +00:00
admtracksteel c16beadb32 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:56:37 2026-05-25 00:56:37 +00:00
admtracksteel 7dff9bbeff 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:50:27 2026-05-25 00:50:27 +00:00
admtracksteel fffdd020f3 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:48:57 2026-05-25 00:48:57 +00:00
admtracksteel 887e172526 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:47:27 2026-05-25 00:47:27 +00:00
admtracksteel 6ef7c3ae8f 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:41:06 2026-05-25 00:41:06 +00:00
admtracksteel 883f022883 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:40:00 2026-05-25 00:40:00 +00:00
admtracksteel 2e7402691a 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:34:30 2026-05-25 00:34:30 +00:00
admtracksteel 3c903c1ff1 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:21:23 2026-05-25 00:21:23 +00:00
admtracksteel e390fd0f59 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:19:23 2026-05-25 00:19:23 +00:00
admtracksteel f38bf58bd2 🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:07:59 2026-05-25 00:07:59 +00:00
admtracksteel de8558b81c 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 23:58:44 2026-05-24 23:58:44 +00:00
admtracksteel c953db0b26 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 23:31:10 2026-05-24 23:31:10 +00:00
gpt-engineer-app[bot] 45d09ab7a3 Adicionou cortes de seção
X-Lovable-Edit-ID: edt-e77adaba-a2c2-4436-843c-ec9c5c701de0
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:55:09 +00:00
gpt-engineer-app[bot] ce86a280ba Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:51 +00:00
gpt-engineer-app[bot] 976d6f42da Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:41 +00:00
gpt-engineer-app[bot] ac30db8241 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:33 +00:00
gpt-engineer-app[bot] bdd5a92c17 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:15 +00:00
gpt-engineer-app[bot] e032f1f3ad Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:54:07 +00:00
gpt-engineer-app[bot] 1222d284e3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:59 +00:00
gpt-engineer-app[bot] 7208e2bc56 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:23 +00:00
gpt-engineer-app[bot] 7efac5448b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:09 +00:00
gpt-engineer-app[bot] eb34692fdd Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:53:02 +00:00
gpt-engineer-app[bot] 2754260962 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:52 +00:00
gpt-engineer-app[bot] 93c693f139 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:40 +00:00
gpt-engineer-app[bot] 649d4ecdca Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 22:52:30 +00:00
gpt-engineer-app[bot] bc55e5edd7 Adicionou Calibrar Grid
X-Lovable-Edit-ID: edt-eb99423f-57d2-4db3-95a2-c0c5ad524efa
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:36 +00:00
gpt-engineer-app[bot] 5790df1541 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:23 +00:00
gpt-engineer-app[bot] 083c9d83ae Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:12 +00:00
gpt-engineer-app[bot] c3d0f6c01d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:51:01 +00:00
gpt-engineer-app[bot] d02afa1797 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:47 +00:00
gpt-engineer-app[bot] a769d51d9f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:18 +00:00
gpt-engineer-app[bot] 15f2bdfbb2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:50:03 +00:00
gpt-engineer-app[bot] 06f0b68756 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:56 +00:00
gpt-engineer-app[bot] 69b9a6b97e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:48 +00:00
gpt-engineer-app[bot] 515cb97fbd Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:49:35 +00:00
gpt-engineer-app[bot] 0150bad2a8 Ajustou grid à face inferior
X-Lovable-Edit-ID: edt-9adbb18a-f106-4dcf-a001-1173b3d4655d
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:45:58 +00:00
gpt-engineer-app[bot] 6887a24f0d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:45:50 +00:00
gpt-engineer-app[bot] 15734e3713 Corrigiu snap e label do modelo
X-Lovable-Edit-ID: edt-b48b9eb0-fdf4-48af-bbd5-4fbbc7d06e5d
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:38 +00:00
gpt-engineer-app[bot] 92e91f288f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:26 +00:00
gpt-engineer-app[bot] e11fd411eb Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:40:20 +00:00
gpt-engineer-app[bot] 123229c66b Sincronizou cubo ao quaternion
X-Lovable-Edit-ID: edt-89f942ba-4eaa-4ce5-aae9-570b432a9c90
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:25 +00:00
gpt-engineer-app[bot] 885ea304bf Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:11 +00:00
gpt-engineer-app[bot] e444a3a20f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:31:04 +00:00
gpt-engineer-app[bot] d110dcb32c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:30:42 +00:00
gpt-engineer-app[bot] 5d5a637816 Added camera switcher to Canvas
X-Lovable-Edit-ID: edt-20061032-6ed9-4916-9fa5-f4792809dfa9
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:40 +00:00
gpt-engineer-app[bot] ff7f69191d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:24 +00:00
gpt-engineer-app[bot] ba87af34f4 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:12 +00:00
gpt-engineer-app[bot] 8ffb67689f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:26:00 +00:00
gpt-engineer-app[bot] cbeb186a60 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:46 +00:00
gpt-engineer-app[bot] 0fc3783eb9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:35 +00:00
gpt-engineer-app[bot] be95b150b2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:27 +00:00
gpt-engineer-app[bot] c285736ff2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:16 +00:00
gpt-engineer-app[bot] 2317df9aac Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:25:02 +00:00
gpt-engineer-app[bot] bb50821ec5 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:52 +00:00
gpt-engineer-app[bot] 075ad7aeb8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:38 +00:00
gpt-engineer-app[bot] 7ff09b918b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:31 +00:00
gpt-engineer-app[bot] f3c958b78c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:23 +00:00
gpt-engineer-app[bot] 8c4483ecf6 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:24:17 +00:00
gpt-engineer-app[bot] 3bf90dafd7 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:23:29 +00:00
gpt-engineer-app[bot] 60af63393a Moved hooks before early returns
X-Lovable-Edit-ID: edt-c703c880-46fb-435e-95fc-c26b2f4ac503
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:11:43 +00:00
gpt-engineer-app[bot] 704b878691 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:11:36 +00:00
gpt-engineer-app[bot] ade8119590 Ajustou cubo para 70%
X-Lovable-Edit-ID: edt-aa853db0-95f5-4dd2-83b8-186372652765
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:41 +00:00
gpt-engineer-app[bot] 4b2baa3fb4 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:25 +00:00
gpt-engineer-app[bot] 4942030721 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:08:10 +00:00
gpt-engineer-app[bot] eee5b80d34 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:58 +00:00
gpt-engineer-app[bot] 9b450d2c35 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:18 +00:00
gpt-engineer-app[bot] b3ddfb2939 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:07:01 +00:00
gpt-engineer-app[bot] ad318ea52e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:52 +00:00
gpt-engineer-app[bot] 5e7ff33a3e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:43 +00:00
gpt-engineer-app[bot] ce642ff505 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:06:21 +00:00
gpt-engineer-app[bot] ca12fffbf6 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:50 +00:00
gpt-engineer-app[bot] d6f684fc3c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:39 +00:00
gpt-engineer-app[bot] 49fb9cff15 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:31 +00:00
gpt-engineer-app[bot] 936e631b96 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:20 +00:00
gpt-engineer-app[bot] 27c12d34f9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:10 +00:00
gpt-engineer-app[bot] 37828d75f2 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 20:05:00 +00:00
gpt-engineer-app[bot] 6757100f92 Adicionou ViewCube em Viewer
X-Lovable-Edit-ID: edt-9f7240de-a34f-415d-9dd8-de7b20a1f746
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:23 +00:00
gpt-engineer-app[bot] a19fcdf792 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:12 +00:00
gpt-engineer-app[bot] f1ed0248d0 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:45:00 +00:00
gpt-engineer-app[bot] faec7d3e4a Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:45 +00:00
gpt-engineer-app[bot] ef2199b972 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:27 +00:00
gpt-engineer-app[bot] 8b70b16fde Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:20 +00:00
gpt-engineer-app[bot] 7d8520fd32 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:44:13 +00:00
gpt-engineer-app[bot] 634baa4e1f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:46 +00:00
gpt-engineer-app[bot] 5f9f5b5bc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:37 +00:00
gpt-engineer-app[bot] 29c6c765d3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:43:23 +00:00
gpt-engineer-app[bot] f4adaf88f9 Adicionou bloqueio via cadeado
X-Lovable-Edit-ID: edt-0a8ebb1f-ee9d-4adb-a6db-b4d3150b9bbe
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:34:24 +00:00
gpt-engineer-app[bot] cbfeee3078 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:34:13 +00:00
gpt-engineer-app[bot] 3b42d44ff7 Atualizou visual de bordas
X-Lovable-Edit-ID: edt-f5acd6ab-7778-44a9-9aae-716f987b2f62
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:57 +00:00
gpt-engineer-app[bot] 000d4d05e8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:44 +00:00
gpt-engineer-app[bot] e5e1eac733 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:28:29 +00:00
gpt-engineer-app[bot] 4cf157b7cd Adicionou botão Sair AR
X-Lovable-Edit-ID: edt-f602c6ac-5eb0-4c32-9ea0-9d1b4f559559
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:22:31 +00:00
gpt-engineer-app[bot] dda94bd107 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:22:23 +00:00
gpt-engineer-app[bot] 5bea0ff9ee Ajustou cota e estabilizou snap
X-Lovable-Edit-ID: edt-c6d88e59-2709-4850-9df5-a42a0e7fce71
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:50 +00:00
gpt-engineer-app[bot] 7918f5f0b1 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:31 +00:00
gpt-engineer-app[bot] 1d788cdca3 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:21 +00:00
gpt-engineer-app[bot] 7203127a1d Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:17:03 +00:00
gpt-engineer-app[bot] f603823c91 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:16:14 +00:00
gpt-engineer-app[bot] 718da6ecc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:16:07 +00:00
gpt-engineer-app[bot] e2f2eb41db Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:15:36 +00:00
gpt-engineer-app[bot] 52ef40f8fb Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:15:14 +00:00
gpt-engineer-app[bot] 9970571fad Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:14:51 +00:00
gpt-engineer-app[bot] d58d506af8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:13:44 +00:00
gpt-engineer-app[bot] aeecd4d7ec Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-24 19:12:18 +00:00
gpt-engineer-app[bot] 79b95ce3c8 Reverted to commit c93e56640d 2026-05-24 18:56:17 +00:00
admtracksteel 13c1ad315d 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:37:52 2026-05-24 18:37:52 +00:00
admtracksteel 2bce52fe54 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:28:15 2026-05-24 18:28:15 +00:00
admtracksteel ee6318859e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 18:16:19 2026-05-24 18:16:19 +00:00
admtracksteel f01c0f6d01 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 17:12:43 2026-05-24 17:12:43 +00:00
admtracksteel 06556c7a45 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 16:24:21 2026-05-24 16:24:21 +00:00
admtracksteel 00a8cee511 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 16:22:27 2026-05-24 16:22:27 +00:00
admtracksteel febb533f5e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 14:10:16 2026-05-24 14:10:17 +00:00
admtracksteel 678d04cc95 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:56:57 2026-05-24 13:56:57 +00:00
admtracksteel 84b8a452a1 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:56:02 2026-05-24 13:56:02 +00:00
admtracksteel fd9082510f 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:09:17 2026-05-24 13:09:17 +00:00
admtracksteel b5a92083f9 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:04:12 2026-05-24 13:04:12 +00:00
admtracksteel b16158656a 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 13:01:22 2026-05-24 13:01:22 +00:00
admtracksteel 377346893e 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 12:38:19 2026-05-24 12:38:19 +00:00
admtracksteel 6046d655df 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:10:48 2026-05-24 02:10:48 +00:00
admtracksteel c8d51e0e1b 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 02:00:33 2026-05-24 02:00:33 +00:00
admtracksteel c839653a97 🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 01:33:36 2026-05-24 01:33:36 +00:00
admtracksteel b7cd422068 🚀 Auto-deploy: melhoria no snap e medição AR em 23/05/2026 23:26:57 2026-05-23 23:26:57 +00:00
admtracksteel c93e56640d 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:38:44 2026-05-22 21:38:44 +00:00
admtracksteel c85dc85936 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:20:32 2026-05-22 21:20:32 +00:00
admtracksteel 63ad53edfb 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 21:07:04 2026-05-22 21:07:04 +00:00
admtracksteel 8610ec67f7 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:56:13 2026-05-22 20:56:13 +00:00
admtracksteel d8ca2b2e6c 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:51:44 2026-05-22 20:51:44 +00:00
admtracksteel d64d77dba6 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:41:05 2026-05-22 20:41:05 +00:00
admtracksteel 4c43d4c289 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:30:38 2026-05-22 20:30:38 +00:00
admtracksteel 5f24e5c85b 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 20:17:13 2026-05-22 20:17:13 +00:00
admtracksteel 69b001f316 🚀 Auto-deploy: melhoria no snap e medição AR em 22/05/2026 14:53:05 2026-05-22 14:53:05 +00:00
gpt-engineer-app[bot] bd9d0028f1 Melhorou snap e botões AR
X-Lovable-Edit-ID: edt-fe7c72e3-ce24-4acd-be0e-85f2e7f6eca3
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:40:00 +00:00
gpt-engineer-app[bot] 45a7bdbd1b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:49 +00:00
gpt-engineer-app[bot] 6c022c514f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:39 +00:00
gpt-engineer-app[bot] 33b32fb268 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:01 +00:00
gpt-engineer-app[bot] de11c4e119 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:42 +00:00
gpt-engineer-app[bot] da9399305e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:25 +00:00
gpt-engineer-app[bot] 073a3f8dc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:18 +00:00
gpt-engineer-app[bot] 71b1e9ae3f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:11 +00:00
gpt-engineer-app[bot] 360daac843 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:05 +00:00
gpt-engineer-app[bot] 639854c765 Corrigiu botões e throttle AR
X-Lovable-Edit-ID: edt-087c5038-cfa1-4ba9-93fe-3ad396e4c693
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:26:14 +00:00
gpt-engineer-app[bot] def95857ac Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:59 +00:00
gpt-engineer-app[bot] 0cce9f94b8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:45 +00:00
gpt-engineer-app[bot] a8cfe0d3b9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:35 +00:00
gpt-engineer-app[bot] 085184ba41 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:21 +00:00
gpt-engineer-app[bot] 16c1e7b755 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:04 +00:00
gpt-engineer-app[bot] 22e3ede63c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:24:35 +00:00
66 changed files with 8378 additions and 808 deletions
-81
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+5 -3
View File
@@ -4,9 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: Set the document title to the name of your application -->
<title>TrackSteelXR</title>
<title>SteelXR</title>
<meta name="description" content="VR tool inspection industrial Q.C.">
<meta name="author" content="Lovable" />
<link rel="icon" type="image/png" href="/iconeXR_transparente.png" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<!-- TODO: Update og:title to match your application name -->
@@ -17,8 +19,8 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" />
<meta name="twitter:image" content="https://storage.googleapis.com/gpt-engineer-file-uploads/attachments/og-images/a850c4e4-56e7-4358-91d7-b33ddcd600ac?Expires=1772161962&amp;GoogleAccessId=go-api-on-aws%40gpt-engineer-390607.iam.gserviceaccount.com&amp;Signature=Q%2B%2F2EsHIygjuVQKvjQWWytqElhVjmehhRIdWJ1eYRremdUzoUIv36NuXp042g0lBzszDBa14EbkCTYykUEMpqrclTioA2035aKEY%2BR39jqYm7oBKXCm2rs2vopnW2ahZYf8SjX5SPqxwFzaO%2FvJt71rMU0A6PzihBNRa96ezpIhxGqzpbZe9zP4PtyRhg03aCp5sJytJp9bBOphzXb9YGeY58XWWqjAqEO1tctOGh25hZsYLgEU61wy3xnehDc8kZ%2FK0MpRpxuGibae5ypjDtGbXf9G5LjSWYecVY5pWOjnO6ZSo1CL8DaWtD%2FU3V0MszZ8f4SETk7fQAh0GR4b8Sg%3D%3D">
<meta property="og:title" content="TrackSteelXR">
<meta name="twitter:title" content="TrackSteelXR">
<meta property="og:title" content="SteelXR">
<meta name="twitter:title" content="SteelXR">
<meta property="og:description" content="VR tool inspection industrial Q.C.">
<meta name="twitter:description" content="VR tool inspection industrial Q.C.">
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

+668
View File
@@ -0,0 +1,668 @@
# TrackSteelXR — Análise de Melhorias
**Data da análise**: 2026-05-15
**Analisado por**: Hermes (VPS BrainSteel)
**Escopo**: `/host_root/root/Apps/tracksteelxr/` (98 arquivos, ~7647 linhas TS/TSX)
---
## 1. DIAGNÓSTICO GERAL
### Stack Tecnológica
- **Frontend**: React 18 + TypeScript 5 + Vite
- **3D Engine**: Three.js 0.160 + @react-three/fiber + @react-three/drei
- **UI**: shadcn-ui + Radix UI + Tailwind CSS
- **State**: Zustand 4.5
- **PDF**: jsPDF 4.2
- **Format support**: GLB, OBJ, STL, IFC via web-ifc
- **Gerenciador**: Bun (bun.lockb)
### Qualidade Geral
- ✅ Projeto bem estruturado com separação de concerns
- ✅ Boas práticas de tipagem TypeScript
- ✅ Componentes bem modularizados (UI, 3D, stores, hooks)
- ✅ Algoritmos de SmartMeasure interesting (circle fit, vertex snap)
- ⚠️ Performance comprometida por loops desnecessários
- ⚠️ Memory leaks em event handlers
- ⚠️ Dependências desatualizadas (CVE risks)
- ⚠️ Validação de input ausente
---
## 2. CRÍTICAS URGENTES (🔴 Alta Prioridade)
### 2.1 Performance: Loop Infinito Anula `frameloop="demand"`
**Arquivo**: `src/components/three/ModelViewer.tsx`
**Linha**: ~485 (no `onCreated` do Canvas)
**Problema**:
```tsx
<Canvas
frameloop="demand" // deveria renderizar só quando algo muda
onCreated={({ gl, invalidate }) => {
const loop = () => { invalidate(); requestAnimationFrame(loop); }; // ❌ RENDERIZA SEMPRE
loop();
}}
>
```
O `frameloop="demand"` faz o Canvas renderizar **apenas quando `invalidate()` é chamado** (normalmente por mudanças de state). Mas o `onCreated` cria um `requestAnimationFrame` loop que chama `invalidate()` a cada frame, forçando renderização contínua — igual ao `frameloop="always"`.
**Impacto**: Em modelos grandes (>500k triângulos), isso causa 60%+ de uso de CPU desnecessário. Dispositivos móveis根本无法正常运行。
**Correção**:
```tsx
<Canvas
frameloop="demand" // manter assim
onCreated={({ gl }) => {
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
// NÃO criar loop aqui
}}
>
```
Ou, se precisa de animação constante (não é o caso aqui), usar `frameloop="always"` e remover o loop manual.
---
### 2.2 Memory Leak em SmartSnapHandler e HoverDetector
**Arquivo**: `src/components/three/ModelViewer.tsx`
**Linhas**: ~200-350
**Problema**:
Event listeners adicionados com `gl.domElement.addEventListener` não são removidos adequadamente quando componentes desmontam ou quando callbacks mudam (muda `onMove` referência). Cada re-render do componente pode adicionar novos listeners sem remover os antigos.
```tsx
gl.domElement.addEventListener('pointermove', onMove);
// ...
return () => {
gl.domElement.removeEventListener('pointermove', onMove);
// ❌ hoverTimer.current também vaza se callback mudar
};
```
**Impacto**: Após extended use, centenas de listeners acumulados no DOM. Memory leak progressivo até tab crash.
**Correção**:
```tsx
useEffect(() => {
if (!measureMode) return () => {};
const onMove = (e: MouseEvent) => { /* ... */ };
const onLeave = () => { /* ... */ };
gl.domElement.addEventListener('pointermove', onMove);
gl.domElement.addEventListener('pointerleave', onLeave);
return () => {
gl.domElement.removeEventListener('pointermove', onMove);
gl.domElement.removeEventListener('pointerleave', onLeave);
hoverTimer.current && clearTimeout(hoverTimer.current);
};
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]); // incluir todas deps
```
---
### 2.3 SmartMeasure.ts — Algoritmo O(n) em Vertex Search
**Arquivo**: `src/lib/SmartMeasure.ts`
**Função**: `findNearestVertex()`
**Problema**:
Para cada mousemove, itera **todos** os vértices do mesh:
```ts
for (let i = 0; i < posAttr.count; i++) {
_v.fromBufferAttribute(posAttr, i);
_v.applyMatrix4(mesh.matrixWorld);
_projected.copy(_v).project(camera);
// cálculo de distância
}
```
Modelo STL médio: 100k-500k vértices. Isso é 500k cálculos por mousemove, a 60fps = 30 milhões de operações/segundo.
**Impacto**: Lag severo em modelos grandes, especialmente em dispositivos com GPU limitada.
**Correção** (usar Octree spatial indexing):
```ts
import { Octree } from 'three/examples/jsm/math/Octree.js';
import { OctreeHelper } from 'three/examples/jsm/objects/OctreeHelper.js';
// Pré-calcular octree uma vez quando modelo carrega
const octree = useMemo(() => {
const tree = new Octree();
tree.fromGeometry(mesh.geometry, 8); // 8 = subdivisions
return tree;
}, [mesh.geometry]);
// Buscar vértices no octree ao invés de iterar todos
const nearbyVertices = octree.search(worldPoint, searchRadius, true);
```
Alternativa mais simples: reduzir search radius ou usar grid spatial hash.
---
### 2.4 Dependências Desatualizadas — Risco de CVE
**Arquivo**: `package.json`
| Pacote | Atual | Latest | Status |
|--------|-------|--------|--------|
| `three` | ^0.160.1 | 0.170+ | ⚠️ Vulnerabilidades conhecidas |
| `@react-three/drei` | ^9.122.0 | 9.134+ | ⚠️ Atualizar |
| `vite` | ^5.4.19 | 6.x disponível | 🔴 CVE em versões 5.x < 5.4.11 |
| `jspdf` | ^4.2.0 | 4.2.0 (atual) | ✅ Mas monitorar |
| `react-router-dom` | ^6.30.1 | 7.x beta | ⚠️ Breaking changes em 7 |
**Riscos específicos**:
- **Vite 5.x**: Múltiplos CVEs de 2024 (CVE-2024-23331, CVE-2024-29041, etc.) permitiriam path traversal e XSS.
- **jsPDF**: Histórico de vulnerabilidades de deserialização. Sempre manter atualizado.
**Correção**:
```bash
# Atualizar com cuidado (alguns são breaking changes)
npm update three @react-three/drei vite react-router-dom
# Ou verificar manualmente antes de atualizar
npm audit
npm audit fix
```
---
## 3. REFATORAÇÃO (🟡 Média Prioridade)
### 3.1 Zustand Store Acoplado — 30+ states misturados
**Arquivo**: `src/stores/useModelStore.ts`
**Problema**: Um único store com responsibilities demais:
- UI state: `opacity`, `renderMode`, `anchorMode`, `showGrid`, `wireframeColor`
- 3D state: `measurePoints`, `measurements`, `snapPoint`, `hoverInfo`
- Business state: `checklist`, `inspectionResult`
- File state: `model`, `compareImage`
- Meta state: `screenshots`, `xrSupported`
Cada mudança de qualquer state causa re-render em **todos** os componentes que usam `useModelStore`.
**Correção**: Separar em stores modulares:
```ts
// stores/uiStore.ts — UI-only (opacity, renderMode, showGrid, etc.)
const useUIStore = create<UIStore>((set) => ({
opacity: 1,
setOpacity: (opacity) => set({ opacity }),
// ...
}));
// stores/measurementStore.ts — 3D/measurement state
const useMeasurementStore = create<MeasurementStore>((set) => ({
measurePoints: [],
addMeasurePoint: (p) => set((state) => /* ... */),
// ...
}));
// stores/inspectionStore.ts — checklist/inspection
const useInspectionStore = create<InspectionStore>((set) => ({
checklist: defaultChecklist,
setChecklistItemStatus: (id, status) => set((state) => /* ... */),
// ...
}));
// stores/uploadStore.ts — model upload handling
const useUploadStore = create<UploadStore>((set) => ({
model: null,
setModel: (model) => set({ model }),
// ...
}));
```
**Benefício**: Components só re-renderizam quando o state específico muda. 50-70% menos re-renders.
---
### 3.2 GLBModel.useEffect — 700+ linhas de lógica acoplada
**Arquivo**: `src/components/three/ModelViewer.tsx` (função `GLBModel`)
**Problema**: Um único `useEffect` de ~150 linhas que faz:
1. Material cloning em todos os meshes
2. Cleanup de edge lines anteriores
3. Lógica de cor (rejected/approved/default)
4. Criação de edge lines novas
5. Atualização de opacity/wireframe
**Correção**: Extrair para hooks customizados:
```ts
// hooks/useMaterialManager.ts
export function useMaterialManager(scene: THREE.Group, renderMode: string) {
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
useEffect(() => {
// material cloning logic
}, [scene, renderMode]);
return { originalColors };
}
// hooks/useEdgeRenderer.ts
export function useEdgeRenderer(
mesh: THREE.Mesh,
enabled: boolean,
thresholdAngle: number
) {
useEffect(() => {
if (!enabled) return;
// create edge lines
return () => { /* cleanup */ };
}, [mesh, enabled, thresholdAngle]);
}
// hooks/useModelColors.ts
export function useModelColors(
scene: THREE.Group,
checklist: ChecklistItem[],
renderMode: string
) {
// color logic based on checklist status
}
```
---
### 3.3 convertToGLB.ts — Material Hardcoded
**Arquivo**: `src/lib/convertToGLB.ts`
**Problema**:
```ts
const material = new THREE.MeshStandardMaterial({
color: 0x8899aa,
metalness: 0.85,
roughness: 0.35,
});
```
Material fixo para todos os modelos convertidos. Usuário não pode customizar cor/textura.
**Correção**:
```ts
interface ConvertOptions {
color?: number;
metalness?: number;
roughness?: number;
}
export async function convertToGLB(
buffer: ArrayBuffer,
ext: 'obj' | 'stl',
fileName: string,
options: ConvertOptions = {}
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
const defaultMaterial = {
color: 0x8899aa,
metalness: 0.85,
roughness: 0.35,
...options,
};
const material = new THREE.MeshStandardMaterial(defaultMaterial);
// ...
}
```
---
### 3.4 generateReport.ts — Tratamento de Erro Inadequado
**Arquivo**: `src/lib/generateReport.ts`
**Problema**:
```ts
try {
pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH);
} catch {
pdf.text(`[Erro ao inserir screenshot ${i + 1}]`, margin, y);
}
```
Catch genérico oculta o erro real. Falha silenciosamente sem logging.
**Correção**:
```ts
try {
pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH);
} catch (err) {
console.error(`[generateReport] Failed to add screenshot ${i + 1}:`, err);
// Fallback: tentar converter ou redimensionar
try {
const imgData = resizeImage(data.screenshots[i], 1920);
pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH);
} catch (fallbackErr) {
pdf.text(`[Screenshot ${i + 1} não disponível]`, margin, y);
}
}
```
---
### 3.5 Validação de Upload Ausente
**Arquivo**: `src/lib/convertToGLB.ts`
**Problema**:
```ts
export function getSupportedExtension(fileName: string): SupportedExt | null {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'glb' || ext === 'obj' || ext === 'stl' || ext === 'ifc') return ext;
return null;
}
```
Só checa extensão, não conteúdo. Arquivo `.glb` com conteúdo corrompido/crasha o loader.
**Correção**:
```ts
export async function validateModelFile(file: File): Promise<ValidationResult> {
// 1. Check extension
const ext = file.name.split('.').pop()?.toLowerCase();
if (!['glb', 'obj', 'stl', 'ifc'].includes(ext)) {
return { valid: false, error: 'Formato não suportado' };
}
// 2. Check MIME type (if browser provides)
const validMimes = {
glb: ['model/gltf-binary', 'application/octet-stream'],
obj: ['model/obj', 'text/plain', 'application/octet-stream'],
stl: ['model/stl', 'application/sla', 'application/octet-stream'],
ifc: ['application/x-step', 'application/octet-stream'],
};
// 3. Check file size (max 500MB for IFC, 100MB for others)
const maxSizes = { ifc: 500 * 1024 * 1024, default: 100 * 1024 * 1024 };
const maxSize = maxSizes[ext] || maxSizes.default;
if (file.size > maxSize) {
return { valid: false, error: `Arquivo muito grande (max ${maxSize / 1024 / 1024}MB)` };
}
// 4. Try to parse header (for GLB: first 4 bytes = 0x46546C67 = 'glTF')
if (ext === 'glb') {
const header = await file.slice(0, 20).arrayBuffer();
const view = new DataView(header);
const magic = view.getUint32(0, true);
if (magic !== 0x46546C67) {
return { valid: false, error: 'Arquivo GLB inválido (magic bytes incorretos)' };
}
}
return { valid: true };
}
```
---
## 4. OTIMIZAÇÕES / BOAS PRÁTICAS (🟢 Baixa Prioridade)
### 4.1 `preserveDrawingBuffer: true` Gasto de Memória
**Arquivo**: `src/components/three/ModelViewer.tsx`
```tsx
gl={{
antialias: true,
alpha: true,
powerPreference: 'high-performance',
preserveDrawingBuffer: true // ⚠️ CUSTO EXTRA
}}
```
`preserveDrawingBuffer: true` mantém o framebuffer em memória após cada render, permitindo `toDataURL()` para screenshots. Mas isso duplica o consumo de memória GPU.
**Solução**: Ativar só quando usuário vai fazer screenshot:
```tsx
// No state
const [screenshots, addScreenshot] = useModelStore(s => [s.screenshots, s.addScreenshot]);
// Mudar Canvas config baseado em necessidade
const glConfig = useMemo(() => ({
antialias: true,
alpha: true,
powerPreference: 'high-performance' as const,
preserveDrawingBuffer: screenshots.length > 0, // só ativa se há screenshots
}), [screenshots.length]);
```
---
### 4.2 pixelRatio Limitado a 1.5
```tsx
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
```
Limitar em 1.5 é bom para performance, mas em dispositivos Apple Retina (2x, 3x) poderia ir a 2 sem impacto perceptível na qualidade vs custo de render.
**Sugestão**:
```tsx
const maxPixelRatio = /Mac|iPhone|iPad/.test(navigator.platform) ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxPixelRatio));
```
---
### 4.3 Test Coverage Ausente
O projeto tem configuração Vitest e ESLint mas:
- Apenas 2 arquivos de teste placeholder em `src/test/`
- Nenhum teste de unidade ou integração
- Cobertura: 0%
**Sugestão de testes prioritários**:
```ts
// src/lib/__tests__/convertToGLB.test.ts
describe('convertToGLB', () => {
it('should convert STL buffer to GLB', async () => { /* ... */ });
it('should convert OBJ buffer to GLB', async () => { /* ... */ });
it('should reject invalid file types', async () => { /* ... */ });
});
// src/stores/__tests__/useModelStore.test.ts
describe('useModelStore', () => {
it('should add measurement point after two clicks', () => { /* ... */ });
it('should clear measurements', () => { /* ... */ });
});
```
---
### 4.4 Fallback para WebXR Inexistente
**Arquivo**: `src/stores/useModelStore.ts`
```ts
xrSupported: boolean | null; // null = não checou ainda
```
Quando `xrSupported === false`, não há fallback ou mensagem ao usuário.
**Sugestão**:
```tsx
// No componente XRSession
const xrSupported = useModelStore(s => s.xrSupported);
if (xrSupported === false) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-muted-foreground">WebXR não disponível neste dispositivo.</p>
<Button variant="outline" onClick={() => setAnchorMode('manual')}>
Usar modo manual
</Button>
</div>
);
}
```
---
### 4.5 Inconsistência Bun/npm no package.json
```json
"scripts": {
"dev": "vite" // npm script
}
```
Mas o projeto usa `bun.lockb` como lockfile. README menciona npm mas o ambiente usa bun.
**Correção**: Padronizar para bun ou npm:
```bash
# Se usando bun (recomendado - mais rápido)
npm install -g bun
bun install
bun run dev
# OU remover bun.lockb e usar npm
rm bun.lockb
npm install
```
---
### 4.6 Screenshot Compression Ausente
```ts
screenshots: string[]; // data:image/png;base64,...
```
Screenshots em base64 sem compressão crescem indefinidamente na memória. Sessões longas com 10+ screenshots podem causar OOM em dispositivos móveis.
**Sugestão**:
```ts
async function compressScreenshot(dataUrl: string, quality = 0.8): Promise<string> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxDim = 1920;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = dataUrl;
});
}
```
---
### 4.7 Audio Validation Ausente
```ts
audioUrl: string | null;
```
Não valida se URL é `blob://` ou `file://` válido. Não limpa strings maliciosas.
**Sugestão**:
```ts
function isValidAudioUrl(url: string): boolean {
if (!url) return true; // null é válido
try {
const parsed = new URL(url);
return ['blob:', 'file:'].includes(parsed.protocol);
} catch {
return false;
}
}
function sanitizeAudioUrl(url: string | null): string | null {
if (!url) return null;
return isValidAudioUrl(url) ? url : null;
}
```
---
## 5. SEGURANÇA
### 5.1 Nenhuma Sanitização de Input de Arquivo
- Upload aceita qualquer extensão de arquivo
- Não há validação de MIME type real
- Não há scan de conteúdo
- Não há limite de tamanho de arquivo
**Recomendação**: Implementar validação conforme item 3.5.
---
### 5.2 Nenhuma Proteção Contra DoS por Memória
- Arquivos IFC podem ter gigabytes
- `web-ifc` carrega arquivo completo em memória
- Não há limite de tamanho de arquivo antes do parsing
**Recomendação**:
```ts
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
function validateIFCFile(file: File): boolean {
if (file.size > MAX_FILE_SIZE) {
throw new Error(`Arquivo IFC muito grande: ${file.size / 1024 / 1024}MB (max 500MB)`);
}
return true;
}
```
---
### 5.3 Sem CSP Headers ou XSS Protection
TrackSteelXR é uma React SPA sem headers de segurança configurados. web-ifc carrega arquivos potencialmente maliciosos.
**Recomendação**: Configurar CSP no servidor que servir a app (ex: Nginx, Caddy, Cloudflare).
Exemplo CSP mínimo:
```
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';
```
---
## 6. RESUMO PRIORITÁRIO
| Prioridade | Item | Impacto | Esforço |
|------------|------|--------|---------|
| 🔴 Alta | Remover loop em `onCreated` | 60%+ CPU | 5 min |
| 🔴 Alta | Fix memory leak listeners | Estabilidade | 15 min |
| 🔴 Alta | Update vite (CVE) | Segurança | 10 min |
| 🟡 Média | Octree para vertex search | Performance | 1-2h |
| 🟡 Média | Separate Zustand stores | Manutenibilidade | 2-3h |
| 🟡 Média | Validação de upload | Segurança | 1h |
| 🟡 Média | Fix generateReport error handling | Confiabilidade | 30 min |
| 🟢 Baixa | Test coverage | Confiabilidade | 4h+ |
| 🟢 Baixa | Screenshot compression | Performance | 1h |
| 🟢 Baixa | Configurar CSP headers | Segurança | 30 min |
---
## 7. PRÓXIMOS PASSOS RECOMENDADOS
1. **Imediato**: Corrigir loop infinito + memory leak (2 items, ~20 min)
2. **Esta semana**: Update vite + outras deps críticas
3. **Esta semana**: Adicionar validação de upload
4. **Próxima semana**: Octree para vertex search
5. **Próxima semana**: Separar Zustand stores
---
*Documento gerado automaticamente por Hermes — VPS BrainSteel*
*Última atualização: 2026-05-15*
+293 -134
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+65
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+4
View File
@@ -7,7 +7,9 @@ import Index from "./pages/Index";
import Viewer from "./pages/Viewer";
import XRSession from "./pages/XRSession";
import Watch from "./pages/Watch";
import MeetingRoom from "./pages/MeetingRoom";
import NotFound from "./pages/NotFound";
import "@/lib/remoteLogger";
const queryClient = new QueryClient();
@@ -22,6 +24,8 @@ const App = () => (
<Route path="/viewer" element={<Viewer />} />
<Route path="/xr" element={<XRSession />} />
<Route path="/watch/:code" element={<Watch />} />
<Route path="/meeting" element={<MeetingRoom />} />
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
+1 -1
View File
@@ -16,7 +16,7 @@ import { addRecentFile } from '@/lib/recentFiles';
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
const PRESETS: { label: string; url: string }[] = [
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
{ label: 'Demo IFC (SteelXR)', url: DEFAULT_URL },
];
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
+76
View File
@@ -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>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { X, RefreshCw, Scissors } from 'lucide-react';
import * as THREE from 'three';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { useModelStore } from '@/stores/useModelStore';
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
type Axis = 'x' | 'y' | 'z';
interface AxisRange { min: number; max: number; }
function computeBoundsByAxis(): Record<Axis, AxisRange> | null {
const groups = getAllModelLocalGroups();
if (groups.length === 0) return null;
const box = new THREE.Box3();
let has = false;
for (const g of groups) {
g.updateWorldMatrix(true, true);
const b = new THREE.Box3().setFromObject(g);
if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) {
if (!has) { box.copy(b); has = true; } else box.union(b);
}
}
if (!has) return null;
return {
x: { min: box.min.x, max: box.max.x },
y: { min: box.min.y, max: box.max.y },
z: { min: box.min.z, max: box.max.z },
};
}
const AXIS_COLOR: Record<Axis, string> = {
x: 'text-red-400',
y: 'text-emerald-400',
z: 'text-sky-400',
};
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
export function SectionCutPanel() {
const open = useModelStore((s) => s.sectionPanelOpen);
const opacity = useModelStore((s) => s.sectionPanelOpacity);
const setOpen = useModelStore((s) => s.setSectionPanelOpen);
const setOpacity = useModelStore((s) => s.setSectionPanelOpacity);
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const setSectionEnabled = useModelStore((s) => s.setSectionEnabled);
const setSectionInvert = useModelStore((s) => s.setSectionInvert);
const setSectionLevel = useModelStore((s) => s.setSectionLevel);
const activeModelId = useModelStore((s) => s.activeModelId);
const [bounds, setBounds] = useState<Record<Axis, AxisRange> | null>(null);
// Compute bounds when the panel opens or active model changes.
useEffect(() => {
if (!open) return;
// Defer one tick to let scene finish updating world matrices.
const id = setTimeout(() => setBounds(computeBoundsByAxis()), 50);
return () => clearTimeout(id);
}, [open, activeModelId]);
// Drag-to-move logic.
const [pos, setPos] = useState<{ x: number; y: number }>({ x: 16, y: 16 });
const dragRef = useRef<{ ox: number; oy: number; sx: number; sy: number } | null>(null);
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (!dragRef.current) return;
setPos({
x: Math.max(8, dragRef.current.sx + (e.clientX - dragRef.current.ox)),
y: Math.max(8, dragRef.current.sy + (e.clientY - dragRef.current.oy)),
});
};
const onUp = () => { dragRef.current = null; };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, []);
const recompute = () => setBounds(computeBoundsByAxis());
if (!open) return null;
return (
<div
className="absolute z-30 w-[320px] rounded-lg border bg-card shadow-2xl backdrop-blur-md"
style={{ right: pos.x, top: pos.y, opacity }}
>
{/* Header (drag handle) */}
<div
className="flex cursor-move items-center justify-between gap-2 border-b px-3 py-2 select-none"
onPointerDown={(e) => {
dragRef.current = { ox: e.clientX, oy: e.clientY, sx: pos.x, sy: pos.y };
}}
>
<div className="flex items-center gap-2">
<Scissors className="h-3.5 w-3.5 text-primary" />
<span className="font-mono text-xs font-semibold uppercase tracking-widest text-foreground">
Cortes de Seção
</span>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpen(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Window opacity */}
<div className="border-b px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Opacidade da janela
</span>
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
</div>
<Slider
value={[Math.round(opacity * 100)]}
min={20}
max={100}
step={5}
onValueChange={([v]) => setOpacity(v / 100)}
/>
</div>
{/* Per-axis controls */}
<div className="space-y-3 px-3 py-3">
{(['x', 'y', 'z'] as Axis[]).map((axis) => {
const range = bounds?.[axis];
const minMM = range ? range.min * 1000 : 0;
const maxMM = range ? range.max * 1000 : 100;
const valMM = level[axis] * 1000;
const disabled = !range;
return (
<div key={axis} className="rounded-md border bg-muted/30 p-2.5">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-bold ${AXIS_COLOR[axis]}`}>
{AXIS_LABEL[axis]}
</span>
<Switch
checked={enabled[axis]}
onCheckedChange={(c) => setSectionEnabled(axis, c)}
disabled={disabled}
/>
</div>
<label className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-muted-foreground">Inverter</span>
<Switch
checked={invert[axis]}
onCheckedChange={(c) => setSectionInvert(axis, c)}
disabled={disabled || !enabled[axis]}
/>
</label>
</div>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">
{disabled ? '— sem peça' : `${minMM.toFixed(0)}${maxMM.toFixed(0)} mm`}
</span>
<span className="font-mono text-[10px] text-foreground">
{disabled ? '—' : `${valMM.toFixed(1)} mm`}
</span>
</div>
<Slider
value={[range ? Math.min(maxMM, Math.max(minMM, valMM)) : 0]}
min={minMM}
max={maxMM}
step={Math.max(0.1, (maxMM - minMM) / 1000)}
onValueChange={([v]) => setSectionLevel(axis, v / 1000)}
disabled={disabled || !enabled[axis]}
/>
<div className="mt-2 grid grid-cols-3 gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.min)}
>
Min
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, (range.min + range.max) / 2)}
>
Centro
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.max)}
>
Max
</Button>
</div>
</div>
);
})}
</div>
<div className="border-t px-3 py-2">
<Button variant="outline" size="sm" className="w-full gap-2 h-8" onClick={recompute}>
<RefreshCw className="h-3.5 w-3.5" />
<span className="font-mono text-[11px]">Recalcular limites</span>
</Button>
</div>
</div>
);
}
+1 -1
View File
@@ -130,7 +130,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
if (navigator.share) {
try {
await navigator.share({
title: 'TrackSteelXR — Sessão ao vivo',
title: 'SteelXR — Sessão ao vivo',
text: 'Acompanhe a inspeção ao vivo:',
url: link,
});
+318
View File
@@ -0,0 +1,318 @@
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 { getModelLocalGroup } from '@/lib/modelTransforms';
import { Check, RotateCcw, Maximize2 } from 'lucide-react';
import { toast } from 'sonner';
/** Builds a square canvas texture with a label centered on it. */
function makeFaceTexture(label: string, accent: boolean): THREE.CanvasTexture {
const size = 256;
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const ctx = c.getContext('2d')!;
const grad = ctx.createLinearGradient(0, 0, 0, size);
grad.addColorStop(0, accent ? '#1a2742' : '#101725');
grad.addColorStop(1, accent ? '#0c1424' : '#070b14');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, size - 6, size - 6);
ctx.fillStyle = '#e6faff';
ctx.font = '700 56px JetBrains Mono, ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = '#00f3ff';
ctx.shadowBlur = 18;
ctx.fillText(label, size / 2, size / 2);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 4;
tex.needsUpdate = true;
return tex;
}
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] },
];
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;
};
useFrame(() => {
const main = mainCameraRef.current;
const controls = mainControlsRef.current;
if (!main) return;
const target: THREE.Vector3 = controls?.target ?? new THREE.Vector3();
const dir = new THREE.Vector3().subVectors(main.position, target).normalize();
localCam.position.copy(dir.multiplyScalar(3));
localCam.up.copy(main.up);
localCam.lookAt(0, 0, 0);
// Sync the cube group orientation with the active model so it always
// mirrors the piece's pose (rotation from Posicionar, calibration, etc).
if (groupRef.current) {
groupRef.current.quaternion.copy(getModelWorldQuat());
}
});
const onClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
const idx = e.face?.materialIndex;
if (idx == null) return;
const def = FACE_DEFS[idx];
if (!def) return;
// Convert the local cube-face direction to world space using the model's
// current world quaternion — this keeps cube ↔ model coherent after any
// rotation/translation/reset of the piece.
const dirWorld = new THREE.Vector3(...def.dir).applyQuaternion(getModelWorldQuat()).normalize();
if (calibrating) {
pushCubeFace(dirWorld);
} else {
requestView(dirWorld);
}
};
// 70% of previous 1.4 → ~0.98
const SIZE = 0.98;
return (
<group ref={groupRef}>
<mesh
ref={meshRef}
material={materials}
onClick={onClick}
onPointerMove={(e) => {
e.stopPropagation();
const idx = e.face?.materialIndex ?? null;
setHover(idx);
}}
onPointerOut={() => setHover(null)}
>
<boxGeometry args={[SIZE, SIZE, SIZE]} />
</mesh>
<AxisTripod />
</group>
);
}
function AxisTripod() {
return (
<group position={[-1.2, -1.2, 0]}>
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
</group>
);
}
const STEP_HINTS: Record<string, string> = {
'await-cube-1': '1/3 — clique numa face do cubo',
'await-model-1': '1/3 — clique na face correspondente da peça',
'await-cube-2': '2/3 — clique noutra face do cubo',
'await-model-2': '2/3 — clique na face correspondente da peça',
'await-cube-3': '3/3 (verificação) — clique numa terceira face do cubo (opcional)',
'await-model-3': '3/3 (verificação) — clique na face correspondente da peça',
'done': 'Calibração concluída',
};
export function ViewCube() {
const [, force] = useState(0);
useEffect(() => subscribeCalibration(() => force(t => t + 1)), []);
const activeModelId = useModelStore((s) => s.activeModelId);
const models = useModelStore((s) => s.models);
const setCalibrationStore = useModelStore((s) => s.setCalibration);
const active = models.find(m => m.id === activeModelId);
const isCalibrating = calibration.step !== 'idle' && calibration.step !== 'done';
const isDone = calibration.step === 'done';
const progress = isDone ? 1 : calibration.progress;
const onClickCalibrar = () => {
const store = useModelStore.getState();
if (isCalibrating) {
// Cancel mid-flow — restore previous camera mode if we forced ortho.
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
cancelCalibration();
toast.info('Calibração cancelada');
return;
}
if (isDone) {
const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined;
if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; }
cancelCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque o cadeado para calibrar');
return;
}
// Force orthographic view for accurate face picking.
(window as any).__tsxr_prevCamMode = store.cameraMode;
if (store.cameraMode !== 'ortho') store.setCameraMode('ortho');
startCalibration(active.id);
toast('Calibração em vista ortogonal — clique numa face do cubo, depois na face correspondente da peça', { duration: 4000 });
};
const onResetCalibration = () => {
if (!active) return;
setCalibrationStore(active.id, null);
toast.success('Calibração removida');
};
return (
<div
className="absolute right-3 top-3 z-20 w-[120px] flex flex-col items-stretch gap-1.5"
style={{ pointerEvents: 'auto' }}
>
<div
className="h-[120px] w-[120px] rounded-md border border-primary/30 bg-background/40 backdrop-blur-sm shadow-lg"
title="Cubo de vistas — clique numa face para girar"
>
<Canvas
camera={{ position: [2, 2, 2], fov: 35, near: 0.1, far: 50 }}
gl={{ antialias: true, alpha: true }}
dpr={[1, 2]}
>
<ambientLight intensity={1} />
<CubeMesh calibrating={isCalibrating} activeModelId={activeModelId} />
</Canvas>
</div>
{/* Calibrar button with progress fill */}
<button
type="button"
onClick={onClickCalibrar}
className={[
'relative h-7 w-full overflow-hidden rounded border font-mono text-[10px] uppercase tracking-widest transition-colors',
isDone
? 'border-success/60 bg-success/20 text-success'
: isCalibrating
? 'border-amber-400/60 bg-amber-400/10 text-amber-300'
: 'border-primary/40 bg-background/40 text-primary hover:bg-primary/10',
].join(' ')}
title={isCalibrating ? 'Cancelar calibração' : 'Calibrar orientação da peça com o cubo'}
>
{/* progress bar */}
<span
className={[
'absolute inset-y-0 left-0 transition-all duration-200',
isDone ? 'bg-success/40' : 'bg-primary/30',
].join(' ')}
style={{ width: `${Math.round(progress * 100)}%` }}
/>
<span className="relative z-10 flex items-center justify-center gap-1">
{isDone ? <Check className="h-3 w-3" /> : null}
{isDone ? 'Calibrado' : isCalibrating ? 'Cancelar' : 'Calibrar'}
</span>
</button>
{/* Step hint while calibrating */}
{(isCalibrating || isDone) && (
<div className="rounded border border-primary/30 bg-background/80 px-2 py-1 text-center font-mono text-[9px] leading-tight text-foreground/90 backdrop-blur-sm">
{STEP_HINTS[calibration.step] ?? ''}
{isDone && Number.isFinite(calibration.verifyErrorDeg) && (
<div className="mt-0.5 text-success">
erro {calibration.verifyErrorDeg.toFixed(1)}°
</div>
)}
</div>
)}
{/* 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"
>
<RotateCcw className="h-2.5 w-2.5" /> reset
</button>
)}
{/* Botão de Tela Cheia */}
<button
type="button"
onClick={() => {
const container = document.getElementById("canvas-container");
if (container) {
container.requestFullscreen().catch((err) => {
toast.error("Não foi possível entrar em tela cheia: " + err.message);
});
}
}}
className="flex h-7 w-full items-center justify-center gap-1 rounded border border-primary/40 bg-background/40 font-mono text-[10px] uppercase tracking-widest text-primary hover:bg-primary/10 transition-colors"
title="Entrar em modo tela cheia"
>
<Maximize2 className="h-3 w-3" /> Tela Cheia
</button>
</div>
);
}
+106 -12
View File
@@ -1,4 +1,4 @@
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react';
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors, Footprints } from 'lucide-react';
import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -11,13 +11,14 @@ import * as THREE from 'three';
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
const WIRE_COLORS = [
{ label: 'Cinza', value: '#8899aa' },
{ label: 'Ciano (radiante)', value: '#00f3ff' },
{ label: 'Vermelho', value: '#ef4444' },
{ label: 'Verde', value: '#22c55e' },
{ label: 'Azul', value: '#3b82f6' },
{ label: 'Amarelo', value: '#eab308' },
{ label: 'Laranja', value: '#f97316' },
{ label: 'Branco', value: '#e2e8f0' },
{ label: 'Cinza', value: '#8899aa' },
];
export function ViewerControls() {
@@ -30,11 +31,16 @@ export function ViewerControls() {
wireframeThickness, setWireframeThickness,
edgeThresholdAngle, setEdgeThresholdAngle,
positionMode, setPositionMode,
walkMode, setWalkMode,
activeModelId, models,
selectionMode, setSelectionMode,
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
cameraMode, setCameraMode,
gridCalibMode, setGridCalibMode, setGridAutoFollow,
sectionPanelOpen, setSectionPanelOpen, sectionEnabled, resetSection,
} = useModelStore();
const sectionActive = sectionEnabled.x || sectionEnabled.y || sectionEnabled.z;
const activeModel = models.find(m => m.id === activeModelId);
const hasSelection = selectedElementKeys.size > 0;
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
@@ -104,26 +110,95 @@ export function ViewerControls() {
<span className="font-mono text-xs">Grid</span>
</Button>
{/* Scale presets */}
<ScaleSelector />
{/* Grid calibration: click a face on the piece to anchor the grid there */}
<Button
variant={renderMode !== 'solid' ? 'default' : 'outline'}
variant={gridCalibMode ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
disabled={!activeModelId}
onClick={() => {
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
setRenderMode(next);
const next = !gridCalibMode;
setGridCalibMode(next);
if (next) {
setGridAutoFollow(false);
toast.info('Calibrar Grid: clique numa face da peça');
} else {
toast.dismiss();
}
}}
title="Posiciona o grid na altura de uma face da peça"
>
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
<LayoutGrid className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wireframe' : 'Bordas'}
{gridCalibMode ? 'Clique na face…' : 'Calibrar Grid'}
</span>
</Button>
{/* Wireframe/Edges settings popover */}
{renderMode !== 'solid' && (
{/* Section cuts (X/Y/Z clipping planes) */}
<div className="flex items-end gap-1.5">
<Button
variant={sectionPanelOpen || sectionActive ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setSectionPanelOpen(!sectionPanelOpen)}
disabled={!activeModelId}
title="Cortes de seção em X / Y / Z"
>
<Scissors className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
Cortes{sectionActive ? ' •' : ''}
</span>
</Button>
{sectionActive && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => { resetSection(); toast.success('Cortes resetados'); }}
title="Resetar todos os cortes"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
{/* Scale presets */}
<ScaleSelector />
<Button
variant={renderMode === 'edges' ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setRenderMode(renderMode === 'edges' ? 'solid' : 'edges')}
>
<Box className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{renderMode === 'edges' ? 'Bordas' : 'Sólido'}
</span>
</Button>
{/* Camera projection toggle (Ortho ⇄ Persp) */}
<Button
variant="outline"
size="sm"
className="gap-2 h-9"
onClick={() => setCameraMode(cameraMode === 'ortho' ? 'persp' : 'ortho')}
title={cameraMode === 'ortho'
? 'Vista ortogonal (CAD) — clique para perspectiva'
: 'Vista em perspectiva — clique para ortogonal'}
>
{cameraMode === 'ortho'
? <Square className="h-3.5 w-3.5" />
: <Box className="h-3.5 w-3.5" />}
<span className="font-mono text-xs">
{cameraMode === 'ortho' ? 'Ortogonal' : 'Perspectiva'}
</span>
</Button>
{/* Edges color/thickness/threshold popover */}
{renderMode === 'edges' && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 h-9">
@@ -194,6 +269,25 @@ export function ViewerControls() {
</span>
</Button>
{/* Walk mode */}
<Button
variant={walkMode ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => {
if (!walkMode) {
toast.info("Modo Caminhada: Use WASD para andar, Q/E para subir/descer e o mouse para olhar. ESC para sair.");
}
setWalkMode(!walkMode);
}}
title="Modo primeira pessoa (WASD mover, Q/E subir/descer)"
>
<Footprints className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
Caminhar
</span>
</Button>
{/* Measure tool */}
<div className="flex items-end gap-1.5">
<Button
+10 -22
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import {
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown,
ClipboardCheck, X, Crosshair, Magnet, HelpCircle,
MousePointerClick, EyeOff, Focus,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -310,16 +310,16 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
</Button>
)}
{/* Two-hand scale toggle */}
{/* Allow scale (two-hand zoom) toggle */}
{onToggleAllowScale && (
<Button
variant={allowScale ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleAllowScale}
title="Permite escalar a peça com gesto de duas mãos (afastando os controles)"
title="Zoom por grip duplo (afastar/aproximar as mãos)"
>
<Maximize2 className="h-3.5 w-3.5" />
{allowScale ? 'Escala ON' : 'Escala'}
<RefreshCw className="h-3.5 w-3.5" />
{allowScale ? 'Zoom ON' : 'Zoom'}
</Button>
)}
@@ -332,15 +332,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
{/* Share live screen */}
<ShareButton variant="compact" />
<Button
variant={freeMove ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleFreeMove}
>
<Grip className="h-3.5 w-3.5" />
{freeMove ? 'Mover ON' : 'Mover'}
</Button>
<Button
variant={showGrid ? 'default' : 'outline'}
size="icon" className="h-8 w-8"
@@ -351,15 +342,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
</Button>
<Button
variant={renderMode !== 'solid' ? 'default' : 'outline'}
variant={renderMode === 'edges' ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => {
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
setRenderMode(next);
}}
onClick={() => setRenderMode(renderMode === 'edges' ? 'solid' : 'edges')}
>
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
<Box className="h-3.5 w-3.5" />
{renderMode === 'edges' ? 'Bordas' : 'Sólido'}
</Button>
<Button
+195 -81
View File
@@ -1,116 +1,230 @@
import { useRef, RefObject } from 'react';
import { useRef, useState } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { useXR } from '@react-three/xr';
import * as THREE from 'three';
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
/**
* Quest 3 thumbstick locomotion (rig-relative).
*
* Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard):
* • Para frente (axes[1] < -0.7), libera (axis volta a ~0) → teletransporta
* para o ponto onde o usuário está olhando (raycast da câmera). Se não
* houver geometria atingida, dá um passo de 0.5 m na direção do olhar.
* • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar.
* • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig.
*
* NÃO lê grip ou trigger — esses são responsabilidade exclusiva de
* XRGrabbable e XRControllerMeasure, respectivamente.
* Mapeamento de Locomoção por Teletransporte Parabólico via Controle XR:
*
* - Incline e segure o Thumbstick (analógico) de qualquer controle para frente.
* Um arco parabólico surgirá projetado a partir da mira física do controle.
* - Ao soltar o direcional, você se teletransporta para o local.
* - Para entrar em escala real dentro do modelo, aponte diretamente para ele. A escala
* será automaticamente definida para 1:1 ao pousar.
*/
interface Props {
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
rigRef: RefObject<THREE.Group>;
rigRef: React.RefObject<THREE.Group>;
}
const DEAD = 0.7;
const RELEASE = 0.3;
const SNAP_DEG = 30;
const STEP_BACK = 0.3;
const STEP_FWD_NO_HIT = 0.5;
export function ControllerLocomotion({ rigRef }: Props) {
const { scene, camera } = useThree();
const { camera, gl } = useThree();
const session = useXR((s) => s.session);
const fwdPushed = useRef(false);
const backLatched = useRef(false);
const rotLatched = useRef<0 | -1 | 1>(0);
const raycaster = useRef(new THREE.Raycaster());
const [arcLine] = useState(() => {
const geom = new THREE.BufferGeometry();
const mat = new THREE.LineBasicMaterial({
color: '#06b6d4',
transparent: true,
opacity: 0.8,
});
const line = new THREE.Line(geom, mat);
line.visible = false;
return line;
});
useFrame(() => {
// 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) return;
if (!rig || !session || !frame) {
arcLine.visible = false;
if (reticleRef.current) reticleRef.current.visible = false;
return;
}
let stickX = 0;
const referenceSpace = gl.xr.getReferenceSpace();
if (!referenceSpace) return;
let activeSource: XRInputSource | null = null;
let stickY = 0;
// Procura qualquer controle com analógico inclinado para a frente
for (const src of session.inputSources) {
const gp = src.gamepad;
if (!gp || gp.axes.length < 4) continue;
// Prefer left stick for locomotion
if (src.handedness === 'left') {
stickX = gp.axes[2] ?? 0;
stickY = gp.axes[3] ?? 0;
break;
}
if (src.handedness === 'right' && stickX === 0 && stickY === 0) {
stickX = gp.axes[2] ?? 0;
stickY = gp.axes[3] ?? 0;
}
}
// ── Snap-rotate (lateral) ──────────────────────────────────────
if (Math.abs(stickX) > DEAD) {
const dir: -1 | 1 = stickX > 0 ? 1 : -1;
if (rotLatched.current !== dir) {
rotLatched.current = dir;
rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180;
}
} else if (Math.abs(stickX) < RELEASE) {
rotLatched.current = 0;
}
// ── Back step ──────────────────────────────────────────────────
if (stickY > DEAD) {
if (!backLatched.current) {
backLatched.current = true;
const headDir = new THREE.Vector3();
camera.getWorldDirection(headDir);
headDir.y = 0;
if (headDir.lengthSq() > 1e-6) {
headDir.normalize();
rig.position.addScaledVector(headDir, -STEP_BACK);
if (gp && gp.axes.length >= 4) {
const sy = gp.axes[3] ?? 0; // Eixo Y vertical do joystick
if (sy < -DEAD) {
activeSource = src;
stickY = sy;
break;
}
}
} else if (stickY < RELEASE) {
backLatched.current = false;
}
// ── Forward → teleport on release ─────────────────────────────
if (stickY < -DEAD) {
// Se encontramos um controle ativo mirando/inclinado para frente
if (activeSource && stickY < -DEAD) {
fwdPushed.current = true;
} else if (fwdPushed.current && stickY > -RELEASE) {
fwdPushed.current = false;
// Cast from camera forward to find target on real/virtual geometry
const origin = new THREE.Vector3();
camera.getWorldPosition(origin);
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
raycaster.current.set(origin, dir);
raycaster.current.far = 30;
const hits = raycaster.current.intersectObjects(scene.children, true);
const hit = hits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (o.userData.__edgeLine) return false;
return true;
});
const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT));
const camWorld = new THREE.Vector3();
camera.getWorldPosition(camWorld);
const delta = new THREE.Vector3().subVectors(target, camWorld);
delta.y = 0; // preserva altura do usuário
rig.position.add(delta);
// Obtém pose do controle usando o targetRaySpace (mira física do controle)
const ctrlPose = frame.getPose(activeSource.targetRaySpace, referenceSpace);
if (ctrlPose) {
const m = new THREE.Matrix4().fromArray(ctrlPose.transform.matrix);
// Aplica a matriz de mundo do rig para alinhar a origem física com a locomoção do rig
m.premultiply(rig.matrixWorld);
origin.setFromMatrixPosition(m);
const q = new THREE.Quaternion().setFromRotationMatrix(m);
dir.set(0, 0, -1).applyQuaternion(q).normalize();
} else {
// Fallback para direção do olhar se pose falhar
camera.getWorldPosition(origin);
camera.getWorldDirection(dir);
}
// Parâmetros da simulação do arco parabólico
const points: THREE.Vector3[] = [];
const currentPos = origin.clone();
const velocity = dir.clone().multiplyScalar(7.5); // velocidade do tiro do arco
const gravity = new THREE.Vector3(0, -9.8, 0);
const dt = 0.025; // passo da física
let hitPoint: THREE.Vector3 | null = null;
let hitModel = false;
const modelsObjects = getAllModelLocalGroups();
points.push(currentPos.clone());
// Executa a trajetória balística
for (let i = 0; i < 35; i++) {
const nextPos = currentPos.clone().addScaledVector(velocity, dt);
velocity.addScaledVector(gravity, dt);
const segDir = new THREE.Vector3().subVectors(nextPos, currentPos);
const segLen = segDir.length();
if (segLen > 0.001) {
segDir.normalize();
raycaster.current.set(currentPos, segDir);
raycaster.current.far = segLen;
// 1. Verifica colisão com modelos 3D
const hits = raycaster.current.intersectObjects(modelsObjects, true);
const firstHit = hits.find(
(h) => h.object instanceof THREE.Mesh && !h.object.userData.__edgeLine
);
if (firstHit) {
hitPoint = firstHit.point.clone();
hitModel = true;
points.push(hitPoint);
break;
}
// 2. Verifica colisão com o chão/grid de calibração plano infinito em Y = gridY
const gridY = useModelStore.getState().gridY;
if (
(currentPos.y >= gridY && nextPos.y <= gridY) ||
(currentPos.y <= gridY && nextPos.y >= gridY)
) {
const t = (gridY - currentPos.y) / (nextPos.y - currentPos.y);
hitPoint = new THREE.Vector3().lerpVectors(currentPos, nextPos, t);
points.push(hitPoint);
break;
}
}
currentPos.copy(nextPos);
points.push(currentPos.clone());
}
const finalTarget = hitPoint ?? points[points.length - 1];
lastTeleportTarget.current = finalTarget.clone();
lastTeleportHitModel.current = hitModel;
// Atualiza a geometria do arco
arcLine.geometry.setFromPoints(points);
arcLine.geometry.attributes.position.needsUpdate = true;
arcLine.geometry.computeBoundingSphere();
arcLine.visible = true;
// Atualiza o retículo
if (reticleRef.current) {
const localTarget = finalTarget.clone();
rig.worldToLocal(localTarget);
reticleRef.current.position.copy(localTarget);
reticleRef.current.visible = true;
// Visual premium: Rosa/Fúcsia para escala real (modelo), Ciano para o grid/chão
const mat = reticleRef.current.material as THREE.MeshBasicMaterial;
if (hitModel) {
mat.color.set('#d946ef');
reticleRef.current.scale.setScalar(1.2);
} else {
mat.color.set('#06b6d4');
reticleRef.current.scale.setScalar(1.0);
}
}
} else {
// Quando soltar o analógico
if (fwdPushed.current) {
fwdPushed.current = false;
const targetWorld = lastTeleportTarget.current;
const hitModel = lastTeleportHitModel.current;
if (targetWorld) {
const camWorld = new THREE.Vector3();
camera.getWorldPosition(camWorld);
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
if (hitModel) {
// "Para entrar em escala real dentro do modelo, aponte para ele"
useModelStore.getState().resetScale();
// Ajusta o rig para que o pé fique no Y do modelo, mantendo a altura da câmera
rig.position.y = targetWorld.y;
delta.y = 0;
toast.success("Entrando na peça em Escala Real (1:1)!");
} else {
delta.y = 0; // preserva a altura vertical do usuário
}
rig.position.add(delta);
}
}
// Oculta feixe e retículo
arcLine.visible = false;
if (reticleRef.current) reticleRef.current.visible = false;
}
});
return null;
return (
<>
{/* Arco parabólico premium */}
<primitive object={arcLine} />
{/* Retículo de pouso */}
<mesh ref={reticleRef} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.07, 0.09, 32]} />
<meshBasicMaterial color="#06b6d4" transparent opacity={0.85} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
</>
);
}
+563 -91
View File
@@ -1,11 +1,27 @@
import { Suspense, useRef, useMemo, useEffect, type ReactNode } from 'react';
import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera, PointerLockControls, Sky, Environment } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
import { registerModelLocalGroup, unregisterModelLocalGroup, getModelWorldScaleFactor, getAllModelLocalGroups } from '@/lib/modelTransforms';
import { parseIFCtoThree } from '@/lib/convertIFC';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { mainCameraRef, mainControlsRef, viewAnim, calibration, pushModelFaceNormal, computeCalibrationQuaternion, subscribeCalibration } from './viewCubeBus';
import { toast } from 'sonner';
export function getColorForMaterialName(name: string): string {
if (!name) return '#a1a1aa';
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const h = Math.abs(hash) % 360;
const s = 65 + (Math.abs(hash >> 8) % 15);
const l = 45 + (Math.abs(hash >> 16) % 10);
return `hsl(${h}, ${s}%, ${l}%)`;
}
interface ModelViewerProps {
url?: string; // legacy, ignored — uses store.models
@@ -52,14 +68,93 @@ export function elementKey(modelId: string, el: THREE.Object3D): string {
export const sceneRef: { current: THREE.Scene | null } = { current: null };
function SceneRefCapture() {
const { scene } = useThree();
const { scene, camera } = useThree();
useEffect(() => {
sceneRef.current = scene;
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
}, [scene]);
useEffect(() => {
mainCameraRef.current = camera;
return () => {
if (mainCameraRef.current === camera) mainCameraRef.current = null;
};
}, [camera]);
return null;
}
/** Drives the camera animation requested by the ViewCube. */
function ViewCubeAnimator() {
const { camera } = useThree();
useFrame((_state, delta) => {
if (!viewAnim.active) return;
viewAnim.t = Math.min(1, viewAnim.t + delta / viewAnim.duration);
const k = viewAnim.t < 0.5
? 2 * viewAnim.t * viewAnim.t
: 1 - Math.pow(-2 * viewAnim.t + 2, 2) / 2;
camera.position.lerpVectors(viewAnim.startPos, viewAnim.endPos, k);
camera.up.lerpVectors(viewAnim.startUp, viewAnim.endUp, k).normalize();
const controls = mainControlsRef.current;
if (controls?.target) camera.lookAt(controls.target);
controls?.update?.();
if (viewAnim.t >= 1) viewAnim.active = false;
});
return null;
}
/** Switches between perspective and orthographic projection while keeping
* the visual framing (position, target, on-screen size of the scene). */
function CameraSwitcher() {
const mode = useModelStore((s) => s.cameraMode);
const { size } = useThree();
// Snapshot current view from whichever camera is active right now, before swap.
const snap = useMemo(() => {
const c = mainCameraRef.current;
const ctrl = mainControlsRef.current;
const target = (ctrl?.target ?? new THREE.Vector3()).clone();
const pos = c ? c.position.clone() : new THREE.Vector3(2, 2, 2);
const up = c ? c.up.clone() : new THREE.Vector3(0, 1, 0);
const dist = pos.distanceTo(target);
// Compute ortho zoom that matches current perspective framing.
let zoom = 100;
if (c && (c as THREE.PerspectiveCamera).isPerspectiveCamera) {
const fov = ((c as THREE.PerspectiveCamera).fov ?? 50) * (Math.PI / 180);
const worldHeight = 2 * Math.max(dist, 0.001) * Math.tan(fov / 2);
zoom = Math.max(1, size.height / Math.max(worldHeight, 1e-6));
} else if (c && (c as THREE.OrthographicCamera).isOrthographicCamera) {
zoom = (c as THREE.OrthographicCamera).zoom;
}
return { target, pos, up, zoom };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// Sync OrbitControls target after camera swap.
useEffect(() => {
const ctrl = mainControlsRef.current;
if (ctrl?.target) ctrl.target.copy(snap.target);
ctrl?.update?.();
}, [snap]);
if (mode === 'ortho') {
return (
<OrthographicCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
zoom={snap.zoom}
near={-1000}
far={1000}
/>
);
}
return (
<PerspectiveCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
fov={50}
near={0.001}
far={1000}
/>
);
}
function LoadingFallback() {
return (
@@ -73,9 +168,36 @@ function LoadingFallback() {
}
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
const { scene: rawScene } = useGLTF(sceneModel.url);
// Clone scene per instance so multiple GLBModels with same url don't share materials/transforms
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
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('[GLBModel] IFC parsing error', err));
} else {
const loader = new GLTFLoader();
loader.load(
sceneModel.url,
(gltf) => {
if (active) setRawScene(gltf.scene);
},
undefined,
(err) => console.error('[GLBModel] GLTF loading error', err)
);
}
return () => {
active = false;
};
}, [sceneModel.url, sceneModel.fileName]);
const ref = useRef<THREE.Group>(null);
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
@@ -85,9 +207,16 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const checklist = useModelStore((s) => s.checklist);
const setActive = useModelStore((s) => s.setActiveModel);
const selectionMode = useModelStore((s) => s.selectionMode);
const measureMode = useModelStore((s) => s.measureMode);
const ifcColorMode = useModelStore((s) => s.ifcColorMode);
const fineTuning = sceneModel.fineTuning;
// Re-render when calibration state changes (so calQuat resets to identity during the flow).
const [, setCalTick] = useState(0);
useEffect(() => subscribeCalibration(() => setCalTick(t => t + 1)), []);
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();
@@ -99,6 +228,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
useEffect(() => {
if (!scene) return;
const hasRejected = checklist.some(i => i.status === 'rejected');
const allApproved = checklist.every(i => i.status === 'approved');
@@ -129,12 +259,13 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
originalColors.current.set(mat, mat.color.clone());
}
if (renderMode === 'edges') {
if (renderMode === 'edges' && !measureMode) {
mat.visible = false;
} else {
mat.visible = true;
mat.transparent = opacity < 1;
mat.opacity = opacity;
const targetOpacity = measureMode ? 0.25 : opacity;
mat.transparent = targetOpacity < 1;
mat.opacity = targetOpacity;
mat.wireframe = renderMode === 'wireframe';
if (renderMode === 'wireframe') {
mat.wireframeLinewidth = wireframeThickness;
@@ -144,19 +275,31 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
const root = findElementRoot(child);
const matName = root?.userData?.materialName;
const descName = root?.userData?.properties?.description || root?.userData?.description;
if (ifcColorMode === 'material' && matName) {
mat.color.set(getColorForMaterialName(matName));
} else if (ifcColorMode === 'description' && descName) {
mat.color.set(getColorForMaterialName(descName));
} else {
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
}
}
}
mat.needsUpdate = true;
}
});
if (renderMode === 'edges' && child.geometry) {
if ((renderMode === 'edges' || measureMode) && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
color: wireframeColor,
linewidth: wireframeThickness,
color: measureMode ? '#00f3ff' : wireframeColor,
linewidth: measureMode ? 2 : wireframeThickness,
toneMapped: false,
transparent: true,
opacity: 0.95,
});
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
lineSegments.userData.__edgeLine = true;
@@ -164,7 +307,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode, ifcColorMode]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
@@ -173,7 +316,24 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
const rotationEuler = useMemo(() => {
return new THREE.Euler(rotXRad, rotYRad, rotZRad, 'YXZ');
}, [rotXRad, rotYRad, rotZRad]);
// Calibration quaternion (applied innermost, around center). We render with identity
// while calibration is in progress for this model so face normals reflect the
// un-calibrated frame; the result is committed at the end.
const calQuatArr = sceneModel.calibrationQuat;
const isCalibratingThis = calibration.modelId === sceneModel.id && calibration.step !== 'idle' && calibration.step !== 'done';
const calQuat = useMemo(() => {
if (isCalibratingThis) return new THREE.Quaternion();
if (!calQuatArr) return new THREE.Quaternion();
return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]);
}, [calQuatArr, isCalibratingThis]);
const calGroupRef = useRef<THREE.Group>(null);
if (!sceneModel.visible) return null;
if (!scene) return null;
// Determine dominant local axis of the model (longest bbox side) for axial roll
const dominantAxis: 'x' | 'y' | 'z' =
@@ -190,6 +350,28 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
e.stopPropagation();
if ((e.delta ?? 0) > 4) return;
// Calibration: capture model face normal in world space.
if (
calibration.modelId === sceneModel.id &&
(calibration.step === 'await-model-1' || calibration.step === 'await-model-2' || calibration.step === 'await-model-3') &&
e.face && e.object
) {
const n = e.face.normal.clone();
const nm = new THREE.Matrix3().getNormalMatrix(e.object.matrixWorld);
n.applyMatrix3(nm).normalize();
const wq = new THREE.Quaternion();
(calGroupRef.current ?? e.object).getWorldQuaternion(wq);
pushModelFaceNormal(n, wq);
// Apply / refresh calibration whenever we have ≥2 pairs.
if (calibration.pairs.length >= 2) {
const q = computeCalibrationQuaternion(calibration.pairs);
if (q) {
useModelStore.getState().setCalibration(sceneModel.id, [q.x, q.y, q.z, q.w]);
}
}
return;
}
if (selectionMode) {
const element = findElementRoot(e.object);
if (element) {
@@ -207,16 +389,16 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
{/* Rotation + scale around the geometry center */}
<group
ref={ref}
rotation={[rotXRad, rotYRad, rotZRad]}
rotation={rotationEuler}
scale={[s, s, s]}
>
{/* Shift geometry so its center sits at the parent's origin (pivot = center).
This is the local frame where measurement points are stored, so measurements
follow the model when posX/Y/Z or rotX/Y/Z change in "Posicionar" mode. */}
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
{/* Calibration rotation (innermost, around center). */}
<group ref={calGroupRef} quaternion={calQuat}>
<ModelLocalFrame modelId={sceneModel.id} center={modelInfo.center}>
<primitive object={scene} />
<ModelMeasurements modelId={sceneModel.id} />
</ModelLocalFrame>
</group>
</group>
</group>
</group>
@@ -260,9 +442,24 @@ function SceneModels() {
);
}
/** Sphere marker at a 3D point — scaled per-frame to keep constant screen size.
* Compensates for any cumulative parent world scale so it stays the same screen
* size whether mounted in world space or inside a transformed model group. */
/** World-units-per-pixel for the active camera (persp or ortho). */
function computeWorldPerPixel(
camera: THREE.Camera,
size: { height: number },
worldPos: THREE.Vector3,
): number {
const ortho = camera as THREE.OrthographicCamera;
if ('isOrthographicCamera' in ortho) {
const visibleHeight = (ortho.top - ortho.bottom) / (ortho.zoom || 1);
return visibleHeight / Math.max(1, size.height);
}
const persp = camera as THREE.PerspectiveCamera;
const dist = camera.position.distanceTo(worldPos);
const fov = (persp.fov ?? 50) * (Math.PI / 180);
return (2 * dist * Math.tan(fov / 2)) / Math.max(1, size.height);
}
/** Sphere marker at a 3D point — scaled per-frame to keep constant screen size. */
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
@@ -271,13 +468,8 @@ function PointMarker({ position, color = '#e8a838' }: { position: [number, numbe
useFrame(() => {
if (!ref.current) return;
ref.current.getWorldPosition(worldPos);
const dist = camera.position.distanceTo(worldPos);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp).
const targetWorld = Math.max(0.0005, 6 * worldPerPixel);
// Divide by parent world scale so the final world size matches `targetWorld`.
const wpp = computeWorldPerPixel(camera, size, worldPos);
const targetWorld = Math.max(0.0005, 6 * wpp);
const parentScale = ref.current.parent ? ref.current.parent.getWorldScale(tmpVec).x || 1 : 1;
ref.current.scale.setScalar(targetWorld / parentScale);
});
@@ -289,19 +481,26 @@ function PointMarker({ position, color = '#e8a838' }: { position: [number, numbe
);
}
/** Snap ring indicator — scaled per-frame */
/** Snap ring indicator — fixed ~10 mm diameter in world units (follows piece scale).
* World units in this app are meters (1 unit = 1000 mm), so 5 mm radius = 0.005
* world units, divided by the model's cumulative world scale so it remains
* ~10 mm physically when the piece is scaled. */
function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
const worldPos = useMemo(() => new THREE.Vector3(), []);
useFrame(() => {
if (!ref.current) return;
ref.current.lookAt(camera.position);
const dist = camera.position.distanceTo(ref.current.position);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
const s = Math.max(0.0008, 10 * worldPerPixel);
ref.current.scale.setScalar(s);
const activeId = useModelStore.getState().activeModelId;
const sf = getModelWorldScaleFactor(activeId);
let radiusWorld = 0.005 / Math.max(1e-6, sf); // 5 mm in world units
if (!Number.isFinite(radiusWorld) || radiusWorld <= 0) {
ref.current.getWorldPosition(worldPos);
const wpp = computeWorldPerPixel(camera, size, worldPos);
radiusWorld = Math.max(0.0008, 8 * wpp);
}
ref.current.scale.setScalar(radiusWorld);
});
return (
<mesh ref={ref} position={position}>
@@ -311,26 +510,13 @@ function SnapRing({ position, color = '#eab308' }: { position: [number, number,
);
}
/** Computes a distanceFactor that keeps an Html label roughly constant on screen */
function useLabelDistanceFactor(position: [number, number, number]): number {
const { camera, size } = useThree();
const v = useMemo(() => new THREE.Vector3(), []);
const ref = useRef(1);
useFrame(() => {
v.set(position[0], position[1], position[2]);
const dist = camera.position.distanceTo(v);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// distanceFactor scales the html so it stays ~140 px tall at this distance
ref.current = Math.max(0.05, worldPerPixel * 140);
});
return ref.current;
}
function MeasurementLabel({ position, text, variant, fixed = false }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const dfDynamic = useLabelDistanceFactor(position);
const df = fixed ? dfDynamic : 1.5;
function MeasurementLabel({ position, text, variant }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' | 'accent'; fixed?: boolean }) {
const { camera } = useThree();
const isOrtho = (camera as THREE.OrthographicCamera).isOrthographicCamera === true;
// In orthographic mode, drei's distanceFactor uses perspective math and can
// explode → the HTML label scales massively and visually wipes the model.
// Use a plain screen-space Html label in ortho; perspective keeps a stable factor.
const df = isOrtho ? undefined : 1.5;
const borderClass = variant === 'success'
? 'border-success/60'
: variant === 'accent'
@@ -552,11 +738,13 @@ function SmartSnapHandler() {
function HoverDetector() {
const { camera, scene, gl } = useThree();
const setHoverInfo = useModelStore((s) => s.setHoverInfo);
const setHoverIfcProps = useModelStore((s) => s.setHoverIfcProps);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHitKey = useRef('');
const lastHitExpressId = useRef<number>(0);
useEffect(() => {
const clearTimers = () => {
@@ -582,11 +770,28 @@ function HoverDetector() {
if (!hit || !(hit.object instanceof THREE.Mesh)) {
lastHitKey.current = '';
lastHitExpressId.current = 0;
setHoverInfo(null);
setHoverIfcProps(null);
clearTimers();
return;
}
// Detecção de propriedades do IFC de forma ágil baseada em mudança de elemento
const elementRoot = findElementRoot(hit.object);
const expressID = elementRoot?.userData?.ifcId ?? 0;
if (expressID !== lastHitExpressId.current) {
lastHitExpressId.current = expressID;
const props = elementRoot?.userData?.properties;
if (props && (props.name || props.material || props.tag)) {
setHoverIfcProps(props);
} else {
setHoverIfcProps(null);
}
}
// Stability check same approximate position for debounce (~3 mm)
const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
if (key === lastHitKey.current) return; // timer already running
@@ -649,7 +854,9 @@ function HoverDetector() {
const onLeave = () => {
lastHitKey.current = '';
lastHitExpressId.current = 0;
setHoverInfo(null);
setHoverIfcProps(null);
clearTimers();
};
@@ -659,8 +866,9 @@ function HoverDetector() {
gl.domElement.removeEventListener('pointermove', onMove);
gl.domElement.removeEventListener('pointerleave', onLeave);
clearTimers();
setHoverIfcProps(null);
};
}, [camera, scene, gl, raycaster, mouse, setHoverInfo]);
}, [camera, scene, gl, raycaster, mouse, setHoverInfo, setHoverIfcProps]);
return null;
}
@@ -802,7 +1010,7 @@ function SelectionHandler() {
/** Walks the scene each time visibility/selection state changes and applies
* per-element visibility + emissive highlight for selected elements. */
function VisibilityApplier() {
export function VisibilityApplier() {
const { scene } = useThree();
const nonce = useModelStore((s) => s.visibilityNonce);
const models = useModelStore((s) => s.models);
@@ -876,38 +1084,149 @@ function GridLayer() {
);
}
/** Computes min Y of all visible models and updates store gridY when auto-follow is on. */
/** Continuously aligns the grid Y to the bottom of all registered model
* groups (not the entire scene), so the piece always sits on the grid —
* including after calibration, fine-tuning or repositioning. */
function GridAutoFollower() {
const models = useModelStore((s) => s.models);
const { scene } = useThree();
useEffect(() => {
const lastY = useRef<number>(Number.NaN);
const acc = useRef(0);
useFrame((_, dt) => {
const auto = useModelStore.getState().gridAutoFollow;
if (!auto) return;
// Defer to next tick so models have rendered/positioned
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) {
// Skip helpers (markers, rings)
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);
}
}
acc.current += dt;
if (acc.current < 0.1) return;
acc.current = 0;
const groups = getAllModelLocalGroups();
if (groups.length === 0) return;
const box = new THREE.Box3();
let has = false;
const tmp = new THREE.Box3();
for (const g of groups) {
g.updateWorldMatrix(true, true);
tmp.makeEmpty();
g.traverse((obj) => {
if (!(obj instanceof THREE.Mesh) || !obj.geometry) return;
if (obj.geometry instanceof THREE.SphereGeometry) return;
if (obj.geometry instanceof THREE.RingGeometry) return;
if (obj.userData.__edgeLine) return;
const b = new THREE.Box3().setFromObject(obj);
if (isFinite(b.min.y)) tmp.union(b);
});
if (has) {
useModelStore.setState({ gridY: box.min.y - 0.005 });
if (!tmp.isEmpty() && isFinite(tmp.min.y)) {
if (!has) { box.copy(tmp); has = true; }
else box.union(tmp);
}
}, 100);
return () => clearTimeout(id);
}, [models, scene]);
}
if (!has) return;
const target = box.min.y - 0.005;
if (!Number.isFinite(lastY.current) || Math.abs(target - lastY.current) > 1e-4) {
lastY.current = target;
useModelStore.setState({ gridY: target });
}
});
return null;
}
/** When gridCalibMode is on, the next click on the model sets the grid Y to
* the world Y of the clicked surface point and disables auto-follow. */
function GridCalibrationHandler() {
const { camera, scene, gl } = useThree();
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
useEffect(() => {
if (!gridCalibMode) return;
const canvas = gl.domElement;
const prevCursor = canvas.style.cursor;
canvas.style.cursor = 'crosshair';
const onPointerUp = (ev: PointerEvent) => {
if (ev.button !== 0) return;
const rect = canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, true);
const hit = hits.find((h) => isPickableModelMesh(h.object));
if (!hit) return;
useModelStore.setState({
gridY: hit.point.y - 0.001,
gridAutoFollow: false,
gridCalibMode: false,
showGrid: true,
});
};
canvas.addEventListener('pointerup', onPointerUp);
return () => {
canvas.removeEventListener('pointerup', onPointerUp);
canvas.style.cursor = prevCursor;
};
}, [gridCalibMode, camera, scene, gl, raycaster, mouse]);
return null;
}
/** Applies axis-aligned clipping planes (X / Y / Z) to every mesh inside the
* registered model groups. Listens to the section state in the store; when
* all axes are off, restores empty clipping arrays so meshes render whole. */
function SectionClippingApplier() {
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const planes = useMemo(() => ({
x: new THREE.Plane(new THREE.Vector3(1, 0, 0), 0),
y: new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
z: new THREE.Plane(new THREE.Vector3(0, 0, 1), 0),
}), []);
useEffect(() => {
// Configure each plane: keep the half-space on the positive side of the
// normal. Inverting flips the normal direction.
(['x', 'y', 'z'] as const).forEach((axis) => {
const plane = planes[axis];
const sign = invert[axis] ? -1 : 1;
plane.normal.set(
axis === 'x' ? sign : 0,
axis === 'y' ? sign : 0,
axis === 'z' ? sign : 0,
);
// For plane equation n·p + d = 0, keeping `n·p >= level*sign_axis` means
// d = -level when normal is +axis, d = +level when normal is -axis.
plane.constant = -sign * level[axis];
});
const active: THREE.Plane[] = [];
if (enabled.x) active.push(planes.x);
if (enabled.y) active.push(planes.y);
if (enabled.z) active.push(planes.z);
const groups = getAllModelLocalGroups();
const touched: THREE.Material[] = [];
const apply = (clipping: THREE.Plane[]) => {
for (const g of groups) {
g.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of mats) {
if (!mat) continue;
mat.clippingPlanes = clipping;
mat.clipShadows = true;
mat.needsUpdate = true;
touched.push(mat);
}
});
}
};
apply(active);
return () => {
// Cleanup: clear clipping from any material we touched.
for (const mat of touched) {
mat.clippingPlanes = [];
mat.needsUpdate = true;
}
};
}, [enabled.x, enabled.y, enabled.z, invert, invert.x, invert.y, invert.z, level, level.x, level.y, level.z, planes]);
return null;
}
@@ -1060,10 +1379,149 @@ function PositionDragHandler() {
function WalkControls() {
const walkMode = useModelStore(s => s.walkMode);
const setWalkMode = useModelStore(s => s.setWalkMode);
const gridY = useModelStore(s => s.gridY);
const { camera } = useThree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const controlsRef = useRef<any>(null);
const keys = useRef({ w: false, a: false, s: false, d: false, q: false, e: false, shift: false });
const initialized = useRef(false);
useEffect(() => {
if (walkMode && !initialized.current) {
camera.position.set(0, gridY + 1.7, 5);
// Give the camera an initial rotation looking forward
camera.rotation.set(0, 0, 0);
initialized.current = true;
} else if (!walkMode) {
initialized.current = false;
}
}, [walkMode, camera, gridY]);
useEffect(() => {
if (walkMode) {
// Small timeout to allow canvas to render and be clickable
const timer = setTimeout(() => {
try { controlsRef.current?.lock(); } catch (e) { /* ignore */ }
}, 100);
return () => clearTimeout(timer);
}
}, [walkMode]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'KeyW') keys.current.w = true;
if (e.code === 'KeyA') keys.current.a = true;
if (e.code === 'KeyS') keys.current.s = true;
if (e.code === 'KeyD') keys.current.d = true;
if (e.code === 'KeyQ') keys.current.q = true;
if (e.code === 'KeyE') keys.current.e = true;
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'KeyW') keys.current.w = false;
if (e.code === 'KeyA') keys.current.a = false;
if (e.code === 'KeyS') keys.current.s = false;
if (e.code === 'KeyD') keys.current.d = false;
if (e.code === 'KeyQ') keys.current.q = false;
if (e.code === 'KeyE') keys.current.e = false;
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') keys.current.shift = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useFrame((_, dt) => {
if (!walkMode) return;
const speed = keys.current.shift ? 6.0 : 2.5;
const dir = new THREE.Vector3();
// Front vector (ignoring pitch to walk purely horizontally)
const front = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
front.y = 0;
if (front.lengthSq() > 0.001) front.normalize();
// Right vector
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
right.y = 0;
if (right.lengthSq() > 0.001) right.normalize();
if (keys.current.w) dir.add(front);
if (keys.current.s) dir.sub(front);
if (keys.current.a) dir.sub(right);
if (keys.current.d) dir.add(right);
if (dir.lengthSq() > 0) {
dir.normalize();
camera.position.addScaledVector(dir, speed * dt);
}
// Vertical movement (Q / E)
if (keys.current.e) camera.position.y += speed * dt;
if (keys.current.q) camera.position.y -= speed * dt;
// Clamp to prevent clipping through the ground
if (camera.position.y < gridY + 0.2) {
camera.position.y = gridY + 0.2;
}
});
if (!walkMode) return null;
return (
<PointerLockControls
ref={controlsRef}
onUnlock={() => {
setWalkMode(false);
}}
/>
);
}
function WalkEnvironment() {
const walkMode = useModelStore(s => s.walkMode);
const gridY = useModelStore(s => s.gridY);
if (!walkMode) return null;
return (
<group>
{/* Um belo cenário HDRI de parque que nos dá árvores, céu azul e nuvens à distância perfeitamente */}
<Environment preset="park" background />
{/* O Piso Digital: Sólido, infinito e na altura exata da peça para que ela não pareça "voar" */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, gridY - 0.01, 0]} receiveShadow>
<planeGeometry args={[1000, 1000]} />
<meshStandardMaterial color="#3f662b" roughness={1} metalness={0.1} />
</mesh>
{/* Grid sutil sobre a grama para dar noção de escala e movimento ao caminhar */}
<Grid
position={[0, gridY, 0]}
args={[1000, 1000]}
cellSize={1}
cellThickness={0.5}
cellColor="#4d7a34"
sectionSize={10}
sectionThickness={1}
sectionColor="#2d4a1f"
fadeDistance={50}
fadeStrength={2}
/>
</group>
);
}
export function ModelViewerCanvas({ url }: ModelViewerProps) {
const positionMode = useModelStore((s) => s.positionMode);
const measureMode = useModelStore((s) => s.measureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
const walkMode = useModelStore((s) => s.walkMode);
return (
<Canvas
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
@@ -1079,8 +1537,10 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
const maxRatio = isApple ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
gl.localClippingEnabled = true;
}}
>
<CameraSwitcher />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
@@ -1096,11 +1556,17 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
<GridLayer />
<GridAutoFollower />
<GridCalibrationHandler />
<SectionClippingApplier />
<PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
<ViewCubeAnimator />
<WalkControls />
<WalkEnvironment />
<OrbitControls
makeDefault
@@ -1108,9 +1574,15 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
enabled={!positionMode}
minZoom={5}
maxZoom={5000}
enabled={!positionMode && !walkMode}
ref={(c: unknown) => {
mainControlsRef.current = c;
}}
/>
</Canvas>
);
}
+253 -8
View File
@@ -252,8 +252,16 @@ export function findNearestEdgeSegment(
}
});
// If no edge lines, generate from EdgesGeometry
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
// If no edge lines, fetch from cache or generate and store in cache
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position;
if (!posAttr) return null;
@@ -262,7 +270,7 @@ export function findNearestEdgeSegment(
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
let bestDist = Infinity;
let bestMid: THREE.Vector3 | null = null;
@@ -298,8 +306,6 @@ export function findNearestEdgeSegment(
}
}
if (!edgeLines) geo.dispose();
if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) {
return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB };
}
@@ -321,11 +327,20 @@ export function detectCircularEdgeAtPoint(
mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
});
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const hitScreen = worldPoint.clone().project(camera);
const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
@@ -347,7 +362,6 @@ export function detectCircularEdgeAtPoint(
seen.add(key);
verts.push(tmp.clone());
}
if (!edgeLines) geo.dispose();
if (verts.length < 6) return null;
@@ -424,3 +438,234 @@ export function resolveSnap(
if (e) return { point: e.midpoint, type: 'edge' };
return { point: hitPoint.clone(), type: 'surface' };
}
const _ab = new THREE.Vector3();
const _ap = new THREE.Vector3();
const _closest = new THREE.Vector3();
function closestPointOnSegment3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3, target: THREE.Vector3): THREE.Vector3 {
_ab.subVectors(b, a);
_ap.subVectors(p, a);
const abLenSq = _ab.lengthSq();
if (abLenSq < 1e-6) return target.copy(a);
const t = THREE.MathUtils.clamp(_ap.dot(_ab) / abLenSq, 0, 1);
return target.copy(a).addScaledVector(_ab, t);
}
function pointToSegmentDistSq3D(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number {
_ab.subVectors(b, a);
_ap.subVectors(p, a);
const abLenSq = _ab.lengthSq();
if (abLenSq < 1e-6) return _ap.lengthSq();
let t = _ap.dot(_ab) / abLenSq;
t = Math.max(0, Math.min(1, t));
_closest.copy(a).addScaledVector(_ab, t);
return p.distanceToSquared(_closest);
}
export function findNearestVertex3D(
mesh: THREE.Mesh,
worldPoint: THREE.Vector3,
thresholdMeters: number = 0.035
): THREE.Vector3 | null {
const geo = mesh.geometry;
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const invMatrix = new THREE.Matrix4().copy(mesh.matrixWorld).invert();
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
let bestDistSq = Infinity;
let bestIndex = -1;
const thresholdSq = thresholdMeters * thresholdMeters;
for (let i = 0; i < posAttr.count; i++) {
const vx = posAttr.getX(i);
const vy = posAttr.getY(i);
const vz = posAttr.getZ(i);
const dx = vx - localPoint.x;
const dy = vy - localPoint.y;
const dz = vz - localPoint.z;
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIndex = i;
}
}
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
const result = new THREE.Vector3(
posAttr.getX(bestIndex),
posAttr.getY(bestIndex),
posAttr.getZ(bestIndex)
);
result.applyMatrix4(mesh.matrixWorld);
return result;
}
return null;
}
export function findNearestEdgeSegment3D(
mesh: THREE.Mesh,
worldPoint: THREE.Vector3,
thresholdMeters: number = 0.05
): { midpoint: THREE.Vector3; snapPoint: THREE.Vector3; lengthMM: number; a: THREE.Vector3; b: THREE.Vector3 } | null {
let edgeLines: THREE.LineSegments | null = null;
mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
edgeLines = c;
}
});
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
let bestDistSq = Infinity;
let bestIndex = -1;
const bestClosestLocal = new THREE.Vector3();
const thresholdSq = thresholdMeters * thresholdMeters;
const segCount = posAttr.count / 2;
const aLocal = new THREE.Vector3();
const bLocal = new THREE.Vector3();
for (let i = 0; i < segCount; i++) {
aLocal.fromBufferAttribute(posAttr, i * 2);
bLocal.fromBufferAttribute(posAttr, i * 2 + 1);
closestPointOnSegment3D(localPoint, aLocal, bLocal, _closest);
const distSq = localPoint.distanceToSquared(_closest);
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestIndex = i;
bestClosestLocal.copy(_closest);
}
}
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
const midpoint = a.clone().add(b).multiplyScalar(0.5);
const snapPoint = bestClosestLocal.clone().applyMatrix4(matrix);
const lengthMM = a.distanceTo(b) * 1000;
return { midpoint, snapPoint, lengthMM, a, b };
}
return null;
}
export function detectCircularEdgeAtPoint3D(
mesh: THREE.Mesh,
worldPoint: THREE.Vector3,
radiusMeters: number = 0.08
): { center: THREE.Vector3; diameterMM: number } | null {
let edgeLines: THREE.LineSegments | null = null;
mesh.children.forEach(c => {
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
});
let geo: THREE.BufferGeometry;
if (edgeLines) {
geo = edgeLines.geometry;
} else {
if (!mesh.userData.__cachedEdgesGeometry) {
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
}
geo = mesh.userData.__cachedEdgesGeometry;
}
const posAttr = geo.attributes.position;
if (!posAttr) return null;
const matrix = edgeLines ? edgeLines.matrixWorld : mesh.matrixWorld;
const invMatrix = new THREE.Matrix4().copy(matrix).invert();
const localPoint = worldPoint.clone().applyMatrix4(invMatrix);
const verts: THREE.Vector3[] = [];
const seen = new Set<string>();
const tmp = new THREE.Vector3();
const radiusSq = radiusMeters * radiusMeters;
const count = posAttr.count;
for (let i = 0; i < count; i++) {
tmp.fromBufferAttribute(posAttr, i);
const dx = tmp.x - localPoint.x;
const dy = tmp.y - localPoint.y;
const dz = tmp.z - localPoint.z;
const distSq = dx * dx + dy * dy + dz * dz;
if (distSq > radiusSq) continue;
const key = `${tmp.x.toFixed(5)},${tmp.y.toFixed(5)},${tmp.z.toFixed(5)}`;
if (seen.has(key)) continue;
seen.add(key);
verts.push(tmp.clone().applyMatrix4(matrix));
}
if (verts.length < 6) return null;
const centroid = new THREE.Vector3();
verts.forEach(v => centroid.add(v));
centroid.divideScalar(verts.length);
let xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
for (const v of verts) {
const dx = v.x - centroid.x, dy = v.y - centroid.y, dz = v.z - centroid.z;
xx += dx * dx; xy += dx * dy; xz += dx * dz;
yy += dy * dy; yz += dy * dz; zz += dz * dz;
}
const axisA = new THREE.Vector3(xx, xy, xz).normalize();
const axisB = new THREE.Vector3(xy, yy, yz).normalize();
const normal = new THREE.Vector3().crossVectors(axisA, axisB);
if (normal.lengthSq() < 1e-8) return null;
normal.normalize();
const basisU = new THREE.Vector3();
if (Math.abs(normal.x) < 0.9) basisU.crossVectors(normal, new THREE.Vector3(1, 0, 0)).normalize();
else basisU.crossVectors(normal, new THREE.Vector3(0, 1, 0)).normalize();
const basisV = new THREE.Vector3().crossVectors(normal, basisU).normalize();
const points2D = verts.map(v => {
const rel = v.clone().sub(centroid);
return { u: rel.dot(basisU), v: rel.dot(basisV) };
});
const fit = circleFitKasa(points2D);
if (!fit) return null;
const diameterMM = fit.radius * 2 * 1000;
if (diameterMM < 2 || diameterMM > 500) return null;
const center3D = centroid.clone()
.add(basisU.clone().multiplyScalar(fit.cx))
.add(basisV.clone().multiplyScalar(fit.cy));
return { center: center3D, diameterMM };
}
export function resolveSnap3D(
mesh: THREE.Mesh,
hitPoint: THREE.Vector3,
vertexThresholdMeters: number = 0.07,
edgeThresholdMeters: number = 0.09
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
if (v) return { point: v, type: 'vertex' };
const e = findNearestEdgeSegment3D(mesh, hitPoint, edgeThresholdMeters);
if (e) return { point: e.snapPoint, type: 'edge' };
return { point: hitPoint.clone(), type: 'surface' };
}
+139 -69
View File
@@ -1,14 +1,41 @@
import { useRef, useState } from 'react';
import { useRef, useState, useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
import { resolveSnap, detectCircularEdgeAtPoint, findNearestEdgeSegment } from './SmartMeasure';
import { sendRemoteLog } from '@/lib/remoteLogger';
import {
resolveSnap,
detectCircularEdgeAtPoint,
findNearestEdgeSegment,
resolveSnap3D,
detectCircularEdgeAtPoint3D,
findNearestEdgeSegment3D
} from './SmartMeasure';
import { toast } from 'sonner';
const TRIG_ON = 0.7;
const TRIG_OFF = 0.3;
const BTN_ON = 0.6;
const MAX_RAY = 10; // meters
function isPickableModelMesh(o: THREE.Object3D): boolean {
let cur: THREE.Object3D | null = o;
while (cur) {
if (cur.userData?.modelId) return true;
cur = cur.parent;
}
return false;
}
function getModelIdFromObject(o: THREE.Object3D): string | undefined {
let cur: THREE.Object3D | null = o;
while (cur) {
if (cur.userData?.modelId) return cur.userData.modelId as string;
cur = cur.parent;
}
return undefined;
}
/**
* Right-controller trigger driven measurement for AR.
*
@@ -30,6 +57,7 @@ export function XRControllerMeasure() {
const lTrigState = useRef(false);
const [snapEnabled, setSnapEnabled] = useState(true);
const raycaster = useRef(new THREE.Raycaster());
const tmpOrigin = useRef(new THREE.Vector3());
const tmpDir = useRef(new THREE.Vector3());
@@ -43,18 +71,33 @@ export function XRControllerMeasure() {
);
const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false }));
const tipColor = useRef(new THREE.Color('#22c55e'));
const lockedSnap = useRef<{ point: THREE.Vector3; kind: 'vertex' | 'edge' | 'hole'; modelId?: string; lastSeen: number } | null>(null);
// Dwell detection for hover-based smart measurement (1 s)
const dwellPos = useRef(new THREE.Vector3(Infinity, Infinity, Infinity));
const dwellStart = useRef(0);
const dwellFired = useRef(false);
// Throttling: raycast + snap analysis are heavy on dense IFC models and
// running them every frame at 72fps can stall the Quest right after
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
const hitTestSourceRef = useRef<XRHitTestSource | null>(null);
const hitTestRequestedRef = useRef<boolean>(false);
useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode;
const selectionMode = useModelStore.getState().selectionMode;
if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false;
if ((!measureMode && !selectionMode) || !frame) return;
if ((!measureMode && !selectionMode) || !frame) {
if (hitTestRequestedRef.current) {
hitTestSourceRef.current = null;
hitTestRequestedRef.current = false;
}
return;
}
const session = frame.session;
const refSpace = gl.xr.getReferenceSpace();
@@ -88,6 +131,10 @@ export function XRControllerMeasure() {
if (!pose) return;
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
const xrRig = useModelStore.getState().xrRig;
if (xrRig) {
m.premultiply(xrRig.matrixWorld);
}
tmpOrigin.current.setFromMatrixPosition(m);
tmpQuat.current.setFromRotationMatrix(m);
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
@@ -95,10 +142,17 @@ export function XRControllerMeasure() {
raycaster.current.set(tmpOrigin.current, tmpDir.current);
raycaster.current.far = MAX_RAY;
let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; modelId?: string; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
let hitModelId: string | undefined;
const nowT = performance.now();
const hits = raycaster.current.intersectObjects(scene.children, true);
const hit = hits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (!isPickableModelMesh(o)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false;
@@ -106,54 +160,44 @@ export function XRControllerMeasure() {
return true;
});
let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
if (hit && hit.object instanceof THREE.Mesh) {
const size = gl.getSize(new THREE.Vector2());
const canvasSize = { width: size.x || 1024, height: size.y || 1024 };
const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
fakeCam.position.copy(tmpOrigin.current);
fakeCam.quaternion.copy(tmpQuat.current);
fakeCam.updateMatrixWorld(true);
hitModelId = getModelIdFromObject(hit.object);
const dist = tmpOrigin.current.distanceTo(hit.point);
const vertexThreshold = Math.max(0.08, dist * 0.12);
const edgeThreshold = Math.max(0.10, dist * 0.14);
// Snap to existing registered hole centers first (within ~30px screen)
// Snap to existing registered hole centers first
const existing = useModelStore.getState().measurements;
let bestHoleCenter: THREE.Vector3 | null = null;
let bestHolePx = Infinity;
const hitProj = hit.point.clone().project(fakeCam);
const hx = (hitProj.x * 0.5 + 0.5) * canvasSize.width;
const hy = (-hitProj.y * 0.5 + 0.5) * canvasSize.height;
let bestHoleDist = Infinity;
for (const m of existing) {
if (m.kind !== 'hole') continue;
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
const p = c.clone().project(fakeCam);
const sx = (p.x * 0.5 + 0.5) * canvasSize.width;
const sy = (-p.y * 0.5 + 0.5) * canvasSize.height;
const d = Math.hypot(sx - hx, sy - hy);
if (d < 30 && d < bestHolePx) { bestHolePx = d; bestHoleCenter = c; }
const d = hit.point.distanceTo(c);
if (d < 0.05 && d < bestHoleDist) {
bestHoleDist = d;
bestHoleCenter = c;
}
}
if (snapEnabled && bestHoleCenter) {
snappedPoint = bestHoleCenter;
snapKind = 'hole';
} else if (snapEnabled) {
// Try detecting a circular edge (hole) at hit
const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 60);
const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, Math.max(0.08, dist * 0.12));
if (circle) {
snappedPoint = circle.center;
snapKind = 'hole';
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center, modelId: hitModelId };
} else {
const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 14, 18);
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
snappedPoint = snap.point;
snapKind = snap.type;
if (snap.type === 'edge') {
const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 18);
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
if (seg) {
const lenMM = seg.a.distanceTo(seg.b) * 1000;
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } };
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, modelId: hitModelId, endpoints: { a: seg.a, b: seg.b } };
}
}
}
@@ -163,9 +207,27 @@ export function XRControllerMeasure() {
}
}
const strongSnapKind = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole' ? snapKind : null;
if (snappedPoint && strongSnapKind) {
const locked = lockedSnap.current;
const sameTarget = locked && locked.kind === strongSnapKind && locked.modelId === hitModelId;
if (sameTarget && locked.point.distanceTo(snappedPoint) < (snapKind === 'edge' ? 0.08 : 0.12)) {
if (snapKind === 'edge') locked.point.lerp(snappedPoint, 0.25);
snappedPoint = locked.point.clone();
locked.lastSeen = nowT;
} else {
lockedSnap.current = { point: snappedPoint.clone(), kind: strongSnapKind, modelId: hitModelId, lastSeen: nowT };
}
} else if (snappedPoint && lockedSnap.current && lockedSnap.current.point.distanceTo(snappedPoint) < 0.12 && nowT - lockedSnap.current.lastSeen < 600) {
snapKind = lockedSnap.current.kind;
snappedPoint = lockedSnap.current.point.clone();
lockedSnap.current.lastSeen = nowT;
} else if (!snappedPoint || (lockedSnap.current && nowT - lockedSnap.current.lastSeen > 600)) {
lockedSnap.current = null;
}
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
const measureModeNow = useModelStore.getState().measureMode;
const nowT = performance.now();
if (snappedPoint && hoverDetected) {
const dist = dwellPos.current.distanceTo(snappedPoint);
if (dist > 0.005) {
@@ -183,6 +245,7 @@ export function XRControllerMeasure() {
a: { x: hoverDetected.endpoints.a.x, y: hoverDetected.endpoints.a.y, z: hoverDetected.endpoints.a.z },
b: { x: hoverDetected.endpoints.b.x, y: hoverDetected.endpoints.b.y, z: hoverDetected.endpoints.b.z },
},
modelId: hoverDetected.modelId,
});
}
}
@@ -194,6 +257,7 @@ export function XRControllerMeasure() {
// ── Update laser visual ───────────────────────────────────────────
if (laserRef.current && tipRef.current) {
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
positions.setXYZ(1, end.x, end.y, end.z);
@@ -201,70 +265,76 @@ export function XRControllerMeasure() {
laserGeom.current.computeBoundingSphere();
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
tipColor.current.set(color);
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
laserRef.current.visible = true;
if (snappedPoint) {
const tipPos = snappedPoint;
if (tipPos) {
tipRef.current.visible = true;
tipRef.current.position.copy(snappedPoint);
// Scale tip with distance from controller (~12mm at 1m)
const dist = tmpOrigin.current.distanceTo(snappedPoint);
const s = Math.max(0.004, dist * 0.012);
tipRef.current.position.copy(tipPos);
const dist = tmpOrigin.current.distanceTo(tipPos);
const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
const factor = strongSnap ? 0.030 : 0.012;
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
tipRef.current.scale.setScalar(s);
} else {
tipRef.current.visible = false;
}
}
// ── Right trigger: add point (measure) OR toggle selection ────────
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
if (!trigState.current && trigVal > TRIG_ON) {
trigState.current = true;
const st = useModelStore.getState();
if (st.selectionMode && hit && hit.object instanceof THREE.Mesh) {
// Walk up to find ifcElement + modelId
let cur: THREE.Object3D | null = hit.object;
let element: THREE.Object3D | null = null;
let modelId: string | null = null;
while (cur) {
if (!element && cur.userData?.ifcElement) element = cur;
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
cur = cur.parent;
}
if (!element) element = hit.object;
if (!modelId) modelId = st.activeModelId;
if (modelId && element) {
const id = element.userData?.ifcId ?? element.name ?? element.uuid;
st.toggleElementSelection(`${modelId}:${id}`);
if (st.selectionMode) {
// Fresh raycast on the press itself (not every frame) for selection
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
const triggerHit = triggerHits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (!isPickableModelMesh(o)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false;
if (o.geometry instanceof THREE.PlaneGeometry) return false;
return true;
});
if (triggerHit && triggerHit.object instanceof THREE.Mesh) {
let cur: THREE.Object3D | null = triggerHit.object;
let element: THREE.Object3D | null = null;
let modelId: string | null = null;
while (cur) {
if (!element && cur.userData?.ifcElement) element = cur;
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
cur = cur.parent;
}
if (!element) element = triggerHit.object;
if (!modelId) modelId = st.activeModelId;
if (modelId && element) {
const id = element.userData?.ifcId ?? element.name ?? element.uuid;
st.toggleElementSelection(`${modelId}:${id}`);
}
}
} else if (snappedPoint) {
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
st.addMeasurePoint({
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
modelId: hitModelId,
});
}
} else if (trigState.current && trigVal < TRIG_OFF) {
trigState.current = false;
}
// ── Button A (undo) ──────────────────────────────────────────────
const aBtn = gp?.buttons?.[4];
const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0;
if (!aState.current && aVal > BTN_ON) {
aState.current = true;
useModelStore.getState().undoLastMeasurement();
} else if (aState.current && aVal < TRIG_OFF) {
aState.current = false;
}
// ── Button B (clear) ─────────────────────────────────────────────
const bBtn = gp?.buttons?.[5];
const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0;
if (!bState.current && bVal > BTN_ON) {
bState.current = true;
useModelStore.getState().clearMeasurements();
} else if (bState.current && bVal < TRIG_OFF) {
bState.current = false;
}
// A and B physical buttons are now handled globally in XRHudInWorld.tsx for the Summon Menu.
});
// Construct the laser Line object once so we can attach via <primitive>
+164 -45
View File
@@ -1,13 +1,15 @@
import { useRef, ReactNode, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { useXR } from '@react-three/xr';
import * as THREE from 'three';
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
import { useModelStore } from '@/stores/useModelStore';
import { activeModelGroupRef } from './xrCalibrationBus';
interface XRGrabbableProps {
/**
* @deprecated Mantido por compatibilidade — agora o zoom por duas mãos
* está SEMPRE ativo (faz parte do mapeamento padrão do grip).
* Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
* Quando false, o grip duplo só ORBITA — a escala da peça é preservada.
*/
allowScale?: boolean;
/** When true, the active model is locked — grab is ignored entirely */
@@ -21,6 +23,8 @@ interface SingleGrabRecord {
hand: 'left' | 'right';
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
offsetWorld: THREE.Vector3;
/** Altura Y de mundo da peça no momento em que o grab iniciou */
startWorldY: number;
}
interface DualGrabRecord {
@@ -42,18 +46,18 @@ const _tmpVecR = new THREE.Vector3();
const _tmpQuat = new THREE.Quaternion();
/**
* Touch Plus grip mapping (Meta Quest 3):
* Mapeamento Geral do Quest 3 para Manipulação do Modelo em AR:
*
* • One-hand grip → **PAN ONLY** (translação). A rotação e a escala
* da peça NÃO mudam — o grip simples só arrasta.
* • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme
* (distância entre os controles). O zoom está sempre ligado.
*
* Trigger não interfere aqui (reservado para seleção/medição).
* • Panorâmica (Pan) -> Segurar o botão Grip (lateral) em qualquer um dos controles
* e mover a mão na direção que deseja arrastar (movimentação livre em 3D).
* • Giro (Rotação) e Zoom (Escala) -> Apontar ambos os controles para a bounding box do modelo,
* segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou
* afastar/aproximar as mãos (zoom).
*/
export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
const groupRef = useRef<THREE.Group>(null);
const grab = useControllerGrab();
const session = useXR((s) => s.session);
const single = useRef<SingleGrabRecord | null>(null);
const dual = useRef<DualGrabRecord | null>(null);
@@ -70,10 +74,76 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
single.current = null;
}, [scaleResetNonce]);
useFrame(() => {
useEffect(() => {
const currentGroup = groupRef.current;
activeModelGroupRef.current = currentGroup;
return () => {
if (activeModelGroupRef.current === currentGroup) {
activeModelGroupRef.current = null;
}
};
});
const syncGrabToStore = () => {
const group = groupRef.current;
if (!group) return;
const snap: ControllerGrabSnapshot = grab.current;
const state = useModelStore.getState();
const activeModelId = state.activeModelId;
if (!activeModelId) return;
const activeModel = state.models.find(m => m.id === activeModelId);
if (!activeModel) return;
const ft = { ...activeModel.fineTuning };
const renderFactor = state.scaleRatio?.factor ?? 1;
// Model's matrix relative to XRGrabbable
const posModel = new THREE.Vector3(ft.posX * renderFactor, ft.posY * renderFactor, ft.posZ * renderFactor);
const rotModel = new THREE.Euler(
(ft.rotX * Math.PI) / 180,
(ft.rotY * Math.PI) / 180,
(ft.rotZ * Math.PI) / 180,
'YXZ'
);
const quatModel = new THREE.Quaternion().setFromEuler(rotModel);
const scaleModel = new THREE.Vector3(ft.scale * renderFactor, ft.scale * renderFactor, ft.scale * renderFactor);
const mModel = new THREE.Matrix4().compose(posModel, quatModel, scaleModel);
// Grab matrix of XRGrabbable group
const mGrab = new THREE.Matrix4().compose(group.position, group.quaternion, group.scale);
// Combined matrix
const mCombined = new THREE.Matrix4().multiplyMatrices(mGrab, mModel);
const finalPos = new THREE.Vector3();
const finalQuat = new THREE.Quaternion();
const finalScale = new THREE.Vector3();
mCombined.decompose(finalPos, finalQuat, finalScale);
// Reset grabbable group to identity so the transform is held entirely by fineTuning
group.position.set(0, 0, 0);
group.quaternion.identity();
group.scale.set(1, 1, 1);
// Save to store
const euler = new THREE.Euler().setFromQuaternion(finalQuat, 'YXZ');
state.setFineTuning({
posX: finalPos.x / renderFactor,
posY: finalPos.y / renderFactor,
posZ: finalPos.z / renderFactor,
rotX: (euler.x * 180) / Math.PI,
rotY: (euler.y * 180) / Math.PI,
rotZ: (euler.z * 180) / Math.PI,
scale: finalScale.x / renderFactor,
});
};
useFrame((_state, dt) => {
const group = groupRef.current;
if (!group) return;
const snap = grab.current;
const L = snap.left;
const R = snap.right;
@@ -87,7 +157,7 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
const lActive = L.isGrabbing && L.hasPose;
const rActive = R.isGrabbing && R.hasPose;
// ─── Halo intensity (visual feedback for analog grip) ────────
// Visual feedback para a pressão analógica do grip (halo)
if (haloRef.current) {
const maxGrip = Math.max(L.gripValue, R.gripValue);
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
@@ -95,7 +165,7 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
haloRef.current.visible = maxGrip > 0.05;
}
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
// ─── Modo de Duas Mãos: Giro (Órbita) + Zoom ────────────────────
if (lActive && rActive) {
_tmpVecL.setFromMatrixPosition(L.gripWorld);
_tmpVecR.setFromMatrixPosition(R.gripWorld);
@@ -107,42 +177,72 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
if (!dual.current) {
group.updateMatrixWorld();
dual.current = {
startGroupWorld: group.matrixWorld.clone(),
startMid: midNow.clone(),
startAxis: axisNow.clone(),
startDist: distNow,
startScale: group.scale.x,
// Se o usuário já estava segurando a peça com uma das mãos, transita para o modo dual imediatamente.
// Caso contrário (peça solta), exige que ambos os controles apontem para a bounding box da peça.
const wasSingleGrabbing = !!single.current;
const box = new THREE.Box3().setFromObject(group);
const checkPointerCollision = (slot: typeof L) => {
const origin = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
const q = new THREE.Quaternion().setFromRotationMatrix(slot.gripWorld);
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize();
const ray = new THREE.Ray(origin, dir);
return ray.intersectsBox(box);
};
single.current = null;
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
if (wasSingleGrabbing || (checkPointerCollision(L) && checkPointerCollision(R))) {
dual.current = {
startGroupWorld: group.matrixWorld.clone(),
startMid: midNow.clone(),
startAxis: axisNow.clone(),
startDist: distNow,
startScale: group.scale.x,
};
single.current = null;
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom) - ' + (wasSingleGrabbing ? 'from active single grab' : 'bounding box hit'));
} else {
// Se não apontavam para o modelo, ignora e permite o pan de uma mão (da primeira mão a engajar)
return;
}
}
const d = dual.current;
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
// ZOOM sempre ativo no grip duplo
const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10);
if (dual.current) {
const d = dual.current;
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
// ZOOM: afasta as mãos para zoom in, aproxima para zoom out
const scaleRatio = allowScale
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
: 1;
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
const m = new THREE.Matrix4().identity()
.multiply(tBack)
.multiply(rot)
.multiply(scl)
.multiply(tToOrigin)
.multiply(d.startGroupWorld);
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
const m = new THREE.Matrix4().identity()
.multiply(tBack)
.multiply(rot)
.multiply(scl)
.multiply(tToOrigin)
.multiply(d.startGroupWorld);
applyWorldMatrixToLocal(group, m);
return;
applyWorldMatrixToLocal(group, m);
return;
}
} else if (dual.current) {
console.log('[XR][grab] ◆ TWO-HAND end');
dual.current = null;
// Se a outra mão ainda estiver segurando, NÃO sincronizamos/resetamos o group local ainda.
// A transição de duas mãos para uma mão herdará o groupWorldPos correto de forma contínua.
const activeHand = lActive ? 'left' : rActive ? 'right' : null;
if (!activeHand) {
syncGrabToStore();
}
}
// ─── One-hand mode (PAN ONLY) ────────────────────────────────
// ─── Modo de Uma Mão: Panorâmica Livre (Arrastar em 3D) ────────
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
if (activeHand) {
const slot = activeHand === 'left' ? L : R;
@@ -152,15 +252,33 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
group.updateMatrixWorld();
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
single.current = { hand: activeHand, offsetWorld };
single.current = { hand: activeHand, offsetWorld, startWorldY: groupWorldPos.y };
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — free 3D pan`);
}
// Target world position = controller + initial offset
// Função particular improvisada: rotacionar com o analógico horizontal durante o pan
const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null;
const gp = inputSource?.gamepad;
if (gp && gp.axes.length >= 4) {
const stickX = gp.axes[2]; // thumbstick horizontal
const deadzone = 0.15;
if (Math.abs(stickX) > deadzone) {
const rotationSpeed = 1.5;
const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt;
const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle);
group.quaternion.multiplyQuaternions(q, group.quaternion);
// Gira o offset em torno da mão
single.current.offsetWorld.applyQuaternion(q);
}
}
// Calcula a nova posição no espaço de mundo (movimentação 3D livre)
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
// Convert to parent-local position (preserve current rotation/scale)
// Converte para coordenadas locais do pai
if (group.parent) {
group.parent.updateMatrixWorld();
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
@@ -168,7 +286,9 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
}
group.position.copy(targetWorldPos);
} else if (single.current) {
console.log('[XR][grab] ● ONE-HAND end');
single.current = null;
syncGrabToStore();
}
});
@@ -183,7 +303,6 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
);
}
/** Convert a desired world matrix to the group's local matrix (given its parent). */
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
if (group.parent) {
group.parent.updateMatrixWorld();
+511 -51
View File
@@ -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 { useXRInputSourceState } from '@react-three/xr';
import { useXRInputSourceState, useXR } from '@react-three/xr';
import { useNavigate } from 'react-router-dom';
import * as THREE from 'three';
import { Text } from '@react-three/drei';
import { XR3DButton, XR3DPanel } from './XRPanel3D';
@@ -10,8 +11,17 @@ import {
useCanvasRecorder, startRecording, pauseRecording, resumeRecording,
stopRecording, captureScreenshot, formatElapsed,
} from '@/hooks/useCanvasRecorder';
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
import { toast } from 'sonner';
import {
xrCalibration,
startXRCalibration,
cancelXRCalibration,
subscribeXRCalibration,
} from './xrCalibrationBus';
import { XRViewCube } from './XRViewCube';
type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr';
type Tab = 'scene' | 'tools' | 'calib' | 'cuts' | 'inspection' | 'share' | 'capture' | 'webxr';
interface XRHudInWorldProps {
freeMove: boolean;
@@ -56,7 +66,11 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
const { camera } = useThree();
const leftCtrl = useXRInputSourceState('controller', 'left');
const rightCtrl = useXRInputSourceState('controller', 'right');
const xrSession = useXR((s) => s.session);
const navigate = useNavigate();
const [open, setOpen] = useState(true);
/** Visibility of the small head-locked floating button group (toggled by X button). */
const [hudVisible, setHudVisible] = useState(true);
const [tab, setTab] = useState<Tab>('scene');
/** When pinned, the panel stays fixed in world space and does NOT follow the head. */
const [pinned, setPinned] = useState(true);
@@ -70,20 +84,72 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
const headLockRef = useRef<THREE.Group>(null);
const targetPos = useRef(new THREE.Vector3());
const targetQuat = useRef(new THREE.Quaternion());
const lastABtn = useRef(false);
const lastButtonsState = useRef({
left4: false, // X
left5: false, // Y
left3: false, // Menu (☰)
right4: false, // A
right5: false, // B
});
/** Cached offset from controller to panel at drag start. */
const dragOffsetPos = useRef(new THREE.Vector3());
const dragOffsetQuat = useRef(new THREE.Quaternion());
const dragInitialized = useRef(false);
useFrame(() => {
// Toggle via left A button (Quest Touch buttons[4])
const gp = leftCtrl?.inputSource?.gamepad;
if (gp) {
const aBtn = gp.buttons[4];
const pressed = !!aBtn?.pressed;
if (pressed && !lastABtn.current) setOpen((v) => !v);
lastABtn.current = pressed;
// Escuta botões A, B, X, Y e Menu do esquerdo
const gpLeft = leftCtrl?.inputSource?.gamepad;
const gpRight = rightCtrl?.inputSource?.gamepad;
let xPressed = false;
let yPressed = false;
let menuPressed = false;
let aPressed = false;
let bPressed = false;
if (gpLeft) {
// Esquerdo: Menu (3), X (4), Y (5)
const btnMenu = gpLeft.buttons[3];
const btnX = gpLeft.buttons[4];
const btnY = gpLeft.buttons[5];
if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true;
if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true;
if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true;
lastButtonsState.current.left4 = !!btnX?.pressed;
lastButtonsState.current.left5 = !!btnY?.pressed;
lastButtonsState.current.left3 = !!btnMenu?.pressed;
}
if (gpRight) {
// Direito: A (4), B (5)
const btnA = gpRight.buttons[4];
const btnB = gpRight.buttons[5];
if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true;
if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true;
lastButtonsState.current.right4 = !!btnA?.pressed;
lastButtonsState.current.right5 = !!btnB?.pressed;
}
// Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR)
if (xPressed && open) {
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
setTimeout(() => navigate('/viewer'), 50);
toast.success("Projeto fechado!");
} else if (aPressed || bPressed || xPressed || yPressed) {
// Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu
setOpen(true);
reanchorRequested.current = true;
}
// Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente
if (menuPressed) {
setOpen(true);
setTab('tools');
reanchorRequested.current = true;
}
// Floating panel:
@@ -166,15 +232,25 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
return (
<>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{/* Head-locked fallback: always-visible quick controls in lower-left of FOV */}
<group ref={headLockRef} renderOrder={999}>
<XR3DButton position={[0, 0.018, 0]} size={[0.07, 0.022]}
{/* Head-locked fallback: floating quick-controls in lower-left of FOV.
Toggled on/off by the left controller X button. */}
<group ref={headLockRef} renderOrder={999} visible={hudVisible}>
<XR3DButton position={[0, 0.030, 0]} size={[0.07, 0.022]}
label={open ? '✕ Menu' : '☰ Menu'} active={open}
onClick={() => { setOpen((v) => !v); reanchorRequested.current = true; }}
fontSize={0.0085} />
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]}
<XR3DButton position={[0, 0.004, 0]} size={[0.07, 0.022]}
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
<XR3DButton position={[0, -0.022, 0]} size={[0.07, 0.022]}
label="⏏ Sair AR"
onClick={() => {
// End the XR session while preserving placement/state in the store,
// then return to the Viewer. Re-entering AR restores the same state.
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
setTimeout(() => navigate('/viewer'), 50);
}}
fontSize={0.0085} />
<RecIndicator />
</group>
{open && (
@@ -209,17 +285,19 @@ function FloatingPanel({
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: 'scene', label: 'Cena', icon: '🧩' },
{ id: 'tools', label: 'Ferram.', icon: '🛠' },
{ id: 'calib', label: 'Calibrar', icon: '🎯' },
{ id: 'cuts', label: 'Cortes', icon: '✂' },
{ id: 'inspection', label: 'Inspeção', icon: '✓' },
{ id: 'capture', label: 'Captura', icon: '🎥' },
{ id: 'share', label: 'Compart.', icon: '📡' },
{ id: 'webxr', label: 'WebXR', icon: '⚙' },
];
const W = 0.5, H = 0.36;
const W = 0.54, H = 0.38;
return (
<XR3DPanel size={[W, H]}>
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
anchorX="left" anchorY="middle">
TrackSteelXR · HUD AR
SteelXR · HUD AR
</Text>
{/* Pin / Drag / Recenter — anchored to top-right, kept inside the panel.
Group origin is at the right edge with margin; buttons extend leftward. */}
@@ -240,15 +318,17 @@ function FloatingPanel({
<group position={[0, H / 2 - 0.045, 0.001]}>
{tabs.map((t, i) => (
<XR3DButton key={t.id}
position={[-W / 2 + 0.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
position={[-W / 2 + 0.0495 + i * 0.063, 0, 0]} size={[0.058, 0.020]}
label={t.label} icon={t.icon} active={tab === t.id}
onClick={() => setTab(t.id)} fontSize={0.0072} />
onClick={() => setTab(t.id)} fontSize={0.0062} />
))}
</group>
<group position={[0, -0.012, 0.001]}>
<group position={[0, -0.018, 0.001]}>
{tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />}
{tab === 'calib' && <CalibrationTab />}
{tab === 'cuts' && <CutsTab />}
{tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />}
{tab === 'share' && <ShareTab {...p} />}
@@ -258,6 +338,164 @@ function FloatingPanel({
);
}
interface AxisRange { min: number; max: number; }
type Axis = 'x' | 'y' | 'z';
function computeBoundsByAxis(): Record<Axis, AxisRange> | null {
const groups = getAllModelLocalGroups();
if (groups.length === 0) return null;
const box = new THREE.Box3();
let has = false;
for (const g of groups) {
g.updateWorldMatrix(true, true);
const b = new THREE.Box3().setFromObject(g);
if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) {
if (!has) { box.copy(b); has = true; } else box.union(b);
}
}
if (!has) return null;
return {
x: { min: box.min.x, max: box.max.x },
y: { min: box.min.y, max: box.max.y },
z: { min: box.min.z, max: box.max.z },
};
}
const AXIS_COLOR: Record<Axis, string> = {
x: '#ef4444',
y: '#10b981',
z: '#0ea5e9',
};
const AXIS_LABEL: Record<Axis, string> = { x: 'X', y: 'Y', z: 'Z' };
function CutsTab() {
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const setSectionEnabled = useModelStore((s) => s.setSectionEnabled);
const setSectionInvert = useModelStore((s) => s.setSectionInvert);
const setSectionLevel = useModelStore((s) => s.setSectionLevel);
const activeModelId = useModelStore((s) => s.activeModelId);
const [bounds, setBounds] = useState<Record<Axis, AxisRange> | null>(null);
const [step, setStep] = useState<number>(0.01); // 10mm por padrão
useEffect(() => {
setBounds(computeBoundsByAxis());
}, [activeModelId]);
const handleStepChange = (sVal: number) => {
setStep(sVal);
};
const handleAdjust = (axis: Axis, dir: 1 | -1) => {
const range = bounds?.[axis];
if (!range) return;
const currentVal = level[axis];
const newVal = Math.max(range.min, Math.min(range.max, currentVal + dir * step));
setSectionLevel(axis, newVal);
};
const handleSetPreset = (axis: Axis, type: 'min' | 'center' | 'max') => {
const range = bounds?.[axis];
if (!range) return;
if (type === 'min') {
setSectionLevel(axis, range.min);
} else if (type === 'center') {
setSectionLevel(axis, (range.min + range.max) / 2);
} else if (type === 'max') {
setSectionLevel(axis, range.max);
}
};
const handleRecalculate = () => {
setBounds(computeBoundsByAxis());
};
return (
<group>
{/* Cabeçalho / Passo */}
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" anchorY="middle">
Passo do Ajuste:
</Text>
<XR3DButton position={[-0.09, 0.115, 0]} size={[0.048, 0.02]}
label="1 mm" active={step === 0.001} onClick={() => handleStepChange(0.001)} fontSize={0.007} />
<XR3DButton position={[-0.038, 0.115, 0]} size={[0.048, 0.02]}
label="10 mm" active={step === 0.01} onClick={() => handleStepChange(0.01)} fontSize={0.007} />
<XR3DButton position={[0.014, 0.115, 0]} size={[0.048, 0.02]}
label="50 mm" active={step === 0.05} onClick={() => handleStepChange(0.05)} fontSize={0.007} />
<XR3DButton position={[0.165, 0.115, 0]} size={[0.12, 0.02]}
label="↺ Recalcular Limites" active={false} onClick={handleRecalculate} fontSize={0.007} />
{/* Controles dos Eixos */}
{(['x', 'y', 'z'] as Axis[]).map((axis, idx) => {
const yOffset = 0.055 - idx * 0.055;
const range = bounds?.[axis];
const minMM = range ? range.min * 1000 : 0;
const maxMM = range ? range.max * 1000 : 100;
const valMM = level[axis] * 1000;
const isAxisEnabled = enabled[axis];
const isAxisInverted = invert[axis];
const isAxisDisabled = !range;
return (
<group key={axis} position={[0, yOffset, 0]}>
{/* Eixo label */}
<Text position={[-0.24, 0, 0]} fontSize={0.014} color={AXIS_COLOR[axis]} anchorX="left" anchorY="middle">
{AXIS_LABEL[axis]}
</Text>
{/* Toggle Cortar */}
<XR3DButton position={[-0.19, 0, 0]} size={[0.045, 0.022]}
label={isAxisEnabled ? "ON" : "OFF"} active={isAxisEnabled}
disabled={isAxisDisabled}
onClick={() => setSectionEnabled(axis, !isAxisEnabled)}
fontSize={0.0075} />
{/* Toggle Inverter */}
<XR3DButton position={[-0.135, 0, 0]} size={[0.052, 0.022]}
label={isAxisInverted ? "Invertido" : "Inverter"} active={isAxisInverted}
disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => setSectionInvert(axis, !isAxisInverted)}
fontSize={0.007} />
{/* Valor */}
<Text position={[-0.09, 0, 0]} fontSize={0.0085} color={isAxisEnabled ? "#ffffff" : "#64748b"} anchorX="left" anchorY="middle">
{isAxisDisabled ? "sem peça" : `${valMM.toFixed(1)} mm`}
</Text>
{/* Menos/Mais */}
<XR3DButton position={[0.01, 0, 0]} size={[0.024, 0.022]}
label="" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleAdjust(axis, -1)}
fontSize={0.01} />
<XR3DButton position={[0.038, 0, 0]} size={[0.024, 0.022]}
label="+" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleAdjust(axis, 1)}
fontSize={0.01} />
{/* Presets */}
<XR3DButton position={[0.076, 0, 0]} size={[0.034, 0.022]}
label="Min" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'min')}
fontSize={0.007} />
<XR3DButton position={[0.118, 0, 0]} size={[0.042, 0.022]}
label="Centro" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'center')}
fontSize={0.007} />
<XR3DButton position={[0.162, 0, 0]} size={[0.036, 0.022]}
label="Max" disabled={isAxisDisabled || !isAxisEnabled}
onClick={() => handleSetPreset(axis, 'max')}
fontSize={0.007} />
</group>
);
})}
</group>
);
}
function SceneTab() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
@@ -328,10 +566,20 @@ function ToolsTab(p: XRHudInWorldProps) {
const setShowGrid = useModelStore((s) => s.setShowGrid);
const measureMode = useModelStore((s) => s.measureMode);
const setMeasureMode = useModelStore((s) => s.setMeasureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
const setSelectionMode = useModelStore((s) => s.setSelectionMode);
const selectedCount = useModelStore((s) => s.selectedElementKeys.size);
const hideSelectedElements = useModelStore((s) => s.hideSelectedElements);
const isolateSelectedElements = useModelStore((s) => s.isolateSelectedElements);
const showAllElements = useModelStore((s) => s.showAllElements);
const hasHidden = useModelStore((s) =>
s.hiddenElementKeys.size > 0 || s.isolatedElementKeys !== null
);
const scaleRatio = useModelStore((s) => s.scaleRatio);
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
const resetScale = useModelStore((s) => s.resetScale);
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement);
return (
<group>
@@ -350,57 +598,67 @@ function ToolsTab(p: XRHudInWorldProps) {
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
active={p.snapToPlanes} onClick={p.onToggleSnap} />
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Escala ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<Text position={[-0.24, -0.02, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
{/* Selecionar — paridade com a tela inicial */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} />
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} />
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="25%"
<XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="5%"
<XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
<XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
<XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
<XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.085, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
<Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label}
</Text>
{SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
return (
<XR3DButton key={lbl}
position={[-0.16 + i * 0.06, -0.11, 0]} size={[0.055, 0.022]} label={lbl}
position={[-0.16 + i * 0.054, -0.14, 0]} size={[0.05, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
{/* Reset Escala — devolve a peça ao tamanho 1:1 (cancela qualquer
escala manual feita com duas mãos). */}
<XR3DButton position={[0.10, -0.11, 0]} size={[0.062, 0.022]}
<XR3DButton position={[0.076, -0.14, 0]} size={[0.046, 0.022]}
label="↺ Reset" color="#dc2626"
onClick={resetScale} fontSize={0.0085} />
{/* Limpa todos os labels de medição. */}
<XR3DButton position={[0.17, -0.11, 0]} size={[0.062, 0.022]}
label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} />
onClick={resetScale} fontSize={0.0075} />
<XR3DButton position={[0.126, -0.14, 0]} size={[0.05, 0.022]}
label="⎌ Desfaz" color="#3b82f6"
onClick={undoLastMeasurement} fontSize={0.0075} />
<XR3DButton position={[0.18, -0.14, 0]} size={[0.05, 0.022]}
label="✕ Limpa" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0075} />
{/* Legenda dos controles para Medir */}
{measureMode && (
<Text position={[-0.24, -0.145, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu
</Text>
)}
{/* Grid floor controls */}
<GridFloorRow />
</group>
);
@@ -413,11 +671,11 @@ function GridFloorRow() {
const setGridAutoFollow = useModelStore((s) => s.setGridAutoFollow);
const setGridY = useModelStore((s) => s.setGridY);
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
const placeAtFloor = () => {
// Headset eye Y minus avg standing eye height (1.6 m)
const floor = camera.position.y - 1.6;
setGridY(floor);
};
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
const setGridCalibMode = useModelStore((s) => s.setGridCalibMode);
const gridLandingMode = useModelStore((s) => s.gridLandingMode);
const setGridLandingMode = useModelStore((s) => s.setGridLandingMode);
return (
<group>
<Text position={[-0.24, -0.15, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
@@ -431,8 +689,10 @@ function GridFloorRow() {
onClick={() => nudgeGridY(-0.01)} />
<XR3DButton position={[0.005, -0.175, 0]} size={[0.04, 0.022]} label="+1cm"
onClick={() => nudgeGridY(0.01)} />
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label="Pousar chão"
onClick={placeAtFloor} />
<XR3DButton position={[0.07, -0.175, 0]} size={[0.075, 0.022]} label={gridLandingMode ? "Pousando" : "Pousar"}
active={gridLandingMode} onClick={() => setGridLandingMode(!gridLandingMode)} />
<XR3DButton position={[0.155, -0.175, 0]} size={[0.075, 0.022]} label={gridCalibMode ? "Calibrando" : "Calibrar"}
active={gridCalibMode} onClick={() => setGridCalibMode(!gridCalibMode)} />
</group>
);
}
@@ -687,3 +947,203 @@ function RecIndicator() {
</group>
);
}
function CalibrationTab() {
const activeModelId = useModelStore((s) => s.activeModelId);
const models = useModelStore((s) => s.models);
const setCalibrationStore = useModelStore((s) => s.setCalibration);
const active = models.find((m) => m.id === activeModelId);
// Escuta o barramento de calibração do XR
const [, force] = useState(0);
useEffect(() => subscribeXRCalibration(() => force((t) => t + 1)), []);
const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
const isDone = xrCalibration.step === 'done';
const isCubeMode = xrCalibration.alignType === 'cube';
const isVirtRealMode = xrCalibration.alignType === 'virt-real';
const onClickCube = () => {
if (isCalibrating && isCubeMode) {
cancelXRCalibration();
return;
}
if (isDone && isCubeMode) {
cancelXRCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id, 'cube');
};
const onClickVirtReal = () => {
if (isCalibrating && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (isDone && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id, 'virt-real');
};
const onResetCalibration = () => {
if (!active) return;
setCalibrationStore(active.id, null);
toast.success('Calibração removida');
};
const STEP_HINTS_AR: Record<string, string> = {
'await-cube-1': '1/3 - Clique numa face do Cubo à direita',
'await-model-1': '1/3 - Aponte e clique na face correspondente da peça',
'await-cube-2': '2/3 - Clique em outra face do Cubo',
'await-model-2': '2/3 - Clique na face correspondente da peça',
'await-cube-3': '3/3 (Verificar) - Clique em uma 3ª face do Cubo (opcional)',
'await-model-3': '3/3 - Clique na face correspondente da peça',
'await-real-1': '1/3 Real - Clique na 1ª superfície da peça REAL',
'await-virtual-1': '1/3 Virtual - Clique na 1ª correspondente da peça VIRTUAL',
'await-real-2': '2/3 Real - Clique na 2ª superfície da peça REAL',
'await-virtual-2': '2/3 Virtual - Clique na 2ª correspondente da peça VIRTUAL',
'await-real-3': '3/3 Real - Clique na 3ª superfície da peça REAL',
'await-virtual-3': '3/3 Virtual - Clique na 3ª correspondente da peça VIRTUAL',
'done': 'Calibração concluída com sucesso!',
};
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Selecione "Cubo" ou "Virt/Real" para calibrar o modelo';
// Configurações dinâmicas de estados de botão
let cubeLabel = 'Cubo';
let cubeActive = false;
let cubeColor = '#3b82f6';
let vrLabel = 'Virt/Real';
let vrActive = false;
let vrColor = '#3b82f6';
if (isCalibrating) {
if (isCubeMode) {
cubeLabel = 'Cancelar';
cubeActive = true;
cubeColor = '#d97706';
} else {
cubeColor = '#475569';
}
if (isVirtRealMode) {
vrLabel = 'Cancelar';
vrActive = true;
vrColor = '#d97706';
} else {
vrColor = '#475569';
}
} else if (isDone) {
if (isCubeMode) {
cubeLabel = 'Concluído';
cubeActive = true;
cubeColor = '#22c55e';
}
if (isVirtRealMode) {
vrLabel = 'Concluído';
vrActive = true;
vrColor = '#22c55e';
}
}
return (
<group>
{/* Coluna da esquerda: Informações e Fluxo */}
<Text position={[-0.24, 0.11, 0]} fontSize={0.012} color="#3b82f6" anchorX="left" anchorY="middle">
Calibração 3D da Peça
</Text>
<Text position={[-0.24, 0.07, 0]} fontSize={0.008} color="#cbd5e1" anchorX="left" anchorY="top" maxWidth={0.28}>
Alinhe o modelo virtual 3D com a peça real usando o Cubo ou clique em 3 pontos de controle ("Virt/Real").
</Text>
{/* Caixa de status do passo atual */}
<group position={[-0.24, 0.00, 0]}>
<mesh position={[0.13, 0, -0.0005]}>
<planeGeometry args={[0.26, 0.05]} />
<meshBasicMaterial color="#111827" transparent opacity={0.65} />
</mesh>
<Text position={[0.01, 0, 0.001]} fontSize={0.008} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
{currentHint}
</Text>
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && isCubeMode && (
<Text position={[0.01, -0.016, 0.001]} fontSize={0.007} color="#22c55e" anchorX="left" anchorY="middle">
Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}°
</Text>
)}
</group>
{/* Botões na parte inferior esquerda */}
<group position={[-0.24, -0.06, 0]}>
<XR3DButton
position={[0.045, 0, 0]}
size={[0.08, 0.026]}
label={cubeLabel}
active={cubeActive}
color={cubeColor}
onClick={onClickCube}
fontSize={0.008}
/>
<XR3DButton
position={[0.135, 0, 0]}
size={[0.08, 0.026]}
label={vrLabel}
active={vrActive}
color={vrColor}
onClick={onClickVirtReal}
fontSize={0.008}
/>
{active?.calibrationQuat && !isCalibrating && (
<XR3DButton
position={[0.225, 0, 0]}
size={[0.08, 0.026]}
label="Limpar"
color="#dc2626"
onClick={onResetCalibration}
fontSize={0.008}
/>
)}
</group>
<Text position={[-0.24, -0.11, 0]} fontSize={0.007} color="#64748b" anchorX="left" maxWidth={0.28}>
{active ? `Peça ativa: ${active.fileName}` : 'Selecione uma peça na aba "Cena"'}
</Text>
{/* Coluna da direita: Cubo de views 3D interativo */}
<group position={[0.14, -0.01, 0.04]}>
<XRViewCube activeModelId={activeModelId} size={0.075} />
{/* Tripé dos eixos abaixo do cubo */}
<group position={[0, -0.06, -0.04]} scale={[0.035, 0.035, 0.035]}>
<arrowHelper args={[new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), 0.7, 0xff4444, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0), 0.7, 0x44ff66, 0.18, 0.12]} />
<arrowHelper args={[new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0), 0.7, 0x4488ff, 0.18, 0.12]} />
</group>
<Text position={[0, 0.065, -0.04]} fontSize={0.008} color="#cbd5e1" anchorX="center" anchorY="middle">
Cubo de Calibração
</Text>
</group>
</group>
);
}
+125
View File
@@ -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>
);
}
+226
View File
@@ -0,0 +1,226 @@
import * as THREE from 'three';
/**
* Shared bus between the main 3D viewer and the floating ViewCube widget.
* - mainCameraRef / mainControlsRef: written by ModelViewer each frame
* - viewAnim: smooth camera animation when a face is clicked
* - calibration: orchestrates a 4-click (+ optional 2-click verify) procedure
* to align the active model's intrinsic axes to the world axes.
*/
export const mainCameraRef: { current: THREE.Camera | null } = { current: null };
export const mainControlsRef: { current: any | null } = { current: null };
// ── View animation ────────────────────────────────────────────────
export interface ViewAnim {
active: boolean;
startPos: THREE.Vector3;
endPos: THREE.Vector3;
startUp: THREE.Vector3;
endUp: THREE.Vector3;
t: number;
duration: number;
}
export const viewAnim: ViewAnim = {
active: false,
startPos: new THREE.Vector3(),
endPos: new THREE.Vector3(),
startUp: new THREE.Vector3(0, 1, 0),
endUp: new THREE.Vector3(0, 1, 0),
t: 0,
duration: 0.5,
};
export function requestView(dir: THREE.Vector3) {
const cam = mainCameraRef.current;
const controls = mainControlsRef.current;
if (!cam || !controls) return;
const target: THREE.Vector3 = controls.target ?? new THREE.Vector3();
const distance = cam.position.distanceTo(target);
const d = dir.clone().normalize();
const endPos = target.clone().add(d.multiplyScalar(distance));
const up = new THREE.Vector3(0, 1, 0);
if (Math.abs(dir.y) > 0.99) {
up.set(0, 0, dir.y > 0 ? -1 : 1);
}
viewAnim.startPos.copy(cam.position);
viewAnim.endPos.copy(endPos);
viewAnim.startUp.copy(cam.up);
viewAnim.endUp.copy(up);
viewAnim.t = 0;
viewAnim.active = true;
}
// ── Calibration state machine ─────────────────────────────────────
export type CalibrationStep =
| 'idle'
| 'await-cube-1'
| 'await-model-1'
| 'await-cube-2'
| 'await-model-2'
| 'await-cube-3' // optional verification
| 'await-model-3'
| 'done';
interface Pair { cube: THREE.Vector3; model: THREE.Vector3 }
interface CalState {
step: CalibrationStep;
modelId: string | null;
pairs: Pair[];
pendingCube: THREE.Vector3 | null;
/** 0..1 progress for the button visual. */
progress: number;
/** Last verification angular error, in degrees. NaN until verify pair captured. */
verifyErrorDeg: number;
/** Listener notified on every state change so the UI re-renders. */
listeners: Set<() => void>;
}
export const calibration: CalState = {
step: 'idle',
modelId: null,
pairs: [],
pendingCube: null,
progress: 0,
verifyErrorDeg: NaN,
listeners: new Set(),
};
function notify() { calibration.listeners.forEach(fn => { try { fn(); } catch {} }); }
export function subscribeCalibration(fn: () => void): () => void {
calibration.listeners.add(fn);
return () => { calibration.listeners.delete(fn); };
}
export function startCalibration(modelId: string) {
calibration.step = 'await-cube-1';
calibration.modelId = modelId;
calibration.pairs = [];
calibration.pendingCube = null;
calibration.progress = 0;
calibration.verifyErrorDeg = NaN;
notify();
}
export function cancelCalibration() {
calibration.step = 'idle';
calibration.modelId = null;
calibration.pairs = [];
calibration.pendingCube = null;
calibration.progress = 0;
calibration.verifyErrorDeg = NaN;
notify();
}
/** Called when user clicks a cube face during calibration. */
export function pushCubeFace(dirWorld: THREE.Vector3) {
if (calibration.step === 'await-cube-1') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-1';
} else if (calibration.step === 'await-cube-2') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-2';
} else if (calibration.step === 'await-cube-3') {
calibration.pendingCube = dirWorld.clone().normalize();
calibration.step = 'await-model-3';
} else {
return;
}
// Auto-rotate the main camera to look straight at the picked cube face
// so the user sees the corresponding model face head-on (especially helpful
// in orthographic mode where this guarantees a perpendicular click).
try { requestView(dirWorld.clone()); } catch {}
notify();
}
/** Snap a unit vector to the nearest principal axis (±X/±Y/±Z) when within
* `maxDeg` degrees of it. Returns the snapped vector or the input unchanged. */
function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 {
const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg));
const axes = [
new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0,-1),
];
let best = v;
let bestDot = cosT;
for (const a of axes) {
const d = v.dot(a);
if (d > bestDot) { bestDot = d; best = a.clone(); }
}
return best;
}
/**
* Called when user clicks a model face during calibration.
* @param normalWorld world-space face normal of the clicked face
* @param calGroupWorldQuat current world quaternion of the calibration group
* (used to map both cube and model directions into the same local frame).
*/
export function pushModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQuat: THREE.Quaternion) {
if (!calibration.pendingCube) return;
const cubeWorld = calibration.pendingCube;
const inv = calGroupWorldQuat.clone().invert();
const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize();
const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize();
// Snap near-axial picks (chamfered or slightly off faces) to the closest
// principal axis. This dramatically improves robustness when picking faces
// from an orthographic head-on view.
const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12);
calibration.pairs.push({ cube: cubeLocal, model: modelLocal });
calibration.pendingCube = null;
if (calibration.step === 'await-model-1') {
calibration.step = 'await-cube-2';
calibration.progress = 0.33;
} else if (calibration.step === 'await-model-2') {
calibration.progress = 0.75;
calibration.step = 'await-cube-3';
} else if (calibration.step === 'await-model-3') {
calibration.progress = 1;
calibration.verifyErrorDeg = computeVerifyError(calibration.pairs);
calibration.step = 'done';
}
notify();
}
/** Computes a quaternion R such that R*pairs[i].model ≈ pairs[i].cube using
* Gram-Schmidt to ortho-normalize both bases. Returns null if degenerate. */
export function computeCalibrationQuaternion(pairs: Pair[]): THREE.Quaternion | null {
if (pairs.length < 2) return null;
const n1 = pairs[0].model.clone().normalize();
const n2raw = pairs[1].model.clone();
let n2 = n2raw.sub(n1.clone().multiplyScalar(n2raw.dot(n1)));
if (n2.lengthSq() < 1e-6) return null;
n2.normalize();
const n3 = new THREE.Vector3().crossVectors(n1, n2);
const c1 = pairs[0].cube.clone().normalize();
const c2raw = pairs[1].cube.clone();
let c2 = c2raw.sub(c1.clone().multiplyScalar(c2raw.dot(c1)));
if (c2.lengthSq() < 1e-6) return null;
c2.normalize();
const c3 = new THREE.Vector3().crossVectors(c1, c2);
// R = C * Nᵀ where columns of N are (n1,n2,n3) and C are (c1,c2,c3).
// In three.js Matrix4: makeBasis sets columns.
const N = new THREE.Matrix4().makeBasis(n1, n2, n3);
const C = new THREE.Matrix4().makeBasis(c1, c2, c3);
const Nt = N.clone().transpose();
const R = new THREE.Matrix4().multiplyMatrices(C, Nt);
const q = new THREE.Quaternion().setFromRotationMatrix(R);
return q;
}
/** Predicted angular error of the verify pair, in degrees. */
export function computeVerifyError(pairs: Pair[]): number {
if (pairs.length < 3) return NaN;
const q = computeCalibrationQuaternion(pairs.slice(0, 2));
if (!q) return NaN;
const predicted = pairs[2].model.clone().applyQuaternion(q).normalize();
const target = pairs[2].cube.clone().normalize();
const cos = THREE.MathUtils.clamp(predicted.dot(target), -1, 1);
return THREE.MathUtils.radToDeg(Math.acos(cos));
}
+274
View File
@@ -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 };
+2 -2
View File
@@ -75,7 +75,7 @@ export function startRecording() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -123,7 +123,7 @@ export function captureScreenshot(): string | null {
// Trigger download
const a = document.createElement('a');
a.href = dataUrl;
a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.download = `steelxr-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
+5
View File
@@ -3,6 +3,7 @@ import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
import { useModelStore } from '@/stores/useModelStore';
export interface GrabState {
/** Analog grip pressure (0..1) */
@@ -108,6 +109,10 @@ export function useControllerGrab() {
const pose = frame.getPose(source.gripSpace, referenceSpace);
if (pose) {
slot.gripWorld.fromArray(pose.transform.matrix);
const xrRig = useModelStore.getState().xrRig;
if (xrRig) {
slot.gripWorld.premultiply(xrRig.matrixWorld);
}
slot.hasPose = true;
}
}
+167 -8
View File
@@ -7,10 +7,54 @@ const WASM_PATH = 'https://unpkg.com/web-ifc@0.0.57/';
/**
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
*/
export async function convertIFCtoGLB(
buffer: ArrayBuffer,
fileName: string
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
function getMaterialName(ifcApi: WebIFC.IfcAPI, modelID: number, matRef: any): string | null {
if (!matRef) return null;
const matId = matRef.value;
if (!matId) return null;
try {
const matLine = ifcApi.GetLine(modelID, matId);
if (!matLine) return null;
if (matLine.Name && matLine.Name.value) {
return matLine.Name.value;
}
if (matLine.Materials) {
for (const mRef of matLine.Materials) {
const name = getMaterialName(ifcApi, modelID, mRef);
if (name) return name;
}
}
if (matLine.MaterialConstituents) {
for (const mcRef of matLine.MaterialConstituents) {
const mcLine = ifcApi.GetLine(modelID, mcRef.value);
if (mcLine && mcLine.Material) {
const name = getMaterialName(ifcApi, modelID, mcLine.Material);
if (name) return name;
}
}
}
if (matLine.MaterialProfileSet) {
return getMaterialName(ifcApi, modelID, matLine.MaterialProfileSet);
}
if (matLine.MaterialProfiles) {
for (const mpRef of matLine.MaterialProfiles) {
const mpLine = ifcApi.GetLine(modelID, mpRef.value);
if (mpLine && mpLine.Material) {
const name = getMaterialName(ifcApi, modelID, mpLine.Material);
if (name) return name;
}
}
}
} catch (err) {
// Silencia erros de atributos inexistentes
}
return null;
}
export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
const ifcApi = new WebIFC.IfcAPI();
ifcApi.SetWasmPath(WASM_PATH, true);
await ifcApi.Init();
@@ -18,6 +62,87 @@ export async function convertIFCtoGLB(
const data = new Uint8Array(buffer);
const modelID = ifcApi.OpenModel(data);
// Mapeamento de materiais
const elementMaterialMap = new Map<number, string>();
try {
const rels = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
for (let i = 0; i < rels.size(); i++) {
const relId = rels.get(i);
const rel = ifcApi.GetLine(modelID, relId);
if (rel && rel.RelatedObjects && rel.RelatingMaterial) {
const matName = getMaterialName(ifcApi, modelID, rel.RelatingMaterial);
if (matName) {
for (const objRef of rel.RelatedObjects) {
elementMaterialMap.set(objRef.value, matName);
}
}
}
}
} catch (err) {
console.warn('[IFC Parser] Falha ao mapear materiais:', err);
}
// Mapeamento de propriedades adicionais
const elementPropertiesMap = new Map<number, Record<string, string>>();
try {
const relsProp = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
for (let i = 0; i < relsProp.size(); i++) {
const relId = relsProp.get(i);
const rel = ifcApi.GetLine(modelID, relId);
if (rel && rel.RelatedObjects && rel.RelatingPropertyDefinition) {
const propDefId = rel.RelatingPropertyDefinition.value;
const propDef = ifcApi.GetLine(modelID, propDefId);
if (propDef) {
const props: Record<string, string> = {};
if (propDef.HasProperties) {
for (const pRef of propDef.HasProperties) {
const pLine = ifcApi.GetLine(modelID, pRef.value);
if (pLine && pLine.Name) {
const name = pLine.Name.value;
let value = '';
if (pLine.NominalValue) {
value = String(pLine.NominalValue.value);
}
if (name && value) {
props[name] = value;
}
}
}
}
if (propDef.Quantities) {
for (const qRef of propDef.Quantities) {
const qLine = ifcApi.GetLine(modelID, qRef.value);
if (qLine && qLine.Name) {
const name = qLine.Name.value;
let value = '';
if (qLine.LengthValue !== undefined) value = `${qLine.LengthValue.toFixed(1)} mm`;
else if (qLine.AreaValue !== undefined) value = `${qLine.AreaValue.toFixed(1)}`;
else if (qLine.VolumeValue !== undefined) value = `${qLine.VolumeValue.toFixed(2)}`;
else if (qLine.NominalValue) value = String(qLine.NominalValue.value);
if (name && value) {
props[name] = value;
}
}
}
}
if (Object.keys(props).length > 0) {
for (const objRef of rel.RelatedObjects) {
const existing = elementPropertiesMap.get(objRef.value) ?? {};
elementPropertiesMap.set(objRef.value, { ...existing, ...props });
}
}
}
}
}
} catch (err) {
console.warn('[IFC Parser] Falha ao mapear propriedades:', err);
}
const scene = new THREE.Scene();
const materials: Map<number, THREE.MeshStandardMaterial> = new Map();
@@ -25,11 +150,38 @@ export async function convertIFCtoGLB(
const placedGeometries = mesh.geometries;
const expressID = (mesh as unknown as { expressID?: number }).expressID ?? 0;
// One Group per IfcProduct → represents a single "element" (beam, plate…).
// userData is preserved by GLTFExporter as `extras` and survives reload.
let name = '';
let tag = '';
let objectType = '';
let description = '';
try {
const elementLine = ifcApi.GetLine(modelID, expressID);
if (elementLine) {
name = elementLine.Name?.value ?? '';
tag = elementLine.Tag?.value ?? '';
objectType = elementLine.ObjectType?.value ?? '';
description = elementLine.Description?.value ?? '';
}
} catch (e) {}
const materialName = elementMaterialMap.get(expressID) ?? '';
const extraProps = elementPropertiesMap.get(expressID) ?? {};
const elementGroup = new THREE.Group();
elementGroup.name = `ifc_${expressID}`;
elementGroup.userData = { ifcElement: true, ifcId: expressID };
elementGroup.userData = {
ifcElement: true,
ifcId: expressID,
materialName,
properties: {
name,
tag,
objectType,
description,
material: materialName,
...extraProps
}
};
for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i);
@@ -92,6 +244,14 @@ export async function convertIFCtoGLB(
});
ifcApi.CloseModel(modelID);
return scene;
}
export async function convertIFCtoGLB(
buffer: ArrayBuffer,
fileName: string
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
const scene = await parseIFCtoThree(buffer);
// Export to GLB
const exporter = new GLTFExporter();
@@ -108,7 +268,6 @@ export async function convertIFCtoGLB(
const blob = new Blob([glb], { type: 'model/gltf-binary' });
// Cleanup
materials.forEach((m) => m.dispose());
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
+26
View File
@@ -22,6 +22,10 @@ export function getModelLocalGroup(modelId: string | null | undefined): THREE.Ob
return localGroups.get(modelId);
}
export function getAllModelLocalGroups(): THREE.Object3D[] {
return Array.from(localGroups.values());
}
export function worldToModelLocal(
modelId: string | null | undefined,
p: { x: number; y: number; z: number },
@@ -33,3 +37,25 @@ export function worldToModelLocal(
g.worldToLocal(v);
return { x: v.x, y: v.y, z: v.z };
}
export function modelLocalToWorld(
modelId: string | null | undefined,
p: { x: number; y: number; z: number },
): { x: number; y: number; z: number } | null {
const g = getModelLocalGroup(modelId);
if (!g) return null;
g.updateWorldMatrix(true, false);
const v = new THREE.Vector3(p.x, p.y, p.z);
g.localToWorld(v);
return { x: v.x, y: v.y, z: v.z };
}
export function getModelWorldScaleFactor(modelId: string | null | undefined): number {
const g = getModelLocalGroup(modelId);
if (!g) return 1;
g.updateWorldMatrix(true, false);
const s = new THREE.Vector3();
g.getWorldScale(s);
const factor = (Math.abs(s.x) + Math.abs(s.y) + Math.abs(s.z)) / 3;
return Number.isFinite(factor) && factor > 1e-6 ? factor : 1;
}
+69
View File
@@ -0,0 +1,69 @@
import { supabase } from '@/integrations/supabase/client';
const logBuffer: any[] = [];
let channelSubscribed = false;
const logChannel = supabase.channel('global-app-logs', {
config: { broadcast: { self: true, ack: false } },
});
logChannel.subscribe((status) => {
console.log(`[RemoteLogger] Status do canal de log: ${status}`);
if (status === 'SUBSCRIBED') {
channelSubscribed = true;
console.log(`[RemoteLogger] Inscrito com sucesso. Enviando ${logBuffer.length} logs acumulados...`);
// Envia o buffer acumulado
while (logBuffer.length > 0) {
const payload = logBuffer.shift();
logChannel.send({
type: 'broadcast',
event: 'log',
payload,
}).catch(err => {
console.error('[RemoteLogger] Erro ao enviar log em buffer:', err);
});
}
}
});
export function sendRemoteLog(level: 'info' | 'warn' | 'error', message: string, data?: any) {
const consoleFn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
consoleFn(`[RemoteLog] [${level}] ${message}`, data || '');
const payload = {
level,
message,
data: data ? JSON.parse(JSON.stringify(data)) : null,
timestamp: new Date().toISOString(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'NodeJS',
};
if (channelSubscribed) {
logChannel.send({
type: 'broadcast',
event: 'log',
payload,
}).catch(err => {
console.error('[RemoteLogger] Falha ao enviar log remoto, enfileirando...', err);
logBuffer.push(payload);
});
} else {
logBuffer.push(payload);
}
}
// Captura global de erros em tempo de execução
if (typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
sendRemoteLog('error', `Global error: ${event.message} at ${event.filename}:${event.lineno}`, {
colno: event.colno,
stack: event.error?.stack,
});
});
window.addEventListener('unhandledrejection', (event) => {
sendRemoteLog('error', `Unhandled Promise rejection: ${event.reason}`, {
reason: event.reason?.stack || event.reason,
});
});
}
+200 -19
View File
@@ -1,9 +1,11 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react';
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Users, Info } from 'lucide-react';
import { LandingContent } from '@/components/LandingContent';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
import { supabase } from '@/integrations/supabase/client';
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC';
import { validateModelFile } from '@/lib/validateModelFile';
@@ -11,15 +13,44 @@ import { CloudLoader } from '@/components/CloudLoader';
import { SceneModelList } from '@/components/SceneModelList';
import { RecentFilesList } from '@/components/RecentFilesList';
import { addRecentFile } from '@/lib/recentFiles';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
const Index = () => {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
const [showLanding, setShowLanding] = useState(false);
const [converting, setConverting] = useState(false);
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
const { model, addModel, models, maxModels, xrSupported, setXrSupported, ifcColorMode, setIfcColorMode } = useModelStore();
// --- Painel de Logs Remotos em Tempo Real ---
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: unknown }[]>([]);
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
useEffect(() => {
if (!showLogs) return;
const logChannel = supabase.channel('global-app-logs-viewer');
logChannel.on('broadcast', { event: 'log' }, ({ payload }) => {
setDebugLogs(prev => [payload, ...prev].slice(0, 100));
}).subscribe();
return () => {
logChannel.unsubscribe();
};
}, [showLogs]);
const [ifcConvert, setIfcConvert] = useState(() => {
const saved = localStorage.getItem('tsxr_ifc_convert');
return saved !== 'false';
});
const handleToggleIfcConvert = (checked: boolean) => {
setIfcConvert(checked);
localStorage.setItem('tsxr_ifc_convert', checked ? 'true' : 'false');
};
useEffect(() => {
if (navigator.xr) {
@@ -74,8 +105,19 @@ const Index = () => {
if (!sigCheck.ok) {
toast.error(sigCheck.reason!);
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
setConverting(false);
return;
}
if (ext === 'ifc' && !ifcConvert) {
const url = URL.createObjectURL(file);
addModel({ fileName: file.name, fileSize: file.size, url });
addRecentFile(file.name, file).catch(() => {});
toast.success(`Modelo "${file.name}" carregado de forma nativa!`);
setConverting(false);
return;
}
let result;
if (ext === 'ifc') {
result = await convertIFCtoGLB(buffer, file.name);
@@ -93,7 +135,7 @@ const Index = () => {
} finally {
setConverting(false);
}
}, [addModel]);
}, [addModel, ifcConvert]);
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
@@ -102,6 +144,14 @@ const Index = () => {
try {
const response = await fetch('/models/demo250hp.ifc');
const buffer = await response.arrayBuffer();
if (!ifcConvert) {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
addModel({ fileName: 'demo250hp.ifc', fileSize: blob.size, url });
addRecentFile('demo250hp.ifc', blob).catch(() => {});
toast.success('Modelo demo "250 HP" (IFC nativo) carregado!');
return;
}
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
const url = URL.createObjectURL(result.blob);
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
@@ -109,17 +159,25 @@ const Index = () => {
toast.success('Modelo demo "250 HP" (IFC) carregado!');
} catch (err) {
console.error(err);
toast.error('Falha ao converter modelo demo IFC');
toast.error('Falha ao processar modelo demo IFC');
} finally {
setLoadingDemoIFC(false);
}
}, [addModel]);
}, [addModel, ifcConvert]);
const handleLoadDemoCotovelo = useCallback(async () => {
setLoadingDemoCotovelo(true);
try {
const response = await fetch('/models/B126-COTOVELO.ifc');
const buffer = await response.arrayBuffer();
if (!ifcConvert) {
const blob = new Blob([buffer], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
addModel({ fileName: 'B126-COTOVELO.ifc', fileSize: blob.size, url });
addRecentFile('B126-COTOVELO.ifc', blob).catch(() => {});
toast.success('Modelo demo "B126 Cotovelo" (IFC nativo) carregado!');
return;
}
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
const url = URL.createObjectURL(result.blob);
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
@@ -127,15 +185,15 @@ const Index = () => {
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
} catch (err) {
console.error(err);
toast.error('Falha ao converter modelo demo Cotovelo');
toast.error('Falha ao processar modelo demo Cotovelo');
} finally {
setLoadingDemoCotovelo(false);
}
}, [addModel]);
}, [addModel, ifcConvert]);
const handleEnterViewer = () => {
if (!model) {
toast.error('Importe um modelo GLB primeiro');
toast.error('Importe um modelo 3D primeiro');
return;
}
navigate('/viewer');
@@ -148,22 +206,32 @@ const Index = () => {
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
title="Importar arquivo de modelo 3D"
onChange={handleFileUpload} />
{/* Logo area */}
<div className="mb-12 text-center">
<div className="mb-4 flex items-center justify-center gap-3">
<Box className="h-10 w-10 text-primary" />
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
TrackSteel<span className="text-primary">XR</span>
</h1>
<div className="mb-10 flex flex-col items-center justify-center w-full">
<div className="relative flex items-center justify-center max-w-[280px] md:max-w-[320px] w-full px-4">
<img
src="/logotipo_steelXR_transparente.png"
alt="SteelXR Logo"
className="w-full h-auto object-contain drop-shadow-[0_0_20px_rgba(251,191,36,0.18)]"
/>
<Button
variant="ghost"
size="icon"
className="absolute -right-8 md:-right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowLanding(!showLanding)}
title="Sobre"
>
<Info className="h-5 w-5" />
</Button>
</div>
<p className="font-mono text-sm uppercase tracking-widest text-muted-foreground">
Inspeção de Qualidade Industrial
</p>
</div>
{showLanding && <LandingContent />}
{/* Main card */}
<div className="w-full max-w-md space-y-4">
{/* Import button */}
@@ -175,11 +243,72 @@ const Index = () => {
{converting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Upload className="h-8 w-8" />}
<div className="text-center">
<p className="text-sm font-semibold">{converting ? 'Convertendo modelo…' : 'Importar Modelo 3D'}</p>
<p className="text-sm font-semibold">{converting ? 'Carregando modelo…' : 'Importar Modelo 3D'}</p>
<p className="text-xs text-muted-foreground">GLB · OBJ · STL · IFC Escala 1:1</p>
</div>
</Button>
{/* Toggle IFC Conversion */}
<div className="flex items-center justify-between rounded-lg border border-border bg-card/50 p-3.5">
<div className="flex flex-col gap-0.5">
<Label htmlFor="ifc-convert-toggle" className="text-xs font-semibold cursor-pointer text-foreground">
Conversão IFC GLB
</Label>
<p className="text-[10px] text-muted-foreground">
{ifcConvert ? 'Otimizado para AR (conversão automática)' : 'Modo nativo (carregamento bruto IFC)'}
</p>
</div>
<Switch
id="ifc-convert-toggle"
checked={ifcConvert}
onCheckedChange={handleToggleIfcConvert}
/>
</div>
{/* Escolha do Filtro de Cores (IFC) */}
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card/50 p-3.5">
<div className="flex flex-col gap-0.5">
<Label className="text-xs font-semibold text-foreground">
Modo de Cores (IFC)
</Label>
<p className="text-[10px] text-muted-foreground">
Escolha como aplicar cores aos elementos do modelo
</p>
</div>
<div className="grid grid-cols-3 gap-1 bg-slate-950/40 p-1 rounded-md border border-border/40 mt-1">
<button
onClick={() => setIfcColorMode('none')}
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
ifcColorMode === 'none'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
}`}
>
Cor Única
</button>
<button
onClick={() => setIfcColorMode('material')}
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
ifcColorMode === 'material'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
}`}
>
Material
</button>
<button
onClick={() => setIfcColorMode('description')}
className={`py-1.5 px-2 text-[10px] font-medium rounded transition-all ${
ifcColorMode === 'description'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-slate-900/60 hover:text-foreground'
}`}
>
Descrição
</button>
</div>
</div>
{/* Demo buttons */}
<div className="grid grid-cols-3 gap-2">
<CloudLoader />
@@ -231,6 +360,15 @@ const Index = () => {
Visualizar Modelo 3D
</Button>
{/* Enter virtual meeting */}
<Button
className="h-14 w-full gap-3 text-base font-semibold bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white shadow-lg shadow-violet-500/20 hover:shadow-violet-500/35 border-0 transition-all duration-200"
onClick={() => navigate('/meeting')}>
<Users className="h-5 w-5" />
Reunião Virtual
</Button>
{/* XR Status */}
<div className="flex items-center justify-center gap-2 pt-2">
{xrSupported === null ?
@@ -254,8 +392,51 @@ const Index = () => {
{/* Footer */}
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
TrackSteelXR v1.01 Q.C. Inspection
SteelXR v1.13 Q.C. Inspection
</p>
{showLogs && (
<div className="fixed bottom-4 right-4 z-50 flex h-[400px] w-[500px] flex-col rounded-lg border border-border bg-slate-950/95 p-4 shadow-2xl backdrop-blur font-mono text-xs text-slate-200">
<div className="flex items-center justify-between border-b border-border pb-2 mb-2">
<span className="font-bold text-primary flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-ping" />
Console de Logs Remotos (Quest 3)
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px]" onClick={() => setDebugLogs([])}>
Limpar
</Button>
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px] text-destructive" onClick={() => setShowLogs(false)}>
Fechar
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{debugLogs.length === 0 ? (
<p className="text-slate-500 text-center mt-12">Aguardando logs do headset Quest 3...</p>
) : (
debugLogs.map((log, i) => (
<div key={i} className={`p-2 rounded border ${
log.level === 'error' ? 'bg-red-950/40 border-red-500/30 text-red-300' :
log.level === 'warn' ? 'bg-amber-950/40 border-amber-500/30 text-amber-300' :
'bg-slate-900/60 border-slate-700/30 text-slate-300'
}`}>
<div className="flex justify-between text-[9px] text-slate-400 mb-1">
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="uppercase font-bold">{log.level}</span>
</div>
<p className="break-all whitespace-pre-wrap">{log.message}</p>
{log.data && (
<pre className="mt-1 text-[10px] bg-black/40 p-1 rounded overflow-x-auto max-h-24 text-slate-400">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
)}
</div>);
};
File diff suppressed because it is too large Load Diff
+313 -22
View File
@@ -1,13 +1,17 @@
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2, Users, Menu, Target } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useModelStore } from "@/stores/useModelStore";
import { xrStore } from "@/stores/useXRStore";
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
import { ViewerControls } from "@/components/ViewerControls";
import { InspectionChecklist } from "@/components/InspectionChecklist";
import { FineTuningControls } from "@/components/FineTuningControls";
import { MeasurementsList } from "@/components/MeasurementsList";
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
import { ViewCube } from "@/components/ViewCube";
import { SectionCutPanel } from "@/components/SectionCutPanel";
import { ShareButton } from "@/components/ShareButton";
import { CloudLoader } from "@/components/CloudLoader";
import { SceneModelList } from "@/components/SceneModelList";
@@ -18,18 +22,180 @@ import { Separator } from "@/components/ui/separator";
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
import { validateModelFile } from "@/lib/validateModelFile";
import { convertIFCtoGLB } from "@/lib/convertIFC";
import { mainCameraRef, mainControlsRef } from "@/components/three/viewCubeBus";
const Viewer = () => {
const navigate = useNavigate();
const { model, xrSupported, addModel, models, maxModels, scaleRatio } = useModelStore();
const {
model,
xrSupported,
addModel,
models,
maxModels,
scaleRatio,
fineTuning,
sectionEnabled,
sectionInvert,
sectionLevel,
hoverIfcProps
} = useModelStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [converting, setConverting] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => {
if (!model) {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
const handleRecenter = useCallback(() => {
const camera = mainCameraRef.current;
const controls = mainControlsRef.current;
if (camera) {
camera.position.set(2, 2, 2);
camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
}
if (controls) {
controls.target.set(0, 0, 0);
controls.update();
}
toast.success("Visualização centralizada");
}, []);
const exitFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen().catch((err) => {
console.error("Erro ao sair de tela cheia:", err);
});
}
}, []);
const [searchParams] = useSearchParams();
const roomId = searchParams.get("room");
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
// Se não houver modelo e não estivermos em Reunião, volta para a Home
useEffect(() => {
if (!model && !roomId) {
navigate("/");
}
}, [model, navigate]);
}, [model, roomId, navigate]);
const sendMeetingUpdate = useCallback((ft: typeof fineTuning, secEn: typeof sectionEnabled, secLevel: typeof sectionLevel, secInv: typeof sectionInvert) => {
if (!roomId || !channelRef.current) return;
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => secEn[ax]);
const isSectionActive = !!enabledAxis;
const sectionAxis = enabledAxis || 'y';
const sectionLevelVal = isSectionActive ? secLevel[sectionAxis] : 0.0;
const sectionInvertVal = isSectionActive ? secInv[sectionAxis] : false;
// Obtém dados da maquete ativa na store
const activeModelStore = useModelStore.getState().model;
const payload = {
rotation: [ft.rotX, ft.rotY, ft.rotZ],
scale: ft.scale,
sectionEnabled: isSectionActive,
sectionAxis,
sectionLevel: sectionLevelVal,
sectionInvert: sectionInvertVal,
modelUrl: activeModelStore?.url,
modelFileName: activeModelStore?.fileName,
modelFileSize: activeModelStore?.fileSize
};
console.log("📡 [Viewer Meeting Sync] Enviando atualização de apresentação:", payload);
channelRef.current.send({
type: 'broadcast',
event: 'presentation_update',
payload
});
}, [roomId]);
// Conecta ao canal do Supabase se o Viewer foi aberto no modo Reunião
useEffect(() => {
if (!roomId) return;
const finalRoomId = roomId.trim().toUpperCase();
console.log(`🔌 [Viewer Meeting Sync] Conectando ao canal da reunião: ${finalRoomId}`);
const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
config: {
broadcast: { self: false }
}
});
channelRef.current = channel;
channel.subscribe((status) => {
console.log(`🔌 [Viewer Meeting Sync] Status do canal: ${status}`);
if (status === 'SUBSCRIBED') {
const state = useModelStore.getState();
sendMeetingUpdate(state.fineTuning, state.sectionEnabled, state.sectionLevel, state.sectionInvert);
}
});
return () => {
console.log(`🔌 [Viewer Meeting Sync] Desconectando do canal da reunião: ${finalRoomId}`);
channel.unsubscribe();
supabase.removeChannel(channel);
};
}, [roomId, sendMeetingUpdate]);
// Observa mudanças primitivas nas ferramentas 3D para propagação imediata via Broadcast
const rotX = fineTuning.rotX;
const rotY = fineTuning.rotY;
const rotZ = fineTuning.rotZ;
const scaleVal = fineTuning.scale;
const secEnabledX = sectionEnabled.x;
const secEnabledY = sectionEnabled.y;
const secEnabledZ = sectionEnabled.z;
const secLevelX = sectionLevel.x;
const secLevelY = sectionLevel.y;
const secLevelZ = sectionLevel.z;
const secInvertX = sectionInvert.x;
const secInvertY = sectionInvert.y;
const secInvertZ = sectionInvert.z;
const activeModelFileName = model?.fileName;
useEffect(() => {
if (!roomId || !channelRef.current) return;
sendMeetingUpdate(fineTuning, sectionEnabled, sectionLevel, sectionInvert);
}, [
roomId,
rotX,
rotY,
rotZ,
scaleVal,
secEnabledX,
secEnabledY,
secEnabledZ,
secLevelX,
secLevelY,
secLevelZ,
secInvertX,
secInvertY,
secInvertZ,
activeModelFileName,
sendMeetingUpdate,
fineTuning,
sectionEnabled,
sectionLevel,
sectionInvert
]);
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -89,14 +255,39 @@ const Viewer = () => {
}
}, [addModel, models.length, maxModels]);
if (!model) return null;
// Removido o bloqueio para permitir carregamento de modelo na sala
const handleEnterXR = async () => {
const [enteringXR, setEnteringXR] = useState(false);
const handleEnterXR = useCallback(() => {
if (!xrSupported) {
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
return;
}
navigate("/xr");
}, [xrSupported, navigate]);
const handleEnterMeeting = () => {
// Coleta o eixo habilitado de seção no Viewer
const enabledAxis = (['x', 'y', 'z'] as const).find(ax => sectionEnabled[ax]);
const isSectionActive = !!enabledAxis;
const sectionAxis = enabledAxis || 'y';
const sectionLevelVal = isSectionActive ? sectionLevel[sectionAxis] : 0.0;
const sectionInvertVal = isSectionActive ? sectionInvert[sectionAxis] : false;
// Salva o estado atual da peça
const initialMeetingState = {
rotation: [fineTuning.rotX, fineTuning.rotY, fineTuning.rotZ],
scale: fineTuning.scale,
sectionEnabled: isSectionActive,
sectionAxis,
sectionLevel: sectionLevelVal,
sectionInvert: sectionInvertVal
};
localStorage.setItem('tsxr_initial_meeting_state', JSON.stringify(initialMeetingState));
toast.success('Configurações da maquete salvas. Direcionando para a reunião...');
navigate('/meeting');
};
const canAddMore = models.length < maxModels;
@@ -108,41 +299,126 @@ const Viewer = () => {
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
title="Selecionar arquivo de maquete para upload"
placeholder="Upload de maquete"
onChange={handleFileUpload}
/>
{/* Top bar */}
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/")}>
<ArrowLeft className="h-5 w-5" />
<Button
variant="ghost"
size={roomId ? "default" : "icon"}
className={roomId ? "gap-1.5 px-3 text-xs font-mono h-9 hover:bg-slate-800" : ""}
onClick={() => roomId ? navigate(`/meeting/${roomId}`) : navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
{roomId && "Voltar para Sala"}
</Button>
<div className="flex items-center gap-2">
<Box className="h-5 w-5 text-primary" />
<h1 className="font-mono text-sm font-semibold text-foreground">
TrackSteel<span className="text-primary">XR</span>
Steel<span className="text-primary">XR</span>
</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2 border-primary/30 hover:bg-primary/10 text-primary font-mono h-9"
onClick={handleEnterMeeting}
>
<Users className="h-4 w-4" />
Reunião Virtual
</Button>
<ShareButton />
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
<Glasses className="h-4 w-4" />
Entrar em Modo XR
<Button className="gap-2 glow-primary h-9" disabled={!xrSupported || enteringXR} onClick={handleEnterXR}>
{enteringXR ? <Loader2 className="h-4 w-4 animate-spin" /> : <Glasses className="h-4 w-4" />}
{enteringXR ? 'Iniciando AR…' : 'Entrar em Modo XR'}
</Button>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* 3D Canvas with floating controls */}
<div className="relative flex-1">
<div className="relative flex-1 min-w-0" id="canvas-container">
<ModelViewerCanvas />
<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 />
<ViewerControls />
<SectionCutPanel />
</>
)}
</div>
{/* Side panel */}
<aside className="w-80 shrink-0 border-l bg-card">
<aside className={`transition-all duration-300 shrink-0 border-l bg-card flex flex-col ${sidebarOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 pointer-events-none border-l-0 overflow-hidden'}`}>
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
<div>
@@ -150,6 +426,15 @@ const Viewer = () => {
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Cena · {models.length}/{maxModels} peças
</h2>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-muted"
onClick={() => setSidebarOpen(false)}
title="Recolher menu"
>
<Menu className="h-4 w-4" />
</Button>
</div>
<SceneModelList />
<div className="mt-2 grid grid-cols-2 gap-1.5">
@@ -174,11 +459,17 @@ const Viewer = () => {
<h2 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Peça selecionada
</h2>
<div className="space-y-3">
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
</div>
{model ? (
<div className="space-y-3">
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
</div>
) : (
<div className="rounded-lg border border-dashed border-muted p-4 text-center bg-muted/20">
<p className="font-mono text-xs text-muted-foreground">Nenhuma peça ativa na cena</p>
</div>
)}
</div>
<Separator />
@@ -210,7 +501,7 @@ const Viewer = () => {
);
};
function InfoItem({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
function InfoItem({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return (
<div className="flex items-start gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
+2 -2
View File
@@ -78,7 +78,7 @@ export default function Watch() {
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card/50 backdrop-blur px-4 py-3 flex items-center justify-between">
<Link to="/" className="font-mono text-sm font-bold text-foreground">
TrackSteel<span className="text-primary">XR</span>
Steel<span className="text-primary">XR</span>
</Link>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] text-muted-foreground">SALA</span>
@@ -159,7 +159,7 @@ export default function Watch() {
<footer className="border-t bg-card/50 backdrop-blur px-4 py-2 flex items-center justify-center gap-2">
<Eye className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-[10px] text-muted-foreground">
Modo somente visualização · TrackSteelXR
Modo somente visualização · SteelXR
</span>
</footer>
</div>
+781 -114
View File
File diff suppressed because it is too large Load Diff
+234 -39
View File
@@ -1,9 +1,22 @@
import { create } from 'zustand';
import { worldToModelLocal } from '@/lib/modelTransforms';
import { getModelWorldScaleFactor, worldToModelLocal } from '@/lib/modelTransforms';
import { sendRemoteLog } from '@/lib/remoteLogger';
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
const PLACEMENT_KEY = 'tsxr_placements_v1';
const XRFEAT_KEY = 'xrFeatures';
const 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 {
handTracking: boolean;
@@ -17,35 +30,45 @@ export interface XRFeatureFlags {
bodyTracking: boolean;
}
const DEFAULT_XR_FEATURES: XRFeatureFlags = {
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
anchors: true, meshDetection: true, depthSensing: true, layers: true,
bodyTracking: true,
handTracking: true,
planeDetection: true,
hitTest: true,
domOverlay: true,
anchors: true,
meshDetection: false,
depthSensing: false,
layers: false,
bodyTracking: false,
};
function loadXRFeatures(): XRFeatureFlags {
try {
const raw = localStorage.getItem(XRFEAT_KEY);
if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) };
} catch {}
} catch (e) { /* ignore */ }
return { ...DEFAULT_XR_FEATURES };
}
function saveXRFeatures(f: XRFeatureFlags) {
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {}
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch (e) { /* ignore */ }
}
interface StoredPlacement { fineTuning: FineTuning; }
interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; }
function loadPlacements(): Record<string, StoredPlacement> {
try {
const raw = localStorage.getItem(PLACEMENT_KEY);
if (raw) return JSON.parse(raw);
} catch {}
} catch (e) { /* ignore */ }
return {};
}
function savePlacement(fileName: string, fineTuning: FineTuning) {
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
try {
const all = loadPlacements();
all[fileName] = { fineTuning };
const prev = all[fileName] ?? { fineTuning };
all[fileName] = {
fineTuning,
calibrationQuat: calibrationQuat === undefined ? prev.calibrationQuat ?? null : calibrationQuat,
};
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
} catch {}
} catch (e) { /* ignore */ }
}
// ── Visibility (selection-based hide/isolate) persistence ────────────
@@ -55,7 +78,7 @@ function loadVisibilityMap(): Record<string, StoredVisibility> {
try {
const raw = localStorage.getItem(VISIBILITY_KEY);
if (raw) return JSON.parse(raw);
} catch {}
} catch (e) { /* ignore */ }
return {};
}
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
@@ -63,7 +86,7 @@ function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<str
const all = loadVisibilityMap();
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
} catch {}
} catch (e) { /* ignore */ }
}
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
const v = loadVisibilityMap()[fileName];
@@ -71,6 +94,36 @@ function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
}
const IFC_MAT_COLORS_KEY = 'tsxr_ifc_mat_colors_v1';
const IFC_COLOR_MODE_KEY = 'tsxr_ifc_color_mode_v1';
export type IFCColorMode = 'none' | 'material' | 'description';
function loadIfcMaterialColors(): boolean {
try {
const raw = localStorage.getItem(IFC_MAT_COLORS_KEY);
return raw !== 'false';
} catch (e) { /* ignore */ }
return true;
}
function saveIfcMaterialColors(enabled: boolean) {
try { localStorage.setItem(IFC_MAT_COLORS_KEY, String(enabled)); } catch (e) { /* ignore */ }
}
function loadIfcColorMode(): IFCColorMode {
try {
const raw = localStorage.getItem(IFC_COLOR_MODE_KEY);
if (raw === 'none' || raw === 'material' || raw === 'description') {
return raw as IFCColorMode;
}
const legacy = localStorage.getItem(IFC_MAT_COLORS_KEY);
if (legacy === 'false') return 'none';
} catch (e) { /* ignore */ }
return 'material';
}
function saveIfcColorMode(mode: IFCColorMode) {
try { localStorage.setItem(IFC_COLOR_MODE_KEY, mode); } catch (e) { /* ignore */ }
}
export interface ScaleRatio {
@@ -119,6 +172,8 @@ export interface SceneModel {
visible: boolean;
locked: boolean;
fineTuning: FineTuning;
/** Local rotation (xyzw) that aligns the model's intrinsic axes to world axes. */
calibrationQuat: [number, number, number, number] | null;
}
const MAX_MODELS = 5;
@@ -132,6 +187,8 @@ export interface MeasurePoint {
x: number;
y: number;
z: number;
/** Model hit by this point, used so saved dimensions attach to the right piece. */
modelId?: string;
}
export interface Measurement {
@@ -152,6 +209,7 @@ export interface HoverInfo {
type: 'hole' | 'edge';
position: [number, number, number];
value: number; // mm (diameter or length)
modelId?: string;
/** For edges: the two endpoints in world coords. */
endpoints?: { a: MeasurePoint; b: MeasurePoint };
}
@@ -187,6 +245,9 @@ interface ModelStore {
clearAllModels: () => void;
maxModels: number;
/** Calibração (rotação local) ↔ ViewCube. Aplicada antes do fineTuning rotation. */
setCalibration: (id: string, quat: [number, number, number, number] | null) => void;
// ── Legacy single-model accessors (proxy to active) ─────────────
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
@@ -217,6 +278,10 @@ interface ModelStore {
renderMode: RenderMode;
setRenderMode: (mode: RenderMode) => void;
/** Camera projection used in the viewer. Ortho is the default for CAD-style work. */
cameraMode: 'ortho' | 'persp';
setCameraMode: (m: 'ortho' | 'persp') => void;
showGrid: boolean;
setShowGrid: (show: boolean) => void;
@@ -228,6 +293,27 @@ interface ModelStore {
setGridAutoFollow: (b: boolean) => void;
/** Nudge grid by delta meters and disable auto-follow. */
nudgeGridY: (delta: number) => void;
/** When true, the next click on a model face sets grid Y to that point. */
gridCalibMode: boolean;
setGridCalibMode: (b: boolean) => void;
/** 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;
setWireframeColor: (color: string) => void;
@@ -248,6 +334,8 @@ interface ModelStore {
setMeasureMode: (on: boolean) => void;
positionMode: boolean;
setPositionMode: (on: boolean) => void;
walkMode: boolean;
setWalkMode: (on: boolean) => void;
// ── Selection (hide/isolate/export elements) ────────────
selectionMode: boolean;
@@ -287,6 +375,9 @@ interface ModelStore {
hoverInfo: HoverInfo | null;
setHoverInfo: (info: HoverInfo | null) => void;
hoverIfcProps: Record<string, string> | null;
setHoverIfcProps: (props: Record<string, string> | null) => void;
screenshots: string[];
addScreenshot: (dataUrl: string) => void;
removeScreenshot: (index: number) => void;
@@ -296,6 +387,14 @@ interface ModelStore {
xrFeatures: XRFeatureFlags;
setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void;
resetXRFeatures: () => void;
ifcMaterialColors: boolean;
setIfcMaterialColors: (enabled: boolean) => void;
ifcColorMode: IFCColorMode;
setIfcColorMode: (mode: IFCColorMode) => void;
xrRig: THREE.Group | null;
setXRRig: (rig: THREE.Group | null) => void;
}
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
@@ -308,6 +407,9 @@ function syncActive(state: Pick<ModelStore, 'models' | 'activeModelId'>): { mode
}
export const useModelStore = create<ModelStore>((set, get) => ({
xrRig: null,
setXRRig: (rig) => set({ xrRig: rig }),
// ── Multi-model ─────────────
models: [],
activeModelId: null,
@@ -326,6 +428,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const fineTuning: FineTuning = saved?.fineTuning
? { ...saved.fineTuning }
: { ...defaultFineTuning, posX: offsetIdx * 0.15 };
const calibrationQuat = saved?.calibrationQuat ?? null;
const newModel: SceneModel = {
id,
fileName: m.fileName,
@@ -335,6 +438,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
visible: true,
locked: false,
fineTuning,
calibrationQuat,
};
const models = [...state.models, newModel];
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
@@ -395,6 +499,15 @@ export const useModelStore = create<ModelStore>((set, get) => ({
clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }),
setCalibration: (id, quat) => set((state) => {
const models = state.models.map(m => {
if (m.id !== id) return m;
savePlacement(m.fileName, m.fineTuning, quat);
return { ...m, calibrationQuat: quat };
});
return { models };
}),
// ── Legacy single-model proxies ─────────────
model: null,
setModel: (m) => {
@@ -408,14 +521,18 @@ export const useModelStore = create<ModelStore>((set, get) => ({
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
color: MODEL_COLORS[0], visible: true, locked: false,
fineTuning: { ...defaultFineTuning },
calibrationQuat: null,
};
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
},
fineTuning: { ...defaultFineTuning },
setFineTuning: (ft) => set((state) => {
const newFT = { ...state.fineTuning, ...ft };
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
// Lock guard: when the active model is locked, ignore any movement/scale change.
if (activeBefore?.locked) return {};
const newFT = { ...state.fineTuning, ...ft };
const models = activeId
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: newFT } : m)
: state.models;
@@ -425,8 +542,10 @@ export const useModelStore = create<ModelStore>((set, get) => ({
return { fineTuning: newFT, models };
}),
resetFineTuning: () => set((state) => {
const reset = { ...defaultFineTuning };
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const reset = { ...defaultFineTuning };
const models = activeId
? state.models.map(m => m.id === activeId ? { ...m, fineTuning: reset } : m)
: state.models;
@@ -445,11 +564,17 @@ export const useModelStore = create<ModelStore>((set, get) => ({
setXrSupported: (xrSupported) => set({ xrSupported }),
scaleRatio: SCALE_PRESETS.find(p => p.label === '1:1')!,
setScaleRatio: (scaleRatio) => set({ scaleRatio }),
setScaleRatio: (scaleRatio) => set((state) => {
const activeBefore = state.models.find(m => m.id === state.activeModelId);
if (activeBefore?.locked) return {};
return { scaleRatio };
}),
scaleResetNonce: 0,
resetScale: () => set((state) => {
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
const models = activeId
? state.models.map(m => m.id === activeId
? { ...m, fineTuning: { ...m.fineTuning, scale: 1 } }
@@ -472,6 +597,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
renderMode: 'solid',
setRenderMode: (renderMode) => set({ renderMode }),
cameraMode: loadCameraMode(),
setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); },
showGrid: false,
setShowGrid: (showGrid) => set({ showGrid }),
@@ -480,11 +608,31 @@ export const useModelStore = create<ModelStore>((set, get) => ({
gridAutoFollow: true,
setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }),
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
gridCalibMode: false,
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
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 }),
wireframeThickness: 1,
wireframeThickness: 2,
setWireframeThickness: (wireframeThickness) => set({ wireframeThickness }),
edgeThresholdAngle: 15,
@@ -509,9 +657,14 @@ export const useModelStore = create<ModelStore>((set, get) => ({
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
measureMode: false,
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode })),
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode, walkMode: false })),
positionMode: false,
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode })),
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode, walkMode: false })),
walkMode: false,
setWalkMode: (walkMode) => {
set({ walkMode });
if (walkMode) set({ positionMode: false, measureMode: false, selectionMode: false, cameraMode: 'persp' });
},
// ── Selection state ─────────────────────────────────────
selectionMode: false,
@@ -553,27 +706,33 @@ export const useModelStore = create<ModelStore>((set, get) => ({
}),
measurePoints: [],
addMeasurePoint: (p) => set((state) => {
sendRemoteLog('info', `addMeasurePoint acionado: ponto atual ${state.measurePoints.length + 1}`, p);
const points = [...state.measurePoints, p];
if (points.length === 2) {
const [a, b] = points;
const dx = (a.x - b.x) * 1000;
const dy = (a.y - b.y) * 1000;
const dz = (a.z - b.z) * 1000;
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
const factor = state.scaleRatio?.factor ?? 1;
const distanceMM = distanceWorldMM / factor;
// Convert world points to active model's local frame so they follow the model.
const modelId = state.activeModelId ?? undefined;
const modelId = (a.modelId && a.modelId === b.modelId ? a.modelId : state.activeModelId) ?? undefined;
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
const attached = !!(aConv && bConv);
const pa = attached ? aConv! : a;
const pb = attached ? bConv! : b;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const distanceMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
distanceMM,
modelId,
attached
});
const measurement: Measurement = {
id: `m_${Date.now()}`,
pointA: attached ? aConv! : a,
pointB: attached ? bConv! : b,
pointA: pa,
pointB: pb,
distanceMM,
modelId: attached ? modelId : undefined,
};
sendRemoteLog('info', `addMeasurePoint: medição gerada com sucesso`, measurement);
return { measurePoints: [], measurements: [...state.measurements, measurement] };
}
return { measurePoints: points };
@@ -593,7 +752,8 @@ export const useModelStore = create<ModelStore>((set, get) => ({
return { measurements: state.measurements.slice(0, -1) };
}),
registerHoverMeasurement: (info) => set((state) => {
const 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 conv = modelId ? worldToModelLocal(modelId, p) : null;
return conv ? { point: conv, attached: true } : { point: p, attached: false };
@@ -604,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 c = toLocal(cw);
attached = c.attached;
const valueMM = attached ? info.value / modelScale : info.value;
measurement = {
id: `m_${Date.now()}`,
pointA: c.point,
pointB: c.point,
distanceMM: info.value,
distanceMM: valueMM,
kind: 'hole',
label: `Ø ${info.value.toFixed(1)}`,
label: `Ø ${valueMM.toFixed(1)}`,
modelId: attached ? modelId : undefined,
};
} else {
@@ -619,13 +780,19 @@ export const useModelStore = create<ModelStore>((set, get) => ({
const a = toLocal(aw);
const b = toLocal(bw);
attached = a.attached && b.attached;
const pa = attached ? a.point : aw;
const pb = attached ? b.point : bw;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const valueMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
measurement = {
id: `m_${Date.now()}`,
pointA: attached ? a.point : aw,
pointB: attached ? b.point : bw,
distanceMM: info.value,
pointA: pa,
pointB: pb,
distanceMM: valueMM,
kind: 'edge',
label: `${info.value.toFixed(1)} mm`,
label: `${valueMM.toFixed(1)} mm`,
modelId: attached ? modelId : undefined,
};
}
@@ -644,6 +811,9 @@ export const useModelStore = create<ModelStore>((set, get) => ({
hoverInfo: null,
setHoverInfo: (hoverInfo) => set({ hoverInfo }),
hoverIfcProps: null,
setHoverIfcProps: (hoverIfcProps) => set({ hoverIfcProps }),
screenshots: [],
addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })),
removeScreenshot: (index) => set((state) => ({
@@ -662,4 +832,29 @@ export const useModelStore = create<ModelStore>((set, get) => ({
saveXRFeatures(next);
set({ xrFeatures: next });
},
ifcMaterialColors: loadIfcMaterialColors(),
setIfcMaterialColors: (enabled: boolean) => {
saveIfcMaterialColors(enabled);
set((state) => {
const mode = enabled ? 'material' : 'none';
saveIfcColorMode(mode);
return { ifcMaterialColors: enabled, ifcColorMode: mode };
});
},
ifcColorMode: loadIfcColorMode(),
setIfcColorMode: (mode: IFCColorMode) => {
saveIfcColorMode(mode);
const enabled = mode === 'material';
saveIfcMaterialColors(enabled);
set({ ifcColorMode: mode, ifcMaterialColors: enabled });
},
}));
// Autodetecção global de suporte a WebXR
if (typeof navigator !== 'undefined' && navigator.xr) {
navigator.xr.isSessionSupported('immersive-ar')
.then((supported) => useModelStore.getState().setXrSupported(supported))
.catch(() => useModelStore.getState().setXrSupported(false));
} else {
useModelStore.getState().setXrSupported(false);
}
+39
View File
@@ -0,0 +1,39 @@
import { createXRStore } from '@react-three/xr';
function loadXRFeatures() {
const defaults = {
handTracking: true,
planeDetection: true,
hitTest: true,
domOverlay: true,
anchors: true,
meshDetection: false,
depthSensing: false,
layers: false,
bodyTracking: false,
lightEstimation: false,
};
try {
const raw = localStorage.getItem('xrFeatures_v2');
if (raw) return { ...defaults, ...JSON.parse(raw) };
} catch (err) {
console.warn('[XR] Falha ao ler xrFeatures do localStorage:', err);
}
return defaults;
}
const _xrf = loadXRFeatures();
export const xrStore = createXRStore({
hand: { left: true, right: true },
controller: { left: true, right: true },
handTracking: _xrf.handTracking,
planeDetection: _xrf.planeDetection,
hitTest: _xrf.hitTest,
domOverlay: _xrf.domOverlay,
anchors: _xrf.anchors,
meshDetection: _xrf.meshDetection,
depthSensing: _xrf.depthSensing,
layers: _xrf.layers,
bodyTracking: _xrf.bodyTracking,
});
+127
View File
@@ -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.**
+2 -1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"types": [
"vitest/globals"
],
@@ -18,7 +19,7 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
+2
View File
@@ -9,6 +9,8 @@
}
],
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": [
"./src/*"
+2 -1
View File
@@ -1,7 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"module": "ESNext",
"skipLibCheck": true,
Executable
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# ---------------------------------------------------------
# STEELXR: SCRIPT DE ATUALIZAÇÃO E DEPLOY AUTOMÁTICO
# ---------------------------------------------------------
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático SteelXR...${NC}"
# 1. Sincronização com Repositório Git (se existir)
if [ -d .git ]; then
echo -e "${YELLOW}📝 Sincronizando código com Git...${NC}"
git add .
if git diff-index --quiet HEAD --; then
echo -e "${GREEN}✨ Código local já está sincronizado.${NC}"
else
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
echo -e "${CYAN}📤 Gravando alterações (Auto-update $TIMESTAMP)...${NC}"
git commit -m "🚀 Auto-deploy: melhoria no snap e medição AR em $TIMESTAMP"
if git remote | grep -q 'origin'; then
echo -e "${CYAN}📤 Enviando para o repositório remoto (Gitea)...${NC}"
git push origin main
fi
fi
else
echo -e "${YELLOW}️ Diretório não é um repositório Git. Pulando sincronização Git.${NC}"
fi
# 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"