diff --git a/src/blocks/fbxInputBlock.ts b/src/blocks/fbxInputBlock.ts index d317634..910f961 100644 --- a/src/blocks/fbxInputBlock.ts +++ b/src/blocks/fbxInputBlock.ts @@ -11,7 +11,7 @@ const FbxInputBlockDefinition = /* @__PURE__ */ defineBlock({ resources: { engine: NullEngineResource, }, - runAsync: (url, _config, { engine }) => loadSingleFileSceneWithPluginAsync(url, engine, ".fbx", () => import("@babylonjs/loaders/FBX/index.js")), + runAsync: (url, _config, { engine }) => loadSingleFileSceneWithPluginAsync(url, engine, () => import("@babylonjs/loaders/FBX/index.js"), { pluginExtension: ".fbx" }), }); /** Loads an FBX URL into a Babylon.js scene. */ diff --git a/src/blocks/gltfInputBlock.ts b/src/blocks/gltfInputBlock.ts index 57556f7..a7f3a56 100644 --- a/src/blocks/gltfInputBlock.ts +++ b/src/blocks/gltfInputBlock.ts @@ -2,7 +2,7 @@ import { Block, type BlockOptions } from "../block/block"; import { defineBlock } from "../block/blockDefinition"; import { BabylonSceneType, UrlType } from "../block/connectionPointType"; import { NullEngineResource } from "../resources/nullEngineResource"; -import { fetchAsDataUriAsync, fetchOrThrowAsync, isHttpUrl, loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; +import { fetchOrThrowAsync, isHttpUrl, loadSingleFileSceneWithPluginAsync, responseToDataUriAsync } from "../helpers/loadSceneWithPlugin"; const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.gltf", @@ -11,32 +11,27 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ resources: { engine: NullEngineResource, }, - runAsync: async (url, _config, { engine }) => { - if (!isHttpUrl(url)) { - return loadSceneWithPluginAsync(url, engine, () => import("@babylonjs/loaders/glTF/index.js")); - } + runAsync: (url, _config, { engine }) => + loadSingleFileSceneWithPluginAsync(url, engine, () => import("@babylonjs/loaders/glTF/index.js"), { + prepareSceneLoadAsync: async (response, resolvedUrl, signal) => { + const format = await readGltfResponseAsync(response, resolvedUrl); + return { + source: format.source, + pluginExtension: format.extension, + pluginOptions: { + gltf: { + preprocessUrlAsync: async (dependencyUrl) => { + if (!isHttpUrl(dependencyUrl)) { + return dependencyUrl; + } - const abortController = new AbortController(); - try { - const response = await fetchOrThrowAsync(url, abortController.signal); - const resolvedUrl = response.url || url; - const format = await readGltfResponseAsync(response, resolvedUrl); - - return await loadSceneWithPluginAsync(format.source, engine, () => import("@babylonjs/loaders/glTF/index.js"), { - rootUrl: new URL(".", resolvedUrl).href, - pluginExtension: format.extension, - name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", - pluginOptions: { - gltf: { - preprocessUrlAsync: (dependencyUrl) => - isHttpUrl(dependencyUrl) ? fetchAsDataUriAsync(dependencyUrl, abortController.signal) : Promise.resolve(dependencyUrl), + return responseToDataUriAsync(await fetchOrThrowAsync(dependencyUrl, signal)); + }, + }, }, - }, - }); - } finally { - abortController.abort(); - } - }, + }; + }, + }), }); /** Loads a glTF or GLB URL into a Babylon.js scene. */ diff --git a/src/blocks/objInputBlock.ts b/src/blocks/objInputBlock.ts index c46ac9e..359b7a8 100644 --- a/src/blocks/objInputBlock.ts +++ b/src/blocks/objInputBlock.ts @@ -4,7 +4,7 @@ import type { Scene as BabylonScene } from "@babylonjs/core/scene.js"; import { Block, type BlockOptions } from "../block/block"; import { defineBlock } from "../block/blockDefinition"; import { BabylonSceneType, UrlType } from "../block/connectionPointType"; -import { createDataUri, fetchOrThrowAsync, isHttpUrl, loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; +import { createDataUri, fetchOrThrowAsync, loadSingleFileSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; import { NullEngineResource } from "../resources/nullEngineResource"; const MaximumConcurrentTextureFetches = 8; @@ -18,43 +18,40 @@ const ObjInputBlockDefinition = /* @__PURE__ */ defineBlock({ }, runAsync: async (url, _config, { engine }) => { const pluginPromise = import("@babylonjs/loaders/OBJ/index.js"); - if (!isHttpUrl(url)) { - return loadSceneWithPluginAsync(url, engine, () => pluginPromise, createObjLoadOptions()); - } - - const abortController = new AbortController(); - try { - const objResponse = await fetchOrThrowAsync(url, abortController.signal); - const resolvedObjUrl = objResponse.url || url; - const obj = await objResponse.text(); - const references = analyzeObjReferences(obj); - - let source = obj; - let textureAssets = new Map(); - let materialTokens = new Map(); - if (references.mtl?.value) { - const mtlUrl = resolveDependencyUrl(references.mtl.value, resolvedObjUrl, "MTL"); - const mtlResponse = await fetchOrThrowAsync(mtlUrl, abortController.signal); - const resolvedMtlUrl = mtlResponse.url || mtlUrl; - const mtl = await mtlResponse.text(); - const rewritten = await rewriteMtlAsync(mtl, resolvedMtlUrl, abortController.signal); - textureAssets = rewritten.textureAssets; - materialTokens = rewritten.materialTokens; - source = rewriteObjReferences(obj, references, createTextDataUri(rewritten.source), materialTokens, resolvedObjUrl); - } else if (references.mtl) { - throw new Error(`Invalid OBJ file "${resolvedObjUrl}": mtllib has no material library path.`); - } - - const scene = await loadSceneWithPluginAsync(createDirectTextSource(source), engine, () => pluginPromise, { - ...createObjLoadOptions(), - name: new URL(resolvedObjUrl).pathname.split("/").pop() ?? "", - }); - await attachTextureDataAsync(scene, textureAssets); - restoreMaterialNames(scene, materialTokens); - return scene; - } finally { - abortController.abort(); - } + let textureAssets = new Map(); + let materialTokens = new Map(); + const scene = await loadSingleFileSceneWithPluginAsync(url, engine, () => pluginPromise, { + includeRootUrl: false, + pluginExtension: ".obj", + pluginOptions: { + obj: { + materialLoadingFailsSilently: false, + }, + }, + prepareSceneLoadAsync: async (objResponse, resolvedObjUrl, signal) => { + const obj = await objResponse.text(); + const references = analyzeObjReferences(obj); + + let source = obj; + if (references.mtl?.value) { + const mtlUrl = resolveDependencyUrl(references.mtl.value, resolvedObjUrl, "MTL"); + const mtlResponse = await fetchOrThrowAsync(mtlUrl, signal); + const resolvedMtlUrl = mtlResponse.url || mtlUrl; + const mtl = await mtlResponse.text(); + const rewritten = await rewriteMtlAsync(mtl, resolvedMtlUrl, signal); + textureAssets = rewritten.textureAssets; + materialTokens = rewritten.materialTokens; + source = rewriteObjReferences(obj, references, createTextDataUri(rewritten.source), materialTokens, resolvedObjUrl); + } else if (references.mtl) { + throw new Error(`Invalid OBJ file "${resolvedObjUrl}": mtllib has no material library path.`); + } + + return { source: createDirectTextSource(source) }; + }, + }); + await attachTextureDataAsync(scene, textureAssets); + restoreMaterialNames(scene, materialTokens); + return scene; }, }); @@ -65,17 +62,6 @@ export class ObjInputBlock extends Block { } } -function createObjLoadOptions() { - return { - pluginExtension: ".obj", - pluginOptions: { - obj: { - materialLoadingFailsSilently: false, - }, - }, - } as const; -} - interface ObjLineReference { readonly end: number; readonly indentation: string; diff --git a/src/blocks/stlInputBlock.ts b/src/blocks/stlInputBlock.ts index 845810b..c69389c 100644 --- a/src/blocks/stlInputBlock.ts +++ b/src/blocks/stlInputBlock.ts @@ -11,7 +11,7 @@ const StlInputBlockDefinition = /* @__PURE__ */ defineBlock({ resources: { engine: NullEngineResource, }, - runAsync: (url, _config, { engine }) => loadSingleFileSceneWithPluginAsync(url, engine, ".stl", () => import("@babylonjs/loaders/STL/index.js")), + runAsync: (url, _config, { engine }) => loadSingleFileSceneWithPluginAsync(url, engine, () => import("@babylonjs/loaders/STL/index.js"), { pluginExtension: ".stl" }), }); /** Loads an STL URL into a Babylon.js scene. */ diff --git a/src/helpers/loadSceneWithPlugin.ts b/src/helpers/loadSceneWithPlugin.ts index 5de681d..0bd5363 100644 --- a/src/helpers/loadSceneWithPlugin.ts +++ b/src/helpers/loadSceneWithPlugin.ts @@ -4,24 +4,66 @@ import type { Scene } from "@babylonjs/core/scene.js"; type SceneSource = string | ArrayBufferView; +interface SceneLoadPreparation { + readonly source: SceneSource; + readonly pluginExtension?: string; + readonly pluginOptions?: LoadOptions["pluginOptions"]; +} + +type PrepareSceneLoadAsync = (response: Response, resolvedUrl: string, signal: AbortSignal) => Promise; + +interface SingleFileSceneLoadOptions { + readonly includeRootUrl?: boolean; + readonly pluginExtension?: string; + readonly pluginOptions?: LoadOptions["pluginOptions"]; + readonly prepareSceneLoadAsync?: PrepareSceneLoadAsync; +} + export async function loadSceneWithPluginAsync(source: SceneSource, engine: AbstractEngine, loadPluginAsync: () => Promise, options?: LoadOptions): Promise { const [{ LoadSceneAsync }] = await Promise.all([import("@babylonjs/core/Loading/sceneLoader.js"), loadPluginAsync()]); return LoadSceneAsync(source, engine, options); } -export async function loadSingleFileSceneWithPluginAsync(url: string, engine: AbstractEngine, pluginExtension: string, loadPluginAsync: () => Promise): Promise { +export async function loadSingleFileSceneWithPluginAsync( + url: string, + engine: AbstractEngine, + loadPluginAsync: () => Promise, + options: SingleFileSceneLoadOptions = {} +): Promise { if (!isHttpUrl(url)) { - return loadSceneWithPluginAsync(url, engine, loadPluginAsync, { pluginExtension }); + if (options.pluginExtension === undefined && options.pluginOptions === undefined) { + return loadSceneWithPluginAsync(url, engine, loadPluginAsync); + } + + return loadSceneWithPluginAsync(url, engine, loadPluginAsync, { + ...(options.pluginExtension === undefined ? {} : { pluginExtension: options.pluginExtension }), + ...(options.pluginOptions === undefined ? {} : { pluginOptions: options.pluginOptions }), + }); } const abortController = new AbortController(); try { - const fetched = await fetchAsDataUriWithUrlAsync(url, abortController.signal); - return await loadSceneWithPluginAsync(fetched.dataUri, engine, loadPluginAsync, { - rootUrl: new URL(".", fetched.url).href, - pluginExtension, - name: new URL(fetched.url).pathname.split("/").pop() ?? "", - }); + const response = await fetchOrThrowAsync(url, abortController.signal); + const resolvedUrl = response.url || url; + const preparation = + options.prepareSceneLoadAsync === undefined + ? { source: await responseToDataUriAsync(response) } + : await options.prepareSceneLoadAsync(response, resolvedUrl, abortController.signal); + const loadOptions: LoadOptions = { + name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", + }; + if (options.includeRootUrl !== false) { + loadOptions.rootUrl = new URL(".", resolvedUrl).href; + } + const resolvedPluginExtension = preparation.pluginExtension ?? options.pluginExtension; + if (resolvedPluginExtension !== undefined) { + loadOptions.pluginExtension = resolvedPluginExtension; + } + const resolvedPluginOptions = preparation.pluginOptions ?? options.pluginOptions; + if (resolvedPluginOptions !== undefined) { + loadOptions.pluginOptions = resolvedPluginOptions; + } + return await loadSceneWithPluginAsync(preparation.source, engine, loadPluginAsync, loadOptions); } finally { abortController.abort(); } @@ -40,17 +82,6 @@ export async function fetchOrThrowAsync(url: string, signal: AbortSignal): Promi return response; } -export async function fetchAsDataUriAsync(url: string, signal: AbortSignal): Promise { - return (await fetchAsDataUriWithUrlAsync(url, signal)).dataUri; -} - -export async function fetchAsDataUriWithUrlAsync(url: string, signal: AbortSignal): Promise<{ readonly dataUri: string; readonly url: string }> { - const response = await fetchOrThrowAsync(url, signal); - const contentType = response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream"; - const dataUri = createDataUri(new Uint8Array(await response.arrayBuffer()), contentType); - return { dataUri, url: response.url || url }; -} - export function createDataUri(data: Uint8Array, contentType: string): string { return `data:${contentType};base64,${toBase64(data)}`; } @@ -67,3 +98,9 @@ export function toBase64(data: Uint8Array): string { } return btoa(binary); } + +export async function responseToDataUriAsync(response: Response): Promise { + const contentType = response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream"; + const data = new Uint8Array(await response.arrayBuffer()); + return createDataUri(data, contentType); +}