@forgeax/engine-gltf 0.1.28 → 0.1.30

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.
@@ -0,0 +1,94 @@
1
+ import {
2
+ type AnimatedBoundsMesh,
3
+ deriveConservativeAnimatedBounds,
4
+ } from '@forgeax/engine-animation/animated-bounds';
5
+ import { buildNodeParentMap, resolveNamedNodePath } from './node-path';
6
+ import type { GltfDoc } from './parse-gltf';
7
+
8
+ /** Source extras and sidecar bounds have already been applied before this producer runs. */
9
+ export function deriveGltfAnimatedBounds(doc: GltfDoc): GltfDoc {
10
+ const parents = buildNodeParentMap(doc.nodes);
11
+ const paths = doc.nodes.map((_, index) => {
12
+ const path = resolveNamedNodePath(doc.nodes, parents, index);
13
+ return path.ok ? path.value.join('/') : undefined;
14
+ });
15
+ const nodes = doc.nodes.map((node, index) => ({
16
+ ...node.transform,
17
+ parent: parents.get(index) ?? null,
18
+ }));
19
+ const channels = doc.animationClips.flatMap((clip) =>
20
+ clip.channels.flatMap((channel) =>
21
+ channel.property === 'weights'
22
+ ? []
23
+ : [
24
+ {
25
+ node: channel.targetNodeIndex,
26
+ property: channel.property,
27
+ values: channel.sampler.output,
28
+ interpolation: channel.sampler.interpolation,
29
+ },
30
+ ],
31
+ ),
32
+ );
33
+ const primitiveStarts: number[] = [];
34
+ let offset = 0;
35
+ for (const count of doc.meshPrimitiveCount?.values() ?? doc.meshes.map(() => 1)) {
36
+ primitiveStarts.push(offset);
37
+ offset += count;
38
+ }
39
+ const skeletons = doc.skeletons.map((skeleton, skinIndex) => {
40
+ if (skeleton.bounds !== undefined) return skeleton;
41
+ const meshes: AnimatedBoundsMesh[] = [];
42
+ for (let index = 0; index < doc.nodes.length; index++) {
43
+ const node = doc.nodes[index];
44
+ if (node === undefined) return skeleton;
45
+ if (node.skinIndex !== skinIndex || node.meshIndex === null) continue;
46
+ const start = primitiveStarts[node.meshIndex];
47
+ if (start === undefined) return skeleton;
48
+ for (const mesh of doc.meshes.slice(
49
+ start,
50
+ start + (doc.meshPrimitiveCount?.get(node.meshIndex) ?? 1),
51
+ )) {
52
+ if (mesh.joints0 === undefined || mesh.weights0 === undefined) return skeleton;
53
+ let maxMorphWeight = Math.max(
54
+ 0,
55
+ ...Array.from(node.morphWeights ?? mesh.morphWeights ?? [], Math.abs),
56
+ );
57
+ for (const clip of doc.animationClips)
58
+ for (const channel of clip.channels)
59
+ if (channel.targetNodeIndex === index && channel.property === 'weights')
60
+ for (const weight of channel.sampler.output)
61
+ maxMorphWeight = Math.max(maxMorphWeight, Math.abs(weight));
62
+ const morphExtent = Float32Array.from(mesh.positions, (_, component) =>
63
+ (mesh.morphTargets ?? []).reduce(
64
+ (sum, target) => sum + Math.abs(target.position?.[component] ?? 0) * maxMorphWeight,
65
+ 0,
66
+ ),
67
+ );
68
+ meshes.push({
69
+ node: index,
70
+ positions: mesh.positions,
71
+ joints: mesh.joints0,
72
+ weights: mesh.weights0,
73
+ morphExtent,
74
+ });
75
+ }
76
+ }
77
+ const jointNodes = skeleton.jointPaths.map((path) => paths.indexOf(path));
78
+ if (
79
+ jointNodes.some(
80
+ (node, index) => node < 0 || paths.lastIndexOf(skeleton.jointPaths[index]) !== node,
81
+ )
82
+ )
83
+ return skeleton;
84
+ const bounds = deriveConservativeAnimatedBounds({
85
+ nodes,
86
+ channels,
87
+ jointNodes,
88
+ inverseBindMatrices: skeleton.inverseBindMatrices,
89
+ meshes,
90
+ });
91
+ return bounds === undefined ? skeleton : { ...skeleton, bounds };
92
+ });
93
+ return { ...doc, skeletons };
94
+ }
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,10 +51,12 @@ import type {
51
51
  import {
52
52
  IMPORT_ERROR_HINTS,
53
53
  ImportError,
54
+ readConservativeAnimatedBounds,
54
55
  reconcileMeshMaterialSlotTopology,
55
56
  resolveMeshMaterialSlotDefaultGuid,
56
57
  toShared,
57
58
  } from '@forgeax/engine-types';
59
+ import { deriveGltfAnimatedBounds } from './animated-bounds';
58
60
  import {
59
61
  gltfDocToSceneAsset,
60
62
  meshIrToMeshAsset,
@@ -72,7 +74,6 @@ import { parseGlbForImporter, parseGltfForImporter } from './parse-gltf.js';
72
74
  type ParseDocResult =
73
75
  | { readonly ok: true; readonly value: GltfDoc }
74
76
  | { readonly ok: false; readonly error: ImportError };
75
-
76
77
  function isGlbBytes(source: string): boolean {
77
78
  return source.toLowerCase().endsWith('.glb');
78
79
  }
@@ -83,6 +84,23 @@ function publishesCatalogProduct(input: {
83
84
  return input.importSettings.geometry !== 'procedural';
84
85
  }
85
86
 
87
+ function applyImportSettingsBounds(
88
+ doc: GltfDoc,
89
+ importSettings: Readonly<Record<string, unknown>>,
90
+ ): GltfDoc {
91
+ let changed = false;
92
+ const skeletons = doc.skeletons.map((record, sourceIndex) => {
93
+ // Source extras are the primary producer path. The sidecar row is an
94
+ // explicit external-producer override for sources whose authoring tool
95
+ // cannot carry ForgeaX extras; neither path derives a bind-pose AABB.
96
+ const bounds = readConservativeAnimatedBounds(importSettings, sourceIndex);
97
+ if (bounds === undefined || record.bounds !== undefined) return record;
98
+ changed = true;
99
+ return { ...record, bounds };
100
+ });
101
+ return changed ? { ...doc, skeletons } : doc;
102
+ }
103
+
86
104
  function previousMaterialSlotTopology(
87
105
  ctx: ImportContext,
88
106
  meshSourceKey: string | undefined,
@@ -527,7 +545,7 @@ async function importGltf(
527
545
  }
528
546
  const parsed = await parseDoc(ctx.source, read.value, ctx, meshopt);
529
547
  if (!parsed.ok) return parsed;
530
- const doc = parsed.value;
548
+ const doc = deriveGltfAnimatedBounds(applyImportSettingsBounds(parsed.value, ctx.importSettings));
531
549
  const maps = buildHandleMaps(ctx.subAssets, doc);
532
550
 
533
551
  // Pre-derive each images[] row's colorSpace from material slot bindings
@@ -921,30 +939,30 @@ async function importGltf(
921
939
  // SkinAsset.jointPaths.
922
940
  //
923
941
  // D-2 / D-3: refs carries structured edge metadata (AssetRef[]).
924
- // Walk scene entities to build a handle-value -> (entityLocalId,
942
+ // Walk keyed scene entities to build a handle-value -> (entityKey,
925
943
  // componentName, fieldName, arrayIndex?) provenance map, then
926
- // produce AssetRef[] with sourceField / sceneEntityId filled for mesh
944
+ // produce AssetRef[] with sourceField / sceneEntityKey filled for mesh
927
945
  // handle-field edges. Skeleton edges: sourceField from Skin.skeleton if entity
928
946
  // carries that GUID. Skin edges: sourceField=undefined (cross-edge
929
947
  // with no entity-component representation).
930
948
  const handleValueProvenance = new Map<
931
949
  number,
932
- { sceneEntityId: number; componentName: string; fieldName: string; arrayIndex?: number }
950
+ { sceneEntityKey: string; componentName: string; fieldName: string; arrayIndex?: number }
933
951
  >();
934
- const skeletonGuidProvenance = new Map<string, { sceneEntityId: number }>();
935
- for (const entity of scene.entities) {
952
+ const skeletonGuidProvenance = new Map<string, { sceneEntityKey: string }>();
953
+ for (const [sceneEntityKey, entity] of Object.entries(scene.entities)) {
936
954
  const comps = entity.components as Record<string, Record<string, unknown>>;
937
955
  const mf = comps.MeshFilter;
938
956
  if (mf !== undefined && typeof mf.assetHandle === 'number') {
939
957
  handleValueProvenance.set(mf.assetHandle, {
940
- sceneEntityId: entity.localId,
958
+ sceneEntityKey,
941
959
  componentName: 'MeshFilter',
942
960
  fieldName: 'assetHandle',
943
961
  });
944
962
  }
945
963
  const skin = comps.Skin;
946
964
  if (skin !== undefined && typeof skin.skeleton === 'string') {
947
- skeletonGuidProvenance.set(skin.skeleton, { sceneEntityId: entity.localId });
965
+ skeletonGuidProvenance.set(skin.skeleton, { sceneEntityKey });
948
966
  }
949
967
  }
950
968
 
@@ -960,7 +978,7 @@ async function importGltf(
960
978
  fieldName: prov.fieldName,
961
979
  ...(prov.arrayIndex !== undefined ? { arrayIndex: prov.arrayIndex } : {}),
962
980
  },
963
- sceneEntityId: prov.sceneEntityId,
981
+ sceneEntityKey: prov.sceneEntityKey,
964
982
  };
965
983
  }
966
984
  return { guid };
@@ -981,7 +999,7 @@ async function importGltf(
981
999
  ? {
982
1000
  guid,
983
1001
  sourceField: { componentName: 'Skin', fieldName: 'skeleton' },
984
- sceneEntityId: skProv.sceneEntityId,
1002
+ sceneEntityKey: skProv.sceneEntityKey,
985
1003
  }
986
1004
  : { guid },
987
1005
  );
@@ -1025,6 +1043,7 @@ async function importGltf(
1025
1043
  kind: 'skeleton' as const,
1026
1044
  inverseBindMatrices: rec.inverseBindMatrices,
1027
1045
  jointCount: rec.jointCount,
1046
+ ...(rec.bounds === undefined ? {} : { bounds: rec.bounds }),
1028
1047
  };
1029
1048
  out.push({ guid: sub.guid, kind: 'skeleton', payload, refs: [], artifacts: {} });
1030
1049
  } 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 };