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.
- package/docs/KERNEL-CONTRACT.md +10 -0
- package/package.json +1 -1
- package/src/framework/geometry/blend-surfaces.js +256 -0
- package/src/framework/geometry/creased-normals.js +121 -7
- package/src/framework/geometry/manifold-backend.js +56 -15
- package/src/framework/geometry/mesh-fillet.js +55 -5
- package/src/framework/materials/patterns.js +48 -0
- package/src/framework/materials/physical.js +8 -2
- package/src/framework/materials/uv.js +27 -8
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
377
|
-
// the extruded section has no policy of its own)
|
|
378
|
-
//
|
|
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
|
|
384
|
-
g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ?
|
|
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
|
|
511
|
-
// base and
|
|
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
|
|
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) ?
|
|
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(
|
|
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);
|
|
573
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
2
|
-
// metal)
|
|
3
|
-
// UVs. Projection is in OBJECT space along each vertex normal's
|
|
4
|
-
// it rides with the sub-part and never swims.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
}
|