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:
gpt-engineer-app[bot]
2026-05-21 21:08:51 +00:00
5 changed files with 257 additions and 132 deletions
+67 -69
View File
@@ -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
View File
@@ -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;
}
+38 -34
View File
@@ -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
View File
@@ -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} />