From 22ff2a752728b1370b8b5bb67e22ea49dcf2ef74 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:43:33 -0400 Subject: [PATCH 1/3] refactor: share scene load coordination Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/blocks/gltfInputBlock.ts | 45 +++++++++++-------------- src/helpers/loadSceneWithPlugin.ts | 54 ++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 37 deletions(-) diff --git a/src/blocks/gltfInputBlock.ts b/src/blocks/gltfInputBlock.ts index 57556f7..41b5d9b 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 { fetchAsDataUriAsync, loadSingleFileSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.gltf", @@ -11,32 +11,25 @@ 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")); - } - - 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), + runAsync: (url, _config, { engine }) => + loadSingleFileSceneWithPluginAsync( + url, + engine, + undefined, + () => import("@babylonjs/loaders/glTF/index.js"), + async (response, resolvedUrl, signal) => { + const format = await readGltfResponseAsync(response, resolvedUrl); + return { + source: format.source, + pluginExtension: format.extension, + pluginOptions: { + gltf: { + preprocessUrlAsync: (dependencyUrl) => fetchAsDataUriAsync(dependencyUrl, signal), + }, }, - }, - }); - } finally { - abortController.abort(); - } - }, + }; + } + ), }); /** Loads a glTF or GLB URL into a Babylon.js scene. */ diff --git a/src/helpers/loadSceneWithPlugin.ts b/src/helpers/loadSceneWithPlugin.ts index 5de681d..762c760 100644 --- a/src/helpers/loadSceneWithPlugin.ts +++ b/src/helpers/loadSceneWithPlugin.ts @@ -4,24 +4,48 @@ 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; + 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, + pluginExtension: string | undefined, + loadPluginAsync: () => Promise, + prepareSceneLoadAsync?: PrepareSceneLoadAsync +): Promise { if (!isHttpUrl(url)) { - return loadSceneWithPluginAsync(url, engine, loadPluginAsync, { pluginExtension }); + return loadSceneWithPluginAsync(url, engine, loadPluginAsync, pluginExtension === undefined ? undefined : { pluginExtension }); } 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 = + prepareSceneLoadAsync === undefined ? { source: await responseToDataUriAsync(response) } : await prepareSceneLoadAsync(response, resolvedUrl, abortController.signal); + const options: LoadOptions = { + rootUrl: new URL(".", resolvedUrl).href, + name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", + }; + const resolvedPluginExtension = preparation.pluginExtension ?? pluginExtension; + if (resolvedPluginExtension !== undefined) { + options.pluginExtension = resolvedPluginExtension; + } + if (preparation.pluginOptions !== undefined) { + options.pluginOptions = preparation.pluginOptions; + } + return await loadSceneWithPluginAsync(preparation.source, engine, loadPluginAsync, options); } finally { abortController.abort(); } @@ -41,14 +65,16 @@ export async function fetchOrThrowAsync(url: string, signal: AbortSignal): Promi } export async function fetchAsDataUriAsync(url: string, signal: AbortSignal): Promise { + if (!isHttpUrl(url)) { + return url; + } + 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 }; + return { dataUri: await responseToDataUriAsync(response), url: response.url || url }; } export function createDataUri(data: Uint8Array, contentType: string): string { @@ -67,3 +93,9 @@ export function toBase64(data: Uint8Array): string { } return btoa(binary); } + +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); +} From 73fbda74c653a877ff9b55be12dd2501860e61e3 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:41:33 -0400 Subject: [PATCH 2/3] refactor: inline single-use scene helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/blocks/gltfInputBlock.ts | 10 ++++++++-- src/helpers/loadSceneWithPlugin.ts | 15 +-------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/blocks/gltfInputBlock.ts b/src/blocks/gltfInputBlock.ts index 41b5d9b..ae0bdd3 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, loadSingleFileSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; +import { fetchOrThrowAsync, isHttpUrl, loadSingleFileSceneWithPluginAsync, responseToDataUriAsync } from "../helpers/loadSceneWithPlugin"; const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.gltf", @@ -24,7 +24,13 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ pluginExtension: format.extension, pluginOptions: { gltf: { - preprocessUrlAsync: (dependencyUrl) => fetchAsDataUriAsync(dependencyUrl, signal), + preprocessUrlAsync: async (dependencyUrl) => { + if (!isHttpUrl(dependencyUrl)) { + return dependencyUrl; + } + + return responseToDataUriAsync(await fetchOrThrowAsync(dependencyUrl, signal)); + }, }, }, }; diff --git a/src/helpers/loadSceneWithPlugin.ts b/src/helpers/loadSceneWithPlugin.ts index 762c760..9a2c939 100644 --- a/src/helpers/loadSceneWithPlugin.ts +++ b/src/helpers/loadSceneWithPlugin.ts @@ -64,19 +64,6 @@ export async function fetchOrThrowAsync(url: string, signal: AbortSignal): Promi return response; } -export async function fetchAsDataUriAsync(url: string, signal: AbortSignal): Promise { - if (!isHttpUrl(url)) { - return url; - } - - 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); - return { dataUri: await responseToDataUriAsync(response), url: response.url || url }; -} - export function createDataUri(data: Uint8Array, contentType: string): string { return `data:${contentType};base64,${toBase64(data)}`; } @@ -94,7 +81,7 @@ export function toBase64(data: Uint8Array): string { return btoa(binary); } -async function responseToDataUriAsync(response: Response): Promise { +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); From f3e2dd87be40e46210b879e4f310d3e0b295fb54 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:05:15 -0400 Subject: [PATCH 3/3] refactor: share input load coordination Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/blocks/fbxInputBlock.ts | 2 +- src/blocks/gltfInputBlock.ts | 12 ++--- src/blocks/objInputBlock.ts | 84 +++++++++++++----------------- src/blocks/stlInputBlock.ts | 2 +- src/helpers/loadSceneWithPlugin.ts | 40 ++++++++++---- 5 files changed, 70 insertions(+), 70 deletions(-) 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 ae0bdd3..a7f3a56 100644 --- a/src/blocks/gltfInputBlock.ts +++ b/src/blocks/gltfInputBlock.ts @@ -12,12 +12,8 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ engine: NullEngineResource, }, runAsync: (url, _config, { engine }) => - loadSingleFileSceneWithPluginAsync( - url, - engine, - undefined, - () => import("@babylonjs/loaders/glTF/index.js"), - async (response, resolvedUrl, signal) => { + loadSingleFileSceneWithPluginAsync(url, engine, () => import("@babylonjs/loaders/glTF/index.js"), { + prepareSceneLoadAsync: async (response, resolvedUrl, signal) => { const format = await readGltfResponseAsync(response, resolvedUrl); return { source: format.source, @@ -34,8 +30,8 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ }, }, }; - } - ), + }, + }), }); /** 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 9a2c939..0bd5363 100644 --- a/src/helpers/loadSceneWithPlugin.ts +++ b/src/helpers/loadSceneWithPlugin.ts @@ -12,6 +12,13 @@ interface SceneLoadPreparation { 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); @@ -20,12 +27,18 @@ export async function loadSceneWithPluginAsync(source: SceneSource, engine: Abst export async function loadSingleFileSceneWithPluginAsync( url: string, engine: AbstractEngine, - pluginExtension: string | undefined, loadPluginAsync: () => Promise, - prepareSceneLoadAsync?: PrepareSceneLoadAsync + options: SingleFileSceneLoadOptions = {} ): Promise { if (!isHttpUrl(url)) { - return loadSceneWithPluginAsync(url, engine, loadPluginAsync, pluginExtension === undefined ? undefined : { 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(); @@ -33,19 +46,24 @@ export async function loadSingleFileSceneWithPluginAsync( const response = await fetchOrThrowAsync(url, abortController.signal); const resolvedUrl = response.url || url; const preparation = - prepareSceneLoadAsync === undefined ? { source: await responseToDataUriAsync(response) } : await prepareSceneLoadAsync(response, resolvedUrl, abortController.signal); - const options: LoadOptions = { - rootUrl: new URL(".", resolvedUrl).href, + options.prepareSceneLoadAsync === undefined + ? { source: await responseToDataUriAsync(response) } + : await options.prepareSceneLoadAsync(response, resolvedUrl, abortController.signal); + const loadOptions: LoadOptions = { name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", }; - const resolvedPluginExtension = preparation.pluginExtension ?? pluginExtension; + if (options.includeRootUrl !== false) { + loadOptions.rootUrl = new URL(".", resolvedUrl).href; + } + const resolvedPluginExtension = preparation.pluginExtension ?? options.pluginExtension; if (resolvedPluginExtension !== undefined) { - options.pluginExtension = resolvedPluginExtension; + loadOptions.pluginExtension = resolvedPluginExtension; } - if (preparation.pluginOptions !== undefined) { - options.pluginOptions = preparation.pluginOptions; + const resolvedPluginOptions = preparation.pluginOptions ?? options.pluginOptions; + if (resolvedPluginOptions !== undefined) { + loadOptions.pluginOptions = resolvedPluginOptions; } - return await loadSceneWithPluginAsync(preparation.source, engine, loadPluginAsync, options); + return await loadSceneWithPluginAsync(preparation.source, engine, loadPluginAsync, loadOptions); } finally { abortController.abort(); }