partforge 0.122.0 → 0.123.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.
@@ -406,6 +406,16 @@ are present:
406
406
  - `normals` — per-vertex shading normals. Smooth within one surface, hard
407
407
  across boolean-cut seams. OCCT ships analytic B-rep normals; Manifold ships
408
408
  the policy-aware crease pass (`src/framework/geometry/creased-normals.js`).
409
+ Fillet bands are analytic on both backends: each Manifold fillet tool registers
410
+ its rolling-ball spine (`src/framework/geometry/blend-surfaces.js` — a line,
411
+ circle, point or planar path), and the crease pass shades any smooth group the
412
+ band takes part in with that exact normal, so a band meets the faces it is
413
+ tangent to with matching normals (within 0.1° of the true surface on a
414
+ filleted box — `test/mesh-fillet-normals.test.js`, with its OCCT twin in
415
+ `test/occt-shading.test.js`). The spine follows the band through later
416
+ booleans, poses and `label()`. Planar-path bands (rims of extrusions) shade
417
+ the smooth curve their polyline samples; at a line-to-arc junction in that
418
+ polyline the normal is off by up to half an arc segment, as before.
409
419
  - `edges` — flat feature-edge segment pairs (6 floats per segment). An EMPTY
410
420
  array means "this solid has no feature edges"; it is not "unknown". OCCT
411
421
  ships true B-rep edges with tangent edges (fillet blends, seam lines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -86,6 +86,21 @@ const rotVec = (p, k, th) => { // Rodrigues rotation about unit axis k
86
86
  return add(add(scl(p, c), scl(cross(k, p), s)), scl(k, dot(k, p) * (1 - c)));
87
87
  };
88
88
 
89
+ // Analytic shading. Each fillet tool's blend wall is a canal surface about a known
90
+ // spine (blend-surfaces.js); tell the kernel, which registers it against the tool's
91
+ // surface id so toMesh can emit the exact rolling-ball normals instead of facet
92
+ // averages. `surf` is in the tool's CURRENT (posed, world) frame. A kernel without
93
+ // the hook (or a chamfer, whose flat/conical walls facet-shade exactly already) just
94
+ // gets the tool back.
95
+ const markBlend = (k, tool, surf) => (k._markBlendSurface ? k._markBlendSurface(tool, surf) : tool);
96
+ // The ball centre's offset from the edge point, given the two unit flank normals —
97
+ // profile2D's C, in any dimension (2-D profile coordinates or 3-D world vectors).
98
+ const ballOffset = (n1, n2, r, convex) => {
99
+ const c = clamp1(n1.reduce((acc, x, i) => acc + x * n2[i], 0));
100
+ const f = (convex ? 1 : -1) * (-r / (1 + c));
101
+ return n1.map((x, i) => f * (x + n2[i]));
102
+ };
103
+
89
104
  // ---------------------------------------------------------------------------
90
105
  // Sharp-edge extraction: weld vertices, keep edges whose two incident triangles
91
106
  // meet at a dihedral sharper than sharpDeg, tag convexity and flank normals.
@@ -663,7 +678,8 @@ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
663
678
  { shading: "smooth" },
664
679
  );
665
680
  if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
666
- return tool.translate(a);
681
+ tool = tool.translate(a);
682
+ return mode === "fillet" ? markBlend(k, tool, { kind: "line", p: add(a, ballOffset(n1, n2, magnitude, convex)), d: e }) : tool;
667
683
  }
668
684
 
669
685
  // `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
@@ -756,7 +772,11 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs, flankAt = ()
756
772
  const dephase = closed ? Math.PI / flankSegs : 0;
757
773
  const twist = Math.atan2(dot(w, cross(xImage, startDir)), dot(xImage, startDir)) + dephase;
758
774
  if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: w, deg: (twist * 180) / Math.PI });
759
- return tool.translate(O);
775
+ tool = tool.translate(O);
776
+ if (mode !== "fillet") return tool;
777
+ // spine: the ball centre's circle — profile C = [R, 0] + offset in (ρ, w)
778
+ const C = ballOffset(n1, n2, magnitude, convex);
779
+ return markBlend(k, tool, { kind: "circle", c: add(O, scl(w, C[1])), a: w, R: R + C[0] });
760
780
  }
761
781
 
