partforge 0.114.0 → 0.116.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.
@@ -3642,7 +3642,24 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3642
3642
  `slotPolygon`, `ringSectorPolygon` and `circleProfile`; mirror a symmetric half with
3643
3643
  `mirrorProfile`.
3644
3644
  - **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
3645
- so builds are quality-agnostic; the export path uses a separate high-res "print" kernel.
3645
+ so builds are quality-agnostic; the export path uses a separate "print" kernel. Preview
3646
+ facets every circle at 116 segments. Print sizes each circle by chord tolerance — the
3647
+ fewest segments that keep the facet sagitta under 0.01 mm — never fewer than the
3648
+ preview's 116 and never more than 480, so a small feature exports at exactly the density
3649
+ you previewed and only circles wider than about 54 mm get finer. A part that previews
3650
+ is a part that exports: the old flat 480 turned a 0.75 mm rivet into 115,200 triangles
3651
+ and a body with a few hundred of them into an out-of-memory trap at export
3652
+ ([export-kernel-out-of-memory](ERROR-PATTERNS.md#export-kernel-out-of-memory)).
3653
+ **Spheres are the exception on both tiers:** a sphere spends the segment count
3654
+ squared (6,728 triangles at 116, whatever its radius), so `k.sphere` is sized by chord
3655
+ tolerance — 0.02 mm at preview, never fewer than 24 segments (288 triangles) and never
3656
+ more than 116, so only spheres under about 60 mm radius get coarser and none get
3657
+ finer. That is what keeps a body studded with a few hundred rivet spheres inside a
3658
+ phone's memory ([preview-build-too-heavy-for-phones](ERROR-PATTERNS.md#preview-build-too-heavy-for-phones)).
3659
+ Spheres are still the costliest way to add small detail: a domed rivet is 288
3660
+ triangles where a short cylinder is 232 and a box is 12, and every one of them is a
3661
+ boolean operand. Prefer instancing one union of a row over a chain of per-feature
3662
+ booleans, and drop counts the print cannot show.
3646
3663
  - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
3647
3664
  depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
3648
3665
  - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
@@ -823,6 +823,18 @@ between the Manifold preview and the OCCT STEP export.
823
823
  - **Cause:** *(partforge ≥ 0.112.)* A hand-authored 2-D profile (a point list, a `pathProfile` contour, a `{outer, holes}` region — handed to a factory op, to `k.shape2d`, or as a `Shape2D` boolean operand) crosses itself. Manifold fills a point ring even-odd, so the crossing quietly inverts the fill on one side instead of failing. The usual author of the crossing is an arc sampled into points by hand (a `Math.cos` loop) whose sweep sign or endpoint order is wrong, or a mirrored half whose point order was not reversed. The kernel now runs `validateProfile` on the way in and reports each crossing on the build result's `warnings`; a `Shape2D` is not re-validated, `text2d`/`vector2d` lifts are trusted, and a profile over 4000 segments is skipped (for `loft`, the ceiling is the sum over all its rings). At most **three** crossings are reported per profile — the third reads `… (and N more crossings on this profile)` — so one badly-drawn star cannot evict every other warning in the build. A hole whose edge touches or runs along its outer is **not** reported: that builds exactly as drawn, and only a contour crossing itself inverts the fill.
824
824
  - **Fix:** Rebuild the curved parts of the outline with `pathProfile(start).lineTo(p).arcTo(to, via).close()` — a three-point arc sweeps through `via`, so its direction cannot flip — or with the `partforge/geometry` helpers (`roundedProfile`, `filletPolygon`, `slotPolygon`, `ringSectorPolygon`, `circleProfile`); build a symmetric half once and `mirrorProfile` it rather than writing the mirror by hand. Confirm with `validateProfile(profile).ok` before extruding. The reported coordinate is in the profile's own frame (before any `rotate`/`at`).
825
825
 
826
+ ## export-kernel-out-of-memory
827
+
828
+ - **Symptom:** `Out of bounds memory access` (Safari) or `memory access out of bounds` (Chrome, Node) from an STL or 3MF export — or from a build — of a part whose preview renders fine; often followed, on every later build in the same session, by `Manifold instance already deleted`, `Out of bounds call_indirect`, `call_indirect to a signature that does not match`, `table index is out of bounds`, or `null function or function signature mismatch`.
829
+ - **Cause:** The mesh kernel's WASM heap ran out while building the print-quality mesh, and a WASM trap leaves that kernel instance corrupt — every later call into it fails until the worker is replaced or the page reloaded. Before partforge 0.115 the print tier meshed every circle at a flat 480 segments whatever its radius, so a part with a few hundred small spheres or cylinders (a 0.75 mm rivet was 115,200 triangles — 176 of them are 20 M before a single boolean) exhausted a 4 GB heap on its first export while its whole unioned preview was a few hundred thousand triangles. Since 0.115 print sizes circles by a 0.01 mm chord tolerance, floored at the preview count, so a part that previews normally exports at roughly the preview's cost; a part that still traps is genuinely too heavy for the browser at ANY quality — usually thousands of repeated small features, or a boolean chain whose intermediates dwarf the result.
830
+ - **Fix:** Reload the page (or let the host replace the kernel worker) before retrying anything — the trapped instance cannot recover. Then reduce what the export has to hold at once: build repeated detail as one union of instances rather than a chain of per-feature booleans, drop feature counts that exceed what the print can show (a 0.75 mm sphere prints as a dot), or pass `segs` to `revolve` where a coarser sweep is acceptable ([Preview vs print quality](AUTHORING-PARTS.md#conventions--gotchas)). Do NOT strip visible detail from the part to dodge a pre-0.115 trap — update partforge instead; the geometry was never the problem.
831
+
832
+ ## preview-build-too-heavy-for-phones
833
+
834
+ - **Symptom:** A part that previews on a desktop crashes, reloads, or shows a blank viewer on phones — iOS Safari's "This webpage was reloaded because a problem occurred", a viewer that never finishes its first build, or (in partforge-cloud) a `sandbox_timeout` from a phone user agent — with no error text at all, because the browser killed the page rather than the build throwing.
835
+ - **Cause:** Peak WASM memory during the preview build exceeded what the phone allows a page (roughly 1–1.5 GB on iOS; a desktop tolerates several GB). The usual shape is hundreds of repeated small features unioned into one body: before partforge 0.116 every sphere was 6,728 triangles regardless of radius (the flat 116-segment preview count, squared), so a body with ~250 rivet spheres carried 1.7 M triangles of rivets into its booleans — measured 10 s and a 2.7 GB peak on a desktop for a 400k-triangle sub-part, against 4 s and 500 MB with the rivets as 12-triangle boxes. A boolean chain whose intermediates dwarf the result (a full-body skin intersected per groove) is the other shape.
836
+ - **Fix:** Update partforge (0.116 sizes spheres by chord tolerance: that body builds in ~550 MB and 120k triangles unchanged). Then keep the preview build's peak down the same ways the export needs: build repeated detail as one union of instances rather than a chain of per-feature booleans, cut grooves from a thin local skin rather than the whole envelope, and drop feature counts the print cannot show ([Preview vs print quality](AUTHORING-PARTS.md#conventions--gotchas)). `partforge measure` reports triangle counts per sub-part; a single preview sub-part past ~200k triangles, or a whole view past ~400k, is the range where phones start to fail.
837
+
826
838
  # Hardware library
827
839
 
828
840
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -375,7 +375,20 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
375
375
 
376
376
  `quality` (`"preview"` | `"print"`) is **advisory**: it trades tessellation density for
377
377
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
378
- depend on triangle counts, segment counts, or normals being present.
378
+ depend on triangle counts, segment counts, or normals being present. The in-repo
379
+ backends both define `print` as a **chord tolerance of 0.01 mm** (OCCT's linear
380
+ deflection; Manifold's per-circle segment rule in `geometry/circle-segs.js`, floored at
381
+ the preview's 116 segments so print is never coarser than preview and capped at 480), so
382
+ a small feature costs the export exactly what its preview cost — the property that makes
383
+ "if it previews, it exports" hold. Preview is a flat 116 on Manifold, a visual choice —
384
+ with one exception: `sphere`, whose triangle count is quadratic in the segment count
385
+ (8·(n/4)²), is sized by chord tolerance on BOTH tiers (`sphereSegs` in
386
+ `geometry/circle-segs.js`: 0.02 mm at preview, floored at 24 segments and capped at
387
+ the flat 116; 0.01 mm at print, floored at the preview count, capped at 480). At the
388
+ flat count a 0.75 mm rivet sphere was 6,728 triangles, and a part carrying a few
389
+ hundred of them was a 2.7 GB preview build that phones could not survive; at the
390
+ floor it is 288. Circles in extrusions, revolves and outlines keep the flat count,
391
+ which the mesh fillet's arc gate and the roundAll fast path are tuned to.
379
392
 
380
393
  ### Shading intent (toMesh normals and edges)
381
394
 
@@ -554,8 +567,9 @@ curve-exact (they integrate the real curves; they do not measure a tessellation)
554
567
  so it is backend-identical too, like everything else in this list.
555
568
 
556
569
  **Lazy materialization.** Backend geometry is built only where it is unavoidable.
557
- Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116
558
- preview / 480 print, OCCT 64): `toRegions()`, `simple()` (its unwrapped form), and
570
+ Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116 per
571
+ circle at preview and, at print, the fewest segments holding a 0.01 mm chord sagitta
572
+ between 116 and 480 — per arc, by its radius; OCCT 64): `toRegions()`, `simple()` (its unwrapped form), and
559
573
  `regions()` — scission currently round-trips through `toRegions()`, so each returned
560
574
  `Shape2D` is a faceted copy, not a curve-native slice of the original. `extrude` and
561
575
  `revolve` materialize the shape into the backend's own form instead (Manifold: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.114.0",
3
+ "version": "0.116.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",
@@ -0,0 +1,109 @@
1
+ // Per-circle segment counts for the mesh backend's two quality tiers.
2
+ //
3
+ // `preview` is a flat count: 116 segments per full circle whatever the radius. It is
4
+ // a VISUAL choice — the density every part is previewed, captured and thumbnailed
5
+ // at — and it already holds a 0.05 mm chord sagitta out to a 136 mm radius, so
6
+ // nothing is gained by scaling it.
7
+ //
8
+ // `print` used to be a flat 480, sized (whether anyone meant it or not) for a circle
9
+ // nearly a metre across: 480 segments meet a 0.01 mm sagitta at r ≈ 467 mm. Spent on
10
+ // a 0.75 mm rivet sphere that is 115,200 triangles for a chord error of 1.6e-5 mm —
11
+ // a thousandth of any printer's resolution — and a part carrying 176 such rivets
12
+ // (20 M triangles of rivets before a single boolean, against a preview whose whole
13
+ // unioned body was 530k) trapped the WASM kernel with "memory access out of bounds"
14
+ // on its first STL export, on a fresh 4 GB instance. So the print tier is now
15
+ // tolerance-based: the fewest segments that keep the chord sagitta r·(1 − cos(π/n))
16
+ // under SAGITTA_TOL.print — the same 0.01 mm the OCCT backend's print tessellation
17
+ // uses, and the same formula `roundAllSegs` (mesh-roundall.js) and `blendSegs`
18
+ // (mesh-fillet.js) already apply to their own circles. Those two keep their own
19
+ // tolerances and clamps on purpose (roundAll has a preview tolerance and a 12..64
20
+ // window; the fillet blends at 1 µm) — this table is not theirs to share, and
21
+ // `roundAllSegs(r, "preview")` against it would collapse to a flat 12. Two clamps
22
+ // make the rule here safe:
23
+ //
24
+ // - FLOOR at the preview count. An export must never be coarser than the preview the
25
+ // user approved on screen, so below the radius where 116 segments already meet the
26
+ // tolerance (r ≈ 27 mm) print and preview facet identically — which is also what
27
+ // makes "if it previews, it exports" true for small features: the export costs what
28
+ // the preview already paid.
29
+ // - CAP at the old flat count. A circle large enough to need more than 480 keeps
30
+ // exactly the density it always had; nothing gets FINER than before.
31
+ //
32
+ // One rule, one place: every Manifold-backend site that facets a circle of known
33
+ // radius — sphere, cylinder, boredCylinder, roundedBox, revolve, and the arc and
34
+ // Bézier samplers behind prism/extrude/Shape2D — sizes through `circleSegs`. The
35
+ // helix tube's station/ring counts (TUBE in manifold-backend.js) and mesh-fillet's
36
+ // blend bands keep their own sizing; loft rings keep LOFT_SEGS.
37
+ //
38
+ // The one exception is `sphere`, which has its own rule at the bottom of this file.
39
+ //
40
+ // Pure, dependency-free: profile.js's samplers take a `(r) => n` function in place of
41
+ // a count, and this is what the backend hands them.
42
+
43
+ export const SEGS = { preview: 116, print: 480 }; // full-circle segments (flat / cap)
44
+ export const SAGITTA_TOL = { print: 0.01 }; // mm — max chord sagitta per tier
45
+
46
+ // THE formula, shared by every tolerance-sized circle in the mesh backend: the fewest
47
+ // full-circle segments that keep the chord sagitta r·(1 − cos(π/n)) under `tol`,
48
+ // clamped to [floor, cap]. acos(1 − tol/r) is the half-angle of a chord with sagitta
49
+ // tol; π over it is the full-circle count. r = Infinity gives acos(1) = 0 → Infinity →
50
+ // the cap. A degenerate radius (0, negative, NaN, undefined) or one no larger than the
51
+ // tolerance takes the floor and never throws. The callers differ only in POLICY — which
52
+ // tolerance, which clamps — and that is all they should ever add: `circleSegs` and
53
+ // `sphereSegs` below, mesh-fillet's blendSegs and mesh-roundall's roundAllSegs.
54
+ export function segsForSagitta(r, tol, floor, cap) {
55
+ if (!(r > tol)) return floor;
56
+ return Math.min(cap, Math.max(floor, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
57
+ }
58
+
59
+ // Segments per full circle for a circle of radius `r` at `quality`. A tier with no
60
+ // tolerance (preview, or an unknown tier) is flat; print is sized by tolerance, floored
61
+ // at the preview count and capped at its own.
62
+ export function circleSegs(r, quality) {
63
+ const cap = SEGS[quality] ?? SEGS.preview;
64
+ const tol = SAGITTA_TOL[quality];
65
+ if (tol === undefined) return cap;
66
+ return segsForSagitta(r, tol, SEGS.preview, cap);
67
+ }
68
+
69
+ // Spheres are the one primitive whose triangle count is QUADRATIC in the segment
70
+ // count — Manifold.sphere(r, n) subdivides an octahedron n/4 times per edge, 8·(n/4)²
71
+ // triangles — so the per-circle budget above, spent on a sphere, is spent squared:
72
+ // at the flat preview 116 every sphere is 6,728 triangles whatever its radius, and a
73
+ // 0.75 mm rivet sphere carries a chord error of 0.0003 mm, a hundredth of a screen
74
+ // pixel at any zoom. A part carrying ~250 such rivets (the steampunk-spider feedback,
75
+ // "not loading on phones") unioned 1.7 M triangles of rivets into a 100k body: 10 s
76
+ // and a 2.7 GB peak on a desktop, which iOS Safari's content process does not
77
+ // survive — with the rivets at 12 triangles each the same body was 4 s and 500 MB.
78
+ //
79
+ // So spheres are sized by chord tolerance on BOTH tiers, like print circles are:
80
+ //
81
+ // - preview holds SPHERE_SAGITTA_TOL.preview (0.02 mm — an absolute chord error is
82
+ // what screen pixels measure, so one tolerance reads equally smooth at every radius
83
+ // and every zoom; 0.02 mm is a fifth of a pixel at a typical 100 mm-part zoom),
84
+ // floored at SPHERE_FLOOR segments (24 — ~15° facets, so a tiny ball never reads as
85
+ // a polygon under close zoom, and 288 triangles instead of 6,728) and capped at the
86
+ // old flat count (nothing gets FINER than before; a sphere of 60 mm radius and up
87
+ // keeps exactly the density it always had).
88
+ // - print holds the print tier's 0.01 mm, floored at the preview count for the same
89
+ // radius (never coarser than the preview the user approved — the same property the
90
+ // circle rule keeps) and capped at 480.
91
+ //
92
+ // Circles in extrusions, revolves, cylinders and 2-D outlines stay on the flat preview
93
+ // count on purpose: their cost is linear in the count, and the mesh fillet's arc gate,
94
+ // the roundAll prism fast path and the shading policies are all tuned to that density
95
+ // — a spike that made EVERY preview circle tolerance-based (0.05 mm, floor 24) turned
96
+ // bore-rim fillets from revolve tools into planar sweeps and drew 26 feature lines
97
+ // across a roundAll band that had none. A sphere has no sharp edges of its own and roundAll
98
+ // sizes its own balls (roundAllSegs), so this rule touches nothing tuned to 116.
99
+ export const SPHERE_SAGITTA_TOL = { preview: 0.02, print: SAGITTA_TOL.print }; // mm
100
+ export const SPHERE_FLOOR = 24; // segments
101
+
102
+ // Segments for a sphere of radius `r` at `quality`: the fewest that keep the chord
103
+ // sagitta under the tier's sphere tolerance, clamped as described above. An unknown
104
+ // tier facets as preview; a degenerate radius takes the floor and never throws.
105
+ export function sphereSegs(r, quality) {
106
+ const tier = Object.hasOwn(SPHERE_SAGITTA_TOL, quality ?? "") ? quality : "preview";
107
+ const floor = tier === "preview" ? SPHERE_FLOOR : sphereSegs(r, "preview");
108
+ return segsForSagitta(r, SPHERE_SAGITTA_TOL[tier], floor, SEGS[tier]);
109
+ }
@@ -19,6 +19,7 @@ import { creasedNormals } from "./creased-normals.js";
19
19
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
20
20
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
21
21
  import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
22
+ import { SEGS, circleSegs, sphereSegs } from "./circle-segs.js";
22
23
  import { checkBooleanResult } from "./boolean-gate.js";
23
24
  import { KernelCapabilityError } from "./errors.js";
24
25
  import { heightfieldMesh, hashGridData } from "./heightfield.js";
@@ -26,7 +27,10 @@ import { heightfieldMesh, hashGridData } from "./heightfield.js";
26
27
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
27
28
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
28
29
  // by the export path — Manifold meshing is cheap, so we tessellate generously).
29
- const SEGS = { preview: 116, print: 480 }; // circular segments
30
+ // Full-circle segment counts live in circle-segs.js: preview is a flat SEGS.preview,
31
+ // print sizes each circle by chord tolerance through circleSegs (floored at the
32
+ // preview count, capped at SEGS.print). `segs` below is the tier's cap — the hash
33
+ // key and the count handed to consumers that size themselves (mesh-fillet, loft).
30
34
  const TUBE = { preview: { stationsPerTurn: 38, ringSegs: 24 }, print: { stationsPerTurn: 160, ringSegs: 40 } };
31
35
 
32
36
  // true axis-angle rotation as a column-major 4x4 (manifold Mat4), translation 0
@@ -44,6 +48,9 @@ function axisAngleMat4(axis, deg) {
44
48
  export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
45
49
  const { Manifold, CrossSection } = wasm;
46
50
  const segs = SEGS[quality], tube = TUBE[quality];
51
+ // Per-radius count for every site that facets a circle it knows the radius of; the
52
+ // samplers behind prism/extrude/Shape2D take it as a function (profile.js).
53
+ const segsAt = (r) => circleSegs(r, quality);
47
54
 
48
55
  // Manifold/CrossSection are WASM objects with no garbage collection — every
49
56
  // primitive and boolean op allocates a new one. Track them all and free them
@@ -166,7 +173,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
166
173
  // on it in pure JS; no CrossSection is built until a shape is handed to a kernel op.
167
174
  // `extrude`/`revolve` are thunks because `kernel` below is defined after this.
168
175
  const shape2d = makeShape2dFactory({
169
- segs,
176
+ segs: segsAt, // the readbacks (toRegions/simple) tessellate at the same per-radius LOD as the kernel ops
170
177
  extrude: (o) => kernel.extrude(o),
171
178
  revolve: (o) => kernel.revolve(o),
172
179
  recordWarning,
@@ -177,7 +184,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
177
184
  // once, and the cache's pin/dispose keeps the WASM object alive exactly as long as
178
185
  // the entry (cleanup() skips pinned objects).
179
186
  const csFor = (shape) => cache.lookup(h("cs2d", shape._hash, segs), () => {
180
- const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions, segs), "EvenOdd"));
187
+ const cs = T(CrossSection.ofPolygons(regionPolys(shape._regions, segsAt), "EvenOdd"));
181
188
  return { value: cs, pin: cs, dispose: () => cs.delete?.() };
182
189
  });
183
190
 
@@ -401,13 +408,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
401
408
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
402
409
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
403
410
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
404
- meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
411
+ meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs, segsAt })));
405
412
  },
