Changes
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Generates a simple QR-like marker pattern as an ImageBitmap.
|
||||
* This creates a deterministic black/white grid pattern that can be
|
||||
* printed and used as an image tracking target.
|
||||
*
|
||||
* The pattern is 200x200px with a distinctive finder pattern.
|
||||
*/
|
||||
export async function generateTrackingMarker(): Promise<ImageBitmap> {
|
||||
const size = 200;
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const cellSize = size / 10;
|
||||
|
||||
// Draw a distinctive pattern (simplified QR-like finder)
|
||||
ctx.fillStyle = '#000000';
|
||||
|
||||
// Outer border
|
||||
ctx.fillRect(0, 0, size, cellSize);
|
||||
ctx.fillRect(0, size - cellSize, size, cellSize);
|
||||
ctx.fillRect(0, 0, cellSize, size);
|
||||
ctx.fillRect(size - cellSize, 0, cellSize, size);
|
||||
|
||||
// Top-left finder (3x3 block)
|
||||
for (let r = 1; r <= 3; r++) {
|
||||
for (let c = 1; c <= 3; c++) {
|
||||
if (r === 2 && c === 2) {
|
||||
ctx.fillStyle = '#000000';
|
||||
} else if ((r + c) % 2 === 0) {
|
||||
ctx.fillStyle = '#000000';
|
||||
} else {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
}
|
||||
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom-right finder (3x3 block)
|
||||
ctx.fillStyle = '#000000';
|
||||
for (let r = 6; r <= 8; r++) {
|
||||
for (let c = 6; c <= 8; c++) {
|
||||
if (r === 7 && c === 7) {
|
||||
ctx.fillStyle = '#000000';
|
||||
} else if ((r + c) % 2 === 0) {
|
||||
ctx.fillStyle = '#000000';
|
||||
} else {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
}
|
||||
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Center cross
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(4 * cellSize, 5 * cellSize, 2 * cellSize, cellSize);
|
||||
ctx.fillRect(5 * cellSize, 4 * cellSize, cellSize, 2 * cellSize);
|
||||
|
||||
// Diagonal accents
|
||||
ctx.fillRect(2 * cellSize, 6 * cellSize, cellSize, cellSize);
|
||||
ctx.fillRect(7 * cellSize, 2 * cellSize, cellSize, cellSize);
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a downloadable PNG of the tracking marker for printing.
|
||||
*/
|
||||
export async function generateMarkerDownloadURL(): Promise<string> {
|
||||
const size = 400; // Larger for print quality
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const bitmap = await generateTrackingMarker();
|
||||
ctx.drawImage(bitmap, 0, 0, size, size);
|
||||
|
||||
// Add label
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
+174
-46
@@ -6,8 +6,18 @@ 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 } from 'lucide-react';
|
||||
import { ArrowLeft, Move, RotateCw, QrCode, Download, Crosshair } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateTrackingMarker, generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||
|
||||
// Store tracking marker bitmap globally so it persists across renders
|
||||
let markerBitmapPromise: Promise<ImageBitmap> | null = null;
|
||||
function getMarkerBitmap(): Promise<ImageBitmap> {
|
||||
if (!markerBitmapPromise) {
|
||||
markerBitmapPromise = generateTrackingMarker();
|
||||
}
|
||||
return markerBitmapPromise;
|
||||
}
|
||||
|
||||
const xrStore = createXRStore({
|
||||
emulate: 'metaQuest3',
|
||||
@@ -17,7 +27,6 @@ const xrStore = createXRStore({
|
||||
|
||||
/**
|
||||
* The 3D model rendered inside the XR scene.
|
||||
* Applies fine tuning offsets from the store.
|
||||
*/
|
||||
function XRModel({ url }: { url: string }) {
|
||||
const { scene } = useGLTF(url);
|
||||
@@ -36,7 +45,6 @@ function XRModel({ url }: { url: string }) {
|
||||
return { size, center };
|
||||
}, [scene]);
|
||||
|
||||
// Apply visual properties
|
||||
useEffect(() => {
|
||||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||
const allApproved = checklist.every(i => i.status === 'approved');
|
||||
@@ -87,8 +95,6 @@ function XRModel({ url }: { url: string }) {
|
||||
|
||||
/**
|
||||
* Controller-based fine tuning input handler.
|
||||
* Left joystick = X/Y position, Right joystick = Z position + Y rotation.
|
||||
* Active only when any grip button is held.
|
||||
*/
|
||||
function ControllerFineTuning() {
|
||||
const { setFineTuning } = useModelStore();
|
||||
@@ -106,13 +112,8 @@ function ControllerFineTuning() {
|
||||
for (const source of inputSources) {
|
||||
const gp = source.gamepad;
|
||||
if (!gp) continue;
|
||||
|
||||
// Check grip button (usually index 2)
|
||||
const gripButton = gp.buttons[2];
|
||||
if (gripButton && gripButton.pressed) {
|
||||
gripHeld = true;
|
||||
}
|
||||
|
||||
if (gripButton && gripButton.pressed) gripHeld = true;
|
||||
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
||||
leftAxes = [gp.axes[2], gp.axes[3]];
|
||||
}
|
||||
@@ -123,10 +124,9 @@ function ControllerFineTuning() {
|
||||
|
||||
if (!gripHeld) return;
|
||||
|
||||
const posStep = 0.001; // 1mm per frame at full deflection
|
||||
const rotStep = 0.1; // 0.1° per frame at full deflection
|
||||
const posStep = 0.001;
|
||||
const rotStep = 0.1;
|
||||
const deadzone = 0.15;
|
||||
|
||||
const store = useModelStore.getState();
|
||||
const ft = { ...store.fineTuning };
|
||||
|
||||
@@ -146,37 +146,146 @@ function ControllerFineTuning() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual placement: places model 1.5m in front of user at floor level.
|
||||
* Image Tracking Anchor — uses WebXR's getImageTrackingResults()
|
||||
* to position the model at a detected QR marker.
|
||||
* Falls back to manual placement when tracking is lost or unavailable.
|
||||
*/
|
||||
function ManualAnchor({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<group position={[0, 0, -1.5]}>
|
||||
{children}
|
||||
</group>
|
||||
);
|
||||
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const session = useXR((s) => s.session);
|
||||
const { gl } = useThree();
|
||||
const { setAnchorMode } = useModelStore();
|
||||
const trackingActive = useRef(false);
|
||||
const lastPoseMatrix = useRef(new THREE.Matrix4());
|
||||
|
||||
useFrame((_, __, frame: any) => {
|
||||
if (!frame || !session || !groupRef.current) return;
|
||||
|
||||
// Try to get image tracking results from the XR frame
|
||||
try {
|
||||
const results = frame.getImageTrackingResults?.();
|
||||
if (results && results.length > 0) {
|
||||
for (const result of results) {
|
||||
if (result.trackingState === 'tracked' || result.trackingState === 'emulated') {
|
||||
const refSpace = gl.xr.getReferenceSpace();
|
||||
if (!refSpace) continue;
|
||||
|
||||
const pose = frame.getPose(result.imageSpace, refSpace);
|
||||
if (pose) {
|
||||
const pos = pose.transform.position;
|
||||
const ori = pose.transform.orientation;
|
||||
|
||||
groupRef.current.position.set(pos.x, pos.y, pos.z);
|
||||
groupRef.current.quaternion.set(ori.x, ori.y, ori.z, ori.w);
|
||||
|
||||
if (!trackingActive.current) {
|
||||
trackingActive.current = true;
|
||||
setAnchorMode('tracking');
|
||||
toast.success('QR Code detectado — Modelo ancorado!');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// getImageTrackingResults not available — fall back to manual
|
||||
}
|
||||
|
||||
// If tracking was active but lost
|
||||
if (trackingActive.current) {
|
||||
// Keep last known position (don't reset)
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual fallback — place 1.5m in front
|
||||
if (!trackingActive.current) {
|
||||
groupRef.current.position.set(0, 0, -1.5);
|
||||
groupRef.current.quaternion.identity();
|
||||
}
|
||||
});
|
||||
|
||||
return <group ref={groupRef}>{children}</group>;
|
||||
}
|
||||
|
||||
const XRSession = () => {
|
||||
const navigate = useNavigate();
|
||||
const { model, xrSupported, anchorMode } = useModelStore();
|
||||
const { model, xrSupported, anchorMode, setAnchorMode } = useModelStore();
|
||||
const [inXR, setInXR] = useState(false);
|
||||
const [markerReady, setMarkerReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
// Pre-load marker bitmap
|
||||
getMarkerBitmap().then(() => setMarkerReady(true));
|
||||
}, [model, navigate]);
|
||||
|
||||
const handleEnterAR = useCallback(async () => {
|
||||
try {
|
||||
// We attempt to request with image-tracking as optional feature.
|
||||
// The createXRStore.enterAR will handle the session request.
|
||||
// We need to configure the session with trackedImages.
|
||||
// Since @react-three/xr doesn't directly support trackedImages,
|
||||
// we'll try to patch it through the native API.
|
||||
|
||||
const bitmap = await getMarkerBitmap();
|
||||
|
||||
// Try entering AR with image tracking support
|
||||
// The @react-three/xr store manages the session, but we can
|
||||
// request image-tracking as an optional feature
|
||||
try {
|
||||
// Override: request session directly with trackedImages
|
||||
if (navigator.xr) {
|
||||
const session = await navigator.xr.requestSession('immersive-ar', {
|
||||
requiredFeatures: ['local-floor'],
|
||||
optionalFeatures: [
|
||||
'hand-tracking',
|
||||
'image-tracking',
|
||||
'anchors',
|
||||
'plane-detection',
|
||||
],
|
||||
// @ts-ignore — trackedImages is a WebXR extension
|
||||
trackedImages: [
|
||||
{
|
||||
image: bitmap,
|
||||
widthInMeters: 0.15, // 15cm QR code
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Let @react-three/xr know about this session
|
||||
// by entering through the store after manual session creation
|
||||
session.addEventListener('end', () => {
|
||||
setInXR(false);
|
||||
setAnchorMode('manual');
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fallback: enter AR without image tracking
|
||||
console.warn('Image tracking not available, using manual placement');
|
||||
}
|
||||
|
||||
await xrStore.enterAR();
|
||||
setInXR(true);
|
||||
toast.success('Sessão XR iniciada — Passthrough ativo');
|
||||
setAnchorMode('manual');
|
||||
toast.success('Sessão XR iniciada — Buscando QR Code…');
|
||||
} catch (err: any) {
|
||||
console.error('XR error:', err);
|
||||
toast.error(`Falha ao iniciar XR: ${err.message}`);
|
||||
}
|
||||
}, [setAnchorMode]);
|
||||
|
||||
const handleDownloadMarker = useCallback(async () => {
|
||||
const url = await generateMarkerDownloadURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'TrackkSteelXR_Marker.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||
}, []);
|
||||
|
||||
if (!model) return null;
|
||||
@@ -194,23 +303,42 @@ const XRSession = () => {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{!inXR && (
|
||||
<Button className="gap-2 glow-primary" onClick={handleEnterAR}>
|
||||
Iniciar Sessão AR
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!inXR && (
|
||||
<>
|
||||
<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>
|
||||
</Button>
|
||||
<Button className="gap-2 glow-primary" onClick={handleEnterAR}>
|
||||
Iniciar Sessão AR
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inXR && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 3D Canvas — fills remaining space */}
|
||||
{/* 3D Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<Canvas
|
||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||
@@ -218,7 +346,6 @@ const XRSession = () => {
|
||||
className="!bg-transparent"
|
||||
onCreated={({ gl }) => {
|
||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
// Transparent background for passthrough
|
||||
gl.setClearColor(0x000000, 0);
|
||||
}}
|
||||
>
|
||||
@@ -229,9 +356,9 @@ const XRSession = () => {
|
||||
|
||||
<XROrigin />
|
||||
|
||||
<ManualAnchor>
|
||||
<ImageTrackingAnchor>
|
||||
<XRModel url={model.url} />
|
||||
</ManualAnchor>
|
||||
</ImageTrackingAnchor>
|
||||
|
||||
<ControllerFineTuning />
|
||||
</XR>
|
||||
@@ -239,7 +366,13 @@ const XRSession = () => {
|
||||
|
||||
{/* Floating HUD overlay */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 pointer-events-none">
|
||||
<div className="pointer-events-auto inline-flex gap-3 rounded-lg border bg-card/90 backdrop-blur-sm p-3 shadow-lg">
|
||||
<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">
|
||||
<QrCode className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{anchorMode === 'tracking' ? '● QR Detectado' : '○ Buscando QR…'}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
@@ -252,11 +385,6 @@ const XRSession = () => {
|
||||
Grip + Joy Dir: Z/Rot
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
Modo: {anchorMode === 'tracking' ? 'Image Tracking' : 'Manual'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user