partforge 0.63.0 → 0.64.1

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 (39) 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/contour-offset.js +18 -6
  13. package/src/framework/geometry/contour-ops.js +28 -1
  14. package/src/framework/geometry/kernel.js +2 -1
  15. package/src/framework/geometry/manifold-backend.js +41 -0
  16. package/src/framework/geometry/mesh-repair.js +87 -0
  17. package/src/framework/geometry/occt-backend.js +33 -1
  18. package/src/framework/geometry/profile.js +45 -3
  19. package/src/framework/geometry/stl-parse.js +45 -0
  20. package/src/framework/geometry/threemf-parse.js +87 -0
  21. package/src/framework/geometry-service.js +3 -1
  22. package/src/framework/imports.js +84 -0
  23. package/src/framework/jobs.js +20 -1
  24. package/src/framework/lint/index.js +2 -1
  25. package/src/framework/lint/rules-imports.js +115 -0
  26. package/src/framework/mount.js +94 -1
  27. package/src/framework/oracle/measure.js +28 -2
  28. package/src/framework/verify-metrics.js +10 -0
  29. package/src/framework/worker.js +11 -2
  30. package/src/import-demo-worker.js +3 -0
  31. package/src/parts/assets/import-demo-scan.stl +86 -0
  32. package/src/parts/import-demo.js +134 -0
  33. package/src/testing/assets.js +19 -0
  34. package/src/testing/manifold.js +14 -2
  35. package/src/testing/occt.js +5 -2
  36. package/src/testing/step-mesh-thread.js +15 -0
  37. package/src/testing/step-mesh.js +17 -0
  38. package/types/kernel.d.ts +6 -0
  39. package/types/part.d.ts +14 -0
@@ -4,6 +4,8 @@ import { sweepMesh } from "./sweep.js";
4
4
  import { roundedBoxRings } from "./rounded-solids.js";
5
5
  import { tessellateContour, tessellateProfile } from "./profile.js";
6
6
  import { h } from "./solid-hash.js";
7
+ import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
8
+ import { manifoldFromMesh } from "./mesh-build.js";
7
9
  import { createSolidCache } from "./solid-cache.js";
8
10
  import { addSugar } from "./solid-sugar.js";
9
11
  import { makeShape2dFactory } from "./shape2d.js";
@@ -49,6 +51,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
49
51
  const cache = createSolidCache();
50
52
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
51
53
  const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
54
+ // name -> { m, digest, hash } | { error, digest } — imported geometry the framework
55
+ // registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
56
+ // these masters must survive cleanup() and be read again on every subsequent build.
57
+ const imports = new Map();
52
58
  // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
53
59
  // tracks the result, and returns the triple the cache needs to pin/dispose it.
