partforge 0.122.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/docs/AUTHORING-PARTS.md +57 -31
- package/docs/KERNEL-CONTRACT.md +10 -0
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +6 -1
- package/src/framework/app.css +69 -57
- package/src/framework/chrome.css +38 -25
- package/src/framework/cutaway-gizmo.js +2 -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/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +106 -7
- package/src/framework/materials/physical.js +9 -2
- package/src/framework/materials/presets.js +6 -3
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/materials/uv.js +27 -8
- package/src/framework/mount.js +83 -36
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +287 -0
- package/src/framework/view-style-state.js +27 -0
- package/src/framework/viewcube/viewcube-controls.js +23 -64
- package/src/framework/viewcube/viewcube-mode.js +5 -3
- package/src/framework/viewer.js +526 -47
- package/types/index.d.ts +14 -8
- package/src/framework/realistic-controls.js +0 -78
|
@@ -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
|