From 1f40184f0142735094a2d4b6af294b2ef05748d2 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:36:46 +0000 Subject: [PATCH 1/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- .lovable/plan.md | 100 +++++++++++++++++++++++++++++------------------ 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/.lovable/plan.md b/.lovable/plan.md index 5891c4b..7b057b0 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -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`: -- Add `rotX: number` and `rotZ: number` to the `FineTuning` interface -- Set defaults to 0 in `defaultFineTuning` +- Lê `session.inputSources` a cada frame e identifica os `XRInputSource` "left" e "right". +- Para cada controle, expõe: + - `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): -- Read `rotX`, `rotZ` from fineTuning -- Change `rotation={[0, rotYRad, 0]}` to `rotation={[rotXRad, rotYRad, rotZRad]}` +Novo arquivo `src/components/three/XRGrabbable.tsx`. Wrappa o `` em um `` controlado e implementa as três modalidades de grab: -Update `ControllerFineTuning`: -- Map additional controller inputs (e.g., A/B buttons or grip+joystick combos) to rotX/rotZ, or leave rotation to the HUD controls +- **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. +- **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`: -- In the fine-tuning expanded panel, add rotation rows for X° and Z° alongside the existing Y° control (same `+/−` button pattern) +Em `ControllerFineTuning` (dentro de `XRSession.tsx`): -### 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`: -- 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: +### 4. Escala no store -Content sections: -- **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) +`src/stores/useModelStore.ts`: adicionar `scale: number` ao `FineTuning` (default 1). `XRModel` e `ModelViewer` aplicam `scale={[s,s,s]}` no ``. -### 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 -- `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 +### 6. Integração e ordem de aplicação +No `XRSession.tsx`, a árvore vira: +``` + (posicionamento inicial) + (grab 6DOF + two-hand) + +``` +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 From eba810b22e7b53a45631731b61fc1275389322c3 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:38:22 +0000 Subject: [PATCH 2/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/XRGrabbable.tsx | 170 +++++++++++++++++++++++++++ src/hooks/useControllerGrab.ts | 95 +++++++++++++++ src/stores/useModelStore.ts | 3 +- 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/components/three/XRGrabbable.tsx create mode 100644 src/hooks/useControllerGrab.ts diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx new file mode 100644 index 0000000..2c10a61 --- /dev/null +++ b/src/components/three/XRGrabbable.tsx @@ -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(null); + const grab = useControllerGrab(); + + const single = useRef(null); + const dual = useRef(null); + const haloRef = useRef(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 ( + + {/* Subtle halo proportional to analog grip — invisible by default */} + + + + + {children} + + ); +} + +/** 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); +} diff --git a/src/hooks/useControllerGrab.ts b/src/hooks/useControllerGrab.ts new file mode 100644 index 0000000..63c8c62 --- /dev/null +++ b/src/hooks/useControllerGrab.ts @@ -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({ + 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; +} diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 617639e..2a4b883 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -13,6 +13,7 @@ interface FineTuning { rotX: number; rotY: number; rotZ: number; + scale: number; } type AnchorMode = 'tracking' | 'manual' | 'fine-tuning'; @@ -119,7 +120,7 @@ interface ModelStore { 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((set) => ({ model: null, From 0aa31ed6ad738629c2cb91b826aa25a851bc4b09 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:38:57 +0000 Subject: [PATCH 3/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/pages/XRSession.tsx | 44 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 36e4410..b9c02cc 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -12,6 +12,7 @@ import { generateMarkerDownloadURL } from '@/lib/trackingMarker'; import { XRHud } from '@/components/XRHud'; import { findNearestVertex } from '@/components/three/SmartMeasure'; import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement'; +import { XRGrabbable } from '@/components/three/XRGrabbable'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); @@ -111,6 +112,7 @@ function XRModel({ url }: { url: string }) { const rotXRad = (fineTuning.rotX * Math.PI) / 180; const rotYRad = (fineTuning.rotY * Math.PI) / 180; const rotZRad = (fineTuning.rotZ * Math.PI) / 180; + const s = fineTuning.scale ?? 1; return ( @@ -128,6 +131,9 @@ function XRModel({ url }: { url: string }) { } // ─── 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 }) { const { setFineTuning } = useModelStore(); const session = useXR((s) => s.session); @@ -139,37 +145,57 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) { let leftAxes: number[] | null = null; let rightAxes: number[] | null = null; + let leftTrig = 0; + let rightTrig = 0; let gripHeld = false; + let anyGripHeld = false; for (const source of inputSources) { const gp = source.gamepad; if (!gp) continue; - const gripButton = gp.buttons[2]; - if (gripButton && gripButton.pressed) gripHeld = true; + const gripBtn = gp.buttons[2]; + const trigBtn = gp.buttons[1]; + const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0; + if (gripBtn?.pressed) gripHeld = true; + if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF + const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; if (source.handedness === 'left' && gp.axes.length >= 4) { leftAxes = [gp.axes[2], gp.axes[3]]; + leftTrig = trigVal; } if (source.handedness === 'right' && gp.axes.length >= 4) { rightAxes = [gp.axes[2], gp.axes[3]]; + rightTrig = trigVal; } } - // 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; - const posStep = 0.001; - const rotStep = 0.1; + const baseStep = 0.001; + const baseRot = 0.1; 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 ft = { ...modelStore.fineTuning }; if (leftAxes) { - if (Math.abs(leftAxes[0]) > deadzone) ft.posX += leftAxes[0] * posStep; - if (Math.abs(leftAxes[1]) > deadzone) ft.posY -= leftAxes[1] * posStep; + // Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z) + if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL; + if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL; } if (rightAxes) { - if (Math.abs(rightAxes[1]) > deadzone) ft.posZ += rightAxes[1] * posStep; - if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += rightAxes[0] * rotStep; + // Right: rotY (horizontal) / Y height (vertical, up = up) + 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); From 85b896259a2d8b952f3c38ba1307d27844dd61bc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:39:17 +0000 Subject: [PATCH 4/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/pages/XRSession.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index b9c02cc..672120c 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -346,6 +346,7 @@ const XRSession = () => { const [freeMove, setFreeMove] = useState(true); const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [snapToPlanes, setSnapToPlanes] = useState(true); + const [allowScale, setAllowScale] = useState(false); useEffect(() => { if (!model) navigate('/'); @@ -445,7 +446,14 @@ const XRSession = () => { toast.success('Modelo posicionado na superfície!'); }} > - + { + if (placementMode) setPlacementMode(false); + }} + > + + @@ -463,6 +471,8 @@ const XRSession = () => { onTogglePlacement={() => setPlacementMode(!placementMode)} snapToPlanes={snapToPlanes} onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} + allowScale={allowScale} + onToggleAllowScale={() => setAllowScale(!allowScale)} /> From abce5376d1cfecf5803e937efe57e0e245900175 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:39:56 +0000 Subject: [PATCH 5/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/XRHud.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx index dde68b1..25dab08 100644 --- a/src/components/XRHud.tsx +++ b/src/components/XRHud.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera, Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip, - ClipboardCheck, X, Crosshair, Magnet, HelpCircle, + ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; @@ -21,9 +21,21 @@ interface XRHudProps { onTogglePlacement?: () => void; snapToPlanes?: boolean; onToggleSnap?: () => void; + allowScale?: boolean; + onToggleAllowScale?: () => void; } 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)', 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.', }, { - title: 'Mover (Free Move)', + title: 'Joystick (ajuste fino)', 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', @@ -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.', }, { - title: 'Controladores Quest 3', + title: 'Botões A/B (Touch Plus)', 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 [checklistOpen, setChecklistOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); From 1e576883024633834cd6ebb9e85c446430378bca Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:40:13 +0000 Subject: [PATCH 6/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/XRHud.tsx | 13 +++++++++++++ src/components/three/ModelViewer.tsx | 2 ++ 2 files changed, 15 insertions(+) diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx index 25dab08..9bb8147 100644 --- a/src/components/XRHud.tsx +++ b/src/components/XRHud.tsx @@ -297,6 +297,19 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog )} + {/* Two-hand scale toggle */} + {onToggleAllowScale && ( + + )} +