partforge 0.62.1 → 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 (39) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +158 -0
  3. package/docs/ERROR-PATTERNS.md +35 -1
  4. package/docs/KERNEL-CONTRACT.md +38 -2
  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 +4 -3
  13. package/src/framework/geometry/manifold-backend.js +50 -0
  14. package/src/framework/geometry/mesh-repair.js +87 -0
  15. package/src/framework/geometry/mesh-roundall.js +83 -0
  16. package/src/framework/geometry/occt-backend.js +42 -1
  17. package/src/framework/geometry/occt-roundall.js +94 -0
  18. package/src/framework/geometry/op-options.js +2 -0
  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 +12 -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";
@@ -13,6 +15,7 @@ import { meshToStl } from "./mesh-stl.js";
13
15
  import { creasedNormals } from "./creased-normals.js";
14
16
  import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
15
17
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
18
+ import { meshRoundAll } from "./mesh-roundall.js";
16
19
  import { KernelCapabilityError } from "./errors.js";
17
20
 
18
21
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
@@ -48,6 +51,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
48
51
  const cache = createSolidCache();
49
52
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
50
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();
51
58
  // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
52
59
  // tracks the result, and returns the triple the cache needs to pin/dispose it.
53
60
  const cached = (hash, computeM) => cache.lookup(hash, () => {
@@ -143,6 +150,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
143
150
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
144
151
  meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
145
152
  },
153
+ roundAll: (r) => {
154
+ if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
155
+ // `quality` in the key is redundant but harmless — the cache lives on a
156
+ // per-quality kernel, so it can never collide across tiers (the OCCT twin
157
+ // key omits it for the same reason); it just spells out that the ball
158
+ // tessellation, and so the result, is tier-dependent.
159
+ return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
160
+ },
146
161
  cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
147
162
  () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
148
163
  intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
@@ -321,6 +336,41 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
321
336
  union: (solids) => solids.length === 1
322
337
  ? solids[0]
323
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,
324
374
  shape2d,
325
375
  // Backend-internal region adapter: the shared native engine (contour-offset.js)
326
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
+ }
@@ -0,0 +1,83 @@
1
+ // Morphological whole-solid rounding on the mesh backend: close-then-open with
2
+ // a ball — dilate(+r), erode(2r), dilate(+r) via Manifold's native Minkowski
3
+ // sum/difference. Rounds EVERY edge (convex and concave) at radius ≈ r and
4
+ // consumes features smaller than the ball (walls < 2r melt, holes < 2r seal) —
5
+ // that is the op's contract, not a defect (docs/roundall-design.md).
6
+ //
7
+ // simplify() between steps is mandatory, not an optimization: the naive
8
+ // Minkowski (hull-of-triangle-pairs) emits sliver-degenerate meshes whose
9
+ // complexity compounds through the chain — the design spike measured 106s and
10
+ // 761k broken-topology triangles without it vs 2.4s and 340 clean triangles
11
+ // with it, on the same torture case. asOriginal() first, because simplify()
12
+ // will not collapse triangles across run (originalID) boundaries and the
13
+ // Minkowski output is stitched from many.
14
+ //
15
+ // This op must NEVER throw KernelCapabilityError / NEEDS_OCCT: the mesh
16
+ // backend is roundAll's reference implementation — rerouting to OCCT would
17
+ // trade a correct result for a skip (occt-roundall.js can only skip where
18
+ // morphology exceeds what B-rep offsets support).
19
+ //
20
+ // INVARIANT — both balls share ONE segment count. Minkowski support functions
21
+ // add, so the chain displaces a face with normal n by 2·h_r(n) − h_2r(n), where
22
+ // h is the ball's support. Manifold spheres built with equal `segs` are similar
23
+ // (the 2r ball is the r ball scaled by two), so h_2r = 2·h_r and the term is
24
+ // exactly zero in EVERY direction — the input's planar faces return to their
25
+ // original planes. Give the two balls different segment counts and the term is
26
+ // only zero where a vertex happens to line up: axis-aligned boxes still look
27
+ // right while off-axis faces drift (measured 0.07mm at preview, r=2). The count
28
+ // is sized from the erosion ball (2r), the larger of the two, so the coarser of
29
+ // the two facetings still meets the tier's sagitta tolerance.
30
+
31
+ // Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
32
+ // fewest segments that keep it under the quality tier's tolerance.
33
+ const SAGITTA_TOL = { preview: 0.05, print: 0.01 }; // mm
34
+ export function roundAllSegs(r, quality) {
35
+ const tol = SAGITTA_TOL[quality] ?? SAGITTA_TOL.preview;
36
+ if (!(r > tol)) return 12;
37
+ return Math.min(64, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
38
+ }
39
+
40
+ export function meshRoundAll(wasm, m, r, quality) {
41
+ if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
42
+ const segs = roundAllSegs(2 * r, quality); // ONE count for BOTH balls — see the invariant above
43
+ // Simplify tolerance: max(r/100, 0.01) mm, but never enough to collapse the
44
+ // rim ring of a rounded edge into the next ring up. That ring sits
45
+ // r·(1 − cos(2π/segs)) above the face plane, and it is what holds a planar
46
+ // face at its exact position; collapsing it shaves the face inward (print
47
+ // tier, r = 2: segs 45 puts the ring 0.0195 mm up, and a flat 0.02 tolerance
48
+ // pulled every face of a box in by 0.034 mm). Half that spacing keeps margin.
49
+ const tol = Math.min(Math.max(r / 100, 0.01), 0.5 * r * (1 - Math.cos((2 * Math.PI) / segs)));
50
+ const step = (input, sphere, op) => {
51
+ const raw = op === "sum" ? input.minkowskiSum(sphere) : input.minkowskiDifference(sphere);
52
+ let orig;
53
+ try {
54
+ orig = raw.asOriginal();
55
+ } finally {
56
+ raw.delete?.();
57
+ }
58
+ try {
59
+ return orig.simplify(tol);
60
+ } finally {
61
+ orig.delete?.();
62
+ }
63
+ };
64
+ const sphR = wasm.Manifold.sphere(r, segs);
65
+ const sph2R = wasm.Manifold.sphere(2 * r, segs);
66
+ try {
67
+ const a = step(m, sphR, "sum"); // dilate: rounds convex, seals holes < 2r
68
+ let b;
69
+ try {
70
+ b = step(a, sph2R, "diff"); // erode 2r: melts walls < 2r
71
+ } finally {
72
+ a.delete?.();
73
+ }
74
+ try {
75
+ return step(b, sphR, "sum"); // dilate back: final radius ≈ r everywhere
76
+ } finally {
77
+ b.delete?.();
78
+ }
79
+ } finally {
80
+ sphR.delete?.();
81
+ sph2R.delete?.();
82
+ }
83
+ }
@@ -21,6 +21,7 @@ import { addSugar } from "./solid-sugar.js";
21
21
  import { makeShape2dFactory } from "./shape2d.js";
22
22
  import { finishKernel } from "./kernel-front.js";
23
23
  import { createOcctRepair } from "./occt-repair.js";
24
+ import { occtRoundAll } from "./occt-roundall.js";
24
25
  import { classifyFaceGroups } from "./feature-attribution.js";
25
26
  import { resolveRings } from "./loft.js";
26
27
  import { resolveSweepStations } from "./sweep.js";
@@ -36,12 +37,17 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tol
36
37
 
37
38
  export function createOcctKernel(replicad) {
38
39
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
39
- loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
40
+ loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
40
41
 
41
42
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
42
43
  // see occt-repair.js for the policies and why they differ per op.
43
44
  const { validChamfer, safeOp } = createOcctRepair(measureVolume);
44
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
+
45
51
  const cache = createSolidCache();
46
52
  // Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
47
53
  // GC frees WASM via replicad's FinalizationRegistry) and dispose is a no-op:
@@ -223,6 +229,14 @@ export function createOcctKernel(replicad) {
223
229
  return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
224
230
  });
225
231
  },
