@zkmake/three-audit 0.2.1 → 0.3.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 +110 -14
- package/dist/audit-scene-CJG1DgRF.js +725 -0
- package/dist/audit-scene-CJG1DgRF.js.map +1 -0
- package/dist/cli.js +11 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +184 -24
- package/dist/index.js +459 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/audit-scene-D5ZaENGZ.js +0 -425
- package/dist/audit-scene-D5ZaENGZ.js.map +0 -1
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
//#region src/scene.ts
|
|
2
|
+
/** The object as three's own type, for the walk. */
|
|
3
|
+
const asObject3D = (object) => object;
|
|
4
|
+
/** Depth-first, in child order. A skipped (or, with `visibleOnly`, hidden) object prunes its subtree. */
|
|
5
|
+
const walk = (root, options, visit) => {
|
|
6
|
+
const stack = [root];
|
|
7
|
+
while (stack.length > 0) {
|
|
8
|
+
const object = stack.pop();
|
|
9
|
+
if (options.visibleOnly === true && !object.visible || options.skip?.(object) === true) continue;
|
|
10
|
+
visit(object);
|
|
11
|
+
for (let i = object.children.length - 1; i >= 0; i -= 1) stack.push(object.children[i]);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const isMesh = (object) => object.isMesh === true;
|
|
15
|
+
/** Anything the renderer draws: a mesh, line, point cloud or sprite. */
|
|
16
|
+
const isDrawable = (object) => {
|
|
17
|
+
const flags = object;
|
|
18
|
+
return flags.isMesh === true || flags.isLine === true || flags.isPoints === true || flags.isSprite === true;
|
|
19
|
+
};
|
|
20
|
+
const isInstanced = (mesh) => mesh.isInstancedMesh === true;
|
|
21
|
+
const isBatched = (mesh) => mesh.isBatchedMesh === true;
|
|
22
|
+
/** Triangles one draw of this geometry makes: indexed or not, within its draw range. */
|
|
23
|
+
const triangleCount = (geometry) => {
|
|
24
|
+
const elements = geometry.index?.count ?? geometry.attributes.position?.count ?? 0;
|
|
25
|
+
const { start, count } = geometry.drawRange;
|
|
26
|
+
const drawn = Math.min(elements, start + count) - start;
|
|
27
|
+
return Math.max(0, Math.floor(drawn / 3));
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A batch's visible instances by the geometry each draws: how many, and that geometry's triangles
|
|
31
|
+
* (its range in the shared buffer). In first-drawn order.
|
|
32
|
+
*/
|
|
33
|
+
const batchedGeometries = (batch) => {
|
|
34
|
+
const geometries = /* @__PURE__ */ new Map();
|
|
35
|
+
for (let id = 0; id < batch.maxInstanceCount; id += 1) {
|
|
36
|
+
let geometryId;
|
|
37
|
+
try {
|
|
38
|
+
if (!batch.getVisibleAt(id)) continue;
|
|
39
|
+
geometryId = batch.getGeometryIdAt(id);
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const known = geometries.get(geometryId);
|
|
44
|
+
if (known) {
|
|
45
|
+
known.instances += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const range = batch.getGeometryRangeAt(geometryId);
|
|
49
|
+
const elements = range.count ?? (batch.geometry.index ? range.indexCount : range.vertexCount);
|
|
50
|
+
geometries.set(geometryId, {
|
|
51
|
+
instances: 1,
|
|
52
|
+
triangles: Math.floor(elements / 3)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return geometries;
|
|
56
|
+
};
|
|
57
|
+
/** Triangles a batch draws across its visible instances, and how many instances that is. */
|
|
58
|
+
const batchedTriangles = (batch) => {
|
|
59
|
+
let triangles = 0;
|
|
60
|
+
let instances = 0;
|
|
61
|
+
for (const geometry of batchedGeometries(batch).values()) {
|
|
62
|
+
triangles += geometry.triangles * geometry.instances;
|
|
63
|
+
instances += geometry.instances;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
instances,
|
|
67
|
+
triangles
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/** The tag of the nearest tagged ancestor (the object itself included), or `null`. */
|
|
71
|
+
const tagOf = (object, tagKey) => {
|
|
72
|
+
for (let at = object; at; at = at.parent) {
|
|
73
|
+
const tag = at.userData[tagKey];
|
|
74
|
+
if (tag !== void 0 && tag !== null) return String(tag);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* A skip that keeps only what's tagged `tag`: prunes objects tagged otherwise, and meshes outside
|
|
80
|
+
* any object tagged `tag`. Throws when nothing under `root` carries the tag.
|
|
81
|
+
*/
|
|
82
|
+
const onlyTag = (root, tag, tagKey, skip) => {
|
|
83
|
+
let found = false;
|
|
84
|
+
walk(root, { skip }, (object) => {
|
|
85
|
+
const own = object.userData[tagKey];
|
|
86
|
+
found ||= own !== void 0 && own !== null && String(own) === tag;
|
|
87
|
+
});
|
|
88
|
+
if (!found) throw new Error(`three-audit: nothing is tagged userData.${tagKey} = "${tag}"`);
|
|
89
|
+
return (object) => {
|
|
90
|
+
if (skip?.(object) === true) return true;
|
|
91
|
+
const own = object.userData[tagKey];
|
|
92
|
+
if (own !== void 0 && own !== null && String(own) !== tag) return true;
|
|
93
|
+
return isDrawable(object) && tagOf(object, tagKey) !== tag;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const hasTag = (root, tagKey, skip) => {
|
|
97
|
+
let found = false;
|
|
98
|
+
walk(root, { skip }, (object) => {
|
|
99
|
+
found ||= object.userData[tagKey] !== void 0 && object.userData[tagKey] !== null;
|
|
100
|
+
});
|
|
101
|
+
return found;
|
|
102
|
+
};
|
|
103
|
+
const meshName = (mesh) => mesh.name || mesh.geometry.name || mesh.geometry.type;
|
|
104
|
+
/**
|
|
105
|
+
* The named ancestors above an object, outermost first, from `root` (included when named) down:
|
|
106
|
+
* at most the nearest `depth`, with `…` for the rest. An unnamed ancestor carrying `tagKey` goes
|
|
107
|
+
* by its tag.
|
|
108
|
+
*/
|
|
109
|
+
const pathOf = (object, root, tagKey, depth = 3) => {
|
|
110
|
+
const names = [];
|
|
111
|
+
for (let at = object.parent; at; at = at === root ? null : at.parent) {
|
|
112
|
+
const tag = tagKey === void 0 ? void 0 : at.userData[tagKey];
|
|
113
|
+
const name = at.name || (tag === void 0 || tag === null ? "" : String(tag));
|
|
114
|
+
if (name) names.unshift(name);
|
|
115
|
+
}
|
|
116
|
+
return names.length > depth ? ["…", ...names.slice(-depth)] : names;
|
|
117
|
+
};
|
|
118
|
+
/** `path/to/mesh [material]`: enough to find a mesh in the scene without walking it by hand. */
|
|
119
|
+
const meshLabel = (mesh, root, tagKey) => `${[...pathOf(mesh, root, tagKey), meshName(mesh)].join("/")} [${materialName(mesh)}]`;
|
|
120
|
+
const materialName = (mesh) => {
|
|
121
|
+
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
|
122
|
+
return material ? material.name || material.type : "none";
|
|
123
|
+
};
|
|
124
|
+
/** Adds a property JSON and `console.table` leave out: a live object that would serialise the scene. */
|
|
125
|
+
const withHidden = (row, key, value) => Object.defineProperty(row, key, {
|
|
126
|
+
value,
|
|
127
|
+
enumerable: false
|
|
128
|
+
});
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/bad-geometry.ts
|
|
131
|
+
const corner = (geometry, k) => geometry.index?.getX(k) ?? k;
|
|
132
|
+
const check = (geometry) => {
|
|
133
|
+
const position = geometry.attributes.position;
|
|
134
|
+
const normal = geometry.attributes.normal;
|
|
135
|
+
const flagged = new Uint8Array(position.count);
|
|
136
|
+
let nonFinite = 0;
|
|
137
|
+
let zeroNormals = 0;
|
|
138
|
+
for (let v = 0; v < position.count; v += 1) if (!(Number.isFinite(position.getX(v)) && Number.isFinite(position.getY(v)) && Number.isFinite(position.getZ(v)) && (!normal || Number.isFinite(normal.getX(v)) && Number.isFinite(normal.getY(v)) && Number.isFinite(normal.getZ(v))))) {
|
|
139
|
+
flagged[v] = 1;
|
|
140
|
+
nonFinite += 1;
|
|
141
|
+
}
|
|
142
|
+
if (!normal) return {
|
|
143
|
+
nonFinite,
|
|
144
|
+
zeroNormals
|
|
145
|
+
};
|
|
146
|
+
const corners = geometry.index?.count ?? position.count;
|
|
147
|
+
for (let k = 0; k + 2 < corners; k += 3) {
|
|
148
|
+
const a = corner(geometry, k);
|
|
149
|
+
const b = corner(geometry, k + 1);
|
|
150
|
+
const c = corner(geometry, k + 2);
|
|
151
|
+
const ux = position.getX(b) - position.getX(a);
|
|
152
|
+
const uy = position.getY(b) - position.getY(a);
|
|
153
|
+
const uz = position.getZ(b) - position.getZ(a);
|
|
154
|
+
const vx = position.getX(c) - position.getX(a);
|
|
155
|
+
const vy = position.getY(c) - position.getY(a);
|
|
156
|
+
const vz = position.getZ(c) - position.getZ(a);
|
|
157
|
+
if (!(Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) > 1e-8)) continue;
|
|
158
|
+
for (const v of [
|
|
159
|
+
a,
|
|
160
|
+
b,
|
|
161
|
+
c
|
|
162
|
+
]) if (flagged[v] === 0 && Math.hypot(normal.getX(v), normal.getY(v), normal.getZ(v)) < 1e-4) {
|
|
163
|
+
flagged[v] = 1;
|
|
164
|
+
zeroNormals += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
nonFinite,
|
|
169
|
+
zeroNormals
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
/** Meshes with geometry that renders NaN, hidden ones included. `[]` is a pass. */
|
|
173
|
+
const findBadGeometry = (root, options = {}) => {
|
|
174
|
+
const tagKey = options.tagKey ?? "studioObject";
|
|
175
|
+
const rows = [];
|
|
176
|
+
walk(asObject3D(root), { skip: options.skip }, (object) => {
|
|
177
|
+
if (!isMesh(object) || !object.geometry.attributes.position) return;
|
|
178
|
+
const { nonFinite, zeroNormals } = check(object.geometry);
|
|
179
|
+
if (nonFinite > 0 || zeroNormals > 0) {
|
|
180
|
+
const under = tagOf(object, tagKey) ?? object.parent?.name ?? "";
|
|
181
|
+
rows.push(withHidden({
|
|
182
|
+
mesh: meshName(object),
|
|
183
|
+
under,
|
|
184
|
+
nonFinite,
|
|
185
|
+
zeroNormals
|
|
186
|
+
}, "object", object));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return rows;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Meshes with no vertices, hidden ones included: what a merge of nothing leaves behind. They draw
|
|
193
|
+
* nothing, but each one is still a draw call and a leak to dispose. A draw range of zero doesn't
|
|
194
|
+
* count: that's usually a pool waiting to fill.
|
|
195
|
+
*/
|
|
196
|
+
const findEmptyMeshes = (root, options = {}) => {
|
|
197
|
+
const scene = asObject3D(root);
|
|
198
|
+
const rows = [];
|
|
199
|
+
walk(scene, { skip: options.skip }, (object) => {
|
|
200
|
+
if (isMesh(object) && (object.geometry.attributes.position?.count ?? 0) === 0) rows.push(withHidden({ mesh: meshLabel(object, scene, options.tagKey ?? "studioObject") }, "object", object));
|
|
201
|
+
});
|
|
202
|
+
return rows;
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/triangles.ts
|
|
206
|
+
const cost = (mesh) => {
|
|
207
|
+
if (isBatched(mesh)) return batchedTriangles(mesh);
|
|
208
|
+
const instances = isInstanced(mesh) ? mesh.count : 1;
|
|
209
|
+
return {
|
|
210
|
+
instances,
|
|
211
|
+
triangles: triangleCount(mesh.geometry) * instances
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Triangles one pass draws: visible meshes, instances counted, no frustum culling. A shadow pass
|
|
216
|
+
* adds roughly as much again for each shadow-casting light.
|
|
217
|
+
*/
|
|
218
|
+
const countTriangles = (root, options = {}) => {
|
|
219
|
+
let total = 0;
|
|
220
|
+
walk(asObject3D(root), {
|
|
221
|
+
skip: options.skip,
|
|
222
|
+
visibleOnly: true
|
|
223
|
+
}, (object) => {
|
|
224
|
+
if (isMesh(object)) total += cost(object).triangles;
|
|
225
|
+
});
|
|
226
|
+
return total;
|
|
227
|
+
};
|
|
228
|
+
/** Every visible mesh, most triangles drawn first. */
|
|
229
|
+
const listMeshes = (scene, options = {}) => {
|
|
230
|
+
const root = asObject3D(scene);
|
|
231
|
+
const rows = [];
|
|
232
|
+
walk(root, {
|
|
233
|
+
skip: options.skip,
|
|
234
|
+
visibleOnly: true
|
|
235
|
+
}, (object) => {
|
|
236
|
+
if (!isMesh(object)) return;
|
|
237
|
+
const { instances, triangles } = cost(object);
|
|
238
|
+
const row = {
|
|
239
|
+
name: [...pathOf(object, root), meshName(object)].join("/"),
|
|
240
|
+
material: materialName(object),
|
|
241
|
+
triangles: instances > 0 ? Math.round(triangles / instances) : 0,
|
|
242
|
+
instances,
|
|
243
|
+
total: triangles
|
|
244
|
+
};
|
|
245
|
+
rows.push(withHidden(row, "mesh", object));
|
|
246
|
+
});
|
|
247
|
+
return rows.sort((a, b) => b.total - a.total);
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Geometries by triangles drawn, hidden meshes included, most first. Name geometries
|
|
251
|
+
* (`geometry.name`) to make this readable. A batch counts each geometry in it separately, by its
|
|
252
|
+
* visible instances: the row a budget is really spent on.
|
|
253
|
+
*/
|
|
254
|
+
const geometryCensus = (root, options = {}) => {
|
|
255
|
+
const scene = asObject3D(root);
|
|
256
|
+
const rows = /* @__PURE__ */ new Map();
|
|
257
|
+
const add = (key, triangles, uses) => {
|
|
258
|
+
const row = rows.get(key) ?? {
|
|
259
|
+
geometry: key,
|
|
260
|
+
uses: 0,
|
|
261
|
+
triangles,
|
|
262
|
+
total: 0,
|
|
263
|
+
share: 0
|
|
264
|
+
};
|
|
265
|
+
row.uses += uses;
|
|
266
|
+
row.total += triangles * uses;
|
|
267
|
+
rows.set(key, row);
|
|
268
|
+
};
|
|
269
|
+
walk(scene, { skip: options.skip }, (object) => {
|
|
270
|
+
if (!isMesh(object) || !object.geometry.attributes.position) return;
|
|
271
|
+
if (isBatched(object)) {
|
|
272
|
+
const name = [...pathOf(object, scene), meshName(object)].join("/");
|
|
273
|
+
for (const [id, { instances, triangles }] of batchedGeometries(object)) add(options.label?.(object, id) ?? `${name}#${id}`, triangles, instances);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const geometry = object.geometry;
|
|
277
|
+
add(geometry.name || `${geometry.type}:${geometry.uuid.slice(0, 6)}`, triangleCount(geometry), isInstanced(object) ? object.count : 1);
|
|
278
|
+
});
|
|
279
|
+
const all = [...rows.values()];
|
|
280
|
+
const sum = all.reduce((total, row) => total + row.total, 0);
|
|
281
|
+
for (const row of all) {
|
|
282
|
+
row.share = sum > 0 ? Math.round(row.total / sum * 1e3) / 1e3 : 0;
|
|
283
|
+
if (options.budget !== void 0) row.overBudget = row.total / Math.max(sum, 1) > options.budget;
|
|
284
|
+
}
|
|
285
|
+
return all.sort((a, b) => b.total - a.total);
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Draw calls one main pass makes: one per visible mesh, line or point cloud, or one per material
|
|
289
|
+
* group for a mesh with several materials. Before frustum culling; each shadow-casting light adds
|
|
290
|
+
* one per caster. `recordDrawLedger` counts the real thing.
|
|
291
|
+
*/
|
|
292
|
+
const countDraws = (root, options = {}) => {
|
|
293
|
+
let draws = 0;
|
|
294
|
+
walk(asObject3D(root), {
|
|
295
|
+
skip: options.skip,
|
|
296
|
+
visibleOnly: true
|
|
297
|
+
}, (object) => {
|
|
298
|
+
if (!isDrawable(object)) return;
|
|
299
|
+
const drawable = object;
|
|
300
|
+
const material = drawable.material;
|
|
301
|
+
if (Array.isArray(material)) {
|
|
302
|
+
const groups = drawable.geometry?.groups ?? [];
|
|
303
|
+
draws += groups.filter((group) => material[group.materialIndex ?? 0]?.visible !== false).length;
|
|
304
|
+
} else if (material?.visible !== false) draws += 1;
|
|
305
|
+
});
|
|
306
|
+
return draws;
|
|
307
|
+
};
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/z-fighting.ts
|
|
310
|
+
/** Normals quantised to 1/40: neighbouring buckets are searched, so this only sets bucket size. */
|
|
311
|
+
const QUANT = 40;
|
|
312
|
+
/** Normals this aligned count as parallel (about 2.6°). */
|
|
313
|
+
const PARALLEL = .999;
|
|
314
|
+
const round = (value, places = 2) => {
|
|
315
|
+
const scale = 10 ** places;
|
|
316
|
+
const rounded = Math.round(value * scale) / scale;
|
|
317
|
+
return rounded === 0 ? 0 : rounded;
|
|
318
|
+
};
|
|
319
|
+
const labelOf = (part) => {
|
|
320
|
+
const centre = part.min.map((low, axis) => round((low + part.max[axis]) / 2));
|
|
321
|
+
return `${part.label} @ (${centre.join(", ")})`;
|
|
322
|
+
};
|
|
323
|
+
const fixed = (value) => round(value, 3).toFixed(3);
|
|
324
|
+
/** `z = -3.000, facing +z` for an axis-aligned plane, `(nx, ny, nz) · p = d` for any other. */
|
|
325
|
+
const planeLabel = (nx, ny, nz, d) => {
|
|
326
|
+
for (const [axis, value] of [
|
|
327
|
+
["x", nx],
|
|
328
|
+
["y", ny],
|
|
329
|
+
["z", nz]
|
|
330
|
+
]) if (Math.abs(value) > .9999) return `${axis} = ${fixed(d / value)}, facing ${value > 0 ? "+" : "-"}${axis}`;
|
|
331
|
+
return `(${fixed(nx)}, ${fixed(ny)}, ${fixed(nz)}) · p = ${fixed(d)}`;
|
|
332
|
+
};
|
|
333
|
+
const materialsOf = (mesh) => (Array.isArray(mesh.material) ? mesh.material : [mesh.material]).filter((material) => material !== void 0 && material !== null);
|
|
334
|
+
/** Whether two parts can fight: one writes depth the other tests, and neither is offset. */
|
|
335
|
+
const canFight = (a, b) => !a.offset && !b.offset && (a.writes && b.tests || b.writes && a.tests);
|
|
336
|
+
/** Pairs of meshes that z-fight, most overlapping triangles first. `[]` is a pass. */
|
|
337
|
+
const findZFighting = (scene, options = {}) => {
|
|
338
|
+
const root = asObject3D(scene);
|
|
339
|
+
const gap = options.gap ?? .004;
|
|
340
|
+
const self = options.self ?? false;
|
|
341
|
+
const tagKey = options.tagKey ?? "studioObject";
|
|
342
|
+
const tagged = options.tagged ?? hasTag(root, tagKey, options.skip);
|
|
343
|
+
root.updateWorldMatrix(true, true);
|
|
344
|
+
const parts = [];
|
|
345
|
+
const owner = [];
|
|
346
|
+
const planes = [];
|
|
347
|
+
const corners = [];
|
|
348
|
+
const keys = [];
|
|
349
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
350
|
+
const keyOf = (x, y, z, d) => `${x},${y},${z},${d}`;
|
|
351
|
+
walk(root, {
|
|
352
|
+
skip: options.skip,
|
|
353
|
+
visibleOnly: true
|
|
354
|
+
}, (object) => {
|
|
355
|
+
if (!isMesh(object) || isInstanced(object) || isBatched(object) || object.geometry.isInstancedBufferGeometry === true || !object.geometry.attributes.position) return;
|
|
356
|
+
if (tagged && tagOf(object, tagKey) === null) return;
|
|
357
|
+
const materials = materialsOf(object).filter((material) => material.visible !== false);
|
|
358
|
+
if (materials.length === 0) return;
|
|
359
|
+
const part = parts.length;
|
|
360
|
+
const { index, attributes } = object.geometry;
|
|
361
|
+
const position = attributes.position;
|
|
362
|
+
const e = object.matrixWorld.elements;
|
|
363
|
+
const min = [
|
|
364
|
+
Infinity,
|
|
365
|
+
Infinity,
|
|
366
|
+
Infinity
|
|
367
|
+
];
|
|
368
|
+
const max = [
|
|
369
|
+
-Infinity,
|
|
370
|
+
-Infinity,
|
|
371
|
+
-Infinity
|
|
372
|
+
];
|
|
373
|
+
const count = index?.count ?? position.count;
|
|
374
|
+
const world = [
|
|
375
|
+
0,
|
|
376
|
+
0,
|
|
377
|
+
0,
|
|
378
|
+
0,
|
|
379
|
+
0,
|
|
380
|
+
0,
|
|
381
|
+
0,
|
|
382
|
+
0,
|
|
383
|
+
0
|
|
384
|
+
];
|
|
385
|
+
parts.push({
|
|
386
|
+
mesh: object,
|
|
387
|
+
label: meshLabel(object, root, tagKey),
|
|
388
|
+
min,
|
|
389
|
+
max,
|
|
390
|
+
writes: materials.some((material) => material.depthWrite !== false),
|
|
391
|
+
tests: materials.some((material) => material.depthTest !== false),
|
|
392
|
+
offset: materials.some((material) => material.polygonOffset === true && (material.polygonOffsetFactor !== 0 || material.polygonOffsetUnits !== 0))
|
|
393
|
+
});
|
|
394
|
+
for (let k = 0; k + 2 < count; k += 3) {
|
|
395
|
+
for (let c = 0; c < 3; c += 1) {
|
|
396
|
+
const v = index?.getX(k + c) ?? k + c;
|
|
397
|
+
const x = position.getX(v);
|
|
398
|
+
const y = position.getY(v);
|
|
399
|
+
const z = position.getZ(v);
|
|
400
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
401
|
+
const value = e[axis] * x + e[axis + 4] * y + e[axis + 8] * z + e[axis + 12];
|
|
402
|
+
world[c * 3 + axis] = value;
|
|
403
|
+
min[axis] = Math.min(min[axis], value);
|
|
404
|
+
max[axis] = Math.max(max[axis], value);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const [ax, ay, az, bx, by, bz, cx, cy, cz] = world;
|
|
408
|
+
const ux = bx - ax;
|
|
409
|
+
const uy = by - ay;
|
|
410
|
+
const uz = bz - az;
|
|
411
|
+
const vx = cx - ax;
|
|
412
|
+
const vy = cy - ay;
|
|
413
|
+
const vz = cz - az;
|
|
414
|
+
let nx = uy * vz - uz * vy;
|
|
415
|
+
let ny = uz * vx - ux * vz;
|
|
416
|
+
let nz = ux * vy - uy * vx;
|
|
417
|
+
const length = Math.hypot(nx, ny, nz);
|
|
418
|
+
if (!(length / 2 >= 1e-5)) continue;
|
|
419
|
+
nx /= length;
|
|
420
|
+
ny /= length;
|
|
421
|
+
nz /= length;
|
|
422
|
+
const d = nx * ax + ny * ay + nz * az;
|
|
423
|
+
const id = owner.length;
|
|
424
|
+
const key = [
|
|
425
|
+
Math.round(nx * QUANT),
|
|
426
|
+
Math.round(ny * QUANT),
|
|
427
|
+
Math.round(nz * QUANT),
|
|
428
|
+
Math.round(d / gap)
|
|
429
|
+
];
|
|
430
|
+
const bucket = buckets.get(keyOf(...key));
|
|
431
|
+
owner.push(part);
|
|
432
|
+
planes.push(nx, ny, nz, d);
|
|
433
|
+
corners.push(...world);
|
|
434
|
+
keys.push(key);
|
|
435
|
+
if (bucket) bucket.push(id);
|
|
436
|
+
else buckets.set(keyOf(...key), [id]);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
const margin = gap / 2;
|
|
440
|
+
const flat = [
|
|
441
|
+
0,
|
|
442
|
+
0,
|
|
443
|
+
0,
|
|
444
|
+
0,
|
|
445
|
+
0,
|
|
446
|
+
0,
|
|
447
|
+
0,
|
|
448
|
+
0,
|
|
449
|
+
0,
|
|
450
|
+
0,
|
|
451
|
+
0,
|
|
452
|
+
0
|
|
453
|
+
];
|
|
454
|
+
const frame = [
|
|
455
|
+
0,
|
|
456
|
+
0,
|
|
457
|
+
0,
|
|
458
|
+
0,
|
|
459
|
+
0,
|
|
460
|
+
0
|
|
461
|
+
];
|
|
462
|
+
const overlaps = (s, t) => {
|
|
463
|
+
const S = s * 9;
|
|
464
|
+
const T = t * 9;
|
|
465
|
+
let ux = corners[S + 3] - corners[S];
|
|
466
|
+
let uy = corners[S + 4] - corners[S + 1];
|
|
467
|
+
let uz = corners[S + 5] - corners[S + 2];
|
|
468
|
+
const length = Math.hypot(ux, uy, uz) || 1;
|
|
469
|
+
ux /= length;
|
|
470
|
+
uy /= length;
|
|
471
|
+
uz /= length;
|
|
472
|
+
const nx = planes[s * 4];
|
|
473
|
+
const ny = planes[s * 4 + 1];
|
|
474
|
+
const nz = planes[s * 4 + 2];
|
|
475
|
+
const vx = ny * uz - nz * uy;
|
|
476
|
+
const vy = nz * ux - nx * uz;
|
|
477
|
+
const vz = nx * uy - ny * ux;
|
|
478
|
+
frame[0] = ux;
|
|
479
|
+
frame[1] = uy;
|
|
480
|
+
frame[2] = uz;
|
|
481
|
+
frame[3] = vx;
|
|
482
|
+
frame[4] = vy;
|
|
483
|
+
frame[5] = vz;
|
|
484
|
+
for (let i = 0; i < 6; i += 1) {
|
|
485
|
+
const at = i < 3 ? S + i * 3 : T + (i - 3) * 3;
|
|
486
|
+
const px = corners[at];
|
|
487
|
+
const py = corners[at + 1];
|
|
488
|
+
const pz = corners[at + 2];
|
|
489
|
+
flat[i * 2] = px * ux + py * uy + pz * uz;
|
|
490
|
+
flat[i * 2 + 1] = px * vx + py * vy + pz * vz;
|
|
491
|
+
}
|
|
492
|
+
for (let edge = 0; edge < 6; edge += 1) {
|
|
493
|
+
const from = edge;
|
|
494
|
+
const to = edge < 3 ? (edge + 1) % 3 : 3 + (edge + 1) % 3;
|
|
495
|
+
const ex = flat[to * 2] - flat[from * 2];
|
|
496
|
+
const ey = flat[to * 2 + 1] - flat[from * 2 + 1];
|
|
497
|
+
const edgeLength = Math.hypot(ex, ey) || 1;
|
|
498
|
+
const axisX = -ey / edgeLength;
|
|
499
|
+
const axisY = ex / edgeLength;
|
|
500
|
+
let p0 = Infinity;
|
|
501
|
+
let p1 = -Infinity;
|
|
502
|
+
let q0 = Infinity;
|
|
503
|
+
let q1 = -Infinity;
|
|
504
|
+
for (let i = 0; i < 6; i += 1) {
|
|
505
|
+
const value = flat[i * 2] * axisX + flat[i * 2 + 1] * axisY;
|
|
506
|
+
if (i < 3) {
|
|
507
|
+
p0 = Math.min(p0, value);
|
|
508
|
+
p1 = Math.max(p1, value);
|
|
509
|
+
} else {
|
|
510
|
+
q0 = Math.min(q0, value);
|
|
511
|
+
q1 = Math.max(q1, value);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (Math.min(p1, q1) - Math.max(p0, q0) < margin) return false;
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* The overlap `overlaps` just found, as area and centroid in s's plane: t's triangle clipped to
|
|
520
|
+
* s's (Sutherland–Hodgman), then the shoelace formula.
|
|
521
|
+
*/
|
|
522
|
+
const clipped = () => {
|
|
523
|
+
const order = (flat[2] - flat[0]) * (flat[5] - flat[1]) - (flat[4] - flat[0]) * (flat[3] - flat[1]) >= 0 ? [
|
|
524
|
+
0,
|
|
525
|
+
1,
|
|
526
|
+
2
|
|
527
|
+
] : [
|
|
528
|
+
0,
|
|
529
|
+
2,
|
|
530
|
+
1
|
|
531
|
+
];
|
|
532
|
+
let polygon = flat.slice(6, 12);
|
|
533
|
+
for (let edge = 0; edge < 3 && polygon.length >= 6; edge += 1) {
|
|
534
|
+
const ax = flat[order[edge] * 2];
|
|
535
|
+
const ay = flat[order[edge] * 2 + 1];
|
|
536
|
+
const ex = flat[order[(edge + 1) % 3] * 2] - ax;
|
|
537
|
+
const ey = flat[order[(edge + 1) % 3] * 2 + 1] - ay;
|
|
538
|
+
const next = [];
|
|
539
|
+
const n = polygon.length / 2;
|
|
540
|
+
for (let i = 0; i < n; i += 1) {
|
|
541
|
+
const px = polygon[i * 2];
|
|
542
|
+
const py = polygon[i * 2 + 1];
|
|
543
|
+
const qx = polygon[(i + 1) % n * 2];
|
|
544
|
+
const qy = polygon[(i + 1) % n * 2 + 1];
|
|
545
|
+
const dp = ex * (py - ay) - ey * (px - ax);
|
|
546
|
+
const dq = ex * (qy - ay) - ey * (qx - ax);
|
|
547
|
+
if (dp >= 0) next.push(px, py);
|
|
548
|
+
if (dp >= 0 !== dq >= 0) {
|
|
549
|
+
const k = dp / (dp - dq);
|
|
550
|
+
next.push(px + (qx - px) * k, py + (qy - py) * k);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
polygon = next;
|
|
554
|
+
}
|
|
555
|
+
let twice = 0;
|
|
556
|
+
let cx = 0;
|
|
557
|
+
let cy = 0;
|
|
558
|
+
const n = polygon.length / 2;
|
|
559
|
+
for (let i = 0; i < n; i += 1) {
|
|
560
|
+
const x0 = polygon[i * 2];
|
|
561
|
+
const y0 = polygon[i * 2 + 1];
|
|
562
|
+
const x1 = polygon[(i + 1) % n * 2];
|
|
563
|
+
const y1 = polygon[(i + 1) % n * 2 + 1];
|
|
564
|
+
const cross = x0 * y1 - x1 * y0;
|
|
565
|
+
twice += cross;
|
|
566
|
+
cx += (x0 + x1) * cross;
|
|
567
|
+
cy += (y0 + y1) * cross;
|
|
568
|
+
}
|
|
569
|
+
return Math.abs(twice) < 1e-12 ? {
|
|
570
|
+
area: 0,
|
|
571
|
+
x: 0,
|
|
572
|
+
y: 0
|
|
573
|
+
} : {
|
|
574
|
+
area: Math.abs(twice) / 2,
|
|
575
|
+
x: cx / (3 * twice),
|
|
576
|
+
y: cy / (3 * twice)
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
const onPlane = (s, t) => {
|
|
580
|
+
const nx = planes[s * 4];
|
|
581
|
+
const ny = planes[s * 4 + 1];
|
|
582
|
+
const nz = planes[s * 4 + 2];
|
|
583
|
+
const d = planes[s * 4 + 3];
|
|
584
|
+
for (let c = 0; c < 3; c += 1) {
|
|
585
|
+
const at = t * 9 + c * 3;
|
|
586
|
+
if (Math.abs(nx * corners[at] + ny * corners[at + 1] + nz * corners[at + 2] - d) > gap) return false;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
};
|
|
590
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
591
|
+
for (let s = 0; s < owner.length; s += 1) {
|
|
592
|
+
const [kx, ky, kz, kd] = keys[s];
|
|
593
|
+
for (let i = -1; i <= 1; i += 1) for (let j = -1; j <= 1; j += 1) for (let k = -1; k <= 1; k += 1) for (let l = -1; l <= 1; l += 1) for (const t of buckets.get(keyOf(kx + i, ky + j, kz + k, kd + l)) ?? []) {
|
|
594
|
+
const a = owner[s];
|
|
595
|
+
const b = owner[t];
|
|
596
|
+
if (t <= s || a === b && !self || !canFight(parts[a], parts[b]) || planes[s * 4] * planes[t * 4] + planes[s * 4 + 1] * planes[t * 4 + 1] + planes[s * 4 + 2] * planes[t * 4 + 2] < PARALLEL || !onPlane(s, t) || !onPlane(t, s) || !overlaps(s, t)) continue;
|
|
597
|
+
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
598
|
+
const pair = pairs.get(key) ?? {
|
|
599
|
+
a: Math.min(a, b),
|
|
600
|
+
b: Math.max(a, b),
|
|
601
|
+
triangles: 0,
|
|
602
|
+
area: 0,
|
|
603
|
+
centre: [
|
|
604
|
+
0,
|
|
605
|
+
0,
|
|
606
|
+
0
|
|
607
|
+
],
|
|
608
|
+
planes: /* @__PURE__ */ new Map()
|
|
609
|
+
};
|
|
610
|
+
const nx = planes[s * 4];
|
|
611
|
+
const ny = planes[s * 4 + 1];
|
|
612
|
+
const nz = planes[s * 4 + 2];
|
|
613
|
+
const d = planes[s * 4 + 3];
|
|
614
|
+
const { area, x, y } = clipped();
|
|
615
|
+
const plane = planeLabel(nx, ny, nz, d);
|
|
616
|
+
pair.triangles += 1;
|
|
617
|
+
pair.area += area;
|
|
618
|
+
pair.centre[0] += area * (x * frame[0] + y * frame[3] + d * nx);
|
|
619
|
+
pair.centre[1] += area * (x * frame[1] + y * frame[4] + d * ny);
|
|
620
|
+
pair.centre[2] += area * (x * frame[2] + y * frame[5] + d * nz);
|
|
621
|
+
pair.planes.set(plane, (pair.planes.get(plane) ?? 0) + area);
|
|
622
|
+
pairs.set(key, pair);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const rows = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const pair of [...pairs.values()].sort((x, y) => y.triangles - x.triangles)) {
|
|
627
|
+
const meshes = [parts[pair.a].mesh, parts[pair.b].mesh];
|
|
628
|
+
const a = labelOf(parts[pair.a]);
|
|
629
|
+
const b = labelOf(parts[pair.b]);
|
|
630
|
+
const planeLabels = [...pair.planes.entries()].sort((x, y) => y[1] - x[1]).map(([plane]) => plane);
|
|
631
|
+
const key = JSON.stringify([
|
|
632
|
+
a,
|
|
633
|
+
b,
|
|
634
|
+
pair.triangles,
|
|
635
|
+
planeLabels
|
|
636
|
+
]);
|
|
637
|
+
const folded = rows.get(key);
|
|
638
|
+
if (folded) {
|
|
639
|
+
folded.count += 1;
|
|
640
|
+
folded.pairs.push(meshes);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const weight = pair.area > 0 ? pair.area : 1;
|
|
644
|
+
const row = withHidden(withHidden({
|
|
645
|
+
a,
|
|
646
|
+
b,
|
|
647
|
+
triangles: pair.triangles,
|
|
648
|
+
planes: planeLabels,
|
|
649
|
+
overlap: {
|
|
650
|
+
area: round(pair.area, 4),
|
|
651
|
+
centre: pair.centre.map((sum) => round(sum / weight))
|
|
652
|
+
},
|
|
653
|
+
count: 1
|
|
654
|
+
}, "meshes", meshes), "pairs", [meshes]);
|
|
655
|
+
rows.set(key, row);
|
|
656
|
+
}
|
|
657
|
+
return [...rows.values()];
|
|
658
|
+
};
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/audit-scene.ts
|
|
661
|
+
/** The skip every check runs with: the caller's, narrowed to `tag` when given. */
|
|
662
|
+
const scopeOf = (root, options) => options.tag === void 0 ? options.skip : onlyTag(asObject3D(root), options.tag, options.tagKey ?? "studioObject", options.skip);
|
|
663
|
+
/**
|
|
664
|
+
* Every check at once, JSON-safe: what a test asserts on or a headless browser prints. A clean
|
|
665
|
+
* scene has `zFighting`, `badGeometry` and `emptyMeshes` all `[]`.
|
|
666
|
+
*/
|
|
667
|
+
const auditScene = (root, options = {}) => {
|
|
668
|
+
const { tagKey } = options;
|
|
669
|
+
const skip = scopeOf(root, options);
|
|
670
|
+
const keyed = tagKey === void 0 ? { skip } : {
|
|
671
|
+
skip,
|
|
672
|
+
tagKey
|
|
673
|
+
};
|
|
674
|
+
return {
|
|
675
|
+
triangles: countTriangles(root, { skip }),
|
|
676
|
+
draws: countDraws(root, { skip }),
|
|
677
|
+
zFighting: findZFighting(root, {
|
|
678
|
+
...options,
|
|
679
|
+
skip,
|
|
680
|
+
...options.tag === void 0 ? {} : { tagged: true }
|
|
681
|
+
}),
|
|
682
|
+
badGeometry: findBadGeometry(root, keyed),
|
|
683
|
+
emptyMeshes: findEmptyMeshes(root, keyed),
|
|
684
|
+
meshes: listMeshes(root, { skip }).slice(0, options.top ?? 10)
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* Every check as one count each: the single call to run after every edit. `0` everywhere (and
|
|
689
|
+
* `triangles`, `draws` where you expect them) is a pass. Use `auditScene` to see what failed.
|
|
690
|
+
*/
|
|
691
|
+
const summarizeScene = (root, options = {}) => {
|
|
692
|
+
const { tagKey } = options;
|
|
693
|
+
const skip = scopeOf(root, options);
|
|
694
|
+
const keyed = tagKey === void 0 ? { skip } : {
|
|
695
|
+
skip,
|
|
696
|
+
tagKey
|
|
697
|
+
};
|
|
698
|
+
let zFighting = null;
|
|
699
|
+
let selfZFighting = null;
|
|
700
|
+
if (options.zFighting !== false) {
|
|
701
|
+
zFighting = 0;
|
|
702
|
+
selfZFighting = 0;
|
|
703
|
+
for (const row of findZFighting(root, {
|
|
704
|
+
...options.gap === void 0 ? {} : { gap: options.gap },
|
|
705
|
+
...tagKey === void 0 ? {} : { tagKey },
|
|
706
|
+
...options.tag === void 0 ? {} : { tagged: true },
|
|
707
|
+
skip,
|
|
708
|
+
self: true
|
|
709
|
+
})) if (row.meshes[0] === row.meshes[1]) selfZFighting += row.count;
|
|
710
|
+
else zFighting += row.count;
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
triangles: countTriangles(root, { skip }),
|
|
714
|
+
draws: countDraws(root, { skip }),
|
|
715
|
+
meshes: listMeshes(root, { skip }).length,
|
|
716
|
+
zFighting,
|
|
717
|
+
selfZFighting,
|
|
718
|
+
badGeometry: findBadGeometry(root, keyed).length,
|
|
719
|
+
emptyMeshes: findEmptyMeshes(root, keyed).length
|
|
720
|
+
};
|
|
721
|
+
};
|
|
722
|
+
//#endregion
|
|
723
|
+
export { triangleCount as _, countTriangles as a, findBadGeometry as c, batchedTriangles as d, isBatched as f, pathOf as g, meshLabel as h, countDraws as i, findEmptyMeshes as l, isMesh as m, summarizeScene as n, geometryCensus as o, isInstanced as p, findZFighting as r, listMeshes as s, auditScene as t, asObject3D as u, walk as v };
|
|
724
|
+
|
|
725
|
+
//# sourceMappingURL=audit-scene-CJG1DgRF.js.map
|