partforge 0.67.0 → 0.67.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.67.0",
3
+ "version": "0.67.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",
@@ -0,0 +1,13 @@
1
+ // Local test harness for the "Scott Layered Label" cloud part (feedback report
2
+ // 41850ea2, part 0d47960f) — the reflex-rim-pivot fix's real-world fixture. The
3
+ // part source is a verbatim copy from the cloud part's part.js into
4
+ // src/parts/scott-label.js. `npm run dev`, then open /scott-label.html.
5
+ import "@fontsource-variable/geist";
6
+ import "@fontsource-variable/geist-mono";
7
+ import part from "./parts/scott-label.js";
8
+ import { mount } from "./framework/index.js";
9
+
10
+ mount(part, {
11
+ createWorker: (name) =>
12
+ new Worker(new URL("./scott-label-worker.js", import.meta.url), { type: "module", name }),
13
+ });
@@ -8,7 +8,7 @@
8
8
  // near-tangent), and a surface's own sharp edges stay crisp too. Each original
9
9
  // surface may carry a shading policy (shading-policy.js); surfaces without one
10
10
  // use SMOOTH, which reproduces the pre-policy behavior exactly.
11
- import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, cosDeg } from "./shading-policy.js";
11
+ import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, MIN_FACE, cosDeg } from "./shading-policy.js";
12
12
 
13
13
  const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
14
14
  const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
@@ -48,17 +48,43 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
48
48
  const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
49
49
  const wx = vp[c] - vp[b], wy = vp[c + 1] - vp[b + 1], wz = vp[c + 2] - vp[b + 2];
50
50
  const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
51
- const L = Math.hypot(nx, ny, nz) || 1;
51
+ const L0 = Math.hypot(nx, ny, nz), L = L0 || 1; // the || 1 is for the normal divide ONLY
52
52
  fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
53
53
  const longest = Math.max(ux * ux + uy * uy + uz * uz, vx * vx + vy * vy + vz * vz, wx * wx + wy * wy + wz * wz);
54
- thin[t] = longest > 0 ? L / Math.sqrt(longest) : 0; // |cross| / maxEdge = min height
54
+ // |cross| / maxEdge = min height from the RAW cross magnitude, never the
55
+ // guarded L: a zero-area triangle (two float32-coincident vertices — the
56
+ // render-precision collapse of a sub-micron boolean seam sliver) must report
57
+ // thin 0 so the feature-edge gate drops it. With L it reported 1/maxEdge and
58
+ // sailed past the gate, and its garbage (0,0,0) normal reads as a 90° crease
59
+ // against every neighbor — a full-weight line down an otherwise smooth wall
60
+ // at whatever seam produced it.
61
+ thin[t] = longest > 0 ? L0 / Math.sqrt(longest) : 0;
62
+ }
63
+
64
+ // Second-stage weld for SHADING adjacency only: a boolean seam whose two
65
+ // sides land sub-micron apart keeps two distinct vertex columns that the
66
+ // merge map does not join, yet at render (float32) precision they are the
67
+ // same point — without this weld the facets on either side average their
68
+ // normals separately and the seam shades as a lighting crease. The line
69
+ // pass below deliberately keeps `remap` (Manifold's own topology): welding
70
+ // its edge keys would make pairing at collapsed seams order-dependent and
71
+ // could pair a boundary ring's edges away.
72
+ const weld = Uint32Array.from(remap);
73
+ {
74
+ const byPos = new Map();
75
+ for (let i = 0; i < nVert; i++) {
76
+ const o = i * np;
77
+ const key = `${vp[o]}|${vp[o + 1]}|${vp[o + 2]}`;
78
+ const first = byPos.get(key);
79
+ if (first === undefined) byPos.set(key, weld[i]); else weld[i] = first;
80
+ }
55
81
  }
56
82
 
57
83
  // canonical vertex → incident triangles
58
84
  const incident = new Map();
59
85
  for (let t = 0; t < nTri; t++)
60
86
  for (let k = 0; k < 3; k++) {
61
- const cv = remap[tris[t * 3 + k]];
87
+ const cv = weld[tris[t * 3 + k]];
62
88
  const arr = incident.get(cv);
63
89
  if (arr) arr.push(t); else incident.set(cv, [t]);
64
90
  }
