partforge 0.65.1 → 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,6 +2241,20 @@ 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 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.
2257
+
2244
2258
  Rules of thumb:
2245
2259
 
2246
2260
  - Keep `r` under half your thinnest wall unless you *want* melting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.65.1",
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",
@@ -72,7 +72,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
72
72
  const v = tris[t * 3 + k];
73
73
  let nx = 0, ny = 0, nz = 0;
74
74
  for (const t2 of incident.get(remap[v])) {
75
- if (triOID[t2] !== oid) continue; // different cut surface → hard
75
+ // different cut surface → hard, EXCEPT two blend surfaces (boundaryLines on
76
+ // both): one band is many tool surfaces continuing each other tangentially,
77
+ // and hard normals at their handovers would put lighting seams along a band
78
+ // that used to shade as one re-originaled surface
79
+ if (triOID[t2] !== oid &&
80
+ !(polFor(triOID[t2]).boundaryLines && polFor(oid).boundaryLines)) continue;
76
81
  if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
77
82
  nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
78
83
  }
@@ -105,16 +110,32 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
105
110
  // are invisible but whose long boundary edges would otherwise draw at full
106
111
  // line weight. A triangle thinner than MIN_EDGE cannot be seen, so its
107
112
  // edges are noise by definition — same threshold the segment filter uses.
108
- if (thin[prev] < MIN_EDGE || thin[t] < MIN_EDGE) continue;
113
+ const sameOID = triOID[prev] === triOID[t];
114
+ // Blend boundary: a cross-surface seam with a BLEND policy on EXACTLY one side
115
+ // is the start/end of a fillet band — draw it even when tangent (the band's
116
+ // extent must be readable). It also bypasses the thin-triangle gate below:
117
+ // simplify() cannot collapse the boolean's slivers ACROSS the blend/base run
118
+ // boundary, so half the seam's edges border a sliver, and gating them dashed
119
+ // the ring — those slivers ride within microns OF the seam curve, so their
120
+ // long edges redraw it rather than add noise (the MIN_EDGE segment-length
121
+ // filter still drops the short ones).
122
+ const boundary = !sameOID &&
123
+ !!polFor(triOID[prev]).boundaryLines !== !!polFor(triOID[t]).boundaryLines;
124
+ if (!boundary && (thin[prev] < MIN_EDGE || thin[t] < MIN_EDGE)) continue;
109
125
  const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
110
126
  // A multi-hole cap triangulation can contain an opposite-wound bridge:
111
127
  // its two normals disagree by 180 degrees even though both triangles lie
112
128
  // in the same plane. Gate on the unoriented supporting-plane angle first
113
129
  // so that triangulation seam never becomes a feature line.
114
130
  const bends = Math.abs(dot) < COPLANAR_COS;
115
- const hard = bends && (triOID[prev] === triOID[t]
131
+ // Two blend surfaces (a handover along one band) line-draw like ONE surface:
132
+ // the 35° same-surface bar, not the 5° cut-seam bar — a band is many tool
133
+ // surfaces whose overshoot crossings bend a few degrees by construction.
134
+ const bothBlend = !sameOID &&
135
+ !!polFor(triOID[prev]).boundaryLines && !!polFor(triOID[t]).boundaryLines;
136
+ const hard = boundary || (bends && (sameOID || bothBlend
116
137
  ? polFor(triOID[t]).sameSurfaceLines && dot < cosFor(triOID[t])
117
- : true);
138
+ : true));
118
139
  if (hard) {
119
140
  const ai = i * np, bj = j * np;
120
141
  const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];
@@ -13,9 +13,9 @@ import { offsetRegions } from "./contour-offset.js";
13
13
  import { finishKernel } from "./kernel-front.js";
14
14
  import { meshToStl } from "./mesh-stl.js";
15
15
  import { creasedNormals } from "./creased-normals.js";
