@zkmake/three-audit 0.2.2 → 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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BufferGeometry, Mesh, Object3D } from "three";
1
+ import { BatchedMesh, BufferGeometry, Mesh, Object3D } from "three";
2
2
  //#region src/scene.d.ts
3
3
  /**
4
4
  * Any three.js object, from whichever copy of three's types the app has. The checks take this, so
@@ -32,6 +32,18 @@ type BadGeometryRow = {
32
32
  };
33
33
  /** Meshes with geometry that renders NaN, hidden ones included. `[]` is a pass. */
34
34
  export declare const findBadGeometry: (root: AnyObject3D, options?: BadGeometryOptions) => BadGeometryRow[];
35
+ type EmptyMeshRow = {
36
+ /** `path/to/mesh [material]`. */
37
+ mesh: string;
38
+ /** Not enumerable: kept out of JSON and `console.table`. */
39
+ readonly object: Mesh;
40
+ };
41
+ /**
42
+ * Meshes with no vertices, hidden ones included: what a merge of nothing leaves behind. They draw
43
+ * nothing, but each one is still a draw call and a leak to dispose. A draw range of zero doesn't
44
+ * count: that's usually a pool waiting to fill.
45
+ */
46
+ export declare const findEmptyMeshes: (root: AnyObject3D, options?: Pick<BadGeometryOptions, "skip" | "tagKey">) => EmptyMeshRow[];
35
47
  //#endregion
36
48
  //#region src/triangles.d.ts
