@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,257 @@
1
+ import {
2
+ defineComponent,
3
+ defineRelationship,
4
+ Entity,
5
+ type EntityHandle,
6
+ type World,
7
+ } from '@forgeax/engine-ecs';
8
+ import { ChildOf, Name, Transform } from '@forgeax/engine-scene';
9
+ import { type AnimationTargetIdValue, err, ok, type Result } from '@forgeax/engine-types';
10
+ import { AnimationPlayer } from './animation-player';
11
+ import { deriveAnimationTargetId, isAnimationTargetId } from './target-id';
12
+
13
+ export const AnimationTargetId = defineComponent('AnimationTargetId', {
14
+ value: { type: 'string' },
15
+ });
16
+
17
+ export const { target: AnimationTargets, source: AnimatedBy } = defineRelationship({
18
+ sourceName: 'AnimatedBy',
19
+ sourceField: 'player',
20
+ targetName: 'AnimationTargets',
21
+ targetField: 'targets',
22
+ exclusive: true,
23
+ linkedSpawn: false,
24
+ allowSelf: true,
25
+ });
26
+
27
+ export type BindAnimationTargetsErrorCode =
28
+ | 'animation-target-player-invalid'
29
+ | 'animation-target-invalid'
30
+ | 'animation-target-outside-player-root'
31
+ | 'animation-target-name-missing'
32
+ | 'animation-target-id-invalid'
33
+ | 'animation-target-id-duplicate'
34
+ | 'animation-target-player-conflict'
35
+ | 'animation-target-capacity-reserve-failed'
36
+ | 'animation-target-bind-failed';
37
+
38
+ export class BindAnimationTargetsError extends Error {
39
+ override readonly name = 'BindAnimationTargetsError';
40
+
41
+ constructor(
42
+ readonly code: BindAnimationTargetsErrorCode,
43
+ readonly expected: string,
44
+ readonly hint: string,
45
+ readonly detail: Readonly<Record<string, unknown>>,
46
+ ) {
47
+ super(`[BindAnimationTargetsError ${code}] expected: ${expected}; hint: ${hint}`);
48
+ }
49
+ }
50
+
51
+ interface Candidate {
52
+ readonly entity: EntityHandle;
53
+ readonly id: AnimationTargetIdValue;
54
+ readonly needsId: boolean;
55
+ readonly owner: EntityHandle | null | undefined;
56
+ }
57
+
58
+ function bindError(
59
+ code: BindAnimationTargetsErrorCode,
60
+ expected: string,
61
+ hint: string,
62
+ detail: Readonly<Record<string, unknown>>,
63
+ ): Result<never, BindAnimationTargetsError> {
64
+ return err(new BindAnimationTargetsError(code, expected, hint, detail));
65
+ }
66
+
67
+ function targetLineage(
68
+ world: World,
69
+ player: EntityHandle,
70
+ target: EntityHandle,
71
+ ): Result<readonly EntityHandle[], BindAnimationTargetsError> {
72
+ const reversed: EntityHandle[] = [];
73
+ const visited = new Set<number>();
74
+ let current = target;
75
+
76
+ while (!visited.has(current as number)) {
77
+ visited.add(current as number);
78
+ reversed.push(current);
79
+ if (current === player) return ok(reversed.reverse());
80
+
81
+ const parent = world.get(current, ChildOf);
82
+ if (!parent.ok || parent.value.parent === null) break;
83
+ current = parent.value.parent;
84
+ }
85
+
86
+ return bindError(
87
+ 'animation-target-outside-player-root',
88
+ 'the target to be the player itself or one of its descendants',
89
+ 'parent the target below the animation player or bind it to the correct player',
90
+ { player: player as number, target: target as number },
91
+ );
92
+ }
93
+
94
+ function inspectTarget(
95
+ world: World,
96
+ player: EntityHandle,
97
+ target: EntityHandle,
98
+ ): Result<Candidate, BindAnimationTargetsError> {
99
+ if (!world.get(target, Entity).ok || !world.get(target, Transform).ok) {
100
+ return bindError(
101
+ 'animation-target-invalid',
102
+ 'a live target entity carrying Transform',
103
+ 'spawn or retain the Transform target before binding it',
104
+ { player: player as number, target: target as number },
105
+ );
106
+ }
107
+
108
+ const lineage = targetLineage(world, player, target);
109
+ if (!lineage.ok) return lineage;
110
+
111
+ const storedId = world.get(target, AnimationTargetId);
112
+ let id: AnimationTargetIdValue;
113
+ if (storedId.ok) {
114
+ if (!isAnimationTargetId(storedId.value.value)) {
115
+ return bindError(
116
+ 'animation-target-id-invalid',
117
+ 'a 32-character lowercase hexadecimal AnimationTargetId wire',
118
+ 'replace the stored value with deriveAnimationTargetId(path)',
119
+ { target: target as number, value: storedId.value.value },
120
+ );
121
+ }
122
+ id = storedId.value.value;
123
+ } else {
124
+ const path: string[] = [];
125
+ for (const entity of lineage.value) {
126
+ const name = world.get(entity, Name);
127
+ if (!name.ok) {
128
+ return bindError(
129
+ 'animation-target-name-missing',
130
+ 'a Name on every entity from the animation root through the target',
131
+ 'attach Name before deriving the target ID',
132
+ { player: player as number, target: target as number, entity: entity as number },
133
+ );
134
+ }
135
+ path.push(name.value.value);
136
+ }
137
+ id = deriveAnimationTargetId(path);
138
+ }
139
+
140
+ const owner = world.get(target, AnimatedBy);
141
+ if (
142
+ owner.ok &&
143
+ owner.value.player !== null &&
144
+ owner.value.player !== player &&
145
+ world.get(owner.value.player, Entity).ok
146
+ ) {
147
+ return bindError(
148
+ 'animation-target-player-conflict',
149
+ 'an unowned target, a stale owner, or the same animation player',
150
+ 'remove AnimatedBy or bind the target through its current live owner',
151
+ {
152
+ player: player as number,
153
+ target: target as number,
154
+ owner: owner.value.player as number,
155
+ },
156
+ );
157
+ }
158
+
159
+ return ok({
160
+ entity: target,
161
+ id,
162
+ needsId: !storedId.ok,
163
+ owner: owner.ok ? owner.value.player : undefined,
164
+ });
165
+ }
166
+
167
+ export function bindAnimationTargets(
168
+ world: World,
169
+ player: EntityHandle,
170
+ targets: readonly EntityHandle[],
171
+ ): Result<void, BindAnimationTargetsError> {
172
+ if (!world.get(player, Entity).ok || !world.get(player, AnimationPlayer).ok) {
173
+ return bindError(
174
+ 'animation-target-player-invalid',
175
+ 'a live entity carrying AnimationPlayer',
176
+ 'spawn or retain the AnimationPlayer before binding targets',
177
+ { player: player as number },
178
+ );
179
+ }
180
+
181
+ const uniqueTargets = [...new Map(targets.map((target) => [target as number, target])).values()];
182
+ const mirror = world.get(player, AnimationTargets);
183
+ const candidates: Candidate[] = [];
184
+ const ids = new Map<AnimationTargetIdValue, EntityHandle[]>();
185
+ if (mirror.ok) {
186
+ for (const targetRaw of mirror.value.targets) {
187
+ const target = targetRaw as EntityHandle;
188
+ if (!world.get(target, Entity).ok) continue;
189
+ const storedId = world.get(target, AnimationTargetId);
190
+ if (!storedId.ok || !isAnimationTargetId(storedId.value.value)) continue;
191
+ const matching = ids.get(storedId.value.value);
192
+ if (matching) {
193
+ if (!matching.includes(target)) matching.push(target);
194
+ } else {
195
+ ids.set(storedId.value.value, [target]);
196
+ }
197
+ }
198
+ }
199
+ for (const target of uniqueTargets) {
200
+ const candidate = inspectTarget(world, player, target);
201
+ if (!candidate.ok) return candidate;
202
+ const matching = ids.get(candidate.value.id);
203
+ const previous = matching?.find((entity) => entity !== target);
204
+ if (previous !== undefined) {
205
+ return bindError(
206
+ 'animation-target-id-duplicate',
207
+ 'one target entity per AnimationTargetId within a batch',
208
+ 'rename one target path or preserve distinct authored target IDs',
209
+ {
210
+ id: candidate.value.id,
211
+ first: previous as number,
212
+ second: target as number,
213
+ },
214
+ );
215
+ }
216
+ if (matching) {
217
+ if (!matching.includes(target)) matching.push(target);
218
+ } else {
219
+ ids.set(candidate.value.id, [target]);
220
+ }
221
+ candidates.push(candidate.value);
222
+ }
223
+
224
+ const existing = new Set(mirror.ok ? [...mirror.value.targets] : []);
225
+
226
+ for (const candidate of candidates) {
227
+ if (candidate.needsId) {
228
+ const added = world.addComponent(candidate.entity, {
229
+ component: AnimationTargetId,
230
+ data: { value: candidate.id },
231
+ });
232
+ if (!added.ok) {
233
+ return bindError(
234
+ 'animation-target-bind-failed',
235
+ 'the preflighted AnimationTargetId write to succeed',
236
+ 'inspect the ECS error before retrying the batch',
237
+ { target: candidate.entity as number, cause: added.error.code },
238
+ );
239
+ }
240
+ }
241
+ if (candidate.owner !== player || !existing.has(candidate.entity)) {
242
+ const added = world.addComponent(candidate.entity, {
243
+ component: AnimatedBy,
244
+ data: { player },
245
+ });
246
+ if (!added.ok) {
247
+ return bindError(
248
+ 'animation-target-bind-failed',
249
+ 'the preflighted AnimatedBy write to succeed',
250
+ 'inspect the ECS error before retrying the batch',
251
+ { target: candidate.entity as number, cause: added.error.code },
252
+ );
253
+ }
254
+ }
255
+ }
256
+ return ok(undefined);
257
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ type AnimationClip,
3
+ type AnimationGraph,
4
+ type AnimationGraphNode,
5
+ type AssetDecoderContribution,
6
+ type AssetKind,
7
+ err,
8
+ ok,
9
+ } from '@forgeax/engine-types';
10
+
11
+ const invalid = (guid: string, expected: string) =>
12
+ err({
13
+ code: 'asset-package-invalid' as const,
14
+ expected,
15
+ hint: 'recook the animation asset and publish its complete payload',
16
+ detail: { guid, reason: 'animation owner validation failed' },
17
+ });
18
+
19
+ function refGuid(value: unknown, refs: readonly string[]): string | undefined {
20
+ if (typeof value === 'string' && value.length > 0) return value;
21
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
22
+ return refs[value];
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ function graphNode(value: unknown, refs: readonly string[]): AnimationGraphNode | undefined {
28
+ if (value === null || typeof value !== 'object') return undefined;
29
+ const source = value as Record<string, unknown>;
30
+ if (typeof source.weight !== 'number' || !Number.isFinite(source.weight)) return undefined;
31
+ if (source.type === 'clip') {
32
+ const clip = refGuid(source.clip, refs);
33
+ return clip === undefined ? undefined : { type: 'clip', clip, weight: source.weight };
34
+ }
35
+ if (source.type === 'blend') {
36
+ return Array.isArray(source.children) &&
37
+ source.children.every((child) => Number.isSafeInteger(child) && (child as number) >= 0)
38
+ ? { type: 'blend', children: source.children as number[], weight: source.weight }
39
+ : undefined;
40
+ }
41
+ if (source.type === 'add') {
42
+ return typeof source.base === 'number' &&
43
+ Number.isSafeInteger(source.base) &&
44
+ source.base >= 0 &&
45
+ Array.isArray(source.additive) &&
46
+ source.additive.every((child) => Number.isSafeInteger(child) && (child as number) >= 0)
47
+ ? {
48
+ type: 'add',
49
+ base: source.base,
50
+ additive: source.additive as number[],
51
+ weight: source.weight,
52
+ }
53
+ : undefined;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ export const animationClipContribution: AssetDecoderContribution<AnimationClip, 'animation-clip'> =
59
+ {
60
+ kind: { kind: 'animation-clip' } as AssetKind<AnimationClip, 'animation-clip'>,
61
+ consumer: 'AnimationPlayer',
62
+ decoder: {
63
+ async decode({ envelope }) {
64
+ return envelope.payload.kind === 'animation-clip' &&
65
+ Array.isArray(envelope.payload.channels) &&
66
+ Number.isFinite(envelope.payload.duration) &&
67
+ envelope.payload.duration >= 0
68
+ ? ok(envelope.payload)
69
+ : invalid(envelope.guid, 'an animation clip with channels and non-negative duration');
70
+ },
71
+ },
72
+ };
73
+
74
+ export const animationGraphContribution: AssetDecoderContribution<
75
+ AnimationGraph,
76
+ 'animation-graph'
77
+ > = {
78
+ kind: { kind: 'animation-graph' } as AssetKind<AnimationGraph, 'animation-graph'>,
79
+ consumer: 'evaluateAnimationGraph',
80
+ decoder: {
81
+ async decode({ envelope }) {
82
+ const payload = envelope.payload as unknown;
83
+ if (payload === null || typeof payload !== 'object') {
84
+ return invalid(envelope.guid, 'an animation graph with at least one node');
85
+ }
86
+ const source = payload as Record<string, unknown>;
87
+ if (
88
+ source.kind !== 'animation-graph' ||
89
+ !Array.isArray(source.nodes) ||
90
+ source.nodes.length === 0 ||
91
+ !Number.isSafeInteger(source.root) ||
92
+ (source.root as number) < 0
93
+ ) {
94
+ return invalid(envelope.guid, 'an animation graph with at least one node');
95
+ }
96
+ const nodes: AnimationGraphNode[] = [];
97
+ for (const value of source.nodes) {
98
+ const node = graphNode(value, envelope.refs);
99
+ if (node === undefined) {
100
+ return invalid(envelope.guid, 'an animation graph with resolvable node references');
101
+ }
102
+ nodes.push(node);
103
+ }
104
+ return ok({ kind: 'animation-graph', nodes, root: source.root as number });
105
+ },
106
+ },
107
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,201 @@
1
+ // @forgeax/engine-animation -- AnimationGraph cluster structured errors.
2
+ //
3
+ // feat-20260713-animation-state-machine-plugin M2 / w14: defineAnimationGraph
4
+ // validates graph topology at CONSTRUCTION time (plan D-5) -- before a GUID
5
+ // handle is minted, so an illegal graph can never be registered or serialized
6
+ // into a pack (AC-14). Each illegal shape returns a structured error carrying a
7
+ // machine-readable `.code` / `.hint` / `.detail` so AI users self-repair by
8
+ // property access without parsing prose (requirements AC-11, charter P3). This
9
+ // mirrors the player-errors.ts convention (code/expected/hint/detail
10
+ // four-field surface + closed code union + closed error union).
11
+
12
+ // -- AnimationGraphEmptyError ----------------------------------------------------
13
+
14
+ /**
15
+ * Structured error for an empty graph (zero nodes).
16
+ *
17
+ * - `.code = 'animation-graph-empty'`
18
+ * - `.expected` -- a graph declares at least one node
19
+ * - `.hint` -- add at least one clip/blend/add node before returning the root
20
+ */
21
+ export class AnimationGraphEmptyError extends Error {
22
+ readonly code = 'animation-graph-empty' as const;
23
+ readonly expected: string;
24
+ readonly hint: string;
25
+
26
+ constructor() {
27
+ super('AnimationGraph is empty: the builder produced zero nodes');
28
+ this.name = 'AnimationGraphEmptyError';
29
+ this.expected = 'an AnimationGraph declares at least one node';
30
+ this.hint =
31
+ 'declare at least one clip/blend/add node inside defineAnimationGraph(...) and return its ref as the root';
32
+ }
33
+ }
34
+
35
+ // -- AnimationGraphNodeOutOfRangeError -------------------------------------------
36
+
37
+ /** Detail for `'animation-graph-node-out-of-range'`. */
38
+ export interface AnimationGraphNodeOutOfRangeDetail {
39
+ /** Index of the node holding the offending reference, or the graph root. */
40
+ readonly node: number;
41
+ /** The out-of-range reference value that was encountered. */
42
+ readonly ref: number;
43
+ /** Total node count -- valid refs live in `[0, nodeCount)`. */
44
+ readonly nodeCount: number;
45
+ }
46
+
47
+ /**
48
+ * Structured error for a node reference that points outside `[0, nodeCount)`.
49
+ *
50
+ * - `.code = 'animation-graph-node-out-of-range'`
51
+ * - `.expected` -- every child/base/additive/root ref is in `[0, nodeCount)`
52
+ * - `.hint` -- reference only node refs returned by the builder in this graph
53
+ * - `.detail = { node, ref, nodeCount }`
54
+ */
55
+ export class AnimationGraphNodeOutOfRangeError extends Error {
56
+ readonly code = 'animation-graph-node-out-of-range' as const;
57
+ readonly expected: string;
58
+ readonly hint: string;
59
+ readonly detail: AnimationGraphNodeOutOfRangeDetail;
60
+
61
+ constructor(detail: AnimationGraphNodeOutOfRangeDetail) {
62
+ const { node, ref, nodeCount } = detail;
63
+ super(
64
+ `AnimationGraph node ${node} references out-of-range node ${ref} (graph has ${nodeCount} nodes)`,
65
+ );
66
+ this.name = 'AnimationGraphNodeOutOfRangeError';
67
+ this.expected = 'every node reference is a valid index in [0, nodeCount)';
68
+ this.hint = `node ${node} references index ${ref}, but valid indices are 0..${nodeCount - 1}; reference only node refs returned by the builder in this graph`;
69
+ this.detail = detail;
70
+ }
71
+ }
72
+
73
+ // -- AnimationGraphNodeWeightInvalidError ----------------------------------------
74
+
75
+ /** Detail for `'animation-graph-node-weight-invalid'`. */
76
+ export interface AnimationGraphNodeWeightInvalidDetail {
77
+ /** Index of the node whose static weight is invalid. */
78
+ readonly node: number;
79
+ /** The offending weight value (negative or non-finite / NaN). */
80
+ readonly weight: number;
81
+ }
82
+
83
+ /**
84
+ * Structured error for a negative or non-finite (NaN / Infinity) static weight.
85
+ *
86
+ * - `.code = 'animation-graph-node-weight-invalid'`
87
+ * - `.expected` -- every node static weight is a finite value `>= 0`
88
+ * - `.hint` -- pass a finite non-negative weight (Add layers may exceed 1)
89
+ * - `.detail = { node, weight }`
90
+ */
91
+ export class AnimationGraphNodeWeightInvalidError extends Error {
92
+ readonly code = 'animation-graph-node-weight-invalid' as const;
93
+ readonly expected: string;
94
+ readonly hint: string;
95
+ readonly detail: AnimationGraphNodeWeightInvalidDetail;
96
+
97
+ constructor(detail: AnimationGraphNodeWeightInvalidDetail) {
98
+ const { node, weight } = detail;
99
+ super(`AnimationGraph node ${node} has an invalid static weight ${weight}`);
100
+ this.name = 'AnimationGraphNodeWeightInvalidError';
101
+ this.expected = 'every node static weight is a finite number >= 0';
102
+ this.hint = `node ${node} has weight ${weight}; pass a finite non-negative static weight (Add layers may sum above 1, but a single node weight must be >= 0)`;
103
+ this.detail = detail;
104
+ }
105
+ }
106
+
107
+ // -- AnimationGraphCycleError ----------------------------------------------------
108
+
109
+ /** Detail for `'animation-graph-cycle'`. */
110
+ export interface AnimationGraphCycleDetail {
111
+ /** A node index that participates in the detected cycle (the back edge). */
112
+ readonly node: number;
113
+ }
114
+
115
+ /**
116
+ * Structured error for a cycle in the (must-be-acyclic) graph.
117
+ *
118
+ * - `.code = 'animation-graph-cycle'`
119
+ * - `.expected` -- the graph is a DAG (no node reaches itself)
120
+ * - `.hint` -- remove the self/back reference so the graph is acyclic
121
+ * - `.detail = { node }`
122
+ */
123
+ export class AnimationGraphCycleError extends Error {
124
+ readonly code = 'animation-graph-cycle' as const;
125
+ readonly expected: string;
126
+ readonly hint: string;
127
+ readonly detail: AnimationGraphCycleDetail;
128
+
129
+ constructor(detail: AnimationGraphCycleDetail) {
130
+ const { node } = detail;
131
+ super(`AnimationGraph contains a cycle reachable from node ${node}`);
132
+ this.name = 'AnimationGraphCycleError';
133
+ this.expected = 'the AnimationGraph is a DAG (no node reaches itself)';
134
+ this.hint = `node ${node} participates in a cycle; a graph must be acyclic -- remove the self/back reference so no node transitively references itself`;
135
+ this.detail = detail;
136
+ }
137
+ }
138
+
139
+ // -- AnimationGraphClipMissingError ----------------------------------------------
140
+
141
+ /** Detail for `'animation-graph-clip-missing'`. */
142
+ export interface AnimationGraphClipMissingDetail {
143
+ /** Index of the clip node whose `shared<AnimationClip>` handle failed to resolve. */
144
+ readonly node: number;
145
+ /** The raw clip handle that did not resolve (never registered / rc released). */
146
+ readonly clip: number;
147
+ }
148
+
149
+ /**
150
+ * Structured error for a graph clip leaf whose `shared<AnimationClip>` handle
151
+ * cannot be resolved at evaluation time (never registered, or its refcount was
152
+ * released). Raised by `evaluateAnimationGraph` (M3 / w25) BEFORE any derived
153
+ * slot is written, so a dangling clip never leaves a dirty pose (requirements
154
+ * AC-11 clip-missing branch, plan §8). Unlike the construction-time
155
+ * {@link AnimationGraphError} shapes, this is an EVALUATION-time error: it is not
156
+ * part of `defineAnimationGraph`'s `Result` (topology is validated at build, clip
157
+ * liveness only at eval), so it stays out of the construction closed union.
158
+ *
159
+ * - `.code = 'animation-graph-clip-missing'`
160
+ * - `.expected` -- every clip node's handle resolves to a live AnimationClip
161
+ * - `.hint` -- keep the clip asset registered (rc retained) while the graph
162
+ * references it
163
+ * - `.detail = { node, clip }`
164
+ */
165
+ export class AnimationGraphClipMissingError extends Error {
166
+ readonly code = 'animation-graph-clip-missing' as const;
167
+ readonly expected: string;
168
+ readonly hint: string;
169
+ readonly detail: AnimationGraphClipMissingDetail;
170
+
171
+ constructor(detail: AnimationGraphClipMissingDetail) {
172
+ const { node, clip } = detail;
173
+ super(`AnimationGraph clip node ${node} references unresolved AnimationClip handle ${clip}`);
174
+ this.name = 'AnimationGraphClipMissingError';
175
+ this.expected = 'every clip node handle resolves to a live AnimationClip';
176
+ this.hint = `clip node ${node} references AnimationClip handle ${clip}, which did not resolve; keep the clip asset registered (refcount retained) for as long as the graph references it`;
177
+ this.detail = detail;
178
+ }
179
+ }
180
+
181
+ // -- AnimationGraphErrorCode / AnimationGraphError closed unions ------------------
182
+
183
+ /**
184
+ * Closed union of AnimationGraph-cluster error codes. AI users perform
185
+ * exhaustive `switch (err.code)` without default; TS guards completeness.
186
+ */
187
+ export type AnimationGraphErrorCode =
188
+ | 'animation-graph-empty'
189
+ | 'animation-graph-node-out-of-range'
190
+ | 'animation-graph-node-weight-invalid'
191
+ | 'animation-graph-cycle';
192
+
193
+ /**
194
+ * Closed union of the AnimationGraph-cluster structured error classes, each
195
+ * carrying an `AnimationGraphErrorCode` discriminant on `.code`.
196
+ */
197
+ export type AnimationGraphError =
198
+ | AnimationGraphEmptyError
199
+ | AnimationGraphNodeOutOfRangeError
200
+ | AnimationGraphNodeWeightInvalidError
201
+ | AnimationGraphCycleError;