@@ -71,13 +97,19 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
71
97
  for (let k = 0; k < 3; k++) {
72
98
  const v = tris[t * 3 + k];
73
99
  let nx = 0, ny = 0, nz = 0;
74
- for (const t2 of incident.get(remap[v])) {
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
100
+ for (const t2 of incident.get(weld[v])) {
101
+ // different cut surface → hard, EXCEPT when a blend surface (boundaryLines)
102
+ // is involved on either side. Blend↔blend: one band is many tool surfaces
103
+ // continuing each other tangentially, and hard normals at their handovers
104
+ // would put lighting seams along a band that used to shade as one
105
+ // re-originaled surface. Blend↔base: the band's start/end seams are TANGENT
106
+ // by construction (that is why the line pass needs boundaryLines to draw
107
+ // them at all), so shading them hard painted a permanent lighting ridge
108
+ // along every fillet boundary ring. Both cases still fall to the crease
109
+ // check below, so a genuinely sharp crossing (a chamfer's 45° shoulder, a
110
+ // band end-cap against a wall) stays hard.
79
111
  if (triOID[t2] !== oid &&
80
- !(polFor(triOID[t2]).boundaryLines && polFor(oid).boundaryLines)) continue;
112
+ !(polFor(triOID[t2]).boundaryLines || polFor(oid).boundaryLines)) continue;
81
113
  if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
82
114
  nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
83
115
  }
@@ -108,8 +140,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
108
140
  // independently tessellated tangent surfaces (e.g. a corner sphere meeting
109
141
  // its edge-fillet cylinders) can leave micron-wide wall strips whose FACES
110
142
  // are invisible but whose long boundary edges would otherwise draw at full
111
- // line weight. A triangle thinner than MIN_EDGE cannot be seen, so its
112
- // edges are noise by definition same threshold the segment filter uses.
143
+ // line weight. A triangle thinner than MIN_FACE cannot carry a visible
144
+ // crease the fan slivers a boolean face-split leaves near a tool
145
+ // crossing are 14-34µm wide with wildly tilted normals over sub-15µm of
146
+ // actual relief (see shading-policy.js) — so its edges are noise by
147
+ // definition. The gate is deliberately wider than the segment filter's
148
+ // MIN_EDGE below, which stays tight so short REAL segments survive.
113
149
  const sameOID = triOID[prev] === triOID[t];
114
150
  // Blend boundary: a cross-surface seam with a BLEND policy on EXACTLY one side
115
151
  // is the start/end of a fillet band — draw it even when tangent (the band's
@@ -121,7 +157,7 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
121
157
  // filter still drops the short ones).
122
158
  const boundary = !sameOID &&
123
159
  !!polFor(triOID[prev]).boundaryLines !== !!polFor(triOID[t]).boundaryLines;
124
- if (!boundary && (thin[prev] < MIN_EDGE || thin[t] < MIN_EDGE)) continue;
160
+ if (!boundary && (thin[prev] < MIN_FACE || thin[t] < MIN_FACE)) continue;
125
161
  const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
126
162
  // A multi-hole cap triangulation can contain an opposite-wound bridge:
127
163
  // its two normals disagree by 180 degrees even though both triangles lie
@@ -132,11 +132,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
132
132
  // anything printable, so dropping it can never erase real geometry.
133
133
  const DEBRIS_VOL = 1e-6; // mm³
134
134
  const dropDebris = (m) => {
135
- const parts = m.decompose();
136
- if (parts.length <= 1) { for (const p of parts) T(p); return m; }
137
- const kept = [];
138
- for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
139
- return kept.length === parts.length ? m : T(Manifold.compose(kept));
135
+ // Iterated, not single-pass: compose() re-welds, and a sliver riding a kept
136
+ // component only as a vertex-weld can come apart as a FRESH femto-component
137
+ // in the composed result (measured on the melt fixture's grazing neck arcs:
138
+ // four needles dropped, two re-minted by the compose). Loop until a pass
139
+ // drops nothing; volumes shrink every round, so three passes is plenty.
140
+ for (let pass = 0; pass < 3; pass++) {
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
+ if (kept.length === parts.length) return m;
146
+ m = T(Manifold.compose(kept));
147
+ }
148
+ return m;
140
149
  };
141
150
  // Blend surfaces KEEP their originalIDs, and every id the op introduced is
142
151
  // registered with the BLEND policy — that is what lets creased-normals draw the
@@ -189,20 +198,36 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
189
198
  // the erosion consumes the whole plate below 2r, and just above it the two
190
199
  // rim bands graze each other — both belong to the reference morphology
191
200
  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.
201
+ // Join types are per-STEP, because a join only acts on the side an offset
202
+ // DIVERGES on. The two dilates diverge at SALIENT corners and use MITER,
203
+ // deliberately: the true morphology mints salient corners of radius
204
+ // exactly r, and a rim fillet of radius r over an r-radius salient corner
205
+ // pinches its top tangent contour to a point — the planar sweep refuses,
206
+ // structurally. Miter keeps them SHARP instead and the selector-free
207
+ // fillet below performs their rounding (cutters at r the same solid the
208
+ // round join would have produced plus the corner treatments). The erode
209
+ // diverges at REFLEX corners and uses ROUND: the ball morphology rounds a
210
+ // reflex corner into a radius-r concave arc (2r at the erode, halved by
211
+ // the re-dilate), and a concave arc offsets INWARD to 2r, so it cannot
212
+ // pinch the rim sweep — the rim rides it as an ordinary arc-in-planar
213
+ // chain. Keeping reflex corners sharp instead (all-Miter, the original
214
+ // design) handed the fillet a sharp vertical filler PLUS a sharp rim
215
+ // corner at once, and the filler — unioned after the cutters — re-covered
216
+ // the rim tools' corner cut, reintroducing the uncut-wedge point artifact
217
+ // the reflex pivot exists to remove. Melt and seal thresholds stay exact
218
+ // on straight stretches; salient-corner silhouettes differ from the ball
219
+ // morphology by the rim fillet's own corner tolerance (~0.25·r) — the
220
+ // documented corner trade — while reflex corners now match it exactly.
221
+ //
222
+ // Arc facets must stay well under the fillet's sharp-edge threshold
223
+ // (detectSharpEdges' 20° default) or the rounded wall would re-detect as a
224
+ // run of sharp vertical edges: floor the round join at 36 segments/360°
225
+ // (10° per facet) even when the sagitta rule alone would allow coarser.
226
+ const arcSegs = Math.max(roundAllSegs(2 * r, quality), 36);
202
227
  let cur = null;
203
228
  try {
204
229
  for (const delta of [r, -2 * r, r]) {
205
- const next = (cur ?? cs).offset(delta, "Miter", 2);
230
+ const next = (cur ?? cs).offset(delta, delta > 0 ? "Miter" : "Round", 2, arcSegs);
206
231
  const cleaned = next.simplify(1e-6);
207
232
  next.delete?.();
208
233
  cur?.delete?.();
@@ -214,10 +239,30 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
214
239
  const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
215
240
  // selector-free: every sharp edge of the mitered prism gets its radius here
216
241
  const filleted = wrapped.fillet(r);
217
- // decouple from the fillet cache's pin: cached() will pin the object this
242
+ // Decouple from the fillet cache's pin: cached() will pin the object this
218
243
  // 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());
244
+ // under two cache entries (double-dispose on eviction). The decouple is
245
+ // blend-AWARE, the same re-stamp label() performs: a blanket asOriginal()
246
+ // folded blend and base into one fresh surface, erasing the distinction
247
+ // the band-boundary lines need — a roundAll'd prism rendered with no
248
+ // feature lines at all, unlike the identical geometry from fillet().
249
+ // Re-stamp as two reserved ids instead (base stays unregistered → SMOOTH,
250
+ // the extruded section has no policy of its own) so the result draws
251
+ // exactly the lines the fillet drew.
252
+ const g0 = filleted._m.getMesh();
253
+ try {
254
+ const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
255
+ if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
256
+ const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
257
+ g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendId : baseId));
258
+ oidPolicies.set(blendId, BLEND);
259
+ // The constructor re-welds, and that can pinch a latent sliver off a
260
+ // component as fresh femto-debris (the same rebirth dropDebris's own
261
+ // compose() loop guards against) — sweep the reconstruction too.
262
+ return dropDebris(T(new Manifold(g0)));
263
+ } finally {
264
+ g0.delete?.();
265
+ }
221
266
  } finally {
222
267
  cur?.delete?.();
223
268
  }
@@ -16,20 +16,25 @@
16
16
  // Anything else (helical edges, varying dihedral, branching curves) raises
17
17
  // UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
