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.
- package/README.md +29 -12
- package/package.json +1 -1
- package/trid/index.d.ts +423 -8
- package/trid/index.js +13 -0
- package/trid/math/projection.js +8 -2
- package/trid/math/transform.js +4 -5
- package/trid/modeling/bevel.js +596 -0
- package/trid/modeling/blobs.js +465 -0
- package/trid/modeling/cleanup.js +471 -0
- package/trid/modeling/curves.js +156 -0
- package/trid/modeling/deform.js +191 -0
- package/trid/modeling/frames.js +97 -0
- package/trid/modeling/loft.js +363 -0
- package/trid/modeling/query.js +706 -0
- package/trid/modeling/subdivide.js +215 -0
- package/trid/modeling/sweep.js +169 -0
- package/trid/modeling/vec.js +35 -0
- package/trid/render/canvasRenderer.js +1 -1
- package/trid/render/draw.js +12 -2
- package/trid/render/post.js +1 -1
- package/trid/render/shapes.js +133 -26
- package/trid/render/webglRenderer.js +1 -1
- package/trid/scene.js +1 -1
|
@@ -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
|
+
}
|