16
- import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
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] };
@@ -123,12 +123,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
123
123
  // degenerate normals poison the crease pass into drawing phantom edge lines).
124
124
  // Unsupported edge classes (helical edges, varying dihedral, …) surface as
125
125
  // KernelCapabilityError so the framework reroutes that sub-part to OCCT.
126
- // simplify() will not collapse triangles across run (originalID) boundaries,
127
- // and the boolean's sliver triangles sit exactly on them — so the result is
128
- // re-originaled first. That folds every surface into one fresh original,
129
- // which is also the documented B-rep semantic: fillet/chamfer produce new
130
- // surfaces, so feature-label attribution downstream of the op uses the
131
- // fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
132
126
  const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
133
127
  // Debris sweep: where blend tools graze each other or a flank near-tangentially, the
134
128
  // boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
@@ -144,15 +138,96 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
144
138
  for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
145
139
  return kept.length === parts.length ? m : T(Manifold.compose(kept));
146
140
  };
147
- const meshCadOp = (op, run) => {
141
+ // Blend surfaces KEEP their originalIDs, and every id the op introduced is
142
+ // registered with the BLEND policy — that is what lets creased-normals draw the
143
+ // band's start/end (a tangent blend↔base seam draws regardless of bend) while
144
+ // blend↔blend handovers along one band stay invisible. This replaced a blanket
145
+ // asOriginal(): folding the result into one fresh original made simplify able to
146
+ // collapse boundary slivers, but it also erased the one distinction the boundary
147
+ // lines need. The trade is stated honestly: simplify() cannot collapse across the
148
+ // surviving run boundaries, so seam-adjacent slivers persist as triangles — they
149
+ // are sub-MIN_EDGE thin, so the line pass gates them, and closed sliver DEBRIS is
150
+ // swept above; the visible cost is a modestly larger mesh, bounded by
151
+ // mesh-fillet-perf.test.js. Shading is unchanged (BLEND shades like SMOOTH), and
152
+ // label() on a blend result already handles a mixed-original mesh (the majority
153
+ // vote below).
154
+ const runOids = (mm) => { const g = mm.getMesh(); const s = new Set(g.runOriginalID); g.delete?.(); return s; };
155
+ const meshCadOp = (op, baseM, run) => {
148
156
  try {
149
- return T(dropDebris(T(run()._m.asOriginal())).simplify(SIMPLIFY_EPS));
157
+ const raw = run()._m;
158
+ const baseOids = runOids(baseM);
159
+ for (const oid of runOids(raw)) if (!baseOids.has(oid)) oidPolicies.set(oid, BLEND);
160
+ // debris sweep AFTER simplify: the boolean's own femto-components are joined by
161
+ // ones simplify() itself pinches off while collapsing seam slivers (measured:
162
+ // 4-triangle atto-scale bubbles, one with negative volume, after a rim fillet
163
+ // over vertical-fillet bands) — sweeping first missed those
164
+ return dropDebris(T(raw.simplify(SIMPLIFY_EPS)));
150
165
  } catch (e) {
151
166
  if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
152
167
  throw e;
153
168
  }
154
169
  };
155
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
+
156
231
  const wrap = (m, hash) => addSugar({
157
232
  _m: m,
158
233
  _hash: hash,
@@ -161,13 +236,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
161
236
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
162
237
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
163
238
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
164
- meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
239
+ meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
165
240
  },
166
241
  chamfer: (d, selector) => {
167
242
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
168
243
  if (d === 0) return wrap(m, hash);
169
244
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
170
- meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
245
+ meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
171
246
  },
172
247
  roundAll: (r) => {
173
248
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
@@ -175,7 +250,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
175
250
  // per-quality kernel, so it can never collide across tiers (the OCCT twin
176
251
  // key omits it for the same reason); it just spells out that the ball
177
252
  // tessellation, and so the result, is tier-dependent.
178
- 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)));
179
255
  },
