trid3d 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Bevel: rounds (or cuts) the sharp edges and corners of any mesh, like a
3
+ * bevel modifier. Manufactured things are rarely sharp-edged: furniture,
4
+ * devices, robots, panels.
5
+ */
6
+ import { newell } from "../render/normals.js";
7
+ import { samePlace, defaultEps, distinctCorners, weldVertices, cleanMesh } from "./cleanup.js";
8
+ import { add, sub, mul, dot, cross, len, norm } from "./vec.js";
9
+
10
+ const keyOf = (a, b) => (a < b ? `${a},${b}` : `${b},${a}`);
11
+ const lerp = (a, b, t) => add(a, mul(sub(b, a), t));
12
+ const centroid = (pts) => mul(pts.reduce((s, p) => add(s, p), { x: 0, y: 0, z: 0 }), 1 / pts.length);
13
+
14
+ /**
15
+ * The mesh as one surface: vertices at the same place merged, faces with
16
+ * their outward normals, edges with the faces along them, and the faces
17
+ * around each vertex in order (null where the vertex is on an open border).
18
+ */
19
+ function surface(mesh) {
20
+ const vs = mesh?.vertices ?? [];
21
+ const rep = samePlace(vs, defaultEps(vs));
22
+ const index = new Map(), P = [];
23
+ const at = (i) => {
24
+ const r = rep[i];
25
+ if (!index.has(r)) index.set(r, P.push({ x: vs[r].x, y: vs[r].y, z: vs[r].z }) - 1);
26
+ return index.get(r);
27
+ };
28
+ const okVertex = (i) => Number.isInteger(i) && i >= 0 && i < vs.length && Number.isFinite(vs[i].x + vs[i].y + vs[i].z);
29
+ const F = [], src = [];
30
+ (mesh?.faces ?? []).forEach((f, n) => {
31
+ if (!Array.isArray(f) || !f.every(okVertex)) return;
32
+ const keep = distinctCorners(f.map(at));
33
+ if (keep.length < 3 || new Set(keep.map(([v]) => v)).size !== keep.length) return;
34
+ F.push(keep.map(([v]) => v));
35
+ src.push({ n, corners: keep.map(([, c]) => c) });
36
+ });
37
+ if (!F.length) return null;
38
+ const N = F.map((f) => norm(newell(f.map((i) => P[i])), { x: 0, y: 1, z: 0 }));
39
+
40
+ // Edges: the faces along each, with the corner where each one starts it (f[c] → f[c + 1])
41
+ const E = new Map();
42
+ F.forEach((f, fi) => f.forEach((a, c) => {
43
+ const b = f[(c + 1) % f.length];
44
+ const k = keyOf(a, b);
45
+ (E.get(k) ?? E.set(k, []).get(k)).push({ f: fi, c, a, b });
46
+ }));
47
+ /** The other face on the edge leaving corner (fi, c), going the other way along it (null on an open or odd edge). */
48
+ const twin = (fi, c) => {
49
+ const f = F[fi], a = f[c], b = f[(c + 1) % f.length];
50
+ const list = E.get(keyOf(a, b));
51
+ if (list?.length !== 2) return null;
52
+ const o = list[0].f === fi && list[0].c === c ? list[1] : list[0];
53
+ return o.a === b && o.b === a ? o : null;
54
+ };
55
+
56
+ // The corners around each vertex, in order: the next one is across the current one's outgoing edge
57
+ const cornersAt = P.map(() => []);
58
+ F.forEach((f, fi) => f.forEach((v, c) => cornersAt[v].push([fi, c])));
59
+ const fans = cornersAt.map((list) => {
60
+ if (!list.length) return null;
61
+ const order = [];
62
+ let [fi, c] = list[0];
63
+ for (let guard = 0; guard <= list.length; guard++) {
64
+ order.push([fi, c]);
65
+ const t = twin(fi, c);
66
+ if (!t) return null;
67
+ fi = t.f;
68
+ c = (t.c + 1) % F[fi].length;
69
+ if (fi === list[0][0] && c === list[0][1]) return order.length === list.length ? order : null;
70
+ }
71
+ return null;
72
+ });
73
+
74
+ let lo = Infinity, hi = -Infinity;
75
+ for (const p of P) { lo = Math.min(lo, p.x, p.y, p.z); hi = Math.max(hi, p.x, p.y, p.z); }
76
+
77
+ // Texture coordinates: a flat (affine) map per face, so new points on it get matching ones
78
+ let uvMaps = null;
79
+ if (Array.isArray(mesh.uvs) && Array.isArray(mesh.faceUVs)) {
80
+ uvMaps = src.map(({ n, corners }, fi) => {
81
+ const c = mesh.faceUVs[n];
82
+ if (!Array.isArray(c) || c.length !== mesh.faces[n].length) return null;
83
+ const uv = corners.map((k) => mesh.uvs[c[k]]);
84
+ if (uv.some((t) => !t || !Number.isFinite(t.u + t.v))) return null;
85
+ return affineUV(F[fi].map((i) => P[i]), uv, N[fi]);
86
+ });
87
+ if (uvMaps.some((m) => !m)) uvMaps = null;
88
+ }
89
+ return { P, F, src, N, E, twin, fans, size: hi - lo || 1, at, uvMaps };
90
+ }
91
+
92
+ /** A map from points (on or near a face's plane) to texture coordinates, fitted to the face's corners. */
93
+ function affineUV(pts, uvs, n) {
94
+ const o = pts[0];
95
+ const e1 = norm(sub(pts[1], o), { x: 1, y: 0, z: 0 });
96
+ const e2 = cross(n, e1);
97
+ // Least squares for u = a s + b t + c (and v), from the corners' plane coordinates
98
+ let ss = 0, st = 0, tt = 0, s1 = 0, t1 = 0, n1 = 0;
99
+ const ru = [0, 0, 0], rv = [0, 0, 0];
100
+ pts.forEach((p, i) => {
101
+ const d = sub(p, o), s = dot(d, e1), t = dot(d, e2);
102
+ ss += s * s; st += s * t; tt += t * t; s1 += s; t1 += t; n1 += 1;
103
+ ru[0] += s * uvs[i].u; ru[1] += t * uvs[i].u; ru[2] += uvs[i].u;
104
+ rv[0] += s * uvs[i].v; rv[1] += t * uvs[i].v; rv[2] += uvs[i].v;
105
+ });
106
+ const solve = (r) => {
107
+ const m = [[ss, st, s1], [st, tt, t1], [s1, t1, n1]];
108
+ const det = (a) => a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
109
+ const D = det(m);
110
+ if (Math.abs(D) < 1e-18) return [0, 0, r[2] / n1];
111
+ return [0, 1, 2].map((col) => det(m.map((row, i) => row.map((x, j) => (j === col ? r[i] : x)))) / D);
112
+ };
113
+ const [ua, ub, uc] = solve(ru), [va, vb, vc] = solve(rv);
114
+ return (p) => {
115
+ const d = sub(p, o), s = dot(d, e1), t = dot(d, e2);
116
+ return { u: ua * s + ub * t + uc, v: va * s + vb * t + vc };
117
+ };
118
+ }
119
+
120
+ /**
121
+ * A hole's outline, untangled: points at the same place in a row count once,
122
+ * and where the outline comes back to a point it already passed (two rounded
123
+ * edges starting the same way at a concave corner), the little loop between
124
+ * is split off. Returns the main outline and the little loops (each filled
125
+ * with its own flat face, so the surface stays closed).
126
+ */
127
+ function untangle(ring, vertices, eps) {
128
+ const same = (a, b) => len(sub(vertices[a], vertices[b])) <= eps;
129
+ let list = ring.filter((v, i) => !same(v, ring[(i + ring.length - 1) % ring.length]));
130
+ const extra = [];
131
+ for (let changed = true; changed;) {
132
+ changed = false;
133
+ search: for (let i = 0; i < list.length; i++) {
134
+ for (let j = i + 1; j < list.length; j++) {
135
+ if (!same(list[i], list[j])) continue;
136
+ const inner = list.slice(i, j), rest = [...list.slice(0, i), ...list.slice(j)];
137
+ const [small, big] = inner.length <= rest.length ? [inner, rest] : [rest, inner];
138
+ if (small.length >= 3) extra.push(small);
139
+ list = big;
140
+ changed = true;
141
+ break search;
142
+ }
143
+ }
144
+ }
145
+ return { main: list, extra };
146
+ }
147
+
148
+ /** Collects the new mesh: vertices, faces, and for each face the original face it takes its material and texture from. */
149
+ function builder() {
150
+ const vertices = [], faces = [], from = [];
151
+ return {
152
+ vertices, faces, from,
153
+ vertex: (p) => vertices.push({ x: p.x, y: p.y, z: p.z }) - 1,
154
+ face(ids, fi) {
155
+ const keep = distinctCorners(ids).map(([v]) => v);
156
+ if (keep.length >= 3) {
157
+ faces.push(keep);
158
+ from.push(fi);
159
+ }
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Turns the patch faces made since `start` so they go from `a` to `b` where
166
+ * they touch that ring edge: the way that fits the faces around them.
167
+ */
168
+ function orientByRing(out, start, a, b) {
169
+ for (let i = start; i < out.faces.length; i++) {
170
+ const f = out.faces[i];
171
+ for (let c = 0; c < f.length; c++) {
172
+ const p = f[c], q = f[(c + 1) % f.length];
173
+ if (p === a && q === b) return;
174
+ if (p === b && q === a) {
175
+ for (let j = start; j < out.faces.length; j++) out.faces[j].reverse();
176
+ return;
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ /** The original face (among `faces`) whose normal is closest to the polygon's: its material and texture for a patch face. */
183
+ function nearestFace(B, faces, pts) {
184
+ const n = norm(newell(pts), null);
185
+ if (!n) return faces[0];
186
+ let best = faces[0], bd = -Infinity;
187
+ for (const f of faces) {
188
+ const d = Math.abs(dot(B.N[f], n));
189
+ if (d > bd) { bd = d; best = f; }
190
+ }
191
+ return best;
192
+ }
193
+
194
+ /** The new mesh, with texture coordinates and materials from the original faces, welded and cleaned. */
195
+ function finish(out, B, mesh) {
196
+ const m = { vertices: out.vertices, faces: out.faces };
197
+ if (B.uvMaps) {
198
+ const uvs = [];
199
+ m.faceUVs = out.faces.map((f, i) => f.map((v) => uvs.push(B.uvMaps[out.from[i]](out.vertices[v])) - 1));
200
+ m.uvs = uvs;
201
+ }
202
+ if (Array.isArray(mesh.faceMaterials)) m.faceMaterials = out.from.map((fi) => mesh.faceMaterials[B.src[fi].n] ?? 0);
203
+ if (mesh.materialNames) m.materialNames = [...mesh.materialNames];
204
+ weldVertices(m, B.size * 1e-9);
205
+ cleanMesh(m);
206
+ delete m.dirty;
207
+ return m;
208
+ }
209
+
210
+ /** A rounded patch over a ring of points around `c` (a fan of rings toward a middle point), or one flat face. */
211
+ function patch(out, ring, c, segs, fi) {
212
+ if (segs < 2 || !c) return out.face(ring, fi);
213
+ const dirs = ring.map((v) => sub(out.vertices[v], c));
214
+ const lens = dirs.map(len);
215
+ const mdir = norm(dirs.reduce((s, d, i) => add(s, mul(d, 1 / (lens[i] || 1))), { x: 0, y: 0, z: 0 }), null);
216
+ if (!mdir) return out.face(ring, fi);
217
+ const mlen = lens.reduce((a, b) => a + b, 0) / lens.length;
218
+ const middle = out.vertex(add(c, mul(mdir, mlen)));
219
+ const level = (j, l) => {
220
+ if (l === segs) return ring[j];
221
+ const t = l / segs;
222
+ const d = norm(add(mul(mdir, 1 - t), mul(norm(dirs[j], mdir), t)), mdir);
223
+ return out.vertex(add(c, mul(d, mlen + (lens[j] - mlen) * t)));
224
+ };
225
+ const rows = ring.map((_, j) => Array.from({ length: segs + 1 }, (_, l) => (l === 0 ? middle : level(j, l))));
226
+ for (let j = 0; j < ring.length; j++) {
227
+ const a = rows[j], b = rows[(j + 1) % ring.length];
228
+ out.face([middle, a[1], b[1]], fi);
229
+ for (let l = 1; l < segs; l++) out.face([a[l], a[l + 1], b[l + 1], b[l]], fi);
230
+ }
231
+ }
232
+
233
+ /** A rounded corner where 3 rounded edges meet: a triangle of points on the ball around `c`. */
234
+ function cornerPatch(out, ring, c, segs, fi) {
235
+ const s = segs;
236
+ const V = [ring[0], ring[s], ring[2 * s]].map((v) => out.vertices[v]);
237
+ const d = V.map((p) => norm(sub(p, c), { x: 0, y: 1, z: 0 }));
238
+ const l = V.map((p) => len(sub(p, c)));
239
+ const inner = new Map();
240
+ const pt = (a, b, k) => {
241
+ if (k === 0) return ring[b];
242
+ if (a === 0) return ring[s + k];
243
+ if (b === 0) return ring[(2 * s + a) % (3 * s)];
244
+ const key = `${a},${b}`;
245
+ if (!inner.has(key)) {
246
+ const dir = norm(add(add(mul(d[0], a), mul(d[1], b)), mul(d[2], k)), d[0]);
247
+ inner.set(key, out.vertex(add(c, mul(dir, (a * l[0] + b * l[1] + k * l[2]) / s))));
248
+ }
249
+ return inner.get(key);
250
+ };
251
+ for (let a = 0; a < s; a++) {
252
+ for (let b = 0; b < s - a; b++) {
253
+ const k = s - a - b;
254
+ out.face([pt(a, b, k), pt(a + 1, b, k - 1), pt(a, b + 1, k - 1)], fi);
255
+ if (b < s - a - 1) out.face([pt(a + 1, b, k - 1), pt(a + 1, b + 1, k - 2), pt(a, b + 1, k - 1)], fi);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * How far a face's corner at `x` moves, for a radius of 1: to where the
262
+ * lines of its two edges cross, each pushed into the face by its offset
263
+ * (0 for an edge that isn't beveled).
264
+ */
265
+ function cornerMove(n, u, x, w, kin, kout) {
266
+ if (!kin && !kout) return { x: 0, y: 0, z: 0 };
267
+ const dIn = norm(sub(x, u), null), dOut = norm(sub(w, x), null);
268
+ if (!dIn || !dOut) return { x: 0, y: 0, z: 0 };
269
+ const mIn = norm(cross(n, dIn), dIn), mOut = norm(cross(n, dOut), dOut);
270
+ const den = dot(dOut, mIn);
271
+ // Edges in a straight line: just push in
272
+ if (Math.abs(den) < 1e-9) return mul(mIn, Math.max(kin, kout));
273
+ const a = (kin - kout * dot(mOut, mIn)) / den;
274
+ return add(mul(dOut, a), mul(mOut, kout));
275
+ }
276
+
277
+ /** Rounds (or cuts) the selected edges with a ball of `radius` rolling along them. */
278
+ function bevelEdges(B, mesh, radius, segs, angle, edgeList) {
279
+ const { P, F, N, E, twin, fans } = B;
280
+
281
+ // Which edges: the listed ones, or every edge where faces meet sharper than `angle`
282
+ const sel = new Set();
283
+ if (Array.isArray(edgeList)) {
284
+ for (const line of edgeList) {
285
+ if (!Array.isArray(line)) continue;
286
+ for (let i = 0; i + 1 < line.length; i++) {
287
+ const a = line[i], b = line[i + 1];
288
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0 || a >= mesh.vertices.length || b >= mesh.vertices.length) continue;
289
+ sel.add(keyOf(B.at(a), B.at(b)));
290
+ }
291
+ }
292
+ } else {
293
+ const minDot = Math.cos((angle * Math.PI) / 180);
294
+ for (const [k, list] of E) if (list.length === 2 && dot(N[list[0].f], N[list[1].f]) < minDot - 1e-9) sel.add(k);
295
+ }
296
+ // Only edges between two faces going opposite ways, away from open borders
297
+ const info = new Map();
298
+ for (const k of sel) {
299
+ const list = E.get(k);
300
+ if (list?.length !== 2 || list[0].a !== list[1].b || !fans[list[0].a] || !fans[list[0].b]) continue;
301
+ const [A, Bf] = list;
302
+ const cosT = Math.max(-1, Math.min(1, dot(N[A.f], N[Bf.f])));
303
+ const theta = Math.min(Math.acos(cosT), (170 * Math.PI) / 180);
304
+ if (theta < 1e-6) continue;
305
+ const cB = centroid(F[Bf.f].map((i) => P[i]));
306
+ info.set(k, { A, B: Bf, off: Math.tan(theta / 2), convex: dot(N[A.f], sub(cB, P[A.a])) < 0 });
307
+ }
308
+
309
+ // How far each face's corner moves (for a radius of 1): its edges' lines, pushed into the face where beveled
310
+ const offOf = (a, b) => info.get(keyOf(a, b))?.off ?? 0;
311
+ const move = F.map((f, fi) => f.map((x, c) => {
312
+ const k = f.length;
313
+ return cornerMove(N[fi], P[f[(c + k - 1) % k]], P[x], P[f[(c + 1) % k]], offOf(f[(c + k - 1) % k], x), offOf(x, f[(c + 1) % k]));
314
+ }));
315
+
316
+ // Faces in one plane joined by edges that aren't beveled (a cap cut into triangles) act as one face:
317
+ // they all take the corner between the beveled edges on either side of them.
318
+ // A corner is rounded when every run of faces between its beveled edges is flat like that.
319
+ const roundAt = new Map();
320
+ fans.forEach((fan, x) => {
321
+ if (!fan) return;
322
+ const L = fan.length;
323
+ const nextOf = ([fi, c]) => F[fi][(c + 1) % F[fi].length], prevOf = ([fi, c]) => F[fi][(c + F[fi].length - 1) % F[fi].length];
324
+ const cut = fan.map((corner) => info.has(keyOf(x, nextOf(corner))));
325
+ const first = cut.indexOf(true);
326
+ if (first < 0) return;
327
+ let flatRuns = true;
328
+ for (let i = (first + 1) % L, n = 0; n < L;) {
329
+ const run = [];
330
+ do {
331
+ run.push(fan[i]);
332
+ const end = cut[i];
333
+ i = (i + 1) % L;
334
+ n++;
335
+ if (end) break;
336
+ } while (n < L);
337
+ const n0 = N[run[0][0]];
338
+ const flat = run.every(([fi]) => dot(N[fi], n0) > 1 - 1e-9);
339
+ if (!flat) flatRuns = false;
340
+ else if (run.length > 1) {
341
+ const a = run[0], b = run[run.length - 1];
342
+ const m = cornerMove(n0, P[prevOf(a)], P[x], P[nextOf(b)], offOf(prevOf(a), x), offOf(x, nextOf(b)));
343
+ for (const [fi, c] of run) move[fi][c] = m;
344
+ }
345
+ }
346
+ roundAt.set(x, flatRuns);
347
+ });
348
+
349
+ // Shrink the radius (everywhere) where bevels from both ends of an edge would meet
350
+ let r = radius;
351
+ F.forEach((f, fi) => f.forEach((x, c) => {
352
+ const y = f[(c + 1) % f.length];
353
+ const L = len(sub(P[y], P[x]));
354
+ const d = norm(sub(P[y], P[x]), null);
355
+ if (!d) return;
356
+ const along = dot(move[fi][c], d) + dot(move[fi][(c + 1) % f.length], mul(d, -1));
357
+ if (along > 1e-12) r = Math.min(r, (0.98 * L) / along);
358
+ }));
359
+
360
+ const out = builder();
361
+ const q = F.map((f, fi) => f.map((x, c) => add(P[x], mul(move[fi][c], r))));
362
+ const qId = q.map((list) => list.map((p) => out.vertex(p)));
363
+ const eps = B.size * 1e-9;
364
+
365
+ // Each rounded edge's profile at each end: from face A's side to face B's, around the rolling ball's center
366
+ const prof = new Map(), centers = new Map();
367
+ for (const [k, e] of info) {
368
+ const { A, B: Bf, convex } = e;
369
+ const s = convex ? 1 : -1;
370
+ for (const x of [A.a, A.b]) {
371
+ const ca = x === A.a ? A.c : (A.c + 1) % F[A.f].length;
372
+ const cb = x === A.a ? (Bf.c + 1) % F[Bf.f].length : Bf.c;
373
+ const qa = q[A.f][ca], qb = q[Bf.f][cb];
374
+ const c = mul(add(sub(qa, mul(N[A.f], s * r)), sub(qb, mul(N[Bf.f], s * r))), 0.5);
375
+ const ids = [qId[A.f][ca]];
376
+ const da = sub(qa, c), db = sub(qb, c), la = len(da), lb = len(db);
377
+ for (let j = 1; j < segs; j++) {
378
+ const t = j / segs;
379
+ const dir = norm(add(mul(norm(da, db), 1 - t), mul(norm(db, da), t)), da);
380
+ ids.push(out.vertex(add(c, mul(dir, la + (lb - la) * t))));
381
+ }
382
+ ids.push(qId[Bf.f][cb]);
383
+ prof.set(`${k}|${x}`, ids);
384
+ (centers.get(x) ?? centers.set(x, []).get(x)).push(c);
385
+ }
386
+ }
387
+
388
+ // Corners: the hole around each vertex with beveled edges. When exactly one face there is
389
+ // untouched (both its edges at the vertex are sharp), the hole becomes part of that face
390
+ // (a rounded edge ending on a flat side); otherwise it gets its own patch.
391
+ const replace = new Map(), patches = [];
392
+ fans.forEach((fan, x) => {
393
+ if (!fan) return;
394
+ const outKeys = fan.map(([fi, c]) => keyOf(x, F[fi][(c + 1) % F[fi].length]));
395
+ const count = outKeys.filter((k) => info.has(k)).length;
396
+ if (!count) return;
397
+ const ring = [];
398
+ fan.forEach(([fi, c], i) => {
399
+ ring.push(qId[fi][c]);
400
+ const k = outKeys[i];
401
+ if (!info.has(k)) return;
402
+ const inner = prof.get(`${k}|${x}`).slice(1, -1);
403
+ ring.push(...(info.get(k).A.f === fi ? inner : inner.reverse()));
404
+ });
405
+ const untouched = fan.filter(([fi, c]) => len(move[fi][c]) === 0);
406
+ if (untouched.length === 1) {
407
+ const [fi, c] = untouched[0];
408
+ const L = ring.length, at = ring.indexOf(qId[fi][c]);
409
+ // The face goes round the hole the other way from the fan, from its incoming neighbor's point to its outgoing one's
410
+ replace.set(`${fi}|${c}`, Array.from({ length: L - 1 }, (_, t) => ring[(at - 1 - t + 2 * L) % L]));
411
+ return;
412
+ }
413
+ patches.push({ x, fan, ring, count, outKeys });
414
+ });
415
+
416
+ // Faces, with their corners moved; a corner next to an edge that isn't beveled also takes
417
+ // the point its neighbor put on that edge, when it's farther along (so nothing is left open)
418
+ const along = (fi, c, toward) => dot(sub(q[fi][c], P[F[fi][c]]), norm(sub(P[toward], P[F[fi][c]]), { x: 0, y: 0, z: 0 }));
419
+ F.forEach((f, fi) => {
420
+ const ids = [];
421
+ const k = f.length;
422
+ f.forEach((x, c) => {
423
+ const merged = replace.get(`${fi}|${c}`);
424
+ if (merged) return ids.push(...merged);
425
+ const u = f[(c + k - 1) % k], w = f[(c + 1) % k];
426
+ const mine = (toward) => along(fi, c, toward);
427
+ if (!info.has(keyOf(u, x))) {
428
+ const t = twin(fi, (c + k - 1) % k); // goes x → u
429
+ if (t && along(t.f, t.c, u) > mine(u) + eps) ids.push(qId[t.f][t.c]);
430
+ }
431
+ ids.push(qId[fi][c]);
432
+ if (!info.has(keyOf(x, w))) {
433
+ const t = twin(fi, c); // goes w → x: its corner at x is the next one
434
+ const cx = t ? (t.c + 1) % F[t.f].length : -1;
435
+ if (t && along(t.f, cx, w) > mine(w) + eps) ids.push(qId[t.f][cx]);
436
+ }
437
+ });
438
+ out.face(ids, fi);
439
+ });
440
+
441
+ // Strips along the rounded edges
442
+ for (const [k, e] of info) {
443
+ const px = prof.get(`${k}|${e.A.a}`), py = prof.get(`${k}|${e.A.b}`);
444
+ for (let j = 0; j < segs; j++) out.face([py[j], px[j], px[j + 1], py[j + 1]], j < segs / 2 ? e.A.f : e.B.f);
445
+ }
446
+
447
+ for (const { x, fan, ring, count, outKeys } of patches) {
448
+ const faces = fan.map(([fi]) => fi);
449
+ if (count === 2) {
450
+ // Two rounded edges meet here (going on, or turning): join their strips with a seam of quads
451
+ const cuts = outKeys.map((k, i) => (info.has(k) ? i : -1)).filter((i) => i >= 0);
452
+ const full = (i) => {
453
+ const [fi] = fan[i];
454
+ const ids = prof.get(`${outKeys[i]}|${x}`);
455
+ return info.get(outKeys[i]).A.f === fi ? ids : [...ids].reverse();
456
+ };
457
+ const between = (from, to) => {
458
+ const ids = [];
459
+ for (let i = (from + 2) % fan.length; i !== (to + 1) % fan.length && i !== to; i = (i + 1) % fan.length) ids.push(qId[fan[i][0]][fan[i][1]]);
460
+ return to === (from + 1) % fan.length ? [] : ids;
461
+ };
462
+ const [i1, i2] = cuts;
463
+ const p1 = full(i1), p2 = full(i2);
464
+ const start = out.faces.length;
465
+ const fi = nearestFace(B, faces, ring.map((v) => out.vertices[v]));
466
+ out.face([p1[segs], ...between(i1, i2), p2[0]], fi);
467
+ out.face([p2[segs], ...between(i2, i1), p1[0]], fi);
468
+ for (let j = 0; j < segs; j++) out.face([p1[j], p1[j + 1], p2[segs - j - 1], p2[segs - j]], fi);
469
+ // Going round the faces in order runs the same way as the strips beside it: patches go the other way
470
+ for (let j = start; j < out.faces.length; j++) out.faces[j].reverse();
471
+ continue;
472
+ }
473
+ // Points at the same place count once
474
+ const { main, extra } = untangle(ring, out.vertices, eps);
475
+ const clean = main.reverse();
476
+ const fi = nearestFace(B, faces, (clean.length >= 3 ? clean : ring).map((v) => out.vertices[v]));
477
+ for (const loop of extra) out.face([...loop].reverse(), fi);
478
+ if (clean.length < 3) continue;
479
+ const start = out.faces.length;
480
+ const cs = centers.get(x);
481
+ const c = cs ? centroid(cs) : null;
482
+ const round = roundAt.get(x);
483
+ if (round && segs > 1 && count === 3 && clean.length === 3 * segs) cornerPatch(out, clean, c, segs, fi);
484
+ else patch(out, clean, round ? c : null, segs, fi);
485
+ orientByRing(out, start, clean[0], clean[1]);
486
+ }
487
+ return finish(out, B, mesh);
488
+ }
489
+
490
+ /** Cuts (or rounds) the selected corners: each edge from a corner is cut `radius` along. */
491
+ function bevelVertices(B, mesh, radius, segs, angle, list) {
492
+ const { P, F, N, E, fans } = B;
493
+ const sel = new Set();
494
+ if (Array.isArray(list)) {
495
+ for (const i of list) if (Number.isInteger(i) && i >= 0 && i < mesh.vertices.length) sel.add(B.at(i));
496
+ } else {
497
+ // Corners: where 3 sharp edges or more meet
498
+ const minDot = Math.cos((angle * Math.PI) / 180);
499
+ const sharp = P.map(() => 0);
500
+ for (const l of E.values()) if (l.length === 2 && dot(N[l[0].f], N[l[1].f]) < minDot - 1e-9) { sharp[l[0].a]++; sharp[l[0].b]++; }
501
+ sharp.forEach((n, v) => { if (n >= 3) sel.add(v); });
502
+ }
503
+ for (const v of sel) if (!fans[v]) sel.delete(v);
504
+
505
+ // How far along each edge: the radius, but under half of the shortest edge there
506
+ const cut = new Map();
507
+ for (const v of sel) {
508
+ let shortest = Infinity;
509
+ for (const [fi, c] of fans[v]) {
510
+ const w = F[fi][(c + 1) % F[fi].length];
511
+ shortest = Math.min(shortest, len(sub(P[w], P[v])));
512
+ }
513
+ cut.set(v, Math.min(radius, 0.45 * shortest));
514
+ }
515
+ const out = builder();
516
+ const ids = P.map((p) => out.vertex(p));
517
+ const onEdge = new Map();
518
+ const pointOn = (x, y) => {
519
+ const key = `${x}>${y}`;
520
+ if (!onEdge.has(key)) onEdge.set(key, out.vertex(add(P[x], mul(norm(sub(P[y], P[x]), { x: 0, y: 0, z: 0 }), cut.get(x)))));
521
+ return onEdge.get(key);
522
+ };
523
+ F.forEach((f, fi) => {
524
+ const k = f.length, face = [];
525
+ f.forEach((x, c) => {
526
+ if (!sel.has(x)) return face.push(ids[x]);
527
+ face.push(pointOn(x, f[(c + k - 1) % k]), pointOn(x, f[(c + 1) % k]));
528
+ });
529
+ out.face(face, fi);
530
+ });
531
+ for (const x of sel) {
532
+ const fan = fans[x];
533
+ // The other way from the faces' order around the corner (see bevelEdges)
534
+ const ring = fan.map(([fi, c]) => pointOn(x, F[fi][(c + 1) % F[fi].length])).reverse();
535
+ const faces = fan.map(([fi]) => fi);
536
+ const fi = nearestFace(B, faces, ring.map((v) => out.vertices[v]));
537
+ const start = out.faces.length;
538
+ if (segs < 2) out.face(ring, fi);
539
+ else {
540
+ // A dome: the flat cut pushed toward the old corner, most in the middle
541
+ const C = centroid(ring.map((v) => out.vertices[v]));
542
+ const h = sub(P[x], C);
543
+ const middle = out.vertex(add(C, mul(h, 0.5)));
544
+ const rows = ring.map((v) => Array.from({ length: segs + 1 }, (_, l) => {
545
+ if (l === 0) return middle;
546
+ if (l === segs) return v;
547
+ const t = l / segs;
548
+ return out.vertex(add(lerp(C, out.vertices[v], t), mul(h, 0.5 * (1 - t * t))));
549
+ }));
550
+ for (let j = 0; j < ring.length; j++) {
551
+ const a = rows[j], b = rows[(j + 1) % ring.length];
552
+ out.face([middle, a[1], b[1]], fi);
553
+ for (let l = 1; l < segs; l++) out.face([a[l], a[l + 1], b[l + 1], b[l]], fi);
554
+ }
555
+ }
556
+ orientByRing(out, start, ring[0], ring[1]);
557
+ }
558
+ return finish(out, B, mesh);
559
+ }
560
+
561
+ /**
562
+ * Rounds the sharp edges of a mesh (or cuts them flat), like a bevel
563
+ * modifier: furniture, devices, robots, panels. Returns a new mesh; the
564
+ * original isn't changed.
565
+ *
566
+ * - `mode: "edges"` (default): every edge where faces meet sharper than
567
+ * `angle` (or each edge in `edges`) is rounded with a ball of `radius`
568
+ * rolling along it; where 3 rounded edges meet, the corner is a piece of
569
+ * a sphere. `segments: 1` cuts them flat (a chamfer).
570
+ * - `mode: "vertices"`: only the corners (where 3 sharp edges or more meet,
571
+ * or each vertex in `vertices`) are cut, `radius` along each edge; with
572
+ * more segments the cut is a small dome.
573
+ *
574
+ * The radius shrinks everywhere when bevels would overlap on short edges.
575
+ * Edges along an open border stay sharp. Texture coordinates follow each
576
+ * face; materials follow their faces (bevels take a neighbor's).
577
+ *
578
+ * @example
579
+ * const crate = bevelMesh(makeShape("box"), { radius: 0.04, segments: 3 });
580
+ *
581
+ * @param {import("../index.d.ts").Mesh} mesh
582
+ * @param {{ radius?: number, segments?: number, angle?: number, mode?: "edges"|"vertices", edges?: number[][], vertices?: number[] }} [opts]
583
+ * @returns {import("../index.d.ts").Mesh}
584
+ */
585
+ export function bevelMesh(mesh, opts = {}) {
586
+ const mode = opts.mode ?? "edges";
587
+ if (mode !== "edges" && mode !== "vertices") throw new Error(`Unknown bevel mode "${mode}": use "edges" or "vertices"`);
588
+ const B = surface(mesh);
589
+ if (!B) return { vertices: [], faces: [] };
590
+ const segs = Math.max(1, Math.min(16, Math.round(Number.isFinite(opts.segments) ? opts.segments : 3)));
591
+ const angle = Number.isFinite(opts.angle) ? opts.angle : 30;
592
+ const radius = opts.radius > 0 ? opts.radius : opts.radius === 0 ? 0 : B.size * 0.05;
593
+ // Radius 0: nothing to bevel, a cleaned copy
594
+ if (radius === 0) return bevelEdges(B, mesh, 0, 1, 181, []);
595
+ return mode === "edges" ? bevelEdges(B, mesh, radius, segs, angle, opts.edges) : bevelVertices(B, mesh, radius, segs, angle, opts.vertices);
596
+ }