@forgeax/engine-gltf 0.1.27 → 0.1.29

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/src/bridge.ts CHANGED
@@ -36,7 +36,6 @@ import type {
36
36
  RenderQueue,
37
37
  Result,
38
38
  SceneAsset,
39
- SceneEntity,
40
39
  Submesh,
41
40
  VertexAttributeMap,
42
41
  } from '@forgeax/engine-types';
@@ -459,6 +458,13 @@ export interface GltfBridgeContext {
459
458
  * matching SkinAsset.jointPaths against the spawn subtree to fill Skin.joints[].
460
459
  */
461
460
  readonly skeletonGuidBySkinIndex?: ReadonlyMap<number, string>;
461
+ /**
462
+ * glTF node index -> a live renderer-owned instance collection identity.
463
+ * The bridge is intentionally pure and cannot mint collection ids or copy
464
+ * matrix bytes into ECS. Runtime consumers create collections through their
465
+ * Renderer first, then provide this map when lowering the SceneAsset.
466
+ */
467
+ readonly instanceCollectionIdsByNodeIndex?: ReadonlyMap<number, number>;
462
468
  }
463
469
 
464
470
  interface MutableSceneEntity {
@@ -547,7 +553,7 @@ function composeMat4(
547
553
  export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): SceneAsset {
548
554
  const sceneIr = doc.scenes[doc.defaultSceneIndex];
549
555
  const resultNodes: MutableSceneEntity[] = [];
550
- if (sceneIr === undefined) return { kind: 'scene', entities: [] };
556
+ if (sceneIr === undefined) return { kind: 'scene', entities: {} } as unknown as SceneAsset;
551
557
  const importedLights = doc.lights ?? doc.extensions?.KHR_lights_punctual?.lights ?? [];
552
558
  const animationTargetIds = new Map<number, string>();
553
559
  for (const clip of doc.animationClips) {
@@ -705,9 +711,16 @@ export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): Scene
705
711
  components.MeshRenderer = { materials: [] };
706
712
  }
707
713
 
708
- // Instances on the same entity as MeshFilter/MeshRenderer.
714
+ // Instances on the same entity as MeshFilter/MeshRenderer. The bridge
715
+ // only carries the renderer-owned identity; IR matrix bytes stay in the
716
+ // caller's collection and never become an ECS managed array. Build-time
717
+ // consumers that do not have a Renderer omit this optional association and
718
+ // must materialise the collection at their runtime boundary.
709
719
  if (ir.instancing !== undefined) {
710
- components.Instances = { transforms: ir.instancing.transforms };
720
+ const collectionId = ctx.instanceCollectionIdsByNodeIndex?.get(gltfNodeIdx);
721
+ if (collectionId !== undefined) {
722
+ components.Instances = { collectionId };
723
+ }
711
724
  }
712
725
 
713
726
  if (isCamera) {
@@ -751,10 +764,21 @@ export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): Scene
751
764
 
752
765
  for (const rootIdx of sceneIr.nodes) visit(rootIdx, null);
753
766
 
754
- const frozen: SceneEntity[] = resultNodes.map((n) => ({
755
- localId: n.localId,
756
- components: n.components,
757
- }));
767
+ const keyByLocalId = new Map<number, string>();
768
+ for (const node of resultNodes) keyByLocalId.set(node.localIdx, `node-${node.localIdx}`);
769
+ const entities: Record<string, { readonly components: Record<string, Record<string, unknown>> }> =
770
+ {};
771
+ for (const node of resultNodes) {
772
+ const components = { ...node.components };
773
+ const childOf = components.ChildOf;
774
+ if (childOf !== undefined && typeof childOf.parent === 'number') {
775
+ const parentKey = keyByLocalId.get(childOf.parent);
776
+ if (parentKey === undefined)
777
+ throw new Error(`gltfDocToSceneAsset: missing parent node ${childOf.parent}`);
778
+ components.ChildOf = { ...childOf, parent: parentKey };
779
+ }
780
+ entities[`node-${node.localIdx}`] = { components };
781
+ }
758
782
  const lightFacts = importedLights.map((light) => ({
759
783
  kind: light.type,
760
784
  intensity: light.intensity,
@@ -763,9 +787,9 @@ export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): Scene
763
787
  }));
764
788
  return {
765
789
  kind: 'scene',
766
- entities: frozen,
790
+ entities,
767
791
  ...(lightFacts.length === 0 ? {} : { lights: lightFacts }),
768
- } as SceneAsset;
792
+ } as unknown as SceneAsset;
769
793
  }
