import { isDevEnvironment } from "../../engine/debug/index.js"; import { getParam } from "../../engine/engine_utils.js"; import type { WebXRImageTrackingModel } from "./WebXRImageTracking.js"; // Kept in a dedicated module (with only lightweight imports) so the marker-loading logic can be unit // tested without pulling in the heavy WebXRImageTracking / USDZ exporter import graph. const debug = getParam("debugimagetracking"); /** * Cache of decoded marker bitmaps, keyed by image url. * Also read by the USDZ exporter (see {@link WebXRImageTracking}) to embed the marker into the .usdz. */ export const _imageElements: Map = new Map(); const _imageLoadingPromises: Map> = new Map(); /** * Resolve marker bitmaps for the given tracked-image models and append them to `args.trackedImages` * (the list handed to `navigator.xr.requestSession`). * * Every valid marker is awaited so that images which are still decoding when the XR session is * requested get included instead of being silently dropped (which previously caused markers to only * work after re-entering AR). Loading happens in parallel and never rejects: a marker that can not be * loaded is skipped individually so a single unavailable image can not fail the whole session request. * * @param models The configured tracked-image models * @param args The session init object; `trackedImages` is created if missing and appended to * @param load Injectable loader (defaults to {@link loadImage}); resolves the decoded bitmap or null * @returns Map of `trackedImages` index -> model for the appended images (used to resolve results) */ export async function collectTrackedImages( models: readonly WebXRImageTrackingModel[] | undefined | null, args: { trackedImages?: Array<{ image: ImageBitmap, widthInMeters: number }> }, load: (url: string) => Promise = loadImage, ): Promise> { const indexMap = new Map(); if (!args.trackedImages) args.trackedImages = []; if (!models) return indexMap; const valid = models.filter(m => !!m.image?.length && m.widthInMeters > 0); // Load in parallel and wait for all of them. Guard every load with a catch so that a rejecting // loader is treated as "unavailable" (null) rather than aborting the whole session request. const bitmaps = await Promise.all(valid.map(m => load(m.image!).catch(() => null))); for (let i = 0; i < valid.length; i++) { const model = valid[i]; const bitmap = bitmaps[i]; if (bitmap) { indexMap.set(args.trackedImages.length, model); args.trackedImages.push({ image: bitmap, widthInMeters: model.widthInMeters }); } else if (isDevEnvironment() || debug) { console.warn(`[WebXRImageTracking] Marker image could not be loaded, skipping: ${model.image}`); } } return indexMap; } /** * Load and decode a marker image into an {@link ImageBitmap}, caching it in {@link _imageElements}. * Idempotent: concurrent or repeated calls for the same URL share a single in-flight load. * * Resolves to `null` and never rejects when the image can not be fetched or decoded, so callers can * skip an unavailable marker without failing the surrounding XR session request. A failed load clears * its cache entry so a later attempt (e.g. re-entering AR) can retry. */ export async function loadImage(url: string): Promise { const cached = _imageElements.get(url); if (cached) return cached; const pending = _imageLoadingPromises.get(url); if (pending) return pending; const promise = new Promise(res => { _imageElements.set(url, null); // placeholder to dedupe concurrent loads if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Start loading image for tracking: ${url}`); const imageElement = document.createElement("img") as HTMLImageElement; imageElement.addEventListener("load", async () => { try { const img = await createImageBitmap(imageElement); _imageElements.set(url, img); if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Loaded image for tracking: ${url}`); res(img); } catch (err) { console.error(`[WebXRImageTracking] Failed to decode marker image for tracking: ${url}`, err); _imageElements.delete(url); // allow a retry later (e.g. re-entering AR) res(null); } }); imageElement.addEventListener("error", (evt) => { console.error(`[WebXRImageTracking] Failed to load marker image for tracking: ${url}`, evt); _imageElements.delete(url); // allow a retry later (e.g. re-entering AR) res(null); }); imageElement.src = url; }); _imageLoadingPromises.set(url, promise); promise.finally(() => { _imageLoadingPromises.delete(url); }); return promise; }