From eb44d8352dceb07d5e914959058cab44a4bdbfbc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:10:09 +0000 Subject: [PATCH] Changes --- src/lib/trackingMarker.ts | 88 +++++++++++++++ src/pages/XRSession.tsx | 220 ++++++++++++++++++++++++++++++-------- 2 files changed, 262 insertions(+), 46 deletions(-) create mode 100644 src/lib/trackingMarker.ts diff --git a/src/lib/trackingMarker.ts b/src/lib/trackingMarker.ts new file mode 100644 index 0000000..fc1328a --- /dev/null +++ b/src/lib/trackingMarker.ts @@ -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 { + 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 { + 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); +} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index b6547ef..08c143b 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -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 | null = null; +function getMarkerBitmap(): Promise { + 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 ( - - {children} - - ); +function ImageTrackingAnchor({ children }: { children: React.ReactNode }) { + const groupRef = useRef(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 {children}; } 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 = () => { - {!inXR && ( - - )} +
+ {!inXR && ( + <> + + + + )} - {inXR && ( -
- - - XR Ativo - -
- )} + {inXR && ( +
+ + + XR Ativo + + {anchorMode === 'tracking' ? ( + + + Tracking + + ) : ( + + + Manual + + )} +
+ )} +
- {/* 3D Canvas — fills remaining space */} + {/* 3D Canvas */}
{ 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 = () => { - + - + @@ -239,7 +366,13 @@ const XRSession = () => { {/* Floating HUD overlay */}
-
+
+
+ + + {anchorMode === 'tracking' ? '● QR Detectado' : '○ Buscando QR…'} + +
@@ -252,11 +385,6 @@ const XRSession = () => { Grip + Joy Dir: Z/Rot
-
- - Modo: {anchorMode === 'tracking' ? 'Image Tracking' : 'Manual'} - -