@forgeax/engine-gltf 0.1.20 → 0.1.23

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.
Files changed (49) hide show
  1. package/README.md +59 -0
  2. package/dist/__tests__/lod-import.integration.test.d.ts +2 -0
  3. package/dist/__tests__/lod-import.integration.test.d.ts.map +1 -0
  4. package/dist/__tests__/lod-meta-publication.integration.test.d.ts +2 -0
  5. package/dist/__tests__/lod-meta-publication.integration.test.d.ts.map +1 -0
  6. package/dist/check-extensions.d.ts.map +1 -1
  7. package/dist/cli-gltf.mjs +183 -8
  8. package/dist/cli-gltf.mjs.map +1 -1
  9. package/dist/errors.d.ts +6 -0
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/gltf-importer.d.ts.map +1 -1
  12. package/dist/importer-entry.mjs +145 -3
  13. package/dist/importer-entry.mjs.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +238 -6
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/lod/parse-lod.d.ts +15 -0
  19. package/dist/lod/parse-lod.d.ts.map +1 -0
  20. package/dist/lod/project-meta.d.ts +23 -0
  21. package/dist/lod/project-meta.d.ts.map +1 -0
  22. package/dist/node-file-entry.mjs +86 -2
  23. package/dist/node-file-entry.mjs.map +1 -1
  24. package/dist/parse-gltf.d.ts +3 -1
  25. package/dist/parse-gltf.d.ts.map +1 -1
  26. package/dist/reimport-reuse-meta.d.ts +1 -1
  27. package/dist/reimport-reuse-meta.d.ts.map +1 -1
  28. package/package.json +12 -12
  29. package/src/__tests__/fixtures/lod/core-only.gltf +1 -0
  30. package/src/__tests__/fixtures/lod/msft-lod-malformed.gltf +1 -0
  31. package/src/__tests__/fixtures/lod/msft-lod-multi-root.gltf +1 -0
  32. package/src/__tests__/fixtures/lod/msft-lod-node-extras.gltf +1 -0
  33. package/src/__tests__/fixtures/lod/msft-lod.gltf +1 -0
  34. package/src/__tests__/gltf-error-derived-views.test-d.ts +4 -0
  35. package/src/__tests__/gltf.unit.test.ts +12 -3
  36. package/src/__tests__/lod-import.integration.test.ts +106 -0
  37. package/src/__tests__/lod-meta-publication.integration.test.ts +24 -0
  38. package/src/__tests__/parse-gltf.unit.test.ts +22 -0
  39. package/src/__tests__/source-key-producer.integration.test.ts +6 -2
  40. package/src/bridge.ts +1 -1
  41. package/src/check-extensions.ts +2 -0
  42. package/src/errors.ts +11 -0
  43. package/src/gltf-importer.ts +87 -1
  44. package/src/index.ts +2 -0
  45. package/src/lod/parse-lod.ts +129 -0
  46. package/src/lod/project-meta.ts +48 -0
  47. package/src/parse-gltf.ts +97 -1
  48. package/src/reimport-reuse-meta.ts +3 -1
  49. package/dist/.tsbuildinfo +0 -1
