partforge 0.64.1 → 0.65.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.
@@ -2153,8 +2153,10 @@ the requirement that exposed the failure.
2153
2153
  Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
2154
2154
  **OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold — and since
2155
2155
  contract v3 that **includes fillet and chamfer**: the mesh backend blends straight
2156
- edges and circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
2157
- face) natively, at exact radius to within tessellation. Only `shell` still routes a
2156
+ edges, circular-arc edges (bore rims, cylinder rims, the arcs where fillets meet a
2157
+ face), and **planar contour edges at constant dihedral** the top/bottom rims of any
2158
+ extruded profile, however curvy its outline: `text2d` lettering, `Shape2D.offset`
2159
+ outlines, spline profiles all round natively now. Only `shell` still routes a
2158
2160
  sub-part to OCCT up front; a fillet/chamfer on an edge class the mesh backend can't
2159
2161
  blend (helical edges, varying dihedral) reroutes that sub-part to OCCT automatically
2160
2162
  at runtime — no declaration needed either way:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.64.1",
3
+ "version": "0.65.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",
@@ -46,7 +46,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
46
46
  // (manifests as "Out of bounds memory access").
47
47
  const tracked = [];
48
48
  const T = (obj) => { tracked.push(obj); return obj; };
49
- const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
49
+ // n-ary union in ONE batch op. This must never be a pairwise reduce: a hundred-tool
50
+ // cutAll (a text rim's fillet) reduced sequentially runs a hundred booleans on an
51
+ // ever-growing intermediate — measured 12 s and a 4 GB peak on a lettering part —
52
+ // while Manifold's own batch operator evaluates the same union as a balanced tree.
53
+ // A one-solid "union" returns the operand's own Manifold untouched (see union below).
54
+ const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
50
55
 
51
56
  const cache = createSolidCache();
52
57
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
@@ -125,9 +130,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
125
130
  // surfaces, so feature-label attribution downstream of the op uses the
126
131
  // fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
127
132
  const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
133
+ // Debris sweep: where blend tools graze each other or a flank near-tangentially, the
134
+ // boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
135
+ // simplify() cannot remove — it collapses edges, never whole components — and that
136
+ // flips the result's genus/decompose count. Anything below DEBRIS_VOL is two orders
137
+ // under the smallest feature the tessellation itself can express, and three under
138
+ // anything printable, so dropping it can never erase real geometry.
139
+ const DEBRIS_VOL = 1e-6; // mm³
140
+ const dropDebris = (m) => {
141
+ const parts = m.decompose();
142
+ if (parts.length <= 1) { for (const p of parts) T(p); return m; }
143
+ const kept = [];
144
+ for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
145
+ return kept.length === parts.length ? m : T(Manifold.compose(kept));
146
+ };
128
147
  const meshCadOp = (op, run) => {
129
148
  try {
130
- return T(T(run()._m.asOriginal()).simplify(SIMPLIFY_EPS));
149
+ return T(dropDebris(T(run()._m.asOriginal())).simplify(SIMPLIFY_EPS));
131
150
  } catch (e) {
132
151
  if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
133
152
  throw e;
@@ -158,8 +177,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
158
177
  // tessellation, and so the result, is tier-dependent.
159
178
  return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
160
179
  },
180
+ // batch difference: first minus the union of the rest, evaluated as one boolean
181
+ // tree — no materialized intermediate union (the unionRaw memory note applies)
161
182
  cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
162
- () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
183
+ () => T(Manifold.difference([m, ...tools.map((t) => t._m)]))),
163
184
  intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
164
185
  union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
165
186
  clone: () => wrap(m, hash),
@@ -325,10 +346,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
325
346
  // (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
326
347
  sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
327
348
  helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
328
- revolve: (pts, { degrees = 360 } = {}) => {
349
+ // opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
350
+ // callers use it where a small feature's sagitta bound needs fewer facets than
351
+ // the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
352
+ revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
353
+ const density = Math.min(segs, segsOverride ?? segs);
329
354
  if (pts && pts._shape2d)
330
- return cached(h("revolve", pts._hash, degrees, segs), () => T(csFor(pts).revolve(segs, degrees)));
331
- return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
355
+ return cached(h("revolve", pts._hash, degrees, density), () => T(csFor(pts).revolve(density, degrees)));
356
+ return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
332
357
  },
333
358
  // A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
334
359
  // unionRaw's reduce returns the operand's own Manifold untouched, so caching it
@@ -10,17 +10,30 @@
10
10
  // - straight chains with planar flanks → lofted prism cutter
11
11
  // - circular-arc chains with revolved flanks → revolved cutter (bore rims,
12
12
  // cylinder rims, the arcs where fillets meet a face), full circles included
13
+ // - planar contour chains at constant dihedral → swept cutter/filler along the
14
+ // chain's own polyline (top/bottom rims of extruded text, offset outlines,
15
+ // splines — see tryPlanarChain/planarTool)
13
16
  // Anything else (helical edges, varying dihedral, branching curves) raises
14
17
  // UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
15
18
  //
16
- // Known limits (documented, not bugs): no spherical corner patches yet two
17
- // chains meeting at a vertex leave a mitred junction where their blend surfaces
18
- // intersect; radius feasibility is the caller's job (clamp like filleted-box.js
19
- // does an oversized radius self-intersects the cutters).
19
+ // Corner treatment: a salient two-chain corner in a common face plane is ROUNDED
20
+ // a small arc chain (radius ~1.05-1.25× the magnitude) replaces the mitre, its horn
21
+ // block shaves the sharp corner down to band depth, and the band sweeps around with
22
+ // no crease (see roundSalientCorners). The silhouette inside the band rounds by about
23
+ // the blend radius at such corners; the flat shelf the horn leaves at the band's base
24
+ // is the deliberate residue. Corners keep their mitre — today's and OCCT's behavior,
25
+ // a real crease the feature-line overlay honestly draws — when they are REFLEX (the
26
+ // ball cannot reach in), or too tight to host the setback (glyph-scale features
27
+ // smaller than ~3× the magnitude), or when three or more chains meet (the spherical
28
+ // cornerPatches below own the orthogonal three-chain case).
29
+ //
30
+ // Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
31
+ // like filleted-box.js does — an oversized radius self-intersects the cutters).
20
32
  //
21
33
  // Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
22
34
  // `dir` only ever matches straight chains, like replicad's inDirection.
23
35
  // Pure module: no DOM, no node:, no three — safe anywhere in the worker graph.
36
+ import { sweepSeedFrame } from "./sweep.js";
24
37
 
25
38
  const TOL = 1e-4; // selector / coplanarity tolerance (mm)
26
39
  const WELD = 1e6; // vertex weld quantization (1/WELD mm grid)
@@ -32,6 +45,24 @@ export class UnsupportedEdgeError extends Error {
32
45
  constructor(message) { super(message); this.name = "UnsupportedEdgeError"; }
33
46
  }
34
47
 
