partforge 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,8 @@ Two geometry backends run in Web Workers, and partforge routes each part to whic
43
43
  needs:
44
44
 
45
45
  - **[Manifold](https://github.com/elalish/manifold)** — fast preview meshes and STL / 3MF,
46
- including mesh-native fillet and chamfer for straight and circular edges.
46
+ including mesh-native fillet and chamfer for straight, circular, planar-rim and
47
+ curved-face edges.
47
48
  - **[Replicad](https://replicad.xyz)** (OpenCASCADE-in-WebAssembly) — exact B-rep for STEP,
48
49
  shell, and automatic fallback for blend geometry Manifold cannot handle.
49
50
 
@@ -3583,7 +3583,7 @@ previously didn't; that's the fix working as intended, not a regression.
3583
3583
  `duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
3584
3584
  `select-default-not-in-options`, `log-scale-needs-positive-min`,
3585
3585
  `when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type`,
3586
- `custom-control-widget-not-function`, `custom-default-not-json`,
3586
+ `legacy-shape-widget`, `custom-control-widget-not-function`, `custom-default-not-json`,
3587
3587
  `custom-keys-not-in-defaults` (errors);
3588
3588
  `slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
3589
3589
  `default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
@@ -3627,6 +3627,13 @@ each comparison operator (`{ gt: 0 }`, `{ in: [...] }`, …) must be one
3627
3627
  condition is always false and the node never shows — which is why both are
3628
3628
  errors rather than warnings.
3629
3629
 
3630
+ `legacy-shape-widget` fires when an entry in the legacy shape (`advanced`, `toggles`,
3631
+ `features`) sets `type`, or sets `control` to anything but `slider`, `number`, `text` or
3632
+ `textarea`. The legacy shape never reads `type`, so a `{ type: "select", options }` there
3633
+ renders as a slider and writes numbers into a key the build compares against strings;
3634
+ put such a control in a `controls` section, where `type` picks the widget. A boolean in the
3635
+ legacy shape belongs in `toggles`.
3636
+
3630
3637
  `unknown-control-type` fires when an authored control's `type` (e.g. a typo
3631
3638
  like `"sldier"`) isn't one of the recognised widget types — the renderer skips
3632
3639
  a node with an unrecognised type entirely, so the control silently vanishes
@@ -4156,9 +4163,11 @@ contract v3 that **includes fillet and chamfer**: the mesh backend blends straig
4156
4163
  edges, circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
4157
4164
  face), and **planar contour edges at constant dihedral** — the top/bottom rims of any
4158
4165
  extruded profile, however curvy its outline: `text2d` lettering, `Shape2D.offset`
4159
- outlines, spline profiles all round natively now. Only `shell` still routes a
4166
+ outlines, spline profiles all round natively now. Edges **between two curved faces** — a boss meeting a tube, a cross hole's rim, the
4167
+ ellipse where a plane cuts a cylinder — blend natively too (a per-vertex
4168
+ cross-section, since 0.138). Only `shell` still routes a
4160
4169
  sub-part to OCCT up front; a fillet/chamfer on an edge class the mesh backend can't
4161
- blend (helical edges, varying dihedral) reroutes that sub-part to OCCT automatically
4170
+ blend (mixed convexity along one edge, a bend tighter than the fillet radius, knife edges) reroutes that sub-part to OCCT automatically
4162
4171
  at runtime — no declaration needed either way:
4163
4172
 
4164
4173
  | Op | Meaning |
@@ -763,8 +763,8 @@ between the Manifold preview and the OCCT STEP export.
763
763
 
764
764
  ## mesh-fillet-unsupported-edge
765
765
 
766
- - **Symptom:** `fillet: ` or `chamfer: ` followed by an edge-class reason — e.g. `edge curve is not circular`, `flank angle varies along the arc`, `selector matched no sharp edges`, `~180° knife edge` — thrown as a `KernelCapabilityError`, or a preview sub-part silently rebuilding on the slow OCCT worker.
767
- - **Cause:** The mesh backend's native fillet/chamfer (`mesh-fillet.js`) covers straight and circular-arc sharp-edge chains; the selected edges fall outside that (helical edge, varying dihedral, non-circular curve, or nothing sharp matched), so the op signals `NEEDS_OCCT` and the framework reroutes that sub-part (the CLI re-execs once with `PARTFORGE_BACKEND=occt`).
766
+ - **Symptom:** `fillet: ` or `chamfer: ` followed by an edge-class reason — e.g. `edge curve is not circular`, `flank angle varies along the arc`, `selector matched no sharp edges`, `~180° knife edge`, `general chain: bend too tight for fillet 2 (local radius 1.48 mm)`, `general chain: too complex for the mesh fillet (work 303831 > budget 195000)`, `general chain: too many sections for the mesh fillet (… stations > …)` — thrown as a `KernelCapabilityError`, or a preview sub-part silently rebuilding on the slow OCCT worker.
767
+ - **Cause:** The mesh backend's native fillet/chamfer (`mesh-fillet.js`) covers straight, circular, planar-rim and curved-face sharp-edge chains; the selected edges fall outside that (an edge that flips between convex and concave, a bend tighter than the radius, a knife edge, or nothing sharp matched), so the op signals `NEEDS_OCCT` and the framework reroutes that sub-part (the CLI re-execs once with `PARTFORGE_BACKEND=occt`). The two `general chain: too …` reasons are complexity refusals, not edge classes: a selection containing a curved-face edge whose estimated blend work (every selected edge, weighted by kind, × √triangles) exceeds the budget — thread, knurl and texture geometry with thousands of sharp edges behind a few curved ones — or a single curved-face edge needing more cross-sections than the cap. Both are deterministic counts, never timings; because the work grows with √triangles, a sub-part near the budget can blend at preview and reroute at print (same reroute, OCCT result correct).
768
768
  - **Fix:** Usually nothing — the reroute is the designed degrade and the OCCT result is correct, just slower. To stay on Manifold, restructure so the blend lands on a supported edge class (design the rounding into the profile, or fillet before the boolean that curves the edge). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet, chamfer & shell".
769
769
 
770
770
  ## mesh-fillet-oversized-radius
@@ -48,12 +48,19 @@ list).
48
48
  implements `fillet` and `chamfer` natively** (`mesh-fillet.js` — tangent-tool CSG).
49
49
  Its coverage and tolerance band are part of the contract:
50
50
 
51
- - **Edge classes:** straight sharp edges with planar flanks, and circular-arc sharp
51
+ - **Edge classes:** straight sharp edges with planar flanks; circular-arc sharp
52
52
  edges whose flanks are surfaces of revolution about the arc axis (bore rims,
53
- cylinder rims, the arcs where blends meet a face) — full circles included. Convex
54
- edges subtract a cutter; concave edges union a filler. Any other edge class
55
- (helical edges, varying dihedral, non-circular curves, function selectors) throws
56
- `KernelCapabilityError` so the host can reroute that build to a B-rep kernel.
53
+ cylinder rims, the arcs where blends meet a face) — full circles included;
54
+ planar-rim edges (an edge lying in a face plane at a constant wall angle — the
55
+ rims of any extruded outline); and, since 0.138, curved-face edges between two
56
+ curved faces (a boss meeting a tube, a cross hole's rim, the ellipse where a plane
57
+ cuts a cylinder), blended with a per-vertex cross-section. Convex edges subtract a
58
+ cutter; concave edges union a filler. A selection the mesh class cannot blend
59
+ throws `KernelCapabilityError` so the host can reroute that build to a B-rep
60
+ kernel: an edge that flips between convex and concave along its length, a bend
61
+ tighter than the radius, a knife edge (anti-parallel flanks), a curved-face
62
+ selection over the mesh fillet's complexity budget, or a function selector.
63
+ There is no partial blending — one such edge reroutes the whole sub-part.
57
64
  - **Tolerance band, not identity:** the blend surface is the exact rolling-ball
58
65
  (fillet) or setback-chord (chamfer) surface to within tessellation, plus
59
66
  micron-scale robustness allowances (tool overshoot past tangency and seam-grazing
@@ -370,7 +377,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
370
377
  | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends — see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
371
378
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
372
379
  | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
373
- | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | `fillet`/`chamfer`: implemented on BOTH in-repo classes since v3 — exactly on B-rep, tolerance-band on the mesh class for straight and circular-arc edge chains (see [Conformance classes](#conformance-classes)); an edge class the mesh kernel cannot blend throws `KernelCapabilityError` and reroutes. Zero magnitude — `fillet(0)` / `chamfer({d: 0})` — is the identity on every class (returns the solid unchanged, never throws; `shell` excluded, `t: 0` is degenerate). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` remains B-rep-only (core throws), hollows inward keeping outer dimensions; `open` (face selector) is required. |
380
+ | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | `fillet`/`chamfer`: implemented on BOTH in-repo classes since v3 — exactly on B-rep, tolerance-band on the mesh class for straight, circular-arc, planar-rim and curved-face edge chains (see [Conformance classes](#conformance-classes)); an edge class the mesh kernel cannot blend throws `KernelCapabilityError` and reroutes. Zero magnitude — `fillet(0)` / `chamfer({d: 0})` — is the identity on every class (returns the solid unchanged, never throws; `shell` excluded, `t: 0` is degenerate). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` remains B-rep-only (core throws), hollows inward keeping outer dimensions; `open` (face selector) is required. |
374
381
  | `roundAll(r)` · `roundAll({r})` | Morphological close-then-open with a ball of radius `r`: rounds EVERY edge (convex and concave) at radius ≈ `r`; faces stay in place (within the class's tolerance band — the B-rep offset chain can drift ~0.1 mm); features smaller than the ball are consumed (walls < 2r melt, holes < 2r seal). Implemented natively on BOTH classes — never routes, never throws `KernelCapabilityError`. Parity-tolerant only while `r` is below the smallest feature size; at consuming radii the mesh class performs true consumption and a B-rep class MAY skip the whole op unchanged with a `roundall-skipped` warning (skip is the only permitted degrade). `roundAll(0)` is the identity on every class. |
375
382
 
376
383
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
@@ -942,7 +949,9 @@ now implement it (no stub, unlike the `OCCT_ONLY_OPS` ops).
942
949
 
943
950
  **v2 → v3** (partforge 0.62): `Solid.fillet` and `Solid.chamfer` are implemented
944
951
  natively on the mesh (core reference) kernel for straight and circular-arc edge
945
- chains, and are **no longer probe-routed to OCCT** — `ROUTED_CAD_OPS` (`shell`) is
952
+ chains (coverage has since grown inside v3 with no contract bump: planar-rim edges,
953
+ then curved-face edges in 0.138 — see [Conformance classes](#conformance-classes)),
954
+ and are **no longer probe-routed to OCCT** — `ROUTED_CAD_OPS` (`shell`) is
946
955
  the remaining probe-routing set, and unsupported edge classes reroute at runtime via
947
956
  `KernelCapabilityError`. Semantics change for existing parts: a part using fillet or
948
957
  chamfer now previews (and STL/3MF-exports) from the mesh kernel's tolerance-band
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.137.0",
3
+ "version": "0.138.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,9 @@
19
19
  // the swept path, f its face normal, dirs[i] the
20
20
  // in-plane wall-side direction at vertex i, and the
21
21
  // ball centre sits at kf·f + kd·dir off the edge
22
+ // { kind: "spine", pts, closed, r }
23
+ // general chain (mesh-fillet generalTool): the
24
+ // ball-centre polyline itself; r is the radius
22
25
  //
23
26
  // A path's polyline usually STANDS FOR a smooth curve (a circle's rim, an outline's
24
27
  // rounded corner): its per-segment cylinders would shade faceted around the curve,
@@ -97,6 +100,11 @@ export function mapSurface(desc, M) {
97
100
  dirs: desc.dirs.map((d) => norm(applyDir(M, d)) ?? d), f: norm(applyDir(M, desc.f)) ?? desc.f,
98
101
  kf: desc.kf * s, kd: desc.kd * s };
99
102
  }
103
+ case "spine": {
104
+ const s = Math.cbrt(Math.abs(
105
+ M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
106
+ return { kind: "spine", closed: desc.closed, pts: desc.pts.map((p) => applyPt(M, p)), r: desc.r * s };
107
+ }
100
108
  default: throw new Error(`blend surface: unknown kind ${desc.kind}`);
101
109
  }
102
110
  }
@@ -168,7 +176,7 @@ function segmentGrid(desc) {
168
176
  const { pts, closed } = desc, n = pts.length, nSeg = closed ? n : n - 1;
169
177
  let total = 0;
170
178
  for (let i = 0; i < nSeg; i++) { const p = pts[i], q = pts[(i + 1) % n]; total += Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]); }
171
- const h = Math.max(total / Math.max(1, nSeg), Math.abs(desc.kf) + Math.abs(desc.kd), 1e-6);
179
+ const h = Math.max(total / Math.max(1, nSeg), Math.abs(desc.kf ?? 0) + Math.abs(desc.kd ?? 0) + (desc.r ?? 0), 1e-6);
172
180
  const cells = new Map();
173
181
  // numeric hashed cell keys: a collision only adds candidates, each distance-checked
174
182
  const key = (a, b, c) => (Math.imul(a, 73856093) ^ Math.imul(b, 19349663) ^ Math.imul(c, 83492791)) | 0;
@@ -232,6 +240,13 @@ function nearestSegment(desc, x) {
232
240
  // Unit spine→point direction at x in the descriptor's frame, or null where undefined.
233
241
  export function surfaceNormal(desc, x) {
234
242
  if (desc.kind === "path") return pathNormal(desc, x);
243
+ if (desc.kind === "spine") {
244
+ const { bi, bt } = nearestSegment(desc, x);
245
+ if (bi < 0) return null;
246
+ const p = desc.pts[bi], q = desc.pts[(bi + 1) % desc.pts.length];
247
+ const c = [p[0] + (q[0] - p[0]) * bt, p[1] + (q[1] - p[1]) * bt, p[2] + (q[2] - p[2]) * bt];
248
+ return norm([x[0] - c[0], x[1] - c[1], x[2] - c[2]]);
249
+ }
235
250
  const s = spinePoint(desc, x);
236
251
  return s ? norm([x[0] - s[0], x[1] - s[1], x[2] - s[2]]) : null;
237
252
  }
@@ -30,6 +30,8 @@ const ANALYTIC_COS = cosDeg(25);
30
30
  // nanometre seam splits (see the weld below).
31
31
  const SHADE_WELD = 5e-5;
32
32
  const SHADE_CELL = 1e-3; // its hash-grid cell
33
+ // Vertex-normal sliver cutoff (mm of triangle height): see the smooth loop below.
34
+ const SHADE_SLIVER = 1e-3;
33
35
 
34
36
  export function creasedNormals(g, { policies = null, featureLabels = null, surfaces = null } = {}) {
35
37
  const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
@@ -205,7 +207,25 @@ export function creasedNormals(g, { policies = null, featureLabels = null, surfa
205
207
  normals[o] = ln[0]; normals[o + 1] = ln[1]; normals[o + 2] = ln[2];
206
208
  continue;
207
209
  }
210
+ // Slivers vote last. The sum below is unweighted — every incident triangle that
211
+ // passes the surface and crease filters counts once, whatever its size — so a
212
+ // boolean's degenerate sliver (a wall strip split a hair from its own diagonal by
213
+ // a revolve cut; a loft seam's triangle with a collapsed edge) whose noisy facet
214
+ // normal lands inside the crease angle gets the same vote as the real faces
215
+ // around it. At a tall wall strip's end vertex one or two of them (18–31° off)
216
+ // tilted the whole strip's shading 5–10°: 12.5% of a plain cylinder's wall
217
+ // streaked under a top-rim fillet, 25% under a chamfer. Triangles under
218
+ // SHADE_SLIVER tall are left out of the sum and used only when nothing else
219
+ // contributes (a corner surrounded by slivers keeps today's normal), so shading
220
+ // changes only at vertices touching one. The cutoff sits in a measured gap: the
221
+ // streak slivers are ≤ ~2e-6 mm tall (and the loft seam's 5e-6), while the thin
222
+ // REAL facets of the reference parts (a coarse loft's, a screw's, a nameplate's)
223
+ // are 1e-3–4e-2 mm and keep their vote — MIN_FACE (0.04), the line pass's gate,
224
+ // took those out too and moved a lofted bottle's normals up to 5.6°.
225
+ // mesh-fillet's contact step leans to meet both neighbours at ≥ 45°, so it never
226
+ // passes the crease filter at all, whatever its height (see CONTACT_MU there).
208
227
  let nx = 0, ny = 0, nz = 0, ax = 0, ay = 0, az = 0, analytic = false;
228
+ let sx = 0, sy = 0, sz = 0, solid = false;
209
229
  for (const t2 of incident.get(weld[v])) {
210
230
  // different cut surface → hard, EXCEPT when a blend surface (boundaryLines)
211
231
  // is involved on either side. Blend↔blend: one band is many tool surfaces
@@ -220,12 +240,14 @@ export function creasedNormals(g, { policies = null, featureLabels = null, surfa
220
240
  if (triOID[t2] !== oid &&
221
241
  !(polFor(triOID[t2]).boundaryLines || polFor(oid).boundaryLines)) continue;
222
242
  if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
223
- nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
243
+ if (thin[t2] < SHADE_SLIVER) { sx += fn[t2 * 3]; sy += fn[t2 * 3 + 1]; sz += fn[t2 * 3 + 2]; }
244
+ else { nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2]; solid = true; }
224
245
  if (runEval) {
225
246
  const an = analyticAt(t2, weld[v]);
226
247
  if (an) { ax += an[0]; ay += an[1]; az += an[2]; analytic = true; }
227
248
  }
228
249
  }
250
+ if (!solid) { nx = sx; ny = sy; nz = sz; }
229
251
  if (analytic && Math.hypot(ax, ay, az) > 1e-9) { nx = ax; ny = ay; nz = az; }
230
252
  const L = Math.hypot(nx, ny, nz) || 1;
231
253
  const o = (t * 3 + k) * 3, vv = v * np;
@@ -106,8 +106,8 @@ export const ROUTED_CAD_OPS = ["shell"];
106
106
  * `normals`/`edges` are authoritative shading intent from both backends — see docs/KERNEL-CONTRACT.md "Shading intent"; quality is advisory — the Manifold kernel bakes it at creation
107
107
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
108
108
  * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
109
- * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges; mesh-native for straight/circular chains with automatic OCCT fallback; fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
110
- * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges; mesh-native for straight/circular chains with automatic OCCT fallback; chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
109
+ * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges; mesh-native incl. curved-face edges, OCCT fallback otherwise; fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
110
+ * @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges; mesh-native incl. curved-face edges, OCCT fallback otherwise; chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
111
111
  * @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted for now (see file header)
112
112
  * @property {() => number} [genus] through-hole count (Manifold only)
113
113
  * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
@@ -6,7 +6,7 @@ import { roundedBoxRings, roundedBoxArcSamples } from "./rounded-solids.js";
6
6
  import { tessellateContour, tessellateProfile } from "./profile.js";
7
7
  import { h } from "./solid-hash.js";
8
8
  import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
9
- import { manifoldFromMesh } from "./mesh-build.js";
9
+ import { manifoldFromMesh, ringStackManifold } from "./mesh-build.js";
10
10
  import { createSolidCache } from "./solid-cache.js";
11
11
  import { hoistCommonSuffix } from "./transform-hoist.js";
12
12
  import { addSugar } from "./solid-sugar.js";
@@ -863,12 +863,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
863
863
  if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
864
864
  return wrap(e.m, e.hash);
865
865
  },
866
- // Side-channel (underscore = off-contract, probe-invisible). Registration is
867
- // TOTAL — it never throws for an unusable format; an `{error}` entry is stored
868
- // verbatim and thrown by `import(name)` above at call time (spec: "Registration
869
- // is total; errors are lazy"). Re-registering the same name+digest is a no-op
870
- // EXCEPT an error entry is always upgradable (the post-crossover retry depends
871
- // on this — see `_importDigest`).
866
+ // Side-channel for mesh-fillet's general-chain tools (underscore = off-contract):
867
+ // a solid from a stack of equal-size 3-D rings (mesh-build.js ringStackManifold).
868
+ _ringStackSolid: (rings, opts = {}) =>
869
+ cached(h("ringStack", rings, { closed: !!opts.closed }), () => T(ringStackManifold(wasm, rings, { closed: !!opts.closed }))),
872
870
  // Side-channel for mesh-fillet (underscore = off-contract): `surf` describes the
873
871
  // tool's blend wall in its current frame. Stored per surface id in the id's
874
872
  // ORIGINAL frame (inverse runTransform), so any later pose or boolean — which
@@ -886,6 +884,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
886
884
  }
887
885
  return tool;
888
886
  },
887
+ // Side-channel (underscore = off-contract, probe-invisible). Registration is
888
+ // TOTAL — it never throws for an unusable format; an `{error}` entry is stored
889
+ // verbatim and thrown by `import(name)` above at call time (spec: "Registration
890
+ // is total; errors are lazy"). Re-registering the same name+digest is a no-op
891
+ // EXCEPT an error entry is always upgradable (the post-crossover retry depends
892
+ // on this — see `_importDigest`).
889
893
  _registerImport: ({ name, digest, positions, indices, error }) => {
890
894
  const prev = imports.get(name);
891
895
  if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
@@ -41,6 +41,38 @@ export function reverseWinding(Tr) {
41
41
  for (let t = 0; t < Tr.length; t += 3) { const tmp = Tr[t + 1]; Tr[t + 1] = Tr[t + 2]; Tr[t + 2] = tmp; }
42
42
  }
43
43
 
44
+ // A solid from a stack of equal-size 3-D rings (mesh-fillet's general-chain tools:
45
+ // one cross-section per edge vertex, each in its own plane). Consecutive rings are
46
+ // stitched by sideQuads; `closed` stitches the last ring back to the first (a loop,
47
+ // no caps), otherwise each end ring is capped by a fan from ITS vertex 0 — valid for
48
+ // rings star-shaped about vertex 0, which every fillet/chamfer section is (vertex 0
49
+ // is the section's corner). Ring orientation is arbitrary: the winding is normalized
50
+ // by the mesh's signed volume, because ofMesh throws or imports an inverted solid
51
+ // when it is wrong (see this file's header).
52
+ export function ringStackManifold(wasm, rings, { closed = false } = {}) {
53
+ const n = rings[0]?.length ?? 0;
54
+ if (n < 3 || !rings.every((r) => r.length === n)) throw new Error("ringStack: rings must have equal size (≥ 3 points)");
55
+ const V = [], Tr = [];
56
+ for (const ring of rings) for (const p of ring) V.push(p[0], p[1], p[2]);
57
+ sideQuads(Tr, rings.length, n, closed);
58
+ if (!closed) {
59
+ const last = (rings.length - 1) * n;
60
+ for (let j = 1; j + 1 < n; j++) {
61
+ Tr.push(0, j + 1, j); // first cap (flip, as fanCap's bottom)
62
+ Tr.push(last, last + j, last + j + 1); // last cap
63
+ }
64
+ }
65
+ let vol6 = 0;
66
+ for (let t = 0; t < Tr.length; t += 3) {
67
+ const a = Tr[t] * 3, b = Tr[t + 1] * 3, c = Tr[t + 2] * 3;
68
+ vol6 += V[a] * (V[b + 1] * V[c + 2] - V[b + 2] * V[c + 1])
69
+ - V[a + 1] * (V[b] * V[c + 2] - V[b + 2] * V[c])
70
+ + V[a + 2] * (V[b] * V[c + 1] - V[b + 1] * V[c]);
71
+ }
72
+ if (vol6 < 0) reverseWinding(Tr);
73
+ return manifoldFromMesh(wasm, V, Tr);
74
+ }
75
+
44
76
  // Import a flat vertex array + triangle indices as a watertight Manifold. merge() welds
45
77
  // coincident vertices so ofMesh sees a closed manifold; ofMesh consumes the mesh handle,
46
78
  // so free it here and let the caller track the returned Manifold.