@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
package/README.md CHANGED
@@ -210,4 +210,63 @@ forgeax-engine-remote-gltf import apps/hello/gltf/assets/box.glb
210
210
  forgeax-engine-remote-gltf import apps/hello/gltf/assets/ --check
211
211
  ```
212
212
 
213
+ ### End-to-end source-to-Pack route
214
+
215
+ The CLI creates the authoring sidecar; the shared import runner is the single
216
+ Pack/DDC publication path. Keep these steps together when adding a glTF LOD:
217
+
218
+ ```mermaid
219
+ flowchart LR
220
+ source["source .gltf/.glb"] --> cli["remote-gltf import"]
221
+ cli --> meta["source.meta.json"]
222
+ meta --> registry["ImporterRegistry + gltfImporter"]
223
+ registry --> runner["runImport"]
224
+ runner --> pack["Pack/DDC + Catalog"]
225
+ ```
226
+
227
+ 1. Run `forgeax-engine-remote-gltf import <source>` to create or refresh the
228
+ adjacent `<source>.meta.json` and its stable sub-asset GUIDs.
229
+ 2. Register `gltfImporter` with the build-time `ImporterRegistry` and pass the
230
+ sidecar to `runImport`; this validates the declared LOD GUID closure and
231
+ writes the Pack/DDC output.
232
+ 3. Verify the resulting Catalog/receipt and load the root GUID at runtime. The
233
+ complete `ImporterRegistry`/`runImport` example, including the filesystem
234
+ adapter, lives in [`@forgeax/engine-import`](../import/README.md#the-importload-split).
235
+
213
236
  See `.forgeax-harness/forgeax-loop/feat-20260515-gltf-loader-via-asset-system/plan-strategy.md` for the full roadmap.
237
+
238
+ ## LOD extensions
239
+
240
+ The glTF adapter admits `MSFT_lod` at node level. The extension's `ids` array
241
+ is preserved in producer order; each referenced root or lower node must resolve
242
+ to a non-empty mesh sub-asset. Material, name, and guessed suffix conventions
243
+ do not create LOD relations.
244
+
245
+ The portable Khronos form stores coverage on the root LOD node:
246
+
247
+ ```json
248
+ {
249
+ "extensions": { "MSFT_lod": { "ids": [1, 2] } },
250
+ "extras": { "MSFT_screencoverage": [0.5, 0.2, 0.01] }
251
+ }
252
+ ```
253
+
254
+ The array describes the root and lower ranges and includes a terminal discard
255
+ threshold. The adapter keeps `[0.5, 0.2]` as the two lower-level absolute
256
+ `MeshAsset.lods[].screenCoverage` values and drops only that terminal discard
257
+ entry, which has no `MeshAsset` representation. The historical document-level
258
+ `extensions.MSFT_screencoverage.scales` form remains an internal fixture
259
+ adapter; node `extras` takes precedence when both forms are present.
260
+
261
+ | Source fact | Projection | Recovery |
262
+ |:--|:--|:--|
263
+ | `MSFT_lod.ids` | root MeshAsset `lods[]` and `refs[]` | repair node references and reimport |
264
+ | root `extras.MSFT_screencoverage` | absolute coverage values (terminal discard omitted) | repair values and reimport |
265
+ | legacy document `MSFT_screencoverage.scales` | explicit fixture coverage values | migrate to node `extras`, then recook |
266
+ | no coverage extension | shared importer defaults | keep existing sidecar values, then recook |
267
+
268
+ On malformed extension data, a required-but-missing `MSFT_lod` group, an empty
269
+ referenced mesh, or an unresolved mesh sub-asset, the importer returns
270
+ `gltf-lod-invalid` before publication. The old sidecar and Catalog LKG are the
271
+ recovery boundary; no relation is silently dropped by `flatMap` or a partial
272
+ Pack is published.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=lod-import.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lod-import.integration.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/lod-import.integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=lod-meta-publication.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lod-meta-publication.integration.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/lod-meta-publication.integration.test.ts"],"names":[],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"check-extensions.d.ts","sourceRoot":"","sources":["../src/check-extensions.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAO,KAAK,SAAS,EAAe,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAE5E,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAGhD,CAAC;AAUF,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,kBAAkB,GACvB,MAAM,CAAC,qBAAqB,EAAE,SAAS,CAAC,CAsB1C"}
1
+ {"version":3,"file":"check-extensions.d.ts","sourceRoot":"","sources":["../src/check-extensions.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAO,KAAK,SAAS,EAAe,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAE5E,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAKhD,CAAC;AAUF,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,kBAAkB,GACvB,MAAM,CAAC,qBAAqB,EAAE,SAAS,CAAC,CAsB1C"}
package/dist/cli-gltf.mjs CHANGED
@@ -3,8 +3,9 @@ import { realpath, stat, readFile, writeFile, readdir } from 'fs/promises';
3
3
  import { resolve, dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { parseArgs } from 'util';
6
- import { reconcileMeshMaterialSlotTopology, err, MESH_MATERIAL_SLOT_SOURCE_OVERRIDE_PAYLOAD_SCHEMA, ok, ImportError, IMPORT_ERROR_HINTS } from '@forgeax/engine-types';
6
+ import { err, reconcileMeshMaterialSlotTopology, MESH_MATERIAL_SLOT_SOURCE_OVERRIDE_PAYLOAD_SCHEMA, ok, ImportError, IMPORT_ERROR_HINTS } from '@forgeax/engine-types';
7
7
  import { vec3, quat, mat4 } from '@forgeax/engine-math';
8
+ import { deriveDefaultLodScreenCoverages, reconcileMeshLodMeta } from '@forgeax/engine-import';
8
9
  import { deriveAnimationTargetId } from '@forgeax/engine-animation/target-id';
9
10
  import { AssetGuid } from '@forgeax/engine-pack/guid';
10
11
 
@@ -27,6 +28,10 @@ var gltfErrorPolicy = {
27
28
  expected: "extension listed in v1 allowlist (see EXTENSION_ALLOWLIST in @forgeax/engine-gltf)",
28
29
  hint: "see feat-future-gltf-extensions-allowlist; remove this extension or wait for the allowlist to expand"
29
30
  },
31
+ "gltf-lod-invalid": {
32
+ expected: "MSFT_lod ids to reference unique existing node indices with valid coverage",
33
+ hint: "repair the MSFT_lod node relation or remove it from extensionsRequired before re-importing"
34
+ },
30
35
  "gltf-material-transmission-invalid": {
31
36
  expected: "KHR transmission, IOR, and volume values are finite and within their glTF ranges",
32
37
  hint: "repair the named glTF material extension value and re-import the source"
@@ -394,7 +399,9 @@ function decodeColorAccessor(input, flags = {}) {
394
399
  // src/check-extensions.ts
395
400
  var EXTENSION_ALLOWLIST = [
396
401
  "EXT_mesh_gpu_instancing",
397
- "EXT_meshopt_compression"
402
+ "EXT_meshopt_compression",
403
+ "MSFT_lod",
404
+ "MSFT_screencoverage"
398
405
  ];
399
406
  var SUPPORTED_EXTENSIONS = [
400
407
  ...EXTENSION_ALLOWLIST,
@@ -454,6 +461,103 @@ function decodeBase64(b64) {
454
461
  return out;
455
462
  }
456
463
 
464
+ // src/lod/parse-lod.ts
465
+ function parseGltfLodExtension(json) {
466
+ const doc = json;
467
+ const nodes = doc.nodes ?? [];
468
+ const rawCoverage = doc.extensions?.MSFT_screencoverage?.scales;
469
+ const groups = [];
470
+ for (let rootNode = 0; rootNode < nodes.length; rootNode += 1) {
471
+ if (nodes[rootNode]?.extensions?.MSFT_lod === void 0) continue;
472
+ const idsRaw = nodes[rootNode]?.extensions?.MSFT_lod?.ids;
473
+ if (!Array.isArray(idsRaw)) {
474
+ return err(gltfErr("gltf-lod-invalid", { rootNode, ids: [], reason: "not-integer" }));
475
+ }
476
+ const ids = idsRaw.filter((value) => typeof value === "number");
477
+ if (ids.length !== idsRaw.length || ids.some((id) => !Number.isInteger(id) || id < 0 || id >= nodes.length)) {
478
+ return err(gltfErr("gltf-lod-invalid", { rootNode, ids, reason: "missing-node" }));
479
+ }
480
+ if (new Set(ids).size !== ids.length || ids.includes(rootNode)) {
481
+ return err(gltfErr("gltf-lod-invalid", { rootNode, ids, reason: "duplicate-node" }));
482
+ }
483
+ const hasMesh = (node) => {
484
+ if (typeof node?.mesh !== "number" || !Number.isInteger(node.mesh) || node.mesh < 0) {
485
+ return false;
486
+ }
487
+ const mesh = doc.meshes?.[node.mesh];
488
+ return mesh !== void 0 && Array.isArray(mesh.primitives) && mesh.primitives.length > 0;
489
+ };
490
+ if (!hasMesh(nodes[rootNode]) || ids.some((id) => !hasMesh(nodes[id]))) {
491
+ return err(gltfErr("gltf-lod-invalid", { rootNode, ids, reason: "missing-node" }));
492
+ }
493
+ const nodeCoverage = nodes[rootNode]?.extras?.MSFT_screencoverage;
494
+ const coverage = normalizeScreenCoverages(
495
+ nodeCoverage === void 0 ? rawCoverage : nodeCoverage,
496
+ ids.length,
497
+ nodeCoverage !== void 0
498
+ );
499
+ if (coverage === "invalid") {
500
+ return err(gltfErr("gltf-lod-invalid", { rootNode, ids, reason: "coverage" }));
501
+ }
502
+ groups.push({
503
+ rootNode,
504
+ lodNodeIds: ids,
505
+ screenCoverages: coverage ?? []
506
+ });
507
+ }
508
+ const first = groups[0];
509
+ if (first === void 0) {
510
+ if (doc.extensionsRequired?.some((extension) => extension === "MSFT_lod")) {
511
+ return err(gltfErr("gltf-lod-invalid", { rootNode: 0, ids: [], reason: "missing-node" }));
512
+ }
513
+ return ok({ rootNode: 0, lodNodeIds: [], screenCoverages: [], groups: [] });
514
+ }
515
+ return ok({
516
+ rootNode: first.rootNode,
517
+ lodNodeIds: first.lodNodeIds,
518
+ screenCoverages: first.screenCoverages,
519
+ groups
520
+ });
521
+ }
522
+ function normalizeScreenCoverages(raw, lodCount, officialNodeForm) {
523
+ if (raw === void 0) return void 0;
524
+ if (!Array.isArray(raw)) return "invalid";
525
+ if (raw.some(
526
+ (value) => typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 1
527
+ )) {
528
+ return "invalid";
529
+ }
530
+ const expectedLengths = officialNodeForm ? [lodCount + 1] : [lodCount, lodCount + 1];
531
+ if (!expectedLengths.includes(raw.length)) return "invalid";
532
+ const values = raw.slice(0, lodCount);
533
+ for (let index = 1; index < values.length; index += 1) {
534
+ if (values[index] >= values[index - 1]) return "invalid";
535
+ }
536
+ return values;
537
+ }
538
+ function projectGltfLodMeta(input) {
539
+ const defaults = deriveDefaultLodScreenCoverages(input.levels.length + 1);
540
+ const previous = (input.previous ?? []).map((level) => ({
541
+ sourceKey: level.sourceKey,
542
+ meshGuid: level.guid,
543
+ ...level.screenCoverage === void 0 ? {} : { screenCoverage: level.screenCoverage }
544
+ }));
545
+ const next = input.levels.map((level, index) => ({
546
+ sourceKey: level.sourceKey,
547
+ meshGuid: level.guid,
548
+ screenCoverage: level.screenCoverage ?? defaults[index] ?? 0
549
+ }));
550
+ const reconciled = reconcileMeshLodMeta(previous, next);
551
+ if (!reconciled.ok) return err({ code: reconciled.error.code, reason: reconciled.error.reason });
552
+ return ok({
553
+ lods: reconciled.value.lods.map((level) => ({
554
+ sourceKey: level.sourceKey,
555
+ meshGuid: level.meshGuid,
556
+ screenCoverage: level.screenCoverage
557
+ }))
558
+ });
559
+ }
560
+
457
561
  // src/material/parse-material.ts
458
562
  function tuple2(values) {
459
563
  if (values === void 0 || values.length < 2) return void 0;
@@ -1479,6 +1583,8 @@ async function parseGltfWithBin(json, ctx) {
1479
1583
  const extResult = checkExtensions(json);
1480
1584
  if (!extResult.ok) return err(extResult.error);
1481
1585
  const unsupportedExtensions = extResult.value.unsupportedUsed;
1586
+ const lodResult = parseGltfLodExtension(json);
1587
+ if (!lodResult.ok) return err(lodResult.error);
1482
1588
  const lightsResult = parsePunctualLights(json, ctx.filePath);
1483
1589
  if (!lightsResult.ok) return err(lightsResult.error);
1484
1590
  const meshesJson = json.meshes ?? [];
@@ -2019,7 +2125,8 @@ async function parseGltfWithBin(json, ctx) {
2019
2125
  matrixTrsCoexistNodes: diagnostics.matrixTrsCoexistNodes
2020
2126
  },
2021
2127
  meshPrimitiveCount,
2022
- lights: lightsResult.value
2128
+ lights: lightsResult.value,
2129
+ ...lodResult.value.lodNodeIds.length === 0 ? {} : { lod: lodResult.value }
2023
2130
  });
2024
2131
  }
2025
2132
  async function parseGltfForImporter(json, externalLoader, filePath, options = {}) {
@@ -2158,6 +2265,74 @@ function toAssetPack(doc, existingMeta, source) {
2158
2265
  const sourceOverrides = {
2159
2266
  ...existingMeta?.sourceOverrides ?? {}
2160
2267
  };
2268
+ const lodGroups = doc.lod === void 0 ? [] : doc.lod.groups.length > 0 ? doc.lod.groups : [doc.lod];
2269
+ for (const lodGroup of lodGroups) {
2270
+ if (lodGroup.lodNodeIds.length === 0) continue;
2271
+ const rootMeshIndex = doc.nodes[lodGroup.rootNode]?.meshIndex;
2272
+ const referencedMeshIndices = [
2273
+ rootMeshIndex,
2274
+ ...lodGroup.lodNodeIds.map((nodeIndex) => doc.nodes[nodeIndex]?.meshIndex)
2275
+ ];
2276
+ if (referencedMeshIndices.some(
2277
+ (meshIndex) => !Number.isInteger(meshIndex) || meshIndex === null || meshIndex === void 0 || !seenMeshIndices.has(meshIndex)
2278
+ )) {
2279
+ return err(
2280
+ gltfErr("gltf-lod-invalid", {
2281
+ rootNode: lodGroup.rootNode,
2282
+ ids: lodGroup.lodNodeIds,
2283
+ reason: "missing-node"
2284
+ })
2285
+ );
2286
+ }
2287
+ const rootOutput = rootMeshIndex === void 0 || rootMeshIndex === null ? void 0 : subAssetByKindIndex.get(`mesh:${rootMeshIndex}`);
2288
+ const levels = lodGroup.lodNodeIds.map((nodeIndex, index) => {
2289
+ const meshIndex = doc.nodes[nodeIndex]?.meshIndex;
2290
+ const output = meshIndex === void 0 || meshIndex === null ? void 0 : subAssetByKindIndex.get(`mesh:${meshIndex}`);
2291
+ return {
2292
+ sourceKey: output?.sourceKey ?? `mesh:${meshIndex ?? nodeIndex}`,
2293
+ meshGuid: output?.guid ?? "",
2294
+ ...lodGroup.screenCoverages[index] === void 0 ? {} : { screenCoverage: lodGroup.screenCoverages[index] }
2295
+ };
2296
+ });
2297
+ if (rootOutput?.sourceKey !== void 0 && levels.every((level) => level.meshGuid !== "")) {
2298
+ const previousLodMeta = (() => {
2299
+ const raw = existingMeta?.sourceOverrides?.[rootOutput.sourceKey]?.lods;
2300
+ if (!Array.isArray(raw)) return void 0;
2301
+ return raw.flatMap((entry) => {
2302
+ if (entry === null || typeof entry !== "object") return [];
2303
+ const value = entry;
2304
+ return typeof value.sourceKey === "string" && typeof value.meshGuid === "string" ? [
2305
+ {
2306
+ sourceKey: value.sourceKey,
2307
+ guid: value.meshGuid,
2308
+ ...typeof value.screenCoverage === "number" ? { screenCoverage: value.screenCoverage } : {}
2309
+ }
2310
+ ] : [];
2311
+ });
2312
+ })();
2313
+ const projected = projectGltfLodMeta({
2314
+ rootSourceKey: rootOutput.sourceKey,
2315
+ levels: levels.map((level) => ({
2316
+ sourceKey: level.sourceKey,
2317
+ guid: level.meshGuid,
2318
+ ...level.screenCoverage === void 0 ? {} : { screenCoverage: level.screenCoverage }
2319
+ })),
2320
+ ...previousLodMeta === void 0 ? {} : { previous: previousLodMeta }
2321
+ });
2322
+ if (!projected.ok)
2323
+ return err(
2324
+ gltfErr("gltf-lod-invalid", {
2325
+ rootNode: lodGroup.rootNode,
2326
+ ids: lodGroup.lodNodeIds,
2327
+ reason: "coverage"
2328
+ })
2329
+ );
2330
+ sourceOverrides[rootOutput.sourceKey] = {
2331
+ ...sourceOverrides[rootOutput.sourceKey] ?? {},
2332
+ lods: projected.value.lods
2333
+ };
2334
+ }
2335
+ }
2161
2336
  for (const meshIndex of seenMeshIndices) {
2162
2337
  const meshOutput = subAssetByKindIndex.get(`mesh:${meshIndex}`);
2163
2338
  if (meshOutput?.sourceKey === void 0) continue;
@@ -2252,13 +2427,13 @@ function serializeMetaJson(meta) {
2252
2427
  }
2253
2428
 
2254
2429
  // src/cli-gltf.ts
2255
- function emitError(ctx, err2) {
2430
+ function emitError(ctx, err3) {
2256
2431
  const payload = {
2257
- code: err2.code,
2258
- expected: err2.expected,
2259
- hint: err2.hint
2432
+ code: err3.code,
2433
+ expected: err3.expected,
2434
+ hint: err3.hint
2260
2435
  };
2261
- if (err2.detail !== void 0) payload.detail = err2.detail;
2436
+ if (err3.detail !== void 0) payload.detail = err3.detail;
2262
2437
  ctx.stderrWrite(JSON.stringify(payload));
2263
2438
  return 1;
2264
2439
  }