import { afterEach, describe, expect, it, vi } from "vitest"; import { validateToolcraftModelDocument } from "../canonical/model-document-validation"; import { ASCII_PLY_FIXTURE, contextFor, createBinaryPlyFixture, transferForStaticModelBytes, } from "../test-fixtures/model-fixtures"; import { TOOLCRAFT_PLY_ADAPTER_VERSION, toolcraftPlyModelFormatAdapter, } from "./ply-model-format-adapter"; function context(bytes: Uint8Array) { return contextFor(transferForStaticModelBytes(bytes, "mesh.ply", "model/ply")); } afterEach(() => { vi.restoreAllMocks(); }); describe("PLY model format adapter", () => { it("is immutable and worker-capable", () => { expect(toolcraftPlyModelFormatAdapter).toMatchObject({ adapterVersion: TOOLCRAFT_PLY_ADAPTER_VERSION, format: "ply", rootExtensions: [".ply"], workerCapable: true, }); expect(Object.isFrozen(toolcraftPlyModelFormatAdapter)).toBe(true); }); it.each([ ["ASCII", ASCII_PLY_FIXTURE], ["binary little-endian", createBinaryPlyFixture("little")], ["binary big-endian", createBinaryPlyFixture("big")], ] as const)("decodes %s indexed PLY geometry", async (_encoding, bytes) => { const result = await toolcraftPlyModelFormatAdapter.decode(context(bytes)); expect(validateToolcraftModelDocument(result.document)).toEqual({ ok: true }); expect(result.document.primitives[0]!.positions).toEqual( new Float32Array([ 10, 20, 30, 12, 20, 30, 12, 23, 30, 10, 23, 30, ]), ); expect(result.document.primitives[0]!.indices).toEqual( new Uint32Array([0, 1, 2, 0, 2, 3]), ); expect(result.document.provenance).toEqual({ adapterVersion: TOOLCRAFT_PLY_ADAPTER_VERSION, canonicalSchemaVersion: 2, operations: [], sourceFormat: "ply", }); expect(result.document.version).toBe(2); if (result.document.version !== 2) throw new Error("Expected v2 document."); if (_encoding === "ASCII") { expect(result.document.primitives[0]!.colors).toEqual({ components: 3, values: new Float32Array([ 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, ]), }); expect(result.document.primitives[0]!.materialId).toBe("material:0"); expect(result.document.materials).toEqual([ expect.objectContaining({ id: "material:0", usesVertexColor: true }), ]); } else { expect(result.document.primitives[0]!.colors).toBeUndefined(); expect(result.document.primitives[0]!.materialId).toBeUndefined(); expect(result.document.materials).toEqual([]); } }); it("records deterministic quad triangulation", async () => { const bytes = new TextEncoder().encode(`ply format ascii 1.0 element vertex 4 property float x property float y property float z element face 1 property list uchar int vertex_indices end_header 0 0 0 2 0 0 2 2 0 0 2 0 4 0 1 2 3 `); const result = await toolcraftPlyModelFormatAdapter.decode(context(bytes)); expect(result.document.primitives[0]!.indices).toEqual( new Uint32Array([0, 1, 3, 1, 2, 3]), ); expect(result.document.provenance.operations).toEqual([ "deterministic-triangulation", ]); }); it("does not treat end_header text inside a comment as the header boundary", async () => { const bytes = new TextEncoder().encode( "ply\nformat ascii 1.0\ncomment end_header\nelement vertex 3\n", ); await expect( toolcraftPlyModelFormatAdapter.decode(context(bytes)), ).rejects.toMatchObject({ category: "format", code: "malformed-ply-header", }); }); it("rejects malformed headers, point clouds, non-finite data, and bad indices", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const encoder = new TextEncoder(); const sources = [ encoder.encode("not ply"), encoder.encode("ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 0\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n1 0 0\n0 1 0\n"), encoder.encode("ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n1 0 0\nnan 1 0\n3 0 1 2\n"), encoder.encode("ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9\n"), encoder.encode("ply\nformat ascii 1.0\nelement vertex 5\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n1 0 0\n1 1 0\n0 1 0\n-1 0 0\n5 0 1 2 3 4\n"), ]; for (const bytes of sources) { await expect( toolcraftPlyModelFormatAdapter.decode(context(bytes)), ).rejects.toMatchObject({ category: expect.stringMatching(/format|geometry/u), }); } }); it.each([ ["vertices", { maxVertices: 3 }, "max-vertices-exceeded"], ["triangles", { maxTriangles: 1 }, "max-triangles-exceeded"], ["decoded bytes", { maxDecodedBytes: 40 }, "decoded-byte-limit-exceeded"], ] as const)("enforces %s limits", async (_name, limit, code) => { await expect( toolcraftPlyModelFormatAdapter.decode({ ...context(ASCII_PLY_FIXTURE), limits: { ...context(ASCII_PLY_FIXTURE).limits, ...limit }, }), ).rejects.toMatchObject({ category: "resource-limit", code }); }); });