@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/README.md +1 -1
- package/dist/bridge.d.ts +7 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/cli-gltf.mjs +6 -4
- package/dist/cli-gltf.mjs.map +1 -1
- package/dist/gltf-importer.d.ts.map +1 -1
- package/dist/importer-entry.mjs +44 -18
- package/dist/importer-entry.mjs.map +1 -1
- package/dist/index.mjs +44 -18
- package/dist/index.mjs.map +1 -1
- package/dist/node-file-entry.mjs +6 -4
- package/dist/node-file-entry.mjs.map +1 -1
- package/dist/parse-gltf.d.ts.map +1 -1
- package/dist/parse-skin.d.ts +8 -16
- package/dist/parse-skin.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/__tests__/bridge.unit.test.ts +70 -51
- package/src/__tests__/gltf.unit.test.ts +95 -15
- package/src/__tests__/morph-import.integration.test.ts +1 -1
- package/src/bridge.ts +34 -10
- package/src/gltf-importer.ts +29 -11
- package/src/parse-gltf.ts +5 -0
- package/src/parse-skin.ts +14 -41
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
|
-
|
|
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
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
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/gltf-importer.ts
CHANGED
|
@@ -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 -> (
|
|
941
|
+
// Walk keyed scene entities to build a handle-value -> (entityKey,
|
|
925
942
|
// componentName, fieldName, arrayIndex?) provenance map, then
|
|
926
|
-
// produce AssetRef[] with sourceField /
|
|
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
|
-
{
|
|
949
|
+
{ sceneEntityKey: string; componentName: string; fieldName: string; arrayIndex?: number }
|
|
933
950
|
>();
|
|
934
|
-
const skeletonGuidProvenance = new Map<string, {
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 };
|