diff --git a/src/App.tsx b/src/App.tsx
index 4de025b..d4de623 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -112,7 +112,7 @@ export default function App() {
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
>
-
+
diff --git a/src/components/InteractiveCube.tsx b/src/components/InteractiveCube.tsx
index 2023180..99e4380 100644
--- a/src/components/InteractiveCube.tsx
+++ b/src/components/InteractiveCube.tsx
@@ -24,149 +24,123 @@ export function InteractiveCube({
locked,
onFaceSelect,
resetSignal,
- tableY = 0.625, // Height of table top
+ tableY = 0.625,
tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 },
}: InteractiveCubeProps) {
const meshRef = useRef(null);
const session = useXR((s) => s.session);
- // Cube state & transform
+ // Physics state
const pos = useRef(new THREE.Vector3(...initialPosition));
const vel = useRef(new THREE.Vector3(0, 0, 0));
- const rot = useRef(new THREE.Euler(0, 0, 0));
+ const rot = useRef(new THREE.Quaternion().identity());
const angVel = useRef(new THREE.Vector3(0, 0, 0));
const scale = useRef(1);
- // Interaction tracking
+ // Hover & selection
const [hovered, setHovered] = useState(false);
const [hoveredFace, setHoveredFace] = useState(null);
const [selectedFace, setSelectedFace] = useState(null);
- // Grab state
+ // Grab state - using ray to prevent feedback loop drift
const isGrabbed = useRef(false);
- const grabControllerId = useRef(null);
- const prevControllerPos = useRef(new THREE.Vector3());
- const grabOffsetPos = useRef(new THREE.Vector3());
+ const grabPointerId = useRef(null);
+ const grabDistance = useRef(0);
- // Two-hand scale/rotate state
- const secondGrabControllerId = useRef(null);
- const initialHandDist = useRef(1);
- const initialCubeScale = useRef(1);
+ const previousRayPos = useRef(new THREE.Vector3());
- // Reset physics signal
+ // Reset signal listener
const lastResetSignal = useRef(resetSignal);
if (resetSignal !== lastResetSignal.current) {
lastResetSignal.current = resetSignal;
pos.current.set(...initialPosition);
vel.current.set(0, 0, 0);
angVel.current.set(0, 0, 0);
- rot.current.set(0, 0, 0);
+ rot.current.identity();
scale.current = 1;
+ isGrabbed.current = false;
setSelectedFace(null);
- if (meshRef.current) {
- meshRef.current.position.copy(pos.current);
- meshRef.current.rotation.copy(rot.current);
- meshRef.current.scale.setScalar(1);
- }
}
- // Handle pointer hover / grab events (works natively with XR rays and hands)
- const handlePointerDown = (e: ThreeEvent) => {
- if (locked) return;
+ const onPointerDown = (e: ThreeEvent) => {
+ if (locked || isGrabbed.current) return;
e.stopPropagation();
+
+ // Capture pointer to track dragging outside object bounds
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
+
+ isGrabbed.current = true;
+ grabPointerId.current = e.pointerId;
+
+ // Save the distance from the controller ray origin to the intersection point
+ grabDistance.current = e.distance;
+
+ // Track velocity
+ previousRayPos.current.copy(e.point);
+ vel.current.set(0, 0, 0);
+ angVel.current.set(0, 0, 0);
soundFx.playGrab();
triggerHaptic(session, 'any', 0.8, 60);
-
- const controller = (e as any).pointerId ?? 'primary';
-
- if (!isGrabbed.current) {
- isGrabbed.current = true;
- grabControllerId.current = controller;
-
- if (e.point) {
- grabOffsetPos.current.copy(pos.current).sub(e.point);
- } else {
- grabOffsetPos.current.set(0, 0, 0);
- }
- prevControllerPos.current.copy(e.point || pos.current);
- vel.current.set(0, 0, 0);
- angVel.current.set(0, 0, 0);
- } else if (!secondGrabControllerId.current && controller !== grabControllerId.current) {
- // Second hand grab -> Pinch scale / rotate mode
- secondGrabControllerId.current = controller;
- initialCubeScale.current = scale.current;
- if (e.point) {
- initialHandDist.current = prevControllerPos.current.distanceTo(e.point) || 0.3;
- }
- }
};
- const handlePointerUp = (e: ThreeEvent) => {
- e.stopPropagation();
- const controller = (e as any).pointerId ?? 'primary';
-
- if (secondGrabControllerId.current === controller) {
- secondGrabControllerId.current = null;
- } else if (grabControllerId.current === controller) {
- if (secondGrabControllerId.current) {
- grabControllerId.current = secondGrabControllerId.current;
- secondGrabControllerId.current = null;
- } else {
- isGrabbed.current = false;
- grabControllerId.current = null;
-
- soundFx.playRelease();
- triggerHaptic(session, 'any', 0.4, 40);
-
- // Apply throw inertia boost
- vel.current.multiplyScalar(1.2);
- angVel.current.set(
- (Math.random() - 0.5) * 4,
- (Math.random() - 0.5) * 4,
- (Math.random() - 0.5) * 4
- );
- }
- }
- };
-
- const handleFaceClick = (face: FaceId, e: ThreeEvent) => {
- e.stopPropagation();
+ const onPointerMove = (e: ThreeEvent) => {
if (locked) return;
- setSelectedFace(face);
- soundFx.playSnap();
- triggerHaptic(session, 'any', 0.6, 50);
- if (onFaceSelect) onFaceSelect(face);
+ if (isGrabbed.current && e.pointerId === grabPointerId.current) {
+ e.stopPropagation();
+
+ // FIX: Use e.ray to calculate new position instead of e.point to avoid feedback drift
+ const ray = e.ray;
+ const targetPos = ray.origin.clone().add(ray.direction.clone().multiplyScalar(grabDistance.current));
+
+ // Calculate velocity for throwing
+ const dt = 0.016; // approximate frame time for velocity calc
+ vel.current.copy(targetPos).sub(pos.current).divideScalar(dt);
+
+ pos.current.copy(targetPos);
+ }
+ };
+
+ const onPointerUp = (e: ThreeEvent) => {
+ if (isGrabbed.current && e.pointerId === grabPointerId.current) {
+ e.stopPropagation();
+ (e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
+
+ isGrabbed.current = false;
+ grabPointerId.current = null;
+
+ // Throw physics applied automatically via vel.current calculated in onPointerMove
+ soundFx.playRelease();
+ triggerHaptic(session, 'any', 0.4, 40);
+ }
};
- // Main frame update for Physics & Grabbing
useFrame((_, delta) => {
if (!meshRef.current) return;
+ const dt = Math.min(delta, 0.05);
- const clampedDelta = Math.min(delta, 0.05);
-
- if (isGrabbed.current) {
- // Calculate controller movement velocity
- // Mesh is updated via event points or target tracking
- } else if (!locked) {
- // PHYSICS SIMULATION
+ if (!isGrabbed.current && !locked) {
+ // Physics Simulation
const gravity = -9.81;
const currentScale = scale.current * size;
const halfSize = currentScale / 2;
- // Apply Gravity & Damping
- vel.current.y += gravity * clampedDelta;
- vel.current.x *= 1 - 0.5 * clampedDelta;
- vel.current.z *= 1 - 0.5 * clampedDelta;
- angVel.current.multiplyScalar(1 - 1.2 * clampedDelta);
+ vel.current.y += gravity * dt;
+
+ // Air resistance
+ vel.current.x *= 1 - 0.5 * dt;
+ vel.current.z *= 1 - 0.5 * dt;
+ angVel.current.multiplyScalar(1 - 1.2 * dt);
- // Integrate Position & Rotation
- pos.current.addScaledVector(vel.current, clampedDelta);
- rot.current.x += angVel.current.x * clampedDelta;
- rot.current.y += angVel.current.y * clampedDelta;
- rot.current.z += angVel.current.z * clampedDelta;
+ // Integrate Position
+ pos.current.addScaledVector(vel.current, dt);
+
+ // Integrate Rotation via quaternions
+ const w = angVel.current.clone().multiplyScalar(dt);
+ const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(w.x, w.y, w.z));
+ rot.current.multiply(q);
- // Table Collision Check
+ // Collision detection (Table and Floor)
const isOnTable =
pos.current.x >= tableBounds.minX &&
pos.current.x <= tableBounds.maxX &&
@@ -178,25 +152,24 @@ export function InteractiveCube({
if (pos.current.y <= surfaceY) {
pos.current.y = surfaceY;
- // Bounce / Restitution
if (Math.abs(vel.current.y) > 0.4) {
soundFx.playImpact(Math.abs(vel.current.y));
triggerHaptic(session, 'any', Math.min(Math.abs(vel.current.y) * 0.2, 0.8), 30);
- vel.current.y = -vel.current.y * 0.35; // 35% restitution
+ vel.current.y = -vel.current.y * 0.35; // bounce
} else {
vel.current.y = 0;
}
- // Friction on surface
+ // Friction
vel.current.x *= 0.82;
vel.current.z *= 0.82;
angVel.current.multiplyScalar(0.75);
}
}
- // Apply transform to 3D object
+ // Apply transform
meshRef.current.position.copy(pos.current);
- meshRef.current.rotation.copy(rot.current);
+ meshRef.current.setRotationFromQuaternion(rot.current);
meshRef.current.scale.setScalar(scale.current);
});
@@ -214,49 +187,53 @@ export function InteractiveCube({
ref={meshRef}
onPointerOver={(e) => {
e.stopPropagation();
- setHovered(true);
- soundFx.playHover();
- triggerHaptic(session, 'any', 0.2, 20);
- }}
- onPointerOut={() => {
- setHovered(false);
- setHoveredFace(null);
- }}
- onPointerDown={handlePointerDown}
- onPointerUp={handlePointerUp}
- onPointerMove={(e) => {
- if (isGrabbed.current && e.point) {
- const newPos = e.point.clone().add(grabOffsetPos.current);
- const dt = 0.016;
- vel.current.copy(newPos).sub(pos.current).divideScalar(dt);
- pos.current.copy(newPos);
+ if (!isGrabbed.current) {
+ setHovered(true);
+ soundFx.playHover();
+ triggerHaptic(session, 'any', 0.2, 20);
}
}}
+ onPointerOut={() => {
+ if (!isGrabbed.current) {
+ setHovered(false);
+ setHoveredFace(null);
+ }
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ onPointerMissed={onPointerUp}
>
- {/* Base Cube Body */}
- {/* Faces with colors, labels and snapping feedback */}
{faces.map((f) => {
const isSelected = selectedFace === f.id;
- const isFaceHovered = hoveredFace === f.id;
+ const isFaceHovered = hoveredFace === f.id && !isGrabbed.current;
return (
{
e.stopPropagation();
- setHoveredFace(f.id);
+ if (!isGrabbed.current) setHoveredFace(f.id);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (locked || isGrabbed.current) return;
+ setSelectedFace(f.id);
+ soundFx.playSnap();
+ triggerHaptic(session, 'any', 0.6, 50);
+ if (onFaceSelect) onFaceSelect(f.id);
}}
- onClick={(e) => handleFaceClick(f.id, e)}
>
- {/* Face Label Text */}
);
})}
-
- {/* Selection Glow Box Indicator */}
- {selectedFace && (
-
-
-
-
- )}
);
}
diff --git a/src/components/QuestXRLocomotion.tsx b/src/components/QuestXRLocomotion.tsx
index 2a15587..e1490e3 100644
--- a/src/components/QuestXRLocomotion.tsx
+++ b/src/components/QuestXRLocomotion.tsx
@@ -1,4 +1,4 @@
-import { useRef } from 'react';
+import { useRef, type MutableRefObject } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { useXR } from '@react-three/xr';
import * as THREE from 'three';
@@ -21,7 +21,11 @@ export function triggerHaptic(session: any, hand: 'left' | 'right' | 'any' = 'an
} catch {}
}
-export function QuestXRLocomotion() {
+interface LocomotionProps {
+ worldRef: MutableRefObject;
+}
+
+export function QuestXRLocomotion({ worldRef }: LocomotionProps) {
const { camera } = useThree();
const session = useXR((s) => s.session);
const snapCooldownRef = useRef(0);
@@ -31,9 +35,8 @@ export function QuestXRLocomotion() {
const sideVector = useRef(new THREE.Vector3());
useFrame((_, delta) => {
- if (!session) return;
+ if (!session || !worldRef.current) return;
- // Get input sources from WebXR session
const inputSources = session.inputSources;
if (!inputSources) return;
@@ -41,39 +44,49 @@ export function QuestXRLocomotion() {
for (const source of inputSources as any) {
const gamepad = source.gamepad;
- if (!gamepad || !gamepad.axes || gamepad.axes.length < 2) continue;
+ if (!gamepad || !gamepad.axes) continue;
const handedness = source.handedness;
- // Axis 0 = Thumbstick X, Axis 1 = Thumbstick Y (Standard WebXR Gamepad mapping)
- const axisX = Math.abs(gamepad.axes[2] ?? gamepad.axes[0]) > STICK_DEADZONE ? (gamepad.axes[2] ?? gamepad.axes[0]) : 0;
- const axisY = Math.abs(gamepad.axes[3] ?? gamepad.axes[1]) > STICK_DEADZONE ? (gamepad.axes[3] ?? gamepad.axes[1]) : 0;
+ // Quest 3 axes mapping:
+ // axes[0], axes[1] or axes[2], axes[3] depending on the browser version
+ const axisX = Math.abs(gamepad.axes[2]) > STICK_DEADZONE ? gamepad.axes[2]
+ : Math.abs(gamepad.axes[0]) > STICK_DEADZONE ? gamepad.axes[0] : 0;
+
+ const axisY = Math.abs(gamepad.axes[3]) > STICK_DEADZONE ? gamepad.axes[3]
+ : Math.abs(gamepad.axes[1]) > STICK_DEADZONE ? gamepad.axes[1] : 0;
if (handedness === 'left') {
// Left Controller -> Smooth Locomotion
if (axisX !== 0 || axisY !== 0) {
- // Get camera yaw angle
+ // Calculate movement relative to headset orientation
camera.getWorldDirection(forwardVector.current);
forwardVector.current.y = 0;
forwardVector.current.normalize();
-
+
sideVector.current.crossVectors(camera.up, forwardVector.current).normalize();
moveVector.current.set(0, 0, 0);
- moveVector.current.addScaledVector(forwardVector.current, -axisY * MOVE_SPEED * delta);
- moveVector.current.addScaledVector(sideVector.current, -axisX * MOVE_SPEED * delta);
+
+ // Move the WORLD in the OPPOSITE direction of the joystick
+ moveVector.current.addScaledVector(forwardVector.current, axisY * MOVE_SPEED * delta);
+ moveVector.current.addScaledVector(sideVector.current, axisX * MOVE_SPEED * delta);
- camera.position.add(moveVector.current);
+ worldRef.current.position.add(moveVector.current);
}
} else if (handedness === 'right') {
// Right Controller -> Snap Turning
if (now > snapCooldownRef.current && Math.abs(axisX) > 0.6) {
- const turnDirection = axisX > 0 ? -1 : 1; // Right stick right = turn right
+ const turnDirection = axisX > 0 ? -1 : 1;
- // Rotate camera around Y axis at current camera position
- const rotationMatrix = new THREE.Matrix4().makeRotationY(turnDirection * SNAP_TURN_ANGLE);
- camera.position.applyMatrix4(rotationMatrix);
- camera.rotation.y += turnDirection * SNAP_TURN_ANGLE;
+ // To turn the player right, we rotate the world LEFT around the player's current position
+ const playerPos = camera.position.clone();
+
+ worldRef.current.position.sub(playerPos);
+ worldRef.current.position.applyAxisAngle(new THREE.Vector3(0, 1, 0), turnDirection * SNAP_TURN_ANGLE);
+ worldRef.current.position.add(playerPos);
+
+ worldRef.current.rotation.y += turnDirection * SNAP_TURN_ANGLE;
triggerHaptic(session, 'right', 0.4, 40);
snapCooldownRef.current = now + SNAP_COOLDOWN_MS;
diff --git a/src/components/SpatialHUD.tsx b/src/components/SpatialHUD.tsx
index 71f86a9..59a35bc 100644
--- a/src/components/SpatialHUD.tsx
+++ b/src/components/SpatialHUD.tsx
@@ -6,8 +6,6 @@ export interface SpatialHUDProps {
locked: boolean;
onToggleLock: () => void;
onReset: () => void;
- onEnterVR: () => void;
- onEnterAR: () => void;
alignmentStep: string;
isAR: boolean;
}
@@ -17,14 +15,11 @@ export function SpatialHUD({
locked,
onToggleLock,
onReset,
- onEnterVR,
- onEnterAR,
alignmentStep,
isAR,
}: SpatialHUDProps) {
return (
- {/* HUD Header Title */}
- {/* Alignment Instructions Message */}
{alignmentStep && (
@@ -56,7 +50,6 @@ export function SpatialHUD({
)}
- {/* Main Spatial Control Card Panel */}
- {/* Action Buttons Row */}
- {/* 1. Lock/Unlock Physics Button */}
{
e.stopPropagation();
soundFx.playHover();
@@ -79,7 +70,7 @@ export function SpatialHUD({
}}
>
-
+
@@ -87,9 +78,8 @@ export function SpatialHUD({
- {/* 2. Reset Position Button */}
{
e.stopPropagation();
soundFx.playSnap();
@@ -97,34 +87,14 @@ export function SpatialHUD({
}}
>
-
+
- 🔄 RESETAR CUBO
+ 🔄 RESETAR MUNDO
- {/* 3. AR / VR Switcher Button */}
- {
- e.stopPropagation();
- soundFx.playHover();
- if (isAR) onEnterVR();
- else onEnterAR();
- }}
- >
-
-
-
-
-
- {isAR ? '🥽 MODO VR (3D)' : '📷 PASSTHROUGH AR'}
-
-
-
- {/* Status Footer info */}
- Controles: Gatilho/Grip (Agarrar/Arremessar) • Joystick Esq (Mover) • Joystick Dir (Girar 45°)
+ {isAR ? 'MODO: PASSTHROUGH AR ATIVO' : 'MODO: VR IMERSIVO'} • Joysticks Movem e Giram
);
diff --git a/src/components/XRScene.tsx b/src/components/XRScene.tsx
index 5ed95fc..336e153 100644
--- a/src/components/XRScene.tsx
+++ b/src/components/XRScene.tsx
@@ -1,23 +1,21 @@
-import { useState, Suspense } from 'react';
+import { useState, Suspense, useRef } from 'react';
import { useXR } from '@react-three/xr';
import { Environment, Grid } from '@react-three/drei';
+import * as THREE from 'three';
import { WorkbenchTable } from './WorkbenchTable';
import { InteractiveCube, type FaceId } from './InteractiveCube';
import { SpatialHUD } from './SpatialHUD';
import { QuestXRLocomotion } from './QuestXRLocomotion';
-export interface XRSceneProps {
- onEnterVR: () => void;
- onEnterAR: () => void;
-}
-
-export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
+export function XRScene() {
const session = useXR((s) => s.session);
const isAR = !!(session && (session as any).environmentBlendMode === 'additive');
const [locked, setLocked] = useState(false);
const [resetSignal, setResetSignal] = useState(0);
+ const worldRef = useRef(null);
+
// Alignment tracking
const [selectedCubeFace, setSelectedCubeFace] = useState(null);
const [alignmentStep, setAlignmentStep] = useState(
@@ -41,10 +39,8 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
return (
<>
- {/* VR Joystick Locomotion & Snap Turn */}
-
+
- {/* Dynamic Background Fog & Color for VR Mode */}
{!isAR && (
<>
@@ -52,67 +48,68 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
>
)}
- {/* Lighting Setup */}
-
-
-
-
+ {/* World Group that moves inversely to simulate player movement */}
+
+
+
+
+
- {/* Grid Floor */}
-
+
- {/* Environment Reflections */}
-
-
-
+
+
+
- {/* Workbench Table */}
- setAlignmentStep(`Aresta selecionada: ${edge}`)}
- />
+ setAlignmentStep(`Aresta selecionada: ${edge}`)}
+ />
- {/* Interactive Physics Cube */}
-
+
- {/* Floating Spatial HUD */}
- setLocked((l) => !l)}
- onReset={() => setResetSignal((s) => s + 1)}
- onEnterVR={onEnterVR}
- onEnterAR={onEnterAR}
- alignmentStep={alignmentStep}
- isAR={isAR}
- />
+ setLocked((l) => !l)}
+ onReset={() => {
+ setResetSignal((s) => s + 1);
+ if (worldRef.current) {
+ worldRef.current.position.set(0, 0, 0);
+ worldRef.current.rotation.set(0, 0, 0);
+ }
+ }}
+ alignmentStep={alignmentStep}
+ isAR={isAR}
+ />
+
>
);
}