18
18
  //
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. A REFLEX corner in a common face plane gets the
25
- // rolling-ball PIVOT (reflexPivotAt/reflexPivotTool): the ball swings about the
26
- // corner's face-normal axis, touching the face and the vertical corner edge, and the
27
- // face's blend boundary rounds into an arc of radius r about the vertex — without it
28
- // the flush-ended neighbor tools leave a wedge of the original rim uncut and the face
29
- // keeps a point AT the corner (the label-part "artifacts" bug). Corners keep their
30
- // mitre only when too tight to host the salient setback (glyph-scale features smaller
31
- // than ~3× the magnitude), or when three or more chains meet (the spherical
32
- // cornerPatches below own the orthogonal three-chain case).
19
+ // Corner treatment, by how sharp the corner is. A SHARP salient corner (turn past
20
+ // SMOOTH_MAX_DEG the same bar that makes chainEdges call it a corner at all) keeps
21
+ // the honest MITRE: the two blends run to the vertex and cross in the classic
22
+ // intersection seam every B-rep fillet shows; the seam is a real crease, its feature
23
+ // line is correct, and the top face keeps its sharp corner (decided 2026-08-17
24
+ // there is provably no band that hugs both walls around a salient corner without
25
+ // creasing, and every lift-off construction strands a corner column that reads as an
26
+ // artifact). A GENTLE salient corner (CORNER_ROUND_MIN_TURN..SMOOTH_MAX_DEG) is
27
+ // steered instead a small arc chain (radius ~1.05-1.25× the magnitude) replaces
28
+ // the mitre and a horn block shaves the corner column to band depth — because a
29
+ // shallow mitre's overlap wedge triangulates into junk lines while the steer's
30
+ // silhouette cost is sub-visible at these angles. A REFLEX corner in a common face
31
+ // plane gets the rolling-ball PIVOT (reflexPivotAt/reflexPivotTool): the ball swings
32
+ // about the corner's face-normal axis, touching the face and the vertical corner
33
+ // edge, and the face's blend boundary rounds into an arc of radius r about the
34
+ // vertex — without it the flush-ended neighbor tools leave a wedge of the original
35
+ // rim uncut and the face keeps a point AT the corner (the label-part "artifacts"
36
+ // bug). Three-or-more-chain vertices go to the spherical cornerPatches below (the
37
+ // orthogonal three-chain case).
33
38
  //
34
39
  // Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
35
40
  // like filleted-box.js does — an oversized radius self-intersects the cutters).