770
794
 
771
795
  /** Internal helper: mark GltfNodeIr usable so future surface evolutions stay typed. */
package/src/cli-gltf.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // @forgeax/engine-gltf/src/cli-gltf — `forgeax-engine-remote-gltf` plugin
3
- // bin (feat-20260516-console-dependency-inversion plan-strategy section
4
- // 2.9). Discovered by the base bin via the kubectl 4th-path
5
- // `forgeax-engine-remote-` prefix scanner.
2
+ // @forgeax/engine-gltf/src/cli-gltf — internal producer used by DevKit's
3
+ // unified `forgeax asset import` command. It is deliberately not a package bin.
6
4
  //
7
5
  // Single subcommand `import` for v1 (UX break vs the prior
8
- // `forgeax-engine-remote-asset import`):
6
+ // `forgeax asset import`):
9
7
  //
10
- // write mode `forgeax-engine-remote-gltf import <gltf-or-glb>`
8
+ // write mode `forgeax asset import <gltf-or-glb> --root <project>`
11
9
  // Parses the source via parseGlb / parseGltf and writes the
12
10
  // sibling `<source>.meta.json` sidecar (sorted-keys, LF line
13
11
  // ending — byte-stable so a clean reimport produces no diff).
14
12
  //
15
- // --check `forgeax-engine-remote-gltf import --check <dir>`
13
+ // --check `forgeax asset import <dir> --dry-run --root <project>`
16
14
  // Dry-run: traverse <dir> reusing SCANNER_BLACKLIST from
17
15
  // @forgeax/engine-pack/scanner and surface the first orphan
18
16
  // .gltf / .glb whose `<source>.meta.json` is absent as
@@ -55,14 +53,14 @@ function emitError(ctx: AssetCtx, err: ErrShape): number {
55
53
 
56
54
  function helpBody(): string {
57
55
  return [
58
- 'forgeax-engine-remote-gltf — glTF / GLB sidecar importer (produces texture/mesh/material/scene sub-assets)',
56
+ 'forgeax asset import — glTF / GLB sidecar importer (internal producer)',
59
57
  '',
60
58
  'Usage:',
61
- ' forgeax-engine-remote-gltf import <path.gltf|path.glb>',
62
- ' forgeax-engine-remote-gltf import --check <dir>',
59
+ ' forgeax asset import <path.gltf|path.glb> --root <project>',
60
+ ' forgeax asset import <dir> --dry-run --root <project>',
63
61
  '',
64
- 'Produces a sibling <source>.meta.json sidecar with sub-asset entries',
65
- 'for every glTF asset category: texture, mesh, material, scene.',
62
+ 'produces texture, mesh, material, and scene sub-asset entries in a sibling',
63
+ '<source>.meta.json sidecar.',
66
64
  '',
67
65
  ].join('\n');
68
66
  }
@@ -77,7 +75,7 @@ export async function runCliGltf(rest: string[], ctx: AssetCtx): Promise<number>
77
75
  return emitError(ctx, {
78
76
  code: 'unknown-subcommand',
79
77
  expected: "subcommand 'import'",
80
- hint: "run 'forgeax-engine-remote-gltf --help' for usage",
78
+ hint: "run 'forgeax help asset import' for usage",
81
79
  detail: { subcommand: sub },
82
80
  });
83
81
  }
@@ -100,8 +98,8 @@ async function runImport(rest: string[], ctx: AssetCtx): Promise<number> {
100
98
  const message = e instanceof Error ? e.message : String(e);
101
99
  return emitError(ctx, {
102
100
  code: 'cli-parse-error',
103
- expected: 'forgeax-engine-remote-gltf import [--check] <path>',
104
- hint: "run 'forgeax-engine-remote-gltf --help' for usage",
101
+ expected: 'forgeax asset import [--dry-run] <path> --root <project>',
102
+ hint: "run 'forgeax help asset import' for usage",
105
103
  detail: { message },
106
104
  });
107
105
  }
@@ -110,8 +108,8 @@ async function runImport(rest: string[], ctx: AssetCtx): Promise<number> {
110
108
  return emitError(ctx, {
111
109
  code: 'cli-parse-error',
112
110
  expected: check
113
- ? 'forgeax-engine-remote-gltf import --check <dir>'
114
- : 'forgeax-engine-remote-gltf import <path.gltf|path.glb>',
111
+ ? 'forgeax asset import <dir> --dry-run --root <project>'
112
+ : 'forgeax asset import <path.gltf|path.glb> --root <project>',
115
113
  hint: 'pass a positional <gltf-or-glb> argument; with --check pass a directory',
116
114
  });
117
115
  }
