This commit is contained in:
gpt-engineer-app[bot]
2026-02-28 00:11:41 +00:00
parent 48a8625b3c
commit 67d6859606
2 changed files with 376 additions and 72 deletions
+171
View File
@@ -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
View File
@@ -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>
);