406
413
  _chamferRaw: (d, selector) => {
407
414
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
408
415
  if (d === 0) return wrap(m, hash);
409
416
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
410
- meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
417
+ meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs, segsAt })));
411
418
  },
412
419
 
413
420
  // The AUTHOR-FACING ops degrade on failure instead of failing the build (the
@@ -628,14 +635,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
628
635
  };
629
636
 
630
637
  const kernel = finishKernel({
631
- cylinder: (rb, rt, h2, { center = false } = {}) =>
632
- wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
638
+ cylinder: (rb, rt, h2, { center = false } = {}) => {
639
+ const n = segsAt(Math.max(rb, rt)); // a cone is sized by its wider end
640
+ return wrap(T(Manifold.cylinder(h2, rb, rt, n, center)), h("cylinder", rb, rt, h2, center, n));
641
+ },
633
642
  // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
634
643
  // node — its internal cylinders/cut are never retained. The template for
635
644
  // future compounds: build internals with T(), return the final tracked solid.
636
- boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segs), () => {
637
- const body = T(Manifold.cylinder(height, od / 2, od / 2, segs, false));
638
- const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segs, false));
645
+ boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segsAt(od / 2), segsAt(bore / 2)), () => {
646
+ const body = T(Manifold.cylinder(height, od / 2, od / 2, segsAt(od / 2), false));
647
+ const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segsAt(bore / 2), false));
639
648
  const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
