partforge 0.66.0 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2241,15 +2241,19 @@ melt away, holes narrower than `2r` seal shut. That makes it ideal for
2241
2241
  "soften this whole organic part" and wrong for parts where a specific edge
2242
2242
  must stay sharp (use `fillet` with a selector for that).
2243
2243
 
2244
- **Cost warning: roundAll is morphological (three Minkowski passes) and its
2245
- runtime grows steeply with the solid's triangle count.** On a simple bracket it
2246
- is interactive; on a complex outline it is not measured 30+ seconds on a
2247
- text-outline extrusion whose `fillet({ r, edges: { inPlane: "XY", at: h } })`
2248
- takes well under a second. **Never use roundAll just to round the top or bottom
2249
- rim of an extrusion**that is exactly what the `inPlane` fillet selector is
2250
- for, at a tiny fraction of the cost. Reach for roundAll only when the design
2251
- genuinely needs every edge softened at once and the solid is geometrically
2252
- simple.
2244
+ **Cost note.** On a Z-aligned extrusion (constant cross-section a plate, a
2245
+ text backing, any straight-sided prism) roundAll takes a fast path: the ball
2246
+ morphology is computed as a 2-D close-open of the cross-section plus rim
2247
+ fillets, so even a complex text-outline backing rounds in well under a second.
2248
+ Everything else pays the full morphology (three Minkowski passes), whose
2249
+ runtime grows steeply with triangle count a rotated or lofted solid of a few
2250
+ thousand triangles can take tens of seconds. When only a specific edge needs
2251
+ rounding, `fillet` with a selector (e.g. `{ inPlane: "XY", at: h }` for a rim)
2252
+ says what you mean and is always the cheap, predictable choice; reach for
2253
+ roundAll when the design genuinely calls for every edge softened at once. At
2254
+ the fast path's corners the plan silhouette follows the rim fillet's corner
2255
+ rounding (radius ≈ 1.05–1.25·r rather than exactly r) — the same corner
2256
+ treatment fillet itself applies.
2253
2257
 
2254
2258
  Rules of thumb:
2255
2259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.66.0",
3
+ "version": "0.66.1",
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",
@@ -15,7 +15,7 @@ import { meshToStl } from "./mesh-stl.js";
15
15
  import { creasedNormals } from "./creased-normals.js";
16
16
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
17
17
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
18
- import { meshRoundAll } from "./mesh-roundall.js";
18
+ import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
19
19
  import { KernelCapabilityError } from "./errors.js";
20
20
 
21
21
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
@@ -168,6 +168,66 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
168
168
  }
169
169
  };
170
170
 
