@zkmake/three-audit 0.5.0 → 0.6.1

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 CHANGED
@@ -52,7 +52,10 @@ or a `setupFiles` module:
52
52
  ```ts
53
53
  import "@zkmake/three-audit/vitest";
54
54
 
55
+ import { expect, test } from "vitest";
56
+
55
57
  import budgets from "../src/budgets.ts";
58
+ import { buildKart } from "../src/kart.ts";
56
59
 
57
60
  test("the kart", () => {
58
61
  const kart = buildKart();
@@ -81,10 +84,10 @@ A failure says what to fix, as the CLI does:
81
84
  ```
82
85
 
83
86
  `toFitBudget` takes `{ skip, top }` (meshes listed, default 5). A frame budget's `calls` counts as
84
- `draws`. The matchers work negated (`.not`) and as asymmetric matchers
85
- (`expect.toFitBudget(…)`), and their types come with the import. `installMatchers()` registers
86
- them again, for a setup that resets `expect`; `matchers` is the object for `expect.extend`. The
87
- entry re-exports `triangles`. Vitest 5 or later.
87
+ `draws`; a budget that sets none of triangles, draws (calls) or meshes throws. The matchers work
88
+ negated (`.not`) and as asymmetric matchers (`expect.toFitBudget(…)`), and their types come with
89
+ the import. `installMatchers()` registers them again, for a setup that resets `expect`; `matchers`
90
+ is the object for `expect.extend`. The entry re-exports `triangles`. Vitest 5 or later.
88
91
 
89
92
  ## Budgets
90
93
 
@@ -94,7 +97,7 @@ place.
94
97
 
95
98
  ```ts
96
99
  // src/budgets.ts
97
- import { defineBudgets } from "@zkmake/three-audit";
100
+ import { defineBudgets } from "@zkmake/three-audit/budgets";
98
101
 
99
102
  export default defineBudgets({
100
103
  frame: { fps: 60, calls: 150, triangles: 250_000 },
@@ -115,10 +118,13 @@ export default defineBudgets({
115
118
  | `parts.*.draws` | Draw calls one main pass makes for it (`countDraws`) |
116
119
  | `parts.*.meshes` | Visible meshes in it |
117
120
 
121
+ `@zkmake/three-audit/budgets` holds `defineBudgets` and `parseBudgets` (and their types) alone,
122
+ with no imports, so the app's own code can import the file (its HUD reads it) and ship a few hundred
123
+ bytes, not the checks; the main entry exports both too. `frame` is required, `parts` optional.
118
124
  `defineBudgets` returns them frozen, with each part in the type: `budgets.parts.truck` is an error
119
125
  when no truck is listed. `parseBudgets(value)` checks budgets from anywhere else (JSON) and throws
120
- on a bad key. `checkBudget(object, budget)` measures an object against one, a row per limit:
121
- `{ metric, actual, limit, over }`.
126
+ on a bad key. `checkBudget(object, budget, { skip }?)`, in the main entry, measures an object
127
+ against one, a row per limit: `{ metric, actual, limit, over }`.
122
128
 
123
129
  - In a test: `expect(kart).toFitBudget(budgets.parts.kart)`.
124
130
  - In the HUD: `mountPerfHud(monitor, { budgets })` (or `<PerfHud budgets={budgets} />`) applies the
@@ -145,9 +151,11 @@ windmill.glb 4,812 triangles, 14 draws
145
151
  …
146
152
  ```
147
153
 