640
649
  return T(body.subtract(tool));
641
650
  }),
@@ -644,19 +653,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
644
653
  // spec). Reuses loftMesh's stitch/cap/winding machinery. Atomic cache
645
654
  // node hashed from its own args, like boredCylinder.
646
655
  roundedBox: ({ size, center, round }) => cached(
647
- h("roundedBox", size, center, round.side, round.top, round.bottom, segs),
656
+ h("roundedBox", size, center, round.side, round.top, round.bottom, segsAt(Math.max(round.side, round.top, round.bottom))),
648
657
  () => {
649
- const solid = T(loftMesh(wasm, roundedBoxRings(size, round, segs)));
658
+ // One count serves every corner and rim arc, so it is sized for the largest.
659
+ const solid = T(loftMesh(wasm, roundedBoxRings(size, round, segsAt(Math.max(round.side, round.top, round.bottom)))));
650
660
  return center ? T(solid.translate([0, 0, -size[2] / 2])) : solid;
651
661
  }),
652
- sphere: (r) => wrap(T(Manifold.sphere(r, segs)), h("sphere", r, segs)),
662
+ // Sized by its own per-radius rule (circle-segs.js: a sphere spends the per-circle
663
+ // count squared), on both tiers — the hash carries the count, so a sphere built at
664
+ // one tier never masquerades as the other's in the cache.
665
+ sphere: (r) => wrap(T(Manifold.sphere(r, sphereSegs(r, quality))), h("sphere", r, sphereSegs(r, quality))),
653
666
  box: (min, max) => {
654
667
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
655
668
  return wrap(T(cube.translate(min)), h("box", min, max));
656
669
  },