@@ -54,10 +59,10 @@ export class UnsupportedEdgeError extends Error {
54
59
  // circles; spending it on a blend of radius r tessellates a 0.5 mm fillet to 0.2 µm
55
60
  // sagitta at preview quality — and a text rim's hundred-tool boolean then carries ~4×
56
61
  // the triangles it needs (measured 12 s / 4 GB on a lettering part before this cap).
57
- // BLEND_SAG (2 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
62
+ // BLEND_SAG (1 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
58
63
  // the 0.02·r term keeps micro-blends sane, and the floor of 12 keeps every facet
59
64
  // angle (≤30°) under the viewer's 35° same-surface crease threshold.
60
- const BLEND_SAG = 2e-3; // mm — max chord sagitta of a blend cross-section
65
+ const BLEND_SAG = 1e-3; // mm — max chord sagitta of a blend cross-section
61
66
  function blendSegs(segs, r) {
62
67
  const s = Math.min(BLEND_SAG, 0.02 * r);
63
68
  return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
@@ -68,6 +73,7 @@ function blendSegs(segs, r) {
68
73
  const cornerArcSegs = (segs, R, magnitude) => blendSegs(segs, R + magnitude);
69
74
 
70
75
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
76
+ const pivotKey = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
71
77
  const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
72
78
  const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
73
79
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
@@ -260,11 +266,36 @@ export function chainEdges(edges) {
260
266
  // crossing) when sharp. Chains stitch only when they share an endpoint, the same face
261
267
  // plane, and the same convexity — the same-plane test is what keeps a top rim from ever
262
268
  // stitching to a bottom rim.
263
- function stitchPlanarChains(chains) {
269
+ function stitchPlanarChains(chains, { absorbLines = false } = {}) {
264
270
  const open = [], out = [];
265
271
  for (const c of chains) (c.kind === "planar" && !c.closed ? open : out).push(c);
266
- if (open.length < 2) return chains;
267
272
  const key = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
273
+ // Absorb LINE chains that continue an open planar chain — apply()'s re-stitch
274
+ // only, post-selection, so a dir/line selector still sees the line form. A
275
+ // straight run flanking a planarized arc otherwise keeps its prism tool, and
276
+ // the tangent junction between the two tools is exactly the overlap-seam
277
+ // category stitching exists to remove (measured: an exact rounded-rect rim
278
+ // with 0.5 mm corner arcs under a 0.3 mm fillet drew ~6 lines per junction).
279
+ // Absorbed into the sweep, the straight is geometrically identical — a prism
280
+ // IS the one-segment case of the sweep — and the junction becomes an interior
281
+ // tangent vertex the sweep miters.
282
+ if (absorbLines) {
283
+ for (let i = out.length - 1; i >= 0; i--) {
284
+ const c = out[i];
285
+ if (c.kind !== "line") continue;
286
+ const ends = [key(c.a), key(c.b)];
287
+ const partner = open.find((p) => p.convex === c.convex &&
288
+ [p.points[0], p.points[p.points.length - 1]].some((q) => ends.includes(key(q))));
289
+ if (!partner) continue;
290
+ const face = [c.n1, c.n2].find((n) => dot(n, partner.faceN) > FLANK_COS);
291
+ if (!face) continue;
292
+ const wall = face === c.n1 ? c.n2 : c.n1;
293
+ out.splice(i, 1);
294
+ open.push({ kind: "planar", points: [[...c.a], [...c.b]], closed: false,
295
+ convex: c.convex, w: face, faceN: face, wallNs: [wall] });
296
+ }
297
+ }
298
+ if (open.length < 2) return [...out, ...open];
268
299
  const compatible = (a, b) => a.convex === b.convex && dot(a.faceN, b.faceN) > FLANK_COS &&
269
300
  Math.abs(dot(a.points[0], a.faceN) - dot(b.points[0], a.faceN)) <= TOL;
270
301
  const rev = (c) => ({ ...c, points: [...c.points].reverse(), wallNs: [...c.wallNs].reverse() });
@@ -739,9 +770,107 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
739
770
  // watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
740
771
  // converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
741
772
  // the build. Returns an ARRAY of tools — one per stretch.
742
- function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
743
- const { points, closed, convex, faceN, wallNs } = chain;
744
- const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
773
+ // Collapse corner features smaller than the blend into virtual sharp corners.
774
+ // A convex corner round with radius under the fold threshold (~0.37·magnitude at
775
+ // the 1. reach) cannot be swept the band's top tangent contour pinches — and
776
+ // cannot be steered either (no setback room), so the fold guard breaks at EVERY
777
+ // facet joint and the band shatters into overshot micro-tools. But such a feature
778
+ // is geometrically a sharp corner blurred by less than the blend radius: replace
779
+ // each maximal run of two or more consecutive breaking joints whose connecting
780
+ // segments are shorter than the magnitude with the intersection of the flanking
781
+ // edge lines, and the ordinary corner machinery (mitre / gentle steer / reflex
782
+ // pivot) handles it downstream. The silhouette cost is bounded by the feature's
783
+ // own radius — sub-blend by construction. Runs that have no usable intersection
784
+ // (a ~180° cap, whose radius is stroke-scale and sweeps fine anyway) or whose
785
+ // intersection lands implausibly far are left untouched.
786
+ function collapseTightCorners(pts0, wallNs0, closed, magnitude) {
787
+ let pts = pts0, wallNs = wallNs0;
788
+ const m0 = pts.length, nSeg0 = closed ? m0 : m0 - 1;
789
+ if (nSeg0 < 3) return { pts, wallNs };
790
+ const reach = magnitude * 1.1, reachWall = 0.1 * magnitude;
791
+ const breaksAt = (pp, ww) => {
792
+ const m = pp.length, nSeg = closed ? m : m - 1;
793
+ const dir = [], sl = [];
794
+ for (let i = 0; i < nSeg; i++) {
795
+ const d = sub(pp[(i + 1) % m], pp[i]), l = len(d);
796
+ dir.push(scl(d, 1 / (l || 1))); sl.push(l);
797
+ }
798
+ const flags = new Array(m).fill(false);
799
+ for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
800
+ const iIn = (i - 1 + nSeg) % nSeg;
801
+ const c = clamp1(dot(dir[iIn], dir[i]));
802
+ const turn = Math.acos(c);
803
+ const bendIn = norm(sub(dir[i], dir[iIn]));
804
+ const reflexBend = dot(bendIn, ww[iIn]) + dot(bendIn, ww[i % nSeg]) > 0;
805
+ const r = reflexBend ? reachWall : reach;
806
+ flags[i] = c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(sl[iIn], sl[i]) ||
807
+ turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
808
+ }
809
+ return { flags, dir, sl };
810
+ };
811
+ let { flags, dir, sl } = breaksAt(pts, wallNs);
812
+ // rotate a closed chain so runs never wrap; a chain breaking everywhere is left alone
813
+ if (closed) {
814
+ const pivot = flags.findIndex((b) => !b);
815
+ if (pivot === -1) return { pts, wallNs };
816
+ if (pivot > 0) {
817
+ pts = [...pts.slice(pivot), ...pts.slice(0, pivot)];
818
+ wallNs = [...wallNs.slice(pivot), ...wallNs.slice(0, pivot)];
819
+ ({ flags, dir, sl } = breaksAt(pts, wallNs));
820
+ }
821
+ }
822
+ const m = pts.length, nSeg = closed ? m : m - 1;
823
+ const out = [], outWalls = [];
824
+ let i = 0;
825
+ const pushPoint = (p, wallIdx) => {
826
+ out.push(p);
827
+ if (wallIdx != null) outWalls.push(wallNs[wallIdx]);
828
+ };
829
+ while (i < m) {
830
+ // maximal run of breaking joints chained by sub-magnitude segments
831
+ let j = i;
832
+ while (j + 1 < m && flags[j] && flags[j + 1] && sl[j] < magnitude) j++;
833
+ if (flags[i] && j > i) {
834
+ const iIn = (i - 1 + nSeg) % nSeg;
835
+ const d1 = dir[iIn], d2 = dir[j % nSeg];
836
+ const p1 = pts[i], p2 = pts[j];
837
+ // intersect the flanking edge lines: p1 + a·d1 = p2 − b·d2 (in-plane)
838
+ const c12 = dot(d1, d2);
839
+ const denom = 1 - c12 * c12;
840
+ let V = null;
841
+ if (denom > 1e-6) {
842
+ const w0 = sub(p2, p1);
843
+ const a = (dot(w0, d1) - c12 * dot(w0, d2)) / denom;
844
+ const cand = add(p1, scl(d1, a));
845
+ let extent = 0;
846
+ for (let t = i; t < j; t++) extent += sl[t];
847
+ if (len(sub(cand, p1)) < 2 * magnitude + extent) V = cand;
848
+ }
849
+ if (V) {
850
+ pushPoint(V, j % nSeg); // V starts the outgoing segment: wall of seg j
851
+ i = j + 1;
852
+ continue;
853
+ }
854
+ }
855
+ pushPoint(pts[i], i < nSeg ? i : null);
856
+ i++;
857
+ }
858
+ if (out.length < (closed ? 3 : 2)) return { pts: pts0, wallNs: wallNs0 };
859
+ return { pts: out, wallNs: outWalls };
860
+ }
861
+
862
+ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
863
+ const { points, closed, convex, faceN } = chain;
864
+ let { wallNs } = chain;
865
+ let pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
866
+ // Corner features SMALLER than the blend collapse to a virtual sharp corner
867
+ // BEFORE any tool is built (see collapseTightCorners) — a run of fold-breaking
868
+ // joints on a sub-blend-radius corner round otherwise shatters into per-facet
869
+ // micro-tools whose disagreements notch the band (the non-bold glyph "divot":
870
+ // a raw letter terminal's ~0.1-0.25 mm corner rounds under a 0.3 mm fillet;
871
+ // bold outlines never hit this because the 0.4 mm round offset pads every
872
+ // convex radius past the fold threshold).
873
+ if (convex) ({ pts, wallNs } = collapseTightCorners(pts, wallNs, closed, magnitude));
745
874
  const m = pts.length;
746
875
  const at = (i) => pts[((i % m) + m) % m];
747
876
  const nSeg = closed ? m : m - 1;
@@ -765,13 +894,35 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
765
894
  // points; a reflex split gets the rolling-ball PIVOT (reflexPivotTool — without it
766
895
  // the flush stretch ends leave the corner wedge uncut and the face keeps its point);
767
896
  // too-tight salient splits keep the overshoot mitre.
768
- const reach = magnitude * 1.5;
897
+ // The reach bound is SIDE-aware, mirroring the sweep's own direction-aware
898
+ // check: rings converge only on the inside of a bend, and only the profile's
899
+ // reach TOWARD the bend center matters. The bend axis of a planar chain is
900
+ // the face normal, so that reach is the profile's IN-PLANE extent — the
901
+ // face-tangency inset, magnitude (+2% corner delta; the arc between the
902
+ // tangencies never reaches past them on the corner side) — NOT the rigid
903
+ // 1.5× diagonal bound, whose extra 50% is the AXIAL extent that a bend about
904
+ // the face normal cannot consume. The old bound split any salient outline
905
+ // arc under ~1.7·magnitude into per-facet micro-tools (a bold glyph's 0.4 mm
906
+ // offset-round corners under a 0.3 mm rim fillet became a patchwork of
907
+ // ~20 µm tools whose disagreements notched the band — the "divot" artifact);
908
+ // with the in-plane bound those arcs ride the one continuous sweep, and
909
+ // splitting starts only near the genuine pinch (R ≈ 1.2·magnitude, where
910
+ // the top tangent contour is closing toward a point). A reflex bend curves
911
+ // past the wall, where the profile reaches only the corner delta — the
912
+ // symmetric bound there shattered concave arcs of the same radii (the
913
+ // roundAll fast path's reflex arcs exactly).
914
+ const reach = magnitude * 1.1;
915
+ const reachWall = 0.1 * magnitude;
769
916
  const breaks = [];
770
917
  for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
771
918
  const iIn = (i - 1 + nSeg) % nSeg;
772
919
  const c = clamp1(dot(segDir[iIn], segDir[i]));
773
920
  const turn = Math.acos(c);
774
- const fold = c < -1 + 1e-6 || reach * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
921
+ // inside-of-bend direction change of travel; past the wall reflex bend
922
+ const bendIn = norm(sub(segDir[i], segDir[iIn]));
923
+ const reflexBend = dot(bendIn, wallNs[iIn]) + dot(bendIn, wallNs[i % nSeg]) > 0;
924
+ const r = reflexBend ? reachWall : reach;
925
+ const fold = c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
775
926
  const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
776
927
  if (fold || sharp) breaks.push(i);
777
928
  }
@@ -794,8 +945,12 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
794
945
  const nextB = closed
795
946
  ? breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)
796
947
  : (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
948
+ // a SHARP corner may steer only when another selected chain leaves this
949
+ // vertex out of the face plane (see apply()'s endTins note) — otherwise
950
+ // it keeps the honest mitre and cornerArcAt's upper gate refuses it
951
+ const allowSharp = !!(endTins?.get(pivotKey(at(i)))?.some((t) => Math.abs(dot(t, faceN)) > 0.7));
797
952
  const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
798
- wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
953
+ wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude, allowSharp);
799
954
  if (got) { cornerArcs.set(i, got); continue; }
800
955
  const piv = reflexPivotAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i], wallNs[iIn], wallNs[i]);
801
956
  if (piv) pivots.push(piv);
@@ -877,33 +1032,32 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
877
1032
  }