48
+ // Blend-band tessellation density: enough facets to keep the chord sagitta invisible,
49
+ // never more. The kernel's `segs` is a per-circle quality knob sized for part-scale
50
+ // circles; spending it on a blend of radius r tessellates a 0.5 mm fillet to 0.2 µm
51
+ // sagitta at preview quality — and a text rim's hundred-tool boolean then carries ~4×
52
+ // the triangles it needs (measured 12 s / 4 GB on a lettering part before this cap).
53
+ // BLEND_SAG (2 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
54
+ // the 0.02·r term keeps micro-blends sane, and the floor of 12 keeps every facet
55
+ // angle (≤30°) under the viewer's 35° same-surface crease threshold.
56
+ const BLEND_SAG = 2e-3; // mm — max chord sagitta of a blend cross-section
57
+ function blendSegs(segs, r) {
58
+ const s = Math.min(BLEND_SAG, 0.02 * r);
59
+ return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
60
+ }
61
+ // One derivation for a synthetic corner arc's angular density, shared by revolveTool
62
+ // (which sweeps at it) and cornerHornTool (whose apothem bound below depends on it) —
63
+ // the horn's containment proof only holds if both compute the same number.
64
+ const cornerArcSegs = (segs, R, magnitude) => blendSegs(segs, R + magnitude);
65
+
35
66
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
36
67
  const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
37
68
  const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
@@ -193,12 +224,135 @@ export function chainEdges(edges) {
193
224
  }
194
225
  if (run) runs.push(run);
195
226
  // a run's type is the joint type joining its members; single-member runs are lines
227
+ const runChains = [];
196
228
  for (const r of runs) {
197
229
  const type = r.ks.length === 1 || r.type === "coll" || r.type === null ? "line" : "arc";
198
- chains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
230
+ runChains.push(buildChain(edges, path, r.ks, type, closedUniform && runs.length === 1));
231
+ }
232
+ // Planar rescue is per-PATH, not per-run, and replaces the WHOLE path's chains: a rim
233
+ // that mixes straight, curvy, and short runs must become ONE swept tool, because
234
+ // per-run tools along the same rim continue each other nearly collinearly — their
235
+ // overshoots then overlap surface-on-surface (not the clean perpendicular crossing of
236
+ // a box corner) and the boolean leaves degenerate seams where identical blend
237
+ // surfaces coincide. A path whose runs are ALL line/arc keeps its exact per-run tools
238
+ // exactly as before — promotion only fires where the path would otherwise reroute.
239
+ if (runChains.some((c) => c.kind === "unsupported")) {
240
+ const rescue = buildPlanarPath(edges, path);
241
+ if (rescue) { chains.push(rescue); continue; }
242
+ }
243
+ chains.push(...runChains);
244
+ }
245
+ return stitchPlanarChains(chains);
246
+ }
247
+
248
+ // Join open planar chains that continue each other across a path junction. The edge walk
249
+ // ends a path at any degree≠2 vertex — and a real rim grows one wherever its outline
250
+ // turns past sharpDeg, because that corner puts a sharp VERTICAL edge up the wall. The
251
+ // rim's halves then arrive as separate open planar chains whose swept tools would cross
252
+ // at the junction's own shallow angle — a near-parallel surface overlap that leaves
253
+ // degenerate seams (the same disease the whole-path promotion cures within one path).
254
+ // Stitched into one chain, the junction becomes an interior vertex: the sweep miters it
255
+ // when gentle, and planarTool's fold guard splits it (with a clean, wide-angle mitre
256
+ // crossing) when sharp. Chains stitch only when they share an endpoint, the same face
257
+ // plane, and the same convexity — the same-plane test is what keeps a top rim from ever
258
+ // stitching to a bottom rim.
259
+ function stitchPlanarChains(chains) {
260
+ const open = [], out = [];
261
+ for (const c of chains) (c.kind === "planar" && !c.closed ? open : out).push(c);
262
+ if (open.length < 2) return chains;
263
+ const key = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
264
+ const compatible = (a, b) => a.convex === b.convex && dot(a.faceN, b.faceN) > FLANK_COS &&
265
+ Math.abs(dot(a.points[0], a.faceN) - dot(b.points[0], a.faceN)) <= TOL;
266
+ const rev = (c) => ({ ...c, points: [...c.points].reverse(), wallNs: [...c.wallNs].reverse() });
267
+ let progress = true;
268
+ while (progress) {
269
+ progress = false;
270
+ outer: for (let i = 0; i < open.length; i++) {
271
+ let a = open[i];
272
+ // Orientations: b forward/reversed against a's END covers end-start and end-end;
273
+ // the ordered (j,i) pass covers start-end. START-START needs a itself reversed —
274
+ // without this clause two chains seeded outward from the same junction vertex
275
+ // (the edge walk picks its seeds by graph order, not geometry) never stitch, and
276
+ // their tools overshoot tangentially into each other at that junction.
277
+ if (open.some((b, j) => j !== i && compatible(a, b) &&
278
+ (key(b.points[0]) === key(a.points[0]) || key(b.points[b.points.length - 1]) === key(a.points[0]))) &&
279
+ !open.some((b, j) => j !== i && compatible(a, b) &&
280
+ (key(b.points[0]) === key(a.points[a.points.length - 1]) || key(b.points[b.points.length - 1]) === key(a.points[a.points.length - 1])))) {
281
+ a = rev(a);
282
+ }
283
+ const aEnd = key(a.points[a.points.length - 1]);
284
+ for (let j = 0; j < open.length; j++) {
285
+ if (i === j || !compatible(a, open[j])) continue;
286
+ let b = open[j];
287
+ if (key(b.points[b.points.length - 1]) === aEnd) b = rev(b);
288
+ if (key(b.points[0]) !== aEnd) continue;
289
+ const points = [...a.points, ...b.points.slice(1)];
290
+ const closed = key(points[0]) === key(points[points.length - 1]);
291
+ const joined = { ...a, points, wallNs: [...a.wallNs, ...b.wallNs], closed };
292
+ open.splice(Math.max(i, j), 1);
293
+ open.splice(Math.min(i, j), 1);
294
+ (closed ? out : open).push(joined);
295
+ progress = true;
296
+ break outer;
297
+ }
298
+ }
299
+ }
300
+ return [...out, ...open];
301
+ }
302
+
303
+ // Whole-path planar rescue, called by chainEdges when any of a path's runs classified
304
+ // unsupported: rebuild the ENTIRE path (every member, in walk order) as one candidate
305
+ // planar chain. See the promotion comment at the call site for why the whole path — and
306
+ // tryPlanarChain below for what qualifies.
307
+ function buildPlanarPath(edges, path) {
308
+ const members = path.members.map((i) => edges[i]);
309
+ const points = [vertPos(members[0], path.verts[0])];
310
+ members.forEach((m, i) => points.push(vertPos(m, otherVid(m, path.verts[i]))));
311
+ return tryPlanarChain(members, points, members[0].convex, path.loop);
312
+ }
313
+
314
+ // Rescue an unsupported path as a PLANAR chain: every point of the path lies in one plane,
315
+ // one flank IS that plane's face (a world-constant normal — the top of an extrusion, the
316
+ // plate around a boss), and the other flank — the wall — turns with the path at a constant
317
+ // dihedral. Top and bottom rims of extruded profiles whose outlines are neither straight
318
+ // nor circular (text, offset outlines, splines) are exactly this shape, and they used to
319
+ // be this module's most common NEEDS_OCCT reroute. The blend tool for a planar chain is a
320
+ // sweep of the same 2-D cross-section the prism and revolve tools use (planarTool below):
321
+ // in the sweep's transported frame both flanks have constant coordinates along the whole
322
+ // run — the same rotating-frame argument fitArcChain makes about surfaces of revolution —
323
+ // so one fixed profile blends the entire path.
324
+ //
325
+ // Flank pairing keys on the CANDIDATE face normal itself (each of member 0's two flanks
326
+ // in turn): every member contributes whichever of its flanks lies closer to the candidate.
327
+ // Neighbor-pairing — the trick classifyChain's line branch uses — is deliberately NOT
328
+ // reused here: over a long turning run the wall normal rotates far enough that it pairs
329
+ // against the face and scrambles both columns (measured on a 37-edge run of the wavy-rim
330
+ // fixture). Keying on the candidate is stable however far the wall turns, because the
331
+ // true face flank stays within FLANK_COS of it while the wall sits a whole dihedral away.
332
+ // A run where neither candidate yields a constant column (a helix, a saddle) returns null
333
+ // and stays unsupported — the rescue never guesses.
334
+ const FLANK_COS = 0.9986; // ~3°, the same constancy bar the line classifier uses
335
+ function tryPlanarChain(members, points, convex, closed) {
336
+ if (points.length < 3) return null; // a 2-point run is a line chain's job
337
+ for (const cand of [members[0].n1, members[0].n2]) {
338
+ const face = [], wall = [];
339
+ for (const m of members) {
340
+ const [f, wl] = dot(m.n1, cand) >= dot(m.n2, cand) ? [m.n1, m.n2] : [m.n2, m.n1];
341
+ face.push(f);
342
+ wall.push(wl);
199
343
  }
344
+ const meanRaw = face.reduce((s, f) => add(s, f), [0, 0, 0]);
345
+ if (len(meanRaw) < 1e-9) continue;
346
+ const w = norm(meanRaw);
347
+ if (!face.every((f) => dot(f, w) > FLANK_COS)) continue; // not world-constant
348
+ const d0 = dot(points[0], w);
349
+ if (!points.every((p) => Math.abs(dot(p, w) - d0) <= TOL)) continue; // run not in the face plane
350
+ const dots = wall.map((n) => dot(n, w));
351
+ const meanDot = dots.reduce((s, x) => s + x, 0) / dots.length;
352
+ if (!dots.every((x) => Math.abs(x - meanDot) <= 0.05)) continue; // dihedral drifts (~3°)
353
+ return { kind: "planar", points, closed, convex, w, faceN: w, wallNs: wall };
200
354
  }
201
- return chains;
355
+ return null;
202
356
  }
