Release 3D Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const path of ["/", "/deep/nested/3d/"]) {
|
||||
test("inspects and transforms locally at " + path, async ({ page }) => {
|
||||
const external: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().startsWith("http://127.0.0.1:4211"))
|
||||
external.push(request.url());
|
||||
});
|
||||
await page.goto(path);
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Inspect geometry without uploading it.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("4", { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Recalculate normals" }).click();
|
||||
await expect(page.getByText(/vertex normals recalculated/u)).toBeVisible();
|
||||
await expect(page.locator("canvas")).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps the installed application available offline", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async () => navigator.serviceWorker.ready);
|
||||
await page.reload();
|
||||
await context.setOffline(true);
|
||||
try {
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Inspect geometry without uploading it.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
describe("3D workbench", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("keeps inspection usable when WebGL is unavailable", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
expect(screen.getByText("4", { selector: "dd" })).toBeVisible();
|
||||
expect(await screen.findByText(/WebGL is unavailable/u)).toBeVisible();
|
||||
await user.clear(screen.getByLabelText("Uniform scale"));
|
||||
await user.type(screen.getByLabelText("Uniform scale"), "2");
|
||||
await user.click(screen.getByRole("button", { name: "Apply transform" }));
|
||||
expect(screen.getByText(/Transform baked/u)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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