37
49
  type TriangleOptions = {
@@ -41,7 +53,7 @@ type MeshRow = {
41
53
  /** `path/to/mesh`: its named ancestors, then its name (or its geometry's name or type). */
42
54
  name: string;
43
55
  material: string;
44
- /** Triangles in one copy (a batch's average). */
56
+ /** Triangles in one copy. A batch's is an average: `geometryCensus` breaks it down. */
45
57
  triangles: number;
46
58
  instances: number;
47
59
  total: number;
@@ -49,12 +61,28 @@ type MeshRow = {
49
61
  readonly mesh: Mesh;
50
62
  };
51
63
  type GeometryRow = {
52
- /** `geometry.name`, or its type and the start of its uuid. */
64
+ /**
65
+ * `geometry.name`, or its type and the start of its uuid. For a geometry in a batch, the batch's
66
+ * `label` for it, or `batch#id`.
67
+ */
53
68
  geometry: string;
54
69
  /** Copies drawn: every mesh using it, instances counted. */
55
70
  uses: number;
56
71
  triangles: number;
57
72
  total: number;
73
+ /** Its part of the census's total, 0–1. */
74
+ share: number;
75
+ /** With `budget`: whether `share` is over it. */
76
+ overBudget?: boolean;
77
+ };
78
+ type CensusOptions = TriangleOptions & {
79
+ /**
80
+ * Names a geometry inside a batch, e.g. from the app's own table of what it added. Default
81
+ * `batch#id`, the batch named by its path.
82
+ */
83
+ label?: ((batch: BatchedMesh, geometryId: number) => string | undefined) | undefined;
84
+ /** Flag rows whose share of the total is over this, 0–1: `0.2` for anything over 20%. */
85
+ budget?: number | undefined;
58
86
  };
59
87
  /**
60
88
  * Triangles one pass draws: visible meshes, instances counted, no frustum culling. A shadow pass
@@ -65,10 +93,16 @@ export declare const countTriangles: (root: AnyObject3D, options?: TriangleOptio
65
93
  export declare const listMeshes: (scene: AnyObject3D, options?: TriangleOptions) => MeshRow[];
66
94
  /**
67
95
  * Geometries by triangles drawn, hidden meshes included, most first. Name geometries
68
- * (`geometry.name`) to make this readable. Batches are left out: their shared buffer is not one
69
- * geometry; `listMeshes` counts them.
96
+ * (`geometry.name`) to make this readable. A batch counts each geometry in it separately, by its
97
+ * visible instances: the row a budget is really spent on.
98
+ */
99
+ export declare const geometryCensus: (root: AnyObject3D, options?: CensusOptions) => GeometryRow[];
100
+ /**
101
+ * Draw calls one main pass makes: one per visible mesh, line or point cloud, or one per material
102
+ * group for a mesh with several materials. Before frustum culling; each shadow-casting light adds
103
+ * one per caster. `recordDrawLedger` counts the real thing.
70
104
  */
71
- export declare const geometryCensus: (root: AnyObject3D, options?: TriangleOptions) => GeometryRow[];
105
+ export declare const countDraws: (root: AnyObject3D, options?: TriangleOptions) => number;
72
106
  //#endregion
73
107
  //#region src/z-fighting.d.ts
74
108
  type ZFightingOptions = {
@@ -117,21 +151,46 @@ export declare const findZFighting: (scene: AnyObject3D, options?: ZFightingOpti
117
151
  type AuditSceneOptions = Pick<ZFightingOptions, "gap" | "self" | "tagged"> & {
118
152
  skip?: Skip | undefined;
119
153
  tagKey?: string;
154
+ /** Only the object(s) tagged this (`userData[tagKey]`): one studio object out of a scene. */
155
+ tag?: string | undefined;
120
156
  /** How many of the costliest meshes to list. */
121
157
  top?: number;
122
158
  };
123
159
  type AuditReport = {
124
160
  triangles: number;
161
+ /** Draw calls one main pass makes, before culling and shadows: see `countDraws`. */
162
+ draws: number;
125
163
  zFighting: ZFightingRow[];
126
164
  badGeometry: BadGeometryRow[];
165
+ emptyMeshes: EmptyMeshRow[];
127
166
  /** The costliest meshes, most triangles first. */
128
167
  meshes: MeshRow[];
129
168
  };
130
169
  /**
131
170
  * Every check at once, JSON-safe: what a test asserts on or a headless browser prints. A clean
132
- * scene has `zFighting` and `badGeometry` both `[]`.
171
+ * scene has `zFighting`, `badGeometry` and `emptyMeshes` all `[]`.
133
172
  */
134
173
  export declare const auditScene: (root: AnyObject3D, options?: AuditSceneOptions) => AuditReport;
174
+ type AuditSummaryOptions = Pick<AuditSceneOptions, "gap" | "skip" | "tag" | "tagKey"> & {
175
+ /** Run the z-fighting check, the slow one. Default true; false gives `null` counts. */
176
+ zFighting?: boolean;
177
+ };
178
+ type AuditSummary = {
179
+ triangles: number;
180
+ draws: number;
181
+ meshes: number;
182
+ /** Pairs of meshes that z-fight, identical pairs counted each. */
183
+ zFighting: number | null;
184
+ /** Meshes that z-fight with themselves: merged geometry whose parts overlap. */
185
+ selfZFighting: number | null;
186
+ badGeometry: number;
187
+ emptyMeshes: number;
188
+ };
189
+ /**
190
+ * Every check as one count each: the single call to run after every edit. `0` everywhere (and
191
+ * `triangles`, `draws` where you expect them) is a pass. Use `auditScene` to see what failed.
192
+ */
193
+ export declare const summarizeScene: (root: AnyObject3D, options?: AuditSummaryOptions) => AuditSummary;
135
194
  //#endregion
136
195
  //#region src/black-frames.d.ts
137
196
  /** The `WebGLRenderer` methods the probe uses, typed loosely to fit any copy of three's types. */
@@ -170,6 +229,10 @@ export declare const watchBlackFrames: (renderer: ProbeRenderer, options?: Black
170
229
  type LedgerRenderer = {
171
230
  render(scene: AnyObject3D, camera: object): void;
172
231
  renderBufferDirect(camera: object, scene: object | null, geometry: object, material: object, object: AnyObject3D, group: unknown): void;
232
+ /** For pass sizes: the target being drawn to, `null` for the screen. */
233
+ getRenderTarget?(): unknown;
234
+ /** For pass sizes: the screen's drawing buffer, device pixels. */
235
+ getContext?(): unknown;
173
236
  };
174
237
  type DrawLedgerOptions = {
175
238
  /**
@@ -193,6 +256,18 @@ type LedgerRow = {
193
256
  /** Draws per pass name. */
194
257
  passes: Record<string, number>;
195
258
  };
259
+ type PassRow = {
260
+ name: string;
261
+ draws: number;
262
+ /** Distinct objects drawn: for `shadow`, the casters. */
263
+ objects: number;
264
+ /** Triangles drawn, instances counted. */
265
+ triangles: number;
266
+ /** Sizes drawn to, in device pixels: `2880×1800`, or `screen 2880×1800`. */
267
+ targets: string[];
268
+ /** Every call was one small mesh through an orthographic camera: a post-processing pass. */
269
+ fullscreen: boolean;
270
+ };
196
271
  type DrawLedger = {
197
272
  /**
198
273
  * Pass names in the order they first drew: `shadow`, then each `render()` call as its scene's
@@ -204,6 +279,11 @@ type DrawLedger = {
204
279
  /** By object name, most draws first. */
205
280
  objects: LedgerRow[];
206
281
  draws: number;
282
+ /** Each pass's draws, triangles, casters and target sizes, in `passes` order. */
283
+ passStats: PassRow[];
284
+ /** `render()` calls that drew one fullscreen pass, and the pixels they shaded between them. */
285
+ fullscreenPasses: number;
286
+ fullscreenPixels: number;
207
287
  };
208
288
  type DrawLedgerRecording = {
209
289
  /** Restore the renderer and return what was drawn since `beginDrawLedger`. */
@@ -234,6 +314,8 @@ type AuditHelpersOptions = {
234
314
  renderer?: (LedgerRenderer & ProbeRenderer) | undefined;
235
315
  skip?: Skip | undefined;
236
316
  tagKey?: string;
317
+ /** Names geometries inside a batch in `census()`: see `geometryCensus`'s `label`. */
318
+ batchLabel?: ((batch: BatchedMesh, geometryId: number) => string | undefined) | undefined;
237
319
  /**
238
320
  * Also put each helper on `globalThis` under its short name (`tris`, `report`…). Default true;
239
321
  * false leaves only `threeAudit`, for pages where short names collide with other tooling.
@@ -250,5 +332,38 @@ type AuditHelpersOptions = {
250
332
  */
251
333
  export declare const installAuditHelpers: (root: AnyObject3D, options?: AuditHelpersOptions) => () => void;
252
334
  //#endregion
253
- export type { AuditHelpersOptions, AuditReport, AuditSceneOptions, BadGeometryOptions, BadGeometryRow, BlackFrameOptions, BlackFrameWatch, DrawLedger, DrawLedgerOptions, DrawLedgerRecording, GeometryRow, LedgerRenderer, LedgerRow, MeshRow, ProbeRenderer, Skip, TriangleOptions, ZFightingOptions, ZFightingRow };
335
+ //#region src/measure.d.ts
336
+ type MeasureOptions = {
337
+ /** `userData` key a string target is looked up by. Default `studioObject`. */
338
+ tagKey?: string;
339
+ };
340
+ /** What to measure: an object, or a tag (`userData[tagKey]`), or failing that an object name. */
341
+ type Target = string | AnyObject3D;
342
+ type Bounds = {
343
+ min: [number, number, number];
344
+ max: [number, number, number];
345
+ size: [number, number, number];
346
+ centre: [number, number, number];
347
+ };
348
+ type Clearance = {
349
+ /**
350
+ * The smallest gap between the two, in world units. Measured between triangles' bounding boxes:
351
+ * exact for faces square to the axes, a slight underestimate for sloped ones. 0 when they touch
352
+ * or intersect.
353
+ */
354
+ gap: number;
355
+ /** The axis the gap is measured along, when it's straight along one; `null` when diagonal. */
356
+ axis: "x" | "y" | "z" | null;
357
+ /** The closest meshes, one from each side. */
358
+ between: [string, string];
359
+ };
360
+ /** A target's world envelope, from its visible meshes' vertices; `null` when it draws nothing. */
361
+ export declare const measureBounds: (root: AnyObject3D, target: Target, options?: MeasureOptions) => Bounds | null;
362
+ /**
363
+ * The gap between two targets, and which of their meshes come closest. Throws when a target
364
+ * matches nothing; `null` when either draws nothing. Meshes in both targets are left out of `a`.
365
+ */
366
+ export declare const measureClearance: (root: AnyObject3D, a: Target, b: Target, options?: MeasureOptions) => Clearance | null;
367
+ //#endregion
368
+ export type { AnyObject3D, AuditHelpersOptions, AuditReport, AuditSceneOptions, AuditSummary, AuditSummaryOptions, BadGeometryOptions, BadGeometryRow, BlackFrameOptions, BlackFrameWatch, Bounds, CensusOptions, Clearance, DrawLedger, DrawLedgerOptions, DrawLedgerRecording, EmptyMeshRow, GeometryRow, LedgerRenderer, LedgerRow, MeasureOptions, MeshRow, PassRow, ProbeRenderer, RecordDrawLedgerOptions, Skip, Target, TriangleOptions, ZFightingOptions, ZFightingRow };
254
369
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as listMeshes, c as triangleCount, i as geometryCensus, n as findZFighting, o as findBadGeometry, r as countTriangles, s as pathOf, t as auditScene } from "./audit-scene-BYsJcco9.js";
1
+ import { _ as triangleCount, a as countTriangles, c as findBadGeometry, d as batchedTriangles, f as isBatched, g as pathOf, h as meshLabel, i as countDraws, l as findEmptyMeshes, m as isMesh, n as summarizeScene, o as geometryCensus, p as isInstanced, r as findZFighting, s as listMeshes, t as auditScene, u as asObject3D, v as walk } from "./audit-scene-CJG1DgRF.js";
2
2
  //#region src/patch.ts
3
3
  /** Swap `key` on `target` for `value`; the returned function puts back exactly what was there. */
4
4
  const patch = (target, key, value) => {
@@ -122,6 +122,15 @@ const tally = (rows, name, pass) => {
122
122
  rows.set(name, row);
123
123
  };
124
124
  const sorted = (rows) => [...rows.values()].sort((a, b) => b.total - a.total);
125
+ /** Triangles one draw makes: its material group's range, times its instances. */
126
+ const drawTriangles = (geometry, object, group) => {
127
+ const mesh = object;
128
+ if (mesh.isMesh !== true) return 0;
129
+ if (isBatched(mesh)) return batchedTriangles(mesh).triangles;
130
+ const range = group;
131
+ const elements = geometry.index?.count ?? geometry.attributes.position?.count ?? 0;
132
+ return (range && typeof range.count === "number" ? Math.floor(Math.max(0, Math.min(range.count, elements - (range.start ?? 0))) / 3) : triangleCount(geometry)) * (isInstanced(mesh) ? mesh.count : 1);
133
+ };
125
134
  /** Start counting every draw. Call `end()` after the frame to restore the renderer and read it. */
126
135
  const beginDrawLedger = (renderer, options = {}) => {
127
136
  if (typeof renderer.renderBufferDirect !== "function") throw new TypeError("three-audit: the draw ledger needs a WebGLRenderer");
@@ -131,23 +140,89 @@ const beginDrawLedger = (renderer, options = {}) => {
131
140
  const passes = [];
132
141
  const objects = /* @__PURE__ */ new Map();
133
142
  const groups = /* @__PURE__ */ new Map();
143
+ const stats = /* @__PURE__ */ new Map();
144
+ const calls = [];
134
145
  let renders = 0;
135
146
  let current = "direct";
136
147
  let draws = 0;
148
+ let fullscreenPasses = 0;
149
+ let fullscreenPixels = 0;
150
+ const statOf = (pass) => {
151
+ let stat = stats.get(pass);
152
+ if (!stat) {
153
+ stat = {
154
+ name: pass,
155
+ draws: 0,
156
+ triangles: 0,
157
+ objects: /* @__PURE__ */ new Set(),
158
+ targets: /* @__PURE__ */ new Set(),
159
+ calls: 0,
160
+ fullscreenCalls: 0
161
+ };
162
+ stats.set(pass, stat);
163
+ }
164
+ return stat;
165
+ };
166
+ const sizeNow = () => {
167
+ const target = renderer.getRenderTarget?.();
168
+ if (target && typeof target.width === "number" && typeof target.height === "number") return {
169
+ width: target.width,
170
+ height: target.height,
171
+ screen: false
172
+ };
173
+ const gl = renderer.getContext?.();
174
+ return target === null && typeof gl?.drawingBufferWidth === "number" ? {
175
+ width: gl.drawingBufferWidth,
176
+ height: gl.drawingBufferHeight ?? 0,
177
+ screen: true
178
+ } : null;
179
+ };
137
180
  const restoreRender = patch(renderer, "render", function(scene, camera) {
138
181
  const outer = current;
139
182
  renders += 1;
140
183
  current = scene.name || `render ${renders}`;
184
+ const call = {
185
+ pass: current,
186
+ draws: 0,
187
+ orthographic: camera.isOrthographicCamera === true,
188
+ vertices: 0,
189
+ size: null
190
+ };
191
+ calls.push(call);
141
192
  try {
142
193
  render.call(this, scene, camera);
143
194
  } finally {
195
+ calls.pop();
144
196
  current = outer;
197
+ const stat = stats.get(call.pass);
198
+ if (stat) {
199
+ stat.calls += 1;
200
+ if (call.draws === 1 && call.orthographic && call.vertices <= 6) {
201
+ stat.fullscreenCalls += 1;
202
+ fullscreenPasses += 1;
203
+ fullscreenPixels += call.size ? call.size.width * call.size.height : 0;
204
+ }
205
+ }
145
206
  }
146
207
  });
147
208
  const restoreDraw = patch(renderer, "renderBufferDirect", function(camera, scene, geometry, material, object, group) {
148
- const pass = material.isMeshDepthMaterial === true || material.isMeshDistanceMaterial === true ? "shadow" : current;
209
+ const shadow = material.isMeshDepthMaterial === true || material.isMeshDistanceMaterial === true;
210
+ const pass = shadow ? "shadow" : current;
149
211
  const name = nameOf(object, material);
150
212
  if (!passes.includes(pass)) passes.push(pass);
213
+ const stat = statOf(pass);
214
+ const size = sizeNow();
215
+ const drawn = geometry;
216
+ stat.draws += 1;
217
+ stat.triangles += drawTriangles(drawn, object, group);
218
+ stat.objects.add(object);
219
+ if (size) stat.targets.add(`${size.screen ? "screen " : ""}${size.width}×${size.height}`);
220
+ const call = calls.at(-1);
221
+ if (call && !shadow) {
222
+ call.draws += 1;
223
+ call.vertices = drawn.attributes?.position?.count ?? Infinity;
224
+ call.size = size;
225
+ }
151
226
  draws += 1;
152
227
  tally(objects, name, pass);
153
228
  tally(groups, groupOf(name), pass);
@@ -160,7 +235,20 @@ const beginDrawLedger = (renderer, options = {}) => {
160
235
  passes,
161
236
  groups: sorted(groups),
162
237
  objects: sorted(objects),
163
- draws
238
+ draws,
239
+ passStats: passes.map((pass) => {
240
+ const stat = stats.get(pass);
241
+ return {
242
+ name: pass,
243
+ draws: stat.draws,
244
+ objects: stat.objects.size,
245
+ triangles: stat.triangles,
246
+ targets: [...stat.targets],
247
+ fullscreen: stat.calls > 0 && stat.fullscreenCalls === stat.calls
248
+ };
249
+ }),
250
+ fullscreenPasses,
251
+ fullscreenPixels
164
252
  };
165
253
  } };
166
254
  };
@@ -205,28 +293,281 @@ const ledgerTable = (rows, passes) => rows.map((row) => ({
205
293
  const printDrawLedger = (ledger) => {
206
294
  console.group(`Draw ledger: ${ledger.draws} draws in one frame`);
207
295
  console.table(ledgerTable(ledger.groups, ledger.passes));
296
+ console.table(ledger.passStats.map((pass) => ({
297
+ ...pass,
298
+ targets: pass.targets.join(", ")
299
+ })));
300
+ if (ledger.fullscreenPasses > 0) console.log(`${ledger.fullscreenPasses} fullscreen passes: ${(ledger.fullscreenPixels / 1e6).toFixed(1)} Mpx shaded`);
208
301
  console.groupCollapsed("Every object");
209
302
  console.table(ledgerTable(ledger.objects, ledger.passes));
210
303
  console.groupEnd();
211
304
  console.groupEnd();
212
305
  };
213
306
  //#endregion
214
- //#region src/console-helpers.ts
307
+ //#region src/measure.ts
308
+ const multiply = (a, b, out) => {
309
+ for (let row = 0; row < 4; row += 1) for (let column = 0; column < 4; column += 1) {
310
+ let sum = 0;
311
+ for (let k = 0; k < 4; k += 1) sum += a[k * 4 + row] * b[column * 4 + k];
312
+ out[column * 4 + row] = sum;
313
+ }
314
+ return out;
315
+ };
316
+ /** Every triangle a mesh draws, in world space: each instance of an instanced or batched mesh. */
317
+ const eachTriangle = (mesh, visit) => {
318
+ const { index, attributes } = mesh.geometry;
319
+ const position = attributes.position;
320
+ if (!position) return;
321
+ const world = mesh.matrixWorld.elements;
322
+ const corner = [
323
+ 0,
324
+ 0,
325
+ 0,
326
+ 0,
327
+ 0,
328
+ 0,
329
+ 0,
330
+ 0,
331
+ 0
332
+ ];
333
+ const emit = (matrix, first, count, indexed) => {
334
+ for (let k = first; k + 2 < first + count; k += 3) {
335
+ for (let c = 0; c < 3; c += 1) {
336
+ const v = indexed ? index.getX(k + c) : k + c;
337
+ const x = position.getX(v);
338
+ const y = position.getY(v);
339
+ const z = position.getZ(v);
340
+ for (let axis = 0; axis < 3; axis += 1) corner[c * 3 + axis] = matrix[axis] * x + matrix[axis + 4] * y + matrix[axis + 8] * z + matrix[axis + 12];
341
+ }
342
+ visit(...corner);
343
+ }
344
+ };
345
+ if (isBatched(mesh)) {
346
+ const batch = mesh;
347
+ const instance = Array.from({ length: 16 }, () => 0);
348
+ const matrix = Array.from({ length: 16 }, () => 0);
349
+ const target = { fromArray(array, offset = 0) {
350
+ for (let i = 0; i < 16; i += 1) instance[i] = array[offset + i];
351
+ return target;
352
+ } };
353
+ for (let id = 0; id < batch.maxInstanceCount; id += 1) {
354
+ let range;
355
+ try {
356
+ if (!batch.getVisibleAt(id)) continue;
357
+ range = batch.getGeometryRangeAt(batch.getGeometryIdAt(id));
358
+ batch.getMatrixAt(id, target);
359
+ } catch {
360
+ continue;
361
+ }
362
+ multiply(world, instance, matrix);
363
+ if (index) emit(matrix, range.indexStart, range.indexCount, true);
364
+ else emit(matrix, range.vertexStart, range.vertexCount, false);
365
+ }
366
+ return;
367
+ }
368
+ const { start, count } = mesh.geometry.drawRange;
369
+ const elements = index?.count ?? position.count;
370
+ const first = Math.min(start, elements);
371
+ const drawn = Math.min(elements, start + count) - first;
372
+ if (isInstanced(mesh)) {
373
+ const matrix = Array.from({ length: 16 }, () => 0);
374
+ const instances = mesh.instanceMatrix.array;
375
+ for (let i = 0; i < mesh.count; i += 1) {
376
+ multiply(world, Array.prototype.slice.call(instances, i * 16, i * 16 + 16), matrix);
377
+ emit(matrix, first, drawn, index !== null);
378
+ }
379
+ return;
380
+ }
381
+ emit(world, first, drawn, index !== null);
382
+ };
383
+ const emptyBox = () => [
384
+ Infinity,
385
+ Infinity,
386
+ Infinity,
387
+ -Infinity,
388
+ -Infinity,
389
+ -Infinity
390
+ ];
391
+ const grow = (box, x, y, z) => {
392
+ box[0] = Math.min(box[0], x);
393
+ box[1] = Math.min(box[1], y);
394
+ box[2] = Math.min(box[2], z);
395
+ box[3] = Math.max(box[3], x);
396
+ box[4] = Math.max(box[4], y);
397
+ box[5] = Math.max(box[5], z);
398
+ };
399
+ /** Per axis, how far apart two boxes are (0 where they overlap). */
400
+ const apart = (a, ai, b, bi, out) => {
401
+ for (let axis = 0; axis < 3; axis += 1) out[axis] = Math.max(0, a[ai + axis] - b[bi + axis + 3], b[bi + axis] - a[ai + axis + 3]);
402
+ return Math.hypot(out[0], out[1], out[2]);
403
+ };
404
+ const round = (value) => {
405
+ const rounded = Math.round(value * 1e3) / 1e3;
406
+ return rounded === 0 ? 0 : rounded;
407
+ };
408
+ /** The visible meshes a target stands for. */
409
+ const meshesOf = (root, target, tagKey, role) => {
410
+ let objects;
411
+ if (typeof target === "string") {
412
+ const tagged = [];
413
+ const named = [];
414
+ walk(root, {}, (object) => {
415
+ const tag = object.userData[tagKey];
416
+ if (tag !== void 0 && tag !== null && String(tag) === target) tagged.push(object);
417
+ else if (object.name === target) named.push(object);
418
+ });
419
+ objects = tagged.length > 0 ? tagged : named;
420
+ if (objects.length === 0) throw new Error(`three-audit: ${role}: nothing is tagged userData.${tagKey} = "${target}" or named "${target}"`);
421
+ } else objects = [asObject3D(target)];
422
+ const meshes = /* @__PURE__ */ new Set();
423
+ for (const object of objects) walk(object, { visibleOnly: true }, (child) => {
424
+ if (isMesh(child)) meshes.add(child);
425
+ });
426
+ return meshes;
427
+ };
428
+ /** A target's world envelope, from its visible meshes' vertices; `null` when it draws nothing. */
429
+ const measureBounds = (root, target, options = {}) => {
430
+ const scene = asObject3D(root);
431
+ scene.updateWorldMatrix(true, true);
432
+ const box = emptyBox();
433
+ for (const mesh of meshesOf(scene, target, options.tagKey ?? "studioObject", "bounds")) eachTriangle(mesh, (ax, ay, az, bx, by, bz, cx, cy, cz) => {
434
+ grow(box, ax, ay, az);
435
+ grow(box, bx, by, bz);
436
+ grow(box, cx, cy, cz);
437
+ });
438
+ if (box[0] > box[3]) return null;
439
+ return {
440
+ min: [
441
+ box[0],
442
+ box[1],
443
+ box[2]
444
+ ].map(round),
445
+ max: [
446
+ box[3],
447
+ box[4],
448
+ box[5]
449
+ ].map(round),
450
+ size: [
451
+ 0,
452
+ 1,
453
+ 2
454
+ ].map((axis) => round(box[axis + 3] - box[axis])),
455
+ centre: [
456
+ 0,
457
+ 1,
458
+ 2
459
+ ].map((axis) => round((box[axis] + box[axis + 3]) / 2))
460
+ };
461
+ };
462
+ /** Triangle boxes past this many pairs, a mesh pair is measured by its whole boxes instead. */
463
+ const PAIR_LIMIT = 4e6;
215
464
  /**
216
- * The checks as console globals, for a dev page or a headless browser's `eval`: `tris()`,
217
- * `meshes()`, `census()`, `audit()`, `zfight()`, `report()`, and with a renderer `ledger()`,
218
- * `beginLedger()` and `blackFrames()`. All of them also sit on `threeAudit`, which is the only
219
- * global with `globals: false`.
465
+ * The gap between two targets, and which of their meshes come closest. Throws when a target
466
+ * matches nothing; `null` when either draws nothing. Meshes in both targets are left out of `a`.
220
467
  */
468
+ const measureClearance = (root, a, b, options = {}) => {
469
+ const scene = asObject3D(root);
470
+ const tagKey = options.tagKey ?? "studioObject";
471
+ scene.updateWorldMatrix(true, true);
472
+ const inB = meshesOf(scene, b, tagKey, "clearance b");
473
+ const inA = [...meshesOf(scene, a, tagKey, "clearance a")].filter((mesh) => !inB.has(mesh));
474
+ const measure = (mesh) => {
475
+ const box = emptyBox();
476
+ eachTriangle(mesh, (ax, ay, az, bx, by, bz, cx, cy, cz) => {
477
+ grow(box, ax, ay, az);
478
+ grow(box, bx, by, bz);
479
+ grow(box, cx, cy, cz);
480
+ });
481
+ return {
482
+ mesh,
483
+ box,
484
+ triangles: null
485
+ };
486
+ };
487
+ const sideA = inA.map(measure).filter(({ box }) => box[0] <= box[3]);
488
+ const sideB = [...inB].map(measure).filter(({ box }) => box[0] <= box[3]);
489
+ if (sideA.length === 0 || sideB.length === 0) return null;
490
+ const trianglesOf = (side) => {
491
+ if (!side.triangles) {
492
+ const boxes = [];
493
+ eachTriangle(side.mesh, (ax, ay, az, bx, by, bz, cx, cy, cz) => {
494
+ boxes.push(Math.min(ax, bx, cx), Math.min(ay, by, cy), Math.min(az, bz, cz), Math.max(ax, bx, cx), Math.max(ay, by, cy), Math.max(az, bz, cz));
495
+ });
496
+ side.triangles = Float64Array.from(boxes);
497
+ }
498
+ return side.triangles;
499
+ };
500
+ const axes = [
501
+ 0,
502
+ 0,
503
+ 0
504
+ ];
505
+ const pairs = sideA.flatMap((left) => sideB.map((right) => ({
506
+ left,
507
+ right,
508
+ distance: apart(left.box, 0, right.box, 0, axes)
509
+ }))).sort((x, y) => x.distance - y.distance);
510
+ let best = Infinity;
511
+ let bestAxes = [
512
+ 0,
513
+ 0,
514
+ 0
515
+ ];
516
+ let between = [pairs[0].left.mesh, pairs[0].right.mesh];
517
+ for (const { left, right, distance } of pairs) {
518
+ if (distance >= best) break;
519
+ const leftTriangles = trianglesOf(left);
520
+ const rightTriangles = trianglesOf(right);
521
+ const near = (triangles, other) => {
522
+ const kept = [];
523
+ for (let t = 0; t < triangles.length; t += 6) if (apart(triangles, t, other, 0, axes) < best) kept.push(t);
524
+ return kept;
525
+ };
526
+ const nearLeft = near(leftTriangles, right.box);
527
+ const nearRight = near(rightTriangles, left.box);
528
+ if (nearLeft.length * nearRight.length > PAIR_LIMIT) {
529
+ if (distance < best) {
530
+ best = distance;
531
+ apart(left.box, 0, right.box, 0, axes);
532
+ bestAxes = [...axes];
533
+ between = [left.mesh, right.mesh];
534
+ }
535
+ continue;
536
+ }
537
+ for (const i of nearLeft) for (const j of nearRight) {
538
+ const gap = apart(leftTriangles, i, rightTriangles, j, axes);
539
+ if (gap < best) {
540
+ best = gap;
541
+ bestAxes = [...axes];
542
+ between = [left.mesh, right.mesh];
543
+ }
544
+ }
545
+ }
546
+ const separated = bestAxes.flatMap((value, axis) => value > 0 ? [axis] : []);
547
+ return {
548
+ gap: round(best),
549
+ axis: separated.length === 1 ? [
550
+ "x",
551
+ "y",
552
+ "z"
553
+ ][separated[0]] : null,
554
+ between: [meshLabel(between[0], scene, tagKey), meshLabel(between[1], scene, tagKey)]
555
+ };
556
+ };
557
+ //#endregion
558
+ //#region src/console-helpers.ts
221
559
  const NAMESPACE = "threeAudit";
222
560
  const NAMES = [
223
561
  "auditRoot",
562
+ "summary",
224
563
  "tris",
225
564
  "meshes",
226
565
  "census",
227
566
  "audit",
228
567
  "zfight",
229
568
  "report",
569
+ "bbox",
570
+ "clearance",
230
571
  "ledger",
231
572
  "beginLedger",
232
573
  "blackFrames"
@@ -243,13 +584,23 @@ const needsRenderer = (name) => () => {
243
584
  * useLayoutEffect(() => installAuditHelpers(scene, { renderer: gl }), [scene, gl]);
244
585
  */
245
586
  const installAuditHelpers = (root, options = {}) => {
246
- const { renderer, skip } = options;
587
+ const { renderer, skip, batchLabel } = options;
247
588
  const tagKey = options.tagKey ?? "studioObject";
248
589
  const helpers = {
249
590
  auditRoot: root,
591
+ summary: (tag, summaryOptions = {}) => summarizeScene(root, {
592
+ skip,
593
+ tagKey,
594
+ ...summaryOptions,
595
+ ...tag === void 0 ? {} : { tag }
596
+ }),
250
597
  tris: () => countTriangles(root, { skip }),
251
598
  meshes: () => listMeshes(root, { skip }),
252
- census: () => geometryCensus(root, { skip }),
599
+ census: (censusOptions = {}) => geometryCensus(root, {
600
+ skip,
601
+ label: batchLabel,
602
+ ...censusOptions
603
+ }),
253
604
  audit: () => findBadGeometry(root, {
254
605
  skip,
255
606
  tagKey
@@ -260,10 +611,13 @@ const installAuditHelpers = (root, options = {}) => {
260
611
  ...gap === void 0 ? {} : { gap },
261
612
  ...self === void 0 ? {} : { self }
262
613
  }),
263
- report: () => auditScene(root, {
614
+ report: (tag) => auditScene(root, {
264
615
  skip,
265
- tagKey
616
+ tagKey,
617
+ ...tag === void 0 ? {} : { tag }
266
618
  }),
619
+ bbox: (target) => measureBounds(root, target, { tagKey }),
620
+ clearance: (a, b) => measureClearance(root, a, b, { tagKey }),
267
621
  ledger: renderer ? async (ledgerOptions) => {
268
622
  const ledger = await recordDrawLedger(renderer, ledgerOptions);
269
623
  printDrawLedger(ledger);
@@ -301,6 +655,6 @@ const installAuditHelpers = (root, options = {}) => {
301
655
  };
302
656
  };
303
657
  //#endregion
304
- export { auditScene, beginDrawLedger, countTriangles, findBadGeometry, findZFighting, geometryCensus, installAuditHelpers, ledgerTable, listMeshes, printDrawLedger, recordDrawLedger, triangleCount, watchBlackFrames };
658
+ export { auditScene, beginDrawLedger, countDraws, countTriangles, findBadGeometry, findEmptyMeshes, findZFighting, geometryCensus, installAuditHelpers, ledgerTable, listMeshes, measureBounds, measureClearance, printDrawLedger, recordDrawLedger, summarizeScene, triangleCount, watchBlackFrames };
305
659
 
306
660
  //# sourceMappingURL=index.js.map