Adicionou nativo 6DOF grab VR
X-Lovable-Edit-ID: edt-a55b54f1-7038-46f0-b511-8c9f930a9373 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+62
-38
@@ -1,54 +1,78 @@
|
|||||||
|
## Plano: Grab nativo dos controles Touch Plus (Quest 3) para "vestir" a peça virtual
|
||||||
|
|
||||||
|
Objetivo: permitir que o usuário **agarre a peça virtual com o grip** do Touch Plus e a movimente em 6 graus de liberdade junto com a mão (translação + rotação simultâneas), e use **as duas mãos juntas** para girar/escalar com naturalidade. Joystick atual continua existindo como ajuste fino.
|
||||||
|
|
||||||
## Plan: Help Button + Full Rotation Controls (X, Y, Z)
|
### 1. Camada de input nova: `useControllerGrab`
|
||||||
|
|
||||||
### 1. Add rotX and rotZ to the store
|
Novo hook em `src/hooks/useControllerGrab.ts` que encapsula tudo que falta hoje:
|
||||||
|
|
||||||
Update `src/stores/useModelStore.ts`:
|
- Lê `session.inputSources` a cada frame e identifica os `XRInputSource` "left" e "right".
|
||||||
- Add `rotX: number` and `rotZ: number` to the `FineTuning` interface
|
- Para cada controle, expõe:
|
||||||
- Set defaults to 0 in `defaultFineTuning`
|
- `gripValue` (0..1, analógico, vindo de `gamepad.buttons[2].value`).
|
||||||
|
- `triggerValue` (0..1, `buttons[1].value`).
|
||||||
|
- `isGrabbing` (boolean com histerese: começa em `value > 0.7`, termina em `< 0.3` — evita flicker).
|
||||||
|
- `gripPose`: matrix4 da pose do `inputSource.gripSpace` no `referenceSpace` atual, atualizada por frame via `XRFrame.getPose()`.
|
||||||
|
- Também escuta os eventos nativos `squeezestart` / `squeezeend` da `XRSession` como fallback caso o gamepad não exponha valor analógico.
|
||||||
|
|
||||||
### 2. Apply rotX/rotZ to the XR model
|
### 2. Componente `XRGrabbable` em volta da peça
|
||||||
|
|
||||||
Update `src/pages/XRSession.tsx` (`XRModel` component):
|
Novo arquivo `src/components/three/XRGrabbable.tsx`. Wrappa o `<XRModel/>` em um `<group>` controlado e implementa as três modalidades de grab:
|
||||||
- Read `rotX`, `rotZ` from fineTuning
|
|
||||||
- Change `rotation={[0, rotYRad, 0]}` to `rotation={[rotXRad, rotYRad, rotZRad]}`
|
|
||||||
|
|
||||||
Update `ControllerFineTuning`:
|
- **One-hand grab**: quando `isGrabbing` de uma mão dispara, calcula matrix relativa `M_offset = gripPose⁻¹ · modelPose`. A cada frame, enquanto segura, faz `modelPose = gripPose · M_offset`. Resultado: a peça vira "extensão da mão", com translação + rotação simultâneas.
|
||||||
- Map additional controller inputs (e.g., A/B buttons or grip+joystick combos) to rotX/rotZ, or leave rotation to the HUD controls
|
- **Two-hand grab** (quando ambas as mãos estão segurando):
|
||||||
|
- Pivô = ponto médio entre os dois `gripPose`.
|
||||||
|
- Rotação = orientação do vetor entre as duas mãos (mantém o cabo da viga sempre alinhado com o eixo entre os punhos).
|
||||||
|
- Escala = razão entre a distância atual e a distância no momento em que a 2ª mão agarrou (com clamp 0.1× a 10×). **Opcional**: pode ser desabilitada na HUD para usos onde a escala 1:1 é obrigatória (caso "vestir" sobre peça real).
|
||||||
|
- Ao soltar (`isGrabbing` cai), grava a pose final em `useModelStore.fineTuning` (decompõe em `posX/Y/Z`, `rotX/Y/Z`, e `scale` se two-hand estiver ativo) — assim o estado fica consistente com o joystick e os controles do HUD.
|
||||||
|
|
||||||
### 3. Add rotX/rotZ controls to XRHud
|
### 3. Joystick continua, agora modulado pelo grip analógico
|
||||||
|
|
||||||
Update `src/components/XRHud.tsx`:
|
Em `ControllerFineTuning` (dentro de `XRSession.tsx`):
|
||||||
- In the fine-tuning expanded panel, add rotation rows for X° and Z° alongside the existing Y° control (same `+/−` button pattern)
|
|
||||||
|
|
||||||
### 4. Create Help panel with (?) button
|
- Mantém o mapeamento atual mas **só ativa quando NENHUM grip está apertado** (para não brigar com o grab).
|
||||||
|
- `posStep` e `rotStep` ganham multiplicador derivado de `triggerValue` da mesma mão: trigger leve = passo fino (×0.3), trigger forte = passo rápido (×3). Resolve a queixa de "movimento mecânico".
|
||||||
|
- Mapeamento corrigido para o padrão VR (já discutido):
|
||||||
|
- Esquerdo: X horizontal / Z vertical (frente-trás).
|
||||||
|
- Direito: rotY horizontal / Y altura vertical.
|
||||||
|
- Adiciona aceleração proporcional (curva quadrática no input do thumbstick) e deadzone de 0.15.
|
||||||
|
|
||||||
Update `src/components/XRHud.tsx`:
|
### 4. Escala no store
|
||||||
- Add a `HelpCircle` (?) icon button to the main toolbar
|
|
||||||
- When pressed, open a full-height slide-out panel (same pattern as the checklist panel) containing a scrollable list of all tools and controls with descriptions:
|
|
||||||
|
|
||||||
Content sections:
|
`src/stores/useModelStore.ts`: adicionar `scale: number` ao `FineTuning` (default 1). `XRModel` e `ModelViewer` aplicam `scale={[s,s,s]}` no `<group>`.
|
||||||
- **Posicionar (Hit-Test)**: Tap to place model on real surfaces
|
|
||||||
- **Snap**: Auto-align to detected planes (floor, wall, table)
|
|
||||||
- **Mover (Free Move)**: Use left joystick for X/Y, right joystick for Z movement
|
|
||||||
- **Grid**: Toggle reference grid
|
|
||||||
- **Sólido / Wire / Bordas**: Render mode switching
|
|
||||||
- **Medir**: Tap two points to measure distance in mm
|
|
||||||
- **Screenshot**: Capture current AR view
|
|
||||||
- **Inspeção**: Open checklist to approve/reject items
|
|
||||||
- **Opacidade**: Adjust model transparency
|
|
||||||
- **Posição (mm)**: Fine-tune X/Y/Z in 1mm steps
|
|
||||||
- **Rotação X° / Y° / Z°**: Rotate model around each axis
|
|
||||||
- **Controladores Quest 3**: Joystick left = move X/Y, joystick right = move Z + rotate Y, grip = hold to move (when Free Move is off)
|
|
||||||
|
|
||||||
### 5. Also apply rotX/rotZ in desktop ModelViewer (if used)
|
### 5. UI: feedback visual e ajuda
|
||||||
|
|
||||||
Check `src/components/three/ModelViewer.tsx` and update rotation there too for consistency.
|
- `XRGrabbable` mostra um halo sutil ao redor da peça quando `gripValue > 0.1` em qualquer mão (intensidade proporcional → o usuário sente a pressão analógica).
|
||||||
|
- `src/components/XRHud.tsx`: na seção de Ajuda (?), adicionar bloco "Agarrar a peça":
|
||||||
|
- "Aperte o grip lateral de um controle perto da peça → ela vira extensão da sua mão."
|
||||||
|
- "Agarre com os dois grips ao mesmo tempo → rotacione girando os punhos / aproxime-afaste para escalar."
|
||||||
|
- "Solte o grip para fixar."
|
||||||
|
- "Trigger modula a velocidade do joystick (leve = fino, forte = rápido)."
|
||||||
|
- Adicionar toggle "Permitir escala (two-hand)" — default OFF para preservar 1:1.
|
||||||
|
|
||||||
### Files to modify
|
### 6. Integração e ordem de aplicação
|
||||||
- `src/stores/useModelStore.ts` — add rotX, rotZ fields
|
|
||||||
- `src/pages/XRSession.tsx` — apply 3-axis rotation, update controller mapping
|
|
||||||
- `src/components/XRHud.tsx` — add rotation controls for X/Z, add help (?) panel
|
|
||||||
- `src/components/three/ModelViewer.tsx` — apply rotX/rotZ if fineTuning is used there
|
|
||||||
|
|
||||||
|
No `XRSession.tsx`, a árvore vira:
|
||||||
|
```
|
||||||
|
<XRHitTestPlacement> (posicionamento inicial)
|
||||||
|
<XRGrabbable> (grab 6DOF + two-hand)
|
||||||
|
<XRModel/>
|
||||||
|
```
|
||||||
|
Hit-test só atua enquanto `placementMode = true`. Quando o usuário agarra pela primeira vez, `placementMode` é desligado automaticamente.
|
||||||
|
|
||||||
|
### Detalhes técnicos
|
||||||
|
|
||||||
|
- Tudo via WebXR Gamepad API + `XRFrame.getPose(gripSpace, referenceSpace)`. Sem dependências novas.
|
||||||
|
- Histerese do grip evita perder o grab por tremor de mão.
|
||||||
|
- Decomposição de matrix usa `THREE.Matrix4.decompose()` para gravar de volta no store sem perder rotação composta.
|
||||||
|
- Plane snapping continua ativo durante o grab (snap acontece no `onRelease`, não a cada frame, para não travar o movimento).
|
||||||
|
- Logs `[XR][grab]` para diagnóstico (start/end, qual mão, valor analógico).
|
||||||
|
|
||||||
|
### Arquivos afetados
|
||||||
|
|
||||||
|
- **Novo**: `src/hooks/useControllerGrab.ts`
|
||||||
|
- **Novo**: `src/components/three/XRGrabbable.tsx`
|
||||||
|
- **Editado**: `src/pages/XRSession.tsx` — envolver modelo, ajustar `ControllerFineTuning` (mapeamento + modulação por trigger + bypass quando grip ativo)
|
||||||
|
- **Editado**: `src/stores/useModelStore.ts` — campo `scale`
|
||||||
|
- **Editado**: `src/components/three/ModelViewer.tsx` — aplicar `scale`
|
||||||
|
- **Editado**: `src/components/XRHud.tsx` — toggle "permitir escala", ajuda nova
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
||||||
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
||||||
ClipboardCheck, X, Crosshair, Magnet, HelpCircle,
|
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
@@ -21,9 +21,21 @@ interface XRHudProps {
|
|||||||
onTogglePlacement?: () => void;
|
onTogglePlacement?: () => void;
|
||||||
snapToPlanes?: boolean;
|
snapToPlanes?: boolean;
|
||||||
onToggleSnap?: () => void;
|
onToggleSnap?: () => void;
|
||||||
|
allowScale?: boolean;
|
||||||
|
onToggleAllowScale?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpItems = [
|
const helpItems = [
|
||||||
|
{
|
||||||
|
title: 'Agarrar a peça (Grip)',
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Two-Hand (girar/escalar)',
|
||||||
|
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).',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Posicionar (Hit-Test)',
|
title: 'Posicionar (Hit-Test)',
|
||||||
icon: '🎯',
|
icon: '🎯',
|
||||||
@@ -35,9 +47,9 @@ const helpItems = [
|
|||||||
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 ativado, o modelo se alinha automaticamente a planos detectados (chão, parede, mesa). O retículo fica azul quando snap está ativo.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Mover (Free Move)',
|
title: 'Joystick (ajuste fino)',
|
||||||
icon: '🕹️',
|
icon: '🕹️',
|
||||||
desc: 'Joystick esquerdo: move o modelo nos eixos X/Y. Joystick direito (vertical): move no eixo Z (profundidade). Quando desativado, é necessário segurar o grip para mover.',
|
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',
|
||||||
@@ -80,13 +92,13 @@ const helpItems = [
|
|||||||
desc: 'Rotaciona o modelo ao redor de cada eixo com incrementos de 0.1°. X = inclinação frontal, Y = giro lateral, Z = tombamento.',
|
desc: 'Rotaciona o modelo ao redor de cada eixo com incrementos de 0.1°. X = inclinação frontal, Y = giro lateral, Z = tombamento.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Controladores Quest 3',
|
title: 'Botões A/B (Touch Plus)',
|
||||||
icon: '🎮',
|
icon: '🎮',
|
||||||
desc: 'Joystick Esq: mover X/Y • Joystick Dir: mover Z + rotação Y • Grip (segurar): habilita movimento quando Free Move está desativado.',
|
desc: 'Atualmente reservados para o sistema (menu Meta). O fluxo natural usa Grip para agarrar e Trigger para acelerar o joystick.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement, snapToPlanes = true, onToggleSnap }: XRHudProps) {
|
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement, snapToPlanes = true, onToggleSnap, allowScale = false, onToggleAllowScale }: XRHudProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
@@ -285,6 +297,19 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Two-hand scale 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)"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
{allowScale ? 'Escala ON' : 'Escala'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={freeMove ? 'default' : 'outline'}
|
variant={freeMove ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||||
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
||||||
|
const s = fineTuning.scale ?? 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
@@ -127,6 +128,7 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
-modelInfo.center.z + fineTuning.posZ,
|
-modelInfo.center.z + fineTuning.posZ,
|
||||||
]}
|
]}
|
||||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||||
|
scale={[s, s, s]}
|
||||||
>
|
>
|
||||||
<primitive object={scene} />
|
<primitive object={scene} />
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useRef, ReactNode } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
||||||
|
|
||||||
|
interface XRGrabbableProps {
|
||||||
|
/** Allow uniform scaling via two-hand grab (distance between hands) */
|
||||||
|
allowScale?: boolean;
|
||||||
|
/** Called the first time the user grabs (e.g. to exit placement mode) */
|
||||||
|
onGrabStart?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleGrabRecord {
|
||||||
|
hand: 'left' | 'right';
|
||||||
|
/** Offset = gripWorld^-1 * groupWorld at grab start */
|
||||||
|
offset: THREE.Matrix4;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DualGrabRecord {
|
||||||
|
/** group^world at grab start of second hand */
|
||||||
|
startGroupWorld: THREE.Matrix4;
|
||||||
|
/** midpoint between hands at start (world) */
|
||||||
|
startMid: THREE.Vector3;
|
||||||
|
/** vector from left to right at start (world, normalized) */
|
||||||
|
startAxis: THREE.Vector3;
|
||||||
|
/** distance between hands at start */
|
||||||
|
startDist: number;
|
||||||
|
/** scale at start */
|
||||||
|
startScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _tmpMat = new THREE.Matrix4();
|
||||||
|
const _tmpMat2 = new THREE.Matrix4();
|
||||||
|
const _tmpVecL = new THREE.Vector3();
|
||||||
|
const _tmpVecR = new THREE.Vector3();
|
||||||
|
const _tmpQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps children in a group whose pose is driven by Touch Plus grip:
|
||||||
|
* • 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 —
|
||||||
|
* the manual fine-tuning sliders remain available as additional offsets).
|
||||||
|
*/
|
||||||
|
export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const grab = useControllerGrab();
|
||||||
|
|
||||||
|
const single = useRef<SingleGrabRecord | null>(null);
|
||||||
|
const dual = useRef<DualGrabRecord | null>(null);
|
||||||
|
const haloRef = useRef<THREE.Mesh>(null);
|
||||||
|
const everGrabbed = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
const snap: ControllerGrabSnapshot = grab.current;
|
||||||
|
const L = snap.left;
|
||||||
|
const R = snap.right;
|
||||||
|
|
||||||
|
const lActive = L.isGrabbing && L.hasPose;
|
||||||
|
const rActive = R.isGrabbing && R.hasPose;
|
||||||
|
|
||||||
|
// ─── Halo intensity (visual feedback for analog grip) ────────
|
||||||
|
if (haloRef.current) {
|
||||||
|
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
||||||
|
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = maxGrip * 0.25;
|
||||||
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Two-hand mode ───────────────────────────────────────────
|
||||||
|
if (lActive && rActive) {
|
||||||
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
|
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||||
|
const midNow = new THREE.Vector3().addVectors(_tmpVecL, _tmpVecR).multiplyScalar(0.5);
|
||||||
|
const axisNow = new THREE.Vector3().subVectors(_tmpVecR, _tmpVecL);
|
||||||
|
const distNow = axisNow.length();
|
||||||
|
if (distNow < 1e-4) return;
|
||||||
|
axisNow.divideScalar(distNow);
|
||||||
|
|
||||||
|
if (!dual.current) {
|
||||||
|
// Capture
|
||||||
|
group.updateMatrixWorld();
|
||||||
|
dual.current = {
|
||||||
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
|
startMid: midNow.clone(),
|
||||||
|
startAxis: axisNow.clone(),
|
||||||
|
startDist: distNow,
|
||||||
|
startScale: group.scale.x,
|
||||||
|
};
|
||||||
|
single.current = null; // dual takes over
|
||||||
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
|
console.log('[XR][grab] ◆ TWO-HAND start');
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = dual.current;
|
||||||
|
// Rotation delta: from startAxis to axisNow
|
||||||
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
|
// Scale ratio
|
||||||
|
const scaleRatio = allowScale
|
||||||
|
? 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 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);
|
||||||
|
m.identity()
|
||||||
|
.multiply(tBack)
|
||||||
|
.multiply(rot)
|
||||||
|
.multiply(scl)
|
||||||
|
.multiply(tToOrigin)
|
||||||
|
.multiply(d.startGroupWorld);
|
||||||
|
|
||||||
|
applyWorldMatrixToLocal(group, m);
|
||||||
|
return;
|
||||||
|
} else if (dual.current) {
|
||||||
|
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||||
|
dual.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── One-hand mode ───────────────────────────────────────────
|
||||||
|
const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null;
|
||||||
|
if (activeHand) {
|
||||||
|
const slot = activeHand === 'left' ? L : R;
|
||||||
|
if (!single.current || single.current.hand !== activeHand) {
|
||||||
|
group.updateMatrixWorld();
|
||||||
|
const offset = new THREE.Matrix4()
|
||||||
|
.copy(slot.gripWorld).invert()
|
||||||
|
.multiply(group.matrixWorld);
|
||||||
|
single.current = { hand: activeHand, offset };
|
||||||
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`);
|
||||||
|
}
|
||||||
|
_tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset);
|
||||||
|
applyWorldMatrixToLocal(group, _tmpMat);
|
||||||
|
} else if (single.current) {
|
||||||
|
single.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{/* Subtle halo proportional to analog grip — invisible by default */}
|
||||||
|
<mesh ref={haloRef} visible={false}>
|
||||||
|
<sphereGeometry args={[0.5, 16, 12]} />
|
||||||
|
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
{children}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
_tmpMat2.copy(group.parent.matrixWorld).invert().multiply(worldMatrix);
|
||||||
|
} else {
|
||||||
|
_tmpMat2.copy(worldMatrix);
|
||||||
|
}
|
||||||
|
_tmpMat2.decompose(group.position, _tmpQuat, group.scale);
|
||||||
|
group.quaternion.copy(_tmpQuat);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export interface GrabState {
|
||||||
|
/** Analog grip pressure (0..1) */
|
||||||
|
gripValue: number;
|
||||||
|
/** Analog trigger pressure (0..1) */
|
||||||
|
triggerValue: number;
|
||||||
|
/** True when grip pressure crosses grab threshold (with hysteresis) */
|
||||||
|
isGrabbing: boolean;
|
||||||
|
/** World-space matrix of the gripSpace (Touch Plus controller pose) */
|
||||||
|
gripWorld: THREE.Matrix4;
|
||||||
|
/** True when controller pose is valid this frame */
|
||||||
|
hasPose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerGrabSnapshot {
|
||||||
|
left: GrabState;
|
||||||
|
right: GrabState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRAB_ON = 0.7;
|
||||||
|
const GRAB_OFF = 0.3;
|
||||||
|
|
||||||
|
function makeState(): GrabState {
|
||||||
|
return {
|
||||||
|
gripValue: 0,
|
||||||
|
triggerValue: 0,
|
||||||
|
isGrabbing: false,
|
||||||
|
gripWorld: new THREE.Matrix4(),
|
||||||
|
hasPose: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls WebXR input sources every frame and writes Touch Plus controller
|
||||||
|
* state (grip analog, trigger analog, grip world pose, isGrabbing with
|
||||||
|
* hysteresis) into a stable ref object.
|
||||||
|
*
|
||||||
|
* The returned ref is mutated in place — callers should also call useFrame()
|
||||||
|
* with a *later* registration to read it. Pass a higher `renderPriority` to
|
||||||
|
* the consumer's useFrame to ensure ordering.
|
||||||
|
*/
|
||||||
|
export function useControllerGrab() {
|
||||||
|
const ref = useRef<ControllerGrabSnapshot>({
|
||||||
|
left: makeState(),
|
||||||
|
right: makeState(),
|
||||||
|
});
|
||||||
|
const { gl } = useThree();
|
||||||
|
|
||||||
|
useFrame((_state, _delta, frame: XRFrame | undefined) => {
|
||||||
|
if (!frame) return;
|
||||||
|
const session = frame.session;
|
||||||
|
const referenceSpace = gl.xr.getReferenceSpace();
|
||||||
|
if (!session || !referenceSpace) return;
|
||||||
|
|
||||||
|
// Reset hasPose flags
|
||||||
|
ref.current.left.hasPose = false;
|
||||||
|
ref.current.right.hasPose = false;
|
||||||
|
|
||||||
|
for (const source of session.inputSources) {
|
||||||
|
if (source.handedness !== 'left' && source.handedness !== 'right') continue;
|
||||||
|
const slot = ref.current[source.handedness];
|
||||||
|
const gp = source.gamepad;
|
||||||
|
|
||||||
|
if (gp) {
|
||||||
|
const gripBtn = gp.buttons[2];
|
||||||
|
const trigBtn = gp.buttons[1];
|
||||||
|
const gripValue = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||||
|
const trigValue = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||||
|
slot.gripValue = gripValue;
|
||||||
|
slot.triggerValue = trigValue;
|
||||||
|
// Hysteresis
|
||||||
|
if (!slot.isGrabbing && gripValue > GRAB_ON) {
|
||||||
|
slot.isGrabbing = true;
|
||||||
|
console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`);
|
||||||
|
} else if (slot.isGrabbing && gripValue < GRAB_OFF) {
|
||||||
|
slot.isGrabbing = false;
|
||||||
|
console.log(`[XR][grab] ◼ ${source.handedness} END`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.gripSpace) {
|
||||||
|
const pose = frame.getPose(source.gripSpace, referenceSpace);
|
||||||
|
if (pose) {
|
||||||
|
slot.gripWorld.fromArray(pose.transform.matrix);
|
||||||
|
slot.hasPose = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
+46
-10
@@ -12,6 +12,7 @@ import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
|||||||
import { XRHud } from '@/components/XRHud';
|
import { XRHud } from '@/components/XRHud';
|
||||||
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
||||||
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
||||||
|
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
||||||
|
|
||||||
// --- Diagnóstico XR ---
|
// --- Diagnóstico XR ---
|
||||||
console.log('[XR] Inicializando store...');
|
console.log('[XR] Inicializando store...');
|
||||||
@@ -111,6 +112,7 @@ function XRModel({ url }: { url: string }) {
|
|||||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||||
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
||||||
|
const s = fineTuning.scale ?? 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
@@ -121,6 +123,7 @@ function XRModel({ url }: { url: string }) {
|
|||||||
-modelInfo.center.z + fineTuning.posZ,
|
-modelInfo.center.z + fineTuning.posZ,
|
||||||
]}
|
]}
|
||||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||||
|
scale={[s, s, s]}
|
||||||
>
|
>
|
||||||
<primitive object={scene} />
|
<primitive object={scene} />
|
||||||
</group>
|
</group>
|
||||||
@@ -128,6 +131,9 @@ function XRModel({ url }: { url: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── ControllerFineTuning ──────────────────────────────
|
// ─── ControllerFineTuning ──────────────────────────────
|
||||||
|
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
||||||
|
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
||||||
|
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
|
||||||
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||||
const { setFineTuning } = useModelStore();
|
const { setFineTuning } = useModelStore();
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
@@ -139,37 +145,57 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|||||||
|
|
||||||
let leftAxes: number[] | null = null;
|
let leftAxes: number[] | null = null;
|
||||||
let rightAxes: number[] | null = null;
|
let rightAxes: number[] | null = null;
|
||||||
|
let leftTrig = 0;
|
||||||
|
let rightTrig = 0;
|
||||||
let gripHeld = false;
|
let gripHeld = false;
|
||||||
|
let anyGripHeld = false;
|
||||||
|
|
||||||
for (const source of inputSources) {
|
for (const source of inputSources) {
|
||||||
const gp = source.gamepad;
|
const gp = source.gamepad;
|
||||||
if (!gp) continue;
|
if (!gp) continue;
|
||||||
const gripButton = gp.buttons[2];
|
const gripBtn = gp.buttons[2];
|
||||||
if (gripButton && gripButton.pressed) gripHeld = true;
|
const trigBtn = gp.buttons[1];
|
||||||
|
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||||
|
if (gripBtn?.pressed) gripHeld = true;
|
||||||
|
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
|
||||||
|
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||||
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
||||||
leftAxes = [gp.axes[2], gp.axes[3]];
|
leftAxes = [gp.axes[2], gp.axes[3]];
|
||||||
|
leftTrig = trigVal;
|
||||||
}
|
}
|
||||||
if (source.handedness === 'right' && gp.axes.length >= 4) {
|
if (source.handedness === 'right' && gp.axes.length >= 4) {
|
||||||
rightAxes = [gp.axes[2], gp.axes[3]];
|
rightAxes = [gp.axes[2], gp.axes[3]];
|
||||||
|
rightTrig = trigVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In freeMove mode, joystick always moves. Otherwise requires grip.
|
// Grab takes priority — joystick is disabled while grabbing
|
||||||
|
if (anyGripHeld) return;
|
||||||
|
// In freeMove mode, joystick always moves. Otherwise requires grip — but
|
||||||
|
// grip is reserved for grab now, so freeMove=false essentially disables.
|
||||||
if (!freeMove && !gripHeld) return;
|
if (!freeMove && !gripHeld) return;
|
||||||
|
|
||||||
const posStep = 0.001;
|
const baseStep = 0.001;
|
||||||
const rotStep = 0.1;
|
const baseRot = 0.1;
|
||||||
const deadzone = 0.15;
|
const deadzone = 0.15;
|
||||||
|
// Trigger modulation: 0 → 0.3×, 1 → 3×
|
||||||
|
const speedL = 0.3 + leftTrig * 2.7;
|
||||||
|
const speedR = 0.3 + rightTrig * 2.7;
|
||||||
|
// Quadratic acceleration on thumbstick value
|
||||||
|
const curve = (v: number) => Math.sign(v) * v * v;
|
||||||
|
|
||||||
const modelStore = useModelStore.getState();
|
const modelStore = useModelStore.getState();
|
||||||
const ft = { ...modelStore.fineTuning };
|
const ft = { ...modelStore.fineTuning };
|
||||||
|
|
||||||
if (leftAxes) {
|
if (leftAxes) {
|
||||||
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += leftAxes[0] * posStep;
|
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
|
||||||
if (Math.abs(leftAxes[1]) > deadzone) ft.posY -= leftAxes[1] * posStep;
|
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
||||||
|
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
||||||
}
|
}
|
||||||
if (rightAxes) {
|
if (rightAxes) {
|
||||||
if (Math.abs(rightAxes[1]) > deadzone) ft.posZ += rightAxes[1] * posStep;
|
// Right: rotY (horizontal) / Y height (vertical, up = up)
|
||||||
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += rightAxes[0] * rotStep;
|
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
||||||
|
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFineTuning(ft);
|
setFineTuning(ft);
|
||||||
@@ -320,6 +346,7 @@ 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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model) navigate('/');
|
if (!model) navigate('/');
|
||||||
@@ -419,7 +446,14 @@ const XRSession = () => {
|
|||||||
toast.success('Modelo posicionado na superfície!');
|
toast.success('Modelo posicionado na superfície!');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XRModel url={model.url} />
|
<XRGrabbable
|
||||||
|
allowScale={allowScale}
|
||||||
|
onGrabStart={() => {
|
||||||
|
if (placementMode) setPlacementMode(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XRModel url={model.url} />
|
||||||
|
</XRGrabbable>
|
||||||
</XRHitTestPlacement>
|
</XRHitTestPlacement>
|
||||||
|
|
||||||
<XRMeasurementOverlay />
|
<XRMeasurementOverlay />
|
||||||
@@ -437,6 +471,8 @@ const XRSession = () => {
|
|||||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
snapToPlanes={snapToPlanes}
|
snapToPlanes={snapToPlanes}
|
||||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface FineTuning {
|
|||||||
rotX: number;
|
rotX: number;
|
||||||
rotY: number;
|
rotY: number;
|
||||||
rotZ: number;
|
rotZ: number;
|
||||||
|
scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||||
@@ -119,7 +120,7 @@ interface ModelStore {
|
|||||||
clearScreenshots: () => void;
|
clearScreenshots: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0 };
|
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
|
||||||
|
|
||||||
export const useModelStore = create<ModelStore>((set) => ({
|
export const useModelStore = create<ModelStore>((set) => ({
|
||||||
model: null,
|
model: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user