@forgeax/engine-animation 0.1.2

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 (127) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +141 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/animation-diagnostic.unit.test.d.ts +2 -0
  5. package/dist/__tests__/animation-diagnostic.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/animation-recovery.integration.test.d.ts +2 -0
  7. package/dist/__tests__/animation-recovery.integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/animation-target-id.browser.test.d.ts +2 -0
  9. package/dist/__tests__/animation-target-id.browser.test.d.ts.map +1 -0
  10. package/dist/__tests__/animation-target-id.unit.test.d.ts +2 -0
  11. package/dist/__tests__/animation-target-id.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/animation-target-isolation.integration.test.d.ts +2 -0
  13. package/dist/__tests__/animation-target-isolation.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/animation-target-relationship.unit.test.d.ts +2 -0
  15. package/dist/__tests__/animation-target-relationship.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts +2 -0
  17. package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bind-animation-targets.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bind-animation-targets.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/documentation-contract.unit.test.d.ts +2 -0
  21. package/dist/__tests__/documentation-contract.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/ecs-consumer.integration.test.d.ts +2 -0
  23. package/dist/__tests__/ecs-consumer.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  25. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/graph-player.integration.test.d.ts +2 -0
  27. package/dist/__tests__/graph-player.integration.test.d.ts.map +1 -0
  28. package/dist/__tests__/graph-player.unit.test.d.ts +2 -0
  29. package/dist/__tests__/graph-player.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/handle-errors.unit.test.d.ts +2 -0
  31. package/dist/__tests__/handle-errors.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/loader-boundary.unit.test.d.ts +2 -0
  33. package/dist/__tests__/loader-boundary.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/morph-weights-playback.integration.test.d.ts +2 -0
  35. package/dist/__tests__/morph-weights-playback.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/morph-weights.unit.test.d.ts +2 -0
  37. package/dist/__tests__/morph-weights.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/public-animation-target-api.test-d.d.ts +2 -0
  39. package/dist/__tests__/public-animation-target-api.test-d.d.ts.map +1 -0
  40. package/dist/__tests__/resolver-ownership.integration.test.d.ts +2 -0
  41. package/dist/__tests__/resolver-ownership.integration.test.d.ts.map +1 -0
  42. package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
  43. package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts +2 -0
  45. package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts.map +1 -0
  46. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  47. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  48. package/dist/__tests__/time-delta-regression.integration.test.d.ts +2 -0
  49. package/dist/__tests__/time-delta-regression.integration.test.d.ts.map +1 -0
  50. package/dist/__tests__/transform-target-playback.integration.test.d.ts +2 -0
  51. package/dist/__tests__/transform-target-playback.integration.test.d.ts.map +1 -0
  52. package/dist/animation-diagnostic.d.ts +27 -0
  53. package/dist/animation-diagnostic.d.ts.map +1 -0
  54. package/dist/animation-player.d.ts +13 -0
  55. package/dist/animation-player.d.ts.map +1 -0
  56. package/dist/animation-target.d.ts +21 -0
  57. package/dist/animation-target.d.ts.map +1 -0
  58. package/dist/assets/animation-decoder.d.ts +4 -0
  59. package/dist/assets/animation-decoder.d.ts.map +1 -0
  60. package/dist/chunk-DMXAQUPI.mjs +52 -0
  61. package/dist/chunk-DMXAQUPI.mjs.map +1 -0
  62. package/dist/errors.d.ts +120 -0
  63. package/dist/errors.d.ts.map +1 -0
  64. package/dist/graph/define-animation-graph.d.ts +42 -0
  65. package/dist/graph/define-animation-graph.d.ts.map +1 -0
  66. package/dist/graph/describe-animation-graph.d.ts +37 -0
  67. package/dist/graph/describe-animation-graph.d.ts.map +1 -0
  68. package/dist/graph/serialize-animation-graph.d.ts +27 -0
  69. package/dist/graph/serialize-animation-graph.d.ts.map +1 -0
  70. package/dist/index.d.ts +28 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.mjs +1457 -0
  73. package/dist/index.mjs.map +1 -0
  74. package/dist/player-errors.d.ts +43 -0
  75. package/dist/player-errors.d.ts.map +1 -0
  76. package/dist/plugin.d.ts +13 -0
  77. package/dist/plugin.d.ts.map +1 -0
  78. package/dist/resolve-animation-asset.d.ts +42 -0
  79. package/dist/resolve-animation-asset.d.ts.map +1 -0
  80. package/dist/systems/advance-animation-player.d.ts +40 -0
  81. package/dist/systems/advance-animation-player.d.ts.map +1 -0
  82. package/dist/systems/evaluate-animation-graph.d.ts +32 -0
  83. package/dist/systems/evaluate-animation-graph.d.ts.map +1 -0
  84. package/dist/target-id.d.ts +4 -0
  85. package/dist/target-id.d.ts.map +1 -0
  86. package/dist/target-id.mjs +9 -0
  87. package/dist/target-id.mjs.map +1 -0
  88. package/package.json +65 -0
  89. package/src/__tests__/animation-diagnostic.unit.test.ts +237 -0
  90. package/src/__tests__/animation-recovery.integration.test.ts +125 -0
  91. package/src/__tests__/animation-target-id.browser.test.ts +22 -0
  92. package/src/__tests__/animation-target-id.unit.test.ts +41 -0
  93. package/src/__tests__/animation-target-isolation.integration.test.ts +179 -0
  94. package/src/__tests__/animation-target-relationship.unit.test.ts +65 -0
  95. package/src/__tests__/animation-target-source-boundary.unit.test.ts +49 -0
  96. package/src/__tests__/bind-animation-targets.unit.test.ts +167 -0
  97. package/src/__tests__/documentation-contract.unit.test.ts +94 -0
  98. package/src/__tests__/ecs-consumer.integration.test.ts +10 -0
  99. package/src/__tests__/errors.unit.test.ts +19 -0
  100. package/src/__tests__/graph-player.integration.test.ts +22 -0
  101. package/src/__tests__/graph-player.unit.test.ts +12 -0
  102. package/src/__tests__/handle-errors.unit.test.ts +17 -0
  103. package/src/__tests__/loader-boundary.unit.test.ts +66 -0
  104. package/src/__tests__/morph-weights-playback.integration.test.ts +84 -0
  105. package/src/__tests__/morph-weights.unit.test.ts +26 -0
  106. package/src/__tests__/public-animation-target-api.test-d.ts +47 -0
  107. package/src/__tests__/resolver-ownership.integration.test.ts +99 -0
  108. package/src/__tests__/root-surface.unit.test.ts +10 -0
  109. package/src/__tests__/scene-instance-target-collection.integration.test.ts +26 -0
  110. package/src/__tests__/scriptable-pack-consumer.test-d.ts +8 -0
  111. package/src/__tests__/time-delta-regression.integration.test.ts +79 -0
  112. package/src/__tests__/transform-target-playback.integration.test.ts +198 -0
  113. package/src/animation-diagnostic.ts +88 -0
  114. package/src/animation-player.ts +84 -0
  115. package/src/animation-target.ts +257 -0
  116. package/src/assets/animation-decoder.ts +107 -0
  117. package/src/errors.ts +201 -0
  118. package/src/graph/define-animation-graph.ts +193 -0
  119. package/src/graph/describe-animation-graph.ts +74 -0
  120. package/src/graph/serialize-animation-graph.ts +90 -0
  121. package/src/index.ts +57 -0
  122. package/src/player-errors.ts +72 -0
  123. package/src/plugin.ts +73 -0
  124. package/src/resolve-animation-asset.ts +128 -0
  125. package/src/systems/advance-animation-player.ts +971 -0
  126. package/src/systems/evaluate-animation-graph.ts +261 -0
  127. package/src/target-id.ts +51 -0
