Release 3D Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user