import { bytesToBase64 } from "@add-ideas/toolbox-helpers"; import { describe, expect, it } from "vitest"; import { exportBinaryStl, exportObj, modelStats, parseModel, transformModel, } from "../../src/core/model"; const encode = (value: string) => new TextEncoder().encode(value); describe("bounded 3D parsing", () => { it("triangulates OBJ polygons and resolves negative indices", () => { const model = parseModel( "quad.obj", encode( [ "o Quad", "v 0 0 0", "v 1 0 0", "v 1 1 0", "v 0 1 0", "f -4 -3 -2 -1", ].join("\n"), ), "OBJ", ); expect(model.indices).toHaveLength(6); expect(model.nodes).toEqual(["Quad"]); expect(modelStats(model)).toMatchObject({ triangles: 2, surfaceArea: 1, degenerateTriangles: 0, }); expect(exportObj(model)).toContain("f 1//1 2//2 3//3"); }); it("round-trips normalized geometry through binary STL", () => { const source = parseModel( "triangle.obj", encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"), "OBJ", ); const binary = exportBinaryStl(source); const parsed = parseModel("triangle.stl", binary, "STL"); expect(parsed.indices).toHaveLength(3); expect(modelStats(parsed).surfaceArea).toBeCloseTo(0.5); }); it("parses ASCII and binary little-endian PLY", () => { const ascii = parseModel( "triangle.ply", encode( [ "ply", "format ascii 1.0", "element vertex 3", "property float x", "property float y", "property float z", "element face 1", "property list uchar int vertex_indices", "end_header", "0 0 0", "1 0 0", "0 1 0", "3 0 1 2", "", ].join("\n"), ), "PLY", ); expect(ascii.indices).toEqual(Uint32Array.of(0, 1, 2)); const header = encode( [ "ply", "format binary_little_endian 1.0", "element vertex 3", "property float x", "property float y", "property float z", "element face 1", "property list uchar int vertex_indices", "end_header", "", ].join("\n"), ); const body = new Uint8Array(36 + 13); const view = new DataView(body.buffer); [0, 0, 0, 1, 0, 0, 0, 1, 0].forEach((value, index) => view.setFloat32(index * 4, value, true), ); body[36] = 3; view.setInt32(37, 0, true); view.setInt32(41, 1, true); view.setInt32(45, 2, true); const binary = new Uint8Array(header.length + body.length); binary.set(header); binary.set(body, header.length); expect(parseModel("triangle.ply", binary, "PLY").indices).toHaveLength(3); }); it("parses embedded glTF and GLB geometry without external resources", () => { const buffer = gltfBuffer(); const document = gltfDocument( "data:application/octet-stream;base64," + bytesToBase64(buffer), buffer.length, ); const model = parseModel( "triangle.gltf", encode(JSON.stringify(document)), "glTF", ); expect(model.indices).toEqual(Uint32Array.of(0, 1, 2)); expect(model.materials).toEqual(["Local"]); const glb = makeGlb(gltfDocument(undefined, buffer.length), buffer); expect(parseModel("triangle.glb", glb, "GLB").indices).toHaveLength(3); }); it("rejects remote glTF buffers and safely transforms normals/winding", () => { const remote = gltfDocument("https://example.invalid/model.bin", 42); expect(() => parseModel("remote.gltf", encode(JSON.stringify(remote)), "glTF"), ).toThrow(/External glTF buffer URLs/u); const model = parseModel( "triangle.obj", encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"), "OBJ", ); const transformed = transformModel(model, { translate: [1, 2, 3], rotate: [0, 0, 90], scale: -2, }); expect(modelStats(transformed).surfaceArea).toBeCloseTo(2); expect(transformed.indices).toEqual(Uint32Array.of(0, 2, 1)); }); }); function gltfBuffer(): Uint8Array { const output = new Uint8Array(44); const view = new DataView(output.buffer); [0, 0, 0, 1, 0, 0, 0, 1, 0].forEach((value, index) => view.setFloat32(index * 4, value, true), ); view.setUint16(36, 0, true); view.setUint16(38, 1, true); view.setUint16(40, 2, true); return output; } function gltfDocument(uri: string | undefined, length: number) { return { asset: { version: "2.0" }, buffers: [{ ...(uri ? { uri } : {}), byteLength: length }], bufferViews: [ { buffer: 0, byteOffset: 0, byteLength: 36 }, { buffer: 0, byteOffset: 36, byteLength: 6 }, ], accessors: [ { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, ], materials: [{ name: "Local" }], meshes: [ { primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }], }, ], nodes: [{ name: "Triangle", mesh: 0 }], }; } function makeGlb(document: object, binary: Uint8Array): Uint8Array { const rawJson = encode(JSON.stringify(document)); const jsonLength = Math.ceil(rawJson.length / 4) * 4; const binaryLength = Math.ceil(binary.length / 4) * 4; const output = new Uint8Array(12 + 8 + jsonLength + 8 + binaryLength); const view = new DataView(output.buffer); view.setUint32(0, 0x46546c67, true); view.setUint32(4, 2, true); view.setUint32(8, output.length, true); view.setUint32(12, jsonLength, true); view.setUint32(16, 0x4e4f534a, true); output.fill(0x20, 20, 20 + jsonLength); output.set(rawJson, 20); const binaryHeader = 20 + jsonLength; view.setUint32(binaryHeader, binaryLength, true); view.setUint32(binaryHeader + 4, 0x004e4942, true); output.set(binary, binaryHeader + 8); return output; }