54
60
  const cached = (hash, computeM) => cache.lookup(hash, () => {
@@ -330,6 +336,41 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
330
336
  union: (solids) => solids.length === 1
331
337
  ? solids[0]
332
338
  : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
339
+ // Imported geometry, registered pre-build by the framework via `_registerImport`
340
+ // (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
341
+ // see `imports` above); wrap() is free, so every call is cheap.
342
+ import: (name) => {
343
+ const e = imports.get(name);
344
+ if (!e) throw new Error(`import: unknown import "${name}" — declare it in the part's \`imports\` field`);
345
+ if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
346
+ return wrap(e.m, e.hash);
347
+ },
348
+ // Side-channel (underscore = off-contract, probe-invisible). Registration is
349
+ // TOTAL — it never throws for an unusable format; an `{error}` entry is stored
350
+ // verbatim and thrown by `import(name)` above at call time (spec: "Registration
351
+ // is total; errors are lazy"). Re-registering the same name+digest is a no-op
352
+ // EXCEPT an error entry is always upgradable (the post-crossover retry depends
353
+ // on this — see `_importDigest`).
354
+ _registerImport: ({ name, digest, positions, indices, error }) => {
355
+ const prev = imports.get(name);
356
+ if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
357
+ if (error) { imports.set(name, { error, digest }); return; }
358
+ ensureOutward(positions, indices);
359
+ let m;
360
+ try {
361
+ m = manifoldFromMesh(wasm, positions, indices);
362
+ if (m.isEmpty()) throw new Error("empty result");
363
+ } catch (err) {
364
+ const open = openEdgeCount(positions, indices);
365
+ throw new Error(`import "${name}": mesh is not a solid after repair (${open} open edges) — repair it in a mesh tool or re-export watertight (${err?.message || err})`);
366
+ }
367
+ prev?.m?.delete?.(); // prev may be an error entry with no manifold
368
+ imports.set(name, { m, digest, hash: h("import", name, digest) });
369
+ },
370
+ // Registration memo: undefined for an error entry, so a later registration with
371
+ // the same digest can upgrade it rather than being treated as a no-op repeat.
372
+ _importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
373
+ _acceptsMesh: true,
333
374
  shape2d,
334
375
  // Backend-internal region adapter: the shared native engine (contour-offset.js)
335
376
  // that Shape2D.offset itself runs on — published here for callers that want the
@@ -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(),
@@ -118,14 +118,36 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
118
118
  recurse(p0, c1, c2, p1, 0);
119
119
  if (out.length === 0) out.push([p1[0], p1[1]]);
120
120
  out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
121
- return out;
121
+ // Emit-if-moved: near a cusp (a degenerate loop-back cubic — e.g. the winding
122
+ // resolver's splice debris, start === end with controls ~0.01 mm out) the curve's
123
+ // SPEED collapses, so depth-capped parameter-uniform splits cluster spatially and
124
+ // the raw list carries runs of samples nanometers apart (measured min gap 2e-9 mm).
125
+ // Those land in tessellated rings as coincident points and poison every consumer
126
+ // (sliver wall facets, mesh edge chains, ring dedup). Keep a sample only once it has
127
+ // moved SAMPLE_EPS from the last kept one; the exact endpoint stays pinned — it
128
+ // replaces a final sample that stopped short of it, so the one pair flanking the pin
129
+ // may be tighter than SAMPLE_EPS, and that is the only pair allowed to be. 1e-6 mm is
130
+ // a nanometer: far below any legitimate facet spacing (a 1 µm-radius arc at segs 96
131
+ // still spaces ~6e-5), so no real curve loses a sample.
132
+ const SAMPLE_EPS = 1e-6;
133
+ const kept = [];
134
+ let last = p0;
135
+ for (const p of out) {
136
+ if (Math.hypot(p[0] - last[0], p[1] - last[1]) < SAMPLE_EPS) continue;
137
+ kept.push(p);
138
+ last = p;
139
+ }
140
+ if (kept.length && Math.hypot(kept[kept.length - 1][0] - p1[0], kept[kept.length - 1][1] - p1[1]) < SAMPLE_EPS)
141
+ kept[kept.length - 1] = [p1[0], p1[1]];
142
+ else kept.push([p1[0], p1[1]]);
143
+ return kept;
122
144
  }
123
145
 
124
146
  // Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
125
147
  // (identical to the former path); a path contour is walked start→segment→segment, lines
126
148
  // pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
127
149
  export function tessellateContour(contour, segs) {
128
- if (Array.isArray(contour)) return contour;
150
+ if (Array.isArray(contour)) return contour; // legacy point list: caller's data, bit-exact
129
151
  const ring = [[contour.start[0], contour.start[1]]];
130
152
  let prev = contour.start;
131
153
  for (const seg of contour.segments) {
@@ -134,7 +156,27 @@ export function tessellateContour(contour, segs) {
134
156
  else ring.push([seg.to[0], seg.to[1]]);
135
157
  prev = seg.to;
136
158
  }
137
- return ring;
159
+ // Coincident consecutive points never survive tessellation: a zero-length line segment
160
+ // (or a sampler edge case) would otherwise land verbatim in the ring and hand every
161
+ // consumer (extrude walls, mesh edge chains, silhouette masks) a degenerate edge. The
162
+ // very last point is kept unconditionally — it replaces a duplicate predecessor rather
163
+ // than being dropped — so the explicit-closure convention (final point lands exactly on
164
+ // `start`) survives the sweep.
165
+ const TESS_EPS = 1e-9;
166
+ const out = [ring[0]];
167
+ for (let i = 1; i < ring.length; i++) {
168
+ const p = ring[i], last = out[out.length - 1];
169
+ if (Math.hypot(p[0] - last[0], p[1] - last[1]) < TESS_EPS) {
170
+ if (i === ring.length - 1) out[out.length - 1] = p; // keep the exact closure point
171
+ continue;
172
+ }
173
+ out.push(p);
174
+ }
175
+ // A path contour always tessellates to ≥2 points (start + arrival), even when the whole
176
+ // contour is degenerate — consumers walk poly EDGES (contour-winding's pieceSamples) and
177
+ // a single-point poly has none to walk.
178
+ if (out.length < 2) return [ring[0], ring[ring.length - 1]];
179
+ return out;
138
180
  }
139
181
 
140
182
  // Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
@@ -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