/** * Poly Haven gltf dependency resolution. * * Poly Haven's glTF files reference their textures RELATIVELY ("textures/x.jpg") * but the files are NOT served at those relative locations — the real urls live in * the asset's `include` map on their files API (e.g. the 1k gltf's textures live * under ".../Textures/jpg/1k/…" and the shared .bin under the 8k folder). This is * their published standard: consumers are expected to resolve dependencies through * the API's include map, not by relative resolution. * * The loader chain consults this module whenever a glTF is loaded FROM * dl.polyhaven.org, so Poly Haven content works wherever the engine runs (a * deployed app loading a project document that references a Poly Haven material — * no host/editor involvement, same behavior as the Needle editor). */ const POLYHAVEN_FILE_HOST = "dl.polyhaven.org"; const POLYHAVEN_FILES_API = "https://api.polyhaven.com/files/"; /** include maps per SOURCE gltf url — one API call per asset+file, shared across loads */ const resolverCache = new Map | null>>(); /** true for gltf/glb urls served from Poly Haven's file CDN */ export function isPolyhavenGltfUrl(url: string): boolean { try { const parsed = new URL(url, typeof globalThis.location !== "undefined" ? globalThis.location.href : undefined); if (parsed.hostname !== POLYHAVEN_FILE_HOST) return false; const path = parsed.pathname.toLowerCase(); return path.endsWith(".gltf") || path.endsWith(".glb"); } catch { return false; } } /** * Resolver for a Poly Haven gltf's dependency urls (textures, .bin), from the * asset's `include` map on the files API. Null when the asset can't be found or * the API is unreachable — the caller falls back to plain relative resolution * (which will 404, but that is Poly Haven's failure surface, not ours). */ export function getPolyhavenResolveUrl(sourceUrl: string): Promise<((url: string) => string) | null> { const key = sourceUrl.split("?")[0]; let pending = resolverCache.get(key); if (!pending) { pending = fetchIncludeMap(key).catch(err => { console.warn("[polyhaven] could not resolve dependency urls via the files API", sourceUrl, err); return null; }); resolverCache.set(key, pending); } return pending.then(include => { if (!include) return null; return (url: string) => { // GLTFLoader hands us ABSOLUTE urls (base + relative uri) — the include // map is keyed by the gltf's RELATIVE uris, so match by suffix for (const relative of Object.keys(include)) { if (url.endsWith(relative)) return include[relative]; } return url; }; }); } /** find the `include` map of the API entry whose gltf url IS the source url */ async function fetchIncludeMap(sourceUrl: string): Promise | null> { // asset id = the containing folder name: ...////.gltf const segments = new URL(sourceUrl).pathname.split("/").filter(Boolean); const assetId = segments[segments.length - 2]; if (!assetId) return null; const response = await fetch(POLYHAVEN_FILES_API + assetId, { headers: { "Accept": "application/json" } }); if (!response.ok) return null; const data = await response.json() as Record; // the response nests entries per format/resolution ({ gltf: { "1k": { gltf: {url, include} } } }); // walk it generically and match OUR url — resilient to asset-type layout differences const found = findEntryByUrl(data, sourceUrl, 0); if (!found?.include) return null; const include: Record = {}; for (const [relative, item] of Object.entries(found.include)) { const url = (item as { url?: unknown })?.url; if (typeof url === "string") include[relative] = url; } return include; } type IncludeEntry = { url?: string; include?: Record }; function findEntryByUrl(node: unknown, sourceUrl: string, depth: number): IncludeEntry | null { if (!node || typeof node !== "object" || depth > 5) return null; const entry = node as IncludeEntry; if (typeof entry.url === "string" && entry.url.split("?")[0] === sourceUrl && entry.include) return entry; for (const value of Object.values(node)) { const found = findEntryByUrl(value, sourceUrl, depth + 1); if (found) return found; } return null; }