148
- It exits 1 when any model fails a check (2 when one can't be read), so it can gate CI. Options:
149
- `--gap <m>`, `--self`, `--skip <regex>` (leave out objects by name, e.g. decals), `--top <n>`,
150
- `--json`, `--no-fail`.
154
+ It exits 1 when any model fails a check (2 when one can't be read), so it can gate CI. Empty meshes
155
+ are listed (`!`) but don't fail. Options: `--gap <m>`, `--self`, `--skip <regex>` (leave out
156
+ objects by name, e.g. decals), `--top <n>` (default 5, 0 for none), `--json` (an array of
157
+ `{ file, report, budget }`, `report` being `auditScene`'s), `--no-fail`,
158
+ `-h` / `--help`, `-v` / `--version`.
151
159
 
152
160
  `--budgets <file>` also holds each model to the app's [budgets](#budgets): a JSON file, or a module
153
161
  whose default export is them (`.js`, or `.ts` where Node strips types). A model answers to the part
@@ -172,12 +180,18 @@ In a test, load a model the same way:
172
180
  ```ts
173
181
  import { findZFighting } from "@zkmake/three-audit";
174
182
  import { loadModel } from "@zkmake/three-audit/node";
183
+ import { expect, test } from "vitest";
175
184
 
176
185
  test("windmill.glb has no z-fighting", async () => {
177
186
  expect(findZFighting(await loadModel("assets/windmill.glb"))).toEqual([]);
178
187
  });
179
188
  ```
180
189
 
190
+ `parseModel(bytes, directory?)` does the same from bytes already read (GLB or glTF JSON, its
191
+ buffers resolved from `directory`). `createNodeDracoLoader()` is the in-process Draco decoder it
192
+ uses, for your own `GLTFLoader` in Node: it has the two methods `GLTFLoader` calls on a
193
+ `DRACOLoader`, so pass it with a cast (`loader.setDRACOLoader(draco as never)`).
194
+
181
195
  glTF is in metres, which is what `--gap`'s default assumes. Instanced meshes
182
196
  (`EXT_mesh_gpu_instancing`) aren't checked for z-fighting; skinned and morphing meshes are
183
197
  checked in their rest pose.
@@ -198,23 +212,24 @@ const { scene, gl } = useThree();
198
212
  useLayoutEffect(() => installAuditHelpers(scene, { renderer: gl }), [scene, gl]);
199
213
  ```
200
214
 
201
- | Global | Returns |
202
- | --------------------- | ----------------------------------------------------------------------------------- |
203
- | `summary(tag?)` | One count per check: the call to run after every edit |
204
- | `tris()` | Triangles one pass draws: visible meshes, instances and batches counted, no culling |
205
- | `meshes()` | Every visible mesh: triangles, instances, total, most first |
206
- | `census({ budget? })` | Geometries by triangles drawn, batches broken down, hidden meshes included |
207
- | `audit()` | Meshes with NaN positions or normals, or zero-length normals |
208
- | `zfight(gap?, self?)` | Pairs of meshes that z-fight |
209
- | `report(tag?)` | Every check in one JSON-safe object |
210
- | `bbox(target)` | An object's world envelope: min, max, size, centre |
211
- | `clearance(a, b)` | The gap between two objects, and which of their meshes come closest |
212
- | `ledger(options?)` | One frame's draw calls by object and pass, and each pass's size (WebGL) |
213
- | `beginLedger()` | `{ end() }`: draw calls between the two, for any span you choose (WebGL) |
214
- | `blackFrames(10)` | Frames in the next 10 s that came out black (WebGL) |
215
-
216
- A `tag` or `target` is a `userData.studioObject` value (or your `tagKey`), falling back to an
217
- object's name; `bbox` and `clearance` also take the object itself.
215
+ | Global | Returns |
216
+ | ----------------------------- | ------------------------------------------------------------------------------------------------ |
217
+ | `summary(tag?, options?)` | One count per check: the call to run after every edit. `{ zFighting: false }` skips the slow one |
218
+ | `tris()` | Triangles one pass draws: visible meshes, instances and batches counted, no culling |
219
+ | `meshes()` | Every visible mesh: triangles, instances, total, most first |
220
+ | `census({ budget?, label? })` | Geometries by triangles drawn, batches broken down, hidden meshes included |
221
+ | `audit()` | Meshes with NaN positions or normals, or zero-length normals |
222
+ | `zfight(gap?, self?)` | Pairs of meshes that z-fight |
223
+ | `report(tag?)` | Every check in one JSON-safe object |
224
+ | `bbox(target)` | An object's world envelope: min, max, size, centre |
225
+ | `clearance(a, b)` | The gap between two objects, and which of their meshes come closest |
226
+ | `ledger(options?)` | One frame's draw calls by object and pass, and each pass's size (WebGL) |
227
+ | `beginLedger()` | `{ end() }`: draw calls between the two, for any span you choose (WebGL) |
228
+ | `blackFrames(10)` | `{ frames, blackFrames }`: frames in the next 10 s that came out black (WebGL) |
229
+ | `auditRoot` | The root the helpers were installed on |
230
+
231
+ A `tag` is a `userData.studioObject` value (or your `tagKey`); a tag nothing carries throws. A
232
+ `target` (`bbox`, `clearance`) is a tag too, falling back to an object's name, or the object itself.
218
233
 
219
234
  ```js
220
235
  threeAudit.summary("bridge");
@@ -225,6 +240,11 @@ threeAudit.clearance("train", "tower");
225
240
 
226
241
  Each one is also on `threeAudit` (`threeAudit.report()`). Where short names like `report` collide
227
242
  with other tooling, `installAuditHelpers(scene, { globals: false })` installs only `threeAudit`.
243
+ The other options: `renderer` (the WebGL renderer `ledger`, `beginLedger` and `blackFrames` need;
244
+ without it they throw), `skip`, `tagKey`, [`bakes`](#baked-groups), and `batchLabel`, which names
245
+ the geometries inside a batch in `census()` (`geometryCensus`'s `label`).
246
+ [three-devtools](../three-devtools) installs them behind `?debug` (its `audit` option), and
247
+ [three-studio](../three-studio)'s pages install them on their own scene.
228
248
 
229
249
  From a headless browser: `agent-browser eval 'JSON.stringify(threeAudit.report())'`.
230
250
 
@@ -268,7 +288,9 @@ between each other instead of over each other's ends.
268
288
  ### `findBadGeometry(root, options?)`
269
289
 
270
290
  Vertices with a non-finite position or normal, and zero-length normals on triangles with area.
271
- Either lights a pixel NaN, and bloom smears one NaN pixel into a black block.
291
+ Either lights a pixel NaN, and bloom smears one NaN pixel into a black block. Hidden meshes are
292
+ checked too. A row is `{ mesh, under, nonFinite, zeroNormals }`, `under` being the tagged part it
293
+ sits in (or its parent's name). Options: `skip`, `tagKey`, `bakes`.
272
294
 
273
295
  ### `summarizeScene(root, options?)`
274
296
 
@@ -278,10 +300,12 @@ emptyMeshes }`. `tag` narrows it to one tagged object. `zFighting: false` skips
278
300
 
279
301
  ### `countTriangles`, `listMeshes`, `geometryCensus`, `countDraws`, `triangles`
280
302
 
281
- Where a triangle budget goes. `triangles(target)` is the one a test reaches for: a geometry's own
282
- triangles (`triangleCount`, within its draw range), or what an object draws (`countTriangles`). Name meshes and geometries (`mesh.name`, `geometry.name`) so the rows
283
- say something. A mesh row's `name` is its named ancestors, then its own name, or its geometry's name
284
- or type when it has none (`CharacterHands/left/BoxGeometry`).
303
+ Where a triangle budget goes. `countTriangles` counts what one pass draws, with no culling; a shadow
304
+ pass adds roughly as much again per shadow-casting light. `triangles(target)` is the one a test
305
+ reaches for: a geometry's own triangles (`triangleCount`, within its draw range), or what an object
306
+ draws (`countTriangles`). Name meshes and geometries (`mesh.name`, `geometry.name`) so the rows say
307
+ something. A mesh row's `name` is its named ancestors, then its own name, or its geometry's name or
308
+ type when it has none (`CharacterHands/left/BoxGeometry`).
285
309
 
286
310
  `geometryCensus` lists a `BatchedMesh` one geometry at a time: visible instances × triangles each,
287
311
  which is where a batch's budget actually goes. Rows are `batch#id` unless `label(batch, geometryId)`
@@ -294,29 +318,32 @@ treadmill_belt 570 513 292,410 0.306 true
294
318
  treadmill_coupler 570 297 169,290 0.177 false
295
319
  ```
296
320
 
297
- `countDraws` counts the draw calls one main pass makes: one per visible mesh, line or point cloud,
298
- one per material group when a mesh has several. It doesn't cull and doesn't count shadows; the draw
299
- ledger counts the real thing.
321
+ `countDraws` counts the draw calls one main pass makes: one per visible mesh, line, point cloud or
322
+ sprite, one per material group when a mesh has several. It doesn't cull and doesn't count shadows;
323
+ the draw ledger counts the real thing.
300
324
 
301
- ### `findEmptyMeshes(root)`
325
+ ### `findEmptyMeshes(root, options?)`
302
326
 
303
327
  Meshes with no vertices, such as what merging nothing leaves. Each one still costs a draw call and
304
328
  a geometry to dispose. A zero draw range doesn't count, since that's usually a pool waiting to fill.
329
+ Hidden meshes are included; it takes `findBadGeometry`'s options.
305
330
 
306
- ### `measureBounds(root, target)` / `measureClearance(root, a, b)`
331
+ ### `measureBounds(root, target, options?)` / `measureClearance(root, a, b, options?)`
307
332
 
308
333
  An object's world envelope, and the smallest gap between two objects, from their vertices as drawn:
309
334
  instances and batches included, rotations exact. The gap is measured between triangles' bounding
310
335
  boxes, which is exact for faces square to the axes and slightly under for sloped ones. So a tower
311
336
  merged into one mesh still measures to its crossbar, not to the box around the whole tower. `axis`
312
- says which way the gap runs when it's straight along one axis. Draw ranges are respected; a shadow pass adds roughly as much again per light.
337
+ says which way the gap runs when it's straight along one axis (`null` when diagonal). Draw ranges
338
+ are respected. A target matching nothing throws; one that draws nothing gives `null`. Options:
339
+ `tagKey`, `bakes`.
313
340
 
314
341
  ### `recordDrawLedger(renderer)` / `beginDrawLedger(renderer)`
315
342
 
316
343
  Wraps `WebGLRenderer.renderBufferDirect` and counts every draw by object name, by group (the name up
317
- to its first `:` or `#`), and by pass: `shadow`, then each `render()` call by scene name, or
318
- `render 1`, `render 2`… An unnamed object goes by its named ancestors, type and material
319
- (`CharacterHands/Mesh [skin]`).
344
+ to its first `:` or `#`, or your `groupOf(name)`), and by pass: `shadow`, then each `render()` call
345
+ by scene name, or `render 1`, `render 2`… An unnamed object goes by its named ancestors, type and
346
+ material (`CharacterHands/Mesh [skin]`).
320
347
 
321
348
  Each row (object or group) carries its draws per pass (`passes`) and its triangles, in all
322
349
  (`triangles`) and per pass (`passTriangles`): what a frame really draws of each part, so the city
@@ -325,7 +352,8 @@ pass, after culling them one by one.
325
352
 
326
353
  `recordDrawLedger` records exactly one animation frame, and fails after `timeout` (2 s) when no
327
354
  frame comes. `{ render }` records one call of your own render instead, frames or not.
328
- `beginDrawLedger` returns `{ end() }` for any span. `printDrawLedger` prints the tables.
355
+ `beginDrawLedger` returns `{ end() }` for any span. `printDrawLedger` prints the tables;
356
+ `ledgerTable(rows, passes)` is one of them as rows for `console.table`.
329
357
 
330
358
  `passStats` sizes each pass: draws, distinct objects (for `shadow`, the casters), triangles, and
331
359
  the targets drawn to in device pixels (`screen 2880×1800` for the canvas). A pass is `fullscreen`
@@ -336,14 +364,17 @@ pass. `fullscreenPasses` and `fullscreenPixels` total them: 8 fullscreen passes
336
364
  ### `watchBlackFrames(renderer)`
337
365
 
338
366
  After each render to the screen, reads a 3 × 3 grid of pixels and counts frames where three or more
339
- are pure black. Use a visible browser: headless ones haven't reproduced black frames. Each read
340
- stalls the GPU, so watch, read `blackFrames`, `stop()`.
367
+ are pure black (options `grid` and `threshold` set the two numbers). Use a visible browser:
368
+ headless ones haven't reproduced black frames. Each read stalls the GPU, so watch, read `frames`
369
+ and `blackFrames` (which frames, 1-based), `stop()`; `reset()` starts the count over.
341
370
 
342
371
  ### `auditScene(root, options?)`
343
372
 
344
- `{ triangles, zFighting, badGeometry, meshes }` in one call. Rows carry the live mesh as a
345
- non-enumerable property (`row.mesh`, `row.meshes`), so they print and serialise without dragging the
346
- scene along.
373
+ `{ triangles, draws, zFighting, badGeometry, emptyMeshes, meshes }` in one call, `meshes` the
374
+ costliest `top` (default 10). `tag` narrows it to one tagged object. Rows carry the live mesh as a
375
+ non-enumerable property (`row.mesh` on a mesh row, `row.meshes` and `row.pairs` on a z-fighting
376
+ row, `row.object` on a bad or empty one), so they print and serialise without dragging the scene
377
+ along.
347
378
 
348
379
  ## Baked groups
349
380
 
@@ -1,4 +1,4 @@
1
- import { a as countDraws, c as listMeshes, d as findEmptyMeshes, f as asObject3D, i as findZFighting, o as countTriangles, u as findBadGeometry, v as onlyTag } from "./budgets-DQggDhEu.js";
1
+ import { c as findBadGeometry, g as onlyTag, i as countTriangles, l as findEmptyMeshes, n as findZFighting, o as listMeshes, r as countDraws, u as asObject3D } from "./budgets-BtXN-bkX.js";
2
2
  // src/audit-scene.ts
3
3
  /** The skip every check runs with: the caller's, narrowed to `tag` when given. */
4
4
  const scopeOf = (root, options) => options.tag === void 0 ? options.skip : onlyTag(asObject3D(root), options.tag, options.tagKey ?? "studioObject", options.skip);
@@ -66,4 +66,4 @@ const summarizeScene = (root, options = {}) => {
66
66
  };
67
67
  export { summarizeScene as n, auditScene as t };
68
68
 
69
- //# sourceMappingURL=audit-scene-DHTFd-zs.js.map
69
+ //# sourceMappingURL=audit-scene-BjB8raDp.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit-scene-DHTFd-zs.js","names":[],"sources":["../src/audit-scene.ts"],"sourcesContent":["import {\n type BadGeometryRow,\n type EmptyMeshRow,\n findBadGeometry,\n findEmptyMeshes,\n} from \"./bad-geometry.ts\";\nimport { type AnyObject3D, asObject3D, onlyTag, type Skip } from \"./scene.ts\";\nimport { countDraws, countTriangles, listMeshes, type MeshRow } from \"./triangles.ts\";\nimport { findZFighting, type ZFightingOptions, type ZFightingRow } from \"./z-fighting.ts\";\n\nexport type AuditSceneOptions = Pick<ZFightingOptions, \"bakes\" | \"gap\" | \"self\" | \"tagged\"> & {\n skip?: Skip | undefined;\n tagKey?: string;\n /** Only the object(s) tagged this (`userData[tagKey]`): one studio object out of a scene. */\n tag?: string | undefined;\n /** How many of the costliest meshes to list. */\n top?: number;\n};\n\nexport type AuditReport = {\n triangles: number;\n /** Draw calls one main pass makes, before culling and shadows: see `countDraws`. */\n draws: number;\n zFighting: ZFightingRow[];\n badGeometry: BadGeometryRow[];\n emptyMeshes: EmptyMeshRow[];\n /** The costliest meshes, most triangles first. */\n meshes: MeshRow[];\n};\n\n/** The skip every check runs with: the caller's, narrowed to `tag` when given. */\nconst scopeOf = (\n root: AnyObject3D,\n options: { skip?: Skip | undefined; tag?: string | undefined; tagKey?: string },\n) =>\n options.tag === undefined\n ? options.skip\n : onlyTag(asObject3D(root), options.tag, options.tagKey ?? \"studioObject\", options.skip);\n\n/**\n * Every check at once, JSON-safe: what a test asserts on or a headless browser prints. A clean\n * scene has `zFighting`, `badGeometry` and `emptyMeshes` all `[]`.\n */\nexport const auditScene = (root: AnyObject3D, options: AuditSceneOptions = {}): AuditReport => {\n const { tagKey, bakes } = options;\n const skip = scopeOf(root, options);\n const keyed = { skip, bakes, ...(tagKey === undefined ? {} : { tagKey }) };\n\n return {\n triangles: countTriangles(root, { skip }),\n draws: countDraws(root, { skip }),\n zFighting: findZFighting(root, {\n ...options,\n skip,\n ...(options.tag === undefined ? {} : { tagged: true }),\n }),\n badGeometry: findBadGeometry(root, keyed),\n emptyMeshes: findEmptyMeshes(root, keyed),\n meshes: listMeshes(root, { skip }).slice(0, options.top ?? 10),\n };\n};\n\nexport type AuditSummaryOptions = Pick<\n AuditSceneOptions,\n \"bakes\" | \"gap\" | \"skip\" | \"tag\" | \"tagKey\"\n> & {\n /** Run the z-fighting check, the slow one. Default true; false gives `null` counts. */\n zFighting?: boolean;\n};\n\nexport type AuditSummary = {\n triangles: number;\n draws: number;\n meshes: number;\n /** Pairs of meshes that z-fight, identical pairs counted each. */\n zFighting: number | null;\n /** Meshes that z-fight with themselves: merged geometry whose parts overlap. */\n selfZFighting: number | null;\n badGeometry: number;\n emptyMeshes: number;\n};\n\n/**\n * Every check as one count each: the single call to run after every edit. `0` everywhere (and\n * `triangles`, `draws` where you expect them) is a pass. Use `auditScene` to see what failed.\n */\nexport const summarizeScene = (\n root: AnyObject3D,\n options: AuditSummaryOptions = {},\n): AuditSummary => {\n const { tagKey, bakes } = options;\n const skip = scopeOf(root, options);\n const keyed = { skip, bakes, ...(tagKey === undefined ? {} : { tagKey }) };\n let zFighting: number | null = null;\n let selfZFighting: number | null = null;\n\n if (options.zFighting !== false) {\n zFighting = 0;\n selfZFighting = 0;\n\n for (const row of findZFighting(root, {\n ...(options.gap === undefined ? {} : { gap: options.gap }),\n ...(tagKey === undefined ? {} : { tagKey }),\n ...(options.tag === undefined ? {} : { tagged: true }),\n skip,\n bakes,\n self: true,\n })) {\n if (row.meshes[0] === row.meshes[1]) {\n selfZFighting += row.count;\n } else {\n zFighting += row.count;\n }\n }\n }\n\n return {\n triangles: countTriangles(root, { skip }),\n draws: countDraws(root, { skip }),\n meshes: listMeshes(root, { skip }).length,\n zFighting,\n selfZFighting,\n badGeometry: findBadGeometry(root, keyed).length,\n emptyMeshes: findEmptyMeshes(root, keyed).length,\n };\n};\n"],"mappings":";;;AA+BA,MAAM,WACJ,MACA,YAEA,QAAQ,QAAQ,KAAA,IACZ,QAAQ,OACR,QAAQ,WAAW,IAAI,GAAG,QAAQ,KAAK,QAAQ,UAAU,gBAAgB,QAAQ,IAAI;;;;;AAM3F,MAAa,cAAc,MAAmB,UAA6B,CAAC,MAAmB;CAC7F,MAAM,EAAE,QAAQ,UAAU;CAC1B,MAAM,OAAO,QAAQ,MAAM,OAAO;CAClC,MAAM,QAAQ;EAAE;EAAM;EAAO,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAAG;CAEzE,OAAO;EACL,WAAW,eAAe,MAAM,EAAE,KAAK,CAAC;EACxC,OAAO,WAAW,MAAM,EAAE,KAAK,CAAC;EAChC,WAAW,cAAc,MAAM;GAC7B,GAAG;GACH;GACA,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ,KAAK;EACtD,CAAC;EACD,aAAa,gBAAgB,MAAM,KAAK;EACxC,aAAa,gBAAgB,MAAM,KAAK;EACxC,QAAQ,WAAW,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,MAAM,GAAG,QAAQ,OAAO,EAAE;CAC/D;AACF;;;;;AA0BA,MAAa,kBACX,MACA,UAA+B,CAAC,MACf;CACjB,MAAM,EAAE,QAAQ,UAAU;CAC1B,MAAM,OAAO,QAAQ,MAAM,OAAO;CAClC,MAAM,QAAQ;EAAE;EAAM;EAAO,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAAG;CACzE,IAAI,YAA2B;CAC/B,IAAI,gBAA+B;CAEnC,IAAI,QAAQ,cAAc,OAAO;EAC/B,YAAY;EACZ,gBAAgB;EAEhB,KAAK,MAAM,OAAO,cAAc,MAAM;GACpC,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,KAAK,QAAQ,IAAI;GACxD,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;GACzC,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ,KAAK;GACpD;GACA;GACA,MAAM;EACR,CAAC,GACC,IAAI,IAAI,OAAO,OAAO,IAAI,OAAO,IAC/B,iBAAiB,IAAI;OAErB,aAAa,IAAI;CAGvB;CAEA,OAAO;EACL,WAAW,eAAe,MAAM,EAAE,KAAK,CAAC;EACxC,OAAO,WAAW,MAAM,EAAE,KAAK,CAAC;EAChC,QAAQ,WAAW,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;EACnC;EACA;EACA,aAAa,gBAAgB,MAAM,KAAK,CAAC,CAAC;EAC1C,aAAa,gBAAgB,MAAM,KAAK,CAAC,CAAC;CAC5C;AACF"}
1
+ {"version":3,"file":"audit-scene-BjB8raDp.js","names":[],"sources":["../src/audit-scene.ts"],"sourcesContent":["import {\n type BadGeometryRow,\n type EmptyMeshRow,\n findBadGeometry,\n findEmptyMeshes,\n} from \"./bad-geometry.ts\";\nimport { type AnyObject3D, asObject3D, onlyTag, type Skip } from \"./scene.ts\";\nimport { countDraws, countTriangles, listMeshes, type MeshRow } from \"./triangles.ts\";\nimport { findZFighting, type ZFightingOptions, type ZFightingRow } from \"./z-fighting.ts\";\n\nexport type AuditSceneOptions = Pick<ZFightingOptions, \"bakes\" | \"gap\" | \"self\" | \"tagged\"> & {\n skip?: Skip | undefined;\n tagKey?: string;\n /** Only the object(s) tagged this (`userData[tagKey]`): one studio object out of a scene. */\n tag?: string | undefined;\n /** How many of the costliest meshes to list. */\n top?: number;\n};\n\nexport type AuditReport = {\n triangles: number;\n /** Draw calls one main pass makes, before culling and shadows: see `countDraws`. */\n draws: number;\n zFighting: ZFightingRow[];\n badGeometry: BadGeometryRow[];\n emptyMeshes: EmptyMeshRow[];\n /** The costliest meshes, most triangles first. */\n meshes: MeshRow[];\n};\n\n/** The skip every check runs with: the caller's, narrowed to `tag` when given. */\nconst scopeOf = (\n root: AnyObject3D,\n options: { skip?: Skip | undefined; tag?: string | undefined; tagKey?: string },\n) =>\n options.tag === undefined\n ? options.skip\n : onlyTag(asObject3D(root), options.tag, options.tagKey ?? \"studioObject\", options.skip);\n\n/**\n * Every check at once, JSON-safe: what a test asserts on or a headless browser prints. A clean\n * scene has `zFighting`, `badGeometry` and `emptyMeshes` all `[]`.\n */\nexport const auditScene = (root: AnyObject3D, options: AuditSceneOptions = {}): AuditReport => {\n const { tagKey, bakes } = options;\n const skip = scopeOf(root, options);\n const keyed = { skip, bakes, ...(tagKey === undefined ? {} : { tagKey }) };\n\n return {\n triangles: countTriangles(root, { skip }),\n draws: countDraws(root, { skip }),\n zFighting: findZFighting(root, {\n ...options,\n skip,\n ...(options.tag === undefined ? {} : { tagged: true }),\n }),\n badGeometry: findBadGeometry(root, keyed),\n emptyMeshes: findEmptyMeshes(root, keyed),\n meshes: listMeshes(root, { skip }).slice(0, options.top ?? 10),\n };\n};\n\nexport type AuditSummaryOptions = Pick<\n AuditSceneOptions,\n \"bakes\" | \"gap\" | \"skip\" | \"tag\" | \"tagKey\"\n> & {\n /** Run the z-fighting check, the slow one. Default true; false gives `null` counts. */\n zFighting?: boolean;\n};\n\nexport type AuditSummary = {\n triangles: number;\n draws: number;\n meshes: number;\n /** Pairs of meshes that z-fight, identical pairs counted each. */\n zFighting: number | null;\n /** Meshes that z-fight with themselves: merged geometry whose parts overlap. */\n selfZFighting: number | null;\n badGeometry: number;\n emptyMeshes: number;\n};\n\n/**\n * Every check as one count each: the single call to run after every edit. `0` everywhere (and\n * `triangles`, `draws` where you expect them) is a pass. Use `auditScene` to see what failed.\n */\nexport const summarizeScene = (\n root: AnyObject3D,\n options: AuditSummaryOptions = {},\n): AuditSummary => {\n const { tagKey, bakes } = options;\n const skip = scopeOf(root, options);\n const keyed = { skip, bakes, ...(tagKey === undefined ? {} : { tagKey }) };\n let zFighting: number | null = null;\n let selfZFighting: number | null = null;\n\n if (options.zFighting !== false) {\n zFighting = 0;\n selfZFighting = 0;\n\n for (const row of findZFighting(root, {\n ...(options.gap === undefined ? {} : { gap: options.gap }),\n ...(tagKey === undefined ? {} : { tagKey }),\n ...(options.tag === undefined ? {} : { tagged: true }),\n skip,\n bakes,\n self: true,\n })) {\n if (row.meshes[0] === row.meshes[1]) {\n selfZFighting += row.count;\n } else {\n zFighting += row.count;\n }\n }\n }\n\n return {\n triangles: countTriangles(root, { skip }),\n draws: countDraws(root, { skip }),\n meshes: listMeshes(root, { skip }).length,\n zFighting,\n selfZFighting,\n badGeometry: findBadGeometry(root, keyed).length,\n emptyMeshes: findEmptyMeshes(root, keyed).length,\n };\n};\n"],"mappings":";;;AA+BA,MAAM,WACJ,MACA,YAEA,QAAQ,QAAQ,KAAA,IACZ,QAAQ,OACR,QAAQ,WAAW,IAAI,GAAG,QAAQ,KAAK,QAAQ,UAAU,gBAAgB,QAAQ,IAAI;;;;;AAM3F,MAAa,cAAc,MAAmB,UAA6B,CAAC,MAAmB;CAC7F,MAAM,EAAE,QAAQ,UAAU;CAC1B,MAAM,OAAO,QAAQ,MAAM,OAAO;CAClC,MAAM,QAAQ;EAAE;EAAM;EAAO,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAAG;CAEzE,OAAO;EACL,WAAW,eAAe,MAAM,EAAE,KAAK,CAAC;EACxC,OAAO,WAAW,MAAM,EAAE,KAAK,CAAC;EAChC,WAAW,cAAc,MAAM;GAC7B,GAAG;GACH;GACA,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ,KAAK;EACtD,CAAC;EACD,aAAa,gBAAgB,MAAM,KAAK;EACxC,aAAa,gBAAgB,MAAM,KAAK;EACxC,QAAQ,WAAW,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,MAAM,GAAG,QAAQ,OAAO,EAAE;CAC/D;AACF;;;;;AA0BA,MAAa,kBACX,MACA,UAA+B,CAAC,MACf;CACjB,MAAM,EAAE,QAAQ,UAAU;CAC1B,MAAM,OAAO,QAAQ,MAAM,OAAO;CAClC,MAAM,QAAQ;EAAE;EAAM;EAAO,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;CAAG;CACzE,IAAI,YAA2B;CAC/B,IAAI,gBAA+B;CAEnC,IAAI,QAAQ,cAAc,OAAO;EAC/B,YAAY;EACZ,gBAAgB;EAEhB,KAAK,MAAM,OAAO,cAAc,MAAM;GACpC,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,KAAK,QAAQ,IAAI;GACxD,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;GACzC,GAAI,QAAQ,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ,KAAK;GACpD;GACA;GACA,MAAM;EACR,CAAC,GACC,IAAI,IAAI,OAAO,OAAO,IAAI,OAAO,IAC/B,iBAAiB,IAAI;OAErB,aAAa,IAAI;CAGvB;CAEA,OAAO;EACL,WAAW,eAAe,MAAM,EAAE,KAAK,CAAC;EACxC,OAAO,WAAW,MAAM,EAAE,KAAK,CAAC;EAChC,QAAQ,WAAW,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;EACnC;EACA;EACA,aAAa,gBAAgB,MAAM,KAAK,CAAC,CAAC;EAC1C,aAAa,gBAAgB,MAAM,KAAK,CAAC,CAAC;CAC5C;AACF"}
@@ -676,57 +676,6 @@ const findZFighting = (scene, options = {}) => {
676
676
  return [...rows.values()];
677
677
  };
678
678
  // src/budgets.ts
679
- const FRAME_KEYS = [
680
- "fps",
681
- "calls",
682
- "triangles",
683
- "textureMB"
684
- ];
685
- const PART_KEYS = [
686
- "triangles",
687
- "draws",
688
- "meshes"
689
- ];
690
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
691
- /** A copy of `value` with only `keys`, each a number ≥ 0, frozen. Throws naming `where`. */
692
- const limits = (value, keys, where) => {
693
- if (!isRecord(value)) throw new TypeError(`three-audit: budgets ${where} must be an object`);
694
- const out = {};
695
- for (const [key, limit] of Object.entries(value)) {
696
- if (!keys.includes(key)) throw new TypeError(`three-audit: budgets ${where} has "${key}"; expected ${keys.map((name) => `"${name}"`).join(", ")}`);
697
- if (limit === void 0) continue;
698
- if (typeof limit !== "number" || !(limit >= 0) || !Number.isFinite(limit)) throw new TypeError(`three-audit: budgets ${where}.${key} must be a number ≥ 0`);
699
- out[key] = limit;
700
- }
701
- return Object.freeze(out);
702
- };
703
- /**
704
- * Checks budgets read from anywhere (a JSON file, a module) and returns them frozen. Throws a
705
- * `TypeError` naming the first bad key.
706
- */
707
- const parseBudgets = (value) => {
708
- if (!isRecord(value)) throw new TypeError("three-audit: budgets must be an object with `frame` and `parts`");
709
- const parts = {};
710
- if (value.parts !== void 0) {
711
- if (!isRecord(value.parts)) throw new TypeError("three-audit: budgets parts must be an object of part budgets");
712
- for (const [name, part] of Object.entries(value.parts)) parts[name] = limits(part, PART_KEYS, `parts.${name}`);
713
- }
714
- return Object.freeze({
715
- frame: limits(value.frame ?? {}, FRAME_KEYS, "frame"),
716
- parts: Object.freeze(parts)
717
- });
718
- };
719
- /**
720
- * The app's budgets, typed and frozen: one file every reader imports.
721
- *
722
- * export default defineBudgets({
723
- * frame: { fps: 60, calls: 150, triangles: 250_000 },
724
- * parts: { kart: { triangles: 7_500, draws: 12 } },
725
- * });
726
- *
727
- * `budgets.parts.kart` is typed, and a part not listed is a type error.
728
- */
729
- const defineBudgets = (budgets) => parseBudgets(budgets);
730
679
  /**
731
680
  * An object measured against a part budget (or the frame's `triangles` and `calls`), one row per
732
681
  * limit the budget sets. Nothing over is a pass.
@@ -751,6 +700,6 @@ const checkBudget = (root, budget, options = {}) => {
751
700
  add("meshes", part.meshes, () => listMeshes(root, options).length);
752
701
  return rows;
753
702
  };
754
- export { meshLabel as _, countDraws as a, triangleCount as b, listMeshes as c, findEmptyMeshes as d, asObject3D as f, isMesh as g, isInstanced as h, findZFighting as i, triangles as l, isBatched as m, defineBudgets as n, countTriangles as o, batchedTriangles as p, parseBudgets as r, geometryCensus as s, checkBudget as t, findBadGeometry as u, onlyTag as v, walk as x, pathOf as y };
703
+ export { pathOf as _, geometryCensus as a, findBadGeometry as c, batchedTriangles as d, isBatched as f, onlyTag as g, meshLabel as h, countTriangles as i, findEmptyMeshes as l, isMesh as m, findZFighting as n, listMeshes as o, isInstanced as p, countDraws as r, triangles as s, checkBudget as t, asObject3D as u, triangleCount as v, walk as y };
755
704
 
756
- //# sourceMappingURL=budgets-DQggDhEu.js.map
705
+ //# sourceMappingURL=budgets-BtXN-bkX.js.map