203
357
 
204
358
  function buildChain(edges, path, ks, type, closed) {
@@ -243,11 +397,39 @@ function fitArcChain(members, points, convex, closed) {
243
397
  const alpha = (l2 * (l1 - c12)) / det, beta = (l1 * (l2 - c12)) / det;
244
398
  const O = add(p0, add(scl(e1, alpha), scl(e2, beta)));
245
399
  const R = len(sub(p0, O));
246
- const rtol = Math.max(1e-3, 1e-3 * R);
400
+ // The fit tolerance is ABSOLUTE and tight (2 µm) on purpose, sandwiched from both
401
+ // sides. Below: it must ACCEPT this module's own blend rims — profile2D's area-exact
402
+ // bump parks interior arc vertices up to r·θ²/12 ≈ 1.3 µm off the true circle (the
403
+ // sagitta bound caps θ so that ceiling is density-independent), and a true revolved
404
+ // rim's float32 quantization is far under that. Above: it must REJECT an offset
405
+ // outline that merely APPROXIMATES a circle after simplify() — those deviate by
406
+ // several microns, and the revolve tool follows the FITTED circle, so accepting one
407
+ // turns every real deviation into tangent-seam jitter along the whole run (measured:
408
+ // a label backing drew ~450 band-edge lines from two accepted pseudo-arcs). Rejected
409
+ // rims fall through to the planar-path rescue, whose sweep follows the true polyline
410
+ // exactly. The old max(1e-3, 1e-3·R) relative term is what let the pseudo-arcs in.
411
+ const rtol = 2e-3;
247
412
  for (const p of points) {
248
413
  if (Math.abs(len(sub(p, O)) - R) > rtol) return bad("edge curve is not circular");
249
414
  if (Math.abs(dot(sub(p, O), w)) > rtol) return bad("edge curve is not planar");
250
415
  }
416
+ // Chord-dip gate: the wall facets hang on these same points, so the deepest chord
417
+ // midpoint below the fitted circle measures how coarse the flank tessellation
418
+ // really is. The revolve tool is the right instrument only for kernel-quality
419
+ // surfaces of revolution — its tangent extension chases facets a few microns deep.
420
+ // A rim whose facets dip an order deeper (a polygonal prism, a coarse offset
421
+ // outline) must blend along its own polyline instead: the planar rescue's sweep
422
+ // makes station-exact contact per facet, where a revolve's round tail can only
423
+ // graze a deep flat facet (measured: 24-gon rim, 188 band lines as an arc, zero as
424
+ // a planar chain). Bound: 3× the dip a DEFAULT_SEGS-quality wall would have, plus
425
+ // the fit tolerance both sides of the chord ride on.
426
+ let dip = 0;
427
+ for (let i = 0; i + 1 < points.length; i++) {
428
+ const q = sub(scl(add(points[i], points[i + 1]), 0.5), O);
429
+ dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
430
+ }
431
+ if (dip > 3 * R * (1 - Math.cos(Math.PI / DEFAULT_SEGS)) + 2 * rtol)
432
+ return bad("edge polyline is coarser than a kernel-quality surface of revolution");
251
433
  // frame: azimuth 0 at the first point; flip w so azimuths increase along the run
252
434
  const u0 = norm(sub(points[0], O));
253
435
  let v0 = cross(w, u0);
@@ -399,17 +581,28 @@ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
399
581
  // plane-on-plane, which the kernel resolves exactly.
400
582
  const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
401
583
  const nArc = Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
584
+ // Area-exact tessellation: an inscribed chord polygon under-sweeps the ball arc by a
585
+ // first-order-in-facet-angle area deficit whose RELATIVE size is radius-independent
586
+ // (~0.23/n² of the blend cross-section) — at the sagitta-bounded density above it
587
+ // would bias every blend's volume by ~0.2-0.3%. Interior vertices sit at
588
+ // r·√(θ/sinθ), the radius at which the chord polygon sweeps exactly the arc's area
589
+ // (a micron-scale outward nudge that is material-safe in both boolean directions:
590
+ // a cutter bites a hair deeper mid-chord, a filler overlaps a hair more). The two
591
+ // END vertices stay exactly on the ball — they are the seam with the flanks.
592
+ const th = (span + 2 * ext) / nArc;
593
+ const rEq = r * Math.sqrt(th / Math.sin(th));
402
594
  const pts = [corner];
403
595
  for (let i = 0; i <= nArc; i++) {
404
596
  const nv = rot2(n1, s2 * (-ext + ((span + 2 * ext) * i) / nArc));
405
- pts.push([C[0] + sgn * r * nv[0], C[1] + sgn * r * nv[1]]);
597
+ const ri = i === 0 || i === nArc ? r : rEq;
598
+ pts.push([C[0] + sgn * ri * nv[0], C[1] + sgn * ri * nv[1]]);
406
599
  }
407
600
  return pts;
408
601
  }
409
602
 
410
603
  // ---------------------------------------------------------------------------
411
604
  // Cutter/filler solids.
412
- function prismTool(k, chain, magnitude, mode, segs) {
605
+ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
413
606
  const { a, dir: e, length, n1, n2, convex } = chain;
414
607
  // pose rotation Z → e; the 2D basis is the image of X,Y under the SAME rotation
415
608
  const axisRaw = cross([0, 0, 1], e);
@@ -420,10 +613,12 @@ function prismTool(k, chain, magnitude, mode, segs) {
420
613
  const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
421
614
  const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
422
615
  const p2 = (w) => [dot(w, u), dot(w, v)];
423
- const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs });
616
+ const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs: pSegs });
424
617
  // convex cutters overshoot the edge ends (sticking outside the solid is