171
+ // roundAll prism fast path. Ball close-then-open via native Minkowski is
172
+ // seconds-per-thousand-triangles (a text-outline backing measured 30+ s), but on
173
+ // a Z-prism the SAME morphology decomposes: the 2-D close-open of the
174
+ // cross-section (Clipper2 offsets +r, -2r, +r) supplies the wall melting and
175
+ // hole sealing, and a selector-free fillet of the re-extruded section at r
176
+ // supplies every rounded surface — vertical edges, both rims, and the corner
177
+ // treatments. Returns a raw (tracked) manifold, or null to
178
+ // keep the reference morphology — the fast path may only ever SUBSTITUTE for
179
+ // it: any doubt (not a prism, plate too thin, everything melted, a fillet
180
+ // refusal) falls back rather than widening or narrowing what roundAll accepts,
181
+ // and roundAll must never surface NEEDS_OCCT (it is its own reference
182
+ // implementation), which is why the whole attempt is fenced by a bare catch.
183
+ const prismRoundAllFast = (m, mHash, r) => {
184
+ let sect = null;
185
+ try {
186
+ sect = prismSection(wasm, m);
187
+ if (!sect) return null;
188
+ const { cs, z0, h: height } = sect;
189
+ // the erosion consumes the whole plate below 2r, and just above it the two
190
+ // rim bands graze each other — both belong to the reference morphology
191
+ if (!(height > 2 * r * 1.05)) return null;
192
+ // MITER joins on all three offsets, deliberately: the true morphology mints
193
+ // outline corners of radius exactly r, and a rim fillet of radius r over an
194
+ // r-radius corner pinches its top tangent contour to a point — the planar
195
+ // sweep refuses, structurally. Miter keeps every corner SHARP instead, and
196
+ // the selector-free fillet below performs ALL the rounding: convex vertical
197
+ // edges (cutters at r — the same solid the round join would have produced),
198
+ // concave verticals (fillers), both rims, and the corner treatments. Melt
199
+ // and seal thresholds stay exact on straight stretches; corner-local
200
+ // thresholds and silhouettes differ from the ball morphology by the rim
201
+ // fillet's own corner tolerance (~0.25·r) — the documented corner trade.
202
+ let cur = null;
203
+ try {
204
+ for (const delta of [r, -2 * r, r]) {
205
+ const next = (cur ?? cs).offset(delta, "Miter", 2);
206
+ const cleaned = next.simplify(1e-6);
207
+ next.delete?.();
208
+ cur?.delete?.();
209
+ cur = cleaned;
210
+ }
211
+ if (!(cur.area() > 0)) return null; // everything melted — reference path owns the empty result
212
+ let base = T(Manifold.extrude(cur, height));
213
+ if (z0 !== 0) base = T(base.translate([0, 0, z0]));
214
+ const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
215
+ // selector-free: every sharp edge of the mitered prism gets its radius here
216
+ const filleted = wrapped.fillet(r);
217
+ // decouple from the fillet cache's pin: cached() will pin the object this
218
+ // returns under the roundAll hash, and one WASM object must never sit
219
+ // under two cache entries (double-dispose on eviction)
220
+ return T(filleted._m.asOriginal());
221
+ } finally {
222
+ cur?.delete?.();
223
+ }
224
+ } catch {
225
+ return null;
226
+ } finally {
227
+ sect?.cs?.delete?.();
228
+ }
229
+ };
230
+
171
231
  const wrap = (m, hash) => addSugar({
172
232
  _m: m,
173
233
  _hash: hash,
@@ -190,7 +250,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
190
250
  // per-quality kernel, so it can never collide across tiers (the OCCT twin
191
251
  // key omits it for the same reason); it just spells out that the ball
192
252
  // tessellation, and so the result, is tier-dependent.
193
- return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
253
+ return cached(h("roundAll", hash, r, quality), () =>
254
+ prismRoundAllFast(m, hash, r) ?? T(meshRoundAll(wasm, m, r, quality)));
194
255
  },
195
256
  // batch difference: first minus the union of the rest, evaluated as one boolean
196
257
  // tree — no materialized intermediate union (the unionRaw memory note applies)
@@ -81,3 +81,46 @@ export function meshRoundAll(wasm, m, r, quality) {
81
81
  sph2R.delete?.();
82
82
  }
83
83
  }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Prism detection for the fast path (manifold-backend.js). The Minkowski chain
87
+ // above is seconds-per-thousand-triangles, but roundAll's expensive real-world
88
+ // inputs are almost always Z-prisms (text backings, plates, extruded outlines) —
89
+ // and on a prism the ball morphology decomposes exactly into the 2-D disk
90
+ // morphology of the cross-section plus rim fillets, all of which are fast. This
91
+ // function answers "is m a Z-prism, and what is its constant cross-section?"
92
+ //
93
+ // Detection is deliberately behavioral, not structural: three slices must have
94
+ // equal area AND vanishing symmetric difference (a sheared prism has equal-area
95
+ // TRANSLATED sections — the subtract catches it), and the solid's volume must
96
+ // equal section × height (a bulge parked between the slice planes would pass the
97
+ // slice checks alone). Any failure returns null and the caller keeps the
98
+ // reference morphology — the fast path may only ever substitute, never widen.
99
+ //
100
+ // On success the returned CrossSection is the CALLER's to delete.
101
+ export function prismSection(wasm, m, relTol = 1e-4) {
102
+ const bb = m.boundingBox();
103
+ const z0 = bb.min[2], h = bb.max[2] - z0;
104
+ if (!(h > 0)) return null;
105
+ const volume = m.volume();
106
+ if (!(volume > 0)) return null;
107
+ const slices = [0.25, 0.5, 0.75].map((t) => m.slice(z0 + t * h));
108
+ try {
109
+ const area = slices[1].area();
110
+ if (!(area > 0)) return null;
111
+ for (const s of slices) if (Math.abs(s.area() - area) > relTol * area) return null;
112
+ for (const s of [slices[0], slices[2]]) {
113
+ const d1 = slices[1].subtract(s), d2 = s.subtract(slices[1]);
114
+ const diff = d1.area() + d2.area();
115
+ d1.delete?.();
116
+ d2.delete?.();
117
+ if (diff > relTol * area) return null;
118
+ }
119
+ if (Math.abs(volume - area * h) > 10 * relTol * area * h) return null;
120
+ const cs = slices[1];
121
+ slices[1] = null; // ownership moves to the caller
122
+ return { cs, z0, h };
123
+ } finally {
124
+ for (const s of slices) s?.delete?.();
125
+ }
126
+ }