657
670
  prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
658
671
  cached(h("prism", pts, height, twist, scaleTop, segs), () => {
659
- const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segs)]));
672
+ const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segsAt)]));
660
673
  if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
661
674
  const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
662
675
  // Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
@@ -711,7 +724,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
711
724
  const shape = profile && profile._shape2d ? profile : null;
712
725
  return cached(h("extrude", shape ? shape._hash : profile, height, twist, scaleTop, segs), () => {
713
726
  const cs = shape ? csFor(shape) : (() => {
714
- const { outer, holes } = tessellateProfile(profile, segs);
727
+ const { outer, holes } = tessellateProfile(profile, segsAt);
715
728
  return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
716
729
  })();
717
730
  if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
@@ -757,10 +770,26 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
757
770
  // opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
758
771
  // callers use it where a small feature's sagitta bound needs fewer facets than
759
772
  // the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
773
+ // Density around the axis: by default sized for the profile's outermost radius —
774
+ // the largest circle the revolve sweeps — through the tier's per-radius rule. An
775
+ // explicit `segs` is the caller's own sizing (mesh-fillet's blend tools compute
776
+ // theirs from a 1 µm sagitta bound and rely on getting exactly that count — the
777
+ // dephase and horn-containment arithmetic assume it), bounded only by the tier's
778
+ // cap: it may still never EXCEED kernel quality, but it is not re-bounded by the
779
+ // part-scale rule meant for circles nobody sized by hand.
760
780
  revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
761
- const density = Math.min(segs, segsOverride ?? segs);
762
- if (pts && pts._shape2d)
763
- return cached(h("revolve", pts._hash, degrees, density), () => T(csFor(pts).revolve(density, degrees)));
781
+ const densityFor = (maxR) => segsOverride != null ? Math.min(segs, segsOverride) : segsAt(maxR);
782
+ if (pts && pts._shape2d) {
783
+ // Keyed on the override, not the resolved density, so a cache hit never
784
+ // materializes the CrossSection just to measure its bounds.
785
+ return cached(h("revolve", pts._hash, degrees, segsOverride ?? null), () => {
786
+ const cs = csFor(pts);
787
+ const b = cs.bounds();
788
+ return T(cs.revolve(densityFor(Math.max(Math.abs(b.min[0]), Math.abs(b.max[0]))), degrees));
789
+ });
790
+ }
791
+ const maxR = Array.isArray(pts) ? pts.reduce((m, p) => Math.max(m, Math.abs(p?.[0] ?? 0)), 0) : 0;
792
+ const density = densityFor(maxR);
764
793
  return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
765
794
  },
766
795
  // A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
@@ -43,6 +43,7 @@
43
43
  // `dir` only ever matches straight chains, like replicad's inDirection.
44
44
  // Pure module: no DOM, no node:, no three — safe anywhere in the worker graph.
45
45
  import { sweepSeedFrame } from "./sweep.js";
46
+ import { segsForSagitta } from "./circle-segs.js";
46
47
 
47
48
  const TOL = 1e-4; // selector / coplanarity tolerance (mm)
48
49
  const WELD = 1e6; // vertex weld quantization (1/WELD mm grid)
@@ -64,8 +65,7 @@ export class UnsupportedEdgeError extends Error {
64
65
  // angle (≤30°) under the viewer's 35° same-surface crease threshold.
65
66
  const BLEND_SAG = 1e-3; // mm — max chord sagitta of a blend cross-section
66
67
  function blendSegs(segs, r) {
67
- const s = Math.min(BLEND_SAG, 0.02 * r);
68
- return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
68
+ return segsForSagitta(r, Math.min(BLEND_SAG, 0.02 * r), 12, segs);
69
69
  }
70
70
  // One derivation for a synthetic corner arc's angular density, shared by revolveTool
71
71
  // (which sweeps at it) and cornerHornTool (whose apothem bound below depends on it) —
@@ -670,8 +670,12 @@ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
670
670
  // closed-revolve dephase, which are about matching the neighboring tessellation and
671
671
  // must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
672
672
  // cross-section itself (blendSegs above).
673
- function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
673
+ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs, flankAt = () => segs) {
674
674
  const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
675
+ // The count the flank's own circle was built at (see apply): the cap on a flat
676
+ // tier, the per-radius rule on print. Every "matching the neighbouring
677
+ // tessellation" figure below reads this, never `segs`.
678
+ const flankSegs = flankAt(R);
675
679
  // Seam-grazing guard. The edge circle passes through the flank tessellation's
676
680
  // VERTICES (circumradius) while its facets sit at the apothem, so a revolved
677
681
  // tool built exactly at R grazes every facet seam tangentially — Manifold
@@ -689,7 +693,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
689
693
  // and a radial knife-fin of wall survived both cutters, drawing a line along the
690
694
  // band (the label-backing bug). A synthetic corner arc measures nothing — its two
691
695
  // points span the whole corner, and its flanks are planes, not a tessellation.
692
- const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / segs));
696
+ const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / flankSegs));
693
697
  let dip = 0;
