@jgengine/assets 0.19.0 → 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/CHANGELOG.md +346 -0
- package/README.md +22 -3
- package/dist/assetBudget.d.ts +36 -0
- package/dist/assetBudget.js +110 -0
- package/dist/catalogs/build.d.ts +1 -0
- package/dist/catalogs/build.js +1 -0
- package/dist/cli/pull.js +56 -1
- package/dist/glbMetrics.d.ts +24 -0
- package/dist/glbMetrics.js +291 -0
- package/dist/gltfMaterials.d.ts +79 -0
- package/dist/gltfMaterials.js +183 -0
- package/dist/importSpec.d.ts +15 -6
- package/dist/importSpec.js +217 -45
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/indexGen.d.ts +3 -1
- package/dist/indexGen.js +14 -4
- package/dist/manifest.d.ts +7 -0
- package/dist/materials.d.ts +3 -3
- package/dist/materials.js +3 -3
- package/dist/snippet.js +1 -1
- package/dist/sources/ambientcg.js +37 -31
- package/dist/staticPrefabBake.d.ts +58 -0
- package/dist/staticPrefabBake.js +315 -0
- package/dist/verify.js +2 -2
- package/package.json +5 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +22 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-assets/SKILL.md +24 -3
- package/skills/jgengine-assets/api.md +90 -15
- package/skills/jgengine-assets/capabilities.md +41 -0
- package/skills/jgengine-assets/references/custom-imports.md +55 -0
|
@@ -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
|
+
}
|
package/dist/catalogs/build.d.ts
CHANGED
|
@@ -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[];
|
package/dist/catalogs/build.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GLTF } from "@gltf-transform/core";
|
|
2
|
+
import type { MaterialTextureMetadata, MaterialTextureRole } from "@jgengine/core/material/materialAsset";
|
|
3
|
+
/** Native material/texture extensions implemented by Three.js GLTFLoader r182. Decoders are still required. */
|
|
4
|
+
export declare const GLTF_MATERIAL_EXTENSIONS_R182: readonly string[];
|
|
5
|
+
/** A native glTF texture use; embedded images retain their source index instead of an invented URL. */
|
|
6
|
+
export interface GltfMaterialTexture {
|
|
7
|
+
/** Bump is native normal perturbation, distinct from authored height displacement. */
|
|
8
|
+
role: MaterialTextureRole | "bump";
|
|
9
|
+
texture: number;
|
|
10
|
+
image: number;
|
|
11
|
+
sourceExtension?: string;
|
|
12
|
+
mimeType?: string;
|
|
13
|
+
bufferView?: number;
|
|
14
|
+
metadata: Omit<MaterialTextureMetadata, "url"> & {
|
|
15
|
+
url?: string;
|
|
16
|
+
};
|
|
17
|
+
/** Packed source channel meanings; anisotropy RG direction is remapped to [-1, 1]. */
|
|
18
|
+
channels: Readonly<Record<string, string>>;
|
|
19
|
+
/** Complete native textureInfo, including unrecognized extensions. */
|
|
20
|
+
native: GLTF.ITextureInfo;
|
|
21
|
+
}
|
|
22
|
+
/** One imported material with the native definition retained as an independent JSON snapshot. */
|
|
23
|
+
export interface GltfImportedMaterial {
|
|
24
|
+
/** Stable within the pinned model bytes; duplicate or absent material names are safe. */
|
|
25
|
+
id: string;
|
|
26
|
+
index: number;
|
|
27
|
+
name?: string;
|
|
28
|
+
alphaMode: "OPAQUE" | "MASK" | "BLEND";
|
|
29
|
+
alphaCutoff: number;
|
|
30
|
+
doubleSided: boolean;
|
|
31
|
+
extensions: readonly string[];
|
|
32
|
+
textures: readonly GltfMaterialTexture[];
|
|
33
|
+
native: GLTF.IMaterial;
|
|
34
|
+
}
|
|
35
|
+
/** A primitive slot retains mesh and material indices even when display names collide. */
|
|
36
|
+
export interface GltfMaterialSlot {
|
|
37
|
+
id: string;
|
|
38
|
+
mesh: number;
|
|
39
|
+
meshName?: string;
|
|
40
|
+
primitive: number;
|
|
41
|
+
material?: number;
|
|
42
|
+
materialId?: string;
|
|
43
|
+
materialName?: string;
|
|
44
|
+
nodes: readonly {
|
|
45
|
+
index: number;
|
|
46
|
+
name?: string;
|
|
47
|
+
}[];
|
|
48
|
+
uvSets: readonly number[];
|
|
49
|
+
hasNormals: boolean;
|
|
50
|
+
hasTangents: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** Actionable fidelity warning or error; this inspector never changes imported surfaces. */
|
|
53
|
+
export interface GltfMaterialDiagnostic {
|
|
54
|
+
severity: "warning" | "error";
|
|
55
|
+
code: "unsupported-extension" | "missing-uv" | "missing-tangent-space" | "incompatible-extensions" | "missing-transmission" | "different-direction-uv";
|
|
56
|
+
path: string;
|
|
57
|
+
message: string;
|
|
58
|
+
}
|
|
59
|
+
/** Native material inventory, primitive selectors, attribution, and renderer compatibility diagnostics. */
|
|
60
|
+
export interface GltfMaterialInspection {
|
|
61
|
+
assetId: string;
|
|
62
|
+
materials: readonly GltfImportedMaterial[];
|
|
63
|
+
slots: readonly GltfMaterialSlot[];
|
|
64
|
+
diagnostics: readonly GltfMaterialDiagnostic[];
|
|
65
|
+
copyright?: string;
|
|
66
|
+
extras?: unknown;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Inventory validated glTF JSON without flattening slots, decoding compressed geometry, or changing materials.
|
|
70
|
+
* IDs use the supplied model namespace plus source indices, and remain stable while its pinned bytes remain stable.
|
|
71
|
+
* @capability gltf-material-inspection inspect native material slots, packed maps, attribution and compatibility
|
|
72
|
+
*/
|
|
73
|
+
export declare function inspectGltfMaterials(json: GLTF.IGLTF, assetId: string): GltfMaterialInspection;
|
|
74
|
+
/**
|
|
75
|
+
* Inspect the JSON chunk of complete GLB v2 bytes, including models with compressed geometry.
|
|
76
|
+
* This measures material metadata only; it never decodes geometry or resolves image URLs.
|
|
77
|
+
* @capability gltf-material-inspection inspect material slots and texture metadata directly from complete GLB bytes
|
|
78
|
+
*/
|
|
79
|
+
export declare function inspectGlbMaterials(bytes: Uint8Array, assetId: string): GltfMaterialInspection;
|