@forgeax/engine-gltf 0.1.28 → 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. */
@@ -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 };