694
698
  if (!chain.synthetic) {
695
699
  const pts = chain.points;
@@ -732,7 +736,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
732
736
  // with the flank's own tessellation of the same circle (the dephase note below).
733
737
  // A SYNTHETIC corner arc (cornerArcAt) is free-standing between planes, so its
734
738
  // angular density follows the same sagitta bound as the cross-section.
735
- const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : segs;
739
+ const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : flankSegs;
736
740
  let tool = k.revolve(poly, { degrees, segs: aSegs });
737
741
  // pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
738
742
  // chain's start direction (backed off by the angular overshoot)
@@ -749,7 +753,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
749
753
  // vertex at every step (the degenerate-needle generator). Half a step lands
750
754
  // every crossing mid-facet. Partial arcs have a slightly different pitch
751
755
  // (degrees don't divide evenly) and never align in the first place.
752
- const dephase = closed ? Math.PI / segs : 0;
756
+ const dephase = closed ? Math.PI / flankSegs : 0;
753
757
  const twist = Math.atan2(dot(w, cross(xImage, startDir)), dot(xImage, startDir)) + dephase;
754
758
  if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: w, deg: (twist * 180) / Math.PI });
755
759
  return tool.translate(O);
@@ -891,7 +895,7 @@ function weldChainPoints(pts, wallNs, closed) {
891
895
  return { pts: outP, wallNs: outW };
892
896
  }
