Updated XR control mapping
X-Lovable-Edit-ID: edt-e9c3b305-26f0-41bc-83b4-c5b5e98ba8b9 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+67
-69
@@ -1,83 +1,81 @@
|
|||||||
|
## Objetivo
|
||||||
|
|
||||||
# Seleção de elementos IFC — Esconder, Isolar e Exportar GLB
|
Alinhar o comportamento dos controles Touch Plus (Meta Quest 3) ao padrão pedido:
|
||||||
|
|
||||||
Adicionar um modo de **seleção por elemento** (viga, chapa, etc.) que permite ao usuário escolher partes do modelo e então **esconder**, **isolar** ou **exportar como GLB reduzido** para reuso. Funciona no desktop e no AR/XR (Quest 3) em uma única fase.
|
- **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).
|
||||||
|
|
||||||
## Fluxo do usuário
|
## Mapeamento alvo
|
||||||
|
|
||||||
1. Usuário ativa botão **"Selecionar"** no ViewerControls (e no XRHud).
|
```text
|
||||||
2. Clica em elementos do modelo — cada clique alterna seleção (highlight laranja/emissive).
|
Mão Botão Ação
|
||||||
3. Contador mostra "N elementos selecionados".
|
─────────────────────────────────────────────────────────
|
||||||
4. Três ações disponíveis:
|
Esquerda/Direita Grip (1 mão) Pan da peça (translação 6DOF, sem rotação)
|
||||||
- **Esconder selecionados** → some com eles
|
Ambas as mãos Grip + afastar Zoom uniforme (escala)
|
||||||
- **Isolar selecionados** → esconde todo o resto
|
Ambas as mãos Grip + giro oposto Orbitar/rotacionar a peça
|
||||||
- **Mostrar tudo** → restaura
|
Direita Trigger Selecionar componente / adicionar ponto de medida
|
||||||
5. Botão extra **"Exportar GLB"** salva arquivo `.glb` contendo apenas os elementos selecionados, baixável pelo navegador. Esse GLB pode ser recarregado no app pelo fluxo normal de import.
|
Esquerda Trigger Alternar snap de vértice (já existe)
|
||||||
6. Estado de visibilidade é **persistido por `fileName`** no `localStorage`, igual ao `PLACEMENT_KEY`, sobrevivendo a recarregar a página e a sair/entrar do AR.
|
Direita Botão A Desfazer última medida (já existe)
|
||||||
|
Direita Botão B Limpar medidas (já existe)
|
||||||
## Granularidade
|
Thumbstick Frente + soltar Teletransporte para o ponto apontado
|
||||||
|
Thumbstick Lados Snap-rotate da câmera (±30°)
|
||||||
Seleção opera no **elemento IFC inteiro** (uma viga, uma chapa). O converter de IFC preserva a hierarquia: cada `IfcProduct` vira um `Group` ou `Mesh` nomeado no GLB. O picking sobe na árvore até encontrar o ancestral marcado como "elemento IFC raiz" (campo `userData.ifcElement = true` adicionado durante a conversão), garantindo que clicar em qualquer face seleciona a viga toda.
|
Thumbstick Trás Recuar o usuário (passo)
|
||||||
|
|
||||||
## Detalhes técnicos
|
|
||||||
|
|
||||||
### 1. `src/lib/convertIFC.ts`
|
|
||||||
- Durante a conversão IFC→GLB, marcar cada `IfcProduct` resultante com `obj.userData.ifcElement = true` e `userData.ifcId = expressId`. Sem isso, a árvore three.js fica plana e não dá pra distinguir "elemento" de "submesh".
|
|
||||||
|
|
||||||
### 2. `src/stores/useModelStore.ts`
|
|
||||||
Novo slice de seleção:
|
|
||||||
```ts
|
|
||||||
selectionMode: boolean
|
|
||||||
selectedElementIds: Set<string> // chave: `${modelId}:${ifcId|uuid}`
|
|
||||||
hiddenElementIds: Set<string>
|
|
||||||
isolatedElementIds: Set<string> | null // null = sem isolamento
|
|
||||||
toggleSelection(key), clearSelection(),
|
|
||||||
hideSelected(), isolateSelected(), showAll()
|
|
||||||
```
|
```
|
||||||
Persistência de `hiddenElementIds`/`isolatedElementIds` por `fileName` em `localStorage` (chave nova `tsxr_visibility_v1`), aplicada ao carregar o modelo.
|
|
||||||
|
|
||||||
### 3. `src/components/three/ModelViewer.tsx`
|
## Mudanças no código
|
||||||
- Novo componente `SelectionHandler`: ativo quando `selectionMode === true`. Raycaster no `onPointerDown`, sobe a árvore via `object.parent` até achar `userData.ifcElement === true`, faz toggle no store. Desabilita OrbitControls durante o clique para não conflitar.
|
|
||||||
- Aplicar visibilidade: `useEffect` percorre `scene.traverse`, define `obj.visible` conforme `hiddenElementIds`/`isolatedElementIds`.
|
|
||||||
- Highlight de seleção: material `emissive` laranja (`#f59e0b`, mesma cor de holes) com `emissiveIntensity = 0.4`, restaurado ao deselecionar. Guardar material original em `userData.originalMaterial`.
|
|
||||||
- `selectionMode` é mutuamente exclusivo com `measureMode` e `positionMode` (mesma lógica que esses dois já têm entre si).
|
|
||||||
|
|
||||||
### 4. `src/components/three/XRControllerMeasure.tsx` (ou novo `XRControllerSelect.tsx`)
|
### 1. `src/components/three/XRGrabbable.tsx`
|
||||||
- Quando `selectionMode === true` em XR: usar o mesmo ray do controller, no `selectstart` (trigger) faz toggle de seleção em vez de pontos de medição.
|
|
||||||
- Tip do laser muda para laranja durante o modo seleção.
|
|
||||||
|
|
||||||
### 5. `src/components/ViewerControls.tsx` + `src/components/XRHud.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`.
|
||||||
Novos botões:
|
- **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).
|
||||||
- `Selecionar` (toggle, ícone `MousePointerClick`)
|
- Remover a prop `allowScale` da assinatura (ou ignorar e tratar como `true`).
|
||||||
- `Esconder` / `Isolar` / `Mostrar tudo` (visíveis quando `selectionMode` ou há ocultos)
|
|
||||||
- `Exportar GLB` (visível quando há seleção)
|
|
||||||
- Badge com contador "N selecionados"
|
|
||||||
|
|
||||||
### 6. `src/lib/exportSelectionGLB.ts` (novo)
|
### 2. `src/pages/XRSession.tsx`
|
||||||
```ts
|
|
||||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
|
||||||
// Clona apenas os elementos selecionados em uma nova Scene,
|
|
||||||
// preserva transformações mundiais, exporta como .glb binário,
|
|
||||||
// dispara download via Blob + <a download>.
|
|
||||||
```
|
|
||||||
Nome do arquivo: `{original}_selecao_{N}elem.glb`. O GLB resultante pode ser aberto pelo próprio app via fluxo normal de import (já aceita `.glb`).
|
|
||||||
|
|
||||||
### 7. Performance
|
- Remover o estado `allowScale` e os toggles correspondentes nas duas instâncias de `<XRGrabbable>`.
|
||||||
- Aplicar visibilidade via `obj.visible = false` (não remove geometria — reversível e barato).
|
- Substituir `<ControllerFineTuning />` por um novo `<ControllerLocomotion />` (ver item 4) — o joystick não deve mais editar `fineTuning` do modelo.
|
||||||
- Frustum culling do three.js já ignora invisíveis no render → ganho real de FPS no Quest mesmo sem exportar.
|
- Garantir que `XRHitTestPlacement` só consome `onSelect` (trigger) quando `placementMode === true`; fora disso o trigger é roteado integralmente para `XRControllerMeasure`.
|
||||||
|
|
||||||
## Arquivos tocados
|
### 3. `src/components/XRHud.tsx` e `src/components/three/XRHudInWorld.tsx`
|
||||||
|
|
||||||
- `src/lib/convertIFC.ts` — marcar `userData.ifcElement`
|
- Remover botão/toggle “Permitir escala” (`allowScale`) — agora é sempre via grip duplo.
|
||||||
- `src/lib/exportSelectionGLB.ts` — **novo**
|
- 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`).
|
||||||
- `src/stores/useModelStore.ts` — slice de seleção + persistência
|
|
||||||
- `src/components/three/ModelViewer.tsx` — `SelectionHandler`, highlight, aplicar visibilidade
|
|
||||||
- `src/components/three/XRControllerMeasure.tsx` — handler de seleção em XR
|
|
||||||
- `src/components/ViewerControls.tsx` — botões desktop
|
|
||||||
- `src/components/XRHud.tsx` — botões XR
|
|
||||||
|
|
||||||
## Fora do escopo (decidido)
|
### 4. Novo `src/components/three/ControllerLocomotion.tsx`
|
||||||
|
|
||||||
- **Não** exportar IFC reduzido (decidido: arquivo é só pra este app, GLB resolve e é trivial).
|
Componente que substitui `ControllerFineTuning`. Lê `useXR().session` e implementa:
|
||||||
- **Não** suportar seleção por face/sub-mesh (decidido: elemento inteiro).
|
|
||||||
- **Não** salvar conjuntos nomeados de seleção ("layout A", "layout B") — pode virar Fase 2 se você pedir.
|
- **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.
|
||||||
|
|||||||
+26
-26
@@ -32,74 +32,74 @@ interface XRHudProps {
|
|||||||
|
|
||||||
const helpItems = [
|
const helpItems = [
|
||||||
{
|
{
|
||||||
title: 'Agarrar a peça (Grip)',
|
title: 'Grip (1 mão) — Pan',
|
||||||
icon: '✊',
|
icon: '✊',
|
||||||
desc: 'Aproxime o controle e APERTE O GRIP LATERAL — a peça vira extensão da sua mão (mover + girar em 6 eixos). Solte o grip para fixar na posição.',
|
desc: 'Aperte UM grip lateral e mova o controle: a peça é arrastada (translação) sem rotacionar. Solte para fixar.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Two-Hand (girar/escalar)',
|
title: 'Grip (2 mãos) — Zoom + Orbit',
|
||||||
icon: '🤲',
|
icon: '🤲',
|
||||||
desc: 'Aperte o grip dos DOIS controles ao mesmo tempo. Gire os punhos para rotacionar a peça pelo eixo entre as mãos. Aproxime/afaste para escalar (apenas se o toggle "Escala" estiver ON).',
|
desc: 'Aperte o grip dos DOIS controles. AFASTE/APROXIME as mãos para dar ZOOM. Gire os punhos em direções opostas para ORBITAR a peça pelo eixo entre as mãos.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trigger — Selecionar / Medir',
|
||||||
|
icon: '🔫',
|
||||||
|
desc: 'O gatilho NÃO move a peça. Ele só age dentro dos modos Selecionar, Medir ou Posicionar — escolhendo elementos, marcando pontos ou colocando o modelo na superfície.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thumbstick — Locomoção',
|
||||||
|
icon: '🕹️',
|
||||||
|
desc: 'Para FRENTE e solte: teletransporta para o ponto apontado. Para os LADOS: snap-rotate de ±30° na câmera. Para TRÁS: passo de 0,3 m no sentido oposto ao olhar.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Posicionar (Hit-Test)',
|
title: 'Posicionar (Hit-Test)',
|
||||||
icon: '🎯',
|
icon: '🎯',
|
||||||
desc: 'Aponte para uma superfície real e toque para posicionar o modelo. O retículo verde indica onde o modelo será colocado.',
|
desc: 'Ative para reposicionar o modelo. Aponte o controle para uma superfície real e aperte o TRIGGER no retículo para fixar o modelo ali.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Snap (Planos)',
|
title: 'Snap (Planos)',
|
||||||
icon: '🧲',
|
icon: '🧲',
|
||||||
desc: 'Quando ativado, o modelo se alinha automaticamente a planos detectados (chão, parede, mesa). O retículo fica azul quando snap está ativo.',
|
desc: 'Quando ON, o modelo se alinha automaticamente aos planos detectados (chão, parede, mesa). Retículo azul indica snap ativo.',
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Joystick (ajuste fino)',
|
|
||||||
icon: '🕹️',
|
|
||||||
desc: 'Esquerdo: X (lateral) / Z (frente-trás). Direito: rotação Y / altura Y. Aperte o TRIGGER para acelerar (leve = passo fino, forte = passo rápido). O joystick é desativado enquanto você segura o grip (que tem prioridade para agarrar).',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Grid',
|
title: 'Grid',
|
||||||
icon: '📐',
|
icon: '📐',
|
||||||
desc: 'Exibe/oculta a grade de referência para auxiliar no alinhamento visual do modelo com o ambiente.',
|
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sólido / Wire / Bordas',
|
title: 'Sólido / Wire / Bordas',
|
||||||
icon: '🔲',
|
icon: '🔲',
|
||||||
desc: 'Alterna entre modos de renderização: Sólido (preenchido), Wireframe (malha de arame), Bordas (apenas contornos).',
|
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Medir',
|
title: 'Medir',
|
||||||
icon: '📏',
|
icon: '📏',
|
||||||
desc: 'Ative e toque em dois pontos no modelo para medir a distância entre eles em milímetros. O snap de vértice auxilia na precisão.',
|
desc: 'Ative e toque o TRIGGER em dois pontos da peça para medir a distância em milímetros. O snap de vértice auxilia a precisão.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Screenshot',
|
title: 'Screenshot',
|
||||||
icon: '📸',
|
icon: '📸',
|
||||||
desc: 'Captura uma imagem da visão AR atual. As capturas ficam salvas na galeria do viewer.',
|
desc: 'Captura uma imagem da visão AR atual. As capturas ficam na galeria do viewer.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Inspeção',
|
title: 'Inspeção',
|
||||||
icon: '📋',
|
icon: '📋',
|
||||||
desc: 'Abre o checklist de inspeção para aprovar ou rejeitar itens como dimensões, furos, soldas e posição das placas.',
|
desc: 'Abre o checklist para aprovar ou rejeitar itens (dimensões, furos, soldas, posição das chapas).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Opacidade',
|
title: 'Opacidade',
|
||||||
icon: '👁️',
|
icon: '👁️',
|
||||||
desc: 'Ajusta a transparência do modelo de 0% (invisível) a 100% (opaco) para visualizar através da peça.',
|
desc: 'Ajusta a transparência do modelo de 0% a 100% para ver através da peça.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Posição (mm)',
|
title: 'Ajuste fino (Posição/Rotação)',
|
||||||
icon: '↔️',
|
icon: '↔️',
|
||||||
desc: 'Ajuste fino da posição do modelo nos eixos X, Y e Z com incrementos de 1mm usando os botões +/−.',
|
desc: 'Use os botões +/− do painel para ajustes finos em milímetros (posição) e décimos de grau (rotação). O joystick não faz mais ajuste fino — ele agora é só locomoção.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rotação X° / Y° / Z°',
|
title: 'Botões A/B (Touch Plus direito)',
|
||||||
icon: '🔄',
|
|
||||||
desc: 'Rotaciona o modelo ao redor de cada eixo com incrementos de 0.1°. X = inclinação frontal, Y = giro lateral, Z = tombamento.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Botões A/B (Touch Plus)',
|
|
||||||
icon: '🎮',
|
icon: '🎮',
|
||||||
desc: 'Atualmente reservados para o sistema (menu Meta). O fluxo natural usa Grip para agarrar e Trigger para acelerar o joystick.',
|
desc: 'No modo Medir: A = desfazer último ponto; B = limpar todas as medições. O botão Menu (esquerdo) abre o menu Universal do Quest.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useRef, RefObject } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { useXR } from '@react-three/xr';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
||||||
|
rigRef: 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 session = useXR((s) => s.session);
|
||||||
|
|
||||||
|
const fwdPushed = useRef(false);
|
||||||
|
const backLatched = useRef(false);
|
||||||
|
const rotLatched = useRef<0 | -1 | 1>(0);
|
||||||
|
const raycaster = useRef(new THREE.Raycaster());
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const rig = rigRef.current;
|
||||||
|
if (!rig || !session) return;
|
||||||
|
|
||||||
|
let stickX = 0;
|
||||||
|
let stickY = 0;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stickY < RELEASE) {
|
||||||
|
backLatched.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Forward → teleport on release ─────────────────────────────
|
||||||
|
if (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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useController
|
|||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
interface XRGrabbableProps {
|
interface XRGrabbableProps {
|
||||||
/** Allow uniform scaling via two-hand grab (distance between hands) */
|
/**
|
||||||
|
* @deprecated Mantido por compatibilidade — agora o zoom por duas mãos
|
||||||
|
* está SEMPRE ativo (faz parte do mapeamento padrão do grip).
|
||||||
|
*/
|
||||||
allowScale?: boolean;
|
allowScale?: boolean;
|
||||||
/** When true, the active model is locked — grab is ignored entirely */
|
/** When true, the active model is locked — grab is ignored entirely */
|
||||||
lockedActive?: boolean;
|
lockedActive?: boolean;
|
||||||
@@ -16,8 +19,8 @@ interface XRGrabbableProps {
|
|||||||
|
|
||||||
interface SingleGrabRecord {
|
interface SingleGrabRecord {
|
||||||
hand: 'left' | 'right';
|
hand: 'left' | 'right';
|
||||||
/** Offset = gripWorld^-1 * groupWorld at grab start */
|
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
||||||
offset: THREE.Matrix4;
|
offsetWorld: THREE.Vector3;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DualGrabRecord {
|
interface DualGrabRecord {
|
||||||
@@ -33,22 +36,22 @@ interface DualGrabRecord {
|
|||||||
startScale: number;
|
startScale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _tmpMat = new THREE.Matrix4();
|
|
||||||
const _tmpMat2 = new THREE.Matrix4();
|
const _tmpMat2 = new THREE.Matrix4();
|
||||||
const _tmpVecL = new THREE.Vector3();
|
const _tmpVecL = new THREE.Vector3();
|
||||||
const _tmpVecR = new THREE.Vector3();
|
const _tmpVecR = new THREE.Vector3();
|
||||||
const _tmpQuat = new THREE.Quaternion();
|
const _tmpQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in a group whose pose is driven by Touch Plus grip:
|
* Touch Plus grip mapping (Meta Quest 3):
|
||||||
* • One-hand grab: model becomes an extension of that hand (6DOF).
|
|
||||||
* • Two-hand grab: midpoint = pivot, axis between hands = rotation,
|
|
||||||
* distance ratio = uniform scale (if allowScale).
|
|
||||||
*
|
*
|
||||||
* On release the group keeps its last pose (we don't write to the store —
|
* • One-hand grip → **PAN ONLY** (translação). A rotação e a escala
|
||||||
* the manual fine-tuning sliders remain available as additional offsets).
|
* 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).
|
||||||
*/
|
*/
|
||||||
export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const grab = useControllerGrab();
|
const grab = useControllerGrab();
|
||||||
|
|
||||||
@@ -63,7 +66,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
const g = groupRef.current;
|
const g = groupRef.current;
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
g.scale.set(1, 1, 1);
|
g.scale.set(1, 1, 1);
|
||||||
// Abort any in-progress two-hand scaling so it doesn't snap back.
|
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
single.current = null;
|
single.current = null;
|
||||||
}, [scaleResetNonce]);
|
}, [scaleResetNonce]);
|
||||||
@@ -75,7 +77,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
const L = snap.left;
|
const L = snap.left;
|
||||||
const R = snap.right;
|
const R = snap.right;
|
||||||
|
|
||||||
// Locked: ignore all grab input — release any in-progress grabs and bail.
|
|
||||||
if (lockedActive) {
|
if (lockedActive) {
|
||||||
if (single.current) single.current = null;
|
if (single.current) single.current = null;
|
||||||
if (dual.current) dual.current = null;
|
if (dual.current) dual.current = null;
|
||||||
@@ -94,7 +95,7 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Two-hand mode ───────────────────────────────────────────
|
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
||||||
if (lActive && rActive) {
|
if (lActive && rActive) {
|
||||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||||
@@ -105,7 +106,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
axisNow.divideScalar(distNow);
|
axisNow.divideScalar(distNow);
|
||||||
|
|
||||||
if (!dual.current) {
|
if (!dual.current) {
|
||||||
// Capture
|
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
dual.current = {
|
dual.current = {
|
||||||
startGroupWorld: group.matrixWorld.clone(),
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
@@ -114,26 +114,21 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
startDist: distNow,
|
startDist: distNow,
|
||||||
startScale: group.scale.x,
|
startScale: group.scale.x,
|
||||||
};
|
};
|
||||||
single.current = null; // dual takes over
|
single.current = null;
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start');
|
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = dual.current;
|
const d = dual.current;
|
||||||
// Rotation delta: from startAxis to axisNow
|
|
||||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
// Scale ratio
|
// ZOOM sempre ativo no grip duplo
|
||||||
const scaleRatio = allowScale
|
const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10);
|
||||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
// Build target world: T(midNow) * R(delta) * S(ratio) * T(-startMid) * startGroupWorld
|
|
||||||
const m = new THREE.Matrix4();
|
|
||||||
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
|
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 tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
||||||
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
||||||
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
||||||
m.identity()
|
const m = new THREE.Matrix4().identity()
|
||||||
.multiply(tBack)
|
.multiply(tBack)
|
||||||
.multiply(rot)
|
.multiply(rot)
|
||||||
.multiply(scl)
|
.multiply(scl)
|
||||||
@@ -147,21 +142,31 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
dual.current = null;
|
dual.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── One-hand mode ───────────────────────────────────────────
|
// ─── One-hand mode (PAN ONLY) ────────────────────────────────
|
||||||
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
||||||
if (activeHand) {
|
if (activeHand) {
|
||||||
const slot = activeHand === 'left' ? L : R;
|
const slot = activeHand === 'left' ? L : R;
|
||||||
|
const ctrlPos = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
|
||||||
|
|
||||||
if (!single.current || single.current.hand !== activeHand) {
|
if (!single.current || single.current.hand !== activeHand) {
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
const offset = new THREE.Matrix4()
|
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
||||||
.copy(slot.gripWorld).invert()
|
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||||
.multiply(group.matrixWorld);
|
single.current = { hand: activeHand, offsetWorld };
|
||||||
single.current = { hand: activeHand, offset };
|
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`);
|
||||||
}
|
}
|
||||||
_tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset);
|
|
||||||
applyWorldMatrixToLocal(group, _tmpMat);
|
// Target world position = controller + initial offset
|
||||||
|
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
||||||
|
|
||||||
|
// Convert to parent-local position (preserve current rotation/scale)
|
||||||
|
if (group.parent) {
|
||||||
|
group.parent.updateMatrixWorld();
|
||||||
|
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
||||||
|
targetWorldPos.applyMatrix4(invParent);
|
||||||
|
}
|
||||||
|
group.position.copy(targetWorldPos);
|
||||||
} else if (single.current) {
|
} else if (single.current) {
|
||||||
single.current = null;
|
single.current = null;
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
{/* Subtle halo proportional to analog grip — invisible by default */}
|
|
||||||
<mesh ref={haloRef} visible={false}>
|
<mesh ref={haloRef} visible={false}>
|
||||||
<sphereGeometry args={[0.5, 16, 12]} />
|
<sphereGeometry args={[0.5, 16, 12]} />
|
||||||
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
||||||
|
|||||||
+10
-3
@@ -17,6 +17,7 @@ import { findNearestVertex } from '@/components/three/SmartMeasure';
|
|||||||
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
||||||
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
||||||
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
|
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
|
||||||
|
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
|
||||||
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
|
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
|
||||||
import { useDevKit } from '@/devkit/useDevKit';
|
import { useDevKit } from '@/devkit/useDevKit';
|
||||||
import { DevPanel } from '@/devkit/DevPanel';
|
import { DevPanel } from '@/devkit/DevPanel';
|
||||||
@@ -470,9 +471,13 @@ const XRSession = () => {
|
|||||||
const [freeMove, setFreeMove] = useState(true);
|
const [freeMove, setFreeMove] = useState(true);
|
||||||
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
||||||
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||||
const [allowScale, setAllowScale] = useState(false);
|
// Zoom por grip duplo é SEMPRE ligado (parte do mapeamento padrão).
|
||||||
|
const allowScale = true;
|
||||||
|
const setAllowScale = (_v?: boolean) => {};
|
||||||
const [liveCode, setLiveCode] = useState<string | null>(null);
|
const [liveCode, setLiveCode] = useState<string | null>(null);
|
||||||
const [liveViewers, setLiveViewers] = useState(0);
|
const [liveViewers, setLiveViewers] = useState(0);
|
||||||
|
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
|
||||||
|
const rigRef = useRef<THREE.Group>(null);
|
||||||
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
||||||
const devkit = useDevKit();
|
const devkit = useDevKit();
|
||||||
const [simXR, setSimXR] = useState(false);
|
const [simXR, setSimXR] = useState(false);
|
||||||
@@ -569,7 +574,9 @@ const XRSession = () => {
|
|||||||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||||||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||||||
|
|
||||||
<XROrigin />
|
<group ref={rigRef}>
|
||||||
|
<XROrigin />
|
||||||
|
</group>
|
||||||
|
|
||||||
{effectiveInXR ? (
|
{effectiveInXR ? (
|
||||||
<>
|
<>
|
||||||
@@ -609,7 +616,7 @@ const XRSession = () => {
|
|||||||
|
|
||||||
<XRMeasurementOverlay />
|
<XRMeasurementOverlay />
|
||||||
<XRControllerMeasure />
|
<XRControllerMeasure />
|
||||||
<ControllerFineTuning freeMove={freeMove} />
|
<ControllerLocomotion rigRef={rigRef} />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user