878
1033
 
879
1034
  // ---------------------------------------------------------------------------
880
- // Rounded corners. Where exactly TWO selected convex chains meet at a salient corner
881
- // in a common face plane (a letter corner, a polygon corner on a rim), the blend used
882
- // to continue straight through from both sides and the two tools crossed in a mitre.
883
- // That groove is a REAL crease 76-90° dihedral, measured — so the feature-line
884
- // overlay faithfully drew a polyline ACROSS the blend band at every such corner, and
885
- // OCCT's native fillet produces the same intersection-and-trim crease. There is no
886
- // groove-free construction that keeps the silhouette sharp: the two straight blends
887
- // must either intersect or the blend must steer around the corner. This steers: the
888
- // corner is replaced by a small circular ARC chain (radius ~1.05-1.25× the blend
889
- // magnitude, tangent to both neighbors at a setback), the neighbors are trimmed to the
890
- // tangent points, and the existing revolveTool sweeps the arc — exactly the shape a
891
- // rounded-rectangle rim already has, which renders line-free today. The cost, stated
892
- // plainly: within the band the silhouette rounds by about the blend radius at corners
893
- // sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
894
- // mitre under the viewer\'s 35° line threshold draws nothing).
1035
+ // Steered corners. Where exactly TWO selected convex chains meet at a salient corner
1036
+ // in a common face plane (a letter corner, a polygon corner on a rim), the two blends
1037
+ // cross in a mitre a REAL crease, 76-90° dihedral measured, the same
1038
+ // intersection-and-trim seam OCCT's native fillet produces. There is no groove-free
1039
+ // construction that keeps the silhouette sharp: a band tangent to both walls around a
1040
+ // salient corner must crease, and a band that lifts off the walls strands the corner
1041
+ // column (verified again 2026-08-17 the reflex pivot's torus does NOT mirror to
1042
+ // salient corners; the ball never touches a convex corner edge, and the mitre groove
1043
+ // of the neighbor cylinders survives beyond any such patch). So the mitre IS the
1044
+ // treatment for corners sharp enough to read as corners see cornerArcAt's upper
1045
+ // gate and the steer below exists only for the GENTLE band
1046
+ // (CORNER_ROUND_MIN_TURN..SMOOTH_MAX_DEG): the corner is replaced by a small
1047
+ // circular ARC chain (radius ~1.05-1.25× the blend magnitude, tangent to both
1048
+ // neighbors at a setback), the neighbors are trimmed to the tangent points, and the
1049
+ // existing revolveTool sweeps the arc. At these angles a mitre's long shallow
1050
+ // overlap wedge triangulates into >35° junk lines (measured: a 20.7° mitre still
1051
+ // drew, an ~8° one does not) while the steer's silhouette cost — a sagitta of
1052
+ // ρ·(1−cos(turn/2)), plus the horn's sub-visible shelf — stays microns deep, so the
1053
+ // trade runs the opposite way to a sharp corner's.
895
1054
  //
896
1055
  // REFLEX corners take the rolling-ball PIVOT instead (reflexPivotAt below): steering
897
1056
  // the band path around a reflex corner would ADD material, but the ball itself swings