893
897
 
894
- function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
898
+ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null, flankAt = () => segs) {
895
899
  const { points, closed, convex, faceN } = chain;
896
900
  let { wallNs } = chain;
897
901
  let pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
@@ -1095,9 +1099,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
1095
1099
  tools.push(...buildStretch(path, wallNs[s % nSeg]));
1096
1100
  }
1097
1101
  for (const got of cornerArcs.values()) {
1098
- tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
1102
+ tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs, flankAt));
1099
1103
  if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
1100
- tools.push(cornerHornTool(k, got, magnitude, segs));
1104
+ tools.push(cornerHornTool(k, got, magnitude, segs, flankAt));
1101
1105
  }
1102
1106
  for (const piv of pivots) tools.push(reflexPivotTool(k, piv, magnitude, mode, segs, pSegs));
1103
1107
  return tools;
@@ -1211,7 +1215,7 @@ function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude,
1211
1215
  // of its step count, so its apothem ≥ R·cos(π/aSegs) > every horn vertex radius. The
1212
1216
  // cost is a micron-deep extra bite at the corner base, covered near the tangent lines
1213
1217
  // by the neighbors' own overshoot.
1214
- function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
1218
+ function cornerHornTool(k, { vertex, f, arc }, magnitude, segs, flankAt = () => segs) {
1215
1219
  const { O, w, u0, R, span } = arc;
1216
1220
  const delta = 0.02 * magnitude;
1217
1221
  const rH = R * Math.cos(Math.PI / cornerArcSegs(segs, R, magnitude)) - Math.min(1e-3, 0.02 * magnitude);
@@ -1488,7 +1492,7 @@ function roundSalientCorners(selected, magnitude) {
1488
1492
  // cube's outer walls land inside the material the edge cutters already remove,
1489
1493
  // so the only new surface is the octant. Non-orthogonal corners keep the mitre
1490
1494
  // — the safe, documented default.
1491
- function cornerPatches(k, selected, r, segs) {
1495
+ function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
1492
1496
  const byVertex = new Map();
1493
1497
  const push = (pt, dirOut) => {
1494
1498
  const key = pt.map((v) => Math.round(v * 1e4)).join(",");
@@ -1527,7 +1531,7 @@ function cornerPatches(k, selected, r, segs) {
1527
1531
  // is tangent to each flat face at a point and meets the edge-fillet
1528
1532
  // cylinders tangentially at the cube walls, and tessellated tangency
1529
1533
  // produces the same grazing-noise creases the edge tools guard against.
1530
- const bury = r * (1 - Math.cos(Math.PI / segs)) + 1e-3;
1534
+ const bury = r * (1 - Math.cos(Math.PI / flankAt(r))) + 1e-3; // the kernel sphere below is built at flankAt(r)
1531
1535
  const inward = norm(add(add(e1, e2), e3));
1532
1536
  // corner block: cube spanned by the edge frame, oversized only outward
1533
1537
  let block = k.box({ min: [-dOut, -dOut, -dOut], max: [r, r, r] });
@@ -1554,8 +1558,17 @@ function cornerPatches(k, selected, r, segs) {
1554
1558
  export function meshFillet(k, solid, opts) { return apply(k, solid, "fillet", opts?.r, opts); }
1555
1559
  export function meshChamfer(k, solid, opts) { return apply(k, solid, "chamfer", opts?.d, opts); }
1556
1560
 
1557
- function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20 } = {}) {
1561
+ // `segs` is the kernel's per-circle CAP: it bounds the blend densities (blendSegs)
1562
+ // and is what every circle was built at on a flat tier. `segsAt(r)` is what a circle
1563
+ // of radius r was ACTUALLY built at — the print tier sizes circles by chord tolerance
1564
+ // (circle-segs.js), so a flank's facet pitch is no longer the cap. The three places
1565
+ // that reason about the neighbouring tessellation (revolveTool's seam-grazing sag and
1566
+ // closed-revolve dephase, cornerHornTool's sphere burial) ask it; everything sized
1567
+ // from the blend's own sagitta bound keeps the cap. Absent, it is the cap — the
1568
+ // pre-print-rule behaviour, and byte-identical at preview either way.
1569
+ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20, segsAt = null } = {}) {
1558
1570
  if (!(magnitude > 0)) throw new Error(`mesh ${mode}: magnitude must be > 0`);
1571
+ const flankAt = segsAt ?? (() => segs);
1559
1572
  const chains = chainEdges(detectSharpEdges(solid.toIndexedMesh(), { sharpDeg }));
1560
1573
  const selected = chains.filter((ch) => matchesSelector(ch, edges));
1561
1574
  if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
@@ -1597,13 +1610,15 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1597
1610
  const pSegs = blendSegs(segs, magnitude);
1598
1611
  const toolsFor = (ch) =>
1599
1612
  ch.kind === "planar"
1600
- ? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins)
1601
- : [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
1613
+ ? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins, flankAt)
1614
+ : ch.kind === "arc"
1615
+ ? [revolveTool(k, ch, magnitude, mode, segs, pSegs, flankAt)]
1616
+ : [prismTool(k, ch, magnitude, mode, segs, pSegs)];
1602
1617
  const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
1603
- cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
1618
+ cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs, flankAt)));
1604
1619
  cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));