232
+ roundAll: (r) => {
233
+ const key = h("roundAll", hash, r);
234
+ return cached(key, () => {
235
+ const a = mat();
236
+ if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
237
+ return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
238
+ });
239
+ },
226
240
  shell: (thickness, openFaces) => {
227
241
  if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
228
242
  const key = h("shell", hash, thickness, selKey(openFaces));
@@ -478,6 +492,33 @@ export function createOcctKernel(replicad) {
478
492
  // surface.
479
493
  _offsetRegions: offsetRegions,
480
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,
481
522
  beginSubPart: (name) => cache.begin(name),
482
523
  endSubPart: () => cache.end(),
483
524
  sweepCache: () => cache.sweep(),
@@ -0,0 +1,94 @@
1
+ // B-rep side of roundAll: dilate(+r), erode(-2r), dilate(+r) via raw OCCT
2
+ // BRepOffsetAPI_MakeOffsetShape (replicad has no solid-offset wrapper). Each
3
+ // step runs a variant cascade — the design spike showed no single parameter
4
+ // combo survives all shapes: plain solids dilate with Arc/Intersection=false,
5
+ // the chained erosion only succeeds with Arc/Intersection=true, and concave
6
+ // post-boolean solids only dilate with the Intersection join (morphologically
7
+ // fine: the FINAL Arc dilation supplies the convex rounding).
8
+ //
9
+ // A candidate is accepted only if IsDone, non-null, closed (mesh check),
10
+ // finite positive volume, AND volume-monotonic (dilation must not shrink,
11
+ // erosion must not grow). The monotonic gate is load-bearing: OCCT offsets
12
+ // return BRepCheck-valid garbage — the spike saw an erosion "succeed" at 0.4%
13
+ // of the input volume, and a sealed hole come back as an 11% crater. A gated
14
+ // failure skips the WHOLE op (warn + clone), mirroring safeOp's fillet policy
15
+ // and for the same reason: OCCT offset failures are not monotonic in r, so
16
+ // searching for a "largest working radius" would converge on garbage.
17
+ // Feature-consuming radii (r at/above the smallest feature) are the expected
18
+ // skip trigger — true consumption is mesh-class-only (docs/roundall-design.md).
19
+ //
20
+ // Every WASM object made here is freed, same convention as occt-repair.js, so
21
+ // OCCT's heap doesn't grow across regenerates: up to twelve offset builders and
22
+ // progress ranges, every rejected candidate, and each superseded intermediate.
23
+ // The one thing never freed is the caller's `shape` — it belongs to the caller,
24
+ // and the skip path hands back a clone of it.
25
+ import { isClosedSolid } from "./occt-repair.js";
26
+
27
+ const VARIANTS = [
28
+ { join: "arc", inter: false },
29
+ { join: "arc", inter: true },
30
+ { join: "int", inter: false },
31
+ { join: "int", inter: true },
32
+ ];
33
+
34
+ export function occtRoundAll(replicad, shape, r) {
35
+ if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
36
+ const oc = replicad.getOC();
37
+ const tryOffset = (topo, offset, v) => {
38
+ const progress = new oc.Message_ProgressRange_1();
39
+ const mk = new oc.BRepOffsetAPI_MakeOffsetShape();
40
+ try {
41
+ mk.PerformByJoin(topo, offset, 1e-6,
42
+ oc.BRepOffset_Mode.BRepOffset_Skin, v.inter, false,
43
+ v.join === "arc" ? oc.GeomAbs_JoinType.GeomAbs_Arc : oc.GeomAbs_JoinType.GeomAbs_Intersection,
44
+ false, progress);
45
+ if (!mk.IsDone()) return null;
46
+ const s = mk.Shape();
47
+ // Wrap BEFORE the finally frees the builder — replicad's own idiom for this
48
+ // very algorithm (its `offset()` does `cast(offsetBuilder.Shape()); offsetBuilder.delete()`).
49
+ // The wrapper carries its own TopoDS_Shape handle, so the result outlives `mk`.
50
+ return s.IsNull() ? null : new replicad.Solid(s);
51
+ } catch {
52
+ return null;
53
+ } finally {
54
+ mk.delete?.();
55
+ progress.delete?.();
56
+ }
57
+ };
58
+ let vol;
59
+ try {
60
+ vol = replicad.measureVolume(shape);
61
+ } catch (e) {
62
+ // Can't gate what can't be measured — skip rather than run the cascade blind.
63
+ console.warn(`partforge: roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
64
+ return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
65
+ }
66
+ let cur = shape;
67
+ for (const off of [r, -2 * r, r]) {
68
+ let next = null;
69
+ for (const v of VARIANTS) {
70
+ const cand = tryOffset(cur.wrapped, off, v);
71
+ if (!cand) continue;
72
+ let cvol;
73
+ try { cvol = replicad.measureVolume(cand); } catch { cand.delete?.(); continue; }
74
+ if (!Number.isFinite(cvol) || cvol <= 0) { cand.delete?.(); continue; }
75
+ if (off > 0 && cvol < vol * 0.999) { cand.delete?.(); continue; } // dilation shrank: garbage
76
+ if (off < 0 && cvol > vol * 1.001) { cand.delete?.(); continue; } // erosion grew: garbage
77
+ // isClosedSolid meshes the candidate, and meshing OCCT offset garbage can
78
+ // throw — that is just another rejected candidate, not an escape hatch out
79
+ // of "roundAll never throws for geometry".
80
+ try { if (!isClosedSolid(cand)) { cand.delete?.(); continue; } }
81
+ catch { cand.delete?.(); continue; }
82
+ next = cand;
83
+ vol = cvol;
84
+ break;
85
+ }
86
+ if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
87
+ if (!next) {
88
+ console.warn(`partforge: roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
89
+ return shape.clone();
90
+ }
91
+ cur = next;
92
+ }
93
+ return cur;
94
+ }
@@ -289,6 +289,8 @@ export const SOLID_OP_SPECS = {
289
289
  return [req("chamfer", o, "d"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
290
290
  shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
291
291
  return [req("shell", o, "t"), req("shell", o, "open")]; } },
292
+ roundAll: { toArgs: (o) => { checkKeys("roundAll", o, ["r"]);
293
+ return [req("roundAll", o, "r")]; } },
292
294
  };
293
295
 
294
296
  // A zero-magnitude fillet/chamfer is the identity on every conformance class
@@ -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
  }