import { describe, expect, it } from "vitest"; import { decodeToolcraftModelDocument } 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, OBJ_STATIC_GEOMETRY_FIXTURE, transferForStaticModelBytes, } from "../test-fixtures/model-fixtures"; import { createObjAppearanceTransfer } from "../test-fixtures/obj-appearance-fixtures"; import { createToolcraftModelWorkerRuntime } from "../worker/model-import-worker-runtime"; import type { ToolcraftModelWorkerResponse } from "../worker/model-import-worker-protocol"; import { createToolcraftModelFormatAdapterRegistry } from "./model-format-adapter"; import { TOOLCRAFT_OBJ_ADAPTER_VERSION, toolcraftObjModelFormatAdapter, } from "./obj-model-format-adapter"; import { TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY } from "./production-model-format-registry"; const encoder = new TextEncoder(); function context(bytes = OBJ_STATIC_GEOMETRY_FIXTURE) { return contextFor( transferForStaticModelBytes(bytes, "models/scene.obj", "model/obj"), ); } describe("OBJ model format adapter", () => { it("is an immutable worker-capable production adapter", () => { expect(toolcraftObjModelFormatAdapter).toMatchObject({ adapterVersion: TOOLCRAFT_OBJ_ADAPTER_VERSION, format: "obj", rootExtensions: [".obj"], workerCapable: true, }); expect(Object.isFrozen(toolcraftObjModelFormatAdapter)).toBe(true); expect( createToolcraftModelFormatAdapterRegistry([ toolcraftObjModelFormatAdapter, ]).adapters, ).toEqual([toolcraftObjModelFormatAdapter]); const productionFormats = TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY.adapters.map( ({ format }) => format, ); expect(productionFormats).toEqual([ "glb", "gltf", "fbx", "obj", "stl", "ply", ]); }); it("preserves OBJ object hierarchy, separate geometry, and source coordinates", async () => { const decodeContext = context(); expect(decodeContext.bundle.rootPath).toBe("models/scene.obj"); expect(decodeContext.bundle.sourceFiles[0]!.path).toBe("models/scene.obj"); expect(decodeContext.bundle.sourceFiles[0]!.bytes.byteLength).toBeGreaterThan(0); const result = await toolcraftObjModelFormatAdapter.decode(decodeContext); expect(validateToolcraftModelDocument(result.document)).toEqual({ ok: true }); expect(result.document.rootNodeIds).toEqual(["node:0"]); expect(result.document.nodes.map(({ id, name, primitiveIds }) => ({ id, name, primitiveIds }))).toEqual([ { id: "node:0", name: "", primitiveIds: [] }, { id: "node:1", name: "First", primitiveIds: ["primitive:0"] }, { id: "node:2", name: "Second", primitiveIds: ["primitive:1"] }, ]); expect(result.document.primitives[0]!.positions).toEqual( new Float32Array([10, 20, 30, 12, 20, 30, 10, 23, 30]), ); expect(result.document.primitives[1]!.positions).toEqual( new Float32Array([-4, -2, 1, -2, -2, 1, -4, -1, 1]), ); expect(result.document.bounds).toEqual({ max: [12, 23, 30], min: [-4, -2, 1], }); expect(result.document.provenance).toEqual({ adapterVersion: TOOLCRAFT_OBJ_ADAPTER_VERSION, canonicalSchemaVersion: 2, operations: ["sequential-indexing"], sourceFormat: "obj", }); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); expect(result.document.materials).toEqual([]); expect(result.document.textures).toEqual([]); expect(result.diagnostics.map(({ code }) => code)).toEqual([ "missing-material-library", "missing-material-definition", "missing-material-definition", ]); }); it("preserves MTL material groups, UVs, and local texture resources", async () => { const result = await toolcraftObjModelFormatAdapter.decode( contextFor(createObjAppearanceTransfer()), ); 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.document.nodes.map(({ name, primitiveIds }) => ({ name, primitiveIds, }))).toEqual([ { name: "", primitiveIds: [] }, { name: "Painted", primitiveIds: ["primitive:0", "primitive:1"] }, ]); expect(result.document.primitives).toHaveLength(2); expect(result.document.primitives.map(({ materialId }) => materialId)).toEqual([ "material:0", "material:1", ]); expect(result.document.primitives[0]!.textureCoordinates?.[0]?.values).toEqual( new Float32Array([0, 0, 1, 0, 0, 1]), ); expect(result.document.primitives[1]!.positions).toEqual( new Float32Array([2, 0, 0, 3, 0, 0, 2, 1, 0]), ); expect(result.document.materials[0]).toMatchObject({ alphaMode: "blend", baseColor: [0.2, 0.3, 0.4], emissive: [0.1, 0.2, 0.3], normalTexture: { scale: 0.4 }, opacity: 0.75, roughness: 0.1, }); expect(result.document.materials[1]).toMatchObject({ baseColor: [0.9, 0.8, 0.7], opacity: 0.75, roughness: 1, }); expect(result.document.materials[1]!.baseColorTexture).toBeUndefined(); expect(result.document.textures).toHaveLength(2); expect(result.appearanceResources).toHaveLength(2); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "missing-appearance-resource", severity: "warning", })); }); it.each([ ["empty", encoder.encode("# no geometry\n")], ["non-finite", encoder.encode("v 0 0 0\nv 1 0 0\nv NaN 1 0\nf 1 2 3\n")], ["malformed face", encoder.encode("v 0 0 0\nv 1 0 0\nf 1 2 9\n")], ])("rejects %s OBJ structures", async (_name, bytes) => { await expect(toolcraftObjModelFormatAdapter.decode(context(bytes))).rejects.toMatchObject({ category: "geometry", }); }); it("records deterministic polygon triangulation and sequential indexing", async () => { const result = await toolcraftObjModelFormatAdapter.decode( context( encoder.encode( "v 0 0 0\nv 2 0 0\nv 2 2 0\nv 0 2 0\nf 1 2 3 4\n", ), ), ); expect(result.document.primitives[0]!.indices).toEqual( new Uint32Array([0, 1, 2, 3, 4, 5]), ); expect(result.document.provenance.operations).toEqual([ "deterministic-triangulation", "sequential-indexing", ]); }); it("enforces cancellation and canonical limits", async () => { const aborted = new AbortController(); aborted.abort(); await expect( toolcraftObjModelFormatAdapter.decode( contextFor(transferForStaticModelBytes(OBJ_STATIC_GEOMETRY_FIXTURE, "scene.obj"), { signal: aborted.signal, }), ), ).rejects.toMatchObject({ name: "AbortError" }); await expect( toolcraftObjModelFormatAdapter.decode({ ...context(), limits: { ...context().limits, maxPrimitives: 1 }, }), ).rejects.toMatchObject({ category: "resource-limit", code: "max-primitives-exceeded", }); await expect( toolcraftObjModelFormatAdapter.decode({ ...context(), limits: { ...context().limits, maxSourceBytes: OBJ_STATIC_GEOMETRY_FIXTURE.byteLength - 1, }, }), ).rejects.toMatchObject({ category: "resource-limit", code: "source-byte-limit-exceeded", }); }); it("decodes through the existing worker runtime when explicitly injected", async () => { const sourceCopy = new Uint8Array(OBJ_STATIC_GEOMETRY_FIXTURE.byteLength); sourceCopy.set(OBJ_STATIC_GEOMETRY_FIXTURE); const bytes = sourceCopy.buffer; const responses: ToolcraftModelWorkerResponse[] = []; const runtime = createToolcraftModelWorkerRuntime({ adapters: [toolcraftObjModelFormatAdapter], geometryDecoderVersions: { obj: "three@0.185.1" }, post: (response) => responses.push(response), }); await runtime.handleMessage({ generation: 1, jobId: "obj-worker-fixture", kind: "decode-and-analyze", payload: { adapterVersion: TOOLCRAFT_OBJ_ADAPTER_VERSION, bundle: { aggregateDigest: "fixture-obj-worker", rootPath: "scene.obj", sourceFiles: [{ bytes, contentDigest: digestModelSourceBytes(new Uint8Array(bytes)), mimeType: "model/obj", path: "scene.obj", }], }, format: "obj", 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 worker result."); expect( decodeToolcraftModelDocument( new Uint8Array(terminal.result.canonicalDocument), TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, ).provenance.sourceFormat, ).toBe("obj"); runtime.dispose(); }); });