1605
1620
  const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
1606
- if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
1621
+ if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs, flankAt));
1607
1622
  let out = solid;
1608
1623
  if (cutters.length) out = out.cutAll(cutters);
1609
1624
  if (fillers.length) out = k.union([out, ...fillers]);
@@ -28,13 +28,14 @@
28
28
  // is sized from the erosion ball (2r), the larger of the two, so the coarser of
29
29
  // the two facetings still meets the tier's sagitta tolerance.
30
30
 
31
+ import { segsForSagitta } from "./circle-segs.js";
32
+
31
33
  // Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
32
- // fewest segments that keep it under the quality tier's tolerance.
34
+ // fewest segments that keep it under the quality tier's tolerance — its own
35
+ // tolerance table and 12..64 window, on the shared formula.
33
36
  const SAGITTA_TOL = { preview: 0.05, print: 0.01 }; // mm
34
37
  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
+ return segsForSagitta(r, SAGITTA_TOL[quality] ?? SAGITTA_TOL.preview, 12, 64);
38
39
  }
39
40
 
40
41
  export function meshRoundAll(wasm, m, r, quality) {
@@ -79,13 +79,16 @@ export function arcGeometry(p0, via, p1) {
79
79
  // three points; the sweep direction is the one whose arc actually passes through `via`
80
80
  // (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
81
81
  // full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
82
- // arc and a circleProfile of equal radius facet identically. A degenerate (collinear)
83
- // triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
84
- // side gets when roundedProfile emits no `via`.
82
+ // arc and a circleProfile of equal radius facet identically. `segs` is either that count
83
+ // or a function of the arc's radius returning one — the mesh backend's print tier sizes
84
+ // circles by chord tolerance (circle-segs.js) and hands the samplers the rule rather
85
+ // than a number. A degenerate (collinear) triple falls back to a single straight segment
86
+ // to p1 — the same "plain line" the OCCT side gets when roundedProfile emits no `via`.
85
87
  export function sampleArc(p0, via, p1, segs) {
86
88
  const g = arcGeometry(p0, via, p1);
87
89
  if (!g) return [[p1[0], p1[1]]]; // collinear → straight line
88
- const steps = Math.max(2, Math.ceil((segs * Math.abs(g.dA)) / (2 * Math.PI)));
90
+ const n = typeof segs === "function" ? segs(g.r) : segs;
91
+ const steps = Math.max(2, Math.ceil((n * Math.abs(g.dA)) / (2 * Math.PI)));
89
92
  const out = [];
90
93
  for (let s = 1; s <= steps; s++) {
91
94
  const ang = g.a0 + g.dA * (s / steps);
@@ -102,8 +105,26 @@ export function sampleArc(p0, via, p1, segs) {
102
105
  // cubic tracing a circular arc facets like the arc primitive at the same segs. Summing
103
106
  // |turn| at BOTH interior control points also catches S-curves a pure endpoint-tangent
104
107
  // test would miss. Depth cap guarantees termination. Pure in (args, segs).
108
+ //
109
+ // `segs` may be a function of radius (see sampleArc). A cubic has no single radius, so
110
+ // the budget is decided per sub-curve from the radius it traces, recovered from its
111
+ // chord c and turn t as the circle on which a chord c subtends t: r = c / (2·sin(t/2)).
112
+ // That is EXACT for a circular arc. For any other cubic the control polygon's turn t
113
+ // overstates the curve's, so r errs small and the count errs LOW — a slightly coarser
114
+ // budget, not a finer one. The bias is bounded by the ratio of polygon turn to curve
115
+ // turn, which the recursion drives to 1 as the pieces shrink, and at the turns that
116
+ // pass (≤ 2π/116) it is under 0.02 % of the count. A cubic tracing a circle of radius
117
+ // r therefore facets like the arc primitive of radius r under the same rule.
105
118
  export function sampleBezier(p0, c1, c2, p1, segs) {
106
- const maxTurn = (2 * Math.PI) / Math.max(3, segs);
119
+ const segsAt = typeof segs === "function" ? segs : null;
120
+ const flatTurn = segsAt ? null : (2 * Math.PI) / Math.max(3, segs);
121
+ const maxTurnFor = (a, d, t) => {
122
+ if (!segsAt) return flatTurn;
123
+ const chord = Math.hypot(d[0] - a[0], d[1] - a[1]);
124
+ const half = Math.sin(t / 2); // t ≤ 2π here: two turns of ≤ π each
125
+ const r = half > 1e-9 ? chord / (2 * half) : Infinity; // straight (t ≈ 0): the cap, and t ≤ budget anyway
126
+ return (2 * Math.PI) / Math.max(3, segsAt(r));
127
+ };
107
128
  const out = [];
108
129
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
109
130
  const turn = (u, v) => {
@@ -117,7 +138,8 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
117
138
  const ab = [b[0] - a[0], b[1] - a[1]];
118
139
  const bc = [c[0] - b[0], c[1] - b[1]];
119
140
  const cd = [d[0] - c[0], d[1] - c[1]];
120
- if (depth >= 12 || turn(ab, bc) + turn(bc, cd) <= maxTurn) { out.push([d[0], d[1]]); return; }
141
+ const t = turn(ab, bc) + turn(bc, cd);
142
+ if (depth >= 12 || t <= maxTurnFor(a, d, t)) { out.push([d[0], d[1]]); return; }
121
143
  const p01 = mid(a, b), p12 = mid(b, c), p23 = mid(c, d);
122
144
  const p012 = mid(p01, p12), p123 = mid(p12, p23), m = mid(p012, p123);
123
145
  recurse(a, p01, p012, m, depth + 1);