partforge 0.115.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.
@@ -3650,6 +3650,16 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3650
3650
  is a part that exports: the old flat 480 turned a 0.75 mm rivet into 115,200 triangles
3651
3651
  and a body with a few hundred of them into an out-of-memory trap at export
3652
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.
3653
3663
  - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
3654
3664
  depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
3655
3665
  - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
@@ -829,6 +829,12 @@ between the Manifold preview and the OCCT STEP export.
829
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
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
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
+
832
838
  # Hardware library
833
839
 
834
840
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -380,7 +380,15 @@ backends both define `print` as a **chord tolerance of 0.01 mm** (OCCT's linear
380
380
  deflection; Manifold's per-circle segment rule in `geometry/circle-segs.js`, floored at
381
381
  the preview's 116 segments so print is never coarser than preview and capped at 480), so
382
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.
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.
384
392
 
385
393
  ### Shading intent (toMesh normals and edges)
386
394
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.115.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",
@@ -35,22 +35,75 @@
35
35
  // helix tube's station/ring counts (TUBE in manifold-backend.js) and mesh-fillet's
36
36
  // blend bands keep their own sizing; loft rings keep LOFT_SEGS.
37
37
  //
38
+ // The one exception is `sphere`, which has its own rule at the bottom of this file.
39
+ //
38
40
  // Pure, dependency-free: profile.js's samplers take a `(r) => n` function in place of
39
41
  // a count, and this is what the backend hands them.
40
42
 
41
43
  export const SEGS = { preview: 116, print: 480 }; // full-circle segments (flat / cap)
42
44
  export const SAGITTA_TOL = { print: 0.01 }; // mm — max chord sagitta per tier
43
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
+
44
59
  // Segments per full circle for a circle of radius `r` at `quality`. A tier with no
45
- // tolerance (preview, or an unknown tier) is flat. A degenerate radius (0, negative,
46
- // NaN, undefined) takes the floor: it facets like the preview and never throws.
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.
47
62
  export function circleSegs(r, quality) {
48
63
  const cap = SEGS[quality] ?? SEGS.preview;
49
64
  const tol = SAGITTA_TOL[quality];
50
65
  if (tol === undefined) return cap;
51
- const floor = SEGS.preview;
52
- if (!(r > tol)) return floor;
53
- // acos(1 − tol/r) is the half-angle of a chord with sagitta tol; π over it is the
54
- // full-circle count. r = Infinity gives acos(1) = 0 Infinity the cap.
55
- return Math.min(cap, Math.max(floor, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
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]);
56
109
  }
@@ -19,7 +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 } from "./circle-segs.js";
22
+ import { SEGS, circleSegs, sphereSegs } from "./circle-segs.js";
23
23
  import { checkBooleanResult } from "./boolean-gate.js";
24
24
  import { KernelCapabilityError } from "./errors.js";
25
25
  import { heightfieldMesh, hashGridData } from "./heightfield.js";
@@ -659,7 +659,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
659
659
  const solid = T(loftMesh(wasm, roundedBoxRings(size, round, segsAt(Math.max(round.side, round.top, round.bottom)))));
660
660
  return center ? T(solid.translate([0, 0, -size[2] / 2])) : solid;
661
661
  }),
662
- sphere: (r) => wrap(T(Manifold.sphere(r, segsAt(r))), h("sphere", r, segsAt(r))),
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))),
663
666
  box: (min, max) => {
664
667
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
665
668
  return wrap(T(cube.translate(min)), h("box", min, max));
@@ -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) —
@@ -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) {