partforge 0.122.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ // Analytic blend-surface descriptors — the exact normal field of a mesh fillet band.
2
+ //
3
+ // A mesh fillet (mesh-fillet.js) is a boolean against tool solids whose curved wall
4
+ // IS the rolling-ball surface. Facet-averaged vertex normals on that wall are only
5
+ // approximately right: one-sided at the band's tangent boundary (half a facet off
6
+ // the face it is tangent to — ~1-2° at preview density), biased by the boolean's
7
+ // uneven fans, and on a coarse corner sphere several degrees off the edge cylinders
8
+ // it meets. Every blend surface is a canal surface — the envelope of a ball rolling
9
+ // along a SPINE — so its exact normal at any point is the direction from the nearest
10
+ // spine point, and the spine is known when the tool is built. A descriptor records
11
+ // that spine:
12
+ //
13
+ // { kind: "line", p, d } straight edge → cylinder (prism tools)
14
+ // { kind: "circle", c, a, R } arc edge → torus (revolve / pivot tools); R may
15
+ // be 0 (a sphere about c)
16
+ // { kind: "point", c } sphere (corner patches)
17
+ // { kind: "path", pts, closed, dirs, f, kf, kd }
18
+ // planar polyline edge (planar sweep tools): pts is
19
+ // the swept path, f its face normal, dirs[i] the
20
+ // in-plane wall-side direction at vertex i, and the
21
+ // ball centre sits at kf·f + kd·dir off the edge
22
+ //
23
+ // A path's polyline usually STANDS FOR a smooth curve (a circle's rim, an outline's
24
+ // rounded corner): its per-segment cylinders would shade faceted around the curve,
25
+ // while the facet average this replaces blurred those joints smooth. So the path
26
+ // normal is Phong-style: the in-plane direction is interpolated between the vertex
27
+ // directions (each the bisector of its two segments), giving the normal of the
28
+ // smooth canal surface the polyline samples — exact on a straight stretch, and on
29
+ // a sampled curve exact at the samples and continuous between them.
30
+ //
31
+ // The normal is sign-free here (a spine direction); the caller orients it against
32
+ // the facet, which also makes one descriptor serve cutters and fillers alike.
33
+ //
34
+ // Descriptors are registered per Manifold originalID in the tool's ORIGINAL frame
35
+ // (the inverse of the run's runTransform at registration), and evaluated per output
36
+ // run through that run's transform — so they follow every later translate, rotate,
37
+ // boolean and re-stamp that carries runTransform along (Manifold does, verified).
38
+ // Pure module: no DOM, no node:, no three.
39
+
40
+ const EPS = 1e-12;
41
+
42
+ // --- 3x4 column-major affine helpers (Manifold's runTransform layout) -------------
43
+ export function affineAt(rt, run) {
44
+ const o = run * 12;
45
+ return [rt[o], rt[o + 1], rt[o + 2], rt[o + 3], rt[o + 4], rt[o + 5],
46
+ rt[o + 6], rt[o + 7], rt[o + 8], rt[o + 9], rt[o + 10], rt[o + 11]];
47
+ }
48
+ export const IDENTITY = Object.freeze([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
49
+ const applyPt = (M, p) => [
50
+ M[0] * p[0] + M[3] * p[1] + M[6] * p[2] + M[9],
51
+ M[1] * p[0] + M[4] * p[1] + M[7] * p[2] + M[10],
52
+ M[2] * p[0] + M[5] * p[1] + M[8] * p[2] + M[11],
53
+ ];
54
+ const applyDir = (M, d) => [
55
+ M[0] * d[0] + M[3] * d[1] + M[6] * d[2],
56
+ M[1] * d[0] + M[4] * d[1] + M[7] * d[2],
57
+ M[2] * d[0] + M[5] * d[1] + M[8] * d[2],
58
+ ];
59
+ // Inverse of an affine; null when singular.
60
+ export function invertAffine(M) {
61
+ const [a, b, c, d, e, f, g, h, i] = M; // columns (a,b,c) (d,e,f) (g,h,i)
62
+ const A = e * i - f * h, B = f * g - d * i, C = d * h - e * g;
63
+ const det = a * A + b * B + c * C;
64
+ if (!(Math.abs(det) > EPS)) return null;
65
+ const s = 1 / det;
66
+ // rows of the inverse = cofactor columns; stored column-major
67
+ const L = [
68
+ A * s, (c * h - b * i) * s, (b * f - c * e) * s,
69
+ B * s, (a * i - c * g) * s, (c * d - a * f) * s,
70
+ C * s, (b * g - a * h) * s, (a * e - b * d) * s,
71
+ ];
72
+ const t = applyDir([...L, 0, 0, 0], [M[9], M[10], M[11]]);
73
+ return [...L, -t[0], -t[1], -t[2]];
74
+ }
75
+
76
+ const norm = (v) => {
77
+ const l = Math.hypot(v[0], v[1], v[2]);
78
+ return l > EPS ? [v[0] / l, v[1] / l, v[2] / l] : null;
79
+ };
80
+
81
+ // Map a descriptor through an affine (used with the INVERSE run transform at
82
+ // registration: world → original frame). Exact for rigid maps, which is what the
83
+ // tools are posed with; radii scale by the map's mean axis stretch.
84
+ export function mapSurface(desc, M) {
85
+ switch (desc.kind) {
86
+ case "line": return { kind: "line", p: applyPt(M, desc.p), d: norm(applyDir(M, desc.d)) ?? desc.d };
87
+ case "point": return { kind: "point", c: applyPt(M, desc.c) };
88
+ case "circle": {
89
+ const s = Math.cbrt(Math.abs(
90
+ M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
91
+ return { kind: "circle", c: applyPt(M, desc.c), a: norm(applyDir(M, desc.a)) ?? desc.a, R: desc.R * s };
92
+ }
93
+ case "path": {
94
+ const s = Math.cbrt(Math.abs(
95
+ M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
96
+ return { kind: "path", closed: desc.closed, pts: desc.pts.map((p) => applyPt(M, p)),
97
+ dirs: desc.dirs.map((d) => norm(applyDir(M, d)) ?? d), f: norm(applyDir(M, desc.f)) ?? desc.f,
98
+ kf: desc.kf * s, kd: desc.kd * s };
99
+ }
100
+ default: throw new Error(`blend surface: unknown kind ${desc.kind}`);
101
+ }
102
+ }
103
+
104
+ // Nearest spine point to x, in the descriptor's own frame.
105
+ function spinePoint(desc, x) {
106
+ switch (desc.kind) {
107
+ case "point": return desc.c;
108
+ case "line": {
109
+ const { p, d } = desc;
110
+ const t = (x[0] - p[0]) * d[0] + (x[1] - p[1]) * d[1] + (x[2] - p[2]) * d[2];
111
+ return [p[0] + t * d[0], p[1] + t * d[1], p[2] + t * d[2]];
112
+ }
113
+ case "circle": {
114
+ const { c, a, R } = desc;
115
+ const q = [x[0] - c[0], x[1] - c[1], x[2] - c[2]];
116
+ const t = q[0] * a[0] + q[1] * a[1] + q[2] * a[2];
117
+ const rad = norm([q[0] - t * a[0], q[1] - t * a[1], q[2] - t * a[2]]);
118
+ if (!rad) return R > 0 ? null : c; // on the axis: the normal is undefined unless the spine is a point
119
+ return [c[0] + R * rad[0], c[1] + R * rad[1], c[2] + R * rad[2]];
120
+ }
121
+ default: return null;
122
+ }
123
+ }
124
+
125
+ // Path normal (see the header): nearest edge segment i at parameter t, the in-plane
126
+ // direction lerped between its vertex directions, the ball centre offset along it,
127
+ // and the normal rebuilt from x's height above the centre (along f) and its signed
128
+ // in-plane distance across the segment.
129
+ function pathNormal(desc, x) {
130
+ const { pts, dirs, f, kf, kd } = desc;
131
+ const n = pts.length;
132
+ const { bi, bt } = nearestSegment(desc, x);
133
+ if (bi < 0) return null;
134
+ const p = pts[bi], q = pts[(bi + 1) % n], d0 = dirs[bi], d1 = dirs[(bi + 1) % n];
135
+ const dir = norm([d0[0] + (d1[0] - d0[0]) * bt, d0[1] + (d1[1] - d0[1]) * bt, d0[2] + (d1[2] - d0[2]) * bt]);
136
+ if (!dir) return null;
137
+ const e = [p[0] + (q[0] - p[0]) * bt, p[1] + (q[1] - p[1]) * bt, p[2] + (q[2] - p[2]) * bt];
138
+ const c = [e[0] + kf * f[0] + kd * dir[0], e[1] + kf * f[1] + kd * dir[1], e[2] + kf * f[2] + kd * dir[2]];
139
+ const v = [x[0] - c[0], x[1] - c[1], x[2] - c[2]];
140
+ const h = v[0] * f[0] + v[1] * f[1] + v[2] * f[2];
141
+ // signed in-plane distance across the SEGMENT (the local surface's own measure)
142
+ const segN = norm([q[0] - p[0], q[1] - p[1], q[2] - p[2]]);
143
+ const perp = segN ? norm([f[1] * segN[2] - f[2] * segN[1], f[2] * segN[0] - f[0] * segN[2], f[0] * segN[1] - f[1] * segN[0]]) : null;
144
+ if (!perp) return null;
145
+ const sgn = perp[0] * dir[0] + perp[1] * dir[1] + perp[2] * dir[2] >= 0 ? 1 : -1;
146
+ const w = sgn * (v[0] * perp[0] + v[1] * perp[1] + v[2] * perp[2]);
147
+ return norm([h * f[0] + w * dir[0], h * f[1] + w * dir[1], h * f[2] + w * dir[2]]);
148
+ }
149
+
150
+ // Nearest path segment to x (index + clamped parameter). A text rim's path runs to
151
+ // hundreds of segments and every band vertex asks, so the segments are bucketed
152
+ // once per descriptor into a uniform grid (lazily, off the enumerable fields) and
153
+ // the query widens ring by ring only until no unvisited cell can hold a closer one.
154
+ // squared distance from x to segment pq; the clamped parameter lands in segT
155
+ let segT = 0;
156
+ function segDist2(p, q, x) {
157
+ const dx = q[0] - p[0], dy = q[1] - p[1], dz = q[2] - p[2];
158
+ const dd = dx * dx + dy * dy + dz * dz;
159
+ let t = dd > EPS ? ((x[0] - p[0]) * dx + (x[1] - p[1]) * dy + (x[2] - p[2]) * dz) / dd : 0;
160
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
161
+ segT = t;
162
+ return (x[0] - p[0] - t * dx) ** 2 + (x[1] - p[1] - t * dy) ** 2 + (x[2] - p[2] - t * dz) ** 2;
163
+ }
164
+ const GRID_MIN_SEGS = 16;
165
+ function segmentGrid(desc) {
166
+ let g = desc[GRID];
167
+ if (g) return g;
168
+ const { pts, closed } = desc, n = pts.length, nSeg = closed ? n : n - 1;
169
+ let total = 0;
170
+ for (let i = 0; i < nSeg; i++) { const p = pts[i], q = pts[(i + 1) % n]; total += Math.hypot(q[0] - p[0], q[1] - p[1], q[2] - p[2]); }
171
+ const h = Math.max(total / Math.max(1, nSeg), Math.abs(desc.kf) + Math.abs(desc.kd), 1e-6);
172
+ const cells = new Map();
173
+ // numeric hashed cell keys: a collision only adds candidates, each distance-checked
174
+ const key = (a, b, c) => (Math.imul(a, 73856093) ^ Math.imul(b, 19349663) ^ Math.imul(c, 83492791)) | 0;
175
+ for (let i = 0; i < nSeg; i++) {
176
+ const p = pts[i], q = pts[(i + 1) % n];
177
+ const lo = [0, 1, 2].map((a) => Math.floor(Math.min(p[a], q[a]) / h));
178
+ const hi = [0, 1, 2].map((a) => Math.floor(Math.max(p[a], q[a]) / h));
179
+ for (let a = lo[0]; a <= hi[0]; a++) for (let b = lo[1]; b <= hi[1]; b++) for (let c = lo[2]; c <= hi[2]; c++) {
180
+ const k = key(a, b, c);
181
+ const list = cells.get(k);
182
+ if (list) list.push(i); else cells.set(k, [i]);
183
+ }
184
+ }
185
+ g = { h, cells, key, nSeg, stamp: new Uint32Array(nSeg), epoch: 0 };
186
+ Object.defineProperty(desc, GRID, { value: g, enumerable: false });
187
+ return g;
188
+ }
189
+ const GRID = Symbol("segmentGrid");
190
+ function nearestSegment(desc, x) {
191
+ const { pts, closed } = desc, n = pts.length, nSeg = closed ? n : n - 1;
192
+ let bi = -1, bt = 0, bestD = Infinity;
193
+ if (nSeg < GRID_MIN_SEGS) {
194
+ for (let i = 0; i < nSeg; i++) {
195
+ const D = segDist2(pts[i], pts[(i + 1) % n], x);
196
+ if (D < bestD) { bestD = D; bi = i; bt = segT; }
197
+ }
198
+ return { bi, bt };
199
+ }
200
+ const g = segmentGrid(desc), { h, cells, key, stamp } = g;
201
+ const epoch = ++g.epoch;
202
+ const c0 = Math.floor(x[0] / h), c1 = Math.floor(x[1] / h), c2 = Math.floor(x[2] / h);
203
+ for (let ring = 0; ; ring++) {
204
+ // every cell at Chebyshev distance `ring`; its points lie ≥ (ring-1)·h away
205
+ if (bi >= 0 && (ring - 1) * h > Math.sqrt(bestD)) break;
206
+ let any = false;
207
+ for (let a = -ring; a <= ring; a++) for (let b = -ring; b <= ring; b++) for (let c = -ring; c <= ring; c++) {
208
+ if (Math.max(Math.abs(a), Math.abs(b), Math.abs(c)) !== ring) continue;
209
+ const list = cells.get(key(c0 + a, c1 + b, c2 + c));
210
+ if (!list) continue;
211
+ any = true;
212
+ for (const i of list) {
213
+ if (stamp[i] === epoch) continue;
214
+ stamp[i] = epoch;
215
+ const D = segDist2(pts[i], pts[(i + 1) % n], x);
216
+ if (D < bestD) { bestD = D; bi = i; bt = segT; }
217
+ }
218
+ }
219
+ // h ≥ the ball-centre offset, so a band point finds its segment within a ring or
220
+ // two; a point this far from the path is not on the band — scan and be done
221
+ if (!any && bi < 0 && ring >= 3) {
222
+ for (let i = 0; i < nSeg; i++) {
223
+ const D = segDist2(pts[i], pts[(i + 1) % n], x);
224
+ if (D < bestD) { bestD = D; bi = i; bt = segT; }
225
+ }
226
+ break;
227
+ }
228
+ }
229
+ return { bi, bt };
230
+ }
231
+
232
+ // Unit spine→point direction at x in the descriptor's frame, or null where undefined.
233
+ export function surfaceNormal(desc, x) {
234
+ if (desc.kind === "path") return pathNormal(desc, x);
235
+ const s = spinePoint(desc, x);
236
+ return s ? norm([x[0] - s[0], x[1] - s[1], x[2] - s[2]]) : null;
237
+ }
238
+
239
+ // Per-run evaluator for creased-normals: world-frame analytic normal at world point
240
+ // x for a run whose original-frame descriptor is `desc` and whose runTransform is M.
241
+ // Normals map by the inverse-transpose of M's linear part, so this stays exact under
242
+ // any affine a part applies after the fillet (mirrors, non-uniform scales).
243
+ export function runEvaluator(desc, M) {
244
+ const inv = invertAffine(M);
245
+ if (!inv) return null;
246
+ return (x) => {
247
+ const n0 = surfaceNormal(desc, applyPt(inv, x));
248
+ if (!n0) return null;
249
+ // (M^-1)^T n0: the inverse's COLUMNS dotted with n0
250
+ return norm([
251
+ inv[0] * n0[0] + inv[1] * n0[1] + inv[2] * n0[2],
252
+ inv[3] * n0[0] + inv[4] * n0[1] + inv[5] * n0[2],
253
+ inv[6] * n0[0] + inv[7] * n0[1] + inv[8] * n0[2],
254
+ ]);
255
+ };
256
+ }
@@ -8,12 +8,30 @@
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
+ //
12
+ // Analytic blend normals: a surface id with a registered blend-surface descriptor
13
+ // (`surfaces`, blend-surfaces.js — mesh fillet bands) shades with the EXACT normal of
14
+ // its rolling-ball surface instead of a facet average. Wherever such a surface takes
15
+ // part in a vertex's smooth group, the group's normal is the average of the analytic
16
+ // normals alone: at a band's tangent boundary that is the neighbouring face's own
17
+ // normal (the surfaces meet with zero angle), so the face side of the seam, the band
18
+ // side, and any band vertex riding a face edge as a T-junction all agree.
11
19
  import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, MIN_FACE, cosDeg } from "./shading-policy.js";
20
+ import { affineAt, IDENTITY, runEvaluator } from "./blend-surfaces.js";
12
21
 
13
22
  const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
14
23
  const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
24
+ // A descriptor's normal is trusted on a triangle only while it stays within this of
25
+ // the facet: a blend facet spans at most 30° of arc (blendSegs' floor of 12), so its
26
+ // normal is ≤15° from any of its vertices' — anything further is a tool face that is
27
+ // NOT the blend wall (a filler's flush end cap, a cutter's closing wall).
28
+ const ANALYTIC_COS = cosDeg(25);
29
+ // Shading-adjacency weld radius (mm): far below anything visible, above the boolean's
30
+ // nanometre seam splits (see the weld below).
31
+ const SHADE_WELD = 5e-5;
32
+ const SHADE_CELL = 1e-3; // its hash-grid cell
15
33
 
16
- export function creasedNormals(g, { policies = null, featureLabels = null } = {}) {
34
+ export function creasedNormals(g, { policies = null, featureLabels = null, surfaces = null } = {}) {
17
35
  const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
18
36
  const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
19
37
 
@@ -38,6 +56,43 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
38
56
  for (let r = 0; r < roid.length; r++)
39
57
  for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
40
58
 
59
+ // per-run analytic evaluators (only runs whose surface id has a descriptor), and
60
+ // each triangle's run — evaluated lazily, memoized per (run, vertex)
61
+ let triRun = null, runEval = null;
62
+ if (surfaces?.size) {
63
+ const rt = g.runTransform;
64
+ runEval = new Array(roid.length).fill(null);
65
+ let any = false;
66
+ for (let r = 0; r < roid.length; r++) {
67
+ const desc = surfaces.get(roid[r]);
68
+ if (!desc) continue;
69
+ runEval[r] = runEvaluator(desc, rt && rt.length >= (r + 1) * 12 ? affineAt(rt, r) : IDENTITY);
70
+ any ||= !!runEval[r];
71
+ }
72
+ if (any) {
73
+ triRun = new Uint32Array(nTri);
74
+ for (let r = 0; r < roid.length; r++)
75
+ for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triRun[t] = r;
76
+ } else runEval = null;
77
+ }
78
+ const analyticMemo = new Map();
79
+ // analytic normal of triangle t at vertex v, oriented with the facet — or null
80
+ const analyticAt = (t, v) => {
81
+ const r = triRun[t], ev = runEval[r];
82
+ if (!ev) return null;
83
+ const key = r * nVert + v;
84
+ let n = analyticMemo.get(key);
85
+ if (n === undefined) {
86
+ const o = v * np;
87
+ n = ev([vp[o], vp[o + 1], vp[o + 2]]);
88
+ analyticMemo.set(key, n);
89
+ }
90
+ if (!n) return null;
91
+ const d = n[0] * fn[t * 3] + n[1] * fn[t * 3 + 1] + n[2] * fn[t * 3 + 2];
92
+ if (Math.abs(d) < ANALYTIC_COS) return null;
93
+ return d < 0 ? [-n[0], -n[1], -n[2]] : n;
94
+ };
95
+
41
96
  // per-triangle face normals, plus each triangle's minimum height (2·area /
42
97
  // longest edge) — the "thinness" the feature-edge pass gates on below
43
98
  const fn = new Float32Array(nTri * 3);
@@ -69,14 +124,40 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
69
124
  // pass below deliberately keeps `remap` (Manifold's own topology): welding
70
125
  // its edge keys would make pairing at collapsed seams order-dependent and
71
126
  // could pair a boundary ring's edges away.
127
+ //
128
+ // The join is within SHADE_WELD, not float-exact: a seam where a fillet band meets
129
+ // a curved flank comes out of the boolean as two or three vertex columns a few
130
+ // NANOmetres apart (measured 0.5-4 nm on a cylinder rim fillet), so an exact key
131
+ // left the flank's rim vertices shading on their own facet while the band beside
132
+ // them shaded smooth — a ~1.5° lighting step along every rim, which mirror-like
133
+ // materials show as a seam.
72
134
  const weld = Uint32Array.from(remap);
73
135
  {
74
- const byPos = new Map();
136
+ // cells SHADE_CELL wide (≫ SHADE_WELD), so a vertex probes a neighbor cell only
137
+ // on the axes where it sits within SHADE_WELD of the cell wall — one lookup for
138
+ // almost every vertex. Numeric hashed keys; a collision only adds candidates,
139
+ // and every candidate is distance-checked.
140
+ const inv = 1 / SHADE_CELL, edge = SHADE_WELD / SHADE_CELL, tol2 = SHADE_WELD * SHADE_WELD;
141
+ const cells = new Map();
142
+ const hash = (x, y, z) => (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) | 0;
143
+ const offs = (f) => (f < edge ? [0, -1] : f > 1 - edge ? [0, 1] : [0]);
75
144
  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;
145
+ const o = i * np, x = vp[o], y = vp[o + 1], z = vp[o + 2];
146
+ const fx = x * inv, fy = y * inv, fz = z * inv;
147
+ const cx = Math.floor(fx), cy = Math.floor(fy), cz = Math.floor(fz);
148
+ let hit = -1;
149
+ search: for (const dx of offs(fx - cx)) for (const dy of offs(fy - cy)) for (const dz of offs(fz - cz)) {
150
+ const reps = cells.get(hash(cx + dx, cy + dy, cz + dz));
151
+ if (!reps) continue;
152
+ for (const j of reps) {
153
+ const q = j * np;
154
+ if ((vp[q] - x) ** 2 + (vp[q + 1] - y) ** 2 + (vp[q + 2] - z) ** 2 <= tol2) { hit = j; break search; }
155
+ }
156
+ }
157
+ if (hit >= 0) { weld[i] = weld[hit]; continue; }
158
+ const key = hash(cx, cy, cz);
159
+ const reps = cells.get(key);
160
+ if (reps) reps.push(i); else cells.set(key, [i]);
80
161
  }
81
162
  }
82
163
 
@@ -89,14 +170,42 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
89
170
  if (arr) arr.push(t); else incident.set(cv, [t]);
90
171
  }
91
172
 
173
+ // Micro-ledges. Where two independently tessellated blend surfaces meet — a
174
+ // corner sphere octant against its edge cylinders, buried a hair on purpose (see
175
+ // mesh-fillet's cornerPatches) — the boolean leaves a step a few µm tall whose
176
+ // triangles face 90° off the band. Sub-visible as geometry, but shaded with their
177
+ // own facet they draw a bright/dark hairline around every fillet corner on a
178
+ // mirror material. A triangle thinner than MIN_FACE cannot show a crease of its
179
+ // own (the line pass drops it for the same reason), so one that touches a blend
180
+ // takes the band's analytic normal at each corner, crease and surface filters
181
+ // notwithstanding.
182
+ const ledgeNormal = (v) => {
183
+ let ax = 0, ay = 0, az = 0, any = false;
184
+ for (const t2 of incident.get(weld[v])) {
185
+ if (thin[t2] < MIN_FACE) continue;
186
+ const an = analyticAt(t2, weld[v]);
187
+ if (an) { ax += an[0]; ay += an[1]; az += an[2]; any = true; }
188
+ }
189
+ const L = Math.hypot(ax, ay, az);
190
+ return any && L > 1e-9 ? [ax / L, ay / L, az / L] : null;
191
+ };
192
+
92
193
  const positions = new Float32Array(nTri * 9);
93
194
  const normals = new Float32Array(nTri * 9);
94
195
  for (let t = 0; t < nTri; t++) {
95
196
  const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2], oid = triOID[t];
96
197
  const sharpCos = cosFor(oid); // per-surface crease threshold
198
+ const ledge = runEval && thin[t] < MIN_FACE;
97
199
  for (let k = 0; k < 3; k++) {
98
200
  const v = tris[t * 3 + k];
99
- let nx = 0, ny = 0, nz = 0;
201
+ const ln = ledge ? ledgeNormal(v) : null;
202
+ if (ln) {
203
+ const o = (t * 3 + k) * 3, vv = v * np;
204
+ positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
205
+ normals[o] = ln[0]; normals[o + 1] = ln[1]; normals[o + 2] = ln[2];
206
+ continue;
207
+ }
208
+ let nx = 0, ny = 0, nz = 0, ax = 0, ay = 0, az = 0, analytic = false;
100
209
  for (const t2 of incident.get(weld[v])) {
101
210
  // different cut surface → hard, EXCEPT when a blend surface (boundaryLines)
102
211
  // is involved on either side. Blend↔blend: one band is many tool surfaces
@@ -112,7 +221,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
112
221
  !(polFor(triOID[t2]).boundaryLines || polFor(oid).boundaryLines)) continue;
113
222
  if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
114
223
  nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
224
+ if (runEval) {
225
+ const an = analyticAt(t2, weld[v]);
226
+ if (an) { ax += an[0]; ay += an[1]; az += an[2]; analytic = true; }
227
+ }
115
228
  }
229
+ if (analytic && Math.hypot(ax, ay, az) > 1e-9) { nx = ax; ny = ay; nz = az; }
116
230
  const L = Math.hypot(nx, ny, nz) || 1;
117
231
  const o = (t * 3 + k) * 3, vv = v * np;
118
232
  positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
@@ -18,6 +18,7 @@ import { meshToStl } from "./mesh-stl.js";
18
18
  import { creasedNormals } from "./creased-normals.js";
19
19
  import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
20
20
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
21
+ import { affineAt, invertAffine, mapSurface } from "./blend-surfaces.js";
21
22
  import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
22
23
  import { SEGS, circleSegs, doubleCurvatureSegs } from "./circle-segs.js";
23
24
  import { checkBooleanResult } from "./boolean-gate.js";
@@ -137,6 +138,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
137
138
  };
138
139
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
139
140
  const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
141
+ // originalID -> analytic blend-surface descriptor in that original's own frame
142
+ // (blend-surfaces.js; registered by mesh-fillet's tools through _markBlendSurface).
143
+ // Keyed on geometry, not on use: a cached tool reused elsewhere IS that surface, so
144
+ // the entry is true wherever the id shows up. Grows per fillet tool, like oidPolicies.
145
+ const blendSurfaces = new Map();
140
146
  // name -> { m, digest, hash } | { error, digest } — imported geometry the framework
141
147
  // registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
142
148
  // these masters must survive cleanup() and be read again on every subsequent build.
@@ -216,7 +222,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
216
222
  // transient mesh handle.
217
223
  function meshOut(m, asStl) {
218
224
  const g = m.getMesh();
219
- const r = asStl ? stlFromMesh(g) : creasedNormals(g, { policies: oidPolicies, featureLabels });
225
+ const r = asStl ? stlFromMesh(g) : creasedNormals(g, { policies: oidPolicies, featureLabels, surfaces: blendSurfaces });
220
226
  g.delete?.();
221
227
  return r;
222
228
  }
@@ -282,6 +288,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
282
288
  // mesh-fillet-perf.test.js. Shading is unchanged (BLEND shades like SMOOTH), and
283
289
  // label() on a blend result already handles a mixed-original mesh (the majority
284
290
  // vote below).
291
+ // Fresh ids for a blend-aware re-stamp (label(), the roundAll decouple): ONE base id
292
+ // for every non-blend run, and one fresh id PER blend surface — each keeps its BLEND
293
+ // policy and its analytic descriptor (runTransform rides through the MeshGL
294
+ // round-trip, so the descriptor's original frame stays valid). Folding the band
295
+ // into a single blend id, as this used to, lost every descriptor with it: a labeled
296
+ // fillet shaded with facet normals again.
297
+ const reserveBlendRestamp = (oids) => {
298
+ const blends = [...oids].filter((oid) => !!oidPolicies.get(oid)?.boundaryLines);
299
+ const baseId = Manifold.reserveIDs(1 + blends.length);
300
+ const blendIdFor = new Map(blends.map((oid, i) => [oid, baseId + 1 + i]));
301
+ for (const [oid, id2] of blendIdFor) {
302
+ oidPolicies.set(id2, BLEND);
303
+ const surf = blendSurfaces.get(oid);
304
+ if (surf) blendSurfaces.set(id2, surf);
305
+ }
306
+ return { baseId, blendIdFor, blendIds: [...blendIdFor.values()] };
307
+ };
285
308
  const runOids = (mm) => { const g = mm.getMesh(); const s = new Set(g.runOriginalID); g.delete?.(); return s; };
286
309
  const meshCadOp = (op, baseM, run) => {
287
310
  try {
@@ -373,16 +396,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
373
396
  // folded blend and base into one fresh surface, erasing the distinction
374
397
  // the band-boundary lines need — a roundAll'd prism rendered with no
375
398
  // feature lines at all, unlike the identical geometry from fillet().
376
- // Re-stamp as two reserved ids instead (base stays unregistered → SMOOTH,
377
- // the extruded section has no policy of its own) so the result draws
378
- // exactly the lines the fillet drew.
399
+ // Re-stamp onto fresh reserved ids instead — one base id (unregistered →
400
+ // SMOOTH, the extruded section has no policy of its own) and one per blend
401
+ // surface (reserveBlendRestamp) — so the result draws exactly the lines, and
402
+ // shades with exactly the normals, the fillet did.
379
403
  const g0 = filleted._m.getMesh();
380
404
  try {
381
405
  const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
382
406
  if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
383
- const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
384
- g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendId : baseId));
385
- oidPolicies.set(blendId, BLEND);
407
+ const { baseId, blendIdFor } = reserveBlendRestamp(new Set(g0.runOriginalID));
408
+ g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendIdFor.get(o) : baseId));
386
409
  // The constructor re-welds, and that can pinch a latent sliver off a
387
410
  // component as fresh femto-debris (the same rebirth dropDebris's own
388
411
  // compose() loop guards against) — sweep the reconstruction too.
@@ -507,8 +530,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
507
530
  // Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
508
531
  // policy), one asOriginal() would fold band and base into a single surface and
509
532
  // erase the band-boundary overlay on exactly the solids parts label — every
510
- // real part labels its top-level solids. Re-stamp as TWO reserved ids instead,
511
- // base and blend: the label covers both (same string → one feature entry), the
533
+ // real part labels its top-level solids. Re-stamp onto fresh reserved ids
534
+ // instead, one base id and one per blend surface (reserveBlendRestamp, which
535
+ // also carries each band's analytic normals): the label covers all of them
536
+ // (same string → one feature entry), the
512
537
  // distinction survives labeling and every later boolean, and reserved ids are
513
538
  // fresh so cached-solid reuse under another label cannot collide — the same
514
539
  // guarantee asOriginal() gives the plain path below.
@@ -544,7 +569,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
544
569
  } };
545
570
  }
