196 lines
5.8 KiB
TypeScript
196 lines
5.8 KiB
TypeScript
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;
|
|
}
|