import { describe, expect, it } from "vitest"; import { Color, SRGBColorSpace } from "three"; import { decodeToolcraftModelDocument, encodeToolcraftModelDocument, } from "../canonical/model-document-codec"; import { validateToolcraftModelDocument } from "../canonical/model-document-validation"; import { TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS } from "../model-import-limits"; import { digestModelSourceBytes } from "../model-source-digest"; import { contextFor } from "../test-fixtures/model-fixtures"; import type { ToolcraftModelWorkerResponse } from "../worker/model-import-worker-protocol"; import { createToolcraftModelWorkerRuntime } from "../worker/model-import-worker-runtime"; import { ASCII_FBX_FIXTURE, createBinaryFbxFixture, transferForFbx, } from "./fbx-model-format-adapter.test-fixtures"; import { ASCII_FBX_APPEARANCE_FIXTURE } from "./fbx-ascii-appearance-test-fixtures"; import { createBinaryFbxAppearanceFixture } from "./fbx-appearance-test-fixtures"; import { TOOLCRAFT_FBX_ADAPTER_VERSION, toolcraftFbxModelFormatAdapter, } from "./fbx-model-format-adapter"; import { createToolcraftModelFormatAdapterRegistry } from "./model-format-adapter"; import { TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY } from "./production-model-format-registry"; function context( bytes: Uint8Array = ASCII_FBX_FIXTURE, ) { return contextFor(transferForFbx(bytes)); } describe("FBX model format adapter", () => { it("is immutable, worker-capable, and advertised through the production manifest", () => { expect(toolcraftFbxModelFormatAdapter).toMatchObject({ adapterVersion: TOOLCRAFT_FBX_ADAPTER_VERSION, format: "fbx", rootExtensions: [".fbx"], workerCapable: true, }); expect(Object.isFrozen(toolcraftFbxModelFormatAdapter)).toBe(true); expect( createToolcraftModelFormatAdapterRegistry([ toolcraftFbxModelFormatAdapter, ]).adapters, ).toEqual([toolcraftFbxModelFormatAdapter]); expect( TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY.adapters.map( ({ format }) => format, ), ).toContain("fbx"); }); it.each([ ["ASCII", ASCII_FBX_FIXTURE], ["binary", createBinaryFbxFixture()], ] as const)("decodes production-shaped %s static geometry", async (_name, bytes) => { const result = await toolcraftFbxModelFormatAdapter.decode(context(bytes)); expect(validateToolcraftModelDocument(result.document)).toEqual({ ok: true }); expect(result.document.primitives).toHaveLength(1); expect(result.document.primitives[0]!.positions).toEqual( new Float32Array([10, 20, 30, 12, 20, 30, 10, 23, 30]), ); expect(result.document.primitives[0]!.indices).toEqual( new Uint32Array([0, 1, 2]), ); expect(result.document.nodes.some(({ primitiveIds }) => primitiveIds.length === 1)).toBe(true); expect(result.document.provenance).toEqual({ adapterVersion: TOOLCRAFT_FBX_ADAPTER_VERSION, canonicalSchemaVersion: 2, operations: ["sequential-indexing"], sourceFormat: "fbx", }); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials).toEqual([]); }); it("preserves static material factors, UVs, local images, and embedded images", async () => { const result = await toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(ASCII_FBX_APPEARANCE_FIXTURE, true)), ); expect(validateToolcraftModelDocument(result.document)).toEqual({ ok: true }); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.diagnostics).toEqual([]); const material = result.document.materials[0]!; const baseColor = new Color().setRGB(0.2, 0.3, 0.4, SRGBColorSpace); const emissive = new Color().setRGB(0.1, 0.2, 0.3, SRGBColorSpace); expect(material.baseColor).toEqual([ baseColor.r, baseColor.g, baseColor.b, ]); expect(material.emissive).toEqual([ emissive.r, emissive.g, emissive.b, ]); expect(material).toMatchObject({ alphaMode: "blend", baseColorTexture: { texCoord: 0 }, emissiveTexture: { texCoord: 0 }, metallic: 0, opacity: 0.75, roughness: 0.1, usesVertexColor: true, }); expect(result.document.primitives[0]!.materialId).toBe("material:0"); expect(result.document.primitives[0]!.textureCoordinates?.[0]?.values).toEqual( new Float32Array([0, 0, 1, 0, 0, 1]), ); expect(result.document.primitives[0]!.colors).toEqual({ components: 3, values: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), }); expect(result.document.textures).toHaveLength(2); expect(result.appearanceResources).toHaveLength(2); }); it("keeps valid FBX channels when a local image is missing", async () => { const result = await toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(ASCII_FBX_APPEARANCE_FIXTURE)), ); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials[0]!.baseColorTexture).toBeUndefined(); expect(result.document.materials[0]!.emissiveTexture).toBeDefined(); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "missing-appearance-resource", severity: "warning", })); }); it("blocks remote FBX images without discarding embedded channels", async () => { const remoteSource = new TextEncoder().encode( new TextDecoder().decode(ASCII_FBX_APPEARANCE_FIXTURE).replaceAll( "textures/diffuse.png", "https://example.com/diffuse.png", ), ); const result = await toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(remoteSource)), ); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials[0]!.baseColorTexture).toBeUndefined(); expect(result.document.materials[0]!.emissiveTexture).toBeDefined(); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "blocked-remote-appearance-resource", severity: "warning", })); expect(result.diagnostics.map(({ code }) => code)).toEqual([ "blocked-remote-appearance-resource", ]); }); it("resolves a unique packaged basename after FBX strips Windows paths", async () => { const windowsPathSource = new TextEncoder().encode( new TextDecoder().decode(ASCII_FBX_APPEARANCE_FIXTURE).replaceAll( "textures/diffuse.png", "textures\\diffuse.png", ), ); const result = await toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(windowsPathSource, true)), ); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials[0]!.baseColorTexture).toBeDefined(); expect(result.diagnostics).toEqual([]); }); it("preserves a bounded binary FBX embedded image", async () => { const result = await toolcraftFbxModelFormatAdapter.decode( context(createBinaryFbxAppearanceFixture()), ); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials[0]!.baseColorTexture).toEqual( expect.objectContaining({ texCoord: 0 }), ); expect(result.document.primitives[0]!.textureCoordinates?.[0]?.values).toEqual( new Float32Array([0, 0, 1, 0, 0, 1]), ); expect(result.document.textures).toHaveLength(1); expect(result.appearanceResources).toHaveLength(1); expect(result.diagnostics).toEqual([]); }); it("enforces canonical FBX appearance limits", async () => { await expect(toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(ASCII_FBX_APPEARANCE_FIXTURE, true), { limits: { maxTextureDimension: 1 }, }), )).rejects.toMatchObject({ category: "resource-limit", code: "max-texture-dimension-exceeded", }); }); it("produces byte-identical canonical output for repeated decodes", async () => { const first = await toolcraftFbxModelFormatAdapter.decode(context()); const second = await toolcraftFbxModelFormatAdapter.decode(context()); expect(encodeToolcraftModelDocument(first.document)).toEqual( encodeToolcraftModelDocument(second.document), ); }); it.each([ ["unknown bytes", new Uint8Array([1, 2, 3])], [ "unsupported ASCII version", new TextEncoder().encode("FBXVersion: 6100\nObjects: {\n}"), ], [ "appearance-only ASCII", new TextEncoder().encode( "FBXVersion: 7400\nVideo: 1, \"Video::Texture\", \"Clip\" {\n}\n", ), ], ["truncated binary", createBinaryFbxFixture().subarray(0, 80)], ] as const)("rejects malformed or unsupported %s", async (_name, bytes) => { await expect( toolcraftFbxModelFormatAdapter.decode(context(bytes)), ).rejects.toMatchObject({ category: expect.stringMatching(/format|geometry/u) }); }); it("enforces cancellation before Three parsing", async () => { const controller = new AbortController(); controller.abort(); await expect( toolcraftFbxModelFormatAdapter.decode( contextFor(transferForFbx(ASCII_FBX_FIXTURE), { signal: controller.signal, }), ), ).rejects.toMatchObject({ name: "AbortError" }); }); it.each([ ["vertices", { maxVertices: 2 }, "max-vertices-exceeded"], [ "source bytes", { maxSourceBytes: ASCII_FBX_FIXTURE.byteLength - 1 }, "source-byte-limit-exceeded", ], ["decoded bytes", { maxDecodedBytes: 80 }, "decoded-byte-limit-exceeded"], [ "worker memory", { maxEstimatedWorkerBytes: 1_000 }, "estimated-worker-memory-limit-exceeded", ], ] as const)("enforces %s limits", async (_name, limit, code) => { await expect( toolcraftFbxModelFormatAdapter.decode({ ...context(), limits: { ...context().limits, ...limit }, }), ).rejects.toMatchObject({ category: "resource-limit", code }); }); it("decodes through the worker runtime when explicitly injected", async () => { const source = new Uint8Array(ASCII_FBX_FIXTURE.byteLength); source.set(ASCII_FBX_FIXTURE); const bytes = source.buffer; const responses: ToolcraftModelWorkerResponse[] = []; const runtime = createToolcraftModelWorkerRuntime({ adapters: [toolcraftFbxModelFormatAdapter], geometryDecoderVersions: { fbx: "three@0.185.1" }, post: (response) => responses.push(response), }); await runtime.handleMessage({ generation: 1, jobId: "fbx-worker-fixture", kind: "decode-and-analyze", payload: { adapterVersion: TOOLCRAFT_FBX_ADAPTER_VERSION, bundle: { aggregateDigest: "fixture-fbx-worker", rootPath: "scene.fbx", sourceFiles: [{ bytes, contentDigest: digestModelSourceBytes(new Uint8Array(bytes)), mimeType: "application/octet-stream", path: "scene.fbx", }], }, format: "fbx", geometryDecoderVersion: "three@0.185.1", limits: TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, topologyProfile: "realtime-mesh", }, }); expect(responses.find(({ kind }) => kind === "error")).toBeUndefined(); expect(responses.some(({ kind }) => kind === "draft")).toBe(true); const terminal = responses.find(({ kind }) => kind === "result"); expect(terminal?.kind).toBe("result"); if (terminal?.kind !== "result") throw new Error("Missing FBX result."); expect( decodeToolcraftModelDocument( new Uint8Array(terminal.result.canonicalDocument), TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, ).provenance.sourceFormat, ).toBe("fbx"); runtime.dispose(); }); });