762
782
  // ---------------------------------------------------------------------------
@@ -895,6 +915,32 @@ function weldChainPoints(pts, wallNs, closed) {
895
915
  return { pts: outP, wallNs: outW };
896
916
  }
897
917
 
918
+ // The spine of a planar sweep tool, as a "path" blend surface (blend-surfaces.js).
919
+ // The sweep transports the profile about the face normal, so the wall keeps its
920
+ // face-normal component and its in-plane part turns with the path; the seed segment
921
+ // (the one sweepSeedFrame is built ⟂ to: the first, or the closing one of a loop)
922
+ // fixes which side that is. Vertex directions bisect their two segments' sides.
923
+ function planarSpine(path, closed, faceN, wallN, magnitude, convex) {
924
+ const n = path.length, nSeg = closed ? n : n - 1;
925
+ const tan = (i) => norm(sub(path[(i + 1) % n], path[i]));
926
+ const tSeed = tan(closed ? n - 1 : 0);
927
+ const wallP = norm(sub(wallN, scl(tSeed, dot(wallN, tSeed))));
928
+ const cf = dot(wallP, faceN);
929
+ const wIn = sub(wallP, scl(faceN, cf));
930
+ const side = dot(wIn, cross(faceN, tSeed)) >= 0 ? 1 : -1;
931
+ const segSide = [];
932
+ for (let i = 0; i < nSeg; i++) segSide.push(scl(norm(cross(faceN, tan(i))), side));
933
+ const dirs = path.map((_, i) => {
934
+ const a = closed ? segSide[(i - 1 + nSeg) % nSeg] : segSide[Math.max(0, i - 1)];
935
+ const b = closed ? segSide[i % nSeg] : segSide[Math.min(nSeg - 1, i)];
936
+ const m = norm(add(a, b));
937
+ return len(m) > 0 ? m : a;
938
+ });
939
+ // ballOffset(faceN, wall) = K·(faceN + wall) with wall = cf·faceN + |wIn|·dir
940
+ const K = (convex ? 1 : -1) * (-magnitude / (1 + clamp1(cf)));
941
+ return { kind: "path", pts: path, closed, dirs, f: faceN, kf: K * (1 + cf), kd: K * len(wIn) };
942
+ }
943
+
898
944
  function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null, flankAt = () => segs) {
899
945
  const { points, closed, convex, faceN } = chain;
900
946
  let { wallNs } = chain;
@@ -1036,7 +1082,8 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
1036
1082
  return [q[0] / l, q[1] / l];
1037
1083
  };
1038
1084
  const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
1039
- return k.sweep(poly, path3D, { closed: isClosed });
1085
+ const tool = k.sweep(poly, path3D, { closed: isClosed });
1086
+ return mode === "fillet" ? markBlend(k, tool, planarSpine(path3D, isClosed, faceN, wallN, magnitude, convex)) : tool;
1040
1087
  };
1041
1088
 
1042
1089
  // Sweep one open stretch; when the sweep refuses a VERTEX fold the pre-split
@@ -1333,7 +1380,9 @@ function reflexPivotTool(k, { vertex, f, u0, span }, magnitude, mode, segs, pSeg
1333
1380
  const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
1334
1381
  const twist = Math.atan2(dot(f, cross(xImage, startDir)), dot(xImage, startDir));
1335
1382
  if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: f, deg: (twist * 180) / Math.PI });
1336
- return tool.translate(vertex);
1383
+ tool = tool.translate(vertex);
1384
+ // spine: the ball centre swings on a circle of radius r, r below the face
1385
+ return mode === "fillet" ? markBlend(k, tool, { kind: "circle", c: sub(vertex, scl(f, r)), a: f, R: r }) : tool;
1337
1386
  }
1338
1387
 
1339
1388
  function chainEndInfo(ch, end) {
@@ -1546,7 +1595,8 @@ function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
1546
1595
  const twist = Math.atan2(dot(e1, cross(xImage, e2)), dot(xImage, e2));
1547
1596
  if (Math.abs(twist) > 1e-9) block = block.rotateAbout({ axis: e1, deg: (twist * 180) / Math.PI });
1548
1597
  block = block.translate(V);
1549
- patches.push(block.cut(k.sphere({ r }).at(add(C, scl(inward, bury)))));
1598
+ const centre = add(C, scl(inward, bury));
1599
+ patches.push(block.cut(markBlend(k, k.sphere({ r }).at(centre), { kind: "point", c: centre })));
1550
1600
  }