@@ -0,0 +1,261 @@
1
+ // @forgeax/engine-animation -- evaluateAnimationGraph system (post-order DAG eval).
2
+ //
3
+ // feat-20260713-animation-state-machine-plugin M3 / w25 (plan D-2 independent
4
+ // before-advance system, D-7 eval owns time):
5
+ //
6
+ // This is the "graph -> N-slot" seam. It runs BEFORE advanceAnimationPlayer (the
7
+ // "N-slot -> pose" seam) and is the SOLE writer of the derived slot columns for
8
+ // entities carrying an AnimationGraph handle (AnimationPlayer.graph != 0):
9
+ //
10
+ // 1. Resolve the shared<AnimationGraph> handle to its POD.
11
+ // 2. Resolve every clip leaf's shared<AnimationClip> BEFORE writing anything --
12
+ // a dangling handle throws the structured animation asset error so no
13
+ // dirty pose is left (AC-04 / AC-11).
14
+ // 3. Post-order evaluate effective weights: effective = runtime weight
15
+ // (nodeWeights, default 1) x graph static weight (AC-07 orthogonal product);
16
+ // Blend normalizes its children (AC-04), Add stacks additive layers without
17
+ // normalization (AC-05), nesting propagates a subtree's normalized weight
18
+ // down before its parent combines it (AC-06).
19
+ // 4. Advance each clip node's seek-time by nodeSpeeds*dt (wrap/clamp reusing
20
+ // advance's looping logic) and persist it back to nodeTimes (D-7: eval owns
21
+ // the time SSOT).
22
+ // 5. Spread one derived slot per clip leaf into the variable clips[] / times[]
23
+ // / weights[] columns and park speeds[]=0 so advance does not re-advance the
24
+ // time (D-7). advance's blend math is left completely unchanged (D-2).
25
+ //
26
+ // Entities without a graph (graph == 0) are left untouched -- their slots stay
27
+ // the direct-write SSOT (single evaluation path, plan D-3). advance's mixing
28
+ // math never branches on graph mode; it just consumes whatever fills the slots.
29
+
30
+ import type { EntityHandle, SystemHandle, World } from '@forgeax/engine-ecs';
31
+ import { defineSystem, Time, Update } from '@forgeax/engine-ecs';
32
+ import type { AnimationClip, AnimationGraph, Handle } from '@forgeax/engine-types';
33
+ import { AnimationPlayer } from '../animation-player';
34
+ import { resolveAnimationAsset } from '../resolve-animation-asset';
35
+ import { ADVANCE_ANIMATION_PLAYER_SYSTEM } from './advance-animation-player';
36
+
37
+ /**
38
+ * System name used when `registerEvaluateAnimationGraph` installs the system.
39
+ * External consumers can reference this constant to declare ordering against the
40
+ * graph-evaluation seam.
41
+ */
42
+ export const EVALUATE_ANIMATION_GRAPH_SYSTEM = 'evaluateAnimationGraph' as const;
43
+
44
+ export type AnimationPayloadLookup = (
45
+ guid: string,
46
+ ) => AnimationClip | { readonly code: 'stale' } | undefined;
47
+
48
+ /**
49
+ * Resolved per-entity graph-mode columns (a `world.get` snapshot). `graph` is the
50
+ * raw shared handle (0 = no graph); the three node-* columns are per-node runtime
51
+ * knobs indexed by graph node index.
52
+ */
53
+ interface PlayerGraphColumns {
54
+ readonly graph: number;
55
+ readonly nodeWeights: Float32Array;
56
+ readonly nodeTimes: Float32Array;
57
+ readonly nodeSpeeds: Float32Array;
58
+ readonly paused: boolean;
59
+ readonly looping: boolean;
60
+ }
61
+
62
+ /** Read `arr[i]` when in range, else the default (missing per-node knobs default). */
63
+ function readAt(arr: Float32Array, i: number, dflt: number): number {
64
+ return i >= 0 && i < arr.length ? (arr[i] ?? dflt) : dflt;
65
+ }
66
+
67
+ /**
68
+ * Wrap / clamp an advanced time against a clip duration -- byte-for-byte the same
69
+ * rule advanceAnimationPlayer applies (looping = modulo into [0, duration);
70
+ * non-looping = clamp to [0, duration]). Shared logic so eval and advance agree
71
+ * on the seek-time domain (D-7).
72
+ */
73
+ function wrapTime(time: number, duration: number, looping: boolean): number {
74
+ if (duration <= 0) return time;
75
+ if (looping) {
76
+ let wrapped = time % duration;
77
+ if (wrapped < 0) wrapped += duration;
78
+ return wrapped;
79
+ }
80
+ if (time > duration) return duration;
81
+ if (time < 0) return 0;
82
+ return time;
83
+ }
84
+
85
+ /**
86
+ * Evaluate every graph-carrying AnimationPlayer for one frame, filling the
87
+ * derived N-slot columns. Iterates rows through the system Query (like advance),
88
+ * collecting entity handles first, then resolving + writing each player outside
89
+ * the transient row facade.
90
+ */
91
+ export function evaluateAnimationGraph(
92
+ world: World,
93
+ dt: number,
94
+ lookup: AnimationPayloadLookup = () => undefined,
95
+ ): void {
96
+ const query = world.query({ with: [AnimationPlayer] }).unwrap();
97
+
98
+ const entities: EntityHandle[] = [];
99
+ for (const row of query) entities.push(row.entity);
100
+
101
+ for (const entityRaw of entities) {
102
+ evaluateOneEntity(world, entityRaw, dt, lookup);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Evaluate one entity's AnimationGraph into its derived slots. No-op when the
108
+ * entity has no graph (graph == 0). Every non-zero graph or clip handle is
109
+ * resolved through the World-local animation lookup and invalid handles throw
110
+ * a structured animation error before any derived write (AC-04 / AC-11).
111
+ */
112
+ function evaluateOneEntity(
113
+ world: World,
114
+ entityRaw: number,
115
+ dt: number,
116
+ lookup: AnimationPayloadLookup,
117
+ ): void {
118
+ const entity = entityRaw as EntityHandle;
119
+ const apRes = world.get(entity, AnimationPlayer);
120
+ if (!apRes.ok) return;
121
+ const ap = apRes.value as unknown as PlayerGraphColumns;
122
+
123
+ const graphRaw = ap.graph;
124
+ if (graphRaw === 0) return; // no graph -> direct-write path, untouched.
125
+
126
+ const graphLookup = world.sharedRefs.resolve<string, AnimationGraph>(
127
+ graphRaw as Handle<string, 'shared'>,
128
+ );
129
+ if (!graphLookup.ok) throw graphLookup.error;
130
+ const graph = graphLookup.value;
131
+
132
+ const nodes = graph.nodes;
133
+ if (nodes.length === 0) return; // construction rejects empty graphs; guard anyway.
134
+
135
+ // Collect clip leaves (ascending node index) and resolve every clip BEFORE any
136
+ // write (AC-11: a dangling handle must not leave a dirty pose).
137
+ const clipNodeIndices: number[] = [];
138
+ const clipHandles: Handle<'AnimationClip', 'shared'>[] = [];
139
+ const clipDurations: number[] = [];
140
+ for (let i = 0; i < nodes.length; i++) {
141
+ const node = nodes[i];
142
+ if (node === undefined || node.type !== 'clip') continue;
143
+ const clipLookup = resolveAnimationAsset<AnimationClip>(
144
+ world,
145
+ node.clip,
146
+ 'animation-clip',
147
+ (guid) => {
148
+ return lookup(guid);
149
+ },
150
+ );
151
+ if (!clipLookup.ok) throw clipLookup.error;
152
+ const handle = clipLookup.value.handle;
153
+ clipNodeIndices.push(i);
154
+ clipHandles.push(handle);
155
+ clipDurations.push(clipLookup.value.asset.duration);
156
+ }
157
+
158
+ // Post-order effective-weight evaluation from the root (incoming influence 1).
159
+ const effByNode = new Map<number, number>();
160
+ const runtimeWeight = (n: number): number => readAt(ap.nodeWeights, n, 1);
161
+ const evalNode = (nodeIndex: number, incoming: number): void => {
162
+ const node = nodes[nodeIndex];
163
+ if (node === undefined) return;
164
+ const eff = incoming * runtimeWeight(nodeIndex) * node.weight;
165
+ switch (node.type) {
166
+ case 'clip':
167
+ effByNode.set(nodeIndex, (effByNode.get(nodeIndex) ?? 0) + eff);
168
+ return;
169
+ case 'blend': {
170
+ let total = 0;
171
+ for (const child of node.children)
172
+ total += runtimeWeight(child) * (nodes[child]?.weight ?? 0);
173
+ if (total > 0) {
174
+ for (const child of node.children) evalNode(child, eff / total);
175
+ }
176
+ return;
177
+ }
178
+ case 'add':
179
+ evalNode(node.base, eff);
180
+ for (const layer of node.additive) evalNode(layer, eff);
181
+ return;
182
+ }
183
+ };
184
+ evalNode(graph.root, 1);
185
+
186
+ // Advance each clip node's seek-time (D-7: eval owns the time). Persist the full
187
+ // per-node time column so it stays bounded frame-to-frame.
188
+ const newNodeTimes = new Float32Array(nodes.length);
189
+ for (let i = 0; i < nodes.length; i++) newNodeTimes[i] = readAt(ap.nodeTimes, i, 0);
190
+ for (let k = 0; k < clipNodeIndices.length; k++) {
191
+ // biome-ignore lint/style/noNonNullAssertion: parallel to clipNodeIndices
192
+ const nodeIndex = clipNodeIndices[k]!;
193
+ // biome-ignore lint/style/noNonNullAssertion: parallel to clipNodeIndices
194
+ const duration = clipDurations[k]!;
195
+ const current = readAt(ap.nodeTimes, nodeIndex, 0);
196
+ const speed = readAt(ap.nodeSpeeds, nodeIndex, 0);
197
+ const advanced = ap.paused ? current : current + speed * dt;
198
+ newNodeTimes[nodeIndex] = wrapTime(advanced, duration, ap.looping);
199
+ }
200
+
201
+ // Spread one derived slot per clip leaf into the variable columns; speeds[]=0
202
+ // parks the time so advance does not re-advance it (D-7).
203
+ const slotCount = clipNodeIndices.length;
204
+ const times = new Float32Array(slotCount);
205
+ const weights = new Float32Array(slotCount);
206
+ const speeds = new Float32Array(slotCount);
207
+ for (let k = 0; k < slotCount; k++) {
208
+ // biome-ignore lint/style/noNonNullAssertion: parallel to clipNodeIndices
209
+ const nodeIndex = clipNodeIndices[k]!;
210
+ weights[k] = effByNode.get(nodeIndex) ?? 0;
211
+ times[k] = newNodeTimes[nodeIndex] ?? 0;
212
+ speeds[k] = 0;
213
+ }
214
+
215
+ world.set(entity, AnimationPlayer, {
216
+ clips: clipHandles,
217
+ times,
218
+ weights,
219
+ speeds,
220
+ nodeTimes: newNodeTimes,
221
+ });
222
+ }
223
+
224
+ /**
225
+ * The `evaluateAnimationGraph` system token. Runs `before` advanceAnimationPlayer
226
+ * (plan D-2 / R-5) so the derived slots are ready when advance blends them, and
227
+ * is labelled `'animation'`. Registered by the default `animationPlugin` (w26),
228
+ * so it is on the default path (AC-09), not opt-in.
229
+ */
230
+ export const EvaluateAnimationGraph: SystemHandle<readonly []> = defineSystem({
231
+ name: EVALUATE_ANIMATION_GRAPH_SYSTEM,
232
+ queries: [],
233
+ before: [ADVANCE_ANIMATION_PLAYER_SYSTEM],
234
+ fn: (world) => {
235
+ evaluateAnimationGraph(world, world.getResource(Time).delta);
236
+ },
237
+ });
238
+
239
+ /**
240
+ * Register `evaluateAnimationGraph` into the ECS schedule (before
241
+ * advanceAnimationPlayer). Called by `animationPlugin` so both createApp forms
242
+ * get graph evaluation on the default path for free.
243
+ */
244
+ export function registerEvaluateAnimationGraph(
245
+ world: World,
246
+ lookup: AnimationPayloadLookup = () => undefined,
247
+ ): () => void {
248
+ world
249
+ .addSystem(Update, {
250
+ name: EVALUATE_ANIMATION_GRAPH_SYSTEM,
251
+ queries: [],
252
+ before: [ADVANCE_ANIMATION_PLAYER_SYSTEM],
253
+ fn: (world) => {
254
+ evaluateAnimationGraph(world, world.getResource(Time).delta, lookup);
255
+ },
256
+ })
257
+ .unwrap();
258
+ return () => {
259
+ world.removeSystem(Update, EVALUATE_ANIMATION_GRAPH_SYSTEM);
260
+ };
261
+ }
@@ -0,0 +1,51 @@
1
+ import type { AnimationTargetIdValue } from '@forgeax/engine-types';
2
+ import { blake3 } from '@noble/hashes/blake3.js';
3
+
4
+ const NAMESPACE = Uint8Array.of(
5
+ 0x31,
6
+ 0x79,
7
+ 0xf5,
8
+ 0x19,
9
+ 0xd9,
10
+ 0x27,
11
+ 0x4f,
12
+ 0xf2,
13
+ 0xb5,
14
+ 0x96,
15
+ 0x6f,
16
+ 0xd0,
17
+ 0x77,
18
+ 0x02,
19
+ 0x39,
20
+ 0x11,
21
+ );
22
+ const TARGET_ID_PATTERN = /^[0-9a-f]{32}$/;
23
+ const textEncoder = new TextEncoder();
24
+
25
+ export function deriveAnimationTargetId(path: readonly string[]): AnimationTargetIdValue {
26
+ const segments = path.map((segment) => textEncoder.encode(segment));
27
+ const input = new Uint8Array(
28
+ NAMESPACE.length + segments.reduce((length, segment) => length + 4 + segment.length, 0),
29
+ );
30
+ input.set(NAMESPACE);
31
+
32
+ const view = new DataView(input.buffer);
33
+ let offset = NAMESPACE.length;
34
+ for (const segment of segments) {
35
+ view.setUint32(offset, segment.length, true);
36
+ offset += 4;
37
+ input.set(segment, offset);
38
+ offset += segment.length;
39
+ }
40
+
41
+ const bytes = blake3(input).slice(0, 16);
42
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x80;
43
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
44
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
45
+ '',
46
+ ) as AnimationTargetIdValue;
47
+ }
48
+
49
+ export function isAnimationTargetId(value: unknown): value is AnimationTargetIdValue {
50
+ return typeof value === 'string' && TARGET_ID_PATTERN.test(value);
51
+ }