@jgengine/assets 0.18.1 → 0.21.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 CHANGED
@@ -36,7 +36,7 @@ assets add sword # icon G�� the game-icon
36
36
  ```
37
37
 
38
38
  - A **model / pack** match is fully automated: if the pack isn't already in `<dir>/models/`, it's pulled and extracted, then `reindex` runs so the id is addressable, then the `buildCatalog` + model-seam snippet is printed.
39
- - A **material** match pulls the maps into `<dir>/materials/<id>/` with normalized names (`color.jpg`, `normal.jpg`, `roughness.jpg`, `ao.jpg`, `displacement.jpg`) and prints the `buildMaterialCatalog` resolve snippet G�� no reindex needed, the material catalog is fully static.
39
+ - A **material** match pulls the maps into `<dir>/materials/<id>/` with normalized names (`color.jpg`, `normal.jpg`, `displacement.jpg`, plus `roughness.jpg`/`ao.jpg` when present) and prints the `buildMaterialCatalog` resolve snippet G�� no reindex needed, the material catalog is fully static.
40
40
  - A **sprite / spritePack** match works like model/pack: if the pack isn't already in `<dir>/sprites/`, it's pulled and extracted, then `reindex-sprites` runs so individual SVG/PNG ids are addressable, then the `buildSpriteCatalog` snippet is printed.
41
41
  - A **component / icon** match prints the one-liner to run and the import + usage G�� no bytes to pull.
42
42
  - Ambiguous query? `add` lists the top matches across kinds; narrow with `--kind model|pack|material|component|icon|sprite|spritePack` or a more specific term. `--json` emits the ranked matches for scripting.
@@ -57,10 +57,27 @@ assets register <path|url> --category <c> --license <l> [--author <a>]
57
57
  # register a one-off single into the shipped index
58
58
  assets reindex [public/models] # regenerate generated/*.json (dims + animation clip names) from pulled model packs
59
59
  assets reindex-sprites [public/sprites] # regenerate generated-sprites/*.json from pulled sprite packs
60
+ assets budget <file|directory> [--max-bytes <n>] [--max-triangles <n>] [--max-texture-dimension <n>] [--json]
61
+ # offline per-model inventory and CI budget check for any local GLBs
60
62
  assets verify # license + alias-integrity gate
61
63
  assets provenance <id> [<id>...] # resolve each id's owner (committed/provisioned/dangling); non-zero on any dangling
62
64
  ```
63
65
 
66
+ ### Offline model budgets
67
+
68
+ Check the actual GLBs in your game's served directory, including your own imported art:
69
+
70
+ ```bash
71
+ assets budget public/models --max-bytes 2097152 --max-triangles 50000 --max-texture-dimension 2048 --json
72
+ assets budget public/imported/hero.glb --json
73
+ ```
74
+
75
+ Limits are per model, inclusive, and chosen by the game. Omitting limits produces an inventory. The deterministic report includes each relative filename, measurements, and actionable errors; any violation, unreadable model, missing resource, or empty scan exits non-zero. The command never fetches, writes models, or changes catalog/provisioning data.
76
+
77
+ `glbBytes` is the file size; `byteLength` adds unique external buffer/image URIs, counting shared files once **per model**. Triangle counts cover every stored mesh primitive once (indexed/non-indexed triangles, strips, and fans), regardless of scene instances; they are not a rendered scene or GPU-memory budget. Texture dimensions are read from PNG, JPEG, WebP, and KTX2 headers without decoding pixels. Local references must stay within the scanned directory; symlink directories fail rather than hiding models. Remote resources and geometry requiring Draco/meshopt decoding fail inspection. An already compressed asset needs a decoder before this tool can verify its geometry.
78
+
79
+ Programmatic inspection is available through `readGlbMetrics` from `@jgengine/assets/glbMetrics`, with an injected resource reader, or `createAssetBudgetReport` / `checkAssetBudget` from the Node subpath `@jgengine/assets/assetBudget`. Reindex stores successful measurements in optional `IndexEntry.metrics`, preserving existing ids, bounds, clips, and ownership. Existing entries without metrics still work; budget checks always inspect the current served bytes.
80
+
64
81
  ### Reference integrity
65
82
 