@@ -0,0 +1,129 @@
1
+ import { err, gltfErr, ok, type Result } from '../errors.js';
2
+
3
+ export interface GltfLodRelation {
4
+ readonly rootNode: number;
5
+ readonly lodNodeIds: readonly number[];
6
+ readonly screenCoverages: readonly number[];
7
+ /** All node-level MSFT_lod groups, retained for multi-root scenes. */
8
+ readonly groups: readonly GltfLodGroup[];
9
+ }
10
+
11
+ export interface GltfLodGroup {
12
+ readonly rootNode: number;
13
+ readonly lodNodeIds: readonly number[];
14
+ readonly screenCoverages: readonly number[];
15
+ }
16
+
17
+ interface RawNode {
18
+ readonly mesh?: unknown;
19
+ readonly extensions?: { readonly MSFT_lod?: { readonly ids?: unknown } };
20
+ readonly extras?: { readonly MSFT_screencoverage?: unknown };
21
+ }
22
+
23
+ interface RawMesh {
24
+ readonly primitives?: unknown;
25
+ }
26
+
27
+ interface RawGltf {
28
+ readonly extensionsRequired?: readonly unknown[];
29
+ readonly meshes?: readonly RawMesh[];
30
+ readonly nodes?: readonly RawNode[];
31
+ readonly extensions?: { readonly MSFT_screencoverage?: { readonly scales?: unknown } };
32
+ }
33
+
34
+ export function parseGltfLodExtension(
35
+ json: unknown,
36
+ ): Result<GltfLodRelation, ReturnType<typeof gltfErr<'gltf-lod-invalid'>>> {
37
+ const doc = json as RawGltf;
38
+ const nodes = doc.nodes ?? [];
39
+ const rawCoverage = doc.extensions?.MSFT_screencoverage?.scales;
40
+ const groups: GltfLodGroup[] = [];
41
+ for (let rootNode = 0; rootNode < nodes.length; rootNode += 1) {
42
+ if (nodes[rootNode]?.extensions?.MSFT_lod === undefined) continue;
43
+ const idsRaw = nodes[rootNode]?.extensions?.MSFT_lod?.ids;
44
+ if (!Array.isArray(idsRaw)) {
45
+ return err(gltfErr('gltf-lod-invalid', { rootNode, ids: [], reason: 'not-integer' }));
46
+ }
47
+ const ids = idsRaw.filter((value): value is number => typeof value === 'number');
48
+ if (
49
+ ids.length !== idsRaw.length ||
50
+ ids.some((id) => !Number.isInteger(id) || id < 0 || id >= nodes.length)
51
+ ) {
52
+ return err(gltfErr('gltf-lod-invalid', { rootNode, ids, reason: 'missing-node' }));
53
+ }
54
+ if (new Set(ids).size !== ids.length || ids.includes(rootNode)) {
55
+ return err(gltfErr('gltf-lod-invalid', { rootNode, ids, reason: 'duplicate-node' }));
56
+ }
57
+ const hasMesh = (node: RawNode | undefined): boolean => {
58
+ if (typeof node?.mesh !== 'number' || !Number.isInteger(node.mesh) || node.mesh < 0) {
59
+ return false;
60
+ }
61
+ const mesh = doc.meshes?.[node.mesh];
62
+ // An extension relation is publishable only when its target mesh has at
63
+ // least one primitive. The parser normally receives the source mesh
64
+ // table, so an empty table/primitive list is a source-contract failure;
65
+ // treating it as a valid relation would let toAssetPack silently drop
66
+ // the LOD level later.
67
+ return mesh !== undefined && Array.isArray(mesh.primitives) && mesh.primitives.length > 0;
68
+ };
69
+ if (!hasMesh(nodes[rootNode]) || ids.some((id) => !hasMesh(nodes[id]))) {
70
+ return err(gltfErr('gltf-lod-invalid', { rootNode, ids, reason: 'missing-node' }));
71
+ }
72
+ const nodeCoverage = nodes[rootNode]?.extras?.MSFT_screencoverage;
73
+ const coverage = normalizeScreenCoverages(
74
+ nodeCoverage === undefined ? rawCoverage : nodeCoverage,
75
+ ids.length,
76
+ nodeCoverage !== undefined,
77
+ );
78
+ if (coverage === 'invalid') {
79
+ return err(gltfErr('gltf-lod-invalid', { rootNode, ids, reason: 'coverage' }));
80
+ }
81
+ groups.push({
82
+ rootNode,
83
+ lodNodeIds: ids,
84
+ screenCoverages: coverage ?? [],
85
+ });
86
+ }
87
+ const first = groups[0];
88
+ if (first === undefined) {
89
+ if (doc.extensionsRequired?.some((extension) => extension === 'MSFT_lod')) {
90
+ return err(gltfErr('gltf-lod-invalid', { rootNode: 0, ids: [], reason: 'missing-node' }));
91
+ }
92
+ return ok({ rootNode: 0, lodNodeIds: [], screenCoverages: [], groups: [] });
93
+ }
94
+ return ok({
95
+ rootNode: first.rootNode,
96
+ lodNodeIds: first.lodNodeIds,
97
+ screenCoverages: first.screenCoverages,
98
+ groups,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Normalize the official node `extras.MSFT_screencoverage` form and the
104
+ * historical ForgeaX document-extension adapter form. The official array
105
+ * includes one terminal threshold for the discard range, while MeshAsset
106
+ * stores only the thresholds that select lower geometry levels.
107
+ */
108
+ function normalizeScreenCoverages(
109
+ raw: unknown,
110
+ lodCount: number,
111
+ officialNodeForm: boolean,
112
+ ): readonly number[] | undefined | 'invalid' {
113
+ if (raw === undefined) return undefined;
114
+ if (!Array.isArray(raw)) return 'invalid';
115
+ if (
116
+ raw.some(
117
+ (value) => typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1,
118
+ )
119
+ ) {
120
+ return 'invalid';
121
+ }
122
+ const expectedLengths = officialNodeForm ? [lodCount + 1] : [lodCount, lodCount + 1];
123
+ if (!expectedLengths.includes(raw.length)) return 'invalid';
124
+ const values = raw.slice(0, lodCount) as number[];
125
+ for (let index = 1; index < values.length; index += 1) {
126
+ if ((values[index] as number) >= (values[index - 1] as number)) return 'invalid';
127
+ }
128
+ return values;
129
+ }
@@ -0,0 +1,48 @@
1
+ import { deriveDefaultLodScreenCoverages, reconcileMeshLodMeta } from '@forgeax/engine-import';
2
+ import { err, ok, type Result } from '@forgeax/engine-types';
3
+
4
+ export interface GltfLodMetaLevel {
5
+ readonly sourceKey: string;
6
+ readonly guid: string;
7
+ readonly screenCoverage?: number;
8
+ }
9
+
10
+ export interface GltfLodMetaCandidate {
11
+ readonly rootSourceKey: string;
12
+ readonly levels: readonly GltfLodMetaLevel[];
13
+ readonly previous?: readonly GltfLodMetaLevel[] | undefined;
14
+ }
15
+
16
+ export interface GltfLodMetaOutput {
17
+ readonly sourceKey: string;
18
+ readonly meshGuid: string;
19
+ readonly screenCoverage: number;
20
+ }
21
+
22
+ export function projectGltfLodMeta(
23
+ input: GltfLodMetaCandidate,
24
+ ): Result<
25
+ { readonly lods: readonly GltfLodMetaOutput[] },
26
+ { readonly code: string; readonly reason: string }
27
+ > {
28
+ const defaults = deriveDefaultLodScreenCoverages(input.levels.length + 1);
29
+ const previous = (input.previous ?? []).map((level) => ({
30
+ sourceKey: level.sourceKey,
31
+ meshGuid: level.guid,
32
+ ...(level.screenCoverage === undefined ? {} : { screenCoverage: level.screenCoverage }),
33
+ }));
34
+ const next = input.levels.map((level, index) => ({
35
+ sourceKey: level.sourceKey,
36
+ meshGuid: level.guid,
37
+ screenCoverage: level.screenCoverage ?? defaults[index] ?? 0,
38
+ }));
39
+ const reconciled = reconcileMeshLodMeta(previous, next);
40
+ if (!reconciled.ok) return err({ code: reconciled.error.code, reason: reconciled.error.reason });
41
+ return ok({
42
+ lods: reconciled.value.lods.map((level) => ({
43
+ sourceKey: level.sourceKey,
44
+ meshGuid: level.meshGuid,
45
+ screenCoverage: level.screenCoverage,
46
+ })),
47
+ });
48
+ }
package/src/parse-gltf.ts CHANGED
@@ -19,6 +19,8 @@ import { decodeColorAccessor } from './accessor/decode-color.js';
19
19
  import { checkExtensions, type GltfExtensionsJson } from './check-extensions.js';
20
20
  import { Base64DecodeError, dataUriBase64Payload, decodeBase64 } from './data-uri.js';
21
21
  import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
22
+ import { type GltfLodRelation, parseGltfLodExtension } from './lod/parse-lod.js';
23
+ import { projectGltfLodMeta } from './lod/project-meta.js';
22
24
  import {
23
25
  type GltfImageIr,
24
26
  type GltfMaterialIr,
@@ -341,6 +343,7 @@ export interface GltfDoc {
341
343
  * fixtures; producers from parseGltf / parseGlb always populate it.
342
344
  */
343
345
  readonly meshPrimitiveCount?: ReadonlyMap<number, number>;
346
+ readonly lod?: GltfLodRelation;
344
347
  }
345
348
 
346
349
  interface BuffersJson {
@@ -363,6 +366,7 @@ interface RootGltfJson extends GltfExtensionsJson {
363
366
  readonly rotation?: readonly number[];
364
367
  readonly scale?: readonly number[];
365
368
  readonly weights?: readonly number[];
369
+ readonly extras?: { readonly MSFT_screencoverage?: readonly number[] };
366
370
  readonly extensions?: {
367
371
  readonly KHR_lights_punctual?: {
368
372
  readonly light?: number;
@@ -374,6 +378,7 @@ interface RootGltfJson extends GltfExtensionsJson {
374
378
  readonly SCALE?: number;
375
379
  };
376
380
  };
381
+ readonly MSFT_lod?: { readonly ids?: readonly number[] };
377
382
  };
378
383
  }>;
379
384
  readonly extensions?: {
@@ -389,6 +394,7 @@ interface RootGltfJson extends GltfExtensionsJson {
389
394
  };
390
395
  }>;
391
396
  };
397
+ readonly MSFT_screencoverage?: { readonly scales?: readonly number[] };
392
398
  };
393
399
  readonly skins?: ReadonlyArray<{
394
400
  readonly name?: string;
@@ -565,6 +571,8 @@ async function parseGltfWithBin(
565
571
  const extResult = checkExtensions(json);
566
572
  if (!extResult.ok) return err(extResult.error);
567
573
  const unsupportedExtensions = extResult.value.unsupportedUsed;
574
+ const lodResult = parseGltfLodExtension(json);
575
+ if (!lodResult.ok) return err(lodResult.error);
568
576
  const lightsResult = parsePunctualLights(json, ctx.filePath);
569
577
  if (!lightsResult.ok) return err(lightsResult.error);
570
578
 
@@ -1183,6 +1191,7 @@ async function parseGltfWithBin(
1183
1191
  },
1184
1192
  meshPrimitiveCount,
1185
1193
  lights: lightsResult.value,
1194
+ ...(lodResult.value.lodNodeIds.length === 0 ? {} : { lod: lodResult.value }),
1186
1195
  });
1187
1196
  }
1188
1197
 
@@ -1293,7 +1302,7 @@ export interface GltfAssetPack {
1293
1302
  readonly subAssets: readonly GltfSubAssetEntry[];
1294
1303
  }
1295
1304
 
1296
- export type GltfAssetPackResult = Result<GltfAssetPack, GltfSourceKeyError>;
1305
+ export type GltfAssetPackResult = Result<GltfAssetPack, GltfSourceKeyError | GltfError>;
1297
1306
 
1298
1307
  /**
1299
1308
  * Project a parsed `GltfDoc` into the disk-shape `<source>.meta.json` plus
@@ -1407,6 +1416,93 @@ export function toAssetPack(
1407
1416
  const sourceOverrides: Record<string, Readonly<Record<string, unknown>>> = {
1408
1417
  ...(existingMeta?.sourceOverrides ?? {}),
1409
1418
  };
1419
+ const lodGroups =
1420
+ doc.lod === undefined ? [] : doc.lod.groups.length > 0 ? doc.lod.groups : [doc.lod];
1421
+ for (const lodGroup of lodGroups) {
1422
+ if (lodGroup.lodNodeIds.length === 0) continue;
1423
+ const rootMeshIndex = doc.nodes[lodGroup.rootNode]?.meshIndex;
1424
+ const referencedMeshIndices = [
1425
+ rootMeshIndex,
1426
+ ...lodGroup.lodNodeIds.map((nodeIndex) => doc.nodes[nodeIndex]?.meshIndex),
1427
+ ];
1428
+ if (
1429
+ referencedMeshIndices.some(
1430
+ (meshIndex) =>
1431
+ !Number.isInteger(meshIndex) ||
1432
+ meshIndex === null ||
1433
+ meshIndex === undefined ||
1434
+ !seenMeshIndices.has(meshIndex as number),
1435
+ )
1436
+ ) {
1437
+ return err(
1438
+ gltfErr('gltf-lod-invalid', {
1439
+ rootNode: lodGroup.rootNode,
1440
+ ids: lodGroup.lodNodeIds,
1441
+ reason: 'missing-node',
1442
+ }),
1443
+ );
1444
+ }
1445
+ const rootOutput =
1446
+ rootMeshIndex === undefined || rootMeshIndex === null
1447
+ ? undefined
1448
+ : subAssetByKindIndex.get(`mesh:${rootMeshIndex}`);
1449
+ const levels = lodGroup.lodNodeIds.map((nodeIndex, index) => {
1450
+ const meshIndex = doc.nodes[nodeIndex]?.meshIndex;
1451
+ const output =
1452
+ meshIndex === undefined || meshIndex === null
1453
+ ? undefined
1454
+ : subAssetByKindIndex.get(`mesh:${meshIndex}`);
1455
+ return {
1456
+ sourceKey: output?.sourceKey ?? `mesh:${meshIndex ?? nodeIndex}`,
1457
+ meshGuid: output?.guid ?? '',
1458
+ ...(lodGroup.screenCoverages[index] === undefined
1459
+ ? {}
1460
+ : { screenCoverage: lodGroup.screenCoverages[index] }),
1461
+ };
1462
+ });
1463
+ if (rootOutput?.sourceKey !== undefined && levels.every((level) => level.meshGuid !== '')) {
1464
+ const previousLodMeta = (() => {
1465
+ const raw = existingMeta?.sourceOverrides?.[rootOutput.sourceKey]?.lods;
1466
+ if (!Array.isArray(raw)) return undefined;
1467
+ return raw.flatMap((entry) => {
1468
+ if (entry === null || typeof entry !== 'object') return [];
1469
+ const value = entry as Record<string, unknown>;
1470
+ return typeof value.sourceKey === 'string' && typeof value.meshGuid === 'string'
1471
+ ? [
1472
+ {
1473
+ sourceKey: value.sourceKey,
1474
+ guid: value.meshGuid,
1475
+ ...(typeof value.screenCoverage === 'number'
1476
+ ? { screenCoverage: value.screenCoverage }
1477
+ : {}),
1478
+ },
1479
+ ]
1480
+ : [];
1481
+ });
1482
+ })();
1483
+ const projected = projectGltfLodMeta({
1484
+ rootSourceKey: rootOutput.sourceKey,
1485
+ levels: levels.map((level) => ({
1486
+ sourceKey: level.sourceKey,
1487
+ guid: level.meshGuid,
1488
+ ...(level.screenCoverage === undefined ? {} : { screenCoverage: level.screenCoverage }),
1489
+ })),
1490
+ ...(previousLodMeta === undefined ? {} : { previous: previousLodMeta }),
1491
+ });
1492
+ if (!projected.ok)
1493
+ return err(
1494
+ gltfErr('gltf-lod-invalid', {
1495
+ rootNode: lodGroup.rootNode,
1496
+ ids: lodGroup.lodNodeIds,
1497
+ reason: 'coverage',
1498
+ }),
1499
+ );
1500
+ sourceOverrides[rootOutput.sourceKey] = {
1501
+ ...(sourceOverrides[rootOutput.sourceKey] ?? {}),
1502
+ lods: projected.value.lods,
1503
+ };
1504
+ }
1505
+ }
1410
1506
  for (const meshIndex of seenMeshIndices) {
1411
1507
  const meshOutput = subAssetByKindIndex.get(`mesh:${meshIndex}`);
1412
1508
  if (meshOutput?.sourceKey === undefined) continue;
@@ -49,7 +49,9 @@ export interface GltfMetaJson {
49
49
  readonly importer: 'gltf';
50
50
  readonly source: string;
51
51
  readonly subAssets: readonly GltfSubAssetEntry[];
52
- readonly sourceOverrides?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
52
+ readonly sourceOverrides?:
53
+ | Readonly<Record<string, Readonly<Record<string, unknown>>>>
54
+ | undefined;
53
55
  readonly sourceOverrideDescriptors?: readonly import('@forgeax/engine-types').SourceOverrideDescriptor[];
54
56
  readonly importSettings: {
55
57
  readonly defaultSceneIndex: number;