@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.
@@ -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