66
83
  Every logical asset id resolves to a declared **owner** through the provisioning contract (`resolveProvenance`): `committed` (a single's shipped URL — resolves on a clean clone with no pull), `provisioned` (a pack index entry whose GLB `assets pull <source>` fetches into the served dir), or `dangling` (nothing owns it, so it can never resolve). `validateAssetReferences(refs)` turns a set of `{ consumer, id }` references into a clean-clone gate: it fails on any dangling reference — naming the consumer, the logical id, and the null resolved path — and returns the exact `assets pull <source>` steps the checkout still needs so provisioning stays deterministic and `ensure-ready`-driven. It resolves references structurally against the catalog rather than grepping source text.
@@ -74,7 +91,7 @@ Run in-repo with `bun run --cwd packages/assets src/cli/pull.ts <verb> GǪ`. (`a
74
91
  `pull` never has just one path to the bytes. For a given `source-id` it tries, in order, until one succeeds:
75
92
 
76
93
  1. **Mirror base override** G�� `--mirror <baseUrl>` (or the `JGENGINE_ASSETS_MIRROR` env var if `--mirror` is not passed) G�� the archive is expected at `<baseUrl>/<provider>/<source-id>.zip`, e.g. `https://my-mirror.example.com/quaternius/quaternius-stylized-nature.zip`.
77
- 2. **The default GitHub-release mirror** G�� `https://github.com/Noisemaker111/jgengine/releases/download/packs/<provider>-<source-id>.zip`, on this repo's own rolling `packs` release (no separate assets repo). github.com is reachable from every cloud sandbox with no network-policy change, so zero-setup sessions still pull. `.github/workflows/mirror-assets.yml` (weekly cron + manual dispatch) keeps the release in sync with `src/sources/*.ts` automatically G�� adding a catalog entry is the whole publishing step, no manual upload. Skip this hop with `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`. Model/sprite pack failures fail the job; individual ambientCG material 404s are soft (logged, non-fatal).
94
+ 2. **The default GitHub-release mirror** G�� `https://github.com/Noisemaker111/jgengine/releases/download/packs/<provider>-<source-id>.zip`, on this repo's own rolling `packs` release (no separate assets repo). github.com is reachable from every cloud sandbox with no network-policy change, so zero-setup sessions still pull. `.github/workflows/mirror-assets.yml` (weekly cron + manual dispatch) keeps the release in sync with `src/sources/*.ts` automatically G�� adding a catalog entry is the whole publishing step, no manual upload. Skip this hop with `JGENGINE_ASSETS_NO_DEFAULT_MIRROR=1`. Model/sprite pack failures fail the job; individual ambientCG material 404s and packs that record why in `unpulled` are soft (logged, non-fatal).
78
95
  3. **The primary provider path** G�� pinned `{ url, sha256? }` when stable: **KayKit** G�� `github.com/KayKit-Game-Assets/*/archive/GǪ/main.zip`; **Quaternius** free Standard packs G�� OpenGameArt direct zips (site pages JS-gate). Else scrape. `extractGlbs` packs co-located `.gltf`+`.bin` into `.glb`, flattens external image URIs to basenames, and returns each referenced texture so `pull` writes it beside the models — a pulled pack renders textured out of the box.
79
96
  4. **The pack's own `mirror`** G�� an optional direct archive URL set on the `AssetSource` entry itself (`src/sources/*.ts`), tried as a last resort.
80
97
 
@@ -102,7 +119,7 @@ bun src/cli/pull.ts reindex ../../apps/dev/public/models
102
119
  bun src/cli/pull.ts verify # license + alias gate
103
120
  ```
104
121
 
105
- A **new provider** is just a new `src/sources/<provider>.ts` added to the `sources` array in `src/sources/index.ts`. Pinned providers use `download: { url, sha256? }`; providers whose pages JS-gate the download and can't be pinned use `download: { scrape: <page> }`. Any entry can also carry an optional top-level `mirror: <archiveUrl>` G�� a direct URL `pull` falls back to if both a `--mirror`/`JGENGINE_ASSETS_MIRROR` override and the primary path fail (see "Mirror fallback and offline pulls" above). Kenney.nl is not an eligible provider for this repo G�� see CLAUDE.md "Never Kenney".
122
+ A **new provider** is just a new `src/sources/<provider>.ts` added to the `sources` array in `src/sources/index.ts`. Pinned providers use `download: { url, sha256? }`; providers whose pages JS-gate the download and can't be pinned use `download: { scrape: <page> }`. Any entry can also carry an optional top-level `mirror: <archiveUrl>` G�� a direct URL `pull` falls back to if both a `--mirror`/`JGENGINE_ASSETS_MIRROR` override and the primary path fail (see "Mirror fallback and offline pulls" above). Kenney.nl is not an eligible provider for this repo G�� see AGENTS.md "Never use Kenney assets".
106
123
 
107
124
  **A whole new sprite/icon pack** G�� same shape as a model pack, just `kind: "sprite"` and `reindex-sprites` instead of `reindex`; files are matched by `.svg`/`.png` extension, deduped by basename regardless of archive nesting, so no filenames are hand-typed here either. `src/sources/gameicons.ts` is the current example (`gameicons-icons`); a new sprite provider follows the same shape in its own `src/sources/<provider>.ts`.
108
125
 
@@ -154,6 +171,8 @@ materials.resolve("ambientcg-grass001")!.maps.color; // "/materials/ambientcg-gr
154
171
  materials.resolve("material/grass")!.maps.normal; // alias G�� "/materials/ambientcg-grass001/normal.jpg"
155
172
  ```
156
173
 
174
+ `maps.ao` and `maps.roughness` are optional: the catalog advertises them only when the pinned source variant provides them. `ambientcg-metalplates001` has no AO; `ambientcg-gravel001` and `ambientcg-concrete001` have neither AO nor roughness. Pass `maps` directly to model, building, or terrain material seams; omitted maps skip loading, and terrain keeps its authored scalar roughness. Callers can still supply their own map URLs. Native ambientCG 1K-JPG pulls do not include KTX2, so the catalog omits `maps.ktx2` too. Pull fails if an archive omits a map its source declares.
175
+
157
176
  Sprite/icon packs resolve the same way once pulled + reindexed G�� individual files, not whole packs:
158
177
 
159
178
  ```ts
@@ -0,0 +1,36 @@
1
+ import { type GlbMetrics } from "./glbMetrics.js";
2
+ /** Per-model limits chosen by the game; equality passes and omitted limits only report. */
3
+ export interface AssetBudget {
4
+ maxBytes?: number;
5
+ maxTriangles?: number;
6
+ maxTextureDimension?: number;
7
+ }
8
+ /** One model's measured inventory or actionable inspection/budget errors. */
9
+ export interface AssetBudgetEntry {
10
+ file: string;
11
+ metrics?: GlbMetrics;
12
+ errors: readonly string[];
13
+ }
14
+ /** Deterministic offline report; an empty directory or any unreadable model fails. */
15
+ export interface AssetBudgetReport {
16
+ ok: boolean;
17
+ budget: AssetBudget;
18
+ entries: readonly AssetBudgetEntry[];
19
+ errors: readonly string[];
20
+ }
21
+ /**
22
+ * Read a GLB plus local resources under `resourceRoot`; remote URLs and symlink escapes fail without fetching.
23
+ * @capability asset-budgets inspect a GLB and its local external resources without network access
24
+ */
25
+ export declare function readGlbFileMetrics(file: string, resourceRoot?: string): GlbMetrics;
26
+ /**
27
+ * Check measured per-model bytes, stored triangles, and maximum image dimension against caller-owned limits.
28
+ * @capability asset-budgets enforce game-owned per-model byte, triangle, and texture-dimension limits
29
+ */
30
+ export declare function checkAssetBudget(metrics: GlbMetrics, budget: AssetBudget): string[];
31
+ /**
32
+ * Scan a file or directory recursively for GLBs without changing their bytes or the catalog.
33
+ * Paths are relative to the scanned directory, sorted lexically; unknown/custom asset folders work equally.
34
+ * @capability asset-budgets fail CI on local or imported GLB byte, triangle, and texture-size regressions
35
+ */
36
+ export declare function createAssetBudgetReport(target: string, budget?: AssetBudget): AssetBudgetReport;
@@ -0,0 +1,110 @@
1
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { readGlbMetrics } from "./glbMetrics.js";
4
+ /**
5
+ * Read a GLB plus local resources under `resourceRoot`; remote URLs and symlink escapes fail without fetching.
6
+ * @capability asset-budgets inspect a GLB and its local external resources without network access
7
+ */
8
+ export function readGlbFileMetrics(file, resourceRoot = dirname(file)) {
9
+ const root = realpathSync(resourceRoot);
10
+ const modelPath = relative(root, realpathSync(file));
11
+ if (modelPath === ".." || modelPath.startsWith(`..${sep}`) || isAbsolute(modelPath)) {
12
+ throw new Error("model escapes resource root");
13
+ }
14
+ return readGlbMetrics(readFileSync(file), (uri) => {
15
+ if (/^[a-z][a-z\d+.-]*:/i.test(uri) || uri.startsWith("/") || /[\\?#]/.test(uri))
16
+ throw new Error(`external resource must be a local relative URI: ${uri}`);
17
+ const decoded = decodeURIComponent(uri);
18
+ if (isAbsolute(decoded) || decoded.includes("\\"))
19
+ throw new Error(`external resource must be a local relative URI: ${uri}`);
20
+ const full = realpathSync(resolve(dirname(file), decoded));
21
+ const path = relative(root, full);
22
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
23
+ throw new Error(`external resource escapes resource root: ${uri}`);
24
+ return readFileSync(full);
25
+ });
26
+ }
27
+ /**
28
+ * Check measured per-model bytes, stored triangles, and maximum image dimension against caller-owned limits.
29
+ * @capability asset-budgets enforce game-owned per-model byte, triangle, and texture-dimension limits
30
+ */
31
+ export function checkAssetBudget(metrics, budget) {
32
+ const errors = [];
33
+ const limits = [
34
+ ["byteLength", "maxBytes"],
35
+ ["triangles", "maxTriangles"],
36
+ ["maxTextureDimension", "maxTextureDimension"],
37
+ ];
38
+ for (const [metric, limit] of limits) {
39
+ const maximum = budget[limit];
40
+ if (maximum === undefined)
41
+ continue;
42
+ if (!Number.isSafeInteger(maximum) || maximum < 0)
43
+ throw new Error(`${limit} must be a non-negative safe integer`);
44
+ if (metrics[metric] > maximum)
45
+ errors.push(`${metric} ${metrics[metric]} exceeds ${limit} ${maximum}`);
46
+ }
47
+ return errors;
48
+ }
49
+ /**
50
+ * Scan a file or directory recursively for GLBs without changing their bytes or the catalog.
51
+ * Paths are relative to the scanned directory, sorted lexically; unknown/custom asset folders work equally.
52
+ * @capability asset-budgets fail CI on local or imported GLB byte, triangle, and texture-size regressions
53
+ */
54
+ export function createAssetBudgetReport(target, budget = {}) {
55
+ checkAssetBudget({
56
+ glbBytes: 0,
57
+ byteLength: 0,
58
+ triangles: 0,
59
+ primitives: 0,
60
+ textures: [],
61
+ maxTextureDimension: 0,
62
+ }, budget);
63
+ const absolute = resolve(target);
64
+ const directory = statSync(absolute).isDirectory();
65
+ const root = directory ? absolute : dirname(absolute);
66
+ const files = [];
67
+ const scanErrors = [];
68
+ const scan = (folder) => {
69
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
70
+ const full = resolve(folder, entry.name);
71
+ if (entry.isDirectory())
72
+ scan(full);
73
+ else if ((entry.isFile() || entry.isSymbolicLink()) && /\.glb$/i.test(entry.name))
74
+ files.push(full);
75
+ else if (entry.isSymbolicLink() && statSync(full).isDirectory()) {
76
+ scanErrors.push(`${relative(root, full).split(sep).join("/")}: symlink directories cannot be scanned`);
77
+ }
78
+ }
79
+ };
80
+ if (directory)
81
+ scan(absolute);
82
+ else if (/\.glb$/i.test(absolute))
83
+ files.push(absolute);
84
+ const entries = files
85
+ .map((file) => ({ file, path: relative(root, file).split(sep).join("/") }))
86
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
87
+ .map(({ file, path }) => {
88
+ try {
89
+ const metrics = readGlbFileMetrics(file, root);
90
+ return {
91
+ file: path,
92
+ metrics,
93
+ errors: checkAssetBudget(metrics, budget),
94
+ };
95
+ }
96
+ catch (error) {
97
+ return {
98
+ file: path,
99
+ errors: [error instanceof Error ? error.message : String(error)],
100
+ };
101
+ }
102
+ });
103
+ const errors = [
104
+ ...scanErrors.sort(),
105
+ ...entries.flatMap((entry) => entry.errors.map((error) => `${entry.file}: ${error}`)),
106
+ ];
107
+ if (entries.length === 0)
108
+ errors.push("no GLB files found; provision or import assets before checking budgets");
109
+ return { ok: errors.length === 0, budget: { ...budget }, entries, errors };
110
+ }
@@ -14,6 +14,7 @@ export interface CatalogExtra {
14
14
  url: string;
15
15
  /** Human-facing label kept only in the source literal; never registered into the catalog. */
16
16
  label?: string;
17
+ anchor?: "center" | "origin";
17
18
  dims?: import("@jgengine/core/scene/assetCatalog").ModelDims;
18
19
  space?: import("@jgengine/core/scene/assetSpace").AssetSpace;
19
20
  clips?: readonly string[];
@@ -45,6 +45,7 @@ export function buildCatalog(options = {}) {
45
45
  for (const extra of options.extras ?? []) {
46
46
  catalog.register(extra.id, {
47
47
  url: extra.url,
48
+ ...(extra.anchor === undefined ? {} : { anchor: extra.anchor }),
48
49
  ...(extra.dims === undefined ? {} : { dims: extra.dims }),
49
50
  ...(extra.space === undefined ? {} : { space: extra.space }),
50
51
  ...(extra.clips === undefined ? {} : { clips: extra.clips }),
package/dist/cli/pull.js CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createAssetBudgetReport } from "../assetBudget.js";
6
7
  import { downloadPackArchive, extractGlbs, extractSpriteFiles } from "../download.js";
7
8
  import { rankAssets } from "../find.js";
8
9
  import { generatedIndex } from "../generated/index.js";
@@ -192,6 +193,9 @@ async function fetchPackInto(source, outRoot, options) {
192
193
  if (maps.every((map) => map.role !== "color")) {
193
194
  fail(`no color map found in ${source.id} archive`);
194
195
  }
196
+ const missing = source.materialMaps?.filter((role) => !maps.some((map) => map.role === role)) ?? [];
197
+ if (missing.length > 0)
198
+ fail(`${source.id} archive is missing declared material map(s): ${missing.join(", ")}`);
195
199
  mkdirSync(outDir, { recursive: true });
196
200
  for (const map of maps)
197
201
  writeFileSync(join(outDir, map.file), map.bytes);
@@ -403,6 +407,54 @@ async function cmdAdd(argv) {
403
407
  console.log(`→ ${describeMatch(top.match)}\n`);
404
408
  await performAdd(top.match, argv);
405
409
  }
410
+ function cmdBudget(argv) {
411
+ const target = argv[0];
412
+ if (target === undefined || target.startsWith("--")) {
413
+ fail("usage: budget <file|directory> [--max-bytes <n>] [--max-triangles <n>] [--max-texture-dimension <n>] [--json]");
414
+ }
415
+ const budget = {};
416
+ const limits = {
417
+ "--max-bytes": "maxBytes",
418
+ "--max-triangles": "maxTriangles",
419
+ "--max-texture-dimension": "maxTextureDimension",
420
+ };
421
+ for (let index = 1; index < argv.length; index += 1) {
422
+ const option = argv[index];
423
+ if (option === "--json")
424
+ continue;
425
+ const key = limits[option];
426
+ if (key === undefined)
427
+ fail(`unknown budget option: ${option}`);
428
+ const raw = argv[++index];
429
+ if (raw === undefined || !/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
430
+ fail(`${option} requires a non-negative safe integer`);
431
+ }
432
+ if (budget[key] !== undefined)
433
+ fail(`duplicate budget option: ${option}`);
434
+ budget[key] = Number(raw);
435
+ }
436
+ try {
437
+ const report = createAssetBudgetReport(target, budget);
438
+ if (argv.includes("--json"))
439
+ console.log(JSON.stringify(report, null, 2));
440
+ else {
441
+ for (const entry of report.entries) {
442
+ const metrics = entry.metrics;
443
+ const detail = metrics === undefined ? "unreadable"
444
+ : `${metrics.byteLength} bytes, ${metrics.triangles} triangles, ${metrics.maxTextureDimension}px max texture`;
445
+ console.log(`${entry.file}: ${detail}`);
446
+ }
447
+ for (const error of report.errors)
448
+ console.error(`error: ${error}`);
449
+ console.log(`budget: ${report.ok ? "ok" : "failed"} (${report.entries.length} models)`);
450
+ }
451
+ if (!report.ok)
452
+ process.exitCode = 1;
453
+ }
454
+ catch (error) {
455
+ fail(error instanceof Error ? error.message : String(error));
456
+ }
457
+ }
406
458
  function cmdReindex(argv) {
407
459
  const modelsDir = resolve(argv[0] ?? resolveDefaultReindexDir(here, "models"));
408
460
  if (!existsSync(modelsDir))
@@ -478,6 +530,9 @@ if (import.meta.main) {
478
530
  case "register":
479
531
  cmdRegisterSingle(rest);
480
532
  break;
533
+ case "budget":
534
+ cmdBudget(rest);
535
+ break;
481
536
  case "reindex":
482
537
  cmdReindex(rest);
483
538
  break;
@@ -491,7 +546,7 @@ if (import.meta.main) {
491
546
  cmdProvenance(rest);
492
547
  break;
493
548
  default:
494
- console.log("usage: assets <add|list|search|pull|register|reindex|reindex-sprites|verify|provenance> [...args]\n" +
549
+ console.log("usage: assets <add|list|search|pull|register|reindex|reindex-sprites|budget|verify|provenance> [...args]\n" +
495
550
  " assets pull starter # every pack the starter catalog needs, into ./public/models");
496
551
  if (command !== undefined && command !== "help")
497
552
  process.exit(1);
@@ -0,0 +1,24 @@
1
+ /** Stored geometry and image inventory; triangles count mesh primitives once, regardless of scene instances. */
2
+ export interface GlbMetrics {
3
+ glbBytes: number;
4
+ /** GLB plus unique external buffer/image bytes; shared files count once per model. */
5
+ byteLength: number;
6
+ triangles: number;
7
+ primitives: number;
8
+ textures: readonly GlbTextureMetrics[];
9
+ maxTextureDimension: number;
10
+ }
11
+ /** Dimensions and encoded bytes for one glTF image, including images referenced by texture extensions. */
12
+ export interface GlbTextureMetrics {
13
+ image: number;
14
+ width: number;
15
+ height: number;
16
+ byteLength: number;
17
+ }
18
+ /**
19
+ * Inspect GLB v2 geometry accessors and image headers offline. Throws on unreadable resources,
20
+ * malformed geometry, or required geometry compression; never silently reports missing data as zero.
21
+ * External resources are supplied by the caller; this function never fetches or decompresses bytes.
22
+ * @capability asset-budgets measure bytes, triangles, and texture dimensions of imported GLB assets offline
23
+ */
24
+ export declare function readGlbMetrics(bytes: Uint8Array, readResource?: (uri: string) => Uint8Array): GlbMetrics;
@@ -0,0 +1,291 @@
1
+ function integer(value, label, minimum = 0) {
2
+ if (!Number.isSafeInteger(value) || value < minimum)
3
+ throw new Error(`${label}: expected an integer >= ${minimum}`);
4
+ return value;
5
+ }
6
+ function dataUri(uri) {
7
+ const match = /^data:[^,]*;base64,([\s\S]*)$/i.exec(uri);
8
+ if (match === null)
9
+ throw new Error("only base64 data URIs are supported");
10
+ return Uint8Array.from(atob(match[1]), (character) => character.charCodeAt(0));
11
+ }
12
+ function imageSize(bytes) {
13
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14
+ const ascii = (offset, length) => new TextDecoder().decode(bytes.subarray(offset, offset + length));
15
+ if (bytes.length >= 24 &&
16
+ view.getUint32(0) === 0x89504e47 &&
17
+ view.getUint32(4) === 0x0d0a1a0a &&
18
+ ascii(12, 4) === "IHDR") {
19
+ return [view.getUint32(16), view.getUint32(20)];
20
+ }
21
+ if (bytes.length >= 48 &&
22
+ ascii(1, 6) === "KTX 20" &&
23
+ bytes[0] === 0xab &&
24
+ bytes[7] === 0xbb &&
25
+ view.getUint32(8) === 0x0d0a1a0a) {
26
+ return [view.getUint32(20, true), view.getUint32(24, true)];
27
+ }
28
+ if (bytes.length >= 30 && ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP") {
29
+ const kind = ascii(12, 4);
30
+ if (kind === "VP8X") {
31
+ const uint24 = (offset) => bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
32
+ return [uint24(24) + 1, uint24(27) + 1];
33
+ }
34
+ if (kind === "VP8 " && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
35
+ return [view.getUint16(26, true) & 0x3fff, view.getUint16(28, true) & 0x3fff];
36
+ }
37
+ if (kind === "VP8L" && bytes[20] === 0x2f) {
38
+ const bits = view.getUint32(21, true);
39
+ return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
40
+ }
41
+ }
42
+ if (bytes.length >= 4 && view.getUint16(0) === 0xffd8) {
43
+ let offset = 2;
44
+ while (offset + 4 <= bytes.length) {
45
+ if (bytes[offset] !== 0xff)
46
+ break;
47
+ while (bytes[offset] === 0xff)
48
+ offset += 1;
49
+ const marker = bytes[offset++];
50
+ if (marker === 0xd9 || marker === 0xda)
51
+ break;
52
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
53
+ continue;
54
+ if (offset + 2 > bytes.length)
55
+ break;
56
+ const length = view.getUint16(offset);
57
+ if (length < 2 || offset + length > bytes.length)
58
+ break;
59
+ if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker) &&
60
+ length >= 8) {
61
+ return [view.getUint16(offset + 5), view.getUint16(offset + 3)];
62
+ }
63
+ offset += length;
64
+ }
65
+ }
66
+ throw new Error("unreadable image dimensions (supported: PNG, JPEG, WebP, KTX2)");
67
+ }
68
+ /**
69
+ * Inspect GLB v2 geometry accessors and image headers offline. Throws on unreadable resources,
70
+ * malformed geometry, or required geometry compression; never silently reports missing data as zero.
71
+ * External resources are supplied by the caller; this function never fetches or decompresses bytes.
72
+ * @capability asset-budgets measure bytes, triangles, and texture dimensions of imported GLB assets offline
73
+ */
74
+ export function readGlbMetrics(bytes, readResource) {
75
+ const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
76
+ if (bytes.length < 20 ||
77
+ header.getUint32(0, true) !== 0x46546c67 ||
78
+ header.getUint32(4, true) !== 2 ||
79
+ header.getUint32(8, true) !== bytes.length) {
80
+ throw new Error("invalid GLB v2 header or file length");
81
+ }
82
+ let json;
83
+ let binary;
84
+ for (let offset = 12; offset < bytes.length;) {
85
+ if (offset + 8 > bytes.length)
86
+ throw new Error("truncated GLB chunk header");
87
+ const length = header.getUint32(offset, true);
88
+ const kind = header.getUint32(offset + 4, true);
89
+ const start = offset + 8;
90
+ if (length % 4 !== 0 || start + length > bytes.length)
91
+ throw new Error("invalid GLB chunk length");
92
+ if (offset === 12 && kind !== 0x4e4f534a)
93
+ throw new Error("first GLB chunk must be JSON");
94
+ if (kind === 0x4e4f534a) {
95
+ if (json !== undefined)
96
+ throw new Error("duplicate GLB JSON chunk");
97
+ json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, start + length)));
98
+ }
99
+ else if (kind === 0x004e4942) {
100
+ if (binary !== undefined)
101
+ throw new Error("duplicate GLB BIN chunk");
102
+ binary = bytes.subarray(start, start + length);
103
+ }
104
+ offset = start + length;
105
+ }
106
+ if (json?.asset?.version !== "2.0")
107
+ throw new Error("missing glTF 2.0 asset metadata");
108
+ if (json.extensionsRequired?.some((name) => name === "KHR_draco_mesh_compression" || name === "EXT_meshopt_compression")) {
109
+ throw new Error("compressed geometry requires a decoder before budget measurement");
110
+ }
111
+ let byteLength = bytes.length;
112
+ const resources = new Map();
113
+ const resource = (uri) => {
114
+ if (resources.has(uri))
115
+ return resources.get(uri);
116
+ const result = uri.startsWith("data:") ? dataUri(uri) : readResource?.(uri);
117
+ if (result === undefined)
118
+ throw new Error(`external resource not supplied: ${uri}`);
119
+ if (!uri.startsWith("data:"))
120
+ byteLength += result.length;
121
+ resources.set(uri, result);
122
+ return result;
123
+ };
124
+ const buffers = (json.buffers ?? []).map((buffer, index) => {
125
+ const result = buffer.uri !== undefined ? resource(buffer.uri) : index === 0 ? binary : undefined;
126
+ const length = integer(buffer.byteLength, `buffer ${index} byteLength`);
127
+ if (result === undefined || result.length < length)
128
+ throw new Error(`buffer ${index}: missing or truncated bytes`);
129
+ return result.subarray(0, length);
130
+ });
131
+ const bufferView = (index) => {
132
+ integer(index, "bufferView index");
133
+ const entry = json.bufferViews?.[index];
134
+ if (entry === undefined)
135
+ throw new Error(`bufferView ${index}: missing`);
136
+ const buffer = buffers[integer(entry.buffer, "buffer index")];
137
+ const offset = integer(entry.byteOffset ?? 0, "bufferView byteOffset");
138
+ const length = integer(entry.byteLength, "bufferView byteLength");
139
+ if (buffer === undefined || offset + length > buffer.length)
140
+ throw new Error(`bufferView ${index}: out of bounds`);
141
+ return {
142
+ bytes: buffer.subarray(offset, offset + length),
143
+ stride: entry.byteStride,
144
+ };
145
+ };
146
+ const accessor = (index, indices) => {
147
+ integer(index, "accessor index");
148
+ const entry = json.accessors?.[index];
149
+ if (entry === undefined)
150
+ throw new Error(`accessor ${index}: missing`);
151
+ const count = integer(entry.count, `accessor ${index} count`, 1);
152
+ const componentBytes = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }[entry.componentType];
153
+ if (componentBytes === undefined ||
154
+ (indices && ![5121, 5123, 5125].includes(entry.componentType)) ||
155
+ entry.type !== (indices ? "SCALAR" : "VEC3")) {
156
+ throw new Error(`accessor ${index}: invalid ${indices ? "index" : "POSITION"} type`);
157
+ }
158
+ const components = indices ? 1 : 3;
159
+ const width = componentBytes * components;
160
+ let read;
161
+ let maxIndex;
162
+ if (entry.bufferView !== undefined) {
163
+ const source = bufferView(entry.bufferView);
164
+ const stride = integer(source.stride ?? width, "accessor stride", width);
165
+ const offset = integer(entry.byteOffset ?? 0, "accessor byteOffset");
166
+ if (stride % componentBytes !== 0 ||
167
+ offset % componentBytes !== 0 ||
168
+ offset + (count - 1) * stride + width > source.bytes.length)
169
+ throw new Error(`accessor ${index}: out of bounds or misaligned`);
170
+ if (indices) {
171
+ const view = new DataView(source.bytes.buffer, source.bytes.byteOffset, source.bytes.length);
172
+ read = (i) => componentBytes === 1
173
+ ? view.getUint8(offset + i * stride)
174
+ : componentBytes === 2
175
+ ? view.getUint16(offset + i * stride, true)
176
+ : view.getUint32(offset + i * stride, true);
177
+ }
178
+ }
179
+ else if (entry.sparse === undefined) {
180
+ throw new Error(`accessor ${index}: no bufferView or sparse values`);
181
+ }
182
+ if (entry.sparse !== undefined) {
183
+ const sparse = entry.sparse;
184
+ const sparseCount = integer(sparse.count, "sparse count", 1);
185
+ const source = bufferView(sparse.indices.bufferView).bytes;
186
+ const values = bufferView(sparse.values.bufferView).bytes;
187
+ const size = { 5121: 1, 5123: 2, 5125: 4 }[sparse.indices.componentType];
188
+ const offset = integer(sparse.indices.byteOffset ?? 0, "sparse indices offset");
189
+ const valueOffset = integer(sparse.values.byteOffset ?? 0, "sparse values offset");
190
+ if (size === undefined ||
191
+ sparseCount > count ||
192
+ offset % size !== 0 ||
193
+ valueOffset % componentBytes !== 0 ||
194
+ offset + sparseCount * size > source.length ||
195
+ valueOffset + sparseCount * width > values.length)
196
+ throw new Error(`accessor ${index}: malformed sparse values`);
197
+ const view = new DataView(source.buffer, source.byteOffset, source.length);
198
+ const valueView = new DataView(values.buffer, values.byteOffset, values.length);
199
+ const replacements = new Map();
200
+ let previous = -1;
201
+ for (let i = 0; i < sparseCount; i += 1) {
202
+ const position = size === 1
203
+ ? view.getUint8(offset + i * size)
204
+ : size === 2
205
+ ? view.getUint16(offset + i * size, true)
206
+ : view.getUint32(offset + i * size, true);
207
+ if (position <= previous || position >= count)
208
+ throw new Error(`accessor ${index}: sparse indices must be increasing and in bounds`);
209
+ previous = position;
210
+ if (indices)
211
+ replacements.set(position, componentBytes === 1
212
+ ? valueView.getUint8(valueOffset + i * width)
213
+ : componentBytes === 2
214
+ ? valueView.getUint16(valueOffset + i * width, true)
215
+ : valueView.getUint32(valueOffset + i * width, true));
216
+ }
217
+ if (indices) {
218
+ const baseRead = read;
219
+ read = (i) => replacements.get(i) ?? baseRead?.(i) ?? 0;
220
+ if (baseRead === undefined) {
221
+ maxIndex = 0;
222
+ for (const value of replacements.values())
223
+ maxIndex = Math.max(maxIndex, value);
224
+ }
225
+ }
226
+ }
227
+ return { count, read, maxIndex };
228
+ };
229
+ let triangles = 0;
230
+ let primitives = 0;
231
+ for (const [meshIndex, mesh] of (json.meshes ?? []).entries()) {
232
+ for (const [primitiveIndex, primitive] of mesh.primitives.entries()) {
233
+ const label = `mesh ${meshIndex} primitive ${primitiveIndex}`;
234
+ if (primitive.extensions?.KHR_draco_mesh_compression !== undefined)
235
+ throw new Error(`${label}: Draco geometry requires a decoder`);
236
+ const position = primitive.attributes?.POSITION;
237
+ if (position === undefined)
238
+ throw new Error(`${label}: missing POSITION`);
239
+ const vertices = accessor(position, false);
240
+ let count = vertices.count;
241
+ if (primitive.indices !== undefined) {
242
+ const indices = accessor(primitive.indices, true);
243
+ count = indices.count;
244
+ if (indices.maxIndex !== undefined) {
245
+ if (indices.maxIndex >= vertices.count)
246
+ throw new Error(`${label}: sparse index exceeds POSITION count ${vertices.count}`);
247
+ }
248
+ else {
249
+ for (let i = 0; i < count; i += 1) {
250
+ if (indices.read(i) >= vertices.count)
251
+ throw new Error(`${label}: index ${i} exceeds POSITION count ${vertices.count}`);
252
+ }
253
+ }
254
+ }
255
+ const mode = integer(primitive.mode ?? 4, `${label} mode`);
256
+ if (mode > 6)
257
+ throw new Error(`${label}: unsupported primitive mode ${mode}`);
258
+ if (mode === 4 && count % 3 !== 0)
259
+ throw new Error(`${label}: TRIANGLES count ${count} is not divisible by 3`);
260
+ triangles += mode === 4 ? count / 3 : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
261
+ primitives += 1;
262
+ }
263
+ }
264
+ const textures = (json.images ?? []).map((image, index) => {
265
+ const imageBytes = image.uri !== undefined
266
+ ? resource(image.uri)
267
+ : image.bufferView !== undefined
268
+ ? bufferView(image.bufferView).bytes
269
+ : undefined;
270
+ if (imageBytes === undefined)
271
+ throw new Error(`image ${index}: missing bytes`);
272
+ let dimensions;
273
+ try {
274
+ dimensions = imageSize(imageBytes);
275
+ }
276
+ catch (error) {
277
+ throw new Error(`image ${index}: ${error instanceof Error ? error.message : String(error)}`);
278
+ }
279
+ const width = integer(dimensions[0], `image ${index} width`, 1);
280
+ const height = integer(dimensions[1], `image ${index} height`, 1);
281
+ return { image: index, width, height, byteLength: imageBytes.length };
282
+ });
283
+ return {
284
+ glbBytes: bytes.length,
285
+ byteLength,
286
+ triangles,
287
+ primitives,
288
+ textures,
289
+ maxTextureDimension: textures.reduce((maximum, texture) => Math.max(maximum, texture.width, texture.height), 0),
290
+ };
291
+ }