1551
1601
  return patches;
1552
1602
  }
@@ -193,3 +193,51 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
193
193
  material.needsUpdate = true;
194
194
  return material;
195
195
  }
196
+
197
+ // Brush frame for anisotropic (brushed) metal, chosen PER PIXEL. three follows the
198
+ // tangent frame `tbn`; built from per-vertex data it interpolates across each
199
+ // triangle, so wherever the brush direction has to turn (it must, somewhere, on
200
+ // any closed part) the turn followed the triangulation — a jagged staircase across
201
+ // a fillet corner's small facets. Here the box-projection rule (brush along object
202
+ // X, or along Y where X dominates the normal) runs on the interpolated object
203
+ // normal in the fragment, so the turn lands on the smooth |nx| = max(|ny|,|nz|)
204
+ // contour instead. The frame is rebuilt from the final shading `normal`, just
205
+ // before <lights_physical_fragment> reads it. Composes with a pattern's own
206
+ // injection (applied first) rather than replacing it.
207
+ const BRUSH_VERT_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
208
+ const BRUSH_VERT_BODY = "vPfBrushN = normal;\nvPfBrushNmX = normalMatrix[0];\nvPfBrushNmY = normalMatrix[1];\nvPfBrushNmZ = normalMatrix[2];\n";
209
+ const BRUSH_FRAG_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
210
+ const BRUSH_FRAG_BODY = `
211
+ #ifdef USE_ANISOTROPY
212
+ {
213
+ vec3 pfN = normalize(vPfBrushN);
214
+ vec3 pfA = abs(pfN);
215
+ vec3 pfAxis = (pfA.x > pfA.y && pfA.x > pfA.z) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
216
+ vec3 pfT = mat3(vPfBrushNmX, vPfBrushNmY, vPfBrushNmZ) * (pfAxis - pfN * dot(pfAxis, pfN));
217
+ pfT = pfT - normal * dot(normal, pfT);
218
+ if (dot(pfT, pfT) > 1e-12) {
219
+ pfT = normalize(pfT);
220
+ tbn[0] = pfT;
221
+ tbn[1] = cross(normal, pfT);
222
+ }
223
+ }
224
+ #endif
225
+ `;
226
+
227
+ export function applyBrushFrame(material) {
228
+ const prev = material.onBeforeCompile;
229
+ const prevKey = material.customProgramCacheKey;
230
+ material.onBeforeCompile = (shader, renderer) => {
231
+ prev?.call(material, shader, renderer);
232
+ shader.vertexShader = shader.vertexShader
233
+ .replace("#include <common>", `#include <common>\n${BRUSH_VERT_DECL}`)
234
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\n${BRUSH_VERT_BODY}`);
235
+ shader.fragmentShader = shader.fragmentShader
236
+ .replace("#include <common>", `#include <common>\n${BRUSH_FRAG_DECL}`)
237
+ .replace("#include <lights_physical_fragment>", `${BRUSH_FRAG_BODY}\n#include <lights_physical_fragment>`);
238
+ };
239
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
240
+ material.customProgramCacheKey = () => `${base}|pf-brush`;
241
+ material.needsUpdate = true;
242
+ return material;
243
+ }
@@ -5,7 +5,7 @@
5
5
  // disagree about which preset a sub-part is.
6
6
  import * as THREE from "three";
7
7
  import { cadAppearance, resolveMaterial } from "./resolve.js";
8
- import { applyPattern } from "./patterns.js";
8
+ import { applyBrushFrame, applyPattern } from "./patterns.js";
9
9
  import { PATTERN_TEXTURES } from "./assets.js";
10
10
 
11
11
  const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
