partforge 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +2 -3
- package/docs/AUTHORING-PARTS.md +135 -0
- package/docs/ERROR-PATTERNS.md +29 -1
- package/docs/KERNEL-CONTRACT.md +15 -1
- package/package.json +1 -1
- package/src/app-import-demo.js +18 -0
- package/src/framework/app.css +8 -1
- package/src/framework/asset-resolve.js +71 -0
- package/src/framework/capture-build.js +12 -4
- package/src/framework/export-controller.js +12 -0
- package/src/framework/fonts.js +12 -30
- package/src/framework/geometry/kernel.js +2 -1
- package/src/framework/geometry/manifold-backend.js +41 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/occt-backend.js +33 -1
- package/src/framework/geometry/stl-parse.js +45 -0
- package/src/framework/geometry/threemf-parse.js +87 -0
- package/src/framework/geometry-service.js +3 -1
- package/src/framework/imports.js +84 -0
- package/src/framework/jobs.js +20 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-imports.js +115 -0
- package/src/framework/mount.js +94 -1
- package/src/framework/oracle/measure.js +28 -2
- package/src/framework/verify-metrics.js +10 -0
- package/src/framework/worker.js +11 -2
- package/src/import-demo-worker.js +3 -0
- package/src/parts/assets/import-demo-scan.stl +86 -0
- package/src/parts/import-demo.js +134 -0
- package/src/testing/assets.js +19 -0
- package/src/testing/manifold.js +14 -2
- package/src/testing/occt.js +5 -2
- package/src/testing/step-mesh-thread.js +15 -0
- package/src/testing/step-mesh.js +17 -0
- package/types/kernel.d.ts +6 -0
- package/types/part.d.ts +14 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Repair + diagnostics for imported meshes (STL/3MF soup, or already-indexed). Files in the
|
|
2
|
+
// wild are inconsistently wound and sometimes non-manifold, so the import path runs these
|
|
3
|
+
// before handing a mesh to Manifold.ofMesh: orient it outward, then measure how far it is
|
|
4
|
+
// from watertight so the caller can decide whether to warn or refuse the import.
|
|
5
|
+
|
|
6
|
+
import { reverseWinding } from "./mesh-build.js";
|
|
7
|
+
|
|
8
|
+
// Read triangle `t`'s three vertex indices, working for both an indexed mesh (`indices` is
|
|
9
|
+
// the triangle-index array) and an unindexed soup (`indices` is null/undefined, so the
|
|
10
|
+
// triangle's own position offsets double as its "indices": 3t, 3t+1, 3t+2).
|
|
11
|
+
function triIndices(indices, t) {
|
|
12
|
+
if (indices) return [indices[t * 3], indices[t * 3 + 1], indices[t * 3 + 2]];
|
|
13
|
+
return [t * 3, t * 3 + 1, t * 3 + 2];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function triCount(positions, indices) {
|
|
17
|
+
return indices ? indices.length / 3 : positions.length / 9;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function vertex(positions, i) {
|
|
21
|
+
return [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Signed volume (mm^3) of a closed mesh: Sigma det(a,b,c)/6 over triangles, the standard
|
|
25
|
+
// divergence-theorem formula. Positive when triangles wind CCW-outward, negative when the
|
|
26
|
+
// whole mesh is inside-out. Works on soup (indices omitted) and indexed meshes alike, and
|
|
27
|
+
// doesn't require the mesh to actually be closed - an open mesh just gives a meaningless
|
|
28
|
+
// number, which is exactly the signal ensureOutward needs (sign, not validity).
|
|
29
|
+
export function signedVolume(positions, indices) {
|
|
30
|
+
const n = triCount(positions, indices);
|
|
31
|
+
let vol = 0;
|
|
32
|
+
for (let t = 0; t < n; t++) {
|
|
33
|
+
const [i0, i1, i2] = triIndices(indices, t);
|
|
34
|
+
const [ax, ay, az] = vertex(positions, i0);
|
|
35
|
+
const [bx, by, bz] = vertex(positions, i1);
|
|
36
|
+
const [cx, cy, cz] = vertex(positions, i2);
|
|
37
|
+
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
|
38
|
+
}
|
|
39
|
+
return vol / 6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Flip the whole mesh outward-facing if it's currently inside-out (negative signed volume),
|
|
43
|
+
// by reversing every triangle's winding in place. A no-op when already outward (or when the
|
|
44
|
+
// sign is ambiguous because the mesh isn't closed - nothing sensible to do there anyway).
|
|
45
|
+
export function ensureOutward(positions, indices) {
|
|
46
|
+
if (signedVolume(positions, indices) < 0) reverseWinding(indices);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count boundary ("open") half-edges after welding vertices by exact position. A watertight
|
|
50
|
+
// mesh has every directed edge matched by an opposite-direction edge on the neighboring
|
|
51
|
+
// triangle; an edge whose reverse is missing borders a hole (or a non-manifold seam) and is
|
|
52
|
+
// the diagnostic for "this import isn't a solid". The weld key is built from the raw
|
|
53
|
+
// (Float32-precision) coordinate values - exact match is correct here because soup vertices
|
|
54
|
+
// straight out of one STL/3MF file that are meant to coincide already have identical float32
|
|
55
|
+
// bit patterns; no epsilon merge needed.
|
|
56
|
+
export function openEdgeCount(positions, indices) {
|
|
57
|
+
const n = triCount(positions, indices);
|
|
58
|
+
|
|
59
|
+
// Weld: coordinate key -> canonical vertex id.
|
|
60
|
+
const weld = new Map();
|
|
61
|
+
function weldedId(i) {
|
|
62
|
+
const [x, y, z] = vertex(positions, i);
|
|
63
|
+
const key = `${x},${y},${z}`;
|
|
64
|
+
let id = weld.get(key);
|
|
65
|
+
if (id === undefined) { id = weld.size; weld.set(key, id); }
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tally every directed edge (a -> b) across all triangles.
|
|
70
|
+
const edges = new Map();
|
|
71
|
+
for (let t = 0; t < n; t++) {
|
|
72
|
+
const [i0, i1, i2] = triIndices(indices, t);
|
|
73
|
+
const a = weldedId(i0), b = weldedId(i1), c = weldedId(i2);
|
|
74
|
+
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
|
|
75
|
+
const key = `${u},${v}`;
|
|
76
|
+
edges.set(key, (edges.get(key) || 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// An edge is open when its reverse direction never appears.
|
|
81
|
+
let open = 0;
|
|
82
|
+
for (const key of edges.keys()) {
|
|
83
|
+
const [u, v] = key.split(",");
|
|
84
|
+
if (!edges.has(`${v},${u}`)) open++;
|
|
85
|
+
}
|
|
86
|
+
return open;
|
|
87
|
+
}
|
|
@@ -37,12 +37,17 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tol
|
|
|
37
37
|
|
|
38
38
|
export function createOcctKernel(replicad) {
|
|
39
39
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
40
|
-
loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
40
|
+
loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
41
41
|
|
|
42
42
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
43
43
|
// see occt-repair.js for the policies and why they differ per op.
|
|
44
44
|
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
45
45
|
|
|
46
|
+
// name -> { shape, digest } | { error, digest } — imported geometry the framework
|
|
47
|
+
// registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
|
|
48
|
+
// solid cache: imports are the framework's own memo, keyed by name+digest).
|
|
49
|
+
const imports = new Map();
|
|
50
|
+
|
|
46
51
|
const cache = createSolidCache();
|
|
47
52
|
// Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
|
|
48
53
|
// GC frees WASM via replicad's FinalizationRegistry) and dispose is a no-op:
|
|
@@ -487,6 +492,33 @@ export function createOcctKernel(replicad) {
|
|
|
487
492
|
// surface.
|
|
488
493
|
_offsetRegions: offsetRegions,
|
|
489
494
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
|
|
495
|
+
// Imported geometry, registered pre-build by the framework via `_registerImport`
|
|
496
|
+
// (ensureImports, Task 8). Every call clones the master shape — replicad ops
|
|
497
|
+
// consume their operands, and the master must never be handed out directly, or
|
|
498
|
+
// a second `import(name)` call would see the first caller's transform.
|
|
499
|
+
import: (name) => {
|
|
500
|
+
const e = imports.get(name);
|
|
501
|
+
if (!e) throw new Error(`import: unknown import "${name}" — declare it in the part's \`imports\` field`);
|
|
502
|
+
if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
|
|
503
|
+
return wrap(e.shape.clone(), [], h("import", name, e.digest));
|
|
504
|
+
},
|
|
505
|
+
// Side-channel (underscore = off-contract, probe-invisible). Registration is
|
|
506
|
+
// TOTAL — it never throws for an unusable format (e.g. an STL/3MF import on
|
|
507
|
+
// this backend); an `{error}` entry is stored verbatim and thrown by
|
|
508
|
+
// `import(name)` above at call time (spec: "Registration is total; errors are
|
|
509
|
+
// lazy"). Re-registering the same name+digest is a no-op EXCEPT an error entry
|
|
510
|
+
// is always upgradable (the post-crossover retry depends on this — see
|
|
511
|
+
// `_importDigest`).
|
|
512
|
+
_registerImport: async ({ name, digest, step, error }) => {
|
|
513
|
+
const prev = imports.get(name);
|
|
514
|
+
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
515
|
+
if (error) { imports.set(name, { error, digest }); return; }
|
|
516
|
+
imports.set(name, { shape: await importSTEP(new Blob([step])), digest });
|
|
517
|
+
},
|
|
518
|
+
// Registration memo: undefined for an error entry, so a later registration with
|
|
519
|
+
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
520
|
+
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
521
|
+
_acceptsStep: true,
|
|
490
522
|
beginSubPart: (name) => cache.begin(name),
|
|
491
523
|
endSubPart: () => cache.end(),
|
|
492
524
|
sweepCache: () => cache.sweep(),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure-JS STL reader (ascii + binary), the read twin of mesh-stl.js's writer.
|
|
2
|
+
// Returns triangle soup: positions x,y,z per vertex, indices 0..3n-1. Vertex
|
|
3
|
+
// welding is deliberately NOT done here — Manifold's Mesh.merge() welds at
|
|
4
|
+
// import (mesh-build.js), and the soup keeps this parser trivial and exact.
|
|
5
|
+
const u8of = (b) => (b instanceof ArrayBuffer ? new Uint8Array(b) : b);
|
|
6
|
+
|
|
7
|
+
function isAscii(u8) {
|
|
8
|
+
// "solid" prefix is not enough (binary files sometimes start with it);
|
|
9
|
+
// require an ascii "facet" token in the first 1 KB too.
|
|
10
|
+
const head = String.fromCharCode(...u8.slice(0, 1024));
|
|
11
|
+
return head.trimStart().startsWith("solid") && head.includes("facet");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseStl(bytes) {
|
|
15
|
+
const u8 = u8of(bytes);
|
|
16
|
+
return isAscii(u8) ? parseAscii(u8) : parseBinary(u8);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseAscii(u8) {
|
|
20
|
+
const text = new TextDecoder().decode(u8);
|
|
21
|
+
const V = [];
|
|
22
|
+
const re = /vertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/g;
|
|
23
|
+
for (let m; (m = re.exec(text)); ) V.push(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
24
|
+
if (V.length === 0 || V.length % 9 !== 0)
|
|
25
|
+
throw new Error(`ascii STL parse failed: ${V.length / 3} vertices (not a multiple of 3)`);
|
|
26
|
+
return soup(Float32Array.from(V));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseBinary(u8) {
|
|
30
|
+
if (u8.length < 84) throw new Error("binary STL truncated: shorter than the 84-byte header");
|
|
31
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
32
|
+
const n = dv.getUint32(80, true);
|
|
33
|
+
if (u8.length < 84 + n * 50) throw new Error(`binary STL truncated: header says ${n} triangles, file has ${Math.floor((u8.length - 84) / 50)}`);
|
|
34
|
+
const positions = new Float32Array(n * 9);
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
const o = 84 + i * 50 + 12; // skip the facet normal
|
|
37
|
+
for (let j = 0; j < 9; j++) positions[i * 9 + j] = dv.getFloat32(o + j * 4, true);
|
|
38
|
+
}
|
|
39
|
+
return soup(positions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const soup = (positions) => ({
|
|
43
|
+
positions,
|
|
44
|
+
indices: Uint32Array.from({ length: positions.length / 3 }, (_, i) => i),
|
|
45
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Minimal 3MF reader, the read twin of threemf.js's writer. 3MF is an OPC
|
|
2
|
+
// package (a zip) holding an XML model; unzip (fflate), find the model part,
|
|
3
|
+
// extract vertices/triangles/per-item transforms and the model unit, and
|
|
4
|
+
// merge every build item into one soup-free indexed mesh in millimetres.
|
|
5
|
+
//
|
|
6
|
+
// Regex-based extraction, NOT a DOM parse — workers have no DOMParser and the
|
|
7
|
+
// worker graph must stay DOM-free (test/worker-layering.test.js enforces
|
|
8
|
+
// this transitively). Scope: geometry only — materials, colors and beam
|
|
9
|
+
// lattices are ignored, and only top-level <object><mesh> content is read
|
|
10
|
+
// (no <components> nesting).
|
|
11
|
+
import { unzipSync } from "fflate";
|
|
12
|
+
|
|
13
|
+
const UNIT_MM = { micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000 };
|
|
14
|
+
|
|
15
|
+
export function parse3MF(bytes) {
|
|
16
|
+
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
17
|
+
let files;
|
|
18
|
+
try {
|
|
19
|
+
files = unzipSync(u8);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`);
|
|
22
|
+
}
|
|
23
|
+
const modelPath = Object.keys(files).find((f) => f.toLowerCase().endsWith(".model"));
|
|
24
|
+
if (!modelPath) throw new Error("3mf import: archive has no 3D model part (*.model)");
|
|
25
|
+
const xml = new TextDecoder().decode(files[modelPath]);
|
|
26
|
+
|
|
27
|
+
const unit = xml.match(/<model\b[^>]*\bunit="([^"]+)"/)?.[1] ?? "millimeter";
|
|
28
|
+
const scale = UNIT_MM[unit];
|
|
29
|
+
if (!scale) throw new Error(`3mf import: unknown unit "${unit}"`);
|
|
30
|
+
|
|
31
|
+
// objects: id -> { P: number[] (already scaled to mm), I: number[] }
|
|
32
|
+
const objects = new Map();
|
|
33
|
+
const objRe = /<object\b[^>]*\bid="(\d+)"[^>]*>([\s\S]*?)<\/object>/g;
|
|
34
|
+
for (let m; (m = objRe.exec(xml)); ) {
|
|
35
|
+
const [, id, body] = m;
|
|
36
|
+
const P = [], I = [];
|
|
37
|
+
const vRe = /<vertex\b[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"[^>]*\bz="([^"]+)"/g;
|
|
38
|
+
for (let v; (v = vRe.exec(body)); ) P.push(+v[1] * scale, +v[2] * scale, +v[3] * scale);
|
|
39
|
+
const tRe = /<triangle\b[^>]*\bv1="(\d+)"[^>]*\bv2="(\d+)"[^>]*\bv3="(\d+)"/g;
|
|
40
|
+
for (let t; (t = tRe.exec(body)); ) I.push(+t[1], +t[2], +t[3]);
|
|
41
|
+
if (I.length) objects.set(id, { P, I });
|
|
42
|
+
}
|
|
43
|
+
if (objects.size === 0) throw new Error("3mf import: model contains no mesh geometry");
|
|
44
|
+
|
|
45
|
+
// Build items: <item objectid="N" transform="m00 m01 m02 m10 m11 m12 m20 m21
|
|
46
|
+
// m22 m30 m31 m32"/> — row-major 4x3, translation in the last row, per the
|
|
47
|
+
// 3MF core spec (the transform is applied to a row vector: v' = v*M, i.e.
|
|
48
|
+
// x' = x*m00 + y*m10 + z*m20 + m30, and so on). No <build>, or an object
|
|
49
|
+
// with no matching item, falls back to identity placement.
|
|
50
|
+
//
|
|
51
|
+
// Attributes are pulled independently from each <item> tag (rather than in
|
|
52
|
+
// one fixed-order regex) because `objectid` and `transform` can appear in
|
|
53
|
+
// either order and a single ordered pattern with an optional middle group
|
|
54
|
+
// can match the tag while silently leaving `transform` uncaptured.
|
|
55
|
+
const items = [];
|
|
56
|
+
const itemTagRe = /<item\b[^>]*\/>/g;
|
|
57
|
+
for (let m; (m = itemTagRe.exec(xml)); ) {
|
|
58
|
+
const tag = m[0];
|
|
59
|
+
const id = tag.match(/\bobjectid="(\d+)"/)?.[1];
|
|
60
|
+
if (!id) continue;
|
|
61
|
+
const t = tag.match(/\btransform="([^"]+)"/)?.[1];
|
|
62
|
+
items.push({ id, t: t ? t.trim().split(/\s+/).map(Number) : null });
|
|
63
|
+
}
|
|
64
|
+
const chosen = items.length ? items : [...objects.keys()].map((id) => ({ id, t: null }));
|
|
65
|
+
|
|
66
|
+
const V = [], Tr = [];
|
|
67
|
+
for (const { id, t } of chosen) {
|
|
68
|
+
const o = objects.get(id);
|
|
69
|
+
if (!o) continue;
|
|
70
|
+
const base = V.length / 3;
|
|
71
|
+
for (let i = 0; i < o.P.length; i += 3) {
|
|
72
|
+
let x = o.P[i], y = o.P[i + 1], z = o.P[i + 2];
|
|
73
|
+
if (t) {
|
|
74
|
+
// Translation components (m30 m31 m32) are expressed in model units
|
|
75
|
+
// per the 3MF spec, same as vertex coordinates — scale them to mm too
|
|
76
|
+
// so they combine correctly with the already-scaled x/y/z above.
|
|
77
|
+
const x2 = t[0] * x + t[3] * y + t[6] * z + t[9] * scale;
|
|
78
|
+
const y2 = t[1] * x + t[4] * y + t[7] * z + t[10] * scale;
|
|
79
|
+
const z2 = t[2] * x + t[5] * y + t[8] * z + t[11] * scale;
|
|
80
|
+
x = x2; y = y2; z = z2;
|
|
81
|
+
}
|
|
82
|
+
V.push(x, y, z);
|
|
83
|
+
}
|
|
84
|
+
for (const idx of o.I) Tr.push(base + idx);
|
|
85
|
+
}
|
|
86
|
+
return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr) };
|
|
87
|
+
}
|
|
@@ -31,8 +31,10 @@ export function createGeometryService({ createWorker, onMessage }) {
|
|
|
31
31
|
// Post a job to the chosen backend's worker. The message's own `type` says what to
|
|
32
32
|
// do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
|
|
33
33
|
// — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
|
|
34
|
+
// `transfer` carries transferable buffers (e.g. priming a mesh's positions/indices
|
|
35
|
+
// into `prime-imports` without a structured-clone copy).
|
|
34
36
|
return {
|
|
35
|
-
send: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
37
|
+
send: (msg, backend = "manifold", transfer = []) => workers[backend].postMessage(msg, transfer),
|
|
36
38
|
terminate: () => terminateWorkers([workers.manifold, workers.occt]),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Resolve a part's declared `imports` ({ name: source }) to bytes + a SHA-256
|
|
2
|
+
// content digest + a detected format, before the synchronous build — the
|
|
3
|
+
// import-asset sibling of fonts.js: same source grammar and identity-
|
|
4
|
+
// memoization rule (import sources are content-stable for a session), built on
|
|
5
|
+
// the shared resolution core in asset-resolve.js. DOM-free and node:-free;
|
|
6
|
+
// crypto.subtle exists in workers and Node.
|
|
7
|
+
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
8
|
+
import { parseStl } from "./geometry/stl-parse.js";
|
|
9
|
+
import { parse3MF } from "./geometry/threemf-parse.js";
|
|
10
|
+
|
|
11
|
+
const EXT = { step: "step", stp: "step", stl: "stl", "3mf": "3mf" };
|
|
12
|
+
|
|
13
|
+
export function detectFormat(source, bytes) {
|
|
14
|
+
const path = source instanceof URL ? source.pathname : typeof source === "string" ? source.split("?")[0] : null;
|
|
15
|
+
const ext = path?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
|
|
16
|
+
if (ext && EXT[ext]) return EXT[ext];
|
|
17
|
+
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
18
|
+
if (u8 && u8.length > 0) {
|
|
19
|
+
const head = String.fromCharCode(...u8.slice(0, 64));
|
|
20
|
+
if (head.startsWith("ISO-10303-21")) return "step";
|
|
21
|
+
if (u8[0] === 0x50 && u8[1] === 0x4b) return "3mf"; // zip signature
|
|
22
|
+
return "stl"; // ascii "solid …" and binary STL both land here
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`unrecognized import format${path ? ` for "${path}"` : ""} — use a .step/.stl/.3mf extension or non-empty bytes`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sha256Hex(bytes) {
|
|
28
|
+
const d = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
29
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cache = new Map(); // source → Promise<{bytes, digest, format}>
|
|
33
|
+
const resolveOne = makeAssetResolver(
|
|
34
|
+
cache,
|
|
35
|
+
async (bytes, v, source) => {
|
|
36
|
+
const format = detectFormat(source instanceof URL || typeof source === "string" ? source : v, bytes);
|
|
37
|
+
return { bytes, digest: await sha256Hex(bytes), format };
|
|
38
|
+
},
|
|
39
|
+
"resolveImports: an import source must be bytes, a URL, or a thunk returning one",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export async function resolveImports(importsDecl) {
|
|
43
|
+
return resolveDecl(importsDecl, resolveOne);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Register a part's imports on a booted kernel (idempotent per digest). The
|
|
47
|
+
// framework calls this in the async phase before every job's synchronous
|
|
48
|
+
// build — worker (jobs.js) and Node boots (src/testing/) alike.
|
|
49
|
+
//
|
|
50
|
+
// Registration is total; errors are lazy (see the spec section of that name):
|
|
51
|
+
// every declared import registers on whichever kernel runs the job, and a
|
|
52
|
+
// format this kernel cannot use registers as an {error} entry that k.import
|
|
53
|
+
// throws at call time. That is what keeps a mixed-format declaration from
|
|
54
|
+
// poisoning unrelated jobs — the OCCT worker's tessellate-imports service
|
|
55
|
+
// job, or a per-backend generate group that never touches the unusable
|
|
56
|
+
// import. STEP on a mesh backend needs pre-tessellated triangles in
|
|
57
|
+
// `importMeshes`; absent, the entry carries code NEEDS_IMPORT_MESH and the
|
|
58
|
+
// first build to call k.import on it makes the host arrange tessellation
|
|
59
|
+
// (mount's needs-import-mesh flow in the browser, worker_threads in Node).
|
|
60
|
+
export async function ensureImports(kernel, importsDecl, importMeshes = null) {
|
|
61
|
+
if (!importsDecl || typeof kernel._registerImport !== "function") return;
|
|
62
|
+
const resolved = await resolveImports(importsDecl);
|
|
63
|
+
for (const [name, a] of resolved) {
|
|
64
|
+
if (kernel._importDigest?.(name) === a.digest) continue; // error entries answer undefined → always retried
|
|
65
|
+
if (a.format === "step") {
|
|
66
|
+
if (kernel._acceptsStep) { await kernel._registerImport({ name, digest: a.digest, step: a.bytes }); continue; }
|
|
67
|
+
const m = importMeshes?.get?.(name);
|
|
68
|
+
if (m && m.digest === a.digest) {
|
|
69
|
+
await kernel._registerImport({ name, digest: a.digest, positions: m.positions, indices: m.indices });
|
|
70
|
+
} else {
|
|
71
|
+
const e = new Error(`import "${name}": STEP needs tessellation for the Manifold backend`);
|
|
72
|
+
e.code = "NEEDS_IMPORT_MESH";
|
|
73
|
+
await kernel._registerImport({ name, digest: a.digest, error: e });
|
|
74
|
+
}
|
|
75
|
+
} else if (kernel._acceptsMesh) {
|
|
76
|
+
const { positions, indices } = a.format === "3mf" ? parse3MF(a.bytes) : parseStl(a.bytes);
|
|
77
|
+
await kernel._registerImport({ name, digest: a.digest, positions, indices });
|
|
78
|
+
} else {
|
|
79
|
+
// No parse for a kernel that can't take the mesh — error entry directly.
|
|
80
|
+
await kernel._registerImport({ name, digest: a.digest, error: new Error(
|
|
81
|
+
`import "${name}": STL/3MF imports need the Manifold backend — this build routes to OCCT (shell or meta.backend, or a fillet/chamfer rerouted for an unsupported edge class); use the mesh import from a Manifold-routed build`) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
6
6
|
import { exportablePartNames } from "./export-select.js";
|
|
7
7
|
import { resolveFonts } from "./fonts.js";
|
|
8
|
+
import { ensureImports, resolveImports } from "./imports.js";
|
|
8
9
|
import { safeName } from "./safe-name.js";
|
|
9
10
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
10
11
|
import { measure } from "./oracle/measure.js";
|
|
@@ -105,6 +106,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
105
106
|
const bufs = await resolveFonts(part.fonts);
|
|
106
107
|
for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, opentype.parse(buf));
|
|
107
108
|
}
|
|
109
|
+
// Register this part's declared imports on the kernel running this job — the
|
|
110
|
+
// import-asset sibling of the fonts preload above. See ensureImports for the
|
|
111
|
+
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
112
|
+
// calls k.import on it.
|
|
113
|
+
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
108
114
|
// Inside the try so a throwing derive posts an error the UI can show,
|
|
109
115
|
// instead of killing the worker turn silently (an endless spinner).
|
|
110
116
|
const { p, d } = resolveParams(part, msg.params);
|
|
@@ -196,6 +202,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
196
202
|
onProgress("writing 3MF file");
|
|
197
203
|
const data = meshTo3MF(meshes);
|
|
198
204
|
post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
|
|
205
|
+
} else if (msg.type === "tessellate-imports") {
|
|
206
|
+
// OCCT-worker service job for the STEP-on-Manifold crossover: answer with
|
|
207
|
+
// print-quality triangle meshes for every STEP import, transferable.
|
|
208
|
+
const resolved = await resolveImports(part.imports ?? {});
|
|
209
|
+
const meshes = {};
|
|
210
|
+
for (const [name, a] of resolved) {
|
|
211
|
+
if (a.format !== "step") continue;
|
|
212
|
+
const { positions, indices } = kernel.import(name).toIndexedMesh({ quality: "print" });
|
|
213
|
+
meshes[name] = { digest: a.digest, positions, indices };
|
|
214
|
+
}
|
|
215
|
+
post({ type: "import-meshes", jobId: msg.jobId, meshes },
|
|
216
|
+
Object.values(meshes).flatMap((m) => [m.positions.buffer, m.indices.buffer]));
|
|
199
217
|
} else if (msg.type === "inspect") {
|
|
200
218
|
// Full geometric oracle for the current view: solid facts (volume/genus/
|
|
201
219
|
// watertight), mesh facts, overlaps, and gap distances, plus the part's
|
|
@@ -246,7 +264,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
246
264
|
} catch (err) {
|
|
247
265
|
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
248
266
|
// failed job covered, so only those latch to OCCT — not the whole part.
|
|
249
|
-
if (err?.code === "
|
|
267
|
+
if (err?.code === "NEEDS_IMPORT_MESH") post({ type: "needs-import-mesh", jobId: msg.jobId, subparts: msg.subparts });
|
|
268
|
+
else if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
|
|
250
269
|
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
251
270
|
} finally {
|
|
252
271
|
kernel.cleanup?.();
|
|
@@ -15,8 +15,9 @@ import { BUILD_RULES } from "./rules-build.js";
|
|
|
15
15
|
import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
|
|
16
16
|
import { ANIMATION_RULES } from "./rules-animations.js";
|
|
17
17
|
import { PLACE_RULES } from "./rules-place.js";
|
|
18
|
+
import { IMPORT_RULES } from "./rules-imports.js";
|
|
18
19
|
|
|
19
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES];
|
|
20
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES];
|
|
20
21
|
|
|
21
22
|
// Every rule runs inside a guard. lintPart is called on a user-facing hosted path
|
|
22
23
|
// (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Group 5 — geometry-import well-formedness. Each condition here is a static
|
|
2
|
+
// early-catch for something that otherwise throws only later: `k.import()` on
|
|
3
|
+
// an undeclared name throws mid-build (kernel.js), a declared mesh import
|
|
4
|
+
// under an OCCT-routed part throws lazily from the `{error}` registration
|
|
5
|
+
// entry the first time `k.import` reads it (imports.js), an unknown
|
|
6
|
+
// `reference` breaks the deviation gate silently (measure.js just reports no
|
|
7
|
+
// deviation), and a `ref*` verify metric with no `reference` always reports
|
|
8
|
+
// status "skip" (verify-metrics.js) rather than the gate the author intended.
|
|
9
|
+
//
|
|
10
|
+
// `import-mesh-on-occt` is deliberately conservative: only extension-detectable
|
|
11
|
+
// sources (a `URL` or a string path) are checked. Bytes and thunks carry no
|
|
12
|
+
// format information without actually resolving them, which lint — geometry-
|
|
13
|
+
// free and synchronous — cannot do; those cases (and any per-sub-part routing
|
|
14
|
+
// split a whole-part `detectBackend` can't see) still fail correctly at build
|
|
15
|
+
// time via imports.js's lazy `{error}` entry. This rule exists to catch the
|
|
16
|
+
// common case early, not to replace that runtime authority.
|
|
17
|
+
import { err, warn } from "./finding.js";
|
|
18
|
+
import { detectBackend } from "../backend-select.js";
|
|
19
|
+
import { SUBPART_METRICS } from "../verify-metrics.js";
|
|
20
|
+
|
|
21
|
+
const MESH_EXT = /\.(stl|3mf)$/i;
|
|
22
|
+
|
|
23
|
+
const REF_METRICS = new Set(Object.keys(SUBPART_METRICS).filter((k) => k.startsWith("ref")));
|
|
24
|
+
|
|
25
|
+
const declaredImports = (part) => Object.keys(part?.imports ?? {});
|
|
26
|
+
|
|
27
|
+
// Only URL/string sources carry a statically-visible extension; a thunk or raw
|
|
28
|
+
// bytes source is skipped (see file header).
|
|
29
|
+
const meshDeclared = (part) => Object.entries(part?.imports ?? {}).filter(([, src]) => {
|
|
30
|
+
const path = src instanceof URL ? src.pathname : typeof src === "string" ? src.split("?")[0] : null;
|
|
31
|
+
return path != null && MESH_EXT.test(path);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const IMPORT_RULES = [
|
|
35
|
+
{
|
|
36
|
+
id: "import-unknown-name",
|
|
37
|
+
run: ({ part, probe }) => {
|
|
38
|
+
const known = new Set(declaredImports(part));
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const call of probe().calls) {
|
|
42
|
+
if (call.scope !== "kernel" || call.op !== "import") continue;
|
|
43
|
+
let name;
|
|
44
|
+
try { name = JSON.parse(call.args[0]); } catch { name = null; }
|
|
45
|
+
if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
|
|
46
|
+
seen.add(name);
|
|
47
|
+
out.push(err("import-unknown-name",
|
|
48
|
+
`build calls k.import with name "${name}", which the part's imports field does not declare: ${[...known].join(", ") || "(nothing)"}`,
|
|
49
|
+
"Declare the file under imports: { name: source }, or fix the name to match an existing entry.",
|
|
50
|
+
"imports"));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "import-mesh-on-occt",
|
|
57
|
+
run: ({ part, p }) => {
|
|
58
|
+
const mesh = meshDeclared(part);
|
|
59
|
+
if (mesh.length === 0 || detectBackend(part, p) !== "occt") return [];
|
|
60
|
+
const cause = part?.meta?.backend === "occt"
|
|
61
|
+
? "meta.backend forces OCCT"
|
|
62
|
+
: "a shell op routes this part to OCCT"; // post-contract-v3, fillet/chamfer no longer probe-route
|
|
63
|
+
return mesh.map(([name]) => err("import-mesh-on-occt",
|
|
64
|
+
`import "${name}" is a mesh (STL/3MF) but ${cause} — mesh imports need the Manifold backend`,
|
|
65
|
+
// detectBackend (this rule's own gate) is the whole-part max over every
|
|
66
|
+
// sub-part, same as lint/measure/single-worker export — so putting the
|
|
67
|
+
// import on a nominally Manifold sub-part does not clear this error
|
|
68
|
+
// while any OTHER sub-part routes to OCCT; that coexistence only works
|
|
69
|
+
// for the browser preview's per-sub-part routing. Say so explicitly —
|
|
70
|
+
// see ERROR-PATTERNS.md#import-mesh-on-occt for the long version.
|
|
71
|
+
"Split the mesh-importing sub-part into its own separate part (per-sub-part Manifold/OCCT coexistence is preview-only), replace it with a STEP source, or drop the CAD-only op / meta.backend pin so the whole part routes to Manifold.",
|
|
72
|
+
"imports"));
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "reference-unknown",
|
|
77
|
+
run: ({ part }) => {
|
|
78
|
+
const known = new Set(declaredImports(part));
|
|
79
|
+
return Object.entries(part?.parts ?? {})
|
|
80
|
+
.filter(([, sp]) => sp?.reference && !known.has(sp.reference))
|
|
81
|
+
.map(([name, sp]) => err("reference-unknown",
|
|
82
|
+
`sub-part "${name}" declares reference: "${sp.reference}" but no such import exists`,
|
|
83
|
+
"reference must name a key of the part's imports field.",
|
|
84
|
+
`parts.${name}.reference`));
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "ref-metric-without-reference",
|
|
89
|
+
// Deliberately resolves function-form `verify.expect` via `resolveExpectOnce()`
|
|
90
|
+
// (same memoized, try/caught call the Group 4 verify rules share) rather than
|
|
91
|
+
// skipping it — the plan's anchor guarded with a bare `typeof expect ===
|
|
92
|
+
// "object"` check on the assumption a function-form `expect` "can't be
|
|
93
|
+
// inspected statically", but `resolveExpectOnce()` already does exactly that
|
|
94
|
+
// inspection safely (a throw is reported separately by `verify-expect-throws`
|
|
95
|
+
// and short-circuits here via `!expect`). A ref* metric surfaced from a
|
|
96
|
+
// function-form expect with no `reference` is just as real a finding as one
|
|
97
|
+
// from the static-object form, so skipping it would be a false negative, not
|
|
98
|
+
// caution.
|
|
99
|
+
run: ({ part, resolveExpectOnce }) => {
|
|
100
|
+
const { expect } = resolveExpectOnce();
|
|
101
|
+
if (!expect || typeof expect !== "object") return [];
|
|
102
|
+
const names = new Set(Object.keys(part?.parts ?? {}));
|
|
103
|
+
return Object.entries(expect)
|
|
104
|
+
// `sub` must name a real sub-part — a typo'd name is already reported by
|
|
105
|
+
// `verify-unknown-subpart`; skip it here so the two rules don't double-report
|
|
106
|
+
// the same typo under two different ids.
|
|
107
|
+
.filter(([sub, metrics]) => sub !== "_view" && names.has(sub) && metrics && typeof metrics === "object" &&
|
|
108
|
+
Object.keys(metrics).some((k) => REF_METRICS.has(k)) && !part?.parts?.[sub]?.reference)
|
|
109
|
+
.map(([sub]) => warn("ref-metric-without-reference",
|
|
110
|
+
`verify.expect.${sub} uses a ref* metric but sub-part "${sub}" declares no reference`,
|
|
111
|
+
`Add reference: "<import name>" to sub-part "${sub}", or the ref* checks always report status "skip".`,
|
|
112
|
+
`verify.expect.${sub}`));
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
];
|