partforge 0.65.1 → 0.66.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.
@@ -2241,6 +2241,16 @@ 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.
2253
+
2244
2254
  Rules of thumb:
2245
2255
 
2246
2256
  - 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.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",
@@ -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,7 +13,7 @@ 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
18
  import { meshRoundAll } from "./mesh-roundall.js";
19
19
  import { KernelCapabilityError } from "./errors.js";
@@ -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,9 +138,30 @@ 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;
@@ -161,13 +176,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
161
176
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
162
177
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
163
178
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
164
- meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
179
+ meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
165
180
  },
166
181
  chamfer: (d, selector) => {
167
182
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
168
183
  if (d === 0) return wrap(m, hash);
169
184
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
170
- meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
185
+ meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
171
186
  },
172
187
  roundAll: (r) => {
173
188
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
@@ -192,6 +207,49 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
192
207
  label: (name) => {
193
208
  const lh = h("label", hash, name);
194
209
  return cache.lookup(lh, () => {
210
+ // Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
211
+ // policy), one asOriginal() would fold band and base into a single surface and
212
+ // erase the band-boundary overlay on exactly the solids parts label — every
213
+ // real part labels its top-level solids. Re-stamp as TWO reserved ids instead,
214
+ // base and blend: the label covers both (same string → one feature entry), the
215
+ // distinction survives labeling and every later boolean, and reserved ids are
216
+ // fresh so cached-solid reuse under another label cannot collide — the same
217
+ // guarantee asOriginal() gives the plain path below.
218
+ const g0 = m.getMesh();
219
+ const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
220
+ const oids0 = new Set(g0.runOriginalID);
221
+ if ([...oids0].some(isBlend)) {
222
+ const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
223
+ // base-group policy: the same triangle-weighted majority vote as the plain
224
+ // path, but over the NON-blend runs only — blend runs would elect BLEND for
225
+ // the base group and flag BOTH sides of every boundary seam, which is
226
+ // exactly the no-line state this path exists to avoid.
227
+ const ri = g0.runIndex, roid = g0.runOriginalID;
228
+ const weightByKey = new Map();
229
+ let bestWeight = -1, basePol;
230
+ for (let r = 0; r < roid.length; r++) {
231
+ if (isBlend(roid[r])) continue;
232
+ const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
233
+ const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
234
+ const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
235
+ weightByKey.set(key, weight);
236
+ const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
237
+ if (better) { bestWeight = weight; basePol = pol; }
238
+ }
239
+ g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendId : baseId));
240
+ const o = T(new Manifold(g0));
241
+ g0.delete?.();
242
+ featureLabels.set(baseId, name);
243
+ featureLabels.set(blendId, name);
244
+ oidPolicies.set(blendId, BLEND);
245
+ if (basePol !== undefined) oidPolicies.set(baseId, basePol);
246
+ return { value: wrap(o, lh), pin: o, dispose: () => {
247
+ featureLabels.delete(baseId); featureLabels.delete(blendId);
248
+ oidPolicies.delete(baseId); oidPolicies.delete(blendId);
249
+ o.delete?.();
250
+ } };
251
+ }
252
+ g0.delete?.();
195
253
  const prevId = typeof m.originalID === "function" ? m.originalID() : -1;
196
254
  const o = T(m.asOriginal());
197
255
  const id = o.originalID();
@@ -231,7 +289,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
231
289
  let bestWeight = -1, bestPol;
232
290
  for (let r = 0; r < roid.length; r++) {
233
291
  const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
234
- const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}`;
292
+ const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
235
293
  const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
236
294
  weightByKey.set(key, weight);
237
295
  const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && bestPol?.sameSurfaceLines);
@@ -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