180
256
  // batch difference: first minus the union of the rest, evaluated as one boolean
181
257
  // tree — no materialized intermediate union (the unionRaw memory note applies)
@@ -192,6 +268,49 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
192
268
  label: (name) => {
193
269
  const lh = h("label", hash, name);
194
270
  return cache.lookup(lh, () => {
271
+ // Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
272
+ // policy), one asOriginal() would fold band and base into a single surface and
273
+ // erase the band-boundary overlay on exactly the solids parts label — every
274
+ // real part labels its top-level solids. Re-stamp as TWO reserved ids instead,
275
+ // base and blend: the label covers both (same string → one feature entry), the
276
+ // distinction survives labeling and every later boolean, and reserved ids are
277
+ // fresh so cached-solid reuse under another label cannot collide — the same
278
+ // guarantee asOriginal() gives the plain path below.
279
+ const g0 = m.getMesh();
280
+ const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
281
+ const oids0 = new Set(g0.runOriginalID);
282
+ if ([...oids0].some(isBlend)) {
283
+ const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
284
+ // base-group policy: the same triangle-weighted majority vote as the plain
285
+ // path, but over the NON-blend runs only — blend runs would elect BLEND for
286
+ // the base group and flag BOTH sides of every boundary seam, which is
287
+ // exactly the no-line state this path exists to avoid.
288
+ const ri = g0.runIndex, roid = g0.runOriginalID;
289
+ const weightByKey = new Map();
290
+ let bestWeight = -1, basePol;
291
+ for (let r = 0; r < roid.length; r++) {
292
+ if (isBlend(roid[r])) continue;
293
+ const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
294
+ const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
295
+ const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
296
+ weightByKey.set(key, weight);
297
+ const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
298
+ if (better) { bestWeight = weight; basePol = pol; }
299
+ }
300
+ g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendId : baseId));
301
+ const o = T(new Manifold(g0));
302
+ g0.delete?.();
303
+ featureLabels.set(baseId, name);
304
+ featureLabels.set(blendId, name);
305
+ oidPolicies.set(blendId, BLEND);
306
+ if (basePol !== undefined) oidPolicies.set(baseId, basePol);
307
+ return { value: wrap(o, lh), pin: o, dispose: () => {
308
+ featureLabels.delete(baseId); featureLabels.delete(blendId);
309
+ oidPolicies.delete(baseId); oidPolicies.delete(blendId);
310
+ o.delete?.();
311
+ } };
312
+ }
313
+ g0.delete?.();
195
314
  const prevId = typeof m.originalID === "function" ? m.originalID() : -1;
196
315
  const o = T(m.asOriginal());
197
316
  const id = o.originalID();
@@ -231,7 +350,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
231
350
  let bestWeight = -1, bestPol;
232
351
  for (let r = 0; r < roid.length; r++) {
233
352
  const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
234
- const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}`;
353
+ const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
235
354
  const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
236
355
  weightByKey.set(key, weight);
237
356
  const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && bestPol?.sameSurfaceLines);
@@ -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
+ }
@@ -8,6 +8,14 @@
8
8
 
9
9
  export const SMOOTH = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true });
10
10
  export const FACETED = Object.freeze({ creaseAngle: 10, sameSurfaceLines: false });
11
+ // A blend surface (fillet/chamfer band). `boundaryLines` widens the CROSS-surface
12
+ // rule for it: a seam between a blend and a NON-blend surface draws regardless of
13
+ // bend — the band's start/end are tangent (~0°) and would otherwise be invisible,
14
+ // leaving the fillet's extent unreadable in the overlay. A seam between TWO blend
15
+ // surfaces keeps the ordinary bend rule (exactly-one-side semantics), so the
16
+ // handover seams along one band — tool splits, corner arcs continuing a sweep —
17
+ // stay invisible while real mitre crossings still draw.
18
+ export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, boundaryLines: true });
11
19
 
12
20
  export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
13
21
  export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line