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.
Files changed (36) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +135 -0
  3. package/docs/ERROR-PATTERNS.md +29 -1
  4. package/docs/KERNEL-CONTRACT.md +15 -1
  5. package/package.json +1 -1
  6. package/src/app-import-demo.js +18 -0
  7. package/src/framework/app.css +8 -1
  8. package/src/framework/asset-resolve.js +71 -0
  9. package/src/framework/capture-build.js +12 -4
  10. package/src/framework/export-controller.js +12 -0
  11. package/src/framework/fonts.js +12 -30
  12. package/src/framework/geometry/kernel.js +2 -1
  13. package/src/framework/geometry/manifold-backend.js +41 -0
  14. package/src/framework/geometry/mesh-repair.js +87 -0
  15. package/src/framework/geometry/occt-backend.js +33 -1
  16. package/src/framework/geometry/stl-parse.js +45 -0
  17. package/src/framework/geometry/threemf-parse.js +87 -0
  18. package/src/framework/geometry-service.js +3 -1
  19. package/src/framework/imports.js +84 -0
  20. package/src/framework/jobs.js +20 -1
  21. package/src/framework/lint/index.js +2 -1
  22. package/src/framework/lint/rules-imports.js +115 -0
  23. package/src/framework/mount.js +94 -1
  24. package/src/framework/oracle/measure.js +28 -2
  25. package/src/framework/verify-metrics.js +10 -0
  26. package/src/framework/worker.js +11 -2
  27. package/src/import-demo-worker.js +3 -0
  28. package/src/parts/assets/import-demo-scan.stl +86 -0
  29. package/src/parts/import-demo.js +134 -0
  30. package/src/testing/assets.js +19 -0
  31. package/src/testing/manifold.js +14 -2
  32. package/src/testing/occt.js +5 -2
  33. package/src/testing/step-mesh-thread.js +15 -0
  34. package/src/testing/step-mesh.js +17 -0
  35. package/types/kernel.d.ts +6 -0
  36. 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
+ }
@@ -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 === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
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
+ ];