898
1057
  // about the corner touching the face and the vertical corner edge — see the reflex
899
- // pivot section. A salient corner whose neighbors are too short to host the setback
900
- // (tight glyph features) falls back to the mitre — that fallback is never a failure.
901
- // Corners gentler than this keep their mitre: the two blends there differ by less than
902
- // the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
903
- // overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
904
- // a 20.7° mitre still drew (its long shallow overlap wedge triangulates into >35°
905
- // junk), an ~8° one does not. The silhouette cost of rounding a gentle corner is a
906
- // sagitta of ρ·(1−cos(turn/2)) — sub-micron at these angles — so the gate is safe low.
1058
+ // pivot section. A gentle salient corner whose neighbors are too short to host the
1059
+ // setback (tight glyph features) falls back to the mitre — that fallback is never a
1060
+ // failure.
907
1061
  const CORNER_ROUND_MIN_TURN = (8 * Math.PI) / 180;
908
1062
  const RHO_MIN = 1.05; // × magnitude — revolve floor: the profile reaches magnitude inward of the arc
909
1063
  const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over the floor for margin
@@ -912,10 +1066,24 @@ const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over
912
1066
  // wall1/wall2 are the sides\' outward wall normals at the vertex; len1/len2 bound the
913
1067
  // setback. Returns { arc, t } (a synthetic kind:"arc" chain for revolveTool, plus the
914
1068
  // setback to trim each side by) or null when the corner keeps its mitre.
915
- function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude) {
1069
+ function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude, allowSharp = false) {
916
1070
  const tIn = scl(tin1, -1), tOut = tin2; // travel: arrive along side 1, depart into 2
917
1071
  const turn = Math.acos(clamp1(dot(tIn, tOut)));
918
1072
  if (turn < CORNER_ROUND_MIN_TURN) return null;
1073
+ // A corner past the chain-smoothness bar READS as a corner and keeps the honest
1074
+ // mitre (decided 2026-08-17): the two blends run to the vertex and cross in the
1075
+ // classic intersection seam every B-rep fillet shows — the seam is a real crease,
1076
+ // its feature line is correct, and the top face keeps its sharp corner. The
1077
+ // arc-steer below is only for gentle corners, where the sub-visible horn shelf is
1078
+ // a fair price for killing the shallow-overlap junk lines a gentle mitre draws.
1079
+ // Steering SHARP corners bought a clean band at the cost of a rounded in-band
1080
+ // silhouette hovering over the sharp extrude corner with a flat shelf between —
1081
+ // a mismatch no CAD user expects (the label part's non-bold letter terminals).
1082
+ // `allowSharp` is the one exception: when the corner's vertical edge is being
1083
+ // blended too (roundAll, a selector-free fillet), the column below the band is
1084
+ // itself rounded at r — the steer approximates the ball's sphere corner there
1085
+ // and nothing is left to mismatch (planarTool derives it from apply()'s endTins).
1086
+ if (!allowSharp && turn > (SMOOTH_MAX_DEG * Math.PI) / 180) return null;
919
1087
  const turnS = dot(cross(tIn, tOut), f);
920
1088
  const matLeft = dot(wall1, cross(tIn, f)) > 0;
921
1089
  if ((turnS > 0) !== matLeft) return null; // reflex: the crease is real — keep the mitre
@@ -1246,12 +1414,28 @@ function roundSalientCorners(selected, magnitude) {
1246
1414
  // so the only new surface is the octant. Non-orthogonal corners keep the mitre
1247
1415
  // — the safe, documented default.
1248
1416
  function cornerPatches(k, selected, r, segs) {
1249
- const lines = selected.filter((ch) => ch.kind === "line" && ch.convex);
1250
1417
  const byVertex = new Map();
1251
- for (const ch of lines) {
1252
- for (const [pt, dirOut] of [[ch.a, ch.dir], [ch.b, scl(ch.dir, -1)]]) {
1253
- const key = pt.map((v) => Math.round(v * 1e4)).join(",");
1254
- (byVertex.get(key) ?? byVertex.set(key, []).get(key)).push({ pt, dirOut });
1418
+ const push = (pt, dirOut) => {
1419
+ const key = pt.map((v) => Math.round(v * 1e4)).join(",");
1420
+ (byVertex.get(key) ?? byVertex.set(key, []).get(key)).push({ pt, dirOut });
1421
+ };
1422
+ for (const ch of selected) {
1423
+ if (ch.convex !== true) continue;
1424
+ if (ch.kind === "line") {
1425
+ push(ch.a, ch.dir);
1426
+ push(ch.b, scl(ch.dir, -1));
1427
+ } else if (ch.kind === "planar" && !ch.closed) {
1428
+ // A planar chain END whose end segment runs straight for ≥ r qualifies
1429
+ // too: inside the corner cube the sweep is the same straight cylinder a
1430
+ // line chain's prism tool would cut, so the octant construction holds
1431
+ // unchanged. This is the roundAll fast path's mixed corner — a straight
1432
+ // rim edge and a vertical edge (line chains) meeting a planar rim chain
1433
+ // whose curvature lives far from the corner. Requiring the full r of
1434
+ // straight run keeps the cube inside the cylinder-only zone.
1435
+ const p = ch.points, n = p.length;
1436
+ const d0 = sub(p[1], p[0]), dN = sub(p[n - 2], p[n - 1]);
1437
+ if (len(d0) >= r) push(p[0], scl(d0, 1 / len(d0)));
1438
+ if (len(dN) >= r) push(p[n - 1], scl(dN, 1 / len(dN)));
1255
1439
  }
1256
1440
  }
1257
1441
  const patches = [];
@@ -1305,12 +1489,27 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1305
1489
  // Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
1306
1490
  // converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
1307
1491
  // these chains were planar, so their junctions are still open here.
1308
- const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
1492
+ const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch), { absorbLines: true });
1493
+ // Ends of selected chains, keyed by vertex — planarTool steers a SHARP break
1494
+ // corner only when another selected chain leaves that vertex out of the face
1495
+ // plane (a vertical edge being blended too, the roundAll/trihedral case where
1496
+ // the corner column below the band is itself rounded and the steer's shelf is
1497
+ // consumed by that blend). A sharp corner with nothing else selected there
1498
+ // keeps the honest mitre — see the steered-corners section.
1499
+ const endTins = new Map();
1500
+ for (const ch of planarized) {
1501
+ if (ch.closed || (ch.kind !== "line" && ch.kind !== "planar")) continue;
1502
+ for (const end of ["start", "end"]) {
1503
+ const info = chainEndInfo(ch, end);
1504
+ const kk = pivotKey(info.v);
1505
+ (endTins.get(kk) ?? endTins.set(kk, []).get(kk)).push(info.tin);
1506
+ }
1507
+ }
1309
1508
  const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1310
1509
  const pSegs = blendSegs(segs, magnitude);
1311
1510
  const toolsFor = (ch) =>
1312
1511
  ch.kind === "planar"
1313
- ? planarTool(k, ch, magnitude, mode, segs, pSegs)
1512
+ ? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins)
1314
1513
  : [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
1315
1514
  const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
1316
1515
  cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
@@ -20,6 +20,15 @@ export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, bo
20
20
  export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
21
21
  export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
22
22
  export const MIN_EDGE = 0.01; // mm — drop shorter segments (degenerate slivers, pole edges)
23
+ // mm — a feature-line's incident FACES must both be at least this wide (min height).
24
+ // Wider than MIN_EDGE deliberately: a boolean face-split near a tool crossing (a
25
+ // mitre corner, a pivot's angular overshoot) re-triangulates the split band quad
26
+ // against seam vertices that sit microns off its plane — long fan slivers 14-34µm
27
+ // wide whose normals tilt 40-56°, drawing a full-weight line down an otherwise
28
+ // perfect band (total surface relief under 15µm — sub-visible at any scale this
29
+ // kernel prints at). Real band facets at the sagitta-bounded density are ≥ ~70µm
30
+ // wide; boundary rings bypass the gate entirely.
31
+ export const MIN_FACE = 0.04;
23
32
 
24
33
  // Loft rings with at least this many sides read as an approximation of a smooth
25
34
  // surface (e.g. a 64-gon "circle"), not as 64 intentional facets.
@@ -112,12 +112,22 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
112
112
  rodrigues(N, axis, ang), rodrigues(B, axis, ang)));
113
113
  }