546
571
  if ([...oids0].some(isBlend)) {
547
- const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
572
+ const { baseId, blendIdFor, blendIds } = reserveBlendRestamp(oids0);
548
573
  // base-group policy: the same triangle-weighted majority vote as the plain
549
574
  // path, but over the NON-blend runs only — blend runs would elect BLEND for
550
575
  // the base group and flag BOTH sides of every boundary seam, which is
@@ -561,16 +586,15 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
561
586
  const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
562
587
  if (better) { bestWeight = weight; basePol = pol; }
563
588
  }
564
- g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendId : baseId));
589
+ g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendIdFor.get(o2) : baseId));
565
590
  const o = T(new Manifold(g0));
566
591
  g0.delete?.();
567
592
  featureLabels.set(baseId, name);
568
- featureLabels.set(blendId, name);
569
- oidPolicies.set(blendId, BLEND);
593
+ for (const id2 of blendIds) featureLabels.set(id2, name);
570
594
  if (basePol !== undefined) oidPolicies.set(baseId, basePol);
571
595
  return { value: wrap(o, lh), pin: o, dispose: () => {
572
- featureLabels.delete(baseId); featureLabels.delete(blendId);
573
- oidPolicies.delete(baseId); oidPolicies.delete(blendId);
596
+ featureLabels.delete(baseId); oidPolicies.delete(baseId);
597
+ for (const id2 of blendIds) { featureLabels.delete(id2); oidPolicies.delete(id2); blendSurfaces.delete(id2); }
574
598
  o.delete?.();
575
599
  } };
576
600
  }
@@ -839,6 +863,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
839
863
  // is total; errors are lazy"). Re-registering the same name+digest is a no-op
840
864
  // EXCEPT an error entry is always upgradable (the post-crossover retry depends
841
865
  // on this — see `_importDigest`).
866
+ // Side-channel for mesh-fillet (underscore = off-contract): `surf` describes the
867
+ // tool's blend wall in its current frame. Stored per surface id in the id's
868
+ // ORIGINAL frame (inverse runTransform), so any later pose or boolean — which
869
+ // carry runTransform along — still evaluates it exactly.
870
+ _markBlendSurface: (tool, surf) => {
871
+ const g = tool._m.getMesh();
872
+ try {
873
+ const roid = g.runOriginalID, rt = g.runTransform;
874
+ for (let r = 0; r < roid.length; r++) {
875
+ const inv = invertAffine(affineAt(rt, r));
876
+ if (inv) blendSurfaces.set(roid[r], mapSurface(surf, inv));
877
+ }
878
+ } finally {
879
+ g.delete?.();
880
+ }
881
+ return tool;
882
+ },
842
883
  _registerImport: ({ name, digest, positions, indices, error }) => {
843
884
  const prev = imports.get(name);
844
885
  if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable