trid3d 0.3.1 → 0.5.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 +18 -7
- package/package.json +1 -1
- package/trid/index.d.ts +565 -11
- package/trid/index.js +18 -1
- 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/subdivide.js +215 -0
- package/trid/modeling/sweep.js +169 -0
- package/trid/modeling/vec.js +35 -0
- package/trid/render/canvasRenderer.js +99 -48
- package/trid/render/effects.js +16 -9
- package/trid/render/grid.js +177 -0
- package/trid/render/lights.js +162 -0
- package/trid/render/post.js +325 -0
- package/trid/render/shapes.js +91 -3
- package/trid/render/webglRenderer.js +341 -19
- package/trid/scene.js +48 -6
- package/trid/tools/helpers.js +107 -9
- package/trid/tools/index.js +1 -1
- package/trid/tools/transformGizmo.js +40 -6
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh cleanup and checks, for meshes written by code (or loaded from
|
|
3
|
+
* files): merge vertices at the same place, drop broken faces, list what's
|
|
4
|
+
* wrong, and turn faces so they all point outward.
|
|
5
|
+
*/
|
|
6
|
+
import { newell } from "../render/normals.js";
|
|
7
|
+
|
|
8
|
+
/** Per-face lists that follow the faces' corners. */
|
|
9
|
+
const CORNER_LISTS = ["faceUVs", "faceNormals"];
|
|
10
|
+
|
|
11
|
+
const finite = (v) => !!v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
|
|
12
|
+
|
|
13
|
+
/** Largest side of the box around the mesh's valid vertices (0 when there are none). */
|
|
14
|
+
function sizeOf(vertices) {
|
|
15
|
+
let lo = Infinity, hi = -Infinity;
|
|
16
|
+
for (const v of vertices) {
|
|
17
|
+
if (!finite(v)) continue;
|
|
18
|
+
lo = Math.min(lo, v.x, v.y, v.z);
|
|
19
|
+
hi = Math.max(hi, v.x, v.y, v.z);
|
|
20
|
+
}
|
|
21
|
+
return hi >= lo ? hi - lo : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* For each vertex, the first vertex within `eps` of it (itself when it's the
|
|
26
|
+
* first there). Uses a grid and looks in the neighboring cells, so close
|
|
27
|
+
* vertices on either side of a cell boundary still meet. Bad vertices stay alone.
|
|
28
|
+
*/
|
|
29
|
+
export function samePlace(vertices, eps) {
|
|
30
|
+
const cells = new Map();
|
|
31
|
+
const cellOf = (x) => Math.floor(x / eps);
|
|
32
|
+
return vertices.map((v, i) => {
|
|
33
|
+
if (!finite(v)) return i;
|
|
34
|
+
const cx = cellOf(v.x), cy = cellOf(v.y), cz = cellOf(v.z);
|
|
35
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
|
|
36
|
+
for (const j of cells.get(`${cx + dx},${cy + dy},${cz + dz}`) ?? []) {
|
|
37
|
+
const w = vertices[j];
|
|
38
|
+
if (Math.abs(w.x - v.x) <= eps && Math.abs(w.y - v.y) <= eps && Math.abs(w.z - v.z) <= eps) return j;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const key = `${cx},${cy},${cz}`;
|
|
42
|
+
const list = cells.get(key);
|
|
43
|
+
if (list) list.push(i);
|
|
44
|
+
else cells.set(key, [i]);
|
|
45
|
+
return i;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const validIndex = (i, n) => Number.isInteger(i) && i >= 0 && i < n;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A face's corners without repeats in a row (and without the last when it
|
|
53
|
+
* repeats the first): `[vertex, corner]` pairs.
|
|
54
|
+
*/
|
|
55
|
+
export function distinctCorners(face) {
|
|
56
|
+
const keep = [];
|
|
57
|
+
face.forEach((v, c) => {
|
|
58
|
+
if (!keep.length || keep[keep.length - 1][0] !== v) keep.push([v, c]);
|
|
59
|
+
});
|
|
60
|
+
while (keep.length > 1 && keep[0][0] === keep[keep.length - 1][0]) keep.pop();
|
|
61
|
+
return keep;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Replaces the faces with `kept` (old face index, new vertex list, which
|
|
66
|
+
* old corners it keeps), keeping texture coordinates, normals and
|
|
67
|
+
* materials in step.
|
|
68
|
+
*/
|
|
69
|
+
function applyFaces(mesh, kept) {
|
|
70
|
+
const old = mesh.faces ?? [];
|
|
71
|
+
for (const key of CORNER_LISTS) {
|
|
72
|
+
if (!mesh[key]) continue;
|
|
73
|
+
const list = mesh[key];
|
|
74
|
+
mesh[key] = kept.map(({ f, corners }) => {
|
|
75
|
+
const c = list[f];
|
|
76
|
+
return Array.isArray(c) && c.length === old[f].length ? corners.map((i) => c[i]) : null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (mesh.faceMaterials) mesh.faceMaterials = kept.map(({ f }) => mesh.faceMaterials[f] ?? 0);
|
|
80
|
+
mesh.faces = kept.map(({ verts }) => verts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Default distance for "the same place": tiny next to the mesh. */
|
|
84
|
+
export const defaultEps = (vertices) => (sizeOf(vertices) || 1) * 1e-7;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merges vertices closer than `epsilon` (default: a ten-millionth of the
|
|
88
|
+
* mesh's size), in place, so faces that touch share their vertices: smooth
|
|
89
|
+
* shading has no seams, and tools that follow neighbors (subdivision,
|
|
90
|
+
* fixWinding) see one surface. Texture coordinates and normals are per face
|
|
91
|
+
* corner, so they're kept as they are. Corners that end up on the same
|
|
92
|
+
* vertex are removed, and faces left with fewer than 3 corners dropped.
|
|
93
|
+
*
|
|
94
|
+
* @template M
|
|
95
|
+
* @param {M} mesh
|
|
96
|
+
* @param {number} [epsilon]
|
|
97
|
+
* @returns {M} The same mesh
|
|
98
|
+
*/
|
|
99
|
+
export function weldVertices(mesh, epsilon) {
|
|
100
|
+
const vs = mesh.vertices ?? [];
|
|
101
|
+
const eps = epsilon > 0 ? epsilon : defaultEps(vs);
|
|
102
|
+
const rep = samePlace(vs, eps);
|
|
103
|
+
const remap = new Array(vs.length);
|
|
104
|
+
const vertices = [];
|
|
105
|
+
vs.forEach((v, i) => {
|
|
106
|
+
remap[i] = rep[i] === i ? vertices.push(v) - 1 : remap[rep[i]];
|
|
107
|
+
});
|
|
108
|
+
const to = (i) => (validIndex(i, vs.length) ? remap[i] : -1);
|
|
109
|
+
|
|
110
|
+
if (mesh.faces) {
|
|
111
|
+
const kept = [];
|
|
112
|
+
mesh.faces.forEach((f, n) => {
|
|
113
|
+
if (!Array.isArray(f)) return;
|
|
114
|
+
const keep = distinctCorners(f.map(to));
|
|
115
|
+
if (keep.length >= 3) kept.push({ f: n, verts: keep.map(([v]) => v), corners: keep.map(([, c]) => c) });
|
|
116
|
+
});
|
|
117
|
+
applyFaces(mesh, kept);
|
|
118
|
+
}
|
|
119
|
+
if (mesh.edges) {
|
|
120
|
+
mesh.edges = mesh.edges.map((e) => distinctCorners(e.map(to)).map(([v]) => v)).filter((e) => e.length >= 2);
|
|
121
|
+
}
|
|
122
|
+
mesh.vertices = vertices;
|
|
123
|
+
mesh.dirty = true;
|
|
124
|
+
return mesh;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Key of a face that's the same whichever corner it starts at (but not reversed). */
|
|
128
|
+
function faceKey(verts) {
|
|
129
|
+
let start = 0;
|
|
130
|
+
for (let i = 1; i < verts.length; i++) if (verts[i] < verts[start]) start = i;
|
|
131
|
+
return verts.slice(start).concat(verts.slice(0, start)).join(",");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fixes what can be fixed, in place, by removing what's broken:
|
|
136
|
+
* - faces pointing to vertices that don't exist or aren't numbers;
|
|
137
|
+
* - corners repeated in a row, faces left with fewer than 3 corners or
|
|
138
|
+
* that cross back on a vertex, faces of no area, and repeated faces;
|
|
139
|
+
* - texture coordinate and normal lists that don't fit their face (set to null);
|
|
140
|
+
* - edges to missing vertices;
|
|
141
|
+
* - vertices nothing uses, texture coordinates and normals nothing uses.
|
|
142
|
+
*
|
|
143
|
+
* With `weld` (true or a distance), vertices at the same place are merged first.
|
|
144
|
+
*
|
|
145
|
+
* @template M
|
|
146
|
+
* @param {M} mesh
|
|
147
|
+
* @param {{ weld?: boolean | number }} [opts]
|
|
148
|
+
* @returns {M} The same mesh
|
|
149
|
+
*/
|
|
150
|
+
export function cleanMesh(mesh, { weld = false } = {}) {
|
|
151
|
+
if (!Array.isArray(mesh.vertices)) mesh.vertices = [];
|
|
152
|
+
if (weld) weldVertices(mesh, typeof weld === "number" ? weld : undefined);
|
|
153
|
+
const vs = mesh.vertices;
|
|
154
|
+
const ok = (i) => validIndex(i, vs.length) && finite(vs[i]);
|
|
155
|
+
const size = sizeOf(vs) || 1;
|
|
156
|
+
const minArea = size * size * 1e-14;
|
|
157
|
+
|
|
158
|
+
if (mesh.faces) {
|
|
159
|
+
const kept = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
mesh.faces.forEach((f, n) => {
|
|
162
|
+
if (!Array.isArray(f) || !f.every(ok)) return;
|
|
163
|
+
const keep = distinctCorners(f);
|
|
164
|
+
const verts = keep.map(([v]) => v);
|
|
165
|
+
if (verts.length < 3 || new Set(verts).size !== verts.length) return;
|
|
166
|
+
const nrm = newell(verts.map((i) => vs[i]));
|
|
167
|
+
if (Math.hypot(nrm.x, nrm.y, nrm.z) / 2 <= minArea) return;
|
|
168
|
+
const key = faceKey(verts);
|
|
169
|
+
if (seen.has(key)) return;
|
|
170
|
+
seen.add(key);
|
|
171
|
+
kept.push({ f: n, verts, corners: keep.map(([, c]) => c) });
|
|
172
|
+
});
|
|
173
|
+
applyFaces(mesh, kept);
|
|
174
|
+
// Corner lists pointing to missing entries are dropped for that face
|
|
175
|
+
for (const [key, values] of [["faceUVs", "uvs"], ["faceNormals", "normals"]]) {
|
|
176
|
+
if (!mesh[key]) continue;
|
|
177
|
+
const count = mesh[values]?.length ?? 0;
|
|
178
|
+
mesh[key] = mesh[key].map((c) => (c && c.every((i) => validIndex(i, count)) ? c : null));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (mesh.edges) {
|
|
182
|
+
mesh.edges = mesh.edges
|
|
183
|
+
.filter((e) => Array.isArray(e) && e.every(ok))
|
|
184
|
+
.map((e) => distinctCorners(e).map(([v]) => v))
|
|
185
|
+
.filter((e) => e.length >= 2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Keep only what's used, in order
|
|
189
|
+
const compact = (count, lists) => {
|
|
190
|
+
const used = new Array(count).fill(false);
|
|
191
|
+
for (const list of lists) for (const item of list ?? []) for (const i of item ?? []) used[i] = true;
|
|
192
|
+
const remap = new Array(count);
|
|
193
|
+
let next = 0;
|
|
194
|
+
for (let i = 0; i < count; i++) remap[i] = used[i] ? next++ : -1;
|
|
195
|
+
return { used, remap, changed: next !== count };
|
|
196
|
+
};
|
|
197
|
+
const v = compact(vs.length, [mesh.faces, mesh.edges]);
|
|
198
|
+
if (v.changed) {
|
|
199
|
+
mesh.vertices = vs.filter((_, i) => v.used[i]);
|
|
200
|
+
if (mesh.faces) mesh.faces = mesh.faces.map((f) => f.map((i) => v.remap[i]));
|
|
201
|
+
if (mesh.edges) mesh.edges = mesh.edges.map((e) => e.map((i) => v.remap[i]));
|
|
202
|
+
}
|
|
203
|
+
for (const [key, values] of [["faceUVs", "uvs"], ["faceNormals", "normals"]]) {
|
|
204
|
+
if (!mesh[values] || !mesh[key]) continue;
|
|
205
|
+
const c = compact(mesh[values].length, [mesh[key]]);
|
|
206
|
+
if (!c.changed) continue;
|
|
207
|
+
mesh[values] = mesh[values].filter((_, i) => c.used[i]);
|
|
208
|
+
mesh[key] = mesh[key].map((f) => (f ? f.map((i) => c.remap[i]) : f));
|
|
209
|
+
}
|
|
210
|
+
mesh.dirty = true;
|
|
211
|
+
return mesh;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The surface's edges, by vertex position (so an unwelded mesh still counts
|
|
216
|
+
* as one surface): for each edge, the faces on it and which way each one
|
|
217
|
+
* goes along it (+1 from the lower to the higher vertex, -1 the other way).
|
|
218
|
+
*/
|
|
219
|
+
function edgeMap(mesh, faceOk) {
|
|
220
|
+
const vs = mesh.vertices;
|
|
221
|
+
const rep = samePlace(vs, defaultEps(vs));
|
|
222
|
+
const edges = new Map();
|
|
223
|
+
(mesh.faces ?? []).forEach((f, n) => {
|
|
224
|
+
if (!faceOk(n)) return;
|
|
225
|
+
for (let c = 0; c < f.length; c++) {
|
|
226
|
+
const a = rep[f[c]], b = rep[f[(c + 1) % f.length]];
|
|
227
|
+
if (a === b) continue;
|
|
228
|
+
const key = a < b ? `${a},${b}` : `${b},${a}`;
|
|
229
|
+
const entry = { face: n, dir: a < b ? 1 : -1 };
|
|
230
|
+
const list = edges.get(key);
|
|
231
|
+
if (list) list.push(entry);
|
|
232
|
+
else edges.set(key, [entry]);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return { edges, rep };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Faces whose corners are all valid vertices. */
|
|
239
|
+
const faceChecker = (mesh) => {
|
|
240
|
+
const vs = mesh.vertices ?? [];
|
|
241
|
+
return (n) => {
|
|
242
|
+
const f = mesh.faces?.[n];
|
|
243
|
+
return Array.isArray(f) && f.length >= 3 && f.every((i) => validIndex(i, vs.length) && finite(vs[i]));
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Signed volume of some faces (> 0: outward), measured from `origin`, with some faces counted reversed. */
|
|
248
|
+
function volumeOf(mesh, faces, flipped, origin) {
|
|
249
|
+
const p = mesh.vertices;
|
|
250
|
+
let vol = 0;
|
|
251
|
+
for (const n of faces) {
|
|
252
|
+
const f = flipped[n] ? [...mesh.faces[n]].reverse() : mesh.faces[n];
|
|
253
|
+
const a = p[f[0]];
|
|
254
|
+
for (let i = 1; i + 1 < f.length; i++) {
|
|
255
|
+
const b = p[f[i]], c = p[f[i + 1]];
|
|
256
|
+
const ax = a.x - origin.x, ay = a.y - origin.y, az = a.z - origin.z;
|
|
257
|
+
const bx = b.x - origin.x, by = b.y - origin.y, bz = b.z - origin.z;
|
|
258
|
+
const cx = c.x - origin.x, cy = c.y - origin.y, cz = c.z - origin.z;
|
|
259
|
+
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return vol / 6;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Pieces of the surface: faces joined by edges shared by exactly two faces,
|
|
267
|
+
* each with the faces it must reverse so that neighbors agree.
|
|
268
|
+
*/
|
|
269
|
+
function orientPieces(mesh) {
|
|
270
|
+
const faceOk = faceChecker(mesh);
|
|
271
|
+
const { edges } = edgeMap(mesh, faceOk);
|
|
272
|
+
// Each face's neighbors across two-face edges, and whether they go the same way on it
|
|
273
|
+
const nb = (mesh.faces ?? []).map(() => []);
|
|
274
|
+
for (const list of edges.values()) {
|
|
275
|
+
if (list.length !== 2 || list[0].face === list[1].face) continue;
|
|
276
|
+
const [p, q] = list;
|
|
277
|
+
const same = p.dir === q.dir;
|
|
278
|
+
nb[p.face].push([q.face, same]);
|
|
279
|
+
nb[q.face].push([p.face, same]);
|
|
280
|
+
}
|
|
281
|
+
const flipped = new Array(nb.length).fill(false);
|
|
282
|
+
const seen = new Array(nb.length).fill(false);
|
|
283
|
+
const pieces = [];
|
|
284
|
+
for (let s = 0; s < nb.length; s++) {
|
|
285
|
+
if (seen[s] || !faceOk(s)) continue;
|
|
286
|
+
const faces = [s];
|
|
287
|
+
seen[s] = true;
|
|
288
|
+
let conflicts = 0;
|
|
289
|
+
for (let k = 0; k < faces.length; k++) {
|
|
290
|
+
const f = faces[k];
|
|
291
|
+
for (const [g, same] of nb[f]) {
|
|
292
|
+
// Neighbors must go opposite ways along their shared edge
|
|
293
|
+
const want = same ? !flipped[f] : flipped[f];
|
|
294
|
+
if (!seen[g]) {
|
|
295
|
+
seen[g] = true;
|
|
296
|
+
flipped[g] = want;
|
|
297
|
+
faces.push(g);
|
|
298
|
+
} else if (flipped[g] !== want) conflicts++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
pieces.push({ faces, conflicts: conflicts / 2 });
|
|
302
|
+
}
|
|
303
|
+
return { pieces, flipped, edges, faceOk };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Is every edge of these faces shared by exactly two of them? */
|
|
307
|
+
function isClosed(mesh, faces, edges) {
|
|
308
|
+
const inPiece = new Set(faces);
|
|
309
|
+
for (const list of edges.values()) {
|
|
310
|
+
const here = list.filter((e) => inPiece.has(e.face));
|
|
311
|
+
if (here.length && here.length !== 2) return false;
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const centerOf = (mesh, faces) => {
|
|
317
|
+
let x = 0, y = 0, z = 0, n = 0;
|
|
318
|
+
for (const f of faces) for (const i of mesh.faces[f]) {
|
|
319
|
+
const v = mesh.vertices[i];
|
|
320
|
+
x += v.x; y += v.y; z += v.z; n++;
|
|
321
|
+
}
|
|
322
|
+
return { x: x / n, y: y / n, z: z / n };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Turns faces around, in place, so that faces next to each other agree,
|
|
327
|
+
* then each piece of the mesh points outward (clockwise from outside, like
|
|
328
|
+
* the built-in shapes). Pieces are found by vertex position, so the mesh
|
|
329
|
+
* doesn't need welding first.
|
|
330
|
+
*
|
|
331
|
+
* For an open piece (a cup, a sheet), "outward" is a guess: away from its
|
|
332
|
+
* middle. A flat piece keeps the way most of its faces already point.
|
|
333
|
+
*
|
|
334
|
+
* @template M
|
|
335
|
+
* @param {M} mesh
|
|
336
|
+
* @returns {M} The same mesh
|
|
337
|
+
*/
|
|
338
|
+
export function fixWinding(mesh) {
|
|
339
|
+
if (!mesh.faces?.length) return mesh;
|
|
340
|
+
const { pieces, flipped } = orientPieces(mesh);
|
|
341
|
+
const size = sizeOf(mesh.vertices) || 1;
|
|
342
|
+
for (const { faces } of pieces) {
|
|
343
|
+
const vol = volumeOf(mesh, faces, flipped, centerOf(mesh, faces));
|
|
344
|
+
const turned = faces.filter((f) => flipped[f]).length;
|
|
345
|
+
// Inside out, or (when it encloses nothing) more faces turned than not: the other way round
|
|
346
|
+
const flat = Math.abs(vol) <= size ** 3 * 1e-9;
|
|
347
|
+
if (flat ? turned * 2 > faces.length : vol < 0) for (const f of faces) flipped[f] = !flipped[f];
|
|
348
|
+
}
|
|
349
|
+
let any = false;
|
|
350
|
+
mesh.faces.forEach((f, n) => {
|
|
351
|
+
if (!flipped[n]) return;
|
|
352
|
+
any = true;
|
|
353
|
+
mesh.faces[n] = [...f].reverse();
|
|
354
|
+
for (const key of CORNER_LISTS) if (mesh[key]?.[n]) mesh[key][n] = [...mesh[key][n]].reverse();
|
|
355
|
+
});
|
|
356
|
+
if (any) mesh.dirty = true;
|
|
357
|
+
return mesh;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Lists what's wrong with a mesh, without changing it. Each entry is one
|
|
362
|
+
* kind of problem, with how bad it is, a short message, and the faces,
|
|
363
|
+
* vertices or edges concerned:
|
|
364
|
+
*
|
|
365
|
+
* - `"error"`: breaks drawing or export (missing vertices, numbers that
|
|
366
|
+
* aren't numbers, lists that don't fit their faces);
|
|
367
|
+
* - `"warning"`: draws wrong (faces of no area, repeated or crossed faces,
|
|
368
|
+
* faces turned the wrong way, inside out, edges shared by 3 faces or more);
|
|
369
|
+
* - `"info"`: worth knowing (open edges, unused vertices, vertices at the same place).
|
|
370
|
+
*
|
|
371
|
+
* An empty list means nothing was found.
|
|
372
|
+
*
|
|
373
|
+
* @param {import("../index.d.ts").Mesh} mesh
|
|
374
|
+
* @returns {{ kind: string, level: "error"|"warning"|"info", message: string, faces?: number[], vertices?: number[], edges?: number[] }[]}
|
|
375
|
+
*/
|
|
376
|
+
export function validateMesh(mesh) {
|
|
377
|
+
const out = [];
|
|
378
|
+
const report = (kind, level, what, items, text) => {
|
|
379
|
+
if (!items.length) return;
|
|
380
|
+
const list = items.slice(0, 5).join(", ") + (items.length > 5 ? ", …" : "");
|
|
381
|
+
out.push({ kind, level, message: `${text(items.length)} (${what} ${list})`, [what]: items });
|
|
382
|
+
};
|
|
383
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
384
|
+
const vs = Array.isArray(mesh?.vertices) ? mesh.vertices : null;
|
|
385
|
+
if (!vs) return [{ kind: "no-vertices", level: "error", message: "The mesh has no vertices list" }];
|
|
386
|
+
const faces = Array.isArray(mesh.faces) ? mesh.faces : [];
|
|
387
|
+
|
|
388
|
+
const badVertex = [];
|
|
389
|
+
vs.forEach((v, i) => { if (!finite(v)) badVertex.push(i); });
|
|
390
|
+
report("bad-vertex", "error", "vertices", badVertex, (n) => `${plural(n, "vertex", "vertices")} with a position that isn't 3 numbers`);
|
|
391
|
+
|
|
392
|
+
const badIndex = [], fewCorners = [], crossed = [], noArea = [], repeated = [];
|
|
393
|
+
const seen = new Map();
|
|
394
|
+
const size = sizeOf(vs) || 1;
|
|
395
|
+
faces.forEach((f, n) => {
|
|
396
|
+
if (!Array.isArray(f) || !f.every((i) => validIndex(i, vs.length))) return badIndex.push(n);
|
|
397
|
+
if (!f.every((i) => finite(vs[i]))) return;
|
|
398
|
+
const verts = distinctCorners(f).map(([v]) => v);
|
|
399
|
+
if (verts.length < 3) return fewCorners.push(n);
|
|
400
|
+
if (new Set(verts).size !== verts.length) return crossed.push(n);
|
|
401
|
+
const nrm = newell(verts.map((i) => vs[i]));
|
|
402
|
+
if (Math.hypot(nrm.x, nrm.y, nrm.z) / 2 <= size * size * 1e-14) noArea.push(n);
|
|
403
|
+
const key = faceKey(verts);
|
|
404
|
+
if (seen.has(key)) repeated.push(n);
|
|
405
|
+
else seen.set(key, n);
|
|
406
|
+
});
|
|
407
|
+
report("bad-index", "error", "faces", badIndex, (n) => `${plural(n, "face")} pointing to vertices that don't exist`);
|
|
408
|
+
report("few-corners", "warning", "faces", fewCorners, (n) => `${plural(n, "face")} with fewer than 3 different corners`);
|
|
409
|
+
report("crossed-face", "warning", "faces", crossed, (n) => `${plural(n, "face")} going through the same vertex twice`);
|
|
410
|
+
report("no-area", "warning", "faces", noArea, (n) => `${plural(n, "face")} of no area`);
|
|
411
|
+
report("repeated-face", "warning", "faces", repeated, (n) => `${plural(n, "face")} repeating another face`);
|
|
412
|
+
|
|
413
|
+
for (const [key, values, label] of [["faceUVs", "uvs", "texture coordinates"], ["faceNormals", "normals", "normals"]]) {
|
|
414
|
+
if (!mesh[key]) continue;
|
|
415
|
+
const count = mesh[values]?.length ?? 0;
|
|
416
|
+
const bad = [];
|
|
417
|
+
faces.forEach((f, n) => {
|
|
418
|
+
const c = mesh[key][n];
|
|
419
|
+
if (c == null) return;
|
|
420
|
+
if (!Array.isArray(c) || !Array.isArray(f) || c.length !== f.length || !c.every((i) => validIndex(i, count))) bad.push(n);
|
|
421
|
+
});
|
|
422
|
+
if (mesh[key].length !== faces.length) {
|
|
423
|
+
out.push({ kind: `bad-${key}`, level: "error", message: `${key} has ${mesh[key].length} entries for ${faces.length} faces` });
|
|
424
|
+
}
|
|
425
|
+
report(`bad-${key}`, "error", "faces", bad, (n) => `${plural(n, "face")} whose ${label} don't fit (wrong count or missing entries)`);
|
|
426
|
+
}
|
|
427
|
+
if (mesh.faceMaterials) {
|
|
428
|
+
const bad = [];
|
|
429
|
+
faces.forEach((_, n) => { if (!Number.isInteger(mesh.faceMaterials[n]) || mesh.faceMaterials[n] < 0) bad.push(n); });
|
|
430
|
+
report("bad-material", "warning", "faces", bad, (n) => `${plural(n, "face")} without a valid material number`);
|
|
431
|
+
}
|
|
432
|
+
if (mesh.edges) {
|
|
433
|
+
const bad = [];
|
|
434
|
+
mesh.edges.forEach((e, n) => { if (!Array.isArray(e) || e.length < 2 || !e.every((i) => validIndex(i, vs.length))) bad.push(n); });
|
|
435
|
+
report("bad-edge", "error", "edges", bad, (n) => `${plural(n, "edge")} pointing to vertices that don't exist`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Shape of the surface (faces with valid corners only)
|
|
439
|
+
const { pieces, edges, flipped } = orientPieces(mesh);
|
|
440
|
+
const open = [], many = [];
|
|
441
|
+
const turned = [];
|
|
442
|
+
for (const list of edges.values()) {
|
|
443
|
+
if (list.length === 1) open.push(list[0].face);
|
|
444
|
+
else if (list.length > 2) many.push(...list.map((e) => e.face));
|
|
445
|
+
}
|
|
446
|
+
report("open-edge", "info", "faces", [...new Set(open)].sort((a, b) => a - b), (n) => `${plural(n, "face")} on an open border (fine for sheets, holes in a solid)`);
|
|
447
|
+
report("many-faces-edge", "warning", "faces", [...new Set(many)].sort((a, b) => a - b), (n) => `${plural(n, "face")} on edges shared by 3 faces or more`);
|
|
448
|
+
const insideOut = [];
|
|
449
|
+
for (const piece of pieces) {
|
|
450
|
+
const flips = piece.faces.filter((f) => flipped[f]);
|
|
451
|
+
// The smaller group is the one turned the wrong way
|
|
452
|
+
const wrong = flips.length * 2 > piece.faces.length ? piece.faces.filter((f) => !flipped[f]) : flips;
|
|
453
|
+
turned.push(...wrong);
|
|
454
|
+
if (!wrong.length && piece.faces.length > 1 && isClosed(mesh, piece.faces, edges) && volumeOf(mesh, piece.faces, flipped, centerOf(mesh, piece.faces)) < 0) {
|
|
455
|
+
insideOut.push(...piece.faces);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
report("turned-face", "warning", "faces", turned.sort((a, b) => a - b), (n) => `${plural(n, "face")} turned the other way from their neighbors (fixWinding fixes it)`);
|
|
459
|
+
report("inside-out", "warning", "faces", insideOut.sort((a, b) => a - b), (n) => `${plural(n, "face")} of a closed part that's inside out (fixWinding fixes it)`);
|
|
460
|
+
|
|
461
|
+
const used = new Array(vs.length).fill(false);
|
|
462
|
+
for (const list of [faces, mesh.edges ?? []]) for (const f of list) if (Array.isArray(f)) for (const i of f) if (validIndex(i, vs.length)) used[i] = true;
|
|
463
|
+
const unused = [];
|
|
464
|
+
used.forEach((u, i) => { if (!u) unused.push(i); });
|
|
465
|
+
report("unused-vertex", "info", "vertices", unused, (n) => `${plural(n, "vertex", "vertices")} no face or edge uses`);
|
|
466
|
+
const rep = samePlace(vs, defaultEps(vs));
|
|
467
|
+
const dup = [];
|
|
468
|
+
rep.forEach((r, i) => { if (r !== i) dup.push(i); });
|
|
469
|
+
report("same-place", "info", "vertices", dup, (n) => `${plural(n, "vertex", "vertices")} at the same place as another (weldVertices merges them)`);
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smooth curves through a few points: paths for sweeps, profiles for lathe,
|
|
3
|
+
* outlines for extrude. Points can be 2D ({ x, y }), 3D ({ x, y, z }) or
|
|
4
|
+
* lathe profile points ({ r, y }): every number field of the first point is
|
|
5
|
+
* used, and the result has the same fields.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The number fields of a point (the curve's coordinates). */
|
|
9
|
+
function keysOf(p) {
|
|
10
|
+
return p ? Object.keys(p).filter((k) => typeof p[k] === "number") : [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dist = (a, b) => {
|
|
14
|
+
let s = 0;
|
|
15
|
+
for (let i = 0; i < a.length; i++) s += (a[i] - b[i]) ** 2;
|
|
16
|
+
return Math.sqrt(s);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Points as arrays of numbers, without repeats in a row (and without the closing repeat of a loop). */
|
|
20
|
+
function toArrays(points, keys, closed) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const p of points) {
|
|
23
|
+
const a = keys.map((k) => p[k]);
|
|
24
|
+
if (a.some((x) => !Number.isFinite(x))) continue;
|
|
25
|
+
if (!out.length || dist(a, out[out.length - 1]) > 1e-12) out.push(a);
|
|
26
|
+
}
|
|
27
|
+
if (closed && out.length > 2 && dist(out[0], out[out.length - 1]) <= 1e-12) out.pop();
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const toPoint = (a, keys) => Object.fromEntries(keys.map((k, i) => [k, a[i]]));
|
|
32
|
+
|
|
33
|
+
/** Total length of a polyline (with the closing side for loops). */
|
|
34
|
+
function lengths(pts, closed) {
|
|
35
|
+
const along = [0];
|
|
36
|
+
const n = pts.length;
|
|
37
|
+
const sides = closed ? n : n - 1;
|
|
38
|
+
for (let i = 0; i < sides; i++) along.push(along[i] + dist(pts[i], pts[(i + 1) % n]));
|
|
39
|
+
return along;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Evenly spaced points (by length) along a polyline of number arrays. */
|
|
43
|
+
function evenly(pts, closed, count) {
|
|
44
|
+
const n = pts.length;
|
|
45
|
+
const along = lengths(pts, closed);
|
|
46
|
+
const total = along[along.length - 1];
|
|
47
|
+
if (total <= 0) return [pts[0].slice()];
|
|
48
|
+
const out = [];
|
|
49
|
+
const steps = closed ? count : count - 1;
|
|
50
|
+
let side = 0;
|
|
51
|
+
for (let k = 0; k < count; k++) {
|
|
52
|
+
const d = (k / steps) * total;
|
|
53
|
+
while (side < along.length - 2 && along[side + 1] < d) side++;
|
|
54
|
+
const a = pts[side], b = pts[(side + 1) % n];
|
|
55
|
+
const t = (d - along[side]) / (along[side + 1] - along[side] || 1);
|
|
56
|
+
out.push(a.map((x, i) => x + (b[i] - x) * t));
|
|
57
|
+
}
|
|
58
|
+
if (!closed) out[out.length - 1] = pts[n - 1].slice();
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many evenly spaced points `count` / `spacing` ask for, or 0 to keep the points as they are. */
|
|
63
|
+
function wanted(pts, closed, count, spacing) {
|
|
64
|
+
if (count != null) return Math.max(closed ? 3 : 2, Math.round(count));
|
|
65
|
+
if (spacing > 0) {
|
|
66
|
+
const along = lengths(pts, closed);
|
|
67
|
+
const sides = Math.max(1, Math.round(along[along.length - 1] / spacing));
|
|
68
|
+
return Math.max(closed ? 3 : 2, closed ? sides : sides + 1);
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Points along a polyline, evenly spaced by length: give `count` (number
|
|
75
|
+
* of points) or `spacing` (distance between them). The ends stay where they
|
|
76
|
+
* are. Corners are kept as corners only if a point falls on them; to keep
|
|
77
|
+
* a shape smooth, use `spline` instead.
|
|
78
|
+
*
|
|
79
|
+
* @template {Record<string, number>} P
|
|
80
|
+
* @param {P[]} points
|
|
81
|
+
* @param {{ count?: number, spacing?: number, closed?: boolean }} [opts]
|
|
82
|
+
* @returns {P[]} New points, with the same number fields
|
|
83
|
+
*/
|
|
84
|
+
export function resamplePath(points, { count, spacing, closed = false } = {}) {
|
|
85
|
+
const keys = keysOf(points?.[0]);
|
|
86
|
+
const pts = toArrays(points ?? [], keys, closed);
|
|
87
|
+
if (pts.length < 2) return pts.map((a) => toPoint(a, keys));
|
|
88
|
+
const loop = closed && pts.length > 2;
|
|
89
|
+
const n = wanted(pts, loop, count, spacing);
|
|
90
|
+
return (n ? evenly(pts, loop, n) : pts).map((a) => toPoint(a, keys));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A smooth curve through `points` (Catmull-Rom spline: the curve passes
|
|
95
|
+
* through every point). With the default `alpha` (0.5, "centripetal") it
|
|
96
|
+
* never loops or overshoots at sharp turns.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* // A tail path from 4 points, 40 points evenly spaced along it
|
|
100
|
+
* const path = spline([{ x: 0, y: 0, z: 0 }, { x: 0.2, y: 0.3, z: -0.4 }, { x: 0, y: 0.6, z: -0.7 }, { x: -0.2, y: 0.5, z: -1 }], { count: 40 });
|
|
101
|
+
*
|
|
102
|
+
* @template {Record<string, number>} P
|
|
103
|
+
* @param {P[]} points Control points (2D, 3D or `{ r, y }`)
|
|
104
|
+
* @param {object} [opts]
|
|
105
|
+
* @param {number} [opts.segments=8] Points made between two control points
|
|
106
|
+
* @param {boolean} [opts.closed=false] The last point joins the first (a loop); the result does not repeat the first point
|
|
107
|
+
* @param {number} [opts.alpha=0.5] 0 = uniform, 0.5 = centripetal (no loops), 1 = chordal (hugs the points)
|
|
108
|
+
* @param {number} [opts.tension=0] 0 = smooth, 1 = straight lines between the points
|
|
109
|
+
* @param {number} [opts.count] Instead of `segments`: this many points in all, evenly spaced by length
|
|
110
|
+
* @param {number} [opts.spacing] Instead of `segments`: points about this far apart, evenly spaced
|
|
111
|
+
* @returns {P[]} New points, with the same number fields
|
|
112
|
+
*/
|
|
113
|
+
export function spline(points, { segments = 8, closed = false, alpha = 0.5, tension = 0, count, spacing } = {}) {
|
|
114
|
+
const keys = keysOf(points?.[0]);
|
|
115
|
+
const pts = toArrays(points ?? [], keys, closed);
|
|
116
|
+
const n = pts.length;
|
|
117
|
+
if (n < 2) return pts.map((a) => toPoint(a, keys));
|
|
118
|
+
const loop = closed && n > 2;
|
|
119
|
+
const even = count != null || spacing > 0;
|
|
120
|
+
// Evenly spaced points are taken from a fine curve
|
|
121
|
+
const steps = Math.max(1, Math.floor(even ? Math.max(32, segments) : segments));
|
|
122
|
+
const tight = 1 - Math.min(1, Math.max(0, tension));
|
|
123
|
+
const dims = keys.length;
|
|
124
|
+
|
|
125
|
+
// Past the ends of an open curve: the next point mirrored through the end point
|
|
126
|
+
const at = (i) => {
|
|
127
|
+
if (loop) return pts[((i % n) + n) % n];
|
|
128
|
+
if (i < 0) return pts[0].map((x, d) => 2 * x - pts[1][d]);
|
|
129
|
+
if (i >= n) return pts[n - 1].map((x, d) => 2 * x - pts[n - 2][d]);
|
|
130
|
+
return pts[i];
|
|
131
|
+
};
|
|
132
|
+
// Parameter step between two points (never 0)
|
|
133
|
+
const knot = (a, b) => Math.max(1e-9, dist(a, b) ** alpha);
|
|
134
|
+
|
|
135
|
+
const curve = [];
|
|
136
|
+
const spans = loop ? n : n - 1;
|
|
137
|
+
for (let i = 0; i < spans; i++) {
|
|
138
|
+
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
139
|
+
const d0 = knot(p0, p1), d1 = knot(p1, p2), d2 = knot(p2, p3);
|
|
140
|
+
// Tangents at p1 and p2 (non-uniform Catmull-Rom), for t from 0 to 1 over this span
|
|
141
|
+
const m1 = [], m2 = [];
|
|
142
|
+
for (let d = 0; d < dims; d++) {
|
|
143
|
+
m1.push(tight * d1 * ((p1[d] - p0[d]) / d0 - (p2[d] - p0[d]) / (d0 + d1) + (p2[d] - p1[d]) / d1));
|
|
144
|
+
m2.push(tight * d1 * ((p2[d] - p1[d]) / d1 - (p3[d] - p1[d]) / (d1 + d2) + (p3[d] - p2[d]) / d2));
|
|
145
|
+
}
|
|
146
|
+
for (let s = 0; s < steps; s++) {
|
|
147
|
+
const t = s / steps, t2 = t * t, t3 = t2 * t;
|
|
148
|
+
const h00 = 2 * t3 - 3 * t2 + 1, h10 = t3 - 2 * t2 + t, h01 = -2 * t3 + 3 * t2, h11 = t3 - t2;
|
|
149
|
+
curve.push(p1.map((x, d) => h00 * x + h10 * m1[d] + h01 * p2[d] + h11 * m2[d]));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!loop) curve.push(pts[n - 1].slice());
|
|
153
|
+
|
|
154
|
+
const out = even ? evenly(curve, loop, wanted(curve, loop, count, spacing)) : curve;
|
|
155
|
+
return out.map((a) => toPoint(a, keys));
|
|
156
|
+
}
|