🚀 Auto-deploy: melhoria no snap e medição AR em 02/06/2026 21:25:43
This commit is contained in:
@@ -1,171 +1,223 @@
|
|||||||
import { useRef, RefObject } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import { useXR } from '@react-three/xr';
|
import { useXR } from '@react-three/xr';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
import { getAllModelLocalGroups } from '@/lib/modelTransforms';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quest 3 thumbstick locomotion (rig-relative).
|
* Mapeamento de Locomoção por Teletransporte Parabólico via Controle XR:
|
||||||
*
|
*
|
||||||
* Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard):
|
* - Incline e segure o Thumbstick (analógico) de qualquer controle para frente.
|
||||||
* • Para frente (axes[1] < -0.7) → exibe mira no modelo em tempo real.
|
* Um arco parabólico surgirá projetado a partir da mira física do controle.
|
||||||
* Ao soltar (axis volta a ~0) → teletransporta para a mira.
|
* - Ao soltar o direcional, você se teletransporta para o local.
|
||||||
* Se não houver modelo atingido pelo olhar, projeta mira a 0.5m.
|
* - Para entrar em escala real dentro do modelo, aponte diretamente para ele. A escala
|
||||||
* • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar.
|
* será automaticamente definida para 1:1 ao pousar.
|
||||||
* Mapeado diretamente para facilitar pequenos ajustes.
|
|
||||||
* • 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 {
|
interface Props {
|
||||||
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
/** Ref para o <group> que envolve <XROrigin/>; sua pose move o usuário. */
|
||||||
rigRef: RefObject<THREE.Group>;
|
rigRef: React.RefObject<THREE.Group>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEAD = 0.7;
|
const DEAD = 0.7;
|
||||||
const RELEASE = 0.3;
|
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) {
|
export function ControllerLocomotion({ rigRef }: Props) {
|
||||||
const { camera } = useThree();
|
const { camera, gl } = useThree();
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
|
|
||||||
const fwdPushed = useRef(false);
|
const fwdPushed = useRef(false);
|
||||||
const backLatched = useRef(false);
|
|
||||||
const rotLatched = useRef<0 | -1 | 1>(0);
|
|
||||||
const raycaster = useRef(new THREE.Raycaster());
|
const raycaster = useRef(new THREE.Raycaster());
|
||||||
|
|
||||||
|
// Refs visuais
|
||||||
|
const arcLineRef = useRef<THREE.Line>(null);
|
||||||
|
const lineGeomRef = useRef<THREE.BufferGeometry>(null);
|
||||||
const reticleRef = useRef<THREE.Mesh>(null);
|
const reticleRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
useFrame(() => {
|
// Estados temporários do teletransporte
|
||||||
|
const lastTeleportTarget = useRef<THREE.Vector3 | null>(null);
|
||||||
|
const lastTeleportHitModel = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||||
const rig = rigRef.current;
|
const rig = rigRef.current;
|
||||||
const reticle = reticleRef.current;
|
if (!rig || !session || !frame) {
|
||||||
if (!rig || !session) {
|
if (arcLineRef.current) arcLineRef.current.visible = false;
|
||||||
if (reticle && reticle.visible) reticle.visible = false;
|
if (reticleRef.current) reticleRef.current.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stickX = 0;
|
const referenceSpace = gl.xr.getReferenceSpace();
|
||||||
|
if (!referenceSpace) return;
|
||||||
|
|
||||||
|
let activeSource: XRInputSource | null = null;
|
||||||
let stickY = 0;
|
let stickY = 0;
|
||||||
|
|
||||||
|
// Procura qualquer controle com analógico inclinado para a frente
|
||||||
for (const src of session.inputSources) {
|
for (const src of session.inputSources) {
|
||||||
const gp = src.gamepad;
|
const gp = src.gamepad;
|
||||||
if (!gp || gp.axes.length < 4) continue;
|
if (gp && gp.axes.length >= 4) {
|
||||||
// Preferencia pelo stick esquerdo para locomoção
|
const sy = gp.axes[3] ?? 0; // Eixo Y vertical do joystick
|
||||||
if (src.handedness === 'left') {
|
if (sy < -DEAD) {
|
||||||
stickX = gp.axes[2] ?? 0;
|
activeSource = src;
|
||||||
stickY = gp.axes[3] ?? 0;
|
stickY = sy;
|
||||||
break;
|
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 ─────────────────────────────
|
// Se encontramos um controle ativo mirando/inclinado para frente
|
||||||
if (stickY < -DEAD) {
|
if (activeSource && stickY < -DEAD) {
|
||||||
fwdPushed.current = true;
|
fwdPushed.current = true;
|
||||||
|
|
||||||
// Realiza raycast a partir da direção que o usuário está olhando (câmera)
|
|
||||||
const origin = new THREE.Vector3();
|
const origin = new THREE.Vector3();
|
||||||
camera.getWorldPosition(origin);
|
|
||||||
const dir = new THREE.Vector3();
|
const dir = new THREE.Vector3();
|
||||||
camera.getWorldDirection(dir);
|
|
||||||
raycaster.current.set(origin, dir);
|
|
||||||
raycaster.current.far = 30;
|
|
||||||
|
|
||||||
// Filtra o raycast para atingir exclusivamente as peças 3D carregadas
|
// Obtém pose do controle usando o targetRaySpace (mira física do controle)
|
||||||
const modelsObjects = getAllModelLocalGroups();
|
const ctrlPose = frame.getPose(activeSource.targetRaySpace, referenceSpace);
|
||||||
const hits = raycaster.current.intersectObjects(modelsObjects, true);
|
if (ctrlPose) {
|
||||||
const hit = hits.find((h) => {
|
const m = new THREE.Matrix4().fromArray(ctrlPose.transform.matrix);
|
||||||
const o = h.object;
|
origin.setFromMatrixPosition(m);
|
||||||
if (!(o instanceof THREE.Mesh)) return false;
|
const q = new THREE.Quaternion().setFromRotationMatrix(m);
|
||||||
if (o.userData.__edgeLine) return false;
|
dir.set(0, 0, -1).applyQuaternion(q).normalize();
|
||||||
return true;
|
} else {
|
||||||
});
|
// Fallback para direção do olhar se pose falhar
|
||||||
|
camera.getWorldPosition(origin);
|
||||||
const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT));
|
camera.getWorldDirection(dir);
|
||||||
|
|
||||||
if (reticle) {
|
|
||||||
// Converte o ponto de mundo para o espaço local do rig
|
|
||||||
const localTarget = target.clone();
|
|
||||||
rig.worldToLocal(localTarget);
|
|
||||||
reticle.position.copy(localTarget);
|
|
||||||
|
|
||||||
if (hit && hit.face) {
|
|
||||||
const normalWorld = hit.face.normal.clone();
|
|
||||||
const nm = new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld);
|
|
||||||
normalWorld.applyMatrix3(nm).normalize();
|
|
||||||
|
|
||||||
// Converte normal do mundo para o espaço local do rig para orientar o retículo
|
|
||||||
const rigWorldQuat = new THREE.Quaternion();
|
|
||||||
rig.getWorldQuaternion(rigWorldQuat);
|
|
||||||
const normalLocal = normalWorld.clone().applyQuaternion(rigWorldQuat.invert());
|
|
||||||
|
|
||||||
const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), normalLocal);
|
|
||||||
reticle.quaternion.copy(quaternion);
|
|
||||||
} else {
|
|
||||||
reticle.rotation.set(-Math.PI / 2, 0, 0);
|
|
||||||
}
|
|
||||||
reticle.visible = true;
|
|
||||||
}
|
}
|
||||||
} else if (fwdPushed.current && stickY > -RELEASE) {
|
|
||||||
fwdPushed.current = false;
|
|
||||||
if (reticle && reticle.visible) {
|
|
||||||
reticle.visible = false;
|
|
||||||
|
|
||||||
// Recupera o ponto projetado e calcula o deslocamento em relação à câmera
|
// Parâmetros da simulação do arco parabólico
|
||||||
const localTarget = reticle.position.clone();
|
const points: THREE.Vector3[] = [];
|
||||||
const targetWorld = localTarget.clone();
|
const currentPos = origin.clone();
|
||||||
rig.localToWorld(targetWorld);
|
const velocity = dir.clone().multiplyScalar(7.5); // velocidade do tiro do arco
|
||||||
|
const gravity = new THREE.Vector3(0, -9.8, 0);
|
||||||
|
const dt = 0.025; // passo da física
|
||||||
|
let hitPoint: THREE.Vector3 | null = null;
|
||||||
|
let hitModel = false;
|
||||||
|
const modelsObjects = getAllModelLocalGroups();
|
||||||
|
|
||||||
const camWorld = new THREE.Vector3();
|
points.push(currentPos.clone());
|
||||||
camera.getWorldPosition(camWorld);
|
|
||||||
|
|
||||||
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
|
// Executa a trajetória balística
|
||||||
delta.y = 0; // preserva altura do rig do usuário
|
for (let i = 0; i < 35; i++) {
|
||||||
rig.position.add(delta);
|
const nextPos = currentPos.clone().addScaledVector(velocity, dt);
|
||||||
|
velocity.addScaledVector(gravity, dt);
|
||||||
|
|
||||||
|
const segDir = new THREE.Vector3().subVectors(nextPos, currentPos);
|
||||||
|
const segLen = segDir.length();
|
||||||
|
|
||||||
|
if (segLen > 0.001) {
|
||||||
|
segDir.normalize();
|
||||||
|
raycaster.current.set(currentPos, segDir);
|
||||||
|
raycaster.current.far = segLen;
|
||||||
|
|
||||||
|
// 1. Verifica colisão com modelos 3D
|
||||||
|
const hits = raycaster.current.intersectObjects(modelsObjects, true);
|
||||||
|
const firstHit = hits.find(
|
||||||
|
(h) => h.object instanceof THREE.Mesh && !h.object.userData.__edgeLine
|
||||||
|
);
|
||||||
|
|
||||||
|
if (firstHit) {
|
||||||
|
hitPoint = firstHit.point.clone();
|
||||||
|
hitModel = true;
|
||||||
|
points.push(hitPoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verifica colisão com o chão/grid de calibração plano infinito em Y = gridY
|
||||||
|
const gridY = useModelStore.getState().gridY;
|
||||||
|
if (
|
||||||
|
(currentPos.y >= gridY && nextPos.y <= gridY) ||
|
||||||
|
(currentPos.y <= gridY && nextPos.y >= gridY)
|
||||||
|
) {
|
||||||
|
const t = (gridY - currentPos.y) / (nextPos.y - currentPos.y);
|
||||||
|
hitPoint = new THREE.Vector3().lerpVectors(currentPos, nextPos, t);
|
||||||
|
points.push(hitPoint);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos.copy(nextPos);
|
||||||
|
points.push(currentPos.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalTarget = hitPoint ?? points[points.length - 1];
|
||||||
|
lastTeleportTarget.current = finalTarget.clone();
|
||||||
|
lastTeleportHitModel.current = hitModel;
|
||||||
|
|
||||||
|
// Atualiza a geometria do arco
|
||||||
|
if (lineGeomRef.current && arcLineRef.current) {
|
||||||
|
lineGeomRef.current.setFromPoints(points);
|
||||||
|
lineGeomRef.current.attributes.position.needsUpdate = true;
|
||||||
|
lineGeomRef.current.computeBoundingSphere();
|
||||||
|
arcLineRef.current.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza o retículo
|
||||||
|
if (reticleRef.current) {
|
||||||
|
const localTarget = finalTarget.clone();
|
||||||
|
rig.worldToLocal(localTarget);
|
||||||
|
reticleRef.current.position.copy(localTarget);
|
||||||
|
reticleRef.current.visible = true;
|
||||||
|
|
||||||
|
// Visual premium: Rosa/Fúcsia para escala real (modelo), Ciano para o grid/chão
|
||||||
|
const mat = reticleRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
if (hitModel) {
|
||||||
|
mat.color.set('#d946ef');
|
||||||
|
reticleRef.current.scale.setScalar(1.2);
|
||||||
|
} else {
|
||||||
|
mat.color.set('#06b6d4');
|
||||||
|
reticleRef.current.scale.setScalar(1.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (reticle && reticle.visible) {
|
// Quando soltar o analógico
|
||||||
reticle.visible = false;
|
if (fwdPushed.current) {
|
||||||
|
fwdPushed.current = false;
|
||||||
|
|
||||||
|
const targetWorld = lastTeleportTarget.current;
|
||||||
|
const hitModel = lastTeleportHitModel.current;
|
||||||
|
|
||||||
|
if (targetWorld) {
|
||||||
|
const camWorld = new THREE.Vector3();
|
||||||
|
camera.getWorldPosition(camWorld);
|
||||||
|
|
||||||
|
const delta = new THREE.Vector3().subVectors(targetWorld, camWorld);
|
||||||
|
|
||||||
|
if (hitModel) {
|
||||||
|
// "Para entrar em escala real dentro do modelo, aponte para ele"
|
||||||
|
useModelStore.getState().resetScale();
|
||||||
|
// Ajusta o rig para que o pé fique no Y do modelo, mantendo a altura da câmera
|
||||||
|
rig.position.y = targetWorld.y;
|
||||||
|
delta.y = 0;
|
||||||
|
toast.success("Entrando na peça em Escala Real (1:1)!");
|
||||||
|
} else {
|
||||||
|
delta.y = 0; // preserva a altura vertical do usuário
|
||||||
|
}
|
||||||
|
|
||||||
|
rig.position.add(delta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oculta feixe e retículo
|
||||||
|
if (arcLineRef.current) arcLineRef.current.visible = false;
|
||||||
|
if (reticleRef.current) reticleRef.current.visible = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh ref={reticleRef} visible={false}>
|
<>
|
||||||
<ringGeometry args={[0.07, 0.09, 32]} />
|
{/* Arco parabólico premium */}
|
||||||
<meshBasicMaterial color="#06b6d4" transparent opacity={0.8} side={THREE.DoubleSide} depthWrite={false} />
|
<line ref={arcLineRef} visible={false}>
|
||||||
</mesh>
|
<bufferGeometry ref={lineGeomRef} />
|
||||||
|
<lineBasicMaterial color="#06b6d4" linewidth={3.5} transparent opacity={0.8} />
|
||||||
|
</line>
|
||||||
|
|
||||||
|
{/* Retículo de pouso */}
|
||||||
|
<mesh ref={reticleRef} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.07, 0.09, 32]} />
|
||||||
|
<meshBasicMaterial color="#06b6d4" transparent opacity={0.85} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,25 +330,7 @@ export function XRControllerMeasure() {
|
|||||||
trigState.current = false;
|
trigState.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Button A (undo) ──────────────────────────────────────────────
|
// A and B physical buttons are now handled globally in XRHudInWorld.tsx for the Summon Menu.
|
||||||
const aBtn = gp?.buttons?.[4];
|
|
||||||
const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (!aState.current && aVal > BTN_ON) {
|
|
||||||
aState.current = true;
|
|
||||||
useModelStore.getState().undoLastMeasurement();
|
|
||||||
} else if (aState.current && aVal < TRIG_OFF) {
|
|
||||||
aState.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Button B (clear) ─────────────────────────────────────────────
|
|
||||||
const bBtn = gp?.buttons?.[5];
|
|
||||||
const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0;
|
|
||||||
if (!bState.current && bVal > BTN_ON) {
|
|
||||||
bState.current = true;
|
|
||||||
useModelStore.getState().clearMeasurements();
|
|
||||||
} else if (bState.current && bVal < TRIG_OFF) {
|
|
||||||
bState.current = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Construct the laser Line object once so we can attach via <primitive>
|
// Construct the laser Line object once so we can attach via <primitive>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface SingleGrabRecord {
|
|||||||
hand: 'left' | 'right';
|
hand: 'left' | 'right';
|
||||||
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
/** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */
|
||||||
offsetWorld: THREE.Vector3;
|
offsetWorld: THREE.Vector3;
|
||||||
/** Altura Y de mundo da peça no momento em que o grab iniciou, para travar horizontalmente */
|
/** Altura Y de mundo da peça no momento em que o grab iniciou */
|
||||||
startWorldY: number;
|
startWorldY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,14 +46,13 @@ const _tmpVecR = new THREE.Vector3();
|
|||||||
const _tmpQuat = new THREE.Quaternion();
|
const _tmpQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Touch Plus grip mapping (Meta Quest 3):
|
* Mapeamento Geral do Quest 3 para Manipulação do Modelo em AR:
|
||||||
*
|
*
|
||||||
* • One-hand grip → **PAN ONLY (RESTRICTED HORIZONTALLY)** (translação). A rotação e a escala
|
* • Panorâmica (Pan) -> Segurar o botão Grip (lateral) em qualquer um dos controles
|
||||||
* da peça NÃO mudam — o grip simples só arrasta no plano horizontal X/Z para preservar a altura Y calibrada.
|
* e mover a mão na direção que deseja arrastar (movimentação livre em 3D).
|
||||||
* • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme
|
* • Giro (Rotação) e Zoom (Escala) -> Apontar ambos os controles para a bounding box do modelo,
|
||||||
* (distância entre os controles). O zoom está sempre ligado.
|
* segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou
|
||||||
*
|
* afastar/aproximar as mãos (zoom).
|
||||||
* Trigger não interfere aqui (reservado para seleção/medição).
|
|
||||||
*/
|
*/
|
||||||
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
@@ -143,7 +142,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
useFrame((_state, dt) => {
|
useFrame((_state, dt) => {
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
const snap: ControllerGrabSnapshot = grab.current;
|
const snap = grab.current;
|
||||||
const L = snap.left;
|
const L = snap.left;
|
||||||
const R = snap.right;
|
const R = snap.right;
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const lActive = L.isGrabbing && L.hasPose;
|
const lActive = L.isGrabbing && L.hasPose;
|
||||||
const rActive = R.isGrabbing && R.hasPose;
|
const rActive = R.isGrabbing && R.hasPose;
|
||||||
|
|
||||||
// ─── Halo intensity (visual feedback for analog grip) ────────
|
// Visual feedback para a pressão analógica do grip (halo)
|
||||||
if (haloRef.current) {
|
if (haloRef.current) {
|
||||||
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
||||||
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
||||||
@@ -165,7 +164,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Two-hand mode (orbit + zoom, sempre) ────────────────────
|
// ─── Modo de Duas Mãos: Giro (Órbita) + Zoom ────────────────────
|
||||||
if (lActive && rActive) {
|
if (lActive && rActive) {
|
||||||
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
_tmpVecL.setFromMatrixPosition(L.gripWorld);
|
||||||
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
_tmpVecR.setFromMatrixPosition(R.gripWorld);
|
||||||
@@ -177,45 +176,64 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
|
|
||||||
if (!dual.current) {
|
if (!dual.current) {
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
dual.current = {
|
|
||||||
startGroupWorld: group.matrixWorld.clone(),
|
// Restrição: Apontar ambos os controles para a caixa delimitadora do modelo
|
||||||
startMid: midNow.clone(),
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
startAxis: axisNow.clone(),
|
const checkPointerCollision = (slot: typeof L) => {
|
||||||
startDist: distNow,
|
const origin = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld);
|
||||||
startScale: group.scale.x,
|
const q = new THREE.Quaternion().setFromRotationMatrix(slot.gripWorld);
|
||||||
|
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize();
|
||||||
|
const ray = new THREE.Ray(origin, dir);
|
||||||
|
return ray.intersectsBox(box);
|
||||||
};
|
};
|
||||||
single.current = null;
|
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (checkPointerCollision(L) && checkPointerCollision(R)) {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)');
|
dual.current = {
|
||||||
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
|
startMid: midNow.clone(),
|
||||||
|
startAxis: axisNow.clone(),
|
||||||
|
startDist: distNow,
|
||||||
|
startScale: group.scale.x,
|
||||||
|
};
|
||||||
|
single.current = null;
|
||||||
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
|
console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom) - bounding box hit');
|
||||||
|
} else {
|
||||||
|
// Se não apontavam para o modelo, ignora e permite o pan de uma mão (da primeira mão a engajar)
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = dual.current;
|
if (dual.current) {
|
||||||
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
const d = dual.current;
|
||||||
// ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita).
|
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
|
||||||
const scaleRatio = allowScale
|
|
||||||
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
|
// ZOOM: afasta as mãos para zoom in, aproxima para zoom out
|
||||||
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
const scaleRatio = allowScale
|
||||||
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10)
|
||||||
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
: 1;
|
||||||
const m = new THREE.Matrix4().identity()
|
|
||||||
.multiply(tBack)
|
|
||||||
.multiply(rot)
|
|
||||||
.multiply(scl)
|
|
||||||
.multiply(tToOrigin)
|
|
||||||
.multiply(d.startGroupWorld);
|
|
||||||
|
|
||||||
applyWorldMatrixToLocal(group, m);
|
const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z);
|
||||||
return;
|
const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
|
||||||
|
const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat);
|
||||||
|
const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio);
|
||||||
|
const m = new THREE.Matrix4().identity()
|
||||||
|
.multiply(tBack)
|
||||||
|
.multiply(rot)
|
||||||
|
.multiply(scl)
|
||||||
|
.multiply(tToOrigin)
|
||||||
|
.multiply(d.startGroupWorld);
|
||||||
|
|
||||||
|
applyWorldMatrixToLocal(group, m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (dual.current) {
|
} else if (dual.current) {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND end');
|
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
syncGrabToStore();
|
syncGrabToStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── One-hand mode (PAN ONLY - HORIZONTAL X/Z) ────────────────
|
// ─── Modo de Uma Mão: Panorâmica Livre (Arrastar em 3D) ────────
|
||||||
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;
|
||||||
@@ -226,38 +244,32 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld);
|
||||||
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos);
|
||||||
|
|
||||||
// Salva a altura Y original de mundo
|
single.current = { hand: activeHand, offsetWorld, startWorldY: groupWorldPos.y };
|
||||||
const startWorldY = groupWorldPos.y;
|
|
||||||
|
|
||||||
single.current = { hand: activeHand, offsetWorld, startWorldY };
|
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — horizontal pan & rotation`);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — free 3D pan`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotação Y via analógico do controle ativo
|
// Função particular improvisada: rotacionar com o analógico horizontal durante o pan
|
||||||
const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null;
|
const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null;
|
||||||
const gp = inputSource?.gamepad;
|
const gp = inputSource?.gamepad;
|
||||||
if (gp && gp.axes.length >= 4) {
|
if (gp && gp.axes.length >= 4) {
|
||||||
const stickX = gp.axes[2]; // horizontal do thumbstick
|
const stickX = gp.axes[2]; // thumbstick horizontal
|
||||||
const deadzone = 0.15;
|
const deadzone = 0.15;
|
||||||
if (Math.abs(stickX) > deadzone) {
|
if (Math.abs(stickX) > deadzone) {
|
||||||
const rotationSpeed = 1.5; // rad por segundo
|
const rotationSpeed = 1.5;
|
||||||
const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt;
|
const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt;
|
||||||
const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle);
|
const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle);
|
||||||
group.quaternion.multiplyQuaternions(q, group.quaternion);
|
group.quaternion.multiplyQuaternions(q, group.quaternion);
|
||||||
|
|
||||||
// Rotaciona o offset para girar ao redor da mão
|
// Gira o offset em torno da mão
|
||||||
single.current.offsetWorld.applyQuaternion(q);
|
single.current.offsetWorld.applyQuaternion(q);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target world position = controller + initial/rotated offset
|
// Calcula a nova posição no espaço de mundo (movimentação 3D livre)
|
||||||
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld);
|
||||||
|
|
||||||
// Trava a translação no plano horizontal (XZ de mundo), preservando a altura Y original de calibração!
|
// Converte para coordenadas locais do pai
|
||||||
targetWorldPos.y = single.current.startWorldY;
|
|
||||||
|
|
||||||
// Convert to parent-local position (preserve current rotation/scale)
|
|
||||||
if (group.parent) {
|
if (group.parent) {
|
||||||
group.parent.updateMatrixWorld();
|
group.parent.updateMatrixWorld();
|
||||||
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert();
|
||||||
@@ -282,7 +294,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert a desired world matrix to the group's local matrix (given its parent). */
|
|
||||||
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
|
function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) {
|
||||||
if (group.parent) {
|
if (group.parent) {
|
||||||
group.parent.updateMatrixWorld();
|
group.parent.updateMatrixWorld();
|
||||||
|
|||||||
@@ -84,20 +84,72 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
|
|||||||
const headLockRef = useRef<THREE.Group>(null);
|
const headLockRef = useRef<THREE.Group>(null);
|
||||||
const targetPos = useRef(new THREE.Vector3());
|
const targetPos = useRef(new THREE.Vector3());
|
||||||
const targetQuat = useRef(new THREE.Quaternion());
|
const targetQuat = useRef(new THREE.Quaternion());
|
||||||
const lastABtn = useRef(false);
|
const lastButtonsState = useRef({
|
||||||
|
left4: false, // X
|
||||||
|
left5: false, // Y
|
||||||
|
left3: false, // Menu (☰)
|
||||||
|
right4: false, // A
|
||||||
|
right5: false, // B
|
||||||
|
});
|
||||||
/** Cached offset from controller to panel at drag start. */
|
/** Cached offset from controller to panel at drag start. */
|
||||||
const dragOffsetPos = useRef(new THREE.Vector3());
|
const dragOffsetPos = useRef(new THREE.Vector3());
|
||||||
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
const dragOffsetQuat = useRef(new THREE.Quaternion());
|
||||||
const dragInitialized = useRef(false);
|
const dragInitialized = useRef(false);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
// Toggle the head-locked 3-button group via left X button (Quest Touch buttons[4])
|
// Escuta botões A, B, X, Y e Menu do esquerdo
|
||||||
const gp = leftCtrl?.inputSource?.gamepad;
|
const gpLeft = leftCtrl?.inputSource?.gamepad;
|
||||||
if (gp) {
|
const gpRight = rightCtrl?.inputSource?.gamepad;
|
||||||
const xBtn = gp.buttons[4];
|
|
||||||
const pressed = !!xBtn?.pressed;
|
let xPressed = false;
|
||||||
if (pressed && !lastABtn.current) setHudVisible((v) => !v);
|
let yPressed = false;
|
||||||
lastABtn.current = pressed;
|
let menuPressed = false;
|
||||||
|
let aPressed = false;
|
||||||
|
let bPressed = false;
|
||||||
|
|
||||||
|
if (gpLeft) {
|
||||||
|
// Esquerdo: Menu (3), X (4), Y (5)
|
||||||
|
const btnMenu = gpLeft.buttons[3];
|
||||||
|
const btnX = gpLeft.buttons[4];
|
||||||
|
const btnY = gpLeft.buttons[5];
|
||||||
|
|
||||||
|
if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true;
|
||||||
|
if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true;
|
||||||
|
if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true;
|
||||||
|
|
||||||
|
lastButtonsState.current.left4 = !!btnX?.pressed;
|
||||||
|
lastButtonsState.current.left5 = !!btnY?.pressed;
|
||||||
|
lastButtonsState.current.left3 = !!btnMenu?.pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gpRight) {
|
||||||
|
// Direito: A (4), B (5)
|
||||||
|
const btnA = gpRight.buttons[4];
|
||||||
|
const btnB = gpRight.buttons[5];
|
||||||
|
|
||||||
|
if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true;
|
||||||
|
if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true;
|
||||||
|
|
||||||
|
lastButtonsState.current.right4 = !!btnA?.pressed;
|
||||||
|
lastButtonsState.current.right5 = !!btnB?.pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR)
|
||||||
|
if (xPressed && open) {
|
||||||
|
try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); }
|
||||||
|
setTimeout(() => navigate('/viewer'), 50);
|
||||||
|
toast.success("Projeto fechado!");
|
||||||
|
} else if (aPressed || bPressed || xPressed || yPressed) {
|
||||||
|
// Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu
|
||||||
|
setOpen(true);
|
||||||
|
reanchorRequested.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente
|
||||||
|
if (menuPressed) {
|
||||||
|
setOpen(true);
|
||||||
|
setTab('tools');
|
||||||
|
reanchorRequested.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating panel:
|
// Floating panel:
|
||||||
@@ -527,6 +579,7 @@ function ToolsTab(p: XRHudInWorldProps) {
|
|||||||
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
|
const setScaleRatio = useModelStore((s) => s.setScaleRatio);
|
||||||
const resetScale = useModelStore((s) => s.resetScale);
|
const resetScale = useModelStore((s) => s.resetScale);
|
||||||
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
const clearMeasurements = useModelStore((s) => s.clearMeasurements);
|
||||||
|
const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
@@ -585,21 +638,24 @@ function ToolsTab(p: XRHudInWorldProps) {
|
|||||||
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
|
||||||
return (
|
return (
|
||||||
<XR3DButton key={lbl}
|
<XR3DButton key={lbl}
|
||||||
position={[-0.16 + i * 0.06, -0.14, 0]} size={[0.055, 0.022]} label={lbl}
|
position={[-0.16 + i * 0.054, -0.14, 0]} size={[0.05, 0.022]} label={lbl}
|
||||||
active={scaleRatio.label === lbl}
|
active={scaleRatio.label === lbl}
|
||||||
onClick={() => setScaleRatio(preset)} />
|
onClick={() => setScaleRatio(preset)} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.076, -0.14, 0]} size={[0.046, 0.022]}
|
||||||
label="↺ Reset" color="#dc2626"
|
label="↺ Reset" color="#dc2626"
|
||||||
onClick={resetScale} fontSize={0.0085} />
|
onClick={resetScale} fontSize={0.0075} />
|
||||||
<XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
|
<XR3DButton position={[0.126, -0.14, 0]} size={[0.05, 0.022]}
|
||||||
label="✕ Medidas" color="#dc2626"
|
label="⎌ Desfaz" color="#3b82f6"
|
||||||
onClick={clearMeasurements} fontSize={0.0085} />
|
onClick={undoLastMeasurement} fontSize={0.0075} />
|
||||||
|
<XR3DButton position={[0.18, -0.14, 0]} size={[0.05, 0.022]}
|
||||||
|
label="✕ Limpa" color="#dc2626"
|
||||||
|
onClick={clearMeasurements} fontSize={0.0075} />
|
||||||
|
|
||||||
{measureMode && (
|
{measureMode && (
|
||||||
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
<Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
|
||||||
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
|
Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user