diff --git a/docs/usage.md b/docs/usage.md index 89e704b..538a07a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,6 +24,11 @@ Named by noun. - Output: output (BabylonScene) - Resources: Babylon glTF loader - Behavior: Uses the Babylon scene loader to load a glTF using NullEngine. +- `ObjInputBlock` + - Input: `string` which is a URL (HTTPS or data) that points to an OBJ file. + - Output: output (BabylonScene) + - Resources: Babylon OBJ loader + - Behavior: Uses the Babylon scene loader to load an OBJ using NullEngine. For HTTP(S) OBJ URLs, referenced MTL files and supported relative textures are fetched automatically and embedded before loading. Data-URI OBJ inputs must be self-contained; relative MTL or texture references require an HTTP(S) base URL. - `StlInputBlock` - Input: `string` which is a URL (HTTPS or data) that points to an STL file. - Output: output (BabylonScene) diff --git a/src/blocks/gltfInputBlock.ts b/src/blocks/gltfInputBlock.ts index 5748fd3..57556f7 100644 --- a/src/blocks/gltfInputBlock.ts +++ b/src/blocks/gltfInputBlock.ts @@ -2,7 +2,7 @@ import { Block, type BlockOptions } from "../block/block"; import { defineBlock } from "../block/blockDefinition"; import { BabylonSceneType, UrlType } from "../block/connectionPointType"; import { NullEngineResource } from "../resources/nullEngineResource"; -import { fetchOrThrowAsync, isHttpUrl, loadSceneWithPluginAsync, toBase64 } from "../helpers/loadSceneWithPlugin"; +import { fetchAsDataUriAsync, fetchOrThrowAsync, isHttpUrl, loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ type: "input.gltf", @@ -28,7 +28,8 @@ const GltfInputBlockDefinition = /* @__PURE__ */ defineBlock({ name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", pluginOptions: { gltf: { - preprocessUrlAsync: (dependencyUrl) => fetchAsDataUriAsync(dependencyUrl, abortController.signal), + preprocessUrlAsync: (dependencyUrl) => + isHttpUrl(dependencyUrl) ? fetchAsDataUriAsync(dependencyUrl, abortController.signal) : Promise.resolve(dependencyUrl), }, }, }); @@ -105,14 +106,3 @@ function isGltfJson(json: string): boolean { return false; } } - -async function fetchAsDataUriAsync(url: string, signal: AbortSignal): Promise { - if (!isHttpUrl(url)) { - return url; - } - - const response = await fetchOrThrowAsync(url, signal); - const contentType = response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream"; - const data = new Uint8Array(await response.arrayBuffer()); - return `data:${contentType};base64,${toBase64(data)}`; -} diff --git a/src/blocks/objInputBlock.ts b/src/blocks/objInputBlock.ts new file mode 100644 index 0000000..c46ac9e --- /dev/null +++ b/src/blocks/objInputBlock.ts @@ -0,0 +1,454 @@ +import type { BaseTexture } from "@babylonjs/core/Materials/Textures/baseTexture.js"; +import type { Scene as BabylonScene } from "@babylonjs/core/scene.js"; + +import { Block, type BlockOptions } from "../block/block"; +import { defineBlock } from "../block/blockDefinition"; +import { BabylonSceneType, UrlType } from "../block/connectionPointType"; +import { createDataUri, fetchOrThrowAsync, isHttpUrl, loadSceneWithPluginAsync } from "../helpers/loadSceneWithPlugin"; +import { NullEngineResource } from "../resources/nullEngineResource"; + +const MaximumConcurrentTextureFetches = 8; + +const ObjInputBlockDefinition = /* @__PURE__ */ defineBlock({ + type: "input.obj", + input: UrlType, + output: BabylonSceneType, + resources: { + engine: NullEngineResource, + }, + runAsync: async (url, _config, { engine }) => { + const pluginPromise = import("@babylonjs/loaders/OBJ/index.js"); + if (!isHttpUrl(url)) { + return loadSceneWithPluginAsync(url, engine, () => pluginPromise, createObjLoadOptions()); + } + + const abortController = new AbortController(); + try { + const objResponse = await fetchOrThrowAsync(url, abortController.signal); + const resolvedObjUrl = objResponse.url || url; + const obj = await objResponse.text(); + const references = analyzeObjReferences(obj); + + let source = obj; + let textureAssets = new Map(); + let materialTokens = new Map(); + if (references.mtl?.value) { + const mtlUrl = resolveDependencyUrl(references.mtl.value, resolvedObjUrl, "MTL"); + const mtlResponse = await fetchOrThrowAsync(mtlUrl, abortController.signal); + const resolvedMtlUrl = mtlResponse.url || mtlUrl; + const mtl = await mtlResponse.text(); + const rewritten = await rewriteMtlAsync(mtl, resolvedMtlUrl, abortController.signal); + textureAssets = rewritten.textureAssets; + materialTokens = rewritten.materialTokens; + source = rewriteObjReferences(obj, references, createTextDataUri(rewritten.source), materialTokens, resolvedObjUrl); + } else if (references.mtl) { + throw new Error(`Invalid OBJ file "${resolvedObjUrl}": mtllib has no material library path.`); + } + + const scene = await loadSceneWithPluginAsync(createDirectTextSource(source), engine, () => pluginPromise, { + ...createObjLoadOptions(), + name: new URL(resolvedObjUrl).pathname.split("/").pop() ?? "", + }); + await attachTextureDataAsync(scene, textureAssets); + restoreMaterialNames(scene, materialTokens); + return scene; + } finally { + abortController.abort(); + } + }, +}); + +/** Loads an OBJ URL and its HTTP(S) MTL and texture dependencies into a Babylon.js scene. */ +export class ObjInputBlock extends Block { + public constructor(options?: BlockOptions) { + super(ObjInputBlockDefinition, options); + } +} + +function createObjLoadOptions() { + return { + pluginExtension: ".obj", + pluginOptions: { + obj: { + materialLoadingFailsSilently: false, + }, + }, + } as const; +} + +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}` }); + + let rewritten = obj; + for (const replacement of replacements.sort((left, right) => right.start - left.start)) { + rewritten = `${rewritten.slice(0, replacement.start)}${replacement.replacement}${rewritten.slice(replacement.end)}`; + } + return rewritten; +} + +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/src/helpers/loadSceneWithPlugin.ts b/src/helpers/loadSceneWithPlugin.ts index a52244b..5de681d 100644 --- a/src/helpers/loadSceneWithPlugin.ts +++ b/src/helpers/loadSceneWithPlugin.ts @@ -16,14 +16,11 @@ export async function loadSingleFileSceneWithPluginAsync(url: string, engine: Ab const abortController = new AbortController(); try { - const response = await fetchOrThrowAsync(url, abortController.signal); - const resolvedUrl = response.url || url; - const contentType = response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream"; - const source = `data:${contentType};base64,${toBase64(new Uint8Array(await response.arrayBuffer()))}`; - return await loadSceneWithPluginAsync(source, engine, loadPluginAsync, { - rootUrl: new URL(".", resolvedUrl).href, + const fetched = await fetchAsDataUriWithUrlAsync(url, abortController.signal); + return await loadSceneWithPluginAsync(fetched.dataUri, engine, loadPluginAsync, { + rootUrl: new URL(".", fetched.url).href, pluginExtension, - name: new URL(resolvedUrl).pathname.split("/").pop() ?? "", + name: new URL(fetched.url).pathname.split("/").pop() ?? "", }); } finally { abortController.abort(); @@ -43,6 +40,21 @@ export async function fetchOrThrowAsync(url: string, signal: AbortSignal): Promi return response; } +export async function fetchAsDataUriAsync(url: string, signal: AbortSignal): Promise { + return (await fetchAsDataUriWithUrlAsync(url, signal)).dataUri; +} + +export async function fetchAsDataUriWithUrlAsync(url: string, signal: AbortSignal): Promise<{ readonly dataUri: string; readonly url: string }> { + const response = await fetchOrThrowAsync(url, signal); + const contentType = response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream"; + const dataUri = createDataUri(new Uint8Array(await response.arrayBuffer()), contentType); + return { dataUri, url: response.url || url }; +} + +export function createDataUri(data: Uint8Array, contentType: string): string { + return `data:${contentType};base64,${toBase64(data)}`; +} + export function toBase64(data: Uint8Array): string { if (typeof Buffer === "function") { return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); diff --git a/src/index.ts b/src/index.ts index 0c78040..af1d651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { DracoEncoderBlock } from "./blocks/dracoEncoderBlock"; export { FbxInputBlock } from "./blocks/fbxInputBlock"; export { GltfInputBlock } from "./blocks/gltfInputBlock"; export { GltfOutputBlock, type GltfOutputBlockOptions } from "./blocks/gltfOutputBlock"; +export { ObjInputBlock } from "./blocks/objInputBlock"; export { StlInputBlock } from "./blocks/stlInputBlock"; export { NodeAsset } from "./nodeAsset/nodeAsset"; export { NodeAssetContext } from "./nodeAsset/nodeAssetContext"; diff --git a/tests/helpers/glb.ts b/tests/helpers/glb.ts index c5301f4..98b2904 100644 --- a/tests/helpers/glb.ts +++ b/tests/helpers/glb.ts @@ -6,8 +6,11 @@ export interface GlbJson { readonly bufferViews?: ReadonlyArray<{ readonly byteLength: number; readonly byteOffset?: number }>; readonly extensionsRequired?: readonly string[]; readonly extensionsUsed?: readonly string[]; - readonly images?: ReadonlyArray<{ readonly bufferView?: number; readonly mimeType?: string }>; + readonly images?: ReadonlyArray<{ readonly bufferView?: number; readonly mimeType?: string; readonly name?: string }>; readonly materials?: ReadonlyArray<{ + readonly normalTexture?: { + readonly index?: number; + }; readonly pbrMetallicRoughness?: { readonly baseColorTexture?: { readonly extensions?: { diff --git a/tests/helpers/obj.ts b/tests/helpers/obj.ts new file mode 100644 index 0000000..921d322 --- /dev/null +++ b/tests/helpers/obj.ts @@ -0,0 +1,59 @@ +const PNG_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVQImWP4z8DwH4QZYAwAR8oH+Xm0fdIAAAAASUVORK5CYII="; + +export function generateObjData(): string { + return `o Triangle +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +vn 0 0 1 +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 +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +vn 0 0 1 +usemtl ${materialName} +f 1/1/1 2/2/1 3/3/1`; +} + +export function generateMtlData(texturePath = "textures/diffuse.png", materialName = "Textured"): string { + return `newmtl ${materialName} +Kd 1 1 1 +MAP_KD ${texturePath} +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/integration/objInput.test.ts b/tests/integration/objInput.test.ts new file mode 100644 index 0000000..87deee6 --- /dev/null +++ b/tests/integration/objInput.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GltfOutputBlock, NodeAsset, ObjInputBlock } from "../../src/index"; +import { parseGlbAsync } from "../helpers/glb"; +import { generateMtlData, generateObjDataUri, 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() }))); + + expect(json.meshes).toHaveLength(1); + expect(json.meshes?.[0]?.primitives).toHaveLength(1); + }); + + 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" }))); + + expect(json.meshes).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + 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); + + 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(); + } + }); +}); + +async function roundTripAsync(source: ObjInputBlock): Promise { + const destination = new GltfOutputBlock(); + source.output.connectTo(destination.input); + return new NodeAsset({ name: "obj-roundtrip", outputBlock: destination }).executeAsync(); +}