Changes
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
||||
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const POSITION_STEP = 0.001;
|
||||
const ROTATION_STEP = 0.1;
|
||||
|
||||
interface XRHudProps {
|
||||
/** Whether joystick moves model freely (without grip) */
|
||||
freeMove: boolean;
|
||||
onToggleFreeMove: () => void;
|
||||
}
|
||||
|
||||
export function XRHud({ freeMove, onToggleFreeMove }: XRHudProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
opacity, setOpacity,
|
||||
renderMode, setRenderMode,
|
||||
showGrid, setShowGrid,
|
||||
measureMode, setMeasureMode,
|
||||
measurements, clearMeasurements, measurePoints,
|
||||
fineTuning, setFineTuning, resetFineTuning,
|
||||
addScreenshot,
|
||||
} = useModelStore();
|
||||
|
||||
const handleScreenshot = () => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
addScreenshot(dataUrl);
|
||||
toast.success('Screenshot capturado!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-none p-3">
|
||||
<div className="pointer-events-auto mx-auto max-w-lg space-y-2">
|
||||
{/* Expanded panel */}
|
||||
{expanded && (
|
||||
<div className="rounded-xl border bg-card/90 backdrop-blur-md p-3 shadow-2xl space-y-3 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="h-3 w-3 text-primary" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">Opacidade</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[opacity * 100]}
|
||||
min={0} max={100} step={5}
|
||||
onValueChange={([v]) => setOpacity(v / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fine Tuning */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Move className="h-3 w-3 text-primary" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">Posição (mm)</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-5 text-[9px] px-1.5 gap-0.5" onClick={resetFineTuning}>
|
||||
<RefreshCw className="h-2.5 w-2.5" /> Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(['posX', 'posY', 'posZ'] as const).map((axis) => (
|
||||
<div key={axis} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1">
|
||||
<span className="font-mono text-[9px] text-muted-foreground w-4">{axis.slice(-1).toUpperCase()}</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||
onClick={() => setFineTuning({ [axis]: fineTuning[axis] - POSITION_STEP })}>−</Button>
|
||||
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
|
||||
{(fineTuning[axis] * 1000).toFixed(1)}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||
onClick={() => setFineTuning({ [axis]: fineTuning[axis] + POSITION_STEP })}>+</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-1 max-w-[33%]">
|
||||
<RotateCw className="h-2.5 w-2.5 text-primary" />
|
||||
<span className="font-mono text-[9px] text-muted-foreground w-4">Y°</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||
onClick={() => setFineTuning({ rotY: fineTuning.rotY - ROTATION_STEP })}>−</Button>
|
||||
<span className="font-mono text-[9px] text-foreground flex-1 text-center">
|
||||
{fineTuning.rotY.toFixed(1)}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-[10px]"
|
||||
onClick={() => setFineTuning({ rotY: fineTuning.rotY + ROTATION_STEP })}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main toolbar */}
|
||||
<div className="flex items-center justify-between rounded-xl border bg-card/90 backdrop-blur-md p-2 shadow-2xl">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* Expand/collapse */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Free move toggle */}
|
||||
<Button
|
||||
variant={freeMove ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
onClick={onToggleFreeMove}
|
||||
>
|
||||
<Grip className="h-3.5 w-3.5" />
|
||||
{freeMove ? 'Mover ON' : 'Mover'}
|
||||
</Button>
|
||||
|
||||
{/* Grid */}
|
||||
<Button
|
||||
variant={showGrid ? 'default' : 'outline'}
|
||||
size="icon" className="h-8 w-8"
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
title="Grid"
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Render mode */}
|
||||
<Button
|
||||
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
onClick={() => {
|
||||
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
||||
setRenderMode(next);
|
||||
}}
|
||||
>
|
||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
|
||||
</Button>
|
||||
|
||||
{/* Measure */}
|
||||
<Button
|
||||
variant={measureMode ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
onClick={() => setMeasureMode(!measureMode)}
|
||||
>
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
{measureMode
|
||||
? measurePoints.length === 0 ? 'Pt A…' : 'Pt B…'
|
||||
: 'Medir'}
|
||||
</Button>
|
||||
|
||||
{measurements.length > 0 && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={clearMeasurements} title="Limpar medições">
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleScreenshot} title="Screenshot">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+205
-72
@@ -1,32 +1,29 @@
|
||||
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { useGLTF } from '@react-three/drei';
|
||||
import { useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||
import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Move, RotateCw, QrCode, Download, Crosshair } from 'lucide-react';
|
||||
import { ArrowLeft, Download, QrCode, Crosshair } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||
import { XRHud } from '@/components/XRHud';
|
||||
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
||||
|
||||
// --- Diagnóstico XR ---
|
||||
console.log('[XR] Inicializando store...');
|
||||
console.log('[XR] navigator.xr disponível:', !!navigator.xr);
|
||||
if (navigator.xr) {
|
||||
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
||||
console.log('[XR] immersive-ar suportado:', supported);
|
||||
}).catch((err) => {
|
||||
console.error('[XR] Erro ao checar suporte AR:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// --- XR Store at MODULE level (required by Quest 3 browser) ---
|
||||
const store = createXRStore({
|
||||
hand: { left: true, right: true },
|
||||
controller: { left: true, right: true },
|
||||
});
|
||||
console.log('[XR] Store criado com sucesso');
|
||||
|
||||
// ─── XRModel ───────────────────────────────────────────
|
||||
function XRModel({ url }: { url: string }) {
|
||||
@@ -35,6 +32,9 @@ function XRModel({ url }: { url: string }) {
|
||||
const fineTuning = useModelStore((s) => s.fineTuning);
|
||||
const opacity = useModelStore((s) => s.opacity);
|
||||
const renderMode = useModelStore((s) => s.renderMode);
|
||||
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
||||
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
||||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||||
const checklist = useModelStore((s) => s.checklist);
|
||||
|
||||
const modelInfo = useMemo(() => {
|
||||
@@ -57,25 +57,55 @@ function XRModel({ url }: { url: string }) {
|
||||
} else {
|
||||
child.material = child.material.clone();
|
||||
}
|
||||
|
||||
// Clean up previous edge lines
|
||||
const toRemove: THREE.Object3D[] = [];
|
||||
child.children.forEach(c => {
|
||||
if (c.userData.__edgeLine) toRemove.push(c);
|
||||
});
|
||||
toRemove.forEach(c => {
|
||||
if (c instanceof THREE.LineSegments) {
|
||||
c.geometry.dispose();
|
||||
(c.material as THREE.Material).dispose();
|
||||
}
|
||||
child.remove(c);
|
||||
});
|
||||
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
mat.transparent = opacity < 1;
|
||||
mat.opacity = opacity;
|
||||
mat.wireframe = renderMode === 'wireframe';
|
||||
mat.needsUpdate = true;
|
||||
if (hasRejected) {
|
||||
mat.color.setHSL(0, 0.7, 0.5);
|
||||
} else if (allApproved) {
|
||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||
if (renderMode === 'edges') {
|
||||
mat.visible = false;
|
||||
} else {
|
||||
mat.color.set(0x8899aa);
|
||||
mat.visible = true;
|
||||
mat.transparent = opacity < 1;
|
||||
mat.opacity = opacity;
|
||||
mat.wireframe = renderMode === 'wireframe';
|
||||
if (renderMode === 'wireframe') {
|
||||
mat.wireframeLinewidth = wireframeThickness;
|
||||
mat.color.set(wireframeColor);
|
||||
} else if (hasRejected) {
|
||||
mat.color.setHSL(0, 0.7, 0.5);
|
||||
} else if (allApproved) {
|
||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||
} else {
|
||||
mat.color.set(0x8899aa);
|
||||
}
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (renderMode === 'edges' && child.geometry) {
|
||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness });
|
||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||||
lineSegments.userData.__edgeLine = true;
|
||||
child.add(lineSegments);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [scene, opacity, renderMode, checklist]);
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle]);
|
||||
|
||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||
|
||||
@@ -95,7 +125,7 @@ function XRModel({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
// ─── ControllerFineTuning ──────────────────────────────
|
||||
function ControllerFineTuning() {
|
||||
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||
const { setFineTuning } = useModelStore();
|
||||
const session = useXR((s) => s.session);
|
||||
|
||||
@@ -121,7 +151,8 @@ function ControllerFineTuning() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!gripHeld) return;
|
||||
// In freeMove mode, joystick always moves. Otherwise requires grip.
|
||||
if (!freeMove && !gripHeld) return;
|
||||
|
||||
const posStep = 0.001;
|
||||
const rotStep = 0.1;
|
||||
@@ -144,17 +175,146 @@ function ControllerFineTuning() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Measurement overlay (reused from ModelViewer) ─────
|
||||
function XRMeasurementOverlay() {
|
||||
const measurements = useModelStore((s) => s.measurements);
|
||||
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
|
||||
return (
|
||||
<>
|
||||
{measureMode && snapPoint && (
|
||||
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.003, 0.005, 24]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
)}
|
||||
{measurePoints.length === 1 && (
|
||||
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#e8a838" />
|
||||
</mesh>
|
||||
)}
|
||||
{measurements.map((m) => {
|
||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
||||
return (
|
||||
<group key={m.id}>
|
||||
<mesh position={a}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
|
||||
<mesh position={b}><sphereGeometry args={[0.003, 16, 16]} /><meshBasicMaterial color="#22c55e" /></mesh>
|
||||
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
|
||||
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
|
||||
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
|
||||
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
|
||||
{m.distanceMM.toFixed(1)} mm
|
||||
</span>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Snap handler for XR raycasting ────────────────────
|
||||
function XRSnapHandler() {
|
||||
const { camera, scene, gl } = useThree();
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
||||
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureMode) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = gl.domElement.getBoundingClientRect();
|
||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
};
|
||||
gl.domElement.addEventListener('pointermove', onMove);
|
||||
return () => gl.domElement.removeEventListener('pointermove', onMove);
|
||||
}, [gl, mouse, measureMode]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!measureMode) { setSnapPoint(null); return; }
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
const hit = intersects.find(i => {
|
||||
const obj = i.object;
|
||||
if (obj instanceof THREE.GridHelper) return false;
|
||||
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
|
||||
if (obj.userData.__edgeLine) return false;
|
||||
return obj instanceof THREE.Mesh;
|
||||
});
|
||||
if (hit && hit.object instanceof THREE.Mesh) {
|
||||
const canvas = gl.domElement;
|
||||
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
|
||||
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
|
||||
} else {
|
||||
setSnapPoint(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Click to measure
|
||||
useEffect(() => {
|
||||
if (!measureMode) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (snapPoint) {
|
||||
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
||||
return;
|
||||
}
|
||||
const rect = gl.domElement.getBoundingClientRect();
|
||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
const hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
|
||||
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||||
};
|
||||
gl.domElement.addEventListener('click', onClick);
|
||||
return () => gl.domElement.removeEventListener('click', onClick);
|
||||
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
|
||||
|
||||
useEffect(() => {
|
||||
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
|
||||
return () => { gl.domElement.style.cursor = 'grab'; };
|
||||
}, [measureMode, gl]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── XR Grid ───────────────────────────────────────────
|
||||
function XRGrid() {
|
||||
const showGrid = useModelStore((s) => s.showGrid);
|
||||
if (!showGrid) return null;
|
||||
return (
|
||||
<Grid
|
||||
infiniteGrid
|
||||
cellSize={0.01}
|
||||
sectionSize={0.1}
|
||||
cellThickness={0.5}
|
||||
sectionThickness={1}
|
||||
cellColor="#334155"
|
||||
sectionColor="#475569"
|
||||
fadeDistance={5}
|
||||
fadeStrength={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ImageTrackingAnchor ───────────────────────────────
|
||||
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (!groupRef.current) return;
|
||||
// Place model 1.5m in front of the user
|
||||
groupRef.current.position.set(0, 0, -1.5);
|
||||
groupRef.current.quaternion.identity();
|
||||
});
|
||||
|
||||
return <group ref={groupRef}>{children}</group>;
|
||||
}
|
||||
|
||||
@@ -163,28 +323,23 @@ const XRSession = () => {
|
||||
const navigate = useNavigate();
|
||||
const { model, anchorMode, setAnchorMode } = useModelStore();
|
||||
const [inXR, setInXR] = useState(false);
|
||||
const [freeMove, setFreeMove] = useState(true); // default ON for easier positioning
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
navigate('/');
|
||||
}
|
||||
if (!model) navigate('/');
|
||||
}, [model, navigate]);
|
||||
|
||||
// Listen for XR session state changes
|
||||
useEffect(() => {
|
||||
console.log('[XR] Subscribing to store state changes');
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
console.log('[XR] Store state changed — session:', !!state.session, 'mode:', state.mode);
|
||||
const session = state.session;
|
||||
if (session && !inXR) {
|
||||
console.log('[XR] ✅ Sessão AR ativa! Features:', (session as any).enabledFeatures);
|
||||
console.log('[XR] ✅ Sessão AR ativa!');
|
||||
setInXR(true);
|
||||
setAnchorMode('manual');
|
||||
toast.success('Sessão AR iniciada!');
|
||||
session.addEventListener('end', () => {
|
||||
console.log('[XR] ❌ Sessão AR encerrada');
|
||||
setInXR(false);
|
||||
setAnchorMode('manual');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -195,7 +350,7 @@ const XRSession = () => {
|
||||
const url = await generateMarkerDownloadURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'TrackkSteelXR_Marker.png';
|
||||
a.download = 'TrackSteelXR_Marker.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||
@@ -221,33 +376,21 @@ const XRSession = () => {
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">Marcador QR</span>
|
||||
<span className="font-mono text-xs">Marcador</span>
|
||||
</Button>
|
||||
{/* CRITICAL: Direct call without async wrapper to preserve user gesture */}
|
||||
<Button className="gap-2 glow-primary" onClick={() => { console.log('[XR] Botão AR clicado — chamando store.enterAR()'); store.enterAR().then((s) => console.log('[XR] enterAR resolveu:', s)).catch((e) => console.error('[XR] enterAR rejeitado:', e)); }}>
|
||||
<Button className="gap-2 glow-primary" onClick={() => {
|
||||
console.log('[XR] Botão AR clicado');
|
||||
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
||||
}}>
|
||||
Iniciar Sessão AR
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inXR && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-success flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-success animate-pulse" />
|
||||
XR Ativo
|
||||
</span>
|
||||
{anchorMode === 'tracking' ? (
|
||||
<span className="font-mono text-[10px] text-primary flex items-center gap-1">
|
||||
<QrCode className="h-3 w-3" />
|
||||
Tracking
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Crosshair className="h-3 w-3" />
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
XR Ativo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
@@ -255,12 +398,14 @@ const XRSession = () => {
|
||||
{/* 3D Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<Canvas
|
||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||
camera={{ position: [0, 1.6, 0], fov: 50, near: 0.01, far: 100 }}
|
||||
className="!bg-transparent"
|
||||
onCreated={({ gl }) => {
|
||||
onCreated={({ gl, invalidate }) => {
|
||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
gl.setClearColor(0x000000, 0);
|
||||
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
||||
loop();
|
||||
}}
|
||||
>
|
||||
<XR store={store}>
|
||||
@@ -274,27 +419,15 @@ const XRSession = () => {
|
||||
<XRModel url={model.url} />
|
||||
</ImageTrackingAnchor>
|
||||
|
||||
<ControllerFineTuning />
|
||||
<XRMeasurementOverlay />
|
||||
<XRSnapHandler />
|
||||
<XRGrid />
|
||||
<ControllerFineTuning freeMove={freeMove} />
|
||||
</XR>
|
||||
</Canvas>
|
||||
|
||||
{/* Floating HUD overlay */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 pointer-events-none">
|
||||
<div className="pointer-events-auto inline-flex flex-wrap gap-3 rounded-lg border bg-card/90 backdrop-blur-sm p-3 shadow-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Move className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
Grip + Joy Esq: X/Y
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RotateCw className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
Grip + Joy Dir: Z/Rot
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Floating HUD overlay with all controls */}
|
||||
<XRHud freeMove={freeMove} onToggleFreeMove={() => setFreeMove(!freeMove)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user