114
114
  } else { // sharp miter: one station in the bisecting plane
115
- if (maxReach * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
116
- throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
117
115
  const Nh = rodrigues(N, axis, theta / 2), Bh = rodrigues(B, axis, theta / 2);
118
116
  const mDir = rodrigues(tIn, axis, theta / 2); // ring-plane normal (average travel dir)
119
117
  const u = norm(cross(axis, mDir)); // in-plane bend direction (stretch axis)
120
118
  const cosh = Math.cos(theta / 2);
119
+ // Fold check, DIRECTION-aware: consecutive mitered rings converge only on
120
+ // the inside of the bend (u points toward the bend center; the far side
121
+ // diverges), so what must fit is the profile's stretched reach along +u —
122
+ // not its symmetric max-|vertex| bound, which refused tight concave rim
123
+ // sweeps whose inside-of-bend extent is only the blend profile's ~2%-of-
124
+ // magnitude beyond-wall margin (mesh-fillet's planar chains over a
125
+ // radius≈magnitude concave outline arc are exactly that case).
126
+ const uN = dot(u, Nh), uB = dot(u, Bh);
127
+ let reachIn = 0;
128
+ for (const [x, y] of profile2D) reachIn = Math.max(reachIn, x * uN + y * uB);
129
+ if ((reachIn / cosh) * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
130
+ throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
121
131
  stations.push(profile2D.map(([x, y]) => {
122
132
  const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
123
133
  return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
@@ -0,0 +1,252 @@
1
+ const FONT_URLS = {
2
+ pacifico: "https://raw.githubusercontent.com/google/fonts/main/ofl/pacifico/Pacifico-Regular.ttf",
3
+ dancing: "https://raw.githubusercontent.com/google/fonts/main/ofl/dancingscript/DancingScript%5Bwght%5D.ttf",
4
+ greatVibes: "https://raw.githubusercontent.com/google/fonts/main/ofl/greatvibes/GreatVibes-Regular.ttf",
5
+ };
6
+
7
+ const slantProfile = (k, profile, shear) => {
8
+ if (!shear) return profile;
9
+ const regions = profile.toRegions();
10
+ if (!Array.isArray(regions)) return profile;
11
+
12
+ let combined = null;
13
+ for (const region of regions) {
14
+ const outer = [];
15
+ for (const point of region.outer) {
16
+ outer.push([point[0] + shear * point[1], point[1]]);
17
+ }
18
+ const holes = [];
19
+ for (const hole of region.holes) {
20
+ const slantedHole = [];
21
+ for (const point of hole) {
22
+ slantedHole.push([point[0] + shear * point[1], point[1]]);
23
+ }
24
+ holes.push(slantedHole);
25
+ }
26
+ const slantedRegion = k.shape2d({ outer, holes });
27
+ combined = combined ? combined.union(slantedRegion) : slantedRegion;
28
+ }
29
+ return combined || profile;
30
+ };
31
+
32
+ const styleProfile = (k, profile, bold, italic) => {
33
+ let styled = slantProfile(k, profile, italic ? 0.2126 : 0);
34
+ if (bold) {
35
+ styled = styled.offset(0.4, { corners: "round", segs: 32 });
36
+ }
37
+ return styled;
38
+ };
39
+
40
+ const offsetGlyphContours = (k, profile, distance) => {
41
+ const options = { corners: "round", segs: 32 };
42
+ const regions = profile.regions();
43
+ if (!Array.isArray(regions)) return k.hull([profile]).offset(distance, options);
44
+ let combined = null;
45
+ for (const region of regions) {
46
+ const expanded = k.hull([region]).offset(distance, options);
47
+ combined = combined ? combined.union(expanded) : expanded;
48
+ }
49
+ return combined || k.hull([profile]).offset(distance, options);
50
+ };
51
+
52
+ const planarTopFillet = (solid, height, radius) => solid.fillet({
53
+ r: radius,
54
+ edges: { inPlane: "XY", at: height },
55
+ });
56
+
57
+ const filletGlyphRegions = (k, profile, height, radius) => {
58
+ const regions = profile.regions();
59
+ if (!Array.isArray(regions)) return profile.extrude({ h: height });
60
+ const rounded = [];
61
+ for (const region of regions) {
62
+ const glyph = region.extrude({ h: height });
63
+ rounded.push(planarTopFillet(glyph, height, radius));
64
+ }
65
+ if (rounded.length === 1) return rounded[0];
66
+ return k.union(rounded);
67
+ };
68
+
69
+ export default {
70
+ meta: {
71
+ title: "Editable Layered Label",
72
+ units: "mm",
73
+ background: 0x171a20,
74
+ },
75
+
76
+ parameters: [
77
+ {
78
+ id: "label",
79
+ title: "Label",
80
+ description: "Dimensions for the raised name and its larger lower backing layer.",
81
+ controls: [
82
+ {
83
+ key: "text",
84
+ type: "text",
85
+ label: "Label text",
86
+ description: "Text shown on the raised face; changes preview immediately.",
87
+ },
88
+ {
89
+ key: "fontStyle",
90
+ type: "select",
91
+ label: "Font",
92
+ options: [
93
+ { value: "roboto", label: "Roboto" },
94
+ { value: "pacifico", label: "Pacifico Script" },
95
+ { value: "dancing", label: "Dancing Script" },
96
+ { value: "greatVibes", label: "Great Vibes Script" },
97
+ ],
98
+ description: "Choose the bundled sans-serif or an elegant script font.",
99
+ },
100
+ {
101
+ key: "bold",
102
+ type: "checkbox",
103
+ label: "Bold",
104
+ description: "Adds 0.4 mm of weight around every letter stroke.",
105
+ },
106
+ {
107
+ key: "italic",
108
+ type: "checkbox",
109
+ label: "Italic",
110
+ description: "Slants the lettering 12 degrees to the right.",
111
+ },
112
+ {
113
+ key: "textSize",
114
+ label: "Letter height",
115
+ unit: "mm",
116
+ min: 10,
117
+ max: 60,
118
+ step: 1,
119
+ description: "Nominal height of the raised lettering.",
120
+ },
121
+ {
122
+ key: "backingStyle",
123
+ type: "select",
124
+ label: "Backing edge style",
125
+ options: [
126
+ { value: "fillet", label: "Top fillet (planar)" },
127
+ { value: "roundAll", label: "Round all edges" },
128
+ ],
129
+ description: "Top fillet rounds only the top rim; Round all rounds every edge of the backing (roundAll test).",
130
+ },
131
+ {
132
+ key: "border",
133
+ label: "Backing offset",
134
+ unit: "mm",
135
+ min: 2,
136
+ max: 20,
137
+ step: 0.5,
138
+ recommended: [4, 12],
139
+ description: "Distance the lower layer extends beyond the lettering.",
140
+ },
141
+ {
142
+ key: "backingThickness",
143
+ label: "Lower layer thickness",
144
+ unit: "mm",
145
+ min: 1.2,
146
+ max: 8,
147
+ step: 0.1,
148
+ recommended: [2, 4],
149
+ description: "Printable thickness of the larger backing layer.",
150
+ },
151
+ {
152
+ key: "letterThickness",
153
+ label: "Raised letter thickness",
154
+ unit: "mm",
155
+ min: 0.8,
156
+ max: 6,
157
+ step: 0.1,
158
+ recommended: [1.2, 3],
159
+ description: "Height of the raised lettering above the lower layer.",
160
+ },
161
+ ],
162
+ },
163
+ ],
164
+
165
+ defaults: {
166
+ text: "Scott",
167
+ fontStyle: "roboto",
168
+ bold: 0,
169
+ italic: 0,
170
+ backingStyle: "roundAll",
171
+ textSize: 31,
172
+ border: 5,
173
+ backingThickness: 2.5,
174
+ letterThickness: 2,
175
+ },
176
+
177
+ derive: (p) => ({
178
+ textValue: String(p.text ?? "").trim() || "Scott",
179
+ fontUrl: FONT_URLS[p.fontStyle],
180
+ topZ: p.backingThickness,
181
+ totalHeight: p.backingThickness + p.letterThickness,
182
+ }),
183
+
184
+ parts: {
185
+ backing: {
186
+ label: "Lower outer layer",
187
+ views: ["backing", "assembly"],
188
+ display: { color: 0x1e88e5 },
189
+ build: (k, p, d) => {
190
+ const rawTextProfile = k.text2d(d.textValue, {
191
+ size: p.textSize,
192
+ ...(d.fontUrl ? { font: d.fontUrl } : {}),
193
+ align: "center",
194
+ valign: "middle",
195
+ });
196
+ const textProfile = slantProfile(k, rawTextProfile, p.italic ? 0.2126 : 0);
197
+ const backingProfile = offsetGlyphContours(
198
+ k,
199
+ textProfile,
200
+ p.border + (p.bold ? 0.4 : 0),
201
+ );
202
+ const topRound = Math.min(0.3, p.backingThickness / 2 - 0.1);
203
+ const solid = p.backingStyle === "roundAll"
204
+ ? backingProfile.extrude({ h: p.backingThickness }).roundAll(topRound)
205
+ : filletGlyphRegions(k, backingProfile, p.backingThickness, topRound);
206
+ return solid.label("Letter contour backing");
207
+ },
208
+ },
209
+ lettering: {
210
+ label: "Editable lettering",
211
+ views: ["lettering", "assembly"],
212
+ display: { color: 0xfdd835 },
213
+ build: (k, p, d) => {
214
+ const rawLetteringProfile = k.text2d(d.textValue, {
215
+ size: p.textSize,
216
+ ...(d.fontUrl ? { font: d.fontUrl } : {}),
217
+ align: "center",
218
+ valign: "middle",
219
+ });
220
+ const letteringProfile = styleProfile(k, rawLetteringProfile, p.bold, p.italic);
221
+ const topRound = Math.min(0.3, p.letterThickness / 2 - 0.1, p.textSize * 0.015);
222
+ return filletGlyphRegions(k, letteringProfile, p.letterThickness, topRound)
223
+ .at([0, 0, d.topZ])
224
+ .label("Raised text");
225
+ },
226
+ },
227
+ },
228
+
229
+ views: {
230
+ backing: { label: "Lower layer" },
231
+ lettering: { label: "Lettering" },
232
+ assembly: { label: "Layered label" },
233
+ },
234
+
235
+ verify: {
236
+ process: "fdm-pla",
237
+ expect: {
238
+ backing: {
239
+ watertight: true,
240
+ bbox: "<=[*,*,8]",
241
+ },
242
+ lettering: {
243
+ watertight: true,
244
+ bbox: "<=[*,*,14]",
245
+ },
246
+ _view: {
247
+ overlaps: 0,
248
+ contacts: [["backing", "lettering"]],
249
+ },
250
+ },
251
+ },
252
+ };
@@ -0,0 +1,3 @@
1
+ import part from "./parts/scott-label.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);