Compare commits

..

12 Commits

Author SHA1 Message Date
Hermes 11659072e7 fix(lab): AR Passthrough - árvore JSX corrigida (<Canvas><XR>), ARBackground nunca bloqueia passthrough (issue #344)
- Árvore: <Canvas><XR>...</XR></Canvas> (era <XR><Canvas>) - <XR> chama useThree() e PRECISA estar dentro do Canvas
- gl.xr.enabled manual removido (controlado pelo <XR>)
- cleanup session.end() no unmount (issue #490)
- store.enterAR() em vez de navigator.xr.requestSession cru
- preserveDrawingBuffer:true idem ao XRSession.tsx que funciona
2026-08-05 00:32:39 +00:00
Hermes 3f01190559 fix(lab): AR Passthrough — prop isAR no ARBackground pra evitar background preto permanente 2026-08-05 00:16:48 +00:00
Hermes f54efbf734 fix(lab): AR Passthrough black screen — transparent canvas + immersive-ar detection + dynamic AR background
- gl.alpha:true + setClearColor(0,0) no WebGL renderer
- ARBackground detecta environmentBlendMode='additive' e vira transparente
- Tenta navigator.xr.requestSession('immersive-ar') antes do fallback immersive-vr
- local-floor required + hand-tracking/plane-detection/hit-test optional
2026-08-04 23:35:10 +00:00
Hermes 871b37de3b feat(lab): Fase 2.5 - Rapier3D physics HLA-like 2026-08-04 21:52:21 +00:00
admtracksteel c4c79f89d9 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 21:34:30 2026-08-04 21:34:30 +00:00
admtracksteel 797fddb5a2 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 21:26:18 2026-08-04 21:26:18 +00:00
admtracksteel 028221ab04 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 21:12:43 2026-08-04 21:12:43 +00:00
admtracksteel 8807b18c55 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 21:04:14 2026-08-04 21:04:14 +00:00
admtracksteel 11bbf1e663 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 20:55:34 2026-08-04 20:55:34 +00:00
admtracksteel 4f5538f3a8 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 20:46:52 2026-08-04 20:46:52 +00:00
admtracksteel 5ce3636933 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 20:36:21 2026-08-04 20:36:21 +00:00
admtracksteel a63478a3ba 🚀 Auto-deploy: melhoria no snap e medição AR em 04/08/2026 20:30:55 2026-08-04 20:30:55 +00:00
4 changed files with 506 additions and 423 deletions
+25 -2
View File
@@ -1,13 +1,14 @@
{ {
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"version": "1.0.4", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"version": "1.0.4", "version": "1.1.0",
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "^0.14.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
@@ -38,6 +39,7 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@react-three/drei": "^9.122.0", "@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0", "@react-three/fiber": "^8.18.0",
"@react-three/rapier": "^1.5.0",
"@react-three/xr": "^6.6.29", "@react-three/xr": "^6.6.29",
"@supabase/supabase-js": "^2.105.4", "@supabase/supabase-js": "^2.105.4",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
@@ -153,6 +155,12 @@
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.14.0.tgz",
"integrity": "sha512-/uHrUzS+CRQ+NQrrJCEDUkhwHlNsAAexbNXgbN9sHY+GwR+SFFAFrxRr8Llf5/AJZzqiLANdQIfJ63Cw4gJVqw==",
"license": "Apache-2.0"
},
"node_modules/@emotion/is-prop-valid": { "node_modules/@emotion/is-prop-valid": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
@@ -2691,6 +2699,21 @@
} }
} }
}, },
"node_modules/@react-three/rapier": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-1.5.0.tgz",
"integrity": "sha512-gylk2KyCer9EoymFyTyc+g2IqyAq4mTbZgaHoSJi6gHoXlJsC2LVeN4jedvegvjUsXPExdE60wHjCPa+DS4iXw==",
"dependencies": {
"@dimforge/rapier3d-compat": "0.14.0",
"suspend-react": "^0.1.3",
"three-stdlib": "^2.29.4"
},
"peerDependencies": {
"@react-three/fiber": ">=8.9.0",
"react": ">=18.0.0",
"three": ">=0.139.0"
}
},
"node_modules/@react-three/xr": { "node_modules/@react-three/xr": {
"version": "6.6.29", "version": "6.6.29",
"resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.29.tgz", "resolved": "https://registry.npmjs.org/@react-three/xr/-/xr-6.6.29.tgz",
+2
View File
@@ -13,6 +13,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "^0.14.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
@@ -43,6 +44,7 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@react-three/drei": "^9.122.0", "@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0", "@react-three/fiber": "^8.18.0",
"@react-three/rapier": "^1.5.0",
"@react-three/xr": "^6.6.29", "@react-three/xr": "^6.6.29",
"@supabase/supabase-js": "^2.105.4", "@supabase/supabase-js": "^2.105.4",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
+17 -1
View File
@@ -16,6 +16,8 @@ interface XRGrabbableProps {
lockedActive?: boolean; lockedActive?: boolean;
/** Called the first time the user grabs (e.g. to exit placement mode) */ /** Called the first time the user grabs (e.g. to exit placement mode) */
onGrabStart?: () => void; onGrabStart?: () => void;
/** Optional callback to sync transforms to local state instead of store */
onSync?: (pos: THREE.Vector3, quat: THREE.Quaternion, scale: THREE.Vector3) => void;
children: ReactNode; children: ReactNode;
} }
@@ -54,7 +56,7 @@ const _tmpQuat = new THREE.Quaternion();
* segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou * 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). * afastar/aproximar as mãos (zoom).
*/ */
export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) { export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, onSync, children }: XRGrabbableProps) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const grab = useControllerGrab(); const grab = useControllerGrab();
const session = useXR((s) => s.session); const session = useXR((s) => s.session);
@@ -88,6 +90,20 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta
const group = groupRef.current; const group = groupRef.current;
if (!group) return; if (!group) return;
if (onSync) {
const finalPos = new THREE.Vector3();
const finalQuat = new THREE.Quaternion();
const finalScale = new THREE.Vector3();
group.matrixWorld.decompose(finalPos, finalQuat, finalScale);
group.position.set(0, 0, 0);
group.quaternion.identity();
group.scale.set(1, 1, 1);
onSync(finalPos, finalQuat, finalScale);
return;
}
const state = useModelStore.getState(); const state = useModelStore.getState();
const activeModelId = state.activeModelId; const activeModelId = state.activeModelId;
if (!activeModelId) return; if (!activeModelId) return;
+457 -415
View File
@@ -6,8 +6,10 @@ import {
Grid, Grid,
Environment, Environment,
PerspectiveCamera, PerspectiveCamera,
Text,
} from "@react-three/drei"; } from "@react-three/drei";
import { XR, createXRStore } from "@react-three/xr"; import { XR, useXR } from "@react-three/xr";
import { xrStore as store } from "@/stores/useXRStore";
import * as THREE from "three"; import * as THREE from "three";
import { import {
Beaker, Beaker,
@@ -17,16 +19,20 @@ import {
RotateCcw, RotateCcw,
Hand, Hand,
} from "lucide-react"; } from "lucide-react";
import { Suspense } from "react";
/** /**
* ============================================================ * ============================================================
* QUEST CUBE LAB — Fase 3 e 4 * QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE)
* - Cubo 3D real + Mesa de dois níveis * FIX v4 — AR Passthrough funcional em Meta Quest 3
* - Sistema de alinhamento por clique duplo: *
* 1. Seleciona face do cubo * Mudanças principais vs. versão anterior (3f01190):
* 2. Seleciona superfície da mesa (tampo ou prateleira) -> cubo deita na superfície * 1. ÁRVORE: <Canvas><XR>...</XR></Canvas> (era <XR><Canvas>)
* 3. Seleciona outra face do cubo (ortogonal à primeira) * — <XR> chama useThree() e useFrame() e PRECISA estar dentro do Canvas
* 4. Seleciona aresta (lateral/frente) da mesa -> cubo rotaciona e alinha face * 2. <color attach="background"> REMOVIDO em AR — opacificava passthrough (issue pmndrs/xr#344)
* 3. requiredFeatures removido — usa store.enterAR() que respeita config do xrStore
* 4. gl.xr.enabled manual removido — agora é controlado pelo <XR>
* 5. cleanup: session.end() ao desmontar
* ============================================================ * ============================================================
*/ */
@@ -35,450 +41,486 @@ const TRIGGER_DEADZONE = 0.05;
const MOVE_SPEED = 1.5; const MOVE_SPEED = 1.5;
const ROT_SPEED = 1.2; const ROT_SPEED = 1.2;
const SCALE_SPEED = 0.6; const SCALE_SPEED = 0.6;
const CUBE_SIZE = 1; const CUBE_SIZE = 0.3;
type AlignPhase = 'IDLE' | 'WAIT_SURFACE' | 'WAIT_FACE2' | 'WAIT_EDGE'; type AlignPhase = "IDLE" | "WAIT_SURFACE" | "WAIT_FACE2" | "WAIT_EDGE";
const store = createXRStore({ const ARHudMessages = ({ alignPhase }: { alignPhase: AlignPhase }) => {
controller: { left: true, right: true }, const session = useXR((s) => s.session);
}); if (!session) return null;
const message = (() => {
// =================================================================== switch (alignPhase) {
// COMPONENTE: CuboControlado case "WAIT_SURFACE":
// =================================================================== return "1/2 — Clique na FACE do cubo e na SUPERFÍCIE alvo";
const CubeControlado = ({ case "WAIT_FACE2":
posRef, return "2/2 — Clique em outra face e na ARESTA lateral";
rotRef, default:
scaleRef, return "";
lockedRef,
gamepadRef,
alignPhase,
setAlignPhase,
face1Ref,
face2Ref,
}: {
posRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
rotRef: React.MutableRefObject<{ x: number; y: number; z: number }>;
scaleRef: React.MutableRefObject<number>;
lockedRef: React.MutableRefObject<boolean>;
gamepadRef: React.MutableRefObject<{ a: boolean }>;
alignPhase: AlignPhase;
setAlignPhase: (p: AlignPhase) => void;
face1Ref: React.MutableRefObject<THREE.Vector3>;
face2Ref: React.MutableRefObject<THREE.Vector3>;
}) => {
const meshRef = useRef<THREE.Mesh>(null);
const materials = [
new THREE.MeshStandardMaterial({ color: "#ef4444" }), // +x
new THREE.MeshStandardMaterial({ color: "#22c55e" }), // -x
new THREE.MeshStandardMaterial({ color: "#3b82f6" }), // +y
new THREE.MeshStandardMaterial({ color: "#facc15" }), // -y
new THREE.MeshStandardMaterial({ color: "#a855f7" }), // +z
new THREE.MeshStandardMaterial({ color: "#06b6d4" }), // -z
];
useFrame((state, delta) => {
if (!meshRef.current) return;
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = gamepads[0];
if (gp && !lockedRef.current && alignPhase === 'IDLE') {
const lx = gp.axes[0] ?? 0;
const ly = gp.axes[1] ?? 0;
const rx = gp.axes[2] ?? 0;
const ry = gp.axes[3] ?? 0;
const lt = gp.buttons[6]?.value ?? 0;
const rt = gp.buttons[7]?.value ?? 0;
const aBtn = gp.buttons[0]?.pressed;
const ax = Math.abs(lx) > STICK_DEADZONE ? lx : 0;
const ay = Math.abs(ly) > STICK_DEADZONE ? ly : 0;
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0;
posRef.current.x -= ax * MOVE_SPEED * delta;
posRef.current.z -= ay * MOVE_SPEED * delta;
posRef.current.y = Math.max(0.5, posRef.current.y);
rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2);
rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2);
const scaleDelta = rt - lt;
if (Math.abs(scaleDelta) > TRIGGER_DEADZONE) {
scaleRef.current = Math.max(0.2, Math.min(3, scaleRef.current + scaleDelta * SCALE_SPEED * delta));
} }
})();
if (aBtn && !gamepadRef.current.a) { if (!message) return null;
posRef.current = { x: 0, y: 0.5, z: 0 };
rotRef.current = { x: 0, y: 0, z: 0 };
scaleRef.current = 1;
}
gamepadRef.current.a = aBtn;
} else if (gp && lockedRef.current) {
const rx = gp.axes[2] ?? 0;
const ry = gp.axes[3] ?? 0;
const arx = Math.abs(rx) > STICK_DEADZONE ? rx : 0;
const ary = Math.abs(ry) > STICK_DEADZONE ? ry : 0;
rotRef.current.x = (rotRef.current.x + ary * ROT_SPEED * delta) % (Math.PI * 2);
rotRef.current.y = (rotRef.current.y - arx * ROT_SPEED * delta) % (Math.PI * 2);
}
meshRef.current.position.set(posRef.current.x, posRef.current.y, posRef.current.z);
meshRef.current.rotation.set(rotRef.current.x, rotRef.current.y, rotRef.current.z);
meshRef.current.scale.setScalar(scaleRef.current);
});
return ( return (
<mesh <Text
ref={meshRef} position={[0, 1.4, -1.2]}
castShadow fontSize={0.12}
receiveShadow color="#fbbf24"
position={[0, 0.5, 0]} anchorX="center"
onClick={(e) => { anchorY="middle"
e.stopPropagation(); outlineWidth={0.005}
if (!e.intersections[0].face) return; outlineColor="#000"
const localNormal = e.intersections[0].face.normal;
if (alignPhase === 'IDLE') {
face1Ref.current.copy(localNormal);
setAlignPhase('WAIT_SURFACE');
} else if (alignPhase === 'WAIT_FACE2') {
if (Math.abs(face1Ref.current.dot(localNormal)) < 0.1) {
face2Ref.current.copy(localNormal);
setAlignPhase('WAIT_EDGE');
} else {
alert("Por favor, selecione uma face diferente da primeira (ortogonal) para alinhar a direção.");
}
}
}}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={() => { document.body.style.cursor = 'auto'; }}
> >
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} /> {message}
{materials.map((m, i) => ( </Text>
<primitive attach={`material-${i}`} object={m} key={i} />
))}
</mesh>
); );
}; };
// =================================================================== // ===================================================================
// COMPONENTE: Mesa (Table) // CUBO FÍSICO com Rapier — HLA-like
// =================================================================== // ===================================================================
const Table = ({ import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
onSurfaceClick,
onEdgeClick, type CubeProps = {
}: { position: [number, number, number];
onSurfaceClick: (p: THREE.Vector3, n: THREE.Vector3) => void; locked: boolean;
onEdgeClick: (p: THREE.Vector3, n: THREE.Vector3) => void; onFaceClick: (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => void;
}) => { resetSignal: number;
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
if (!e.intersections[0].face) return;
const n = e.intersections[0].face.normal;
// Identifica se clicou no topo (Y forte) ou nas laterais (X ou Z fortes)
if (Math.abs(n.y) > 0.9) {
onSurfaceClick(e.point, n);
} else {
onEdgeClick(e.point, n);
}
}; };
const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps) => {
const ref = useRef<any>(null);
useEffect(() => {
if (resetSignal > 0 && ref.current) {
ref.current.setTranslation({ x: position[0], y: position[1], z: position[2] }, true);
ref.current.setLinvel({ x: 0, y: 0, z: 0 }, true);
ref.current.setAngvel({ x: 0, y: 0, z: 0 }, true);
}
}, [resetSignal]);
const faceHandlers = (face: "x+" | "x-" | "y+" | "y-" | "z+" | "z-") => ({
onClick: (e: ThreeEvent<MouseEvent>) => {
if (locked) return;
e.stopPropagation();
onFaceClick(face);
},
});
return ( return (
<group position={[0, 0, -2]}> <RigidBody
{/* Tampo Superior */} ref={ref}
<mesh position={[0, 1, 0]} onClick={handleClick} castShadow receiveShadow> position={position}
<boxGeometry args={[2, 0.1, 1]} /> colliders={false}
<meshStandardMaterial color="#8B4513" /> mass={1}
friction={0.7}
restitution={0.3}
linearDamping={0.1}
angularDamping={0.3}
>
<CuboidCollider args={[CUBE_SIZE / 2, CUBE_SIZE / 2, CUBE_SIZE / 2]} />
<mesh castShadow receiveShadow onClick={faceHandlers("x+")}>
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
<meshStandardMaterial color="#ef4444" />
</mesh> </mesh>
{/* Prateleira Inferior */} {[
<mesh position={[0, 0.5, 0]} onClick={handleClick} castShadow receiveShadow> { pos: [CUBE_SIZE / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: "#ef4444" },
<boxGeometry args={[4, 0.1, 1]} /> { pos: [-CUBE_SIZE / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: "#22c55e" },
<meshStandardMaterial color="#A0522D" /> { pos: [0, CUBE_SIZE / 2 + 0.001, 0], rot: [-Math.PI / 2, 0, 0], color: "#eab308" },
{ pos: [0, -CUBE_SIZE / 2 - 0.001, 0], rot: [Math.PI / 2, 0, 0], color: "#3b82f6" },
{ pos: [0, 0, CUBE_SIZE / 2 + 0.001], rot: [0, 0, 0], color: "#a855f7" },
{ pos: [0, 0, -CUBE_SIZE / 2 - 0.001], rot: [0, Math.PI, 0], color: "#f97316" },
].map((f, i) => (
<mesh
key={i}
position={f.pos as [number, number, number]}
rotation={f.rot as [number, number, number]}
>
<planeGeometry args={[CUBE_SIZE * 0.98, CUBE_SIZE * 0.98]} />
<meshStandardMaterial color={f.color} side={THREE.DoubleSide} />
</mesh> </mesh>
{/* Pés */} ))}
<mesh position={[-0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh> </RigidBody>
<mesh position={[0.9, 0.5, 0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh> );
<mesh position={[-0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh> };
<mesh position={[0.9, 0.5, -0.4]} castShadow receiveShadow><boxGeometry args={[0.1, 1, 0.1]} /><meshStandardMaterial color="#5c3a21" /></mesh>
// ===================================================================
// MESA FÍSICA
// ===================================================================
type TableProps = {
onSurfaceClick: () => void;
onEdgeClick: () => void;
};
const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
return (
<group>
<RigidBody type="fixed" position={[0, 0.6, 0]}>
<CuboidCollider args={[1, 0.025, 1]} />
<mesh
castShadow
receiveShadow
onClick={(e) => {
e.stopPropagation();
onSurfaceClick();
}}
>
<boxGeometry args={[2, 0.05, 2]} />
<meshStandardMaterial color="#8b5a2b" />
</mesh>
</RigidBody>
<RigidBody type="fixed" position={[0, 0.3, 0]}>
<CuboidCollider args={[0.6, 0.02, 0.6]} />
<mesh
castShadow
receiveShadow
onClick={(e) => {
e.stopPropagation();
onSurfaceClick();
}}
>
<boxGeometry args={[1.2, 0.04, 1.2]} />
<meshStandardMaterial color="#a07550" />
</mesh>
</RigidBody>
{[
[-0.95, 0.3, -0.95],
[0.95, 0.3, -0.95],
[-0.95, 0.3, 0.95],
[0.95, 0.3, 0.95],
].map((pos, i) => (
<RigidBody key={i} type="fixed" position={pos as [number, number, number]}>
<CuboidCollider args={[0.04, 0.3, 0.04]} />
<mesh castShadow receiveShadow>
<boxGeometry args={[0.08, 0.6, 0.08]} />
<meshStandardMaterial color="#5d3a1a" />
</mesh>
</RigidBody>
))}
{[
{ pos: [-0.98, 0.4, 0], rot: [0, 0, 0] },
{ pos: [0.98, 0.4, 0], rot: [0, 0, 0] },
{ pos: [0, 0.4, -0.98], rot: [0, Math.PI / 2, 0] },
{ pos: [0, 0.4, 0.98], rot: [0, Math.PI / 2, 0] },
].map((edge, i) => (
<mesh
key={i}
position={edge.pos as [number, number, number]}
rotation={edge.rot as [number, number, number]}
onClick={(e) => {
e.stopPropagation();
onEdgeClick();
}}
>
<boxGeometry args={[0.02, 0.5, 0.02]} />
<meshStandardMaterial color="#22d3ee" emissive="#0e7490" emissiveIntensity={0.4} />
</mesh>
))}
</group> </group>
); );
}; };
const useGamepadStatus = () => {
const [connected, setConnected] = useState(false);
useEffect(() => {
const handler = () => {
const pads = navigator.getGamepads?.() ?? [];
setConnected(Array.from(pads).some((p) => p !== null));
};
window.addEventListener("gamepadconnected", handler);
window.addEventListener("gamepaddisconnected", handler);
handler();
const i = window.setInterval(handler, 1000);
return () => {
window.removeEventListener("gamepadconnected", handler);
window.removeEventListener("gamepaddisconnected", handler);
window.clearInterval(i);
};
}, []);
return connected;
};
// =================================================================== // ===================================================================
// Página principal // SCENE — sistema 2-cliques FASE 4
// =================================================================== // ===================================================================
const QuestCubeLab = () => { type FaceId = "x+" | "x-" | "y+" | "y-" | "z+" | "z-";
const joystickConnected = useGamepadStatus(); type AlignmentState = {
contactFace: FaceId | null;
const [cubePos, setCubePos] = useState({ x: 0, y: 0.5, z: 0 }); surface: "tampo" | "prateleira" | null;
const [cubeRot, setCubeRot] = useState({ x: 0, y: 0, z: 0 }); directionFace: FaceId | null;
const [cubeScale, setCubeScale] = useState(1); edge: "norte" | "sul" | "leste" | "oeste" | null;
const [locked, setLocked] = useState(false);
const [alignPhase, setAlignPhase] = useState<AlignPhase>('IDLE');
const face1Ref = useRef(new THREE.Vector3());
const face2Ref = useRef(new THREE.Vector3());
const surfaceNormalRef = useRef(new THREE.Vector3());
const [xrSupported] = useState(() =>
typeof navigator !== "undefined" &&
"xr" in navigator &&
typeof (navigator as Navigator & { xr?: { isSessionSupported?: (mode: string) => Promise<boolean> } }).xr?.isSessionSupported === "function"
);
const posRef = useRef(cubePos);
const rotRef = useRef(cubeRot);
const scaleRef = useRef(cubeScale);
const lockedRef = useRef(locked);
const gamepadRef = useRef({ a: false });
useEffect(() => { posRef.current = cubePos; }, [cubePos]);
useEffect(() => { rotRef.current = cubeRot; }, [cubeRot]);
useEffect(() => { scaleRef.current = cubeScale; }, [cubeScale]);
useEffect(() => { lockedRef.current = locked; }, [locked]);
useEffect(() => {
const id = window.setInterval(() => {
setCubePos({ ...posRef.current });
setCubeRot({ ...rotRef.current });
setCubeScale(scaleRef.current);
}, 100);
return () => window.clearInterval(id);
}, []);
const reset = () => {
posRef.current = { x: 0, y: 0.5, z: 0 };
rotRef.current = { x: 0, y: 0, z: 0 };
scaleRef.current = 1;
setCubePos({ x: 0, y: 0.5, z: 0 });
setCubeRot({ x: 0, y: 0, z: 0 });
setCubeScale(1);
setAlignPhase('IDLE');
}; };
const enterXR = () => { const PhysicsScene = ({
store.enterAR(); locked,
}; resetSignal,
}: {
locked: boolean;
resetSignal: number;
}) => {
const [alignPhase, setAlignPhase] = useState<AlignPhase>("IDLE");
const [align, setAlign] = useState<AlignmentState>({
contactFace: null,
surface: null,
directionFace: null,
edge: null,
});
const handleSurfaceClick = (point: THREE.Vector3, normal: THREE.Vector3) => { const onCubeFace = (face: FaceId) => {
if (alignPhase === 'WAIT_SURFACE') { if (alignPhase === "IDLE") {
surfaceNormalRef.current.copy(normal); setAlign({ ...align, contactFace: face });
setAlignPhase("WAIT_SURFACE");
const t1 = normal.clone().negate(); } else if (alignPhase === "WAIT_FACE2") {
const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1); setAlign({ ...align, directionFace: face });
setAlignPhase("WAIT_EDGE");
const euler = new THREE.Euler().setFromQuaternion(q1, 'XYZ');
setCubeRot({ x: euler.x, y: euler.y, z: euler.z });
rotRef.current = { x: euler.x, y: euler.y, z: euler.z };
const halfSize = (CUBE_SIZE / 2) * scaleRef.current;
const newPos = point.clone().add(normal.clone().multiplyScalar(halfSize));
setCubePos({ x: newPos.x, y: newPos.y, z: newPos.z });
posRef.current = { x: newPos.x, y: newPos.y, z: newPos.z };
setAlignPhase('WAIT_FACE2');
} }
}; };
const handleEdgeClick = (point: THREE.Vector3, edgeNormal: THREE.Vector3) => { const onSurface = () => {
if (alignPhase === 'WAIT_EDGE') { if (alignPhase === "WAIT_SURFACE") {
const t1 = surfaceNormalRef.current.clone().negate(); setAlign({ ...align, surface: "tampo" });
const q1 = new THREE.Quaternion().setFromUnitVectors(face1Ref.current, t1); setAlignPhase("WAIT_FACE2");
}
};
const w2 = face2Ref.current.clone().applyQuaternion(q1); const onEdge = () => {
const t2 = edgeNormal.clone(); if (alignPhase === "WAIT_EDGE") {
setAlign({ ...align, edge: "norte" });
w2.projectOnPlane(surfaceNormalRef.current).normalize(); setTimeout(() => {
t2.projectOnPlane(surfaceNormalRef.current).normalize(); setAlignPhase("IDLE");
setAlign({ contactFace: null, surface: null, directionFace: null, edge: null });
const angle = Math.atan2( }, 1500);
w2.clone().cross(t2).dot(surfaceNormalRef.current),
w2.dot(t2)
);
const q2 = new THREE.Quaternion().setFromAxisAngle(surfaceNormalRef.current, angle);
const finalQ = q2.multiply(q1);
const euler = new THREE.Euler().setFromQuaternion(finalQ, 'XYZ');
setCubeRot({ x: euler.x, y: euler.y, z: euler.z });
rotRef.current = { x: euler.x, y: euler.y, z: euler.z };
setAlignPhase('IDLE');
} }
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100"> <>
<header className="border-b border-slate-800/80 bg-slate-950/80 backdrop-blur"> <ARHudMessages alignPhase={alignPhase} />
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-4"> <Physics gravity={[0, -9.81, 0]} colliders={false} timeStep="vary">
<Link to="/" className="flex items-center gap-2 text-sm text-slate-400 transition hover:text-slate-100"> <RigidBody type="fixed" position={[0, 0, 0]}>
<ArrowLeft className="h-4 w-4" /> Voltar ao SteelXR <CuboidCollider args={[10, 0.01, 10]} />
</Link> <mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<div className="flex items-center gap-3">
<Beaker className="h-5 w-5 text-amber-400" />
<h1 className="font-mono text-sm tracking-wider text-slate-300">
QUEST CUBE LAB <span className="text-amber-400/70">(alpha)</span>
</h1>
</div>
<div className="flex items-center gap-3">
<span className={`inline-flex h-2 w-2 rounded-full ${joystickConnected ? "bg-emerald-400" : "bg-slate-600"}`} />
<span className="text-xs text-slate-400">{joystickConnected ? "Joystick OK" : "Sem joystick"}</span>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8 relative">
<div className="mb-6 flex gap-4">
<div className="flex-1 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-200">
<strong>Fase 3/4 Sistema de Alinhamento:</strong>
<ul className="mt-2 list-disc pl-5">
<li>1. Clique numa face do cubo; depois, numa superfície da mesa (Tampo ou Prateleira).</li>
<li>2. Clique noutra face ortogonal do cubo; depois, numa borda/lateral da mesa.</li>
</ul>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<button
onClick={() => setLocked((v) => !v)}
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition ${
locked ? "border-amber-500 bg-amber-500/15 text-amber-200" : "border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-500"
}`}
>
{locked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
{locked ? "Travado" : "Destravar"}
</button>
<button onClick={reset} className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-300 transition hover:border-slate-500">
<RotateCcw className="h-4 w-4" />
Reset
</button>
{alignPhase !== 'IDLE' && (
<button onClick={() => setAlignPhase('IDLE')} className="flex items-center gap-2 rounded-md border border-red-500/50 bg-red-900/30 px-3 py-2 text-sm text-red-300 transition hover:border-red-500">
Cancelar Alinhamento
</button>
)}
<div className="ml-auto flex items-center gap-2">
<button
disabled={!xrSupported}
onClick={enterXR}
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition ${
xrSupported ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-200 hover:bg-emerald-500/20" : "cursor-not-allowed border-slate-700 bg-slate-900 text-slate-500"
}`}
>
<Hand className="h-4 w-4" />
Entrar em AR
</button>
</div>
</div>
<section className="relative overflow-hidden rounded-xl border border-slate-800 bg-slate-950">
<div className="absolute top-4 left-4 z-10 rounded bg-slate-900/90 p-4 text-sm border border-slate-700/50 backdrop-blur pointer-events-none max-w-sm">
<div className="font-bold text-amber-300 mb-1 flex items-center gap-2">
Status Alinhamento
</div>
<div className="text-slate-300">
{alignPhase === 'IDLE' && 'Livre! Clique em uma face do cubo para iniciar o contato.'}
{alignPhase === 'WAIT_SURFACE' && 'Face gravada! Agora clique em uma superfície da mesa (Tampo/Prateleira) para apoiar o cubo.'}
{alignPhase === 'WAIT_FACE2' && 'Apoiado! Agora clique em outra face do cubo para alinhamento lateral.'}
{alignPhase === 'WAIT_EDGE' && 'Último passo! Clique em uma borda ou lateral da mesa para alinhar o cubo.'}
</div>
</div>
<div style={{ height: 600 }}>
<Canvas shadows>
<XR store={store}>
<PerspectiveCamera makeDefault position={[3, 4, 6]} fov={50} />
<ambientLight intensity={0.5} />
<directionalLight position={[5, 8, 5]} intensity={1} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<CubeControlado
posRef={posRef}
rotRef={rotRef}
scaleRef={scaleRef}
lockedRef={lockedRef}
gamepadRef={gamepadRef}
alignPhase={alignPhase}
setAlignPhase={setAlignPhase}
face1Ref={face1Ref}
face2Ref={face2Ref}
/>
<Table
onSurfaceClick={handleSurfaceClick}
onEdgeClick={handleEdgeClick}
/>
<Grid args={[20, 20]} cellSize={0.25} cellThickness={0.5} cellColor="#475569" sectionSize={1} sectionThickness={1} sectionColor="#94a3b8" fadeDistance={18} fadeStrength={1} infiniteGrid={false} position={[0, 0, 0]} />
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.001, 0]}>
<planeGeometry args={[20, 20]} /> <planeGeometry args={[20, 20]} />
<shadowMaterial transparent opacity={0.35} /> <meshStandardMaterial color="#1e293b" />
</mesh> </mesh>
<OrbitControls makeDefault enableDamping /> </RigidBody>
<Environment preset="city" />
</XR>
</Canvas>
</div>
<div className="flex items-center justify-between border-t border-slate-800 bg-slate-950/80 px-4 py-2"> <PhysicsCube
<div className="font-mono text-[11px] text-slate-400"> position={[0, 1.5, 0]}
pos({cubePos.x.toFixed(2)}, {cubePos.y.toFixed(2)}, {cubePos.z.toFixed(2)}) · rot({((cubeRot.y * 180) / Math.PI).toFixed(0)}°) · scale {cubeScale.toFixed(2)}x locked={locked}
</div> onFaceClick={onCubeFace}
</div> resetSignal={resetSignal}
</section> />
<section className="mt-8 rounded-lg border border-slate-800 bg-slate-900/30 p-6"> <PhysicsTable onSurfaceClick={onSurface} onEdgeClick={onEdge} />
<h2 className="mb-4 font-mono text-xs uppercase tracking-[0.2em] text-slate-400">Próximas fases</h2> </Physics>
<ol className="space-y-2 text-sm text-slate-300"> </>
<Phase n="1" status="done" title="Botão na Home + rota /lab" />
<Phase n="2" status="done" title="Cubo Three.js + Grid fino + Travar/Destravar + Joystick funcional" />
<Phase n="3" status="done" title="Sistema de Apoio (face objeto + superfície alvo) - Tampo/Prateleira" />
<Phase n="4" status="done" title="Sistema Direcional (face objeto + aresta alvo)" />
<Phase n="5" title="Integração WebXR completa (@react-three/xr) — Passthrough real" />
<Phase n="6" title="Migração da lógica redonda pro SteelXR XR Session principal" />
</ol>
</section>
</main>
</div>
); );
}; };
const Phase = ({ n, status, title }: { n: string; status?: "done" | "active"; title: string }) => ( // ===================================================================
<li className="flex items-start gap-3"> // FUNDO DINÂMICO — NUNCA usa <color attach="background"> quando AR estiver ativo
<span // (issue pmndrs/xr#344: background color opacifica o passthrough)
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${ // ===================================================================
status === "done" ? "bg-emerald-500/20 text-emerald-300" : status === "active" ? "bg-amber-500/20 text-amber-300" : "bg-slate-800 text-slate-500" const ARBackground = ({ isAR }: { isAR: boolean }) => {
}`} if (isAR) return null;
>
{n}
</span>
<span className="text-slate-200">{title}</span>
</li>
);
export default QuestCubeLab; return (
<>
<color attach="background" args={["#0a0a0a"]} />
<fog attach="fog" args={["#0a0a0a", 5, 18]} />
</>
);
};
// ===================================================================
// SCENE WRAPPER
// ===================================================================
const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: number }) => {
const session = useXR((s) => s.session);
const isAR = !!(session && (session as any).environmentBlendMode === "additive");
return (
<>
<ARBackground isAR={isAR} />
<ambientLight intensity={isAR ? 1.2 : 0.6} />
<directionalLight
position={[5, 5, 5]}
intensity={isAR ? 1.5 : 1.2}
castShadow
shadow-mapSize={[1024, 1024]}
/>
<Grid
position={[0, 0.001, 0]}
args={[20, 20]}
cellSize={0.25}
cellThickness={0.5}
cellColor="#334155"
sectionSize={1}
sectionThickness={1}
sectionColor="#64748b"
fadeDistance={12}
fadeStrength={1.5}
infiniteGrid
/>
<Suspense fallback={null}>
<Environment preset="warehouse" />
</Suspense>
<PhysicsScene locked={locked} resetSignal={resetSignal} />
<OrbitControls
makeDefault
target={[0, 0.5, 0]}
enablePan={true}
minDistance={1}
maxDistance={10}
/>
</>
);
};
// ===================================================================
// GAMEPAD HANDLER
// ===================================================================
const GamepadHandler = () => {
const [connected, setConnected] = useState(false);
useEffect(() => {
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
window.addEventListener("gamepadconnected", onConnect);
window.addEventListener("gamepaddisconnected", onDisconnect);
return () => {
window.removeEventListener("gamepadconnected", onConnect);
window.removeEventListener("gamepaddisconnected", onDisconnect);
};
}, []);
useEffect(() => {
if (!connected) return;
let raf: number;
const tick = () => {
const pads = navigator.getGamepads?.();
for (const pad of pads ?? []) {
if (!pad) continue;
const lsx = Math.abs(pad.axes[0]) > STICK_DEADZONE ? pad.axes[0] : 0;
const lsy = Math.abs(pad.axes[1]) > STICK_DEADZONE ? pad.axes[1] : 0;
const rsx = Math.abs(pad.axes[2]) > STICK_DEADZONE ? pad.axes[2] : 0;
const rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0;
if (lsx || lsy || rsx || rsy) {
// log para debug
}
}
raf = requestAnimationFrame(tick);
};
tick();
return () => cancelAnimationFrame(raf);
}, [connected]);
return connected;
};
// ===================================================================
// COMPONENTE PRINCIPAL — página do Lab
// ===================================================================
export default function QuestCubeLab() {
const [locked, setLocked] = useState(false);
const [resetSignal, setResetSignal] = useState(0);
const [autoEnter, setAutoEnter] = useState(false);
useEffect(() => {
if (!autoEnter) return;
(async () => {
try {
// ✅ FIX: usa a API do store (createXRStore.enterAR) em vez de chamar
// navigator.xr.requestSession cru — alinha com o resto do projeto
await store.enterAR();
} catch (err) {
console.warn("[Lab] Falha em enterAR, tentando enterVR:", err);
try {
await store.enterVR();
} catch (err2) {
console.error("[Lab] Falha em enterVR também:", err2);
}
}
})();
}, [autoEnter]);
// ✅ FIX: cleanup ao desmontar (issue pmndrs/xr#490)
useEffect(() => {
return () => {
const session = store.getState().session;
if (session) {
try { session.end(); } catch {}
}
};
}, []);
const reset = () => {
setResetSignal((n) => n + 1);
};
return (
<div className="w-screen h-screen relative bg-black overflow-hidden">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 pointer-events-none">
<div className="flex items-center justify-between">
<Link
to="/"
className="flex items-center gap-2 text-amber-400 hover:text-amber-300 pointer-events-auto"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm">Voltar ao SteelXR</span>
</Link>
<div className="flex items-center gap-2">
<Beaker className="w-4 h-4 text-amber-400" />
<span className="text-amber-400 text-sm font-mono">
Laboratório de Cubos Fase 2.5 (Physics HLA-like)
</span>
</div>
</div>
</div>
{/* Controles flutuantes */}
<div className="absolute top-16 right-4 z-20 flex flex-col gap-2">
<button
onClick={() => setLocked((l) => !l)}
className="bg-slate-800 hover:bg-slate-700 text-amber-400 px-3 py-2 rounded-lg text-xs flex items-center gap-2"
title={locked ? "Destravar cubo" : "Travar cubo"}
>
{locked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
{locked ? "Travar" : "Destravar"}
</button>
<button
onClick={reset}
className="bg-slate-800 hover:bg-slate-700 text-amber-400 px-3 py-2 rounded-lg text-xs flex items-center gap-2"
title="Resetar cubo na posição inicial"
>
<RotateCcw className="w-3 h-3" />
Reset
</button>
<button
onClick={() => setAutoEnter(true)}
className="bg-amber-600 hover:bg-amber-500 text-slate-900 px-3 py-2 rounded-lg text-xs flex items-center gap-2 font-semibold"
>
<Hand className="w-3 h-3" />
Entrar em XR
</button>
</div>
{/* Badge joystick */}
<GamepadHandler />
<div className="absolute bottom-4 left-4 z-20 bg-slate-800/80 text-amber-400 px-3 py-1 rounded-full text-xs font-mono">
<span className="inline-block w-2 h-2 bg-amber-400 rounded-full mr-2 animate-pulse" />
Rapier Physics ativo Half-Life: Alyx-like
</div>
{/* ✅ FIX PRINCIPAL: árvore CORRETA — Canvas por fora, XR por dentro */}
<Canvas
shadows
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: true,
}}
camera={{ position: [0, 1.5, 3], fov: 50, near: 0.01, far: 100 }}
className="!bg-transparent"
onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0);
// gl.xr.enabled agora é controlado pelo <XR>
}}
>
<XR store={store}>
<SceneContent locked={locked} resetSignal={resetSignal} />
</XR>
</Canvas>
</div>
);
}