Add QR image tracking

Implement image tracking via WebXR to auto-anchor model to printed QR marker, including tracking marker generation, session handling, and UI adjustments.

X-Lovable-Edit-ID: edt-1bf2a256-cb06-4527-96ef-25bf676c3707
This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 01:10:09 +00:00
2 changed files with 262 additions and 46 deletions
+88
View File
@@ -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
View File
@@ -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>