425
- // harmless when subtracting); concave fillers must end flush any overshoot
426
- // would bulge outside the part when unioned
618
+ // harmless when subtracting, and at a rounded corner the overshoot continues
619
+ // tangentially into the arc tool, like a stadium rim's prisms always have);
620
+ // concave fillers must end flush — any overshoot would bulge outside the part
621
+ // when unioned
427
622
  const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
428
623
  let tool = k.loft(
429
624
  [{ polygon: poly, z: -over }, { polygon: poly, z: length + over }],
@@ -433,7 +628,11 @@ function prismTool(k, chain, magnitude, mode, segs) {
433
628
  return tool.translate(a);
434
629
  }
435
630
 
436
- function revolveTool(k, chain, magnitude, mode, segs) {
631
+ // `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
632
+ // closed-revolve dephase, which are about matching the neighboring tessellation and
633
+ // must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
634
+ // cross-section itself (blendSegs above).
635
+ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
437
636
  const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
438
637
  // Seam-grazing guard. The edge circle passes through the flank tessellation's
439
638
  // VERTICES (circumradius) while its facets sit at the apothem, so a revolved
@@ -442,14 +641,33 @@ function revolveTool(k, chain, magnitude, mode, segs) {
442
641
  // cannot always collapse them. `sag` is that facet sagitta plus a roundoff pad
443
642
  // bounded relative to the requested feature, so tiny blends never inherit a
444
643
  // fixed allowance larger than their own cross-section.
445
- const sag = (R + magnitude) * (1 - Math.cos(Math.PI / segs)) + Math.min(2e-4, 0.02 * magnitude);
644
+ //
645
+ // The kernel-density term is an ASSUMPTION about the flank, and it is wrong
646
+ // whenever the wall's facets hang on a polyline coarser than kernel quality —
647
+ // an offset outline, a polygonal prism — or when the rim rides the fit tolerance
648
+ // off the fitted circle. The wall facets hang on the chain's own points, so the
649
+ // real depth is measurable: the deepest chord midpoint below the fitted circle.
650
+ // Where the assumed extension fell short of that, the crossing failed mid-facet
651
+ // and a radial knife-fin of wall survived both cutters, drawing a line along the
652
+ // band (the label-backing bug). A synthetic corner arc measures nothing — its two
653
+ // points span the whole corner, and its flanks are planes, not a tessellation.
654
+ const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / segs));
655
+ let dip = 0;
656
+ if (!chain.synthetic) {
657
+ const pts = chain.points;
658
+ for (let i = 0; i + 1 < pts.length; i++) {
659
+ const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), O);
660
+ dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
661
+ }
662
+ }
663
+ const sag = Math.max(kernelSag, dip) + Math.min(2e-4, 0.02 * magnitude);
446
664
  // Fillet: size the arc-tail extension to cross the facet planes, but cap it at
447
665
  // 0.4 rad. Below the mesh's own facet scale a larger tail wraps around the tiny
448
666
  // profile and creates one tunnel per facet; the cap bounds penetration to 8%
449
667
  // of the requested radius while the cutter's outside corner still opens into
450
668
  // free space.
451
669
  const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / magnitude))));
