From 9185c44ec87afd8ec203d5183df4abeaec4a8fbc Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:10:50 -0400 Subject: [PATCH 1/7] refactor: standardize asset input loading Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/babylon-loader-gaps.md | 65 +++ packages/core/docs/blocks.md | 11 +- packages/core/docs/usage.md | 25 + packages/core/package.json | 3 +- packages/core/src/blocks/fbxInputBlock.ts | 9 +- packages/core/src/blocks/gltfInputBlock.ts | 9 +- packages/core/src/blocks/objInputBlock.ts | 453 +----------------- packages/core/src/blocks/stlInputBlock.ts | 9 +- .../helpers/convertBabylonSceneToDocument.ts | 232 +-------- packages/core/src/helpers/inputLocation.ts | 17 + .../core/src/helpers/loadSceneWithPlugin.ts | 156 +----- .../core/src/helpers/nodeXmlHttpRequest.ts | 112 +++++ .../core/src/resources/nullEngineResource.ts | 2 + packages/core/src/types/xhr2.d.ts | 21 + pnpm-lock.yaml | 9 + tests/bundle/browserConsumerBundle.test.ts | 32 +- tests/e2e/cli.test.ts | 5 +- tests/helpers/fbx.ts | 4 - tests/helpers/gltf.ts | 25 +- tests/helpers/input.ts | 41 ++ tests/helpers/obj.ts | 20 - tests/helpers/stl.ts | 4 - tests/integration/encodeKtx2.test.ts | 82 +--- tests/integration/fbxInput.test.ts | 102 ++-- tests/integration/gltfInput.test.ts | 34 +- tests/integration/inputLocations.test.ts | 79 +++ tests/integration/objInput.test.ts | 94 ++-- tests/integration/stlInput.test.ts | 48 +- 28 files changed, 630 insertions(+), 1073 deletions(-) create mode 100644 packages/core/docs/babylon-loader-gaps.md create mode 100644 packages/core/src/helpers/inputLocation.ts create mode 100644 packages/core/src/helpers/nodeXmlHttpRequest.ts create mode 100644 packages/core/src/types/xhr2.d.ts create mode 100644 tests/helpers/input.ts create mode 100644 tests/integration/inputLocations.test.ts diff --git a/packages/core/docs/babylon-loader-gaps.md b/packages/core/docs/babylon-loader-gaps.md new file mode 100644 index 0000000..79c570c --- /dev/null +++ b/packages/core/docs/babylon-loader-gaps.md @@ -0,0 +1,65 @@ +# Babylon loader gaps + +This is the upstream follow-up list for the standard-loader refactor, based on +Babylon 9.21.2. Node-Assets no longer patches these behaviors. The old OBJ/MTL +preparation, texture placeholders, rebinding, and image conversion have been +deleted rather than preserved in a compatibility module. + +## Dependency roots after redirects + +- Upstream target: `packages/dev/core/src/Loading/sceneLoader.ts`, + `appendSceneCoreAsync` / `LoadSceneAsync`. +- Missing behavior: `loadDataAsync` returns `responseURL`, but the append path + discards it. Relative dependencies keep the originally requested directory. + The import-mesh path has a `rewriteRootURL` hook; the append path does not use it. +- Previous local workaround: fetch the main file first and pass a root derived + from the final response URL. +- Completion case: an FBX or OBJ redirected into another directory loads its + relative dependencies from the final location through `LoadSceneAsync`. + +## OBJ material-library and texture locations + +- Upstream targets: `packages/dev/loaders/src/OBJ/objFileLoader.pure.ts`, + `_loadMTL` / `_parseSolidAsync`, and `packages/dev/loaders/src/OBJ/mtlFileLoader.ts`, + `_GetTexture`. +- Missing behavior: the MTL parser receives the OBJ root, not the MTL's own + location. Dependency paths are concatenated with that root rather than + consistently resolved as URIs. +- Previous local workaround: fetch and rewrite the MTL and its texture references. +- Completion case: `scene.obj` references `materials/scene.mtl`, which references + `textures/color.png`; the image resolves under `materials/textures/`, including + after an MTL redirect. Absolute dependency URIs should remain absolute. + +## Encoded-image MIME handling in headless export + +- Upstream targets: `packages/dev/serializers/src/exportImageUtils.ts`, + `GetCachedImageAsync`, and + `packages/dev/serializers/src/glTF/2.0/glTFMaterialExporter.ts`. +- Missing behavior: image retrieval does not preserve the response Content-Type + for the serializer's encoded-image path. An extensionless PNG can fall through + to pixel readback even though usable encoded bytes were downloaded. +- Previous local workaround: detect the MIME type and rebind a typed data URI. +- Completion case: an extensionless PNG served as `image/png` exports from + NullEngine without image decoding or GPU readback. + +## OBJ/MTL material-name parsing + +- Upstream targets: `packages/dev/loaders/src/OBJ/solidParser.ts` and + `packages/dev/loaders/src/OBJ/mtlFileLoader.ts`. +- Follow-up: normalize names consistently between `usemtl` and `newmtl`. + OBJ preprocessing and MTL parsing handle whitespace and comments differently. +- Previous local workaround: replace names with generated tokens and restore them + after loading. +- Completion case: supported material names are matched and preserved without + rewriting the source into synthetic identifiers. + +## Not Babylon workarounds + +`src/helpers/nodeXmlHttpRequest.ts` is permanent Node transport setup: xhr2 handles +HTTP(S), and the adapter adds asynchronous filesystem reads for models and their +sidecars. `src/helpers/inputLocation.ts` normalizes paths and file URLs. Neither +contains format parsing or texture handling. + +TGA/BMP/GIF-to-PNG conversion was also removed. That is an image-conversion +capability requiring a CPU encoding strategy, not something an XHR implementation +can provide. It is separate from preserving already-supported encoded images. diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index 5ba394f..e79b3b6 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -4,22 +4,25 @@ ## Inputs +See [input locations and dependencies](usage.md#input-locations-and-dependencies) +for the shared Node filesystem contract and Babylon dependency limitations. + - `FbxInputBlock` - - Input: `string` which is a URL (HTTPS or data) that points to an FBX file. + - Input: `string` HTTP(S) or data URL, or a Node filesystem path/file URL, pointing to an FBX file. - Output: `Document` - Uses: Babylon FBX loader - Behavior: Uses the Babylon scene loader to load an FBX using NullEngine, exports it as a GLB, then reimports the bytes as a `Document`. - `GltfInputBlock` - - Input: `string` URI accepted by the current `PlatformIO` that points to a glTF or GLB. + - Input: `string` URI accepted by the current `PlatformIO`, or a Node filesystem path/file URL, pointing to a glTF or GLB. - Output: `Document` - Behavior: Reads glTF or GLB into a `Document`, using glTF Transform's default extension handling. - `ObjInputBlock` - - Input: `string` which is a URL (HTTPS or data) that points to an OBJ file. + - Input: `string` HTTP(S) or data URL, or a Node filesystem path/file URL, pointing to an OBJ file. - Output: `Document` - Uses: Babylon OBJ loader - Behavior: Uses the Babylon scene loader to load an OBJ using NullEngine, exports it as a GLB, then reimports the bytes as a `Document`. - `StlInputBlock` - - Input: `string` which is a URL (HTTPS or data) that points to an STL file. + - Input: `string` HTTP(S) or data URL, or a Node filesystem path/file URL, pointing to an STL file. - Output: `Document` - Uses: Babylon STL loader - Behavior: Uses the Babylon scene loader to load an STL using NullEngine, exports it as a GLB, then reimports the bytes as a `Document`. diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index d154f5c..36280a0 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -16,6 +16,31 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` +# Input locations and dependencies + +All four input blocks accept HTTP(S) URLs, local filesystem paths, and file URLs in Node. +Relative filesystem paths are relative to the working directory. The CLI still +accepts only glTF and GLB inputs. + +STL, OBJ, and FBX use Babylon's standard scene loaders and dependency resolution. +In Node, the library lazily installs an XMLHttpRequest implementation with +HTTP(S) and filesystem support, unless the host has already supplied one. A +host-supplied implementation must support the input locations being loaded. +Browser loading uses the browser's XMLHttpRequest; filesystem paths are Node-only. + +OBJ and FBX materials and textures are loaded by Babylon, not rewritten or +prefetched by the input blocks. Babylon's current limitations therefore apply: +OBJ texture paths are relative to the OBJ directory, even when its MTL is in a +subdirectory; redirects do not rebase dependency paths. Headless export requires +encoded images that Babylon's serializer can preserve, such as PNG and JPEG. +There is no automatic TGA/BMP/GIF conversion or extensionless-image MIME repair. +The [Babylon loader gaps](babylon-loader-gaps.md) document lists the upstream +locations for these removed workarounds. + +STL, OBJ, and FBX also accept Babylon-supported data URIs. A data URI has no +filesystem or HTTP base directory for relative dependencies. The glTF block +continues to use PlatformIO and does not accept top-level data URIs in Node. + # Example: CLI run reports ```sh diff --git a/packages/core/package.json b/packages/core/package.json index 8b3edb8..29dea8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -55,6 +55,7 @@ "babylonpress-ktx2-encoder": "0.6.0", "draco3dgltf": "1.5.7", "meshoptimizer": "1.2.0", - "sharp": "0.35.4" + "sharp": "0.35.4", + "xhr2": "0.2.1" } } diff --git a/packages/core/src/blocks/fbxInputBlock.ts b/packages/core/src/blocks/fbxInputBlock.ts index 1625822..4bcb1bb 100644 --- a/packages/core/src/blocks/fbxInputBlock.ts +++ b/packages/core/src/blocks/fbxInputBlock.ts @@ -1,13 +1,14 @@ +import type { ISceneLoaderPluginFactory } from "@babylonjs/core/Loading/sceneLoader.js"; import { FBXFileLoaderMetadata } from "@babylonjs/loaders/FBX/fbxFileLoader.metadata.js"; import { GltfDocumentType } from "../connectionPoints/gltfDocument"; import { UrlType } from "../connectionPoints/url"; import { convertBabylonSceneToDocumentAsync } from "../helpers/convertBabylonSceneToDocument"; +import { loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; import { NullEngineResource } from "../resources/nullEngineResource"; import { PlatformIOResource } from "../resources/platformIOResource"; import { Block, type BlockOptions } from "./block"; import { defineBlock } from "./blockDefinition"; -import { loadSingleFileSceneWithPluginAsync, type SceneLoaderPluginFactory } from "../helpers/loadSceneWithPlugin"; const FbxLoaderFactory = { ...FBXFileLoaderMetadata, @@ -19,7 +20,7 @@ const FbxLoaderFactory = { RegisterStandardMaterial(); return new FBXFileLoader(); }, -} satisfies SceneLoaderPluginFactory; +} satisfies ISceneLoaderPluginFactory; const FbxInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.fbx", @@ -31,14 +32,14 @@ const FbxInputBlockDefinition = /* @__PURE__ */ defineBlock({ }, runAsync: async (url, _config, { engine, io }) => convertBabylonSceneToDocumentAsync( - await loadSingleFileSceneWithPluginAsync(url, engine, FbxLoaderFactory, { + await loadSceneWithPluginAsync(url, engine, FbxLoaderFactory, { pluginExtension: ".fbx", }), io ), }); -/** Loads an FBX URL. */ +/** Loads an FBX URL or Node filesystem path. */ export class FbxInputBlock extends Block { public constructor(options?: BlockOptions) { super(FbxInputBlockDefinition, options); diff --git a/packages/core/src/blocks/gltfInputBlock.ts b/packages/core/src/blocks/gltfInputBlock.ts index 871e3e8..72e25c4 100644 --- a/packages/core/src/blocks/gltfInputBlock.ts +++ b/packages/core/src/blocks/gltfInputBlock.ts @@ -2,6 +2,7 @@ import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { GltfDocumentType } from "../connectionPoints/gltfDocument"; import { UrlType } from "../connectionPoints/url"; +import { resolveInputLocationAsync } from "../helpers/inputLocation"; import { GltfDecoderResource } from "../resources/gltfDecoderResource"; import { PlatformIOResource } from "../resources/platformIOResource"; import { Block, type BlockOptions } from "./block"; @@ -16,14 +17,18 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ io: PlatformIOResource, }, runAsync: async (url, _config, { decoders, io }) => { - const document = await io.registerExtensions(ALL_EXTENSIONS).registerDependencies(decoders).read(url); + const location = await resolveInputLocationAsync(url); + const document = await io + .registerExtensions(ALL_EXTENSIONS) + .registerDependencies(decoders) + .read(location.path ?? location.uri); document.disposeExtension("KHR_draco_mesh_compression"); document.disposeExtension("EXT_meshopt_compression"); return document; }, }); -/** Loads a glTF or GLB URI. */ +/** Loads a glTF or GLB URI or Node filesystem path. */ export class GltfInputBlock extends Block { public constructor(options?: BlockOptions) { super(GltfInputBlockDefinition, options); diff --git a/packages/core/src/blocks/objInputBlock.ts b/packages/core/src/blocks/objInputBlock.ts index 515d70d..59e15a7 100644 --- a/packages/core/src/blocks/objInputBlock.ts +++ b/packages/core/src/blocks/objInputBlock.ts @@ -1,18 +1,22 @@ -import type { BaseTexture } from "@babylonjs/core/Materials/Textures/baseTexture.js"; -import { RegisterSceneLoaderPlugin, type ISceneLoaderPluginFactory, type SceneLoaderPluginOptions } from "@babylonjs/core/Loading/sceneLoader.js"; -import type { Scene as BabylonScene } from "@babylonjs/core/scene.js"; +import type { ISceneLoaderPluginFactory } from "@babylonjs/core/Loading/sceneLoader.js"; import { OBJFileLoaderMetadata } from "@babylonjs/loaders/OBJ/objFileLoader.metadata.js"; import { GltfDocumentType } from "../connectionPoints/gltfDocument"; import { UrlType } from "../connectionPoints/url"; import { convertBabylonSceneToDocumentAsync } from "../helpers/convertBabylonSceneToDocument"; -import { createDataUri, fetchOrThrowAsync, loadSingleFileSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; +import { loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; import { NullEngineResource } from "../resources/nullEngineResource"; import { PlatformIOResource } from "../resources/platformIOResource"; import { Block, type BlockOptions } from "./block"; import { defineBlock } from "./blockDefinition"; -const MaximumConcurrentTextureFetches = 8; +const ObjLoaderFactory = { + ...OBJFileLoaderMetadata, + createPlugin: async (options) => { + const { OBJFileLoader } = await import("@babylonjs/loaders/OBJ/objFileLoader.pure.js"); + return new OBJFileLoader(options.obj); + }, +} satisfies ISceneLoaderPluginFactory; const ObjInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.obj", @@ -22,440 +26,19 @@ const ObjInputBlockDefinition = /* @__PURE__ */ defineBlock({ engine: NullEngineResource, io: PlatformIOResource, }, - runAsync: async (url, _config, { engine, io }) => { - let textureAssets = new Map(); - let materialTokens = new Map(); - const scene = await loadSingleFileSceneWithPluginAsync(url, engine, registerObjLoader, { - 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 convertBabylonSceneToDocumentAsync(scene, io); - }, + runAsync: async (url, _config, { engine, io }) => + convertBabylonSceneToDocumentAsync( + await loadSceneWithPluginAsync(url, engine, ObjLoaderFactory, { + pluginExtension: ".obj", + pluginOptions: { obj: { materialLoadingFailsSilently: false } }, + }), + io + ), }); -/** Loads an OBJ URL and its HTTP(S) MTL and texture dependencies. */ +/** Loads an OBJ URL or Node filesystem path using Babylon's dependency resolution. */ export class ObjInputBlock extends Block { public constructor(options?: BlockOptions) { super(ObjInputBlockDefinition, options); } } - -function registerObjLoader(): void { - RegisterSceneLoaderPlugin({ - ...OBJFileLoaderMetadata, - createPlugin: async (options: SceneLoaderPluginOptions) => { - const { OBJFileLoader } = await import("@babylonjs/loaders/OBJ/objFileLoader.pure.js"); - return new OBJFileLoader(options[OBJFileLoaderMetadata.name]); - }, - } satisfies ISceneLoaderPluginFactory); -} - -interface ObjLineReference { - readonly end: number; - readonly indentation: string; - readonly start: number; - readonly value: string; -} - -interface ObjReferences { - readonly materials: readonly ObjLineReference[]; - readonly mtl?: ObjLineReference; -} - -function analyzeObjReferences(obj: string): ObjReferences { - let mtl: ObjLineReference | undefined; - const materials: ObjLineReference[] = []; - let start = 0; - while (start <= obj.length) { - const newline = obj.indexOf("\n", start); - const lineEnd = newline === -1 ? obj.length : newline; - const end = lineEnd > start && obj[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd; - const line = obj.slice(start, end); - const withoutComment = line.replace(/#.*$/, "").trim(); - if (withoutComment === "mtllib" || withoutComment.startsWith("mtllib ")) { - mtl = { - end, - indentation: line.match(/^\s*/)?.[0] ?? "", - start, - value: withoutComment.slice("mtllib".length).trim(), - }; - } else if (withoutComment === "usemtl" || withoutComment.startsWith("usemtl ")) { - materials.push({ - end, - indentation: line.match(/^\s*/)?.[0] ?? "", - start, - value: withoutComment.slice("usemtl".length).trim(), - }); - } - if (newline === -1) { - break; - } - start = newline + 1; - } - return { materials, ...(mtl === undefined ? {} : { mtl }) }; -} - -function rewriteObjReferences(obj: string, references: ObjReferences, mtlDataUri: string, materialTokens: ReadonlyMap, objUrl: string): string { - const replacements: Array = references.materials.flatMap((reference) => { - if (reference.value.length === 0) { - throw new Error(`Invalid OBJ file "${objUrl}": usemtl has no material name.`); - } - const token = materialTokens.get(reference.value); - if (token === undefined) { - return []; - } - return [{ ...reference, replacement: `${reference.indentation}usemtl ${token}` }]; - }); - if (references.mtl === undefined) { - throw new Error(`Unable to rewrite the OBJ material library reference in "${objUrl}".`); - } - replacements.push({ ...references.mtl, replacement: `${references.mtl.indentation}mtllib ${mtlDataUri}` }); - - const parts: string[] = []; - let cursor = 0; - for (const replacement of replacements.sort((left, right) => left.start - right.start)) { - if (replacement.start < cursor) { - throw new Error(`Unable to rewrite overlapping OBJ references in "${objUrl}".`); - } - parts.push(obj.slice(cursor, replacement.start), replacement.replacement); - cursor = replacement.end; - } - parts.push(obj.slice(cursor)); - return parts.join(""); -} - -interface RewrittenMtl { - readonly materialTokens: Map; - readonly source: string; - readonly textureAssets: Map; -} - -async function rewriteMtlAsync(mtl: string, mtlUrl: string, signal: AbortSignal): Promise { - const lines = mtl.split(/\r?\n/); - const references: MtlTextureReference[] = []; - const materialTokens = new Map(); - let hasMaterial = false; - - for (const [lineIndex, line] of lines.entries()) { - const parsedLine = parseMtlLine(line); - if (parsedLine === undefined) { - continue; - } - - const { indentation, key, originalKey, value } = parsedLine; - if (key === "newmtl") { - if (value.length === 0) { - throw new Error(`Invalid MTL file "${mtlUrl}": material name is empty.`); - } - hasMaterial = true; - let token = materialTokens.get(value); - if (token === undefined) { - token = `node-assets-obj-material-${materialTokens.size}`; - materialTokens.set(value, token); - } - lines[lineIndex] = `${indentation}${originalKey} ${token}`; - continue; - } - if (!hasMaterial || !isTextureDirective(key)) { - continue; - } - - const texture = parseTextureReference(key, value, mtlUrl); - const textureUrl = resolveDependencyUrl(texture.path, mtlUrl, "texture"); - references.push({ format: texture.format, indentation, lineIndex, originalKey, textureUrl }); - } - - if (!hasMaterial) { - throw new Error(`Invalid MTL file "${mtlUrl}".`); - } - - const textureAssets = await fetchTextureAssetsAsync( - references.map(({ textureUrl }) => textureUrl), - signal - ); - for (const reference of references) { - const replacement = reference.textureUrl.startsWith("data:") ? reference.textureUrl : textureAssets.get(reference.textureUrl)?.placeholder; - if (replacement === undefined) { - throw new Error(`Unable to rewrite texture "${reference.textureUrl}".`); - } - lines[reference.lineIndex] = `${reference.indentation}${reference.originalKey} ${reference.format(replacement)}`; - } - return { - materialTokens, - source: lines.join("\n"), - textureAssets: new Map(Array.from(textureAssets.values(), (asset) => [asset.placeholder, asset])), - }; -} - -interface ParsedMtlLine { - readonly indentation: string; - readonly key: string; - readonly originalKey: string; - readonly value: string; -} - -function parseMtlLine(line: string): ParsedMtlLine | undefined { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#")) { - return undefined; - } - - const separatorIndex = trimmed.search(/\s/); - const originalKey = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex); - return { - indentation: line.match(/^\s*/)?.[0] ?? "", - key: originalKey.toLowerCase(), - originalKey, - value: separatorIndex === -1 ? "" : trimmed.slice(separatorIndex).trim(), - }; -} - -function isTextureDirective(key: string | undefined): key is "map_ka" | "map_kd" | "map_ks" | "map_bump" | "map_d" { - return key === "map_ka" || key === "map_kd" || key === "map_ks" || key === "map_bump" || key === "map_d"; -} - -interface TextureReference { - readonly path: string; - readonly format: (dataUri: string) => string; -} - -function parseTextureReference(key: string, value: string, mtlUrl: string): TextureReference { - if (value.length === 0) { - throw new Error(`Invalid MTL file "${mtlUrl}": ${key} has no texture path.`); - } - - if (key !== "map_bump") { - return { path: value, format: (dataUri) => dataUri }; - } - - const tokens = value.split(/\s+/); - const bumpMultiplierIndex = tokens.indexOf("-bm"); - if (bumpMultiplierIndex < 0) { - return { path: value, format: (dataUri) => dataUri }; - } - const bumpMultiplier = tokens[bumpMultiplierIndex + 1]; - if (bumpMultiplier === undefined) { - throw new Error(`Invalid MTL file "${mtlUrl}": map_bump has an incomplete -bm option.`); - } - - const pathTokens = tokens.filter((_, index) => index !== bumpMultiplierIndex && index !== bumpMultiplierIndex + 1); - const path = pathTokens.join(" ").trim(); - if (path.length === 0) { - throw new Error(`Invalid MTL file "${mtlUrl}": map_bump has no texture path.`); - } - - const option = `-bm ${bumpMultiplier}`; - return { - path, - format: (dataUri) => (bumpMultiplierIndex === 0 ? `${option} ${dataUri}` : `${dataUri} ${option}`), - }; -} - -interface MtlTextureReference { - readonly format: (dataUri: string) => string; - readonly indentation: string; - readonly lineIndex: number; - readonly originalKey: string; - readonly textureUrl: string; -} - -interface TextureAsset { - readonly dataUri: string; - readonly extension: string; - readonly name: string; - readonly placeholder: string; -} - -async function fetchTextureAssetsAsync(textureUrls: readonly string[], signal: AbortSignal): Promise> { - const urls = Array.from(new Set(textureUrls.filter((url) => !url.startsWith("data:")))); - const assets = new Map(); - let nextIndex = 0; - - const worker = async () => { - while (nextIndex < urls.length) { - signal.throwIfAborted(); - const index = nextIndex++; - const url = urls[index]; - if (url === undefined) { - return; - } - const response = await fetchOrThrowAsync(url, signal); - const data = new Uint8Array(await response.arrayBuffer()); - const resolvedUrl = response.url || url; - const format = detectTextureFormat(data, resolvedUrl); - assets.set(url, { - dataUri: createDataUri(data, format.contentType), - extension: format.extension, - name: resolvedUrl, - placeholder: `data:${format.contentType},node-assets-obj-texture-${index}`, - }); - } - }; - - await Promise.all(Array.from({ length: Math.min(MaximumConcurrentTextureFetches, urls.length) }, worker)); - return assets; -} - -interface TextureFormat { - readonly contentType: string; - readonly extension: string; -} - -function detectTextureFormat(data: Uint8Array, url: string): TextureFormat { - if ( - data.length >= 20 && - startsWithBytes(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) && - hasBytesAt(data, 12, [0x49, 0x48, 0x44, 0x52]) && - hasBytesAt(data, data.length - 8, [0x49, 0x45, 0x4e, 0x44]) - ) { - return { contentType: "image/png", extension: ".png" }; - } - if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[data.length - 2] === 0xff && data[data.length - 1] === 0xd9) { - return { contentType: "image/jpeg", extension: ".jpg" }; - } - if ( - data.length >= 12 && - startsWithBytes(data, [0x52, 0x49, 0x46, 0x46]) && - hasBytesAt(data, 8, [0x57, 0x45, 0x42, 0x50]) && - readUint32LittleEndian(data, 4) + 8 <= data.length - ) { - return { contentType: "image/webp", extension: ".webp" }; - } - if (isAvif(data)) { - return { contentType: "image/avif", extension: ".avif" }; - } - if (data.length >= 68 && startsWithBytes(data, [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a])) { - return { contentType: "image/ktx2", extension: ".ktx2" }; - } - throw new Error(`Unsupported or invalid texture "${url}".`); -} - -function isAvif(data: Uint8Array): boolean { - if (data.length < 16 || !hasBytesAt(data, 4, [0x66, 0x74, 0x79, 0x70])) { - return false; - } - const boxSize = readUint32BigEndian(data, 0); - if (boxSize < 16 || boxSize > data.length) { - return false; - } - if (isAvifBrand(data, 8)) { - return true; - } - for (let offset = 16; offset + 4 <= boxSize; offset += 4) { - if (isAvifBrand(data, offset)) { - return true; - } - } - return false; -} - -function isAvifBrand(data: Uint8Array, offset: number): boolean { - return hasBytesAt(data, offset, [0x61, 0x76, 0x69, 0x66]) || hasBytesAt(data, offset, [0x61, 0x76, 0x69, 0x73]); -} - -function readUint32BigEndian(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) * 0x1000000 + ((data[offset + 1] ?? 0) << 16) + ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0; -} - -function readUint32LittleEndian(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8) + ((data[offset + 2] ?? 0) << 16) + (data[offset + 3] ?? 0) * 0x1000000) >>> 0; -} - -function startsWithBytes(data: Uint8Array, expected: readonly number[]): boolean { - return hasBytesAt(data, 0, expected); -} - -function hasBytesAt(data: Uint8Array, offset: number, expected: readonly number[]): boolean { - return data.length >= offset + expected.length && expected.every((byte, index) => data[offset + index] === byte); -} - -async function attachTextureDataAsync(scene: BabylonScene, textureAssets: ReadonlyMap): Promise { - const updates: Promise[] = []; - for (const texture of scene.textures) { - if (!isUrlTexture(texture) || typeof texture.url !== "string") { - continue; - } - const asset = textureAssets.get(texture.url); - if (asset === undefined) { - continue; - } - updates.push( - new Promise((resolve) => { - texture.updateURL( - asset.dataUri, - asset.dataUri, - () => { - texture.name = asset.name; - resolve(); - }, - asset.extension - ); - }) - ); - } - await Promise.all(updates); -} - -interface UrlTexture extends BaseTexture { - readonly url: string | null; - updateURL(url: string, buffer: string, onLoad: () => void, forcedExtension: string): void; -} - -function isUrlTexture(texture: BaseTexture): texture is UrlTexture { - return "url" in texture && "updateURL" in texture && typeof texture.updateURL === "function"; -} - -function restoreMaterialNames(scene: BabylonScene, materialTokens: ReadonlyMap): void { - const namesByToken = new Map(); - for (const [name, token] of materialTokens) { - namesByToken.set(token, name); - namesByToken.set(`${token}_line`, `${name}_line`); - } - for (const material of scene.materials) { - material.name = namesByToken.get(material.name) ?? material.name; - material.id = namesByToken.get(material.id) ?? material.id; - } -} - -function resolveDependencyUrl(reference: string, baseUrl: string, dependencyType: string): string { - try { - return new URL(reference, baseUrl).href; - } catch (error) { - throw new Error(`Invalid ${dependencyType} reference "${reference}" in "${baseUrl}".`, { cause: error }); - } -} - -function createTextDataUri(text: string): string { - return createDataUri(new TextEncoder().encode(text), "text/plain"); -} - -function createDirectTextSource(text: string): string { - return `data:#,\n${text}`; -} diff --git a/packages/core/src/blocks/stlInputBlock.ts b/packages/core/src/blocks/stlInputBlock.ts index 9971b0c..5d28af1 100644 --- a/packages/core/src/blocks/stlInputBlock.ts +++ b/packages/core/src/blocks/stlInputBlock.ts @@ -1,13 +1,14 @@ +import type { ISceneLoaderPluginFactory } from "@babylonjs/core/Loading/sceneLoader.js"; import { STLFileLoaderMetadata } from "@babylonjs/loaders/STL/stlFileLoader.metadata.js"; import { GltfDocumentType } from "../connectionPoints/gltfDocument"; import { UrlType } from "../connectionPoints/url"; import { convertBabylonSceneToDocumentAsync } from "../helpers/convertBabylonSceneToDocument"; +import { loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; import { NullEngineResource } from "../resources/nullEngineResource"; import { PlatformIOResource } from "../resources/platformIOResource"; import { Block, type BlockOptions } from "./block"; import { defineBlock } from "./blockDefinition"; -import { loadSingleFileSceneWithPluginAsync, type SceneLoaderPluginFactory } from "../helpers/loadSceneWithPlugin"; const StlLoaderFactory = { ...STLFileLoaderMetadata, @@ -19,7 +20,7 @@ const StlLoaderFactory = { RegisterStandardMaterial(); return new STLFileLoader(); }, -} satisfies SceneLoaderPluginFactory; +} satisfies ISceneLoaderPluginFactory; const StlInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.stl", @@ -31,14 +32,14 @@ const StlInputBlockDefinition = /* @__PURE__ */ defineBlock({ }, runAsync: async (url, _config, { engine, io }) => convertBabylonSceneToDocumentAsync( - await loadSingleFileSceneWithPluginAsync(url, engine, StlLoaderFactory, { + await loadSceneWithPluginAsync(url, engine, StlLoaderFactory, { pluginExtension: ".stl", }), io ), }); -/** Loads an STL URL. */ +/** Loads an STL URL or Node filesystem path. */ export class StlInputBlock extends Block { public constructor(options?: BlockOptions) { super(StlInputBlockDefinition, options); diff --git a/packages/core/src/helpers/convertBabylonSceneToDocument.ts b/packages/core/src/helpers/convertBabylonSceneToDocument.ts index 705361c..77b5e68 100644 --- a/packages/core/src/helpers/convertBabylonSceneToDocument.ts +++ b/packages/core/src/helpers/convertBabylonSceneToDocument.ts @@ -1,15 +1,9 @@ -import type { BaseTexture } from "@babylonjs/core/Materials/Textures/baseTexture.js"; import type { Scene as BabylonScene } from "@babylonjs/core/scene.js"; import type { Document, PlatformIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; -import type sharpFactory from "sharp"; - -import { createDataUri } from "./loadSceneWithPlugin"; -import { isNodeRuntime } from "./isNodeRuntime"; export async function convertBabylonSceneToDocumentAsync(scene: BabylonScene, io: PlatformIO): Promise { try { - await embedExternalTextureDataAsync(scene); const { GLTF2Export } = await import("@babylonjs/serializers/glTF/2.0/index.js"); const fileName = "scene.glb"; const result = await GLTF2Export.GLBAsync(scene, fileName); @@ -17,232 +11,8 @@ export async function convertBabylonSceneToDocumentAsync(scene: BabylonScene, io if (!(root instanceof Blob)) { throw new Error(`The Babylon glTF serializer did not produce "${fileName}".`); } - return io.registerExtensions(ALL_EXTENSIONS).readBinary(new Uint8Array(await root.arrayBuffer())); + return await io.registerExtensions(ALL_EXTENSIONS).readBinary(new Uint8Array(await root.arrayBuffer())); } finally { scene.dispose(); } } - -async function embedExternalTextureDataAsync(scene: BabylonScene): Promise { - // URL-loaded Babylon textures may not retain CPU-readable source bytes, and NullEngine cannot fall back to GPU readback. - // Rebind fetched data before GLB export; non-glTF image formats are transcoded to PNG. - const texturesByUrl = new Map(); - for (const texture of scene.textures) { - if (!isUrlTexture(texture) || texture.url === null || texture.url.startsWith("data:")) { - continue; - } - const textures = texturesByUrl.get(texture.url) ?? []; - textures.push(texture); - texturesByUrl.set(texture.url, textures); - } - - await Promise.all( - Array.from(texturesByUrl, async ([url, textures]) => { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to load texture "${url}": HTTP ${response.status} ${response.statusText}`.trim()); - } - const mimeType = getImageMimeType(response.headers.get("content-type"), response.url || url); - const prepared = await prepareImageAsync(new Uint8Array(await response.arrayBuffer()), mimeType); - const dataUri = createDataUri(prepared.data, prepared.mimeType); - const extension = getImageExtension(prepared.mimeType); - await Promise.all(textures.map((texture) => updateTextureAsync(texture, dataUri, extension))); - }) - ); -} - -interface PreparedImage { - readonly data: Uint8Array; - readonly mimeType: string; -} - -interface RawImage { - readonly data: Uint8Array; - readonly height: number; - readonly width: number; -} - -async function prepareImageAsync(data: Uint8Array, mimeType: string): Promise { - if (mimeType !== "image/bmp" && mimeType !== "image/gif" && mimeType !== "image/x-tga") { - return { data, mimeType }; - } - - const raw = mimeType === "image/x-tga" ? await decodeTgaAsync(data) : await decodeImageAsync(data); - return { - data: await encodePngAsync(raw), - mimeType: "image/png", - }; -} - -async function decodeTgaAsync(data: Uint8Array): Promise { - const { GetTGAHeader, UploadContent } = await import("@babylonjs/core/Misc/tga.js"); - const header = GetTGAHeader(data) as { readonly height: number; readonly width: number }; - let pixels: Uint8Array | undefined; - const texture = { - getEngine: () => ({ - _uploadDataToTextureDirectly: (_texture: unknown, imageData: ArrayBufferView) => { - pixels = new Uint8Array(imageData.buffer, imageData.byteOffset, imageData.byteLength); - }, - }), - }; - UploadContent(texture as Parameters[0], data); - if (pixels === undefined) { - throw new Error("Unable to decode TGA texture."); - } - return { data: pixels, height: header.height, width: header.width }; -} - -async function decodeImageAsync(data: Uint8Array): Promise { - if (isNodeRuntime()) { - const sharp = await loadSharpAsync(); - const decoded = await sharp(data).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); - return { - data: new Uint8Array(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength), - height: decoded.info.height, - width: decoded.info.width, - }; - } - - const image = await createImageBitmap(new Blob([Uint8Array.from(data).buffer])); - try { - const canvas = new OffscreenCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - if (context === null) { - throw new Error("Unable to create a 2D canvas context."); - } - context.drawImage(image, 0, 0); - return { - data: new Uint8Array(context.getImageData(0, 0, image.width, image.height).data), - height: image.height, - width: image.width, - }; - } finally { - image.close(); - } -} - -async function encodePngAsync(image: RawImage): Promise { - if (isNodeRuntime()) { - const sharp = await loadSharpAsync(); - const png = await sharp(image.data, { raw: { channels: 4, height: image.height, width: image.width } }) - .png() - .toBuffer(); - return new Uint8Array(png.buffer, png.byteOffset, png.byteLength); - } - - const canvas = new OffscreenCanvas(image.width, image.height); - const context = canvas.getContext("2d"); - if (context === null) { - throw new Error("Unable to create a 2D canvas context."); - } - context.putImageData(new ImageData(new Uint8ClampedArray(image.data), image.width, image.height), 0, 0); - return new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()); -} - -async function loadSharpAsync(): Promise { - const sharpModuleName = "sharp"; - return (await import(/* @vite-ignore */ sharpModuleName)).default as typeof sharpFactory; -} - -interface UrlTexture extends BaseTexture { - readonly url: string | null; - updateURL(url: string, buffer: string, onLoad: () => void, forcedExtension: string): void; -} - -function isUrlTexture(texture: BaseTexture): texture is UrlTexture { - return "url" in texture && "updateURL" in texture && typeof texture.updateURL === "function"; -} - -function updateTextureAsync(texture: UrlTexture, dataUri: string, extension: string): Promise { - return new Promise((resolve, reject) => { - let settled = false; - let internalTexture: ReturnType; - const cleanup = () => { - internalTexture?.onErrorObservable.removeCallback(onError); - }; - const onLoad = () => { - if (settled) { - return; - } - settled = true; - cleanup(); - resolve(); - }; - const onError = ({ message, exception }: { readonly exception?: unknown; readonly message?: string }) => { - if (settled) { - return; - } - settled = true; - cleanup(); - reject(new Error(message ?? "Failed to update texture.", { cause: exception })); - }; - - try { - texture.updateURL(dataUri, dataUri, onLoad, extension); - if (settled) { - return; - } - internalTexture = texture.getInternalTexture(); - if (internalTexture === null) { - throw new Error("Texture update did not create an internal texture."); - } - internalTexture.onErrorObservable.add(onError); - } catch (error) { - settled = true; - cleanup(); - reject(error); - } - }); -} - -function getImageMimeType(contentType: string | null, url: string): string { - const mimeType = contentType?.split(";", 1)[0]?.trim().toLowerCase(); - if (mimeType?.startsWith("image/")) { - return mimeType; - } - const extension = new URL(url).pathname.split(".").pop()?.toLowerCase(); - switch (extension) { - case "avif": - return "image/avif"; - case "bmp": - return "image/bmp"; - case "gif": - return "image/gif"; - case "jpg": - case "jpeg": - return "image/jpeg"; - case "ktx2": - return "image/ktx2"; - case "png": - return "image/png"; - case "tga": - return "image/x-tga"; - case "webp": - return "image/webp"; - default: - throw new Error(`Unable to determine the image format for texture "${url}".`); - } -} - -function getImageExtension(mimeType: string): string { - switch (mimeType) { - case "image/avif": - return ".avif"; - case "image/bmp": - return ".bmp"; - case "image/gif": - return ".gif"; - case "image/jpeg": - return ".jpg"; - case "image/ktx2": - return ".ktx2"; - case "image/png": - return ".png"; - case "image/webp": - return ".webp"; - case "image/x-tga": - return ".tga"; - default: - throw new Error(`Unsupported texture MIME type "${mimeType}".`); - } -} diff --git a/packages/core/src/helpers/inputLocation.ts b/packages/core/src/helpers/inputLocation.ts new file mode 100644 index 0000000..7ec4b73 --- /dev/null +++ b/packages/core/src/helpers/inputLocation.ts @@ -0,0 +1,17 @@ +import type * as NodeUrl from "node:url"; + +import { isNodeRuntime } from "./isNodeRuntime"; + +export async function resolveInputLocationAsync(input: string): Promise<{ readonly uri: string; readonly path?: string }> { + if (!isNodeRuntime() || !isFileLocation(input)) { + return { uri: input }; + } + const moduleName = "node:url"; + const { fileURLToPath, pathToFileURL } = (await import(/* @vite-ignore */ moduleName)) as typeof NodeUrl; + const url = /^file:/i.test(input) ? new URL(input) : pathToFileURL(input); + return { uri: url.href, path: fileURLToPath(url) }; +} + +export function isFileLocation(input: string): boolean { + return /^file:/i.test(input) || /^[a-z]:[\\/]/i.test(input) || !/^[a-z][a-z\d+.-]*:/i.test(input); +} diff --git a/packages/core/src/helpers/loadSceneWithPlugin.ts b/packages/core/src/helpers/loadSceneWithPlugin.ts index 1b364e3..00f3039 100644 --- a/packages/core/src/helpers/loadSceneWithPlugin.ts +++ b/packages/core/src/helpers/loadSceneWithPlugin.ts @@ -1,156 +1,12 @@ import type { AbstractEngine } from "@babylonjs/core/Engines/abstractEngine.js"; -import type { ISceneLoaderPlugin, ISceneLoaderPluginAsync, ISceneLoaderPluginFactory, LoadOptions } from "@babylonjs/core/Loading/sceneLoader.js"; +import type { ISceneLoaderPluginFactory, LoadOptions } from "@babylonjs/core/Loading/sceneLoader.js"; import type { Scene } from "@babylonjs/core/scene.js"; -type SceneSource = string | ArrayBufferView; +import { resolveInputLocationAsync } from "./inputLocation"; -interface SceneLoadPreparation { - readonly source: SceneSource; - readonly pluginExtension?: string; - readonly pluginOptions?: LoadOptions["pluginOptions"]; -} - -type PrepareSceneLoadAsync = (response: Response, resolvedUrl: string, signal: AbortSignal) => Promise; -type SceneLoaderPlugin = ISceneLoaderPlugin | ISceneLoaderPluginAsync; -export type SceneLoaderPluginFactory = Omit & { - createPlugin(): SceneLoaderPlugin | Promise; -}; -type PluginRegistration = (() => void) | SceneLoaderPluginFactory; - -interface SingleFileSceneLoadOptions { - readonly includeRootUrl?: boolean; - readonly pluginExtension?: string; - readonly pluginOptions?: LoadOptions["pluginOptions"]; - readonly prepareSceneLoadAsync?: PrepareSceneLoadAsync; -} - -export async function loadSceneWithPluginAsync(source: SceneSource, engine: AbstractEngine, pluginRegistration: PluginRegistration, options?: LoadOptions): Promise { +export async function loadSceneWithPluginAsync(source: string, engine: AbstractEngine, factory: ISceneLoaderPluginFactory, options: LoadOptions): Promise { + const { uri } = await resolveInputLocationAsync(source); const { LoadSceneAsync, RegisterSceneLoaderPlugin } = await import("@babylonjs/core/Loading/sceneLoader.js"); - if (typeof pluginRegistration === "function") { - pluginRegistration(); - } else { - RegisterSceneLoaderPlugin(pluginRegistration); - } - return LoadSceneAsync(source, engine, options); -} - -export async function loadSingleFileSceneWithPluginAsync( - url: string, - engine: AbstractEngine, - pluginRegistration: PluginRegistration, - options: SingleFileSceneLoadOptions = {} -): Promise { - if (!isHttpUrl(url)) { - if (options.pluginExtension === undefined && options.pluginOptions === undefined) { - return loadSceneWithPluginAsync(url, engine, pluginRegistration); - } - - return loadSceneWithPluginAsync(url, engine, pluginRegistration, { - ...(options.pluginExtension === undefined ? {} : { pluginExtension: options.pluginExtension }), - ...(options.pluginOptions === undefined ? {} : { pluginOptions: options.pluginOptions }), - }); - } - - const abortController = new AbortController(); - try { - const response = await fetchOrThrowAsync(url, abortController.signal); - const resolvedUrl = response.url || url; - const rootUrl = new URL(".", resolvedUrl).href; - const name = new URL(resolvedUrl).pathname.split("/").pop() ?? ""; - if (options.prepareSceneLoadAsync === undefined && typeof pluginRegistration !== "function") { - const [data, plugin] = await Promise.all([response.arrayBuffer(), pluginRegistration.createPlugin()]); - return await loadFetchedSceneWithPluginAsync(data, engine, rootUrl, name, plugin); - } - const preparation = - options.prepareSceneLoadAsync === undefined - ? { source: await responseToDataUriAsync(response) } - : await options.prepareSceneLoadAsync(response, resolvedUrl, abortController.signal); - const loadOptions: LoadOptions = { - name, - }; - if (options.includeRootUrl !== false) { - loadOptions.rootUrl = rootUrl; - } - 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, pluginRegistration, loadOptions); - } finally { - abortController.abort(); - } -} - -async function loadFetchedSceneWithPluginAsync(data: ArrayBuffer, engine: AbstractEngine, rootUrl: string, name: string, plugin: SceneLoaderPlugin): Promise { - const { Scene } = await import("@babylonjs/core/scene.pure.js"); - const scene = new Scene(engine); - - try { - const loadingToken = {}; - scene.addPendingData(loadingToken); - try { - if (isAsyncSceneLoaderPlugin(plugin)) { - await plugin.loadAsync(scene, data, rootUrl, undefined, name); - } else { - let pluginError: { readonly exception?: unknown; readonly message: string } | undefined; - const loaded = plugin.load(scene, data, rootUrl, (message, exception) => { - pluginError = { exception, message }; - }); - if (!loaded) { - throw pluginError?.exception ?? new Error(pluginError?.message ?? `The ${plugin.name} loader failed.`); - } - } - scene.loadingPluginName = plugin.name; - } finally { - scene.removePendingData(loadingToken); - } - return scene; - } catch (error) { - scene.dispose(); - throw error; - } -} - -function isAsyncSceneLoaderPlugin(plugin: SceneLoaderPlugin): plugin is ISceneLoaderPluginAsync { - return "loadAsync" in plugin; -} - -export function isHttpUrl(url: string): boolean { - const scheme = url.slice(0, 8).toLowerCase(); - return scheme.startsWith("http://") || scheme.startsWith("https://"); -} - -export async function fetchOrThrowAsync(url: string, signal: AbortSignal): Promise { - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`Failed to fetch "${url}": HTTP ${response.status} ${response.statusText}`.trim()); - } - return response; -} - -export function createDataUri(data: Uint8Array, contentType: string): string { - return `data:${contentType};base64,${toBase64(data)}`; -} - -export function toBase64(data: Uint8Array): string { - if (typeof Buffer === "function") { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); - } - - const chunkSize = 32_768; - let binary = ""; - for (let offset = 0; offset < data.length; offset += chunkSize) { - binary += String.fromCharCode(...data.subarray(offset, offset + chunkSize)); - } - 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); + RegisterSceneLoaderPlugin(factory); + return LoadSceneAsync(uri, engine, options); } diff --git a/packages/core/src/helpers/nodeXmlHttpRequest.ts b/packages/core/src/helpers/nodeXmlHttpRequest.ts new file mode 100644 index 0000000..634e3cb --- /dev/null +++ b/packages/core/src/helpers/nodeXmlHttpRequest.ts @@ -0,0 +1,112 @@ +import type * as FileSystem from "node:fs/promises"; +import type * as NodeUrl from "node:url"; +import type * as Xhr from "xhr2"; + +import { isNodeRuntime } from "./isNodeRuntime"; +import { isFileLocation } from "./inputLocation"; + +export async function initializeNodeXmlHttpRequestAsync(): Promise { + if (!isNodeRuntime() || typeof globalThis.XMLHttpRequest !== "undefined") { + return; + } + + const xhrModuleName = "xhr2"; + const fileSystemModuleName = "node:fs/promises"; + const urlModuleName = "node:url"; + const [{ default: HttpRequest }, { readFile }, { pathToFileURL }] = await Promise.all([ + import(/* @vite-ignore */ xhrModuleName) as Promise, + import(/* @vite-ignore */ fileSystemModuleName) as Promise, + import(/* @vite-ignore */ urlModuleName) as Promise, + ]); + + // xhr2 supplies HTTP(S); this adapter adds asynchronous, read-only filesystem requests. + class NodeXmlHttpRequest extends HttpRequest { + #file: URL | undefined; + #controller: AbortController | undefined; + #timeout: ReturnType | undefined; + + public override open(method: string, url: string, async = true, user?: string, password?: string): void { + this.abort(); + this.#file = isFileLocation(url) ? (/^file:/i.test(url) ? new URL(url) : pathToFileURL(url)) : undefined; + if (this.#file && (method.toUpperCase() !== "GET" || !async)) { + throw new Error("Local asset requests require asynchronous GET."); + } + super.open(method, this.#file?.href ?? url, async, user, password); + } + + public override send(body?: unknown): void { + if (!this.#file) { + super.send(body); + return; + } + if (this.readyState !== HttpRequest.OPENED || this.#controller) { + throw new Error("The file request is not ready to send."); + } + if (this.responseType !== "" && this.responseType !== "text" && this.responseType !== "arraybuffer") { + throw new Error(`Unsupported local asset response type "${this.responseType}".`); + } + + const controller = new AbortController(); + this.#controller = controller; + this.response = null; + this.responseText = null; + this.responseURL = this.#file.href; + if (this.timeout > 0) { + this.#timeout = setTimeout(() => this.#cancel("timeout"), this.timeout); + } + this.dispatchEvent(new HttpRequest.ProgressEvent("loadstart")); + void readFile(this.#file, { signal: controller.signal }).then( + (data) => { + if (this.#controller !== controller) { + return; + } + this.status = 200; + this.statusText = "OK"; + this.responseText = this.responseType === "arraybuffer" ? null : data.toString("utf8"); + this.response = this.responseType === "arraybuffer" ? Uint8Array.from(data).buffer : this.responseText; + this.#finish("load"); + }, + (error: unknown) => { + if (this.#controller !== controller) { + return; + } + this.status = error instanceof Error && "code" in error && error.code === "ENOENT" ? 404 : 400; + this.statusText = error instanceof Error ? error.message : String(error); + this.#finish("error"); + } + ); + } + + public override abort(): void { + if (this.#controller) { + this.#cancel("abort"); + } else { + super.abort(); + } + } + + #cancel(event: "abort" | "timeout"): void { + this.#controller?.abort(); + this.status = 0; + this.statusText = event; + this.response = null; + this.responseText = null; + this.#finish(event); + } + + #finish(event: string): void { + clearTimeout(this.#timeout); + this.#timeout = undefined; + this.#controller = undefined; + this.readyState = HttpRequest.DONE; + this.dispatchEvent(new HttpRequest.ProgressEvent("readystatechange")); + this.dispatchEvent(new HttpRequest.ProgressEvent(event)); + this.dispatchEvent(new HttpRequest.ProgressEvent("loadend")); + } + } + + // Another execution or the host may have installed a transport while imports were pending. + if (typeof globalThis.XMLHttpRequest === "undefined") { + Object.defineProperty(globalThis, "XMLHttpRequest", { configurable: true, writable: true, value: NodeXmlHttpRequest }); + } +} diff --git a/packages/core/src/resources/nullEngineResource.ts b/packages/core/src/resources/nullEngineResource.ts index 157c8a9..b729e05 100644 --- a/packages/core/src/resources/nullEngineResource.ts +++ b/packages/core/src/resources/nullEngineResource.ts @@ -1,9 +1,11 @@ import type { NullEngine as BabylonNullEngine } from "@babylonjs/core/Engines/nullEngine.js"; +import { initializeNodeXmlHttpRequestAsync } from "../helpers/nodeXmlHttpRequest"; import type { Resource } from "./resource"; export const NullEngineResource = { name: "NullEngine", create: async () => { + await initializeNodeXmlHttpRequestAsync(); const { NullEngine } = await import("@babylonjs/core/Engines/nullEngine.js"); return new NullEngine(); }, diff --git a/packages/core/src/types/xhr2.d.ts b/packages/core/src/types/xhr2.d.ts new file mode 100644 index 0000000..2805162 --- /dev/null +++ b/packages/core/src/types/xhr2.d.ts @@ -0,0 +1,21 @@ +declare module "xhr2" { + export default class XMLHttpRequest { + static readonly OPENED: 1; + static readonly DONE: 4; + static readonly ProgressEvent: new (type: string) => { readonly type: string }; + + readyState: number; + response: unknown; + responseText: string | null; + responseType: string; + responseURL: string; + status: number; + statusText: string; + timeout: number; + + open(method: string, url: string, async?: boolean, user?: string, password?: string): void; + send(body?: unknown): void; + abort(): void; + dispatchEvent(event: { readonly type: string }): void; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb5133..f554080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: sharp: specifier: 0.35.4 version: 0.35.4(@types/node@26.2.0) + xhr2: + specifier: 0.2.1 + version: 0.2.1 packages: @@ -1658,6 +1661,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xhr2@0.2.1: + resolution: {integrity: sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==} + engines: {node: '>= 6'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3082,6 +3089,8 @@ snapshots: word-wrap@1.2.5: {} + xhr2@0.2.1: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/tests/bundle/browserConsumerBundle.test.ts b/tests/bundle/browserConsumerBundle.test.ts index 6bd29d1..1b95c09 100644 --- a/tests/bundle/browserConsumerBundle.test.ts +++ b/tests/bundle/browserConsumerBundle.test.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; @@ -7,6 +8,8 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import type * as NodeAssets from "../../packages/core/src/index"; import { parseGlbAsync } from "../helpers/glb"; import { generateGltfJson } from "../helpers/gltf"; +import { withInputFilesAsync } from "../helpers/input"; +import { generateMtlData, generateTexturedObjData, generateTextureData } from "../helpers/obj"; const ConsumerModuleId = "\0node-assets-browser-consumer"; const PublishedPackageName = "@babylonjs/node-assets"; @@ -15,12 +18,12 @@ const PublishedEntryPath = fileURLToPath(new URL("../../packages/core/dist/index describe("browser consumer bundle", () => { beforeAll(buildLibrary, 120_000); - it("bundles the published entry without resolving Sharp", async () => { + it("bundles the published entry without resolving Node-only dependencies", async () => { const transformedModuleIds = new Set(); const result = await build({ configFile: false, logLevel: "silent", - plugins: [rejectSharp(), createConsumerPlugin(), trackTransformedModules(transformedModuleIds)], + plugins: [rejectNodeOnlyDependencies(), createConsumerPlugin(), trackTransformedModules(transformedModuleIds)], build: { assetsInlineLimit: 0, rollupOptions: { @@ -62,6 +65,23 @@ describe("browser consumer bundle", () => { vi.unstubAllGlobals(); } }); + + it("loads local OBJ dependencies through the published entry", async () => { + const { NodeAsset, ObjInputBlock } = (await import(pathToFileURL(PublishedEntryPath).href)) as typeof NodeAssets; + await withInputFilesAsync( + { + "model.obj": generateTexturedObjData("model.mtl"), + "model.mtl": generateMtlData(), + "textures/diffuse.png": generateTextureData(), + }, + async (directory) => { + const document = await new NodeAsset({ name: "published-obj", outputBlock: new ObjInputBlock({ input: join(directory, "model.obj") }) }).executeAsync(); + + expect(document.getRoot().listMaterials()[0]?.getName()).toBe("Textured"); + expect(document.getRoot().listTextures()[0]?.getImage()).toEqual(generateTextureData()); + } + ); + }); }); async function buildLibrary(): Promise { @@ -85,13 +105,13 @@ function createConsumerPlugin(): Plugin { }; } -function rejectSharp(): Plugin { +function rejectNodeOnlyDependencies(): Plugin { return { - name: "node-assets-reject-sharp", + name: "node-assets-reject-node-only-dependencies", enforce: "pre", resolveId(id) { - if (id === "sharp") { - throw new Error("Browser consumer attempted to resolve sharp"); + if (id === "sharp" || id === "xhr2") { + throw new Error(`Browser consumer attempted to resolve ${id}`); } }, }; diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 8b2fa98..50c34fe 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -9,7 +9,7 @@ import cliPackage from "../../packages/cli/package.json"; import { EncodeDracoBlock, EncodeKTX2Block, EncodeMeshoptBlock, GltfInputBlock, GltfOutputBlock, NodeAsset } from "../../packages/core/src/index"; import { buildCliFixtureAsync, runNodeAsync } from "../helpers/cli"; import { expectKtx2Image, parseGlbAsync } from "../helpers/glb"; -import { generateGlbDataUri, generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; +import { generateGlbData, generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; describe("Node Assets CLI", () => { let directory: string; @@ -24,8 +24,7 @@ describe("Node Assets CLI", () => { texturedInput = join(directory, "textured.gltf"); await writeFile(input, generateGltfJson()); await writeFile(texturedInput, generateTexturedGltfJson()); - const glbDataUri = generateGlbDataUri(); - await writeFile(join(directory, "input.glb"), Buffer.from(glbDataUri.slice(glbDataUri.indexOf(",") + 1), "base64")); + await writeFile(join(directory, "input.glb"), generateGlbData()); }, 120_000); afterAll(async () => { diff --git a/tests/helpers/fbx.ts b/tests/helpers/fbx.ts index 93c3161..6b2a7bd 100644 --- a/tests/helpers/fbx.ts +++ b/tests/helpers/fbx.ts @@ -1,7 +1,3 @@ -export function generateFbxDataUri(): string { - return `data:application/octet-stream;base64,${btoa(generateFbxData())}`; -} - export function generateFbxData(): string { return generateAsciiFbxData(); } diff --git a/tests/helpers/gltf.ts b/tests/helpers/gltf.ts index 7cf836c..5b15dd0 100644 --- a/tests/helpers/gltf.ts +++ b/tests/helpers/gltf.ts @@ -34,7 +34,7 @@ export function generateTexturedGltfJson(): string { }); } -export function generateGlbDataUri(): string { +export function generateGlbData(): Uint8Array { const gltf = JSON.parse(generateGltfJson()) as { buffers: Array<{ byteLength: number; uri?: string }>; }; @@ -67,7 +67,7 @@ export function generateGlbDataUri(): string { header.setUint32(binaryChunkOffset + 4, 0x004e4942, true); glb.set(binary, binaryChunkOffset + 8); - return `data:model/gltf-binary;base64,${toBase64(glb)}`; + return glb; } export function generateGltfJson(): string { @@ -95,24 +95,3 @@ export function generateGltfJson(): string { scenes: [{ nodes: [0] }], }); } - -export function decodeDataUri(dataUri: string): string | ArrayBuffer { - if (dataUri.startsWith("data:{")) { - return dataUri.slice("data:".length); - } - - const separator = dataUri.indexOf(","); - const payload = dataUri.slice(separator + 1); - if (!dataUri.slice(0, separator).endsWith(";base64")) { - return payload; - } - return Uint8Array.from(atob(payload), (character) => character.charCodeAt(0)).buffer; -} - -function toBase64(data: Uint8Array): string { - let binary = ""; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary); -} diff --git a/tests/helpers/input.ts b/tests/helpers/input.ts new file mode 100644 index 0000000..8dfcc26 --- /dev/null +++ b/tests/helpers/input.ts @@ -0,0 +1,41 @@ +import { once } from "node:events"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +type InputFiles = Readonly>; + +export async function withInputFilesAsync(files: InputFiles, run: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), "node-assets-input-")); + try { + for (const [name, data] of Object.entries(files)) { + const path = join(directory, name); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, data); + } + return await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +export async function withHttpInputsAsync(files: InputFiles, run: (rootUrl: string) => Promise): Promise { + const server = createServer((request, response) => { + const data = files[(request.url ?? "").slice(1)]; + response.writeHead(data === undefined ? 404 : 200); + response.end(data); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Expected an HTTP server address."); + } + return await run(`http://127.0.0.1:${address.port}/`); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +} diff --git a/tests/helpers/obj.ts b/tests/helpers/obj.ts index 921d322..8c054e2 100644 --- a/tests/helpers/obj.ts +++ b/tests/helpers/obj.ts @@ -13,10 +13,6 @@ usemtl None f 1/1/1 2/2/1 3/3/1`; } -export function generateObjDataUri(): string { - return createTextDataUri(generateObjData()); -} - export function generateTexturedObjData(mtlPath = "materials/model.mtl", materialName = "Textured"): string { return `mtllib ${mtlPath} o Triangle @@ -41,19 +37,3 @@ map_bump -bm 0.5 ${texturePath}`; export function generateTextureData(): Uint8Array { return Uint8Array.from(atob(PNG_DATA), (character) => character.charCodeAt(0)); } - -export function generateTextureDataUri(): string { - return `data:image/png;base64,${PNG_DATA}`; -} - -function createTextDataUri(text: string): string { - return `data:text/plain;base64,${toBase64(new TextEncoder().encode(text))}`; -} - -function toBase64(data: Uint8Array): string { - let binary = ""; - for (const byte of data) { - binary += String.fromCharCode(byte); - } - return btoa(binary); -} diff --git a/tests/helpers/stl.ts b/tests/helpers/stl.ts index f10f4fd..e69ead8 100644 --- a/tests/helpers/stl.ts +++ b/tests/helpers/stl.ts @@ -1,7 +1,3 @@ -export function generateStlDataUri(): string { - return `data:application/octet-stream;base64,${btoa(generateStlData())}`; -} - export function generateStlData(): string { return `solid triangle facet normal 0 0 1 diff --git a/tests/integration/encodeKtx2.test.ts b/tests/integration/encodeKtx2.test.ts index c451c9f..f650988 100644 --- a/tests/integration/encodeKtx2.test.ts +++ b/tests/integration/encodeKtx2.test.ts @@ -6,6 +6,7 @@ import { EncodeKTX2Block, FbxInputBlock, GltfInputBlock, GltfOutputBlock, NodeAs import { generateTexturedFbxDataWithUvs } from "../helpers/fbx"; import { expectKtx2Image, getTextureImageIndex, parseGlbAsync } from "../helpers/glb"; import { generateTexturedGltfJson } from "../helpers/gltf"; +import { withHttpInputsAsync } from "../helpers/input"; import { generateMtlData, generateTexturedObjData, generateTextureData } from "../helpers/obj"; describe("KTX2 encoding", () => { @@ -53,63 +54,34 @@ describe("KTX2 encoding", () => { }); it("encodes a texture shared by color and normal slots once", async () => { - const rootUrl = "https://example.com/assets/model.obj"; - const mtlUrl = "https://example.com/assets/materials/model.mtl"; - const textureUrl = "https://example.com/assets/materials/textures/diffuse.png"; - vi.stubGlobal( - "fetch", - vi.fn((input: string | URL | Request) => { - switch (String(input)) { - case rootUrl: - return Promise.resolve(new Response(generateTexturedObjData())); - case mtlUrl: - return Promise.resolve(new Response(generateMtlData())); - case textureUrl: - return Promise.resolve(new Response(generateTextureData().buffer as ArrayBuffer)); - default: - return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); - } - }) + await withHttpInputsAsync( + { + "model.obj": generateTexturedObjData("model.mtl"), + "model.mtl": generateMtlData(), + "textures/diffuse.png": generateTextureData(), + }, + async (rootUrl) => { + const source = new ObjInputBlock({ input: `${rootUrl}model.obj` }); + const encoder = new EncodeKTX2Block(); + const destination = new GltfOutputBlock(); + source.output.connectTo(encoder.input); + encoder.output.connectTo(destination.input); + + const parsed = await parseGlbAsync(await new NodeAsset({ name: "shared-texture-encoding", outputBlock: destination }).executeAsync()); + const material = parsed.json.materials?.[0]; + const colorImageIndex = getTextureImageIndex(parsed, material?.pbrMetallicRoughness?.baseColorTexture?.index); + const normalImageIndex = getTextureImageIndex(parsed, material?.normalTexture?.index); + + expect(parsed.json.images).toHaveLength(1); + expect(colorImageIndex).toBe(normalImageIndex); + expectKtx2Image(parsed); + } ); - - try { - const source = new ObjInputBlock({ input: rootUrl }); - const encoder = new EncodeKTX2Block(); - const destination = new GltfOutputBlock(); - source.output.connectTo(encoder.input); - encoder.output.connectTo(destination.input); - - const parsed = await parseGlbAsync(await new NodeAsset({ name: "shared-texture-encoding", outputBlock: destination }).executeAsync()); - const material = parsed.json.materials?.[0]; - const colorImageIndex = getTextureImageIndex(parsed, material?.pbrMetallicRoughness?.baseColorTexture?.index); - const normalImageIndex = getTextureImageIndex(parsed, material?.normalTexture?.index); - - expect(parsed.json.images).toHaveLength(1); - expect(colorImageIndex).toBe(normalImageIndex); - expectKtx2Image(parsed); - } finally { - vi.unstubAllGlobals(); - } }); it("encodes a texture from an FBX input", async () => { - const rootUrl = "https://example.com/model.fbx"; - const textureUrl = "https://example.com/textures/diffuse.png"; - vi.stubGlobal( - "fetch", - vi.fn((input: string | URL | Request) => { - if (String(input) === rootUrl) { - return Promise.resolve(new Response(generateTexturedFbxDataWithUvs("textures/diffuse.png"))); - } - if (String(input) === textureUrl) { - return Promise.resolve(new Response(generateTextureData().buffer as ArrayBuffer, { headers: { "content-type": "image/png" } })); - } - return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); - }) - ); - - try { - const source = new FbxInputBlock({ input: rootUrl }); + await withHttpInputsAsync({ "model.fbx": generateTexturedFbxDataWithUvs("textures/diffuse.png"), "textures/diffuse.png": generateTextureData() }, async (rootUrl) => { + const source = new FbxInputBlock({ input: `${rootUrl}model.fbx` }); const encoder = new EncodeKTX2Block(); const destination = new GltfOutputBlock(); source.output.connectTo(encoder.input); @@ -119,9 +91,7 @@ describe("KTX2 encoding", () => { expect(parsed.json.images).toHaveLength(1); expectKtx2Image(parsed); - } finally { - vi.unstubAllGlobals(); - } + }); }); it("appends .ktx2 to extensionless texture URIs without collisions", async () => { diff --git a/tests/integration/fbxInput.test.ts b/tests/integration/fbxInput.test.ts index 249211a..e384c4f 100644 --- a/tests/integration/fbxInput.test.ts +++ b/tests/integration/fbxInput.test.ts @@ -1,79 +1,69 @@ -import { describe, expect, it, vi } from "vitest"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; import { FbxInputBlock, GltfOutputBlock, NodeAsset, NodeAssetContext } from "../../packages/core/src/index"; -import { generateBinaryFbxData, generateFbxData, generateFbxDataUri, generateTexturedFbxDataWithUvs, generateTgaTextureData } from "../helpers/fbx"; -import { parseGlbAsync } from "../helpers/glb"; +import { generateBinaryFbxData, generateFbxData, generateTexturedFbxDataWithUvs } from "../helpers/fbx"; +import { getEmbeddedImageBytes, parseGlbAsync } from "../helpers/glb"; +import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; +import { generateTextureData } from "../helpers/obj"; describe("FBX input", () => { - it("loads generated FBX data without relying on a URL extension", async () => { - const { json } = await parseGlbAsync(await roundTripAsync(new FbxInputBlock({ input: generateFbxDataUri() }))); - - expect(json.meshes).toHaveLength(1); - expect(json.meshes?.[0]?.primitives).toHaveLength(1); - }); - it.each([ { data: generateFbxData(), format: "ASCII" }, { data: generateBinaryFbxData(), format: "binary" }, ])("loads an extensionless HTTP $format asset", async ({ data }) => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(new Response(typeof data === "string" ? data : new Uint8Array(data).buffer))) - ); - - try { - const { json } = await parseGlbAsync(await roundTripAsync(new FbxInputBlock({ input: "https://example.com/model" }))); + await withHttpInputsAsync({ model: data }, async (rootUrl) => { + const { json } = await parseGlbAsync(await roundTripAsync(new FbxInputBlock({ input: `${rootUrl}model` }))); expect(json.meshes).toHaveLength(1); expect(json.meshes?.[0]?.primitives).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); - } + }); }); - it("resolves redirected FBX texture dependencies and preserves the material", async () => { - const rootUrl = "https://example.com/model"; - const redirectedRootUrl = "https://cdn.example.com/assets/scene.fbx"; - const textureUrl = "https://cdn.example.com/assets/textures/diffuse.tga"; - vi.stubGlobal( - "fetch", - vi.fn((input: string | URL | Request) => { - if (String(input) === rootUrl) { - const response = new Response(generateTexturedFbxDataWithUvs("textures/diffuse.tga")); - Object.defineProperty(response, "url", { value: redirectedRootUrl }); - return Promise.resolve(response); - } - if (String(input) === textureUrl) { - return Promise.resolve(new Response(generateTgaTextureData().buffer as ArrayBuffer, { headers: { "content-type": "image/x-tga" } })); - } - return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); - }) - ); - - try { - const { json } = await parseGlbAsync(await roundTripAsync(new FbxInputBlock({ input: rootUrl }))); + it.each(["local", "HTTP"])("preserves an FBX material and its external PNG over %s", async (location) => { + const files = { + "model.fbx": generateTexturedFbxDataWithUvs("textures/diffuse.png"), + "textures/diffuse.png": generateTextureData(), + }; + const load = async (input: string) => { + const parsed = await parseGlbAsync(await roundTripAsync(new FbxInputBlock({ input }))); - expect(json.meshes).toHaveLength(1); - expect(json.meshes?.[0]?.primitives).toHaveLength(1); - expect(json.materials).toHaveLength(1); - expect(json.materials?.[0]?.name).toBe("Textured"); - expect(json.images).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); + expect(parsed.json.meshes).toHaveLength(1); + expect(parsed.json.materials?.[0]?.name).toBe("Textured"); + expect(parsed.json.images).toHaveLength(1); + expect(getEmbeddedImageBytes(parsed, parsed.json.images![0]!)).toEqual(generateTextureData()); + }; + if (location === "local") { + await withInputFilesAsync(files, (directory) => load(join(directory, "model.fbx"))); + } else { + await withHttpInputsAsync(files, (rootUrl) => load(`${rootUrl}model.fbx`)); } }); it("accepts input through an execution context", async () => { - const source = new FbxInputBlock(); - const destination = new GltfOutputBlock(); - source.output.connectTo(destination.input); - const asset = new NodeAsset({ name: "context-fbx-to-glb", outputBlock: destination }); - const context = new NodeAssetContext(asset); - context.setInput(source, generateFbxDataUri()); + await withInputFilesAsync({ "triangle.fbx": generateFbxData() }, async (directory) => { + const source = new FbxInputBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(destination.input); + const asset = new NodeAsset({ name: "context-fbx-to-glb", outputBlock: destination }); + const context = new NodeAssetContext(asset); + context.setInput(source, join(directory, "triangle.fbx")); + + const { json } = await parseGlbAsync(await asset.executeAsync(context)); - const { json } = await parseGlbAsync(await asset.executeAsync(context)); + expect(json.meshes).toHaveLength(1); + }); + }); - expect(json.meshes).toHaveLength(1); + it.each(["local", "HTTP"])("rejects an FBX whose texture cannot be exported over %s", async (location) => { + const files = { "model.fbx": generateTexturedFbxDataWithUvs("missing.png") }; + const load = (input: string) => expect(roundTripAsync(new FbxInputBlock({ input }))).rejects.toThrow(); + if (location === "local") { + await withInputFilesAsync(files, (directory) => load(join(directory, "model.fbx"))); + } else { + await withHttpInputsAsync(files, (rootUrl) => load(`${rootUrl}model.fbx`)); + } }); }); diff --git a/tests/integration/gltfInput.test.ts b/tests/integration/gltfInput.test.ts index c2fd962..d7e4380 100644 --- a/tests/integration/gltfInput.test.ts +++ b/tests/integration/gltfInput.test.ts @@ -1,12 +1,42 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + import { describe, expect, it, vi } from "vitest"; import { GltfInputBlock, NodeAsset, NodeAssetContext } from "../../packages/core/src/index"; -import { decodeDataUri, generateGlbDataUri, generateGltfJson } from "../helpers/gltf"; +import { generateGlbData, generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; +import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; +import { generateTextureData } from "../helpers/obj"; describe("glTF input", () => { + it.each(["local", "HTTP"])("loads glTF external buffers and textures over %s", async (location) => { + const json = JSON.parse(generateTexturedGltfJson()) as { buffers: [{ uri: string }]; images: [{ uri: string }] }; + const bufferUri = json.buffers[0].uri; + const geometry = Buffer.from(bufferUri.slice(bufferUri.indexOf(",") + 1), "base64"); + json.buffers[0].uri = "geometry.bin"; + json.images[0].uri = "textures/diffuse.png"; + const files = { + "model.gltf": JSON.stringify(json), + "geometry.bin": geometry, + "textures/diffuse.png": generateTextureData(), + }; + const load = async (input: string) => { + const document = await new NodeAsset({ name: "gltf-sidecars", outputBlock: new GltfInputBlock({ input }) }).executeAsync(); + + expect(document.getRoot().listMeshes()).toHaveLength(1); + expect(document.getRoot().listTextures()).toHaveLength(1); + expect(Uint8Array.from(document.getRoot().listTextures()[0]!.getImage()!)).toEqual(generateTextureData()); + }; + if (location === "local") { + await withInputFilesAsync(files, (directory) => load(pathToFileURL(join(directory, "model.gltf")).href)); + } else { + await withHttpInputsAsync(files, (rootUrl) => load(`${rootUrl}model.gltf`)); + } + }); + it.each([ { body: generateGltfJson(), format: "glTF", url: "https://example.com/model.gltf" }, - { body: decodeDataUri(generateGlbDataUri()), format: "GLB", url: "https://example.com/model.glb" }, + { body: generateGlbData(), format: "GLB", url: "https://example.com/model.glb" }, ])("loads $format through PlatformIO", async ({ body, url }) => { vi.stubGlobal( "fetch", diff --git a/tests/integration/inputLocations.test.ts b/tests/integration/inputLocations.test.ts new file mode 100644 index 0000000..f6c7b49 --- /dev/null +++ b/tests/integration/inputLocations.test.ts @@ -0,0 +1,79 @@ +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it, vi } from "vitest"; + +import { FbxInputBlock, GltfInputBlock, NodeAsset, ObjInputBlock, StlInputBlock } from "../../packages/core/src/index"; +import { generateFbxData } from "../helpers/fbx"; +import { generateGltfJson } from "../helpers/gltf"; +import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; +import { generateObjData } from "../helpers/obj"; +import { generateStlData } from "../helpers/stl"; + +describe("input locations", () => { + it.each([ + { Block: GltfInputBlock, data: generateGltfJson(), extension: "gltf" }, + { Block: StlInputBlock, data: generateStlData(), extension: "stl" }, + { Block: ObjInputBlock, data: generateObjData(), extension: "obj" }, + { Block: FbxInputBlock, data: generateFbxData(), extension: "fbx" }, + ])("loads $extension paths and file URLs containing reserved characters", async ({ Block, data, extension }) => { + const name = `model #100%.${extension}`; + await withInputFilesAsync({ [name]: data }, async (directory) => { + const path = join(directory, name); + for (const input of [path, relative(process.cwd(), path), pathToFileURL(path).href]) { + const document = await new NodeAsset({ name: "input-location", outputBlock: new Block({ input }) }).executeAsync(); + + expect(document.getRoot().listMeshes()).toHaveLength(1); + } + }); + }); + + it.each([GltfInputBlock, StlInputBlock, ObjInputBlock, FbxInputBlock])("rejects missing local and HTTP inputs with %s", async (Block) => { + await withInputFilesAsync({}, async (directory) => { + await expect(new NodeAsset({ name: "missing-file", outputBlock: new Block({ input: join(directory, "missing") }) }).executeAsync()).rejects.toThrow(); + }); + await withHttpInputsAsync({}, async (rootUrl) => { + await expect(new NodeAsset({ name: "missing-url", outputBlock: new Block({ input: `${rootUrl}missing` }) }).executeAsync()).rejects.toThrow(); + }); + }); + + it("loads independent local inputs concurrently", async () => { + vi.stubGlobal("XMLHttpRequest", undefined); + try { + await withInputFilesAsync({ "model.stl": generateStlData(), "model.fbx": generateFbxData() }, async (directory) => { + const documents = await Promise.all([ + new NodeAsset({ name: "concurrent-stl", outputBlock: new StlInputBlock({ input: join(directory, "model.stl") }) }).executeAsync(), + new NodeAsset({ name: "concurrent-fbx", outputBlock: new FbxInputBlock({ input: join(directory, "model.fbx") }) }).executeAsync(), + ]); + + expect(documents.map((document) => document.getRoot().listMeshes().length)).toEqual([1, 1]); + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("preserves a host-provided HTTP transport", async () => { + await withInputFilesAsync({ "model.stl": generateStlData() }, async (directory) => { + await new NodeAsset({ name: "initialize-node", outputBlock: new StlInputBlock({ input: join(directory, "model.stl") }) }).executeAsync(); + }); + await withHttpInputsAsync({ model: generateStlData() }, async (rootUrl) => { + class HostRequest extends globalThis.XMLHttpRequest { + public override open(method: string, _url: string | URL): void { + super.open(method, `${rootUrl}model`, true); + } + } + vi.stubGlobal("XMLHttpRequest", HostRequest); + try { + const document = await new NodeAsset({ + name: "host-transport", + outputBlock: new StlInputBlock({ input: `${rootUrl}missing` }), + }).executeAsync(); + + expect(document.getRoot().listMeshes()).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + }); +}); diff --git a/tests/integration/objInput.test.ts b/tests/integration/objInput.test.ts index 03a54c6..92daadf 100644 --- a/tests/integration/objInput.test.ts +++ b/tests/integration/objInput.test.ts @@ -1,62 +1,68 @@ -import { describe, expect, it, vi } from "vitest"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; import { GltfOutputBlock, NodeAsset, ObjInputBlock } from "../../packages/core/src/index"; -import { parseGlbAsync } from "../helpers/glb"; -import { generateMtlData, generateObjDataUri, generateObjData, generateTexturedObjData, generateTextureData } from "../helpers/obj"; +import { getEmbeddedImageBytes, parseGlbAsync } from "../helpers/glb"; +import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; +import { generateMtlData, generateObjData, generateTexturedObjData, generateTextureData } from "../helpers/obj"; describe("OBJ input", () => { - it("loads generated OBJ data from a data URI", async () => { - const { json } = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: generateObjDataUri() }))); + it("loads a local OBJ with its MTL and texture dependencies", async () => { + await withInputFilesAsync( + { + "model.obj": generateTexturedObjData("model.mtl"), + "model.mtl": generateMtlData(), + "textures/diffuse.png": generateTextureData(), + }, + async (directory) => { + const parsed = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: join(directory, "model.obj") }))); - expect(json.meshes).toHaveLength(1); - expect(json.meshes?.[0]?.primitives).toHaveLength(1); + expect(parsed.json.materials?.[0]?.name).toBe("Textured"); + expect(parsed.json.images).toHaveLength(1); + expect(getEmbeddedImageBytes(parsed, parsed.json.images![0]!)).toEqual(generateTextureData()); + } + ); }); it("loads an extensionless HTTP asset", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(new Response(generateObjData(), { headers: { "content-type": "text/plain" } }))) - ); - - try { - const { json } = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: "https://example.com/model" }))); + await withHttpInputsAsync({ model: generateObjData() }, async (rootUrl) => { + const { json } = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: `${rootUrl}model` }))); expect(json.meshes).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); + }); + }); + + it.each(["local", "HTTP"])("rejects a missing MTL over %s", async (location) => { + const files = { "model.obj": generateTexturedObjData("missing.mtl") }; + const load = (input: string) => expect(roundTripAsync(new ObjInputBlock({ input }))).rejects.toThrow(); + if (location === "local") { + await withInputFilesAsync(files, (directory) => load(join(directory, "model.obj"))); + } else { + await withHttpInputsAsync(files, (rootUrl) => load(`${rootUrl}model.obj`)); } }); it("resolves relative MTL and texture dependencies and preserves the material", async () => { - const rootUrl = "https://example.com/assets/model"; - const mtlUrl = "https://example.com/assets/materials/model.mtl"; - const textureUrl = "https://example.com/assets/materials/textures/diffuse.png"; - const fetchMock = vi.fn((input: string | URL | Request) => { - switch (String(input)) { - case rootUrl: - return Promise.resolve(new Response(generateTexturedObjData(), { headers: { "content-type": "text/plain" } })); - case mtlUrl: - return Promise.resolve(new Response(generateMtlData(), { headers: { "content-type": "text/plain" } })); - case textureUrl: - return Promise.resolve(new Response(generateTextureData().buffer as ArrayBuffer)); - default: - return Promise.reject(new Error(`Unexpected fetch: ${String(input)}`)); - } - }); - vi.stubGlobal("fetch", fetchMock); + await withHttpInputsAsync( + { + "assets/model": generateTexturedObjData("model.mtl"), + "assets/model.mtl": generateMtlData(), + "assets/textures/diffuse.png": generateTextureData(), + }, + async (rootUrl) => { + const parsed = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: `${rootUrl}assets/model` }))); + const { json } = parsed; - try { - const { json } = await parseGlbAsync(await roundTripAsync(new ObjInputBlock({ input: rootUrl }))); - - expect(json.meshes).toHaveLength(1); - expect(json.materials).toHaveLength(1); - expect(json.materials?.[0]?.pbrMetallicRoughness?.baseColorTexture).toBeDefined(); - expect(json.materials?.[0]?.normalTexture).toBeDefined(); - expect(json.images).toHaveLength(1); - expect(json.images?.[0]?.name).toBe(textureUrl); - } finally { - vi.unstubAllGlobals(); - } + expect(json.meshes).toHaveLength(1); + expect(json.materials).toHaveLength(1); + expect(json.materials?.[0]?.name).toBe("Textured"); + expect(json.materials?.[0]?.pbrMetallicRoughness?.baseColorTexture).toBeDefined(); + expect(json.materials?.[0]?.normalTexture).toBeDefined(); + expect(json.images).toHaveLength(1); + expect(getEmbeddedImageBytes(parsed, json.images![0]!)).toEqual(generateTextureData()); + } + ); }); }); diff --git a/tests/integration/stlInput.test.ts b/tests/integration/stlInput.test.ts index 9deee11..75b5b99 100644 --- a/tests/integration/stlInput.test.ts +++ b/tests/integration/stlInput.test.ts @@ -1,47 +1,47 @@ -import { describe, expect, it, vi } from "vitest"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; import { GltfOutputBlock, NodeAsset, NodeAssetContext, StlInputBlock } from "../../packages/core/src/index"; import { parseGlbAsync } from "../helpers/glb"; -import { generateBinaryStlData, generateStlData, generateStlDataUri } from "../helpers/stl"; +import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; +import { generateBinaryStlData, generateStlData } from "../helpers/stl"; describe("STL input", () => { - it("loads generated STL data without relying on a URL extension", async () => { - const { json } = await parseGlbAsync(await roundTripAsync(new StlInputBlock({ input: generateStlDataUri() }))); + it("loads a local binary STL file", async () => { + await withInputFilesAsync({ "triangle.stl": generateBinaryStlData() }, async (directory) => { + const { json } = await parseGlbAsync(await roundTripAsync(new StlInputBlock({ input: join(directory, "triangle.stl") }))); - expect(json.meshes).toHaveLength(1); - expect(json.meshes?.[0]?.primitives).toHaveLength(1); + expect(json.meshes).toHaveLength(1); + expect(json.meshes?.[0]?.primitives).toHaveLength(1); + }); }); it.each([ { data: generateStlData(), format: "ASCII" }, { data: generateBinaryStlData(), format: "binary" }, ])("loads an extensionless HTTP $format asset", async ({ data }) => { - vi.stubGlobal( - "fetch", - vi.fn(() => Promise.resolve(new Response(typeof data === "string" ? data : new Uint8Array(data).buffer))) - ); - - try { - const { json } = await parseGlbAsync(await roundTripAsync(new StlInputBlock({ input: "https://example.com/model" }))); + await withHttpInputsAsync({ model: data }, async (rootUrl) => { + const { json } = await parseGlbAsync(await roundTripAsync(new StlInputBlock({ input: `${rootUrl}model` }))); expect(json.meshes).toHaveLength(1); expect(json.meshes?.[0]?.primitives).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); - } + }); }); it("accepts input through an execution context", async () => { - const source = new StlInputBlock(); - const destination = new GltfOutputBlock(); - source.output.connectTo(destination.input); - const asset = new NodeAsset({ name: "context-stl-to-glb", outputBlock: destination }); - const context = new NodeAssetContext(asset); - context.setInput(source, generateStlDataUri()); + await withInputFilesAsync({ "triangle.stl": generateStlData() }, async (directory) => { + const source = new StlInputBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(destination.input); + const asset = new NodeAsset({ name: "context-stl-to-glb", outputBlock: destination }); + const context = new NodeAssetContext(asset); + context.setInput(source, join(directory, "triangle.stl")); - const { json } = await parseGlbAsync(await asset.executeAsync(context)); + const { json } = await parseGlbAsync(await asset.executeAsync(context)); - expect(json.meshes).toHaveLength(1); + expect(json.meshes).toHaveLength(1); + }); }); }); From 2b4ffe7c82f3abbe783d594277ff682b815ffc7e Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:30:39 -0400 Subject: [PATCH 2/7] fix: handle redirects and reduce loader memory Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/babylon-loader-gaps.md | 7 +++++++ packages/core/docs/usage.md | 2 ++ .../helpers/convertBabylonSceneToDocument.ts | 3 ++- .../core/src/helpers/nodeXmlHttpRequest.ts | 15 ++++++++++--- packages/core/src/helpers/xhr2Workarounds.ts | 14 +++++++++++++ packages/core/src/types/xhr2.d.ts | 4 ++++ tests/helpers/input.ts | 8 ++++++- tests/helpers/stl.ts | 13 +++++++----- tests/integration/inputLocations.test.ts | 21 +++++++++++++++++++ tests/integration/stlInput.test.ts | 16 ++++++++------ 10 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/helpers/xhr2Workarounds.ts diff --git a/packages/core/docs/babylon-loader-gaps.md b/packages/core/docs/babylon-loader-gaps.md index 79c570c..dd07068 100644 --- a/packages/core/docs/babylon-loader-gaps.md +++ b/packages/core/docs/babylon-loader-gaps.md @@ -60,6 +60,13 @@ HTTP(S), and the adapter adds asynchronous filesystem reads for models and their sidecars. `src/helpers/inputLocation.ts` normalizes paths and file URLs. Neither contains format parsing or texture handling. +`src/helpers/xhr2Workarounds.ts` contains a transport dependency workaround, not a +Babylon patch. xhr2 0.2.1 does not resolve relative redirect locations against the +current request by default. The adapter updates its base URL after each parsed +request/redirect through xhr2's `_parseUrl` hook and `nodejsSet` configuration. +Remove this adapter when xhr2 resolves relative redirect chains itself. Following +HTTP redirects does not repair Babylon's separate dependency-root issue above. + TGA/BMP/GIF-to-PNG conversion was also removed. That is an image-conversion capability requiring a CPU encoding strategy, not something an XHR implementation can provide. It is separate from preserving already-supported encoded images. diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 36280a0..6c67976 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -27,6 +27,8 @@ In Node, the library lazily installs an XMLHttpRequest implementation with HTTP(S) and filesystem support, unless the host has already supplied one. A host-supplied implementation must support the input locations being loaded. Browser loading uses the browser's XMLHttpRequest; filesystem paths are Node-only. +HTTP redirects are followed, including relative redirect chains. This does not +change Babylon's dependency-root behavior described below. OBJ and FBX materials and textures are loaded by Babylon, not rewritten or prefetched by the input blocks. Babylon's current limitations therefore apply: diff --git a/packages/core/src/helpers/convertBabylonSceneToDocument.ts b/packages/core/src/helpers/convertBabylonSceneToDocument.ts index 77b5e68..4ec2811 100644 --- a/packages/core/src/helpers/convertBabylonSceneToDocument.ts +++ b/packages/core/src/helpers/convertBabylonSceneToDocument.ts @@ -11,7 +11,8 @@ export async function convertBabylonSceneToDocumentAsync(scene: BabylonScene, io if (!(root instanceof Blob)) { throw new Error(`The Babylon glTF serializer did not produce "${fileName}".`); } - return await io.registerExtensions(ALL_EXTENSIONS).readBinary(new Uint8Array(await root.arrayBuffer())); + // The GLB bytes no longer depend on the scene; release it before awaiting the document. + return io.registerExtensions(ALL_EXTENSIONS).readBinary(new Uint8Array(await root.arrayBuffer())); } finally { scene.dispose(); } diff --git a/packages/core/src/helpers/nodeXmlHttpRequest.ts b/packages/core/src/helpers/nodeXmlHttpRequest.ts index 634e3cb..a400daa 100644 --- a/packages/core/src/helpers/nodeXmlHttpRequest.ts +++ b/packages/core/src/helpers/nodeXmlHttpRequest.ts @@ -4,6 +4,7 @@ import type * as Xhr from "xhr2"; import { isNodeRuntime } from "./isNodeRuntime"; import { isFileLocation } from "./inputLocation"; +import { withRelativeHttpRedirects } from "./xhr2Workarounds"; export async function initializeNodeXmlHttpRequestAsync(): Promise { if (!isNodeRuntime() || typeof globalThis.XMLHttpRequest !== "undefined") { @@ -18,9 +19,10 @@ export async function initializeNodeXmlHttpRequestAsync(): Promise { import(/* @vite-ignore */ fileSystemModuleName) as Promise, import(/* @vite-ignore */ urlModuleName) as Promise, ]); + const RedirectAwareRequest = withRelativeHttpRedirects(HttpRequest); // xhr2 supplies HTTP(S); this adapter adds asynchronous, read-only filesystem requests. - class NodeXmlHttpRequest extends HttpRequest { + class NodeXmlHttpRequest extends RedirectAwareRequest { #file: URL | undefined; #controller: AbortController | undefined; #timeout: ReturnType | undefined; @@ -62,8 +64,15 @@ export async function initializeNodeXmlHttpRequestAsync(): Promise { } this.status = 200; this.statusText = "OK"; - this.responseText = this.responseType === "arraybuffer" ? null : data.toString("utf8"); - this.response = this.responseType === "arraybuffer" ? Uint8Array.from(data).buffer : this.responseText; + if (this.responseType === "arraybuffer") { + this.response = + data.byteOffset === 0 && data.byteLength === data.buffer.byteLength + ? data.buffer + : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + } else { + this.responseText = data.toString("utf8"); + this.response = this.responseText; + } this.#finish("load"); }, (error: unknown) => { diff --git a/packages/core/src/helpers/xhr2Workarounds.ts b/packages/core/src/helpers/xhr2Workarounds.ts new file mode 100644 index 0000000..092c48f --- /dev/null +++ b/packages/core/src/helpers/xhr2Workarounds.ts @@ -0,0 +1,14 @@ +import type { UrlWithStringQuery } from "node:url"; +import type XMLHttpRequest from "xhr2"; + +// Upstream: xhr2 0.2.1, _onHttpResponse/_parseUrl in lib/xhr2.js. +// Remove when xhr2 resolves each Location against the current request URL. +export function withRelativeHttpRedirects(Request: typeof XMLHttpRequest): typeof XMLHttpRequest { + return class extends Request { + protected override _parseUrl(url: string): UrlWithStringQuery { + const parsed = super._parseUrl(url); + this.nodejsSet({ baseUrl: parsed.href }); + return parsed; + } + }; +} diff --git a/packages/core/src/types/xhr2.d.ts b/packages/core/src/types/xhr2.d.ts index 2805162..05c3d45 100644 --- a/packages/core/src/types/xhr2.d.ts +++ b/packages/core/src/types/xhr2.d.ts @@ -1,4 +1,6 @@ declare module "xhr2" { + import type { UrlWithStringQuery } from "node:url"; + export default class XMLHttpRequest { static readonly OPENED: 1; static readonly DONE: 4; @@ -17,5 +19,7 @@ declare module "xhr2" { send(body?: unknown): void; abort(): void; dispatchEvent(event: { readonly type: string }): void; + nodejsSet(options: { readonly baseUrl: string }): void; + protected _parseUrl(url: string): UrlWithStringQuery; } } diff --git a/tests/helpers/input.ts b/tests/helpers/input.ts index 8dfcc26..aab89b5 100644 --- a/tests/helpers/input.ts +++ b/tests/helpers/input.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; type InputFiles = Readonly>; +type HttpInputs = Readonly>; export async function withInputFilesAsync(files: InputFiles, run: (directory: string) => Promise): Promise { const directory = await mkdtemp(join(tmpdir(), "node-assets-input-")); @@ -20,9 +21,14 @@ export async function withInputFilesAsync(files: InputFiles, run: (directory: } } -export async function withHttpInputsAsync(files: InputFiles, run: (rootUrl: string) => Promise): Promise { +export async function withHttpInputsAsync(files: HttpInputs, run: (rootUrl: string) => Promise): Promise { const server = createServer((request, response) => { const data = files[(request.url ?? "").slice(1)]; + if (typeof data === "object" && "redirect" in data) { + response.writeHead(302, { location: data.redirect }); + response.end(); + return; + } response.writeHead(data === undefined ? 404 : 200); response.end(data); }); diff --git a/tests/helpers/stl.ts b/tests/helpers/stl.ts index e69ead8..e4f2e02 100644 --- a/tests/helpers/stl.ts +++ b/tests/helpers/stl.ts @@ -10,13 +10,16 @@ endfacet endsolid triangle`; } -export function generateBinaryStlData(): Uint8Array { - const data = new Uint8Array(84 + 50); +export function generateBinaryStlData(triangleCount = 1): Uint8Array { + const data = new Uint8Array(84 + 50 * triangleCount); const view = new DataView(data.buffer); - view.setUint32(80, 1, true); + view.setUint32(80, triangleCount, true); const values = [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0]; - values.forEach((value, index) => view.setFloat32(84 + index * 4, value, true)); - view.setUint16(84 + 48, 0, true); + for (let triangle = 0; triangle < triangleCount; triangle++) { + const offset = 84 + triangle * 50; + values.forEach((value, index) => view.setFloat32(offset + index * 4, value, true)); + view.setUint16(offset + 48, 0, true); + } return data; } diff --git a/tests/integration/inputLocations.test.ts b/tests/integration/inputLocations.test.ts index f6c7b49..47bf4a4 100644 --- a/tests/integration/inputLocations.test.ts +++ b/tests/integration/inputLocations.test.ts @@ -28,6 +28,27 @@ describe("input locations", () => { }); }); + it.each([ + { Block: GltfInputBlock, data: generateGltfJson(), format: "glTF" }, + { Block: StlInputBlock, data: generateStlData(), format: "STL" }, + { Block: ObjInputBlock, data: generateObjData(), format: "OBJ" }, + { Block: FbxInputBlock, data: generateFbxData(), format: "FBX" }, + ])("loads $format through relative HTTP redirect chains", async ({ Block, data }) => { + await withHttpInputsAsync( + { + start: { redirect: "/redirects/step" }, + "redirects/step": { redirect: "next" }, + "redirects/next": { redirect: "../assets/model" }, + "assets/model": data, + }, + async (rootUrl) => { + const document = await new NodeAsset({ name: "redirected-input", outputBlock: new Block({ input: `${rootUrl}start` }) }).executeAsync(); + + expect(document.getRoot().listMeshes()).toHaveLength(1); + } + ); + }); + it.each([GltfInputBlock, StlInputBlock, ObjInputBlock, FbxInputBlock])("rejects missing local and HTTP inputs with %s", async (Block) => { await withInputFilesAsync({}, async (directory) => { await expect(new NodeAsset({ name: "missing-file", outputBlock: new Block({ input: join(directory, "missing") }) }).executeAsync()).rejects.toThrow(); diff --git a/tests/integration/stlInput.test.ts b/tests/integration/stlInput.test.ts index 75b5b99..aa685fd 100644 --- a/tests/integration/stlInput.test.ts +++ b/tests/integration/stlInput.test.ts @@ -8,12 +8,16 @@ import { withHttpInputsAsync, withInputFilesAsync } from "../helpers/input"; import { generateBinaryStlData, generateStlData } from "../helpers/stl"; describe("STL input", () => { - it("loads a local binary STL file", async () => { - await withInputFilesAsync({ "triangle.stl": generateBinaryStlData() }, async (directory) => { - const { json } = await parseGlbAsync(await roundTripAsync(new StlInputBlock({ input: join(directory, "triangle.stl") }))); - - expect(json.meshes).toHaveLength(1); - expect(json.meshes?.[0]?.primitives).toHaveLength(1); + it.each([1, 20_000])("loads a local binary STL containing %i triangles", async (triangleCount) => { + await withInputFilesAsync({ "triangles.stl": generateBinaryStlData(triangleCount) }, async (directory) => { + const document = await new NodeAsset({ + name: "local-binary-stl", + outputBlock: new StlInputBlock({ input: join(directory, "triangles.stl") }), + }).executeAsync(); + + const meshes = document.getRoot().listMeshes(); + expect(meshes).toHaveLength(1); + expect(meshes[0]?.listPrimitives()[0]?.getAttribute("POSITION")?.getCount()).toBe(triangleCount * 3); }); }); From 9330147eba513745dd0ceef94fd71507d43e6fb8 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:10:40 -0400 Subject: [PATCH 3/7] perf: defer Node transport loading Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/babylon-loader-gaps.md | 13 +++++++++++++ packages/core/src/resources/nullEngineResource.ts | 7 +++++-- tests/bundle/browserConsumerBundle.test.ts | 11 +++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/core/docs/babylon-loader-gaps.md b/packages/core/docs/babylon-loader-gaps.md index dd07068..b2fe86f 100644 --- a/packages/core/docs/babylon-loader-gaps.md +++ b/packages/core/docs/babylon-loader-gaps.md @@ -30,6 +30,19 @@ deleted rather than preserved in a compatibility module. `textures/color.png`; the image resolves under `materials/textures/`, including after an MTL redirect. Absolute dependency URIs should remain absolute. +## FBX texture filename escaping + +- Upstream target: `packages/dev/loaders/src/FBX/fbxFileLoader.pure.ts`, + `_getExternalTextureUrls`. +- Missing behavior: external texture filenames are concatenated with the root + URL. A literal `?` in a filename becomes a query delimiter rather than part of + the filename. +- Current limitation: an FBX reference to `diffuse?1.png` must use + `diffuse%3F1.png`. The generic file transport retains standard URL semantics; + it does not reinterpret query strings as filename characters. +- Completion case: an FBX referencing a local `textures/diffuse?1.png` preserves + the image without requiring an escaped reference in the FBX. + ## Encoded-image MIME handling in headless export - Upstream targets: `packages/dev/serializers/src/exportImageUtils.ts`, diff --git a/packages/core/src/resources/nullEngineResource.ts b/packages/core/src/resources/nullEngineResource.ts index b729e05..2f0d6f8 100644 --- a/packages/core/src/resources/nullEngineResource.ts +++ b/packages/core/src/resources/nullEngineResource.ts @@ -1,11 +1,14 @@ import type { NullEngine as BabylonNullEngine } from "@babylonjs/core/Engines/nullEngine.js"; -import { initializeNodeXmlHttpRequestAsync } from "../helpers/nodeXmlHttpRequest"; +import { isNodeRuntime } from "../helpers/isNodeRuntime"; import type { Resource } from "./resource"; export const NullEngineResource = { name: "NullEngine", create: async () => { - await initializeNodeXmlHttpRequestAsync(); + if (isNodeRuntime()) { + const { initializeNodeXmlHttpRequestAsync } = await import("../helpers/nodeXmlHttpRequest"); + await initializeNodeXmlHttpRequestAsync(); + } const { NullEngine } = await import("@babylonjs/core/Engines/nullEngine.js"); return new NullEngine(); }, diff --git a/tests/bundle/browserConsumerBundle.test.ts b/tests/bundle/browserConsumerBundle.test.ts index 98c6fb8..37bd461 100644 --- a/tests/bundle/browserConsumerBundle.test.ts +++ b/tests/bundle/browserConsumerBundle.test.ts @@ -42,6 +42,17 @@ describe("browser consumer bundle", () => { expect(fileNames.some((fileName) => /draco_encoder.*\.wasm$/.test(fileName))).toBe(true); expect(fileNames.some((fileName) => /basis_encoder.*\.js$/.test(fileName))).toBe(true); expect(fileNames.some((fileName) => /basis_encoder.*\.wasm$/.test(fileName))).toBe(true); + const chunks = new Map(result.output.filter((entry) => entry.type === "chunk").map((chunk) => [chunk.fileName, chunk])); + const initialChunks = new Set([...chunks.values()].filter((chunk) => chunk.isEntry)); + for (const chunk of initialChunks) { + expect(chunk.code.includes("xhr2")).toBe(false); + for (const importedFile of chunk.imports) { + const importedChunk = chunks.get(importedFile); + if (importedChunk) { + initialChunks.add(importedChunk); + } + } + } }, 120_000); it("tree-shakes KTX2 decoder code from an encoder-only published consumer", async () => { From 0a3579e8028fb677cf2a81c2569d588c5b204acc Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:18:38 -0400 Subject: [PATCH 4/7] docs: remove loader gap guide Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/babylon-loader-gaps.md | 85 ----------------------- packages/core/docs/usage.md | 2 - 2 files changed, 87 deletions(-) delete mode 100644 packages/core/docs/babylon-loader-gaps.md diff --git a/packages/core/docs/babylon-loader-gaps.md b/packages/core/docs/babylon-loader-gaps.md deleted file mode 100644 index b2fe86f..0000000 --- a/packages/core/docs/babylon-loader-gaps.md +++ /dev/null @@ -1,85 +0,0 @@ -# Babylon loader gaps - -This is the upstream follow-up list for the standard-loader refactor, based on -Babylon 9.21.2. Node-Assets no longer patches these behaviors. The old OBJ/MTL -preparation, texture placeholders, rebinding, and image conversion have been -deleted rather than preserved in a compatibility module. - -## Dependency roots after redirects - -- Upstream target: `packages/dev/core/src/Loading/sceneLoader.ts`, - `appendSceneCoreAsync` / `LoadSceneAsync`. -- Missing behavior: `loadDataAsync` returns `responseURL`, but the append path - discards it. Relative dependencies keep the originally requested directory. - The import-mesh path has a `rewriteRootURL` hook; the append path does not use it. -- Previous local workaround: fetch the main file first and pass a root derived - from the final response URL. -- Completion case: an FBX or OBJ redirected into another directory loads its - relative dependencies from the final location through `LoadSceneAsync`. - -## OBJ material-library and texture locations - -- Upstream targets: `packages/dev/loaders/src/OBJ/objFileLoader.pure.ts`, - `_loadMTL` / `_parseSolidAsync`, and `packages/dev/loaders/src/OBJ/mtlFileLoader.ts`, - `_GetTexture`. -- Missing behavior: the MTL parser receives the OBJ root, not the MTL's own - location. Dependency paths are concatenated with that root rather than - consistently resolved as URIs. -- Previous local workaround: fetch and rewrite the MTL and its texture references. -- Completion case: `scene.obj` references `materials/scene.mtl`, which references - `textures/color.png`; the image resolves under `materials/textures/`, including - after an MTL redirect. Absolute dependency URIs should remain absolute. - -## FBX texture filename escaping - -- Upstream target: `packages/dev/loaders/src/FBX/fbxFileLoader.pure.ts`, - `_getExternalTextureUrls`. -- Missing behavior: external texture filenames are concatenated with the root - URL. A literal `?` in a filename becomes a query delimiter rather than part of - the filename. -- Current limitation: an FBX reference to `diffuse?1.png` must use - `diffuse%3F1.png`. The generic file transport retains standard URL semantics; - it does not reinterpret query strings as filename characters. -- Completion case: an FBX referencing a local `textures/diffuse?1.png` preserves - the image without requiring an escaped reference in the FBX. - -## Encoded-image MIME handling in headless export - -- Upstream targets: `packages/dev/serializers/src/exportImageUtils.ts`, - `GetCachedImageAsync`, and - `packages/dev/serializers/src/glTF/2.0/glTFMaterialExporter.ts`. -- Missing behavior: image retrieval does not preserve the response Content-Type - for the serializer's encoded-image path. An extensionless PNG can fall through - to pixel readback even though usable encoded bytes were downloaded. -- Previous local workaround: detect the MIME type and rebind a typed data URI. -- Completion case: an extensionless PNG served as `image/png` exports from - NullEngine without image decoding or GPU readback. - -## OBJ/MTL material-name parsing - -- Upstream targets: `packages/dev/loaders/src/OBJ/solidParser.ts` and - `packages/dev/loaders/src/OBJ/mtlFileLoader.ts`. -- Follow-up: normalize names consistently between `usemtl` and `newmtl`. - OBJ preprocessing and MTL parsing handle whitespace and comments differently. -- Previous local workaround: replace names with generated tokens and restore them - after loading. -- Completion case: supported material names are matched and preserved without - rewriting the source into synthetic identifiers. - -## Not Babylon workarounds - -`src/helpers/nodeXmlHttpRequest.ts` is permanent Node transport setup: xhr2 handles -HTTP(S), and the adapter adds asynchronous filesystem reads for models and their -sidecars. `src/helpers/inputLocation.ts` normalizes paths and file URLs. Neither -contains format parsing or texture handling. - -`src/helpers/xhr2Workarounds.ts` contains a transport dependency workaround, not a -Babylon patch. xhr2 0.2.1 does not resolve relative redirect locations against the -current request by default. The adapter updates its base URL after each parsed -request/redirect through xhr2's `_parseUrl` hook and `nodejsSet` configuration. -Remove this adapter when xhr2 resolves relative redirect chains itself. Following -HTTP redirects does not repair Babylon's separate dependency-root issue above. - -TGA/BMP/GIF-to-PNG conversion was also removed. That is an image-conversion -capability requiring a CPU encoding strategy, not something an XHR implementation -can provide. It is separate from preserving already-supported encoded images. diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index c0c116d..2f54bf1 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -36,8 +36,6 @@ OBJ texture paths are relative to the OBJ directory, even when its MTL is in a subdirectory; redirects do not rebase dependency paths. Headless export requires encoded images that Babylon's serializer can preserve, such as PNG and JPEG. There is no automatic TGA/BMP/GIF conversion or extensionless-image MIME repair. -The [Babylon loader gaps](babylon-loader-gaps.md) document lists the upstream -locations for these removed workarounds. STL, OBJ, and FBX also accept Babylon-supported data URIs. A data URI has no filesystem or HTTP base directory for relative dependencies. The glTF block From f06731148e046ec379cabe898d325681ec08dee4 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:36:32 -0400 Subject: [PATCH 5/7] docs: trim input location guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/usage.md | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 2f54bf1..e0340eb 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -18,28 +18,9 @@ const result = await asset.executeAsync(); # Input locations and dependencies -All four input blocks accept HTTP(S) URLs, local filesystem paths, and file URLs in Node. -Relative filesystem paths are relative to the working directory. The CLI still -accepts only glTF and GLB inputs. - -STL, OBJ, and FBX use Babylon's standard scene loaders and dependency resolution. -In Node, the library lazily installs an XMLHttpRequest implementation with -HTTP(S) and filesystem support, unless the host has already supplied one. A -host-supplied implementation must support the input locations being loaded. -Browser loading uses the browser's XMLHttpRequest; filesystem paths are Node-only. -HTTP redirects are followed, including relative redirect chains. This does not -change Babylon's dependency-root behavior described below. - -OBJ and FBX materials and textures are loaded by Babylon, not rewritten or -prefetched by the input blocks. Babylon's current limitations therefore apply: -OBJ texture paths are relative to the OBJ directory, even when its MTL is in a -subdirectory; redirects do not rebase dependency paths. Headless export requires -encoded images that Babylon's serializer can preserve, such as PNG and JPEG. -There is no automatic TGA/BMP/GIF conversion or extensionless-image MIME repair. - -STL, OBJ, and FBX also accept Babylon-supported data URIs. A data URI has no -filesystem or HTTP base directory for relative dependencies. The glTF block -continues to use PlatformIO and does not accept top-level data URIs in Node. +All input blocks accept HTTP(S) URLs and, in Node, filesystem paths and `file:` URLs. +Relative paths use the working directory. STL, OBJ, and FBX use Babylon's standard +dependency resolution. The CLI accepts only glTF/GLB. # Example: CLI run reports From 9d36a0686958a794adc517f601c4292e0d3f2c2c Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:42:18 -0400 Subject: [PATCH 6/7] docs: focus input guidance on sources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/blocks.md | 3 +-- packages/core/docs/usage.md | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index 677a302..fac0203 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -4,8 +4,7 @@ ## Inputs -See [input locations and dependencies](usage.md#input-locations-and-dependencies) -for the shared Node filesystem contract and Babylon dependency limitations. +See [input locations](usage.md#input-locations) for supported sources. - `FbxInputBlock` - Input: `string` HTTP(S) or data URL, or a Node filesystem path/file URL, pointing to an FBX file. diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index e0340eb..9fc2ae4 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -16,11 +16,10 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` -# Input locations and dependencies +# Input locations All input blocks accept HTTP(S) URLs and, in Node, filesystem paths and `file:` URLs. -Relative paths use the working directory. STL, OBJ, and FBX use Babylon's standard -dependency resolution. The CLI accepts only glTF/GLB. +Relative paths use the working directory. # Example: CLI run reports From e6dbbb3f7f07244aee85ea2b36b7e9b803fbb3fb Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:46:53 -0400 Subject: [PATCH 7/7] docs: remove redundant input link Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/blocks.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index fac0203..17bdabc 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -4,8 +4,6 @@ ## Inputs -See [input locations](usage.md#input-locations) for supported sources. - - `FbxInputBlock` - Input: `string` HTTP(S) or data URL, or a Node filesystem path/file URL, pointing to an FBX file. - Output: `Document`