@@ -192,7 +190,7 @@ async function runCheck(target: string, ctx: AssetCtx): Promise<number> {
192
190
  const message = e instanceof Error ? e.message : String(e);
193
191
  return emitError(ctx, {
194
192
  code: 'cli-parse-error',
195
- expected: 'forgeax-engine-remote-gltf import --check <dir>',
193
+ expected: 'forgeax asset import <dir> --dry-run --root <project>',
196
194
  hint: 'pass a directory that exists and is readable',
197
195
  detail: { path: target, message },
198
196
  });
package/src/errors.ts CHANGED
@@ -267,7 +267,7 @@ const gltfErrorPolicy = {
267
267
  'gltf-malformed-header': {
268
268
  expected:
269
269
  'GLB 12-byte header (magic 0x46546C67 + version=2 + length) plus mandatory JSON chunk',
270
- hint: 'verify .glb is not truncated; rerun: forgeax-engine-remote-gltf import <path>',
270
+ hint: 'verify .glb is not truncated; rerun: forgeax asset import <path> --root <project> --json',
271
271
  },
272
272
  'gltf-version-unsupported': {
273
273
  expected: 'asset.version === "2.0"',
@@ -305,7 +305,7 @@ const gltfErrorPolicy = {
305
305
  },
306
306
  'gltf-meta-missing': {
307
307
  expected: "sidecar <source>.meta.json (importer: 'gltf') present in same directory",
308
- hint: 'run: forgeax-engine-remote-gltf import <path>',
308
+ hint: 'run: forgeax asset import <path> --root <project> --json',
309
309
  },
310
310
  'gltf-instancing-count-mismatch': {
311
311
  expected: 'all instance attribute accessors share the same count',
@@ -334,7 +334,7 @@ const gltfErrorPolicy = {
334
334
  'gltf-image-extract-failed': {
335
335
  expected:
336
336
  'image bytes extractable from bufferView / data-URI / external URI without corruption',
337
- hint: 'verify the bufferView byte range / data: URI base64 / external URI sibling file is intact next to the .gltf source; rerun: forgeax-engine-remote-gltf import <path>',
337
+ hint: 'verify the bufferView byte range / data: URI base64 / external URI sibling file is intact next to the .gltf source; rerun: forgeax asset import <path> --root <project> --json',
338
338
  },
339
339
  'gltf-skin-attr-asymmetric': {
340
340
  expected:
@@ -51,6 +51,7 @@ import type {
51
51
  import {
52
52
  IMPORT_ERROR_HINTS,
53
53
  ImportError,
54
+ readConservativeAnimatedBounds,
54
55
  reconcileMeshMaterialSlotTopology,
55
56
  resolveMeshMaterialSlotDefaultGuid,
56
57
  toShared,
@@ -72,7 +73,6 @@ import { parseGlbForImporter, parseGltfForImporter } from './parse-gltf.js';
72
73
  type ParseDocResult =
73
74
  | { readonly ok: true; readonly value: GltfDoc }
74
75
  | { readonly ok: false; readonly error: ImportError };
75
-
76
76
  function isGlbBytes(source: string): boolean {
77
77
  return source.toLowerCase().endsWith('.glb');
78
78
  }
@@ -83,6 +83,23 @@ function publishesCatalogProduct(input: {
83
83
  return input.importSettings.geometry !== 'procedural';
84
84
  }
85
85
 
86
+ function applyImportSettingsBounds(
87
+ doc: GltfDoc,
88
+ importSettings: Readonly<Record<string, unknown>>,
89
+ ): GltfDoc {
90
+ let changed = false;
91
+ const skeletons = doc.skeletons.map((record, sourceIndex) => {
92
+ // Source extras are the primary producer path. The sidecar row is an
93
+ // explicit external-producer override for sources whose authoring tool
94
+ // cannot carry ForgeaX extras; neither path derives a bind-pose AABB.
95
+ const bounds = readConservativeAnimatedBounds(importSettings, sourceIndex);
96
+ if (bounds === undefined || record.bounds !== undefined) return record;
97
+ changed = true;
98
+ return { ...record, bounds };
99
+ });
100
+ return changed ? { ...doc, skeletons } : doc;
101
+ }
102
+
86
103
  function previousMaterialSlotTopology(
87
104
  ctx: ImportContext,
88
105
  meshSourceKey: string | undefined,
@@ -527,7 +544,7 @@ async function importGltf(
527
544
  }
528
545
  const parsed = await parseDoc(ctx.source, read.value, ctx, meshopt);
529
546
  if (!parsed.ok) return parsed;
530
- const doc = parsed.value;
547
+ const doc = applyImportSettingsBounds(parsed.value, ctx.importSettings);
531
548
  const maps = buildHandleMaps(ctx.subAssets, doc);
532
549
 
533
550
  // Pre-derive each images[] row's colorSpace from material slot bindings
@@ -921,30 +938,30 @@ async function importGltf(
921
938
  // SkinAsset.jointPaths.
922
939
  //
923
940
  // D-2 / D-3: refs carries structured edge metadata (AssetRef[]).
924
- // Walk scene entities to build a handle-value -> (entityLocalId,
941
+ // Walk keyed scene entities to build a handle-value -> (entityKey,
925
942
  // componentName, fieldName, arrayIndex?) provenance map, then
926
- // produce AssetRef[] with sourceField / sceneEntityId filled for mesh
943
+ // produce AssetRef[] with sourceField / sceneEntityKey filled for mesh
927
944
  // handle-field edges. Skeleton edges: sourceField from Skin.skeleton if entity
928
945
  // carries that GUID. Skin edges: sourceField=undefined (cross-edge
929
946
  // with no entity-component representation).
930
947
  const handleValueProvenance = new Map<
931
948
  number,
932
- { sceneEntityId: number; componentName: string; fieldName: string; arrayIndex?: number }
949
+ { sceneEntityKey: string; componentName: string; fieldName: string; arrayIndex?: number }
933
950
  >();
934
- const skeletonGuidProvenance = new Map<string, { sceneEntityId: number }>();
935
- for (const entity of scene.entities) {
951
+ const skeletonGuidProvenance = new Map<string, { sceneEntityKey: string }>();
952
+ for (const [sceneEntityKey, entity] of Object.entries(scene.entities)) {
936
953
  const comps = entity.components as Record<string, Record<string, unknown>>;
937
954
  const mf = comps.MeshFilter;
938
955
  if (mf !== undefined && typeof mf.assetHandle === 'number') {
939
956
  handleValueProvenance.set(mf.assetHandle, {
940
- sceneEntityId: entity.localId,
957
+ sceneEntityKey,
941
958
  componentName: 'MeshFilter',
942
959
  fieldName: 'assetHandle',
943
960
  });
944
961
  }
945
962
  const skin = comps.Skin;
946
963
  if (skin !== undefined && typeof skin.skeleton === 'string') {
947
- skeletonGuidProvenance.set(skin.skeleton, { sceneEntityId: entity.localId });
964
+ skeletonGuidProvenance.set(skin.skeleton, { sceneEntityKey });
948
965
  }
949
966
  }
950
967
 
@@ -960,7 +977,7 @@ async function importGltf(
960
977
  fieldName: prov.fieldName,
961
978
  ...(prov.arrayIndex !== undefined ? { arrayIndex: prov.arrayIndex } : {}),
962
979
  },
963
- sceneEntityId: prov.sceneEntityId,
980
+ sceneEntityKey: prov.sceneEntityKey,
964
981
  };
965
982
  }
966
983
  return { guid };
@@ -981,7 +998,7 @@ async function importGltf(
981
998
  ? {
982
999
  guid,
983
1000
  sourceField: { componentName: 'Skin', fieldName: 'skeleton' },
984
- sceneEntityId: skProv.sceneEntityId,
1001
+ sceneEntityKey: skProv.sceneEntityKey,
985
1002
  }
986
1003
  : { guid },
987
1004
  );
@@ -1025,6 +1042,7 @@ async function importGltf(
1025
1042
  kind: 'skeleton' as const,
1026
1043
  inverseBindMatrices: rec.inverseBindMatrices,
1027
1044
  jointCount: rec.jointCount,
1045
+ ...(rec.bounds === undefined ? {} : { bounds: rec.bounds }),
1028
1046
  };
1029
1047
  out.push({ guid: sub.guid, kind: 'skeleton', payload, refs: [], artifacts: {} });
1030
1048
  } else if (sub.kind === 'skin') {
package/src/parse-gltf.ts CHANGED
@@ -400,6 +400,11 @@ interface RootGltfJson extends GltfExtensionsJson {
400
400
  readonly name?: string;
401
401
  readonly joints: readonly number[];
402
402
  readonly inverseBindMatrices?: number;
403
+ readonly extras?: {
404
+ readonly forgeax?: {
405
+ readonly conservativeAnimatedBounds?: unknown;
406
+ };
407
+ };
403
408
  }>;
404
409
  readonly meshes?: readonly MeshJson[];
405
410
  readonly materials?: ReadonlyArray<GltfMaterialJson>;
package/src/parse-skin.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  // - plan-strategy D-1 (3-asset separation: IBM / skin binding / animation clip)
9
9
  // - plan-strategy D-2 (skin index dedupe via reimport reuse managed by toAssetPack)
10
10
  // - requirements AC-03 (skin index dedupe), AC-05 (jointPaths + Name missing fail-fast)
11
- // - requirements AC-10 (IR extension), AC-27 (BindPose static AABB)
12
- // - plan-strategy D-11 (BindPose AABB importer-phase, per-frame zero cost)
11
+ // - requirements AC-10 (IR extension)
13
12
  // - charter P3 (fail-fast on invalid data)
14
13
 
14
+ import { parseConservativeAnimatedBounds } from '@forgeax/engine-types';
15
15
  import { decodeF32Accessor } from './accessor/decode-accessor.js';
16
16
  import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
17
17
  import { buildNodeParentMap, resolveNamedNodePath } from './node-path.js';
@@ -33,12 +33,20 @@ export interface GltfSkeletonRecord {
33
33
  readonly inverseBindMatrices: Float32Array;
34
34
  /** Per-joint Name path from scene root (parallel to joints array). */
35
35
  readonly jointPaths: readonly string[];
36
+ /** Producer-authored conservative animated local bounds when supplied. */
37
+ readonly bounds?: Float32Array;
36
38
  }
37
39
 
38
40
  interface SkinJson {
39
41
  readonly name?: string;
40
42
  readonly joints: readonly number[];
41
43
  readonly inverseBindMatrices?: number;
44
+ /** glTF source metadata authored by the ForgeaX asset producer. */
45
+ readonly extras?: {
46
+ readonly forgeax?: {
47
+ readonly conservativeAnimatedBounds?: unknown;
48
+ };
49
+ };
42
50
  }
43
51
 
44
52
  interface NodeJson {
@@ -185,54 +193,19 @@ export function parseSkin(
185
193
  jointPaths.push(pathResult.value.join('/'));
186
194
  }
187
195
 
196
+ const bounds = parseConservativeAnimatedBounds(
197
+ skin.extras?.forgeax?.conservativeAnimatedBounds,
198
+ );
188
199
  records.push({
189
200
  jointCount: joints.length,
190
201
  inverseBindMatrices: ibm,
191
202
  jointPaths,
203
+ ...(bounds === undefined ? {} : { bounds }),
192
204
  });
193
205
  }
194
206
 
195
207
  return ok(records);
196
208
  }
197
209
 
198
- /**
199
- * Compute the BindPose static AABB for a skinned mesh's vertex positions.
200
- *
201
- * At bind pose, joint_bind = IBM^{-1}, so skinning collapses:
202
- * world_pos = Sum(w_i * joint_bind_i * IBM_i * local_pos) = local_pos
203
- * Therefore the BindPose AABB is simply the local position bounds.
204
- *
205
- * Per-frame zero cost: stored in mesh asset metadata at importer time.
206
- * Dynamic AABB for animated poses is deferred to OOS-skin-dyn-bounds.
207
- *
208
- * Returns { min: [x,y,z], max: [x,y,z] } or undefined if positions is empty.
209
- */
210
- export function computeBindPoseAABB(positions: Float32Array):
211
- | {
212
- readonly min: readonly [number, number, number];
213
- readonly max: readonly [number, number, number];
214
- }
215
- | undefined {
216
- if (positions.length < 3) return undefined;
217
- let minX = positions[0] ?? 0;
218
- let minY = positions[1] ?? 0;
219
- let minZ = positions[2] ?? 0;
220
- let maxX = minX;
221
- let maxY = minY;
222
- let maxZ = minZ;
223
- for (let i = 3; i < positions.length; i += 3) {
224
- const x = positions[i] ?? 0;
225
- const y = positions[i + 1] ?? 0;
226
- const z = positions[i + 2] ?? 0;
227
- if (x < minX) minX = x;
228
- if (y < minY) minY = y;
229
- if (z < minZ) minZ = z;
230
- if (x > maxX) maxX = x;
231
- if (y > maxY) maxY = y;
232
- if (z > maxZ) maxZ = z;
233
- }
234
- return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
235
- }
236
-
237
210
  /** Re-export MAX_JOINTS for use by downstream modules. */
238
211
  export { MAX_JOINTS };