Files
FreeCADWasmDev/tools/mesh-business-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

328 lines
21 KiB
JavaScript

import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { setTimeout as yieldToWorkers } from "node:timers/promises";
import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const capabilityId = "Mesh:mesh.createRecompute";
const persistenceId = "Mesh:mesh.saveReopen";
const ioId = "Mesh:mesh.importExport";
const threeMfIds = ["business-format:Mesh:export:.3mf", "business-format:Mesh:import:.3mf"];
const amfExportId = "business-format:Mesh:export:.amf";
const astImportId = "business-format:Mesh:import:.ast";
const objImportId = "business-format:Mesh:import:.obj";
const offImportId = "business-format:Mesh:import:.off";
const plyIds = ["business-format:Mesh:export:.ply", "business-format:Mesh:import:.ply"];
const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify([capabilityId]));
const results = [];
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-mesh-business-"));
let exitCode = 0;
const report = { schema: "freecad-web-business-cases/v1", results };
try {
assert.ok(Array.isArray(ids) && ids.length > 0, "FREECAD_BUSINESS_IDS must be a nonempty array");
assert.equal(new Set(ids).size, ids.length, "FREECAD_BUSINESS_IDS must not contain duplicates");
for (const id of ids) assert.ok([capabilityId, persistenceId, ioId, ...threeMfIds, amfExportId, astImportId, objImportId, offImportId, ...plyIds].includes(id), `Unsupported Mesh business ID: ${id}`);
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), ["--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler", "--strict", "--skipLibCheck", "--outDir", out, path.join(root, "web/src/mesh-facade.ts"), path.join(root, "web/src/mesh-persistence-facade.ts"), path.join(root, "web/src/mesh-io-facade.ts")], { stdio: "pipe" });
const facade = await import(pathToFileURL(path.join(out, "mesh-facade.js")));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
const persistence = await import(pathToFileURL(path.join(out, "mesh-persistence-facade.js")));
const io = await import(pathToFileURL(path.join(out, "mesh-io-facade.js")));
const { kernel } = await loadWasmProbe(process.env.FREECAD_MESH_BUSINESS_BUILD ?? path.join(root, "freecad-source/build-web/configure/bin"), "FreeCADWebMeshRuntimeProbe");
const queue = new FacadeSerialQueue();
let threeMfCases;
let plyCases;
for (const id of ids) {
const cases = [];
try {
if (id === astImportId || id === objImportId || id === offImportId || plyIds.includes(id)) {
const format = plyIds.includes(id) ? "PLY" : id === offImportId ? "OFF" : id === objImportId ? "OBJ" : "AST";
if (format === "PLY" && plyCases) {
cases.push(...plyCases);
} else {
for (const [index, { vertices, area }] of [
{ vertices: [], area: null },
{ vertices: [[0,0,0],[1,1,1],[2,2,2]], area: null },
{ vertices: [[0,0,0],[2,0,0],[0,3,0]], area: 3 },
{ vertices: [[4,5,6],[4,5,10],[4,7,6]], area: 4 },
].entries()) {
await yieldToWorkers(0);
const input = { ...io.MESH_IO_FIXTURE, format, path: `/tmp/mesh-roundtrip-${index}.${format.toLowerCase()}`, vertices };
const entry = { input, assertions: [] };
cases.push(entry);
try {
const actual = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: io.MESH_IO_COMMAND, args: input })]));
entry.actual = actual;
if (area === null) {
assert.equal(actual.ok, false);
assert.equal(actual.code, "invalid_argument");
assert.match(actual.error, /vert|geometry|triangle|degenerate/i);
} else {
assert.equal(actual.ok, true, JSON.stringify(actual));
assert.equal(actual.format, format);
assert.equal(actual.path, input.path);
assert.equal(typeof actual.fileBase64, "string");
const bytes = Buffer.from(actual.fileBase64, "base64");
assert.equal(actual.exportBytes, bytes.length);
let artifactVertices;
if (format === "PLY") {
const lines = bytes.toString("utf8").trim().split(/\r?\n/).map(line => line.trim());
const end = lines.indexOf("end_header");
assert.ok(end > 0);
assert.deepEqual(lines.slice(0, end).filter(line => !line.startsWith("comment ")), [
"ply", "format ascii 1.0", "element vertex 3",
"property float32 x", "property float32 y", "property float32 z",
"element face 1", "property list uchar int vertex_index",
]);
const rows = lines.slice(end + 1).map(line => line.split(/\s+/).map(Number));
assert.equal(rows.length, 4);
const points = rows.slice(0, 3);
assert.ok(points.every(point => point.length === 3 && point.every(Number.isFinite)));
assert.equal(rows[3].length, 4);
assert.equal(rows[3][0], 3);
const indices = rows[3].slice(1);
assert.equal(new Set(indices).size, 3);
assert.ok(indices.every(index => Number.isInteger(index) && index >= 0 && index < points.length));
artifactVertices = indices.map(index => points[index]);
entry.artifact = { vertices: points, indices };
} else if (format === "OFF") {
const tokens = bytes.toString("utf8").replace(/#[^\r\n]*/g, "").trim().split(/\s+/);
assert.equal(tokens.shift(), "OFF");
const numbers = tokens.map(Number);
assert.ok(numbers.every(Number.isFinite));
assert.deepEqual(numbers.slice(0, 3), [3, 1, 0]);
assert.equal(numbers.length, 16);
const points = [numbers.slice(3, 6), numbers.slice(6, 9), numbers.slice(9, 12)];
assert.equal(numbers[12], 3);
const indices = numbers.slice(13);
assert.equal(new Set(indices).size, 3);
assert.ok(indices.every(index => Number.isInteger(index) && index >= 0 && index < points.length));
artifactVertices = indices.map(index => points[index]);
entry.artifact = { vertices: points, indices };
} else if (format === "OBJ") {
const lines = bytes.toString("utf8").split(/\r?\n/).map(line => line.trim().split(/\s+/));
const points = lines.filter(tokens => tokens[0] === "v").map(tokens => tokens.slice(1).map(Number));
const faces = lines.filter(tokens => tokens[0] === "f");
assert.equal(points.length, 3);
assert.ok(points.every(point => point.length === 3 && point.every(Number.isFinite)));
assert.equal(faces.length, 1);
const indices = faces[0].slice(1).map(token => Number(token.split("/")[0]));
assert.equal(indices.length, 3);
assert.equal(new Set(indices).size, 3);
assert.ok(indices.every(index => Number.isInteger(index) && index >= 1 && index <= points.length));
artifactVertices = indices.map(index => points[index - 1]);
entry.artifact = { vertices: points, indices };
} else {
assert.match(bytes.toString("utf8"), /^\s*solid\b/);
assert.match(bytes.toString("utf8"), /endsolid\b/);
artifactVertices = [...bytes.toString("utf8").matchAll(/\bvertex\s+(\S+)\s+(\S+)\s+(\S+)/g)].map(match => match.slice(1).map(Number));
}
const preservesWinding = points => points.length === 3 && [0,1,2].some(offset => points.every((point, i) => JSON.stringify(point) === JSON.stringify(vertices[(i + offset) % 3])));
assert.ok(preservesWinding(artifactVertices), "actual ASCII artifact preserves input geometry");
assert.ok(preservesWinding(actual.vertices), `native ${format} import preserves coordinates and winding`);
assert.equal(actual.importedFacetCount, 1);
assert.equal(actual.importedPointCount, 3);
assert.equal(actual.area, area);
assert.equal(actual.resourcesReleased, true);
const facadeResult = await io.handleMeshIo(input, new AbortController().signal, kernel);
assert.equal(facadeResult.area, area);
assert.ok(preservesWinding(facadeResult.vertices));
}
entry.assertions.push({ name: area === null ? `${format} rejects invalid geometry` : `${format} file and native imported geometry preserve varying input through facade`, passed: true });
} catch (error) {
entry.assertions.push({ name: `${format} import business contract`, passed: false, error: String(error) });
} finally {
try { kernel.FS?.unlink(input.path); } catch {}
}
}
if (format === "PLY") plyCases = cases.slice();
}
assert.ok(cases.every(entry => entry.assertions.every(check => check.passed)), `${format} import contract failed; see actual responses`);
} else if (id === amfExportId) {
for (const [index, vertices] of [[], [[0,0,0],[1,1,1],[2,2,2]], [[0,0,0],[2,0,0],[0,3,0]], [[4,5,6],[4,5,10],[4,7,6]]].entries()) {
await yieldToWorkers(0);
const input = { ...io.MESH_IO_FIXTURE, format: "AMF", path: `/tmp/mesh-export-${index}.amf`, vertices };
const entry = { input, assertions: [] };
cases.push(entry);
try {
const actual = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: io.MESH_IO_COMMAND, args: input })]));
entry.actual = actual;
if (index < 2) {
assert.equal(actual.ok, false);
assert.equal(actual.code, "invalid_argument");
assert.match(actual.error, /vert|geometry|triangle|degenerate/i);
} else {
assert.equal(actual.ok, true, JSON.stringify(actual));
assert.equal(actual.format, "AMF");
assert.equal(actual.path, input.path);
assert.equal(typeof actual.fileBase64, "string");
const bytes = Buffer.from(actual.fileBase64, "base64");
assert.ok(bytes.length > 0);
assert.equal(actual.exportBytes, bytes.length);
// Parse the actual exported artifact, so an STL payload or echoed request cannot pass.
const artifact = JSON.parse(execFileSync("python3", ["-c", `import sys, json, io, zipfile
import xml.etree.ElementTree as ET
data = sys.stdin.buffer.read()
if zipfile.is_zipfile(io.BytesIO(data)):
with zipfile.ZipFile(io.BytesIO(data)) as archive:
names = archive.namelist()
assert len(names) == 1
data = archive.read(names[0])
root = ET.fromstring(data)
assert root.tag == 'amf'
assert root.get('unit', 'millimeter') == 'millimeter'
meshes = root.findall('./object/mesh')
assert len(meshes) == 1
mesh = meshes[0]
vertices = [[float(v.findtext('coordinates/' + axis)) for axis in ('x', 'y', 'z')] for v in mesh.findall('./vertices/vertex')]
triangles = [[int(t.findtext(axis)) for axis in ('v1', 'v2', 'v3')] for t in mesh.findall('./volume/triangle')]
print(json.dumps(dict(vertices=vertices, triangles=triangles)))
`], { input: bytes, encoding: "utf8" }));
entry.artifact = artifact;
assert.equal(artifact.vertices.length, 3);
assert.equal(artifact.triangles.length, 1);
const triangle = artifact.triangles[0];
assert.equal(new Set(triangle).size, 3);
assert.ok(triangle.every(i => Number.isInteger(i) && i >= 0 && i < 3));
const points = triangle.map(i => artifact.vertices[i]);
assert.ok([0, 1, 2].some(offset => points.every((point, i) => JSON.stringify(point) === JSON.stringify(vertices[(i + offset) % 3]))), "AMF artifact preserves input coordinates and winding");
assert.equal(actual.resourcesReleased, true);
}
entry.assertions.push({ name: index < 2 ? "AMF rejects invalid geometry" : "AMF export artifact preserves varying coordinates and winding after invalid requests", passed: true });
} catch (error) {
entry.assertions.push({ name: "AMF native export business contract", passed: false, error: String(error) });
} finally {
try { kernel.FS?.stat(input.path); kernel.FS?.unlink(input.path); } catch {}
}
}
assert.ok(cases.every(entry => entry.assertions.every(check => check.passed)), "AMF native export business contract failed; see actual responses");
} else if (threeMfIds.includes(id)) {
// Both format IDs exercise the same native file roundtrip in this runtime.
if (!threeMfCases) {
threeMfCases = [];
const inputs = [
{ vertices: [], area: null },
{ vertices: [[0,0,0],[1,1,1],[2,2,2]], area: null },
{ vertices: [[0,0,0],[2,0,0],[0,3,0]], area: 3 },
{ vertices: [[4,5,6],[4,7,6],[4,5,10]], area: 4 },
];
for (const [index, { vertices, area }] of inputs.entries()) {
await yieldToWorkers(0);
const input = { ...io.MESH_IO_FIXTURE, format: "3MF", path: `/tmp/mesh-roundtrip-${index}.3mf`, vertices };
const entry = { input, assertions: [] };
threeMfCases.push(entry);
try {
const actual = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: io.MESH_IO_COMMAND, args: input })]));
entry.actual = actual;
if (area === null) {
// A format rejection cannot stand in for geometry validation.
assert.equal(actual.ok, false);
assert.equal(actual.code, "invalid_argument");
assert.match(actual.error, /vert|geometry|triangle|degenerate/i);
} else {
assert.equal(actual.ok, true, JSON.stringify(actual));
assert.equal(actual.format, "3MF");
assert.equal(actual.path, input.path);
assert.equal(typeof actual.fileBase64, "string");
const bytes = Buffer.from(actual.fileBase64, "base64");
assert.equal(actual.exportBytes, bytes.length);
const artifact = JSON.parse(execFileSync("python3", ["-c", `import sys, json, io, zipfile
import xml.etree.ElementTree as ET
with zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read())) as archive:
root = ET.fromstring(archive.read('3D/3dmodel.model'))
ns = {'m': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'}
mesh = root.find('./m:resources/m:object/m:mesh', ns)
assert mesh is not None
vertices = [[float(v.get(axis)) for axis in ('x', 'y', 'z')] for v in mesh.findall('./m:vertices/m:vertex', ns)]
triangles = [[int(t.get(axis)) for axis in ('v1', 'v2', 'v3')] for t in mesh.findall('./m:triangles/m:triangle', ns)]
print(json.dumps(dict(vertices=vertices, triangles=triangles)))
`], { input: bytes, encoding: "utf8" }));
entry.artifact = artifact;
assert.equal(artifact.triangles.length, 1);
const points = artifact.triangles[0].map(i => artifact.vertices[i]);
assert.ok([0, 1, 2].some(offset => points.every((point, i) => JSON.stringify(point) === JSON.stringify(vertices[(i + offset) % 3]))), "3MF artifact preserves coordinates and winding");
assert.ok(actual.exportBytes > 0);
assert.equal(actual.importedFacetCount, 1);
assert.equal(actual.importedPointCount, 3);
assert.equal(actual.area, area);
assert.ok([0, 1, 2].some(offset => actual.vertices.every((point, i) => JSON.stringify(point) === JSON.stringify(vertices[(i + offset) % 3]))));
assert.equal(actual.vertices.length, 3);
assert.equal(actual.resourcesReleased, true);
}
entry.assertions.push({ name: area === null ? "3MF rejects invalid geometry" : "3MF file roundtrip preserves input coordinates, winding and area", passed: true });
} catch (error) {
entry.assertions.push({ name: "3MF native file business contract", passed: false, error: String(error) });
}
}
}
cases.push(...threeMfCases);
assert.ok(cases.every(entry => entry.assertions.every(check => check.passed)), "3MF native file business contract failed; see actual responses");
} else if (id === persistenceId) {
const response = await persistence.dispatchMeshPersistence(queue, persistence.createMeshPersistenceRequest("mesh-save-reopen"), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.format, "FCStd"); assert.equal(actual.path, "/tmp/MeshAction05.FCStd");
assert.ok(actual.size > 0); assert.equal(actual.objectType, "Mesh::Feature"); assert.equal(actual.objectCount, 1);
assert.equal(actual.facetCount, 1); assert.equal(actual.pointCount, 3); assert.equal(actual.roundTrip, true);
cases.push({ input: persistence.MESH_PERSISTENCE_FIXTURE, actual, assertions: [{ name: "Mesh FCStd save and reopen preserves one triangle", passed: true }] });
} else if (id === ioId) {
let sequence = 0;
for (const vertices of [[], [[0,0,0],[1,1,1],[2,2,2]]]) {
const input = { ...io.MESH_IO_FIXTURE, vertices };
const actual = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: io.MESH_IO_COMMAND, args: input })]));
assert.equal(actual.ok, false);
cases.push({ input, actual, assertions: [{ name: "native rejects invalid geometry", passed: true }] });
}
for (const [vertices, area] of [[[[0,0,0],[1,0,0],[0,1,0]], 0.5], [[[0,0,0],[2,0,0],[0,3,0]], 3], [[[4,5,6],[4,7,6],[4,5,10]], 4]]) {
// Let completed pthread workers return to the pool before another synchronous import.
await yieldToWorkers(0);
const input = { ...io.MESH_IO_FIXTURE, path: `/tmp/mesh-roundtrip-${++sequence}.stl`, vertices };
const response = await io.dispatchMeshIo(queue, io.createMeshIoRequest(`mesh-import-export-${sequence}`, input), kernel);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.format, "STL"); assert.equal(actual.path, input.path);
assert.ok([0, 1, 2].some(offset => actual.vertices.every((point, index) => JSON.stringify(point) === JSON.stringify(vertices[(index + offset) % 3]))), "STL preserves coordinates and winding up to cyclic vertex rotation");
assert.equal(actual.area, area);
assert.ok(actual.exportBytes > 0); assert.equal(actual.importedFacetCount, 1); assert.equal(actual.importedPointCount, 3); assert.equal(actual.resourcesReleased, true);
cases.push({ input, actual, assertions: [{ name: "STL roundtrip preserves varying native coordinates and area after invalid requests", passed: true }] });
}
} else {
let sequence = 0;
const call = vertices => facade.dispatchMesh(queue, facade.createMeshRequest(`mesh-${++sequence}`, { ...facade.MESH_FIXTURE, vertices }), kernel);
const native = vertices => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: facade.MESH_COMMAND, args: { vertices } })]));
for (const vertices of [[], [[0,0,0],[1,1,1],[2,2,2]], [[0,0,0],[0,0,0],[1,0,0]], [[null,0,0],[1,0,0],[0,1,0]], [[1e20,0,0],[1,0,0],[0,1,0]]]) {
const actual = native(vertices);
assert.equal(actual.ok, false); assert.equal(actual.code, "invalid_argument");
const response = await call(vertices);
assert.notEqual(response.kind, "result");
cases.push({ input: { vertices }, actual, assertions: [{ name: "invalid geometry rejected by native and facade", passed: true }] });
}
for (const [vertices, area] of [[[[0,0,0],[1,0,0],[0,1,0]], 0.5], [[[0,0,0],[2,0,0],[0,3,0]], 3], [[[4,5,6],[4,7,6],[4,5,10]], 4]]) {
const response = await call(vertices);
assert.equal(response.kind, "result", JSON.stringify(response));
const actual = response.result;
assert.equal(actual.objectType, "Mesh::Feature"); assert.equal(actual.facetCount, 1); assert.equal(actual.pointCount, 3); assert.equal(actual.resourcesReleased, true);
assert.deepEqual(actual.vertices, vertices); assert.equal(actual.area, area);
cases.push({ input: { vertices }, actual, assertions: [{ name: "recomputed native facet coordinates and area match varying inputs after invalid requests", passed: true }, { name: "native resources are released", passed: true }] });
}
}
results.push({ capabilityId: id, status: "passed", cases });
} catch (error) {
results.push({ capabilityId: id, status: "failed", cases, error: String(error), diagnostics: error.stdout?.toString() });
exitCode = 1;
}
}
} catch (error) {
for (const id of Array.isArray(ids) ? ids : [capabilityId]) {
results.push({ capabilityId: id, status: "failed", cases: [], error: String(error), diagnostics: error.stdout?.toString() });
}
exitCode = 1;
} finally {
fs.rmSync(out, { recursive: true, force: true });
}
// Flush the complete evidence before exiting the one-shot pthread runtime.
process.stdout.write(JSON.stringify(report) + "\n", () => process.exit(exitCode));