@@ -24,7 +24,13 @@ export function buildCadMaterial(display, base) {
24
24
 
25
25
  // Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
26
26
  // a caching TextureLoader), so this stays unit-testable without a network.
27
- export function buildPhysicalMaterial(display, { printFrame, loadTexture } = {}) {
27
+ export function buildPhysicalMaterial(display, opts = {}) {
28
+ const m = buildPhysical(display, opts);
29
+ // brushed metal picks its brush direction per pixel (patterns.js applyBrushFrame)
30
+ return m.userData.pfAnisotropic ? applyBrushFrame(m) : m;
31
+ }
32
+
33
+ function buildPhysical(display, { printFrame, loadTexture } = {}) {
28
34
  const { params, tinted } = resolveMaterial(display);
29
35
  const set = params.textures;
30
36
  // A textured preset's colour map carries its own colour; the preset `color` is
@@ -1,23 +1,42 @@
1
- // Box-projected UVs, added only for realistic mode. three's anisotropy (brushed
2
- // metal) builds its tangent frame from UV derivatives, and CAD meshes carry no
3
- // UVs. Projection is in OBJECT space along each vertex normal's dominant axis, so
4
- // it rides with the sub-part and never swims. Seams on curved surfaces are
5
- // acceptable: nothing samples a texture through these UVs — triplanar patterns
6
- // (patterns.js) ignore them.
1
+ // Box-projected UVs and brush tangents, added only for realistic mode. three's
2
+ // anisotropy (brushed metal) needs a tangent frame, and CAD meshes carry neither
3
+ // UVs nor tangents. Projection is in OBJECT space along each vertex normal's
4
+ // dominant axis, so it rides with the sub-part and never swims. Nothing samples a
5
+ // texture through these UVs — triplanar patterns (patterns.js) ignore them.
6
+ //
7
+ // The TANGENT attribute is what the brush actually follows. Without one, three
8
+ // derives the frame per pixel from screen-space UV derivatives, which is constant
9
+ // across each triangle: on a fillet or a corner every facet read as its own flat
10
+ // brushed patch, and a triangle whose corners picked different projection axes
11
+ // got a garbage frame outright. The per-vertex tangent is the projection's u-axis
12
+ // (world X, or Y where X dominates the normal) flattened into the tangent plane, so
13
+ // it interpolates smoothly with the (analytic) vertex normals. It turns 90° only
14
+ // across the one facet row where the dominant axis changes, and never through
15
+ // zero — the two candidate axes are orthogonal.
7
16
  import * as THREE from "three";
8
17
 
9
18
  export function ensureBoxUVs(geometry, scaleMm = 20) {
10
- if (geometry.userData.boxUVs && geometry.attributes.uv) return geometry;
19
+ if (geometry.userData.boxUVs && geometry.attributes.uv && geometry.attributes.tangent) return geometry;
11
20
  const pos = geometry.attributes.position, nrm = geometry.attributes.normal;
12
21
  const uv = new Float32Array(pos.count * 2);
22
+ const tangent = new Float32Array(pos.count * 4);
13
23
  for (let i = 0; i < pos.count; i++) {
14
24
  const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
15
- const ax = Math.abs(nrm ? nrm.getX(i) : 0), ay = Math.abs(nrm ? nrm.getY(i) : 0), az = Math.abs(nrm ? nrm.getZ(i) : 1);
25
+ const nx = nrm ? nrm.getX(i) : 0, ny = nrm ? nrm.getY(i) : 0, nz = nrm ? nrm.getZ(i) : 1;
26
+ const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
16
27
  let u, v;
17
28
  if (az >= ax && az >= ay) { u = x; v = y; } else if (ay >= ax) { u = x; v = z; } else { u = y; v = z; }
18
29
  uv[i * 2] = u / scaleMm; uv[i * 2 + 1] = v / scaleMm;
30
+ // u-axis minus its normal component; |that| ≥ √½ because the axis is never the
31
+ // dominant one, so the normalize is always well-conditioned
32
+ const uxAxis = !(ax > ay && ax > az);
33
+ const d = uxAxis ? nx : ny;
34
+ let tx = (uxAxis ? 1 : 0) - d * nx, ty = (uxAxis ? 0 : 1) - d * ny, tz = -d * nz;
35
+ const L = Math.hypot(tx, ty, tz) || 1;
36
+ tangent[i * 4] = tx / L; tangent[i * 4 + 1] = ty / L; tangent[i * 4 + 2] = tz / L; tangent[i * 4 + 3] = 1;
19
37
  }
20
38
  geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
39
+ geometry.setAttribute("tangent", new THREE.BufferAttribute(tangent, 4));
21
40
  geometry.userData.boxUVs = true;
22
41
  return geometry;
23
42
  }