452
- let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs, ext });
670
+ let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs: pSegs, ext });
453
671
  if (mode === "chamfer") {
454
672
  // Chamfer: the cone itself is the cutting surface — no tail to extend, so
455
673
  // bury the whole profile by `sag` along the material-side bisector instead.
@@ -472,7 +690,12 @@ function revolveTool(k, chain, magnitude, mode, segs) {
472
690
  if (area < 0) poly = poly.slice().reverse();
473
691
  const ovAng = closed || !convex ? 0 : Math.min(0.15, Math.max(1e-3, (0.05 * magnitude) / R));
474
692
  const degrees = closed ? 360 : ((span + 2 * ovAng) * 180) / Math.PI;
475
- let tool = k.revolve(poly, { degrees });
693
+ // A real edge-circle arc keeps the kernel's angular density — its facets interact
694
+ // with the flank's own tessellation of the same circle (the dephase note below).
695
+ // A SYNTHETIC corner arc (cornerArcAt) is free-standing between planes, so its
696
+ // angular density follows the same sagitta bound as the cross-section.
697
+ const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : segs;
698
+ let tool = k.revolve(poly, { degrees, segs: aSegs });
476
699
  // pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
477
700
  // chain's start direction (backed off by the angular overshoot)
478
701
  const startDir = closed ? u0 : add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
@@ -494,6 +717,413 @@ function revolveTool(k, chain, magnitude, mode, segs) {
494
717
  return tool.translate(O);
495
718
  }
496
719
 
720
+ // ---------------------------------------------------------------------------
721
+ // Planar-chain blend tool: sweep the shared 2-D cross-section (profile2D) along the
722
+ // chain's own polyline with k.sweep — a prism IS the one-segment case of this sweep,
723
+ // generalized to a path that turns. In the sweep's transported frame the face and wall
724
+ // flanks keep constant coordinates along a planar constant-dihedral path, so ONE profile
725
+ // polygon serves every station; sweepSeedFrame gives the exact frame the sweep will seed,
726
+ // so the profile is authored in it rather than re-deriving (and drifting from) the pick.
727
+ //
728
+ // The sweep miters gently-turning joints on its own. A vertex whose miter would fold —
729
+ // a sharp corner, a reversal cusp, a segment shorter than the profile's reach — SPLITS
730
+ // the chain there instead, and each open stretch overshoots its ends the way prism
731
+ // cutters do, so adjacent stretches mitre into each other across the split. Concave
732
+ // fillers stay flush at their ends — overshoot would bulge outside the part when unioned
733
+ // (prismTool's own rule) — which can leave a hairline notch in a bead at a split; that is
734
+ // the mitred-junction limit from the module header, not a leak (the boolean stays
735
+ // watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
736
+ // converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
737
+ // the build. Returns an ARRAY of tools — one per stretch.
738
+ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
739
+ const { points, closed, convex, faceN, wallNs } = chain;
740
+ const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
741
+ const m = pts.length;
742
+ const at = (i) => pts[((i % m) + m) % m];
743
+ const nSeg = closed ? m : m - 1;
744
+ const segDir = [], segLen = [];
745
+ for (let i = 0; i < nSeg; i++) {
746
+ const d = sub(at(i + 1), at(i)), l = len(d);
747
+ segDir.push(scl(d, 1 / (l || 1)));
748
+ segLen.push(l);
749
+ }
750
+
751
+ // Fold guard, mirrored from resolveSweepStations' miter check with a stricter factor
752
+ // (0.45 vs 0.5) so the split fires before the sweep would throw. `reach` is a cheap
753
+ // rigid upper bound on the profile's half-width — exact reach needs the profile, the
754
+ // profile needs the stretch, and conservatism here only costs an extra mitred split.
755
+ // Split at a vertex whose miter would fold (fold guard, stricter 0.45 factor so the
756
+ // split fires before the sweep would throw) — and also at any stitched-junction
757
+ // corner sharper than SMOOTH_MAX_DEG, whose miter crease would otherwise exceed the
758
+ // viewer's line threshold and draw across the band. A salient split corner with room
759
+ // for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
760
+ // apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
761
+ // points; reflex or too-tight splits keep the overshoot mitre.
762
+ const reach = magnitude * 1.5;
763
+ const breaks = [];
764
+ for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
765
+ const iIn = (i - 1 + nSeg) % nSeg;
766
+ const c = clamp1(dot(segDir[iIn], segDir[i]));
767
+ const turn = Math.acos(c);
768
+ const fold = c < -1 + 1e-6 || reach * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
769
+ const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
770
+ if (fold || sharp) breaks.push(i);
771
+ }
772
+ // Corner arcs per break vertex, with each side's setback budget measured along the
773
+ // polyline to the ADJACENT break (or chain end) — a single tessellation segment says
774
+ // nothing about the room a whole smooth stretch offers.
775
+ const cornerArcs = new Map(); // break vertex index → { arc, t }
776
+ if (convex && breaks.length) {
777
+ const segSum = (from, to) => {
778
+ let sum = 0;
779
+ for (let i = from; i < to; i++) sum += segLen[((i % nSeg) + nSeg) % nSeg];
780
+ return sum;
781
+ };
782
+ for (let j = 0; j < breaks.length; j++) {
783
+ const i = breaks[j], iIn = (i - 1 + nSeg) % nSeg;
784
+ const prevB = closed
785
+ ? breaks[(j - 1 + breaks.length) % breaks.length] - (j === 0 ? m : 0)
786
+ : (j > 0 ? breaks[j - 1] : 0);
787
+ const nextB = closed
788
+ ? breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)
789
+ : (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
790
+ const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
791
+ wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
792
+ if (got) cornerArcs.set(i, got);
793
+ }
794
+ }
795
+
796
+ const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
797
+ const overshoot = (path) => {
798
+ if (!(over > 0) || path.length < 2) return path;
799
+ const a = path[0], b = path[1], y = path[path.length - 1], x = path[path.length - 2];
800
+ return [add(a, scl(norm(sub(a, b)), over)), ...path, add(y, scl(norm(sub(y, x)), over))];
801
+ };
802
+ // pull a stretch endpoint back along the polyline by t, toward a corner arc's
803
+ // tangent point — consuming whole segments where the setback spans several
804
+ const pullBack = (path, t, fromEnd) => {
805
+ if (!(t > 0) || path.length < 2) return path;
806
+ let p = fromEnd ? path.slice().reverse() : path.slice();
807
+ let rem = t;
808
+ while (rem > 1e-12 && p.length >= 2) {
809
+ const seg = sub(p[1], p[0]), l = len(seg);
810
+ if (l > rem + 1e-9) { p[0] = add(p[0], scl(seg, rem / l)); break; }
811
+ rem -= l;
812
+ p.shift();
813
+ }
814
+ return fromEnd ? p.reverse() : p;
815
+ };
816
+
817
+ // One tool per stretch. The profile's wall normal is the SEED member's — the segment
818
+ // whose tangent the sweep frame is seeded ⟂ to: the closing segment for a closed loop,
819
+ // the first segment for an open stretch (overshoot extends along that same tangent, so
820
+ // it never changes the seed).
821
+ // ext stays 0 for every planar sweep, cutters and fillers alike — measured both ways
822
+ // on the fixtures. Stations sit ON the path vertices, so the profile's tangent lines
823
+ // ride the flank facets exactly: plane-on-plane contact the kernel resolves cleanly.
824
+ // An arc-tail extension (revolveTool's recipe for curved-vs-curved phase noise) turns
825
+ // that exact contact into a ~2° grazing CROSSING — and a grazing crossing's float
826
+ // wiggle carves sliver seams whether the tool is subtracted or unioned, because the
827
+ // crossing curve is exactly where the boolean's boundary hands over, always exposed.
828
+ const toolFor = (path3D, isClosed, wallN) => {
829
+ const { N, B } = sweepSeedFrame(path3D, isClosed);
830
+ const p2 = (v) => {
831
+ const q = [dot(v, N), dot(v, B)], l = Math.hypot(q[0], q[1]) || 1;
832
+ return [q[0] / l, q[1] / l];
833
+ };
834
+ const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
835
+ return k.sweep(poly, path3D, { closed: isClosed });
836
+ };
837
+
838
+ try {
839
+ if (closed && breaks.length === 0) {
840
+ return [toolFor(pts.map((p) => [p[0], p[1], p[2]]), true, wallNs[nSeg - 1])];
841
+ }
842
+ // Open stretches between breaks. An open chain's endpoints are implicit breaks; a
843
+ // closed chain's stretches wrap from each break to the next.
844
+ const bounds = closed
845
+ ? breaks.map((b, j) => [b, breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)])
846
+ : (breaks.length ? [[0, breaks[0]], ...breaks.map((b, j) => [b, j + 1 < breaks.length ? breaks[j + 1] : m - 1])] : [[0, m - 1]]);
847
+ const tools = [];
848
+ const arcAt = (i) => cornerArcs.get(((i % m) + m) % m);
849
+ for (const [s, e] of bounds) {
850
+ if (e <= s) continue;
851
+ let path = [];
852
+ for (let i = s; i <= e; i++) path.push(at(i));
853
+ const aS = arcAt(s), aE = arcAt(e);
854
+ if (aS) path = pullBack(path, aS.t, false);
855
+ if (aE) path = pullBack(path, aE.t, true);
856
+ tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
857
+ }
858
+ for (const got of cornerArcs.values()) {
859
+ tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
860
+ if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
861
+ tools.push(cornerHornTool(k, got, magnitude, segs));
862
+ }
863
+ return tools;
864
+ } catch (e) {
865
+ throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
866
+ }
867
+ }
868
+
869
+ // ---------------------------------------------------------------------------
870
+ // Rounded corners. Where exactly TWO selected convex chains meet at a salient corner
871
+ // in a common face plane (a letter corner, a polygon corner on a rim), the blend used
872
+ // to continue straight through from both sides and the two tools crossed in a mitre.
873
+ // That groove is a REAL crease — 76-90° dihedral, measured — so the feature-line
874
+ // overlay faithfully drew a polyline ACROSS the blend band at every such corner, and
875
+ // OCCT's native fillet produces the same intersection-and-trim crease. There is no
876
+ // groove-free construction that keeps the silhouette sharp: the two straight blends
877
+ // must either intersect or the blend must steer around the corner. This steers: the
878
+ // corner is replaced by a small circular ARC chain (radius ~1.05-1.25× the blend
879
+ // magnitude, tangent to both neighbors at a setback), the neighbors are trimmed to the
880
+ // tangent points, and the existing revolveTool sweeps the arc — exactly the shape a
881
+ // rounded-rectangle rim already has, which renders line-free today. The cost, stated
882
+ // plainly: within the band the silhouette rounds by about the blend radius at corners
883
+ // sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
884
+ // mitre under the viewer\'s 35° line threshold draws nothing).
885
+ //
886
+ // REFLEX corners keep the mitre: the ball genuinely cannot reach into the corner, the
887
+ // crease there is real geometry, and rounding the path would ADD material. A corner
888
+ // whose neighbors are too short to host the setback (tight glyph features) falls back
889
+ // to the mitre too — the fallback is today\'s behavior, never a failure.
890
+ // Corners gentler than this keep their mitre: the two blends there differ by less than
891
+ // the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
892
+ // overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
893
+ // a 20.7° mitre still drew (its long shallow overlap wedge triangulates into >35°
894
+ // junk), an ~8° one does not. The silhouette cost of rounding a gentle corner is a
895
+ // sagitta of ρ·(1−cos(turn/2)) — sub-micron at these angles — so the gate is safe low.
896
+ const CORNER_ROUND_MIN_TURN = (8 * Math.PI) / 180;
897
+ const RHO_MIN = 1.05; // × magnitude — revolve floor: the profile reaches magnitude inward of the arc
898
+ const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over the floor for margin
899
+
900
+ // Corner-arc descriptor at one vertex. tin1/tin2 point from the vertex INTO each side;
901
+ // wall1/wall2 are the sides\' outward wall normals at the vertex; len1/len2 bound the
902
+ // setback. Returns { arc, t } (a synthetic kind:"arc" chain for revolveTool, plus the
903
+ // setback to trim each side by) or null when the corner keeps its mitre.
904
+ function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude) {
905
+ const tIn = scl(tin1, -1), tOut = tin2; // travel: arrive along side 1, depart into 2
906
+ const turn = Math.acos(clamp1(dot(tIn, tOut)));
907
+ if (turn < CORNER_ROUND_MIN_TURN) return null;
908
+ const turnS = dot(cross(tIn, tOut), f);
909
+ const matLeft = dot(wall1, cross(tIn, f)) > 0;
910
+ if ((turnS > 0) !== matLeft) return null; // reflex: the crease is real — keep the mitre
911
+ const tanH = Math.tan(turn / 2);
912
+ if (!(tanH > 1e-6) || !Number.isFinite(tanH)) return null;
913
+ const t = Math.min(RHO_PREF * magnitude * tanH, 0.45 * len1, 0.45 * len2);
914
+ const rho = t / tanH;
915
+ if (rho < RHO_MIN * magnitude) return null; // no room: mitre fallback
916
+ // inward bisector from the walls; O sits at distance rho from both edge lines
917
+ const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
918
+ const uA = proj(wall1), uB = proj(wall2);
919
+ const bisRaw = add(uA, uB);
920
+ if (len(bisRaw) < 1e-9) return null;
921
+ const O = add(vertex, scl(norm(bisRaw), rho / Math.cos(turn / 2)));
922
+ const pA = add(vertex, scl(tin1, t)), pB = add(vertex, scl(tin2, t));
923
+ const u0raw = sub(pA, O), uEraw = sub(pB, O);
924
+ const u0 = norm(u0raw), uE = norm(uEraw);
925
+ const span = Math.acos(clamp1(dot(u0, uE)));
926
+ if (!(span > 1e-4)) return null;
927
+ const s = dot(cross(u0, uE), f) >= 0 ? 1 : -1; // orient w so azimuth increases pA → pB
928
+ const w = scl(f, s);
929
+ // rotating-frame flanks, fitArcChain\'s convention ([ρ-component, w-component]):
930
+ // the face flank is pure ±w, the wall is pure outward radial
931
+ return {
932
+ t,
933
+ vertex,
934
+ f,
935
+ arc: { kind: "arc", points: [pA, pB], O, w, u0, v0: cross(w, u0), R: rho, span,
936
+ closed: false, n1: [0, s], n2: [1, 0], convex: true, synthetic: true },
937
+ };
938
+ }
939
+
940
+ // The horn cutter that completes a rounded corner. The arc tool blends the band around
941
+ // the corner's arc cylinder, but the SOLID still has its sharp corner: the column of
942
+ // material between that cylinder and the original vertex would poke up through the band
943
+ // untouched. This block removes it — footprint bounded by the two walls and an arc-side
944
+ // polyline held strictly INSIDE the arc tool's own cut region — from just above the
945
+ // face down to exactly band depth. What remains below is a small flat shelf at the
946
+ // corner base; its rim is a boundary line BELOW the band, the deliberate trade for a
947
+ // band with no lines across it.
948
+ //
949
+ // The arc-side vertices sit at the arc TOOL's guaranteed apothem, R·cos(π/aSegs), less
950
+ // a micron margin — not on the circle itself. Vertices on the circle only bow inside
951
+ // the SMOOTH cylinder; the tool is a polygonal revolve whose facets sit at ITS apothem,
952
+ // and whenever the horn's chords landed shallower than a tool facet (a short-span arc
953
+ // at reduced angular density), the wall between them survived both cutters as a lens
954
+ // filament — an island or a handle, decided by facet phase (measured: the arrow's
955
+ // 20.7° corner flipped genus at some densities and not others). The apothem bound makes
956
+ // containment a proof instead of a phase lottery: tool pitch ≤ 2π/aSegs by definition
957
+ // of its step count, so its apothem ≥ R·cos(π/aSegs) > every horn vertex radius. The
958
+ // cost is a micron-deep extra bite at the corner base, covered near the tangent lines
959
+ // by the neighbors' own overshoot.
960
+ function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
961
+ const { O, w, u0, R, span } = arc;
962
+ const delta = 0.02 * magnitude;
963
+ const rH = R * Math.cos(Math.PI / cornerArcSegs(segs, R, magnitude)) - Math.min(1e-3, 0.02 * magnitude);
964
+ // Pose and depth run along the FACE normal f (material below the face), never the
965
+ // arc's w — w flips sign with the arc's travel direction, and a block lofted along a
966
+ // downward w would stand above the face and cut the top instead of the horn.
967
+ const aRaw = cross([0, 0, 1], f);
968
+ const s = len(aRaw);
969
+ let axis = null, theta = 0;
970
+ if (s > 1e-9) { axis = scl(aRaw, 1 / s); theta = Math.atan2(s, f[2]); }
971
+ else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
972
+ const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
973
+ const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
974
+ const p2 = (p) => { const q = sub(p, O); return [dot(q, u), dot(q, v)]; };
975
+ const poly = [];
976
+ poly.push(p2(add(vertex, scl(norm(sub(vertex, O)), delta)))); // vertex, nudged outward
977
+ poly.push(p2(add(O, scl(u0, R + delta)))); // tangent A, nudged past its wall
978
+ const steps = 8;
979
+ for (let i = 0; i <= steps; i++) poly.push(p2(add(O, scl(rotVec(u0, w, (span * i) / steps), rH))));
980
+ poly.push(p2(add(O, scl(rotVec(u0, w, span), R + delta)))); // tangent B, nudged past its wall
981
+ let area = 0;
982
+ for (let i = 0; i < poly.length; i++) {
983
+ const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
984
+ area += x1 * y2 - x2 * y1;
985
+ }
986
+ const ring = area < 0 ? poly.slice().reverse() : poly;
987
+ let tool = k.loft([{ polygon: ring, z: -magnitude }, { polygon: ring, z: delta }], { shading: "smooth" });
988
+ if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
989
+ return tool.translate(O);
990
+ }
991
+
992
+ function chainEndInfo(ch, end) {
993
+ if (ch.kind === "line") {
994
+ return end === "start"
995
+ ? { v: ch.a, tin: ch.dir, flanks: [ch.n1, ch.n2], len: ch.length }
996
+ : { v: ch.b, tin: scl(ch.dir, -1), flanks: [ch.n1, ch.n2], len: ch.length };
997
+ }
998
+ const pts = ch.points, m = pts.length;
999
+ let plen = 0;
1000
+ for (let i = 0; i + 1 < m; i++) plen += len(sub(pts[i + 1], pts[i]));
1001
+ return end === "start"
1002
+ ? { v: pts[0], tin: norm(sub(pts[1], pts[0])), flanks: [ch.faceN, ch.wallNs[0]], len: plen }
1003
+ : { v: pts[m - 1], tin: norm(sub(pts[m - 2], pts[m - 1])), flanks: [ch.faceN, ch.wallNs[ch.wallNs.length - 1]], len: plen };
1004
+ }
1005
+
1006
+ // Corner arc for two chain ends meeting at one vertex, or null (no common face plane,
1007
+ // gentle turn, reflex corner, or no room for the setback).
1008
+ function cornerArcBetween(E1, E2, magnitude) {
1009
+ let f = null, wall1 = null, wall2 = null;
1010
+ for (const c1 of E1.flanks) {
1011
+ for (const c2 of E2.flanks) {
1012
+ if (dot(c1, c2) <= FLANK_COS) continue;
1013
+ // the shared face is ⟂ BOTH tangents; each wall is ⟂ only its own chain\'s
1014
+ if (Math.abs(dot(c1, E1.tin)) > 0.05 || Math.abs(dot(c1, E2.tin)) > 0.05) continue;
1015
+ f = norm(add(c1, c2));
1016
+ wall1 = E1.flanks[0] === c1 ? E1.flanks[1] : E1.flanks[0];
1017
+ wall2 = E2.flanks[0] === c2 ? E2.flanks[1] : E2.flanks[0];
1018
+ }
1019
+ }
1020
+ if (!f) return null;
1021
+ return cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
1022
+ }
1023
+
1024
+ // Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
1025
+ // the arc has no world-constant flank (a rim on a curved face — revolveTool's
1026
+ // irreplaceable case). In-plane rims blend by sweeping their own polyline instead of
1027
+ // revolving a fitted circle, for two reasons measured on a label backing. The sweep's
1028
+ // stations sit ON the rim vertices, so its flank contact is plane-exact per wall facet,
1029
+ // where the revolve's contact is a three-way micron contest (its own angular chords,
1030
+ // the wall's facets, and the circle fit's offset) that strands radial knife-fins along
1031
+ // the band whenever the margins interfere. And a planar chain STITCHES to its planar
1032
+ // neighbors, so the arc↔planar junction — two tools overshooting tangentially into
1033
+ // each other, which roundSalientCorners never handled because it skips arc chains —
1034
+ // stops existing as a category. Selection still runs on the ARC form (near-selectors
1035
+ // match the fitted circle, not its chords); conversion happens after, in apply().
1036
+ function planarizeArc(ch) {
1037
+ if (ch.kind !== "arc") return null;
1038
+ // face flank = the rotating-frame flank that is axial (±w, world-constant); ~3° bar
1039
+ const pick = Math.abs(ch.n1[0]) <= 0.05 ? 0 : Math.abs(ch.n2[0]) <= 0.05 ? 1 : -1;
1040
+ if (pick === -1) return null;
1041
+ const [face, wall] = pick === 0 ? [ch.n1, ch.n2] : [ch.n2, ch.n1];
1042
+ const faceN = scl(ch.w, Math.sign(face[1]));
1043
+ const pts = ch.points;
1044
+ const wallNs = [];
1045
+ for (let i = 0; i + 1 < pts.length; i++) {
1046
+ const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), ch.O);
1047
+ const rho = norm(sub(q, scl(ch.w, dot(q, ch.w))));
1048
+ wallNs.push(norm(add(scl(rho, wall[0]), scl(ch.w, wall[1]))));
1049
+ }
1050
+ return { kind: "planar", points: pts.map((p) => [p[0], p[1], p[2]]), closed: ch.closed,
1051
+ convex: ch.convex, w: faceN, faceN, wallNs };
1052
+ }
1053
+
1054
+ // Trim a chain back by tStart/tEnd (0 = untouched) toward the corner arcs that replace
1055
+ // its mitred ends. Line chains shift their endpoints; planar chains walk the polyline in
1056
+ // from each end, dropping consumed vertices (and their members\' wall normals) and
1057
+ // planting the new endpoint mid-segment. Returns the trimmed copy, or null when nothing
1058
+ // usable remains (guarded against by cornerArcAt\'s 0.45·length setback cap).
1059
+ function trimChain(ch, tStart, tEnd) {
1060
+ if (!(tStart > 0) && !(tEnd > 0)) return ch;
1061
+ if (ch.kind === "line") {
1062
+ const length = ch.length - tStart - tEnd;
1063
+ if (!(length > 1e-9)) return null;
1064
+ return { ...ch, a: add(ch.a, scl(ch.dir, tStart)), b: sub(ch.b, scl(ch.dir, tEnd)), length };
1065
+ }
1066
+ let pts = ch.points.map((p) => [p[0], p[1], p[2]]);
1067
+ let walls = ch.wallNs.slice();
1068
+ const eat = (t) => { // consume t from the FRONT of pts/walls
1069
+ while (t > 1e-12 && pts.length >= 2) {
1070
+ const seg = sub(pts[1], pts[0]), l = len(seg);
1071
+ if (l > t + 1e-12) { pts[0] = add(pts[0], scl(seg, t / l)); return true; }
1072
+ t -= l;
1073
+ pts.shift();
1074
+ walls.shift();
1075
+ }
1076
+ return pts.length >= 2;
1077
+ };
1078
+ const flip = () => { pts.reverse(); walls.reverse(); };
1079
+ if (tStart > 0 && !eat(tStart)) return null;
1080
+ if (tEnd > 0) { flip(); if (!eat(tEnd)) return null; flip(); }
1081
+ if (pts.length < 2) return null;
1082
+ return { ...ch, points: pts, wallNs: walls, closed: false };
1083
+ }
1084
+
1085
+ // Round the salient two-chain corners of a selection: returns the effective chain list
1086
+ // (trimmed neighbors substituted in place) plus the synthetic corner-arc chains.
1087
+ function roundSalientCorners(selected, magnitude) {
1088
+ const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
1089
+ const ends = new Map();
1090
+ for (const ch of selected) {
1091
+ if (ch.closed || ch.convex !== true) continue;
1092
+ if (ch.kind !== "line" && ch.kind !== "planar") continue;
1093
+ for (const end of ["start", "end"]) {
1094
+ const info = chainEndInfo(ch, end);
1095
+ const kk = keyOf(info.v);
1096
+ (ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
1097
+ }
1098
+ }
1099
+ const arcs = [], horns = [], trims = new Map();
1100
+ const addTrim = (ch, end, t) => {
1101
+ const cur = trims.get(ch) ?? { start: 0, end: 0 };
1102
+ cur[end] = t;
1103
+ trims.set(ch, cur);
1104
+ };
1105
+ for (const list of ends.values()) {
1106
+ if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
1107
+ const got = cornerArcBetween(list[0].info, list[1].info, magnitude);
1108
+ if (!got) continue;
1109
+ arcs.push(got.arc);
1110
+ // a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
1111
+ // small turns — not worth a cutter (and thin cutters are their own noise source)
1112
+ if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
1113
+ horns.push({ vertex: got.vertex, f: got.f, arc: got.arc });
1114
+ addTrim(list[0].ch, list[0].end, got.t);
1115
+ addTrim(list[1].ch, list[1].end, got.t);
1116
+ }
1117
+ if (!arcs.length) return { chains: selected, arcs, horns };
1118
+ const chains = [];
1119
+ for (const ch of selected) {
1120
+ const tr = trims.get(ch);
1121
+ const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
1122
+ if (eff) chains.push(eff);
1123
+ }
1124
+ return { chains, arcs, horns };
1125
+ }
1126
+
497
1127
  // ---------------------------------------------------------------------------
498
1128
  // Spherical corner patches. Where exactly three selected straight convex chains
499
1129
  // meet at a vertex with mutually orthogonal directions (a box-like corner), the
@@ -565,10 +1195,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
565
1195
  if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
566
1196
  const unsupported = selected.find((ch) => ch.kind === "unsupported");
567
1197
  if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
568
- const tool = (ch) => (ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs);
569
- const cutters = selected.filter((ch) => ch.convex).map(tool);
570
- const fillers = selected.filter((ch) => !ch.convex).map(tool);
571
- if (mode === "fillet") cutters.push(...cornerPatches(k, selected, magnitude, segs));
1198
+ // Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
1199
+ // converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
1200
+ // these chains were planar, so their junctions are still open here.
1201
+ const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
1202
+ const { chains: effective, arcs, horns } = roundSalientCorners(planarized, magnitude);
1203
+ const pSegs = blendSegs(segs, magnitude);
1204
+ const toolsFor = (ch) =>
1205
+ ch.kind === "planar"
1206
+ ? planarTool(k, ch, magnitude, mode, segs, pSegs)
1207
+ : [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
1208
+ const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
1209
+ cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
1210
+ const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
1211
+ if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
572
1212
  let out = solid;
573
1213
  if (cutters.length) out = out.cutAll(cutters);
574
1214
  if (fillers.length) out = k.union([out, ...fillers]);
@@ -43,6 +43,20 @@ const rodrigues = (v, k, ang) => {
43
43
  const placeRing = (profile2D, center, N, B) =>
44
44
  profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
45
45
 
46
+ // The seed frame a sweep of `path3D` will start from — exported so a caller authoring a
47
+ // profile FOR a sweep (mesh-fillet's planar-chain tool) can express it in exactly the
48
+ // frame the sweep will use, instead of replicating the reference-vector pick and drifting
49
+ // when it changes. Matches resolveSweepStations: the frame is ⟂ the tangent coming INTO
50
+ // the first processed station (the closing segment for a closed loop, the first segment
51
+ // for an open path).
52
+ export function sweepSeedFrame(path3D, closed = false) {
53
+ const m = path3D.length;
54
+ const T = closed ? norm(sub(path3D[0], path3D[m - 1])) : norm(sub(path3D[1], path3D[0]));
55
+ const ref = Math.abs(dot(T, Z)) < 0.9 ? Z : X;
56
+ const N = norm(sub(ref, scl(T, dot(ref, T))));
57
+ return { T, N, B: cross(T, N) };
58
+ }
59
+
46
60
  export function resolveSweepStations(profile2D, path3D, { closed = false, cornerRadius = 0 } = {}) {
47
61
  if (!Array.isArray(profile2D) || profile2D.length < 3)
48
62
  throw new Error("sweep: profile2D must be an array of ≥3 [x,y] points");
@@ -66,11 +80,9 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
66
80
  for (const [x, y] of profile2D) maxReach = Math.max(maxReach, Math.hypot(x, y));
67
81
 
68
82
  // Seed the frame ⟂ the tangent coming INTO the first processed station (reference-vector
69
- // method; the ref pick avoids N collapsing when the path starts along Z).
70
- const seedT = closed ? dir[segCount - 1] : dir[0];
71
- const ref = Math.abs(dot(seedT, Z)) < 0.9 ? Z : X;
72
- let N = norm(sub(ref, scl(seedT, dot(ref, seedT))));
73
- let B = cross(seedT, N);
83
+ // method; the ref pick avoids N collapsing when the path starts along Z). Shared with
84
+ // sweepSeedFrame above so profile authors can target the exact same frame.
85
+ let { N, B } = sweepSeedFrame(P, closed);
74
86
 
75
87
  const stations = [];
76
88