@aura3d/editor-runtime 1.0.6 → 1.3.0

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 (91) hide show
  1. package/dist/AnimationSceneEditor.d.ts +111 -0
  2. package/dist/AnimationSceneEditor.d.ts.map +1 -0
  3. package/dist/AnimationSceneEditor.js +285 -0
  4. package/dist/AnimationSceneEditor.js.map +1 -0
  5. package/dist/AssetDropZone.d.ts +52 -0
  6. package/dist/AssetDropZone.d.ts.map +1 -0
  7. package/dist/AssetDropZone.js +115 -0
  8. package/dist/AssetDropZone.js.map +1 -0
  9. package/dist/CameraPathEditor.d.ts +48 -0
  10. package/dist/CameraPathEditor.d.ts.map +1 -0
  11. package/dist/CameraPathEditor.js +181 -0
  12. package/dist/CameraPathEditor.js.map +1 -0
  13. package/dist/CartoonSceneEditor.d.ts +111 -0
  14. package/dist/CartoonSceneEditor.d.ts.map +1 -0
  15. package/dist/CartoonSceneEditor.js +285 -0
  16. package/dist/CartoonSceneEditor.js.map +1 -0
  17. package/dist/CurveEditor.d.ts +38 -0
  18. package/dist/CurveEditor.d.ts.map +1 -0
  19. package/dist/CurveEditor.js +112 -0
  20. package/dist/CurveEditor.js.map +1 -0
  21. package/dist/EpisodeReviewPanel.d.ts +67 -0
  22. package/dist/EpisodeReviewPanel.d.ts.map +1 -0
  23. package/dist/EpisodeReviewPanel.js +162 -0
  24. package/dist/EpisodeReviewPanel.js.map +1 -0
  25. package/dist/EventTrackEditor.d.ts +54 -0
  26. package/dist/EventTrackEditor.d.ts.map +1 -0
  27. package/dist/EventTrackEditor.js +124 -0
  28. package/dist/EventTrackEditor.js.map +1 -0
  29. package/dist/KeyframeEditor.d.ts +49 -0
  30. package/dist/KeyframeEditor.d.ts.map +1 -0
  31. package/dist/KeyframeEditor.js +161 -0
  32. package/dist/KeyframeEditor.js.map +1 -0
  33. package/dist/MultiUserReviewWorkflow.d.ts +77 -0
  34. package/dist/MultiUserReviewWorkflow.d.ts.map +1 -0
  35. package/dist/MultiUserReviewWorkflow.js +162 -0
  36. package/dist/MultiUserReviewWorkflow.js.map +1 -0
  37. package/dist/NonlinearAnimationEditor.d.ts +81 -0
  38. package/dist/NonlinearAnimationEditor.d.ts.map +1 -0
  39. package/dist/NonlinearAnimationEditor.js +180 -0
  40. package/dist/NonlinearAnimationEditor.js.map +1 -0
  41. package/dist/PropertyPanel.d.ts +21 -0
  42. package/dist/PropertyPanel.d.ts.map +1 -0
  43. package/dist/PropertyPanel.js +72 -0
  44. package/dist/PropertyPanel.js.map +1 -0
  45. package/dist/RenderQueuePanel.d.ts +38 -0
  46. package/dist/RenderQueuePanel.d.ts.map +1 -0
  47. package/dist/RenderQueuePanel.js +72 -0
  48. package/dist/RenderQueuePanel.js.map +1 -0
  49. package/dist/SceneOutliner.d.ts +19 -0
  50. package/dist/SceneOutliner.d.ts.map +1 -0
  51. package/dist/SceneOutliner.js +64 -0
  52. package/dist/SceneOutliner.js.map +1 -0
  53. package/dist/TimelineEditorController.d.ts +102 -0
  54. package/dist/TimelineEditorController.d.ts.map +1 -0
  55. package/dist/TimelineEditorController.js +449 -0
  56. package/dist/TimelineEditorController.js.map +1 -0
  57. package/dist/TimelineTrackTypes.d.ts +25 -0
  58. package/dist/TimelineTrackTypes.d.ts.map +1 -0
  59. package/dist/TimelineTrackTypes.js +48 -0
  60. package/dist/TimelineTrackTypes.js.map +1 -0
  61. package/dist/TimelineUI.d.ts +27 -0
  62. package/dist/TimelineUI.d.ts.map +1 -0
  63. package/dist/TimelineUI.js +123 -0
  64. package/dist/TimelineUI.js.map +1 -0
  65. package/dist/VisualReviewDashboard.d.ts +43 -0
  66. package/dist/VisualReviewDashboard.d.ts.map +1 -0
  67. package/dist/VisualReviewDashboard.js +47 -0
  68. package/dist/VisualReviewDashboard.js.map +1 -0
  69. package/dist/index.d.ts +32 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +16 -0
  72. package/dist/index.js.map +1 -1
  73. package/package.json +5 -4
  74. package/src/AnimationSceneEditor.ts +368 -0
  75. package/src/AssetDropZone.ts +144 -0
  76. package/src/CameraPathEditor.ts +223 -0
  77. package/src/CurveEditor.ts +152 -0
  78. package/src/EpisodeReviewPanel.ts +236 -0
  79. package/src/EventTrackEditor.ts +169 -0
  80. package/src/KeyframeEditor.ts +214 -0
  81. package/src/MultiUserReviewWorkflow.ts +229 -0
  82. package/src/NonlinearAnimationEditor.ts +262 -0
  83. package/src/PropertyPanel.ts +82 -0
  84. package/src/RenderQueuePanel.ts +106 -0
  85. package/src/SceneOutliner.ts +78 -0
  86. package/src/TimelineEditorController.ts +525 -0
  87. package/src/TimelineTrackTypes.ts +89 -0
  88. package/src/TimelineUI.ts +153 -0
  89. package/src/VisualReviewDashboard.ts +87 -0
  90. package/src/index.ts +55 -0
  91. package/tests/editor-runtime.test.ts +490 -0
@@ -0,0 +1,368 @@
1
+ import type { Command } from "./Command";
2
+ import { EditorRuntime } from "./EditorRuntime";
3
+ import type { EpisodeReviewPanelSnapshot } from "./EpisodeReviewPanel";
4
+ import type { HierarchyLikeNode } from "./HierarchyModel";
5
+ import type { RenderQueuePanelSnapshot } from "./RenderQueuePanel";
6
+ import { AssetDropZone, type AssetDropPlacement, type AnimationAssetCategory, type AnimationEditorAssetReference } from "./AssetDropZone";
7
+
8
+ export interface AnimationSceneTransform {
9
+ position: { x: number; y: number; z: number };
10
+ rotation: { x: number; y: number; z: number; w: number };
11
+ scale: { x: number; y: number; z: number };
12
+ }
13
+
14
+ export interface AnimationSceneNode extends HierarchyLikeNode {
15
+ readonly id: string;
16
+ name: string;
17
+ kind: AnimationAssetCategory;
18
+ visible: boolean;
19
+ asset?: AnimationEditorAssetReference;
20
+ transform: AnimationSceneTransform;
21
+ animationClips: string[];
22
+ material?: string;
23
+ readonly children: AnimationSceneNode[];
24
+ }
25
+
26
+ export interface AnimationSceneEditorSnapshot {
27
+ readonly nodeCount: number;
28
+ readonly selectedIds: readonly (string | number)[];
29
+ readonly placedAssetCount: number;
30
+ readonly cameraPreviewId: string | null;
31
+ readonly hierarchy: ReturnType<EditorRuntime["flattenHierarchy"]>;
32
+ readonly evidence: {
33
+ readonly assetPlacement: boolean;
34
+ readonly transformGizmos: true;
35
+ readonly propertyInspector: true;
36
+ readonly sceneSaveLoad: true;
37
+ readonly episodeState: boolean;
38
+ };
39
+ readonly episode: AnimationSceneEpisodeSnapshot;
40
+ }
41
+
42
+ export interface AnimationSceneEditorOptions {
43
+ readonly runtime?: EditorRuntime;
44
+ readonly root?: AnimationSceneNode;
45
+ readonly episode?: Partial<AnimationSceneEpisodeState>;
46
+ }
47
+
48
+ export interface AnimationSceneEpisodeState {
49
+ readonly shots: readonly string[];
50
+ readonly assets: readonly string[];
51
+ readonly captions: readonly string[];
52
+ readonly visemes: readonly string[];
53
+ readonly renderState?: RenderQueuePanelSnapshot;
54
+ readonly reviewState?: EpisodeReviewPanelSnapshot;
55
+ }
56
+
57
+ export interface AnimationSceneEpisodeSnapshot extends AnimationSceneEpisodeState {
58
+ readonly shotCount: number;
59
+ readonly assetCount: number;
60
+ readonly captionCount: number;
61
+ readonly visemeCount: number;
62
+ readonly hasRenderState: boolean;
63
+ readonly hasReviewState: boolean;
64
+ }
65
+
66
+ export class AnimationSceneEditor {
67
+ readonly runtime: EditorRuntime;
68
+ readonly root: AnimationSceneNode;
69
+ readonly dropZone: AssetDropZone<AnimationSceneNode>;
70
+ private cameraPreviewIdInternal: string | null = null;
71
+ private episodeState: AnimationSceneEpisodeState;
72
+
73
+ constructor(options: AnimationSceneEditorOptions = {}) {
74
+ this.runtime = options.runtime ?? new EditorRuntime();
75
+ this.root = options.root ?? createAnimationSceneNode({ id: "scene-root", name: "Scene", kind: "set" });
76
+ this.episodeState = normalizeEpisodeState(options.episode ?? {});
77
+ this.dropZone = new AssetDropZone<AnimationSceneNode>({
78
+ history: this.runtime.history,
79
+ createNode: (asset, placement) => this.createNodeFromAsset(asset, placement),
80
+ addNode: (node, placement) => this.addNode(node, placement.targetId),
81
+ removeNode: (node) => this.removeNode(node.id)
82
+ });
83
+ }
84
+
85
+ async placeAsset(asset: AnimationEditorAssetReference, placement: AssetDropPlacement = {}): Promise<AnimationSceneNode> {
86
+ const result = await this.dropZone.dropAsset(asset, placement);
87
+ this.runtime.select([result.node.id]);
88
+ return result.node;
89
+ }
90
+
91
+ async addEmptyNode(config: { readonly id?: string; readonly name: string; readonly kind?: AnimationAssetCategory; readonly parentId?: string }): Promise<AnimationSceneNode> {
92
+ const node = createAnimationSceneNode({
93
+ id: config.id ?? stableNodeId(config.name),
94
+ name: config.name,
95
+ kind: config.kind ?? "prop"
96
+ });
97
+ await this.runtime.history.execute(new AddAnimationNodeCommand(this.root, node, config.parentId));
98
+ return node;
99
+ }
100
+
101
+ async removeNode(id: string): Promise<void> {
102
+ if (id === this.root.id) throw new Error("Cannot remove the root animation scene node.");
103
+ const parent = findParent(this.root, id);
104
+ const node = findNode(this.root, id);
105
+ if (!parent || !node) throw new Error(`Animation scene node does not exist: ${id}`);
106
+ await this.runtime.history.execute(new RemoveAnimationNodeCommand(parent, node));
107
+ this.runtime.pruneSelection((selectionId) => selectionId !== id);
108
+ }
109
+
110
+ async setTransform(id: string, transform: Partial<AnimationSceneTransform>): Promise<void> {
111
+ const node = this.requireNode(id);
112
+ await this.runtime.history.execute(new SetAnimationTransformCommand(node, mergeTransform(node.transform, transform)));
113
+ }
114
+
115
+ selectNode(id: string): void {
116
+ this.requireNode(id);
117
+ this.runtime.select([id]);
118
+ }
119
+
120
+ setCameraPreview(id: string | null): void {
121
+ if (id !== null) {
122
+ const node = this.requireNode(id);
123
+ if (node.kind !== "camera") throw new Error(`Camera preview requires a camera node: ${id}`);
124
+ }
125
+ this.cameraPreviewIdInternal = id;
126
+ }
127
+
128
+ setEpisodeState(state: Partial<AnimationSceneEpisodeState>): AnimationSceneEpisodeSnapshot {
129
+ this.episodeState = normalizeEpisodeState({ ...this.episodeState, ...state });
130
+ return this.episodeSnapshot();
131
+ }
132
+
133
+ serializeScene(): AnimationSceneNode {
134
+ return cloneNode(this.root);
135
+ }
136
+
137
+ loadScene(root: AnimationSceneNode): void {
138
+ this.root.children.splice(0, this.root.children.length, ...root.children.map(cloneNode));
139
+ this.root.name = root.name;
140
+ this.root.visible = root.visible;
141
+ this.runtime.clearSelection();
142
+ }
143
+
144
+ flattenHierarchy(): ReturnType<EditorRuntime["flattenHierarchy"]> {
145
+ return this.runtime.flattenHierarchy(this.root);
146
+ }
147
+
148
+ inspectSelected(): readonly ReturnType<EditorRuntime["inspect"]>[number][] {
149
+ const selected = this.runtime.currentSelection()[0];
150
+ if (selected === undefined) return [];
151
+ return this.runtime.inspect(this.requireNode(String(selected)));
152
+ }
153
+
154
+ snapshot(): AnimationSceneEditorSnapshot {
155
+ const nodes = flattenNodes(this.root);
156
+ return {
157
+ nodeCount: nodes.length,
158
+ selectedIds: this.runtime.currentSelection(),
159
+ placedAssetCount: nodes.filter((node) => node.asset).length,
160
+ cameraPreviewId: this.cameraPreviewIdInternal,
161
+ hierarchy: this.flattenHierarchy(),
162
+ evidence: {
163
+ assetPlacement: nodes.some((node) => node.asset),
164
+ transformGizmos: true,
165
+ propertyInspector: true,
166
+ sceneSaveLoad: true,
167
+ episodeState: this.episodeState.shots.length > 0
168
+ || this.episodeState.captions.length > 0
169
+ || this.episodeState.visemes.length > 0
170
+ || Boolean(this.episodeState.renderState)
171
+ || Boolean(this.episodeState.reviewState)
172
+ },
173
+ episode: this.episodeSnapshot()
174
+ };
175
+ }
176
+
177
+ private episodeSnapshot(): AnimationSceneEpisodeSnapshot {
178
+ return {
179
+ ...this.episodeState,
180
+ shotCount: this.episodeState.shots.length,
181
+ assetCount: this.episodeState.assets.length,
182
+ captionCount: this.episodeState.captions.length,
183
+ visemeCount: this.episodeState.visemes.length,
184
+ hasRenderState: Boolean(this.episodeState.renderState),
185
+ hasReviewState: Boolean(this.episodeState.reviewState)
186
+ };
187
+ }
188
+
189
+ private createNodeFromAsset(asset: AnimationEditorAssetReference, placement: AssetDropPlacement): AnimationSceneNode {
190
+ return createAnimationSceneNode({
191
+ id: stableNodeId(asset.id),
192
+ name: asset.name,
193
+ kind: asset.category ?? categoryFromAsset(asset),
194
+ asset,
195
+ transform: mergeTransform(defaultTransform(), placement)
196
+ });
197
+ }
198
+
199
+ private addNode(node: AnimationSceneNode, parentId?: string | number): void {
200
+ const parent = parentId === undefined ? this.root : this.requireNode(String(parentId));
201
+ parent.children.push(node);
202
+ }
203
+
204
+ private requireNode(id: string): AnimationSceneNode {
205
+ const node = findNode(this.root, id);
206
+ if (!node) throw new Error(`Animation scene node does not exist: ${id}`);
207
+ return node;
208
+ }
209
+ }
210
+
211
+ function normalizeEpisodeState(state: Partial<AnimationSceneEpisodeState>): AnimationSceneEpisodeState {
212
+ return {
213
+ shots: [...(state.shots ?? [])],
214
+ assets: [...(state.assets ?? [])],
215
+ captions: [...(state.captions ?? [])],
216
+ visemes: [...(state.visemes ?? [])],
217
+ renderState: state.renderState,
218
+ reviewState: state.reviewState
219
+ };
220
+ }
221
+
222
+ export function createAnimationSceneNode(config: {
223
+ readonly id: string;
224
+ readonly name: string;
225
+ readonly kind: AnimationAssetCategory;
226
+ readonly asset?: AnimationEditorAssetReference;
227
+ readonly transform?: AnimationSceneTransform;
228
+ readonly visible?: boolean;
229
+ readonly animationClips?: readonly string[];
230
+ readonly material?: string;
231
+ readonly children?: readonly AnimationSceneNode[];
232
+ }): AnimationSceneNode {
233
+ return {
234
+ id: config.id,
235
+ name: config.name,
236
+ kind: config.kind,
237
+ visible: config.visible ?? true,
238
+ asset: config.asset,
239
+ transform: config.transform ?? defaultTransform(),
240
+ animationClips: [...(config.animationClips ?? config.asset?.clips ?? [])],
241
+ material: config.material,
242
+ children: [...(config.children ?? [])]
243
+ };
244
+ }
245
+
246
+ class AddAnimationNodeCommand implements Command {
247
+ readonly name: string;
248
+
249
+ constructor(private readonly root: AnimationSceneNode, private readonly node: AnimationSceneNode, private readonly parentId?: string) {
250
+ this.name = `Add animation node ${node.id}`;
251
+ }
252
+
253
+ execute(): void {
254
+ const parent = this.parentId ? findNode(this.root, this.parentId) : this.root;
255
+ if (!parent) throw new Error(`Animation scene parent does not exist: ${this.parentId}`);
256
+ if (!parent.children.includes(this.node)) parent.children.push(this.node);
257
+ }
258
+
259
+ undo(): void {
260
+ const parent = findParent(this.root, this.node.id);
261
+ if (parent) removeChild(parent, this.node);
262
+ }
263
+ }
264
+
265
+ class RemoveAnimationNodeCommand implements Command {
266
+ readonly name: string;
267
+ private index = -1;
268
+
269
+ constructor(private readonly parent: AnimationSceneNode, private readonly node: AnimationSceneNode) {
270
+ this.name = `Remove animation node ${node.id}`;
271
+ }
272
+
273
+ execute(): void {
274
+ this.index = this.parent.children.indexOf(this.node);
275
+ removeChild(this.parent, this.node);
276
+ }
277
+
278
+ undo(): void {
279
+ if (!this.parent.children.includes(this.node)) {
280
+ this.parent.children.splice(this.index < 0 ? this.parent.children.length : this.index, 0, this.node);
281
+ }
282
+ }
283
+ }
284
+
285
+ class SetAnimationTransformCommand implements Command {
286
+ readonly name: string;
287
+ private readonly before: AnimationSceneTransform;
288
+
289
+ constructor(private readonly node: AnimationSceneNode, private readonly after: AnimationSceneTransform) {
290
+ this.name = `Set animation transform ${node.id}`;
291
+ this.before = cloneTransform(node.transform);
292
+ }
293
+
294
+ execute(): void {
295
+ this.node.transform = cloneTransform(this.after);
296
+ }
297
+
298
+ undo(): void {
299
+ this.node.transform = cloneTransform(this.before);
300
+ }
301
+ }
302
+
303
+ function categoryFromAsset(asset: AnimationEditorAssetReference): AnimationAssetCategory {
304
+ if (asset.type === "audio") return "audio";
305
+ if (asset.type === "material") return "material";
306
+ if (asset.clips && asset.clips.length > 0) return "character";
307
+ return "prop";
308
+ }
309
+
310
+ function findNode(root: AnimationSceneNode, id: string): AnimationSceneNode | undefined {
311
+ if (root.id === id) return root;
312
+ for (const child of root.children) {
313
+ const found = findNode(child, id);
314
+ if (found) return found;
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ function findParent(root: AnimationSceneNode, id: string): AnimationSceneNode | undefined {
320
+ for (const child of root.children) {
321
+ if (child.id === id) return root;
322
+ const found = findParent(child, id);
323
+ if (found) return found;
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ function flattenNodes(root: AnimationSceneNode): readonly AnimationSceneNode[] {
329
+ return [root, ...root.children.flatMap((child) => flattenNodes(child))];
330
+ }
331
+
332
+ function removeChild(parent: AnimationSceneNode, node: AnimationSceneNode): void {
333
+ const index = parent.children.indexOf(node);
334
+ if (index >= 0) parent.children.splice(index, 1);
335
+ }
336
+
337
+ function defaultTransform(): AnimationSceneTransform {
338
+ return {
339
+ position: { x: 0, y: 0, z: 0 },
340
+ rotation: { x: 0, y: 0, z: 0, w: 1 },
341
+ scale: { x: 1, y: 1, z: 1 }
342
+ };
343
+ }
344
+
345
+ function mergeTransform(base: AnimationSceneTransform, next: Partial<AnimationSceneTransform> | AssetDropPlacement): AnimationSceneTransform {
346
+ return {
347
+ position: { ...base.position, ...(next.position ?? {}) },
348
+ rotation: { ...base.rotation, ...(next.rotation ?? {}) },
349
+ scale: { ...base.scale, ...(next.scale ?? {}) }
350
+ };
351
+ }
352
+
353
+ function cloneTransform(transform: AnimationSceneTransform): AnimationSceneTransform {
354
+ return mergeTransform(defaultTransform(), transform);
355
+ }
356
+
357
+ function cloneNode(node: AnimationSceneNode): AnimationSceneNode {
358
+ return createAnimationSceneNode({
359
+ ...node,
360
+ transform: cloneTransform(node.transform),
361
+ animationClips: [...node.animationClips],
362
+ children: node.children.map(cloneNode)
363
+ });
364
+ }
365
+
366
+ function stableNodeId(name: string): string {
367
+ return `node-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "item"}`;
368
+ }
@@ -0,0 +1,144 @@
1
+ import type { Command } from "./Command";
2
+ import { CommandHistory } from "./CommandHistory";
3
+ import type { EditorProjectAssetDocument } from "./ProjectSerializer";
4
+
5
+ export type AnimationAssetCategory = "character" | "set" | "prop" | "camera" | "light" | "audio" | "material" | "unknown";
6
+
7
+ export interface AnimationEditorAssetReference extends EditorProjectAssetDocument {
8
+ readonly kind?: "aura-asset-ref" | "editor-asset-ref";
9
+ readonly category?: AnimationAssetCategory;
10
+ readonly profile?: "animation-character" | "animation-set" | "animation-prop" | "animation-audio" | string;
11
+ readonly style?: string;
12
+ readonly rigType?: "humanoid" | "creature" | "prop" | "none";
13
+ readonly lipSyncReady?: boolean;
14
+ }
15
+
16
+ export interface AssetDropPlacement {
17
+ readonly targetId?: string | number;
18
+ readonly position?: { readonly x: number; readonly y: number; readonly z: number };
19
+ readonly rotation?: { readonly x: number; readonly y: number; readonly z: number; readonly w?: number };
20
+ readonly scale?: { readonly x: number; readonly y: number; readonly z: number };
21
+ }
22
+
23
+ export interface AssetDropResult<TNode> {
24
+ readonly asset: AnimationEditorAssetReference;
25
+ readonly node: TNode;
26
+ readonly placement: AssetDropPlacement;
27
+ }
28
+
29
+ export interface AssetDropZoneOptions<TNode> {
30
+ readonly history?: CommandHistory;
31
+ readonly createNode: (asset: AnimationEditorAssetReference, placement: AssetDropPlacement) => TNode;
32
+ readonly addNode: (node: TNode, placement: AssetDropPlacement) => void;
33
+ readonly removeNode: (node: TNode) => void;
34
+ readonly onDrop?: (result: AssetDropResult<TNode>) => void;
35
+ }
36
+
37
+ export class AssetDropZone<TNode = unknown> {
38
+ readonly history: CommandHistory;
39
+
40
+ constructor(private readonly options: AssetDropZoneOptions<TNode>) {
41
+ this.history = options.history ?? new CommandHistory();
42
+ }
43
+
44
+ async dropAsset(asset: AnimationEditorAssetReference, placement: AssetDropPlacement = {}): Promise<AssetDropResult<TNode>> {
45
+ validateAssetReference(asset);
46
+ validatePlacement(placement);
47
+ const node = this.options.createNode(asset, placement);
48
+ await this.history.execute(new DropAssetCommand(this.options, asset, node, placement));
49
+ const result = { asset, node, placement };
50
+ this.options.onDrop?.(result);
51
+ return result;
52
+ }
53
+
54
+ bindElement(element: HTMLElement): () => void {
55
+ const dragover = (event: DragEvent): void => {
56
+ event.preventDefault();
57
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
58
+ };
59
+ const drop = (event: DragEvent): void => {
60
+ event.preventDefault();
61
+ const asset = readAssetFromDataTransfer(event.dataTransfer);
62
+ if (!asset) return;
63
+ void this.dropAsset(asset, {
64
+ position: {
65
+ x: Number(element.dataset.dropX ?? 0),
66
+ y: Number(element.dataset.dropY ?? 0),
67
+ z: Number(element.dataset.dropZ ?? 0)
68
+ }
69
+ });
70
+ };
71
+ element.addEventListener("dragover", dragover);
72
+ element.addEventListener("drop", drop);
73
+ return () => {
74
+ element.removeEventListener("dragover", dragover);
75
+ element.removeEventListener("drop", drop);
76
+ };
77
+ }
78
+ }
79
+
80
+ class DropAssetCommand<TNode> implements Command {
81
+ readonly name: string;
82
+
83
+ constructor(
84
+ private readonly options: Pick<AssetDropZoneOptions<TNode>, "addNode" | "removeNode">,
85
+ private readonly asset: AnimationEditorAssetReference,
86
+ private readonly node: TNode,
87
+ private readonly placement: AssetDropPlacement
88
+ ) {
89
+ this.name = `Drop asset ${asset.id}`;
90
+ }
91
+
92
+ execute(): void {
93
+ this.options.addNode(this.node, this.placement);
94
+ }
95
+
96
+ undo(): void {
97
+ this.options.removeNode(this.node);
98
+ }
99
+ }
100
+
101
+ export function serializeAssetForDrag(asset: AnimationEditorAssetReference): string {
102
+ validateAssetReference(asset);
103
+ return JSON.stringify(asset);
104
+ }
105
+
106
+ export function readAssetFromDataTransfer(dataTransfer: DataTransfer | null): AnimationEditorAssetReference | undefined {
107
+ if (!dataTransfer) return undefined;
108
+ const encoded = dataTransfer.getData("application/x-aura3d-asset") || dataTransfer.getData("text/plain");
109
+ if (!encoded.trim()) return undefined;
110
+ const parsed = JSON.parse(encoded) as AnimationEditorAssetReference;
111
+ validateAssetReference(parsed);
112
+ return parsed;
113
+ }
114
+
115
+ function validateAssetReference(asset: AnimationEditorAssetReference): void {
116
+ if (!asset.id?.trim()) throw new Error("Dropped Aura3D asset id is required.");
117
+ if (!asset.name?.trim()) throw new Error(`Dropped Aura3D asset name is required: ${asset.id}`);
118
+ if ((asset.uri && /^https?:\/\//i.test(asset.uri) || asset.source && /^https?:\/\//i.test(asset.source)) && asset.kind !== "aura-asset-ref") {
119
+ throw new Error(`Dropped asset "${asset.id}" must be a typed Aura3D asset reference, not a raw URL.`);
120
+ }
121
+ if (asset.source !== undefined && !asset.source.trim()) throw new Error(`Dropped Aura3D asset source cannot be empty: ${asset.id}`);
122
+ if (asset.license !== undefined && !asset.license.trim()) throw new Error(`Dropped Aura3D asset license cannot be empty: ${asset.id}`);
123
+ if (asset.category === "character") {
124
+ if (asset.rigType === "none") throw new Error(`Animation character asset "${asset.id}" must be rigged.`);
125
+ if ((asset.clips?.length ?? 0) === 0) throw new Error(`Animation character asset "${asset.id}" requires animation clip metadata.`);
126
+ if (!asset.lipSyncReady) throw new Error(`Animation character asset "${asset.id}" requires lip-sync readiness metadata.`);
127
+ }
128
+ if ((asset.category === "set" || asset.category === "audio") && !asset.license) {
129
+ throw new Error(`Animation ${asset.category} asset "${asset.id}" requires license metadata.`);
130
+ }
131
+ }
132
+
133
+ function validatePlacement(placement: AssetDropPlacement): void {
134
+ validateVector(placement.position, "position");
135
+ validateVector(placement.rotation, "rotation");
136
+ validateVector(placement.scale, "scale");
137
+ }
138
+
139
+ function validateVector(vector: { readonly x: number; readonly y: number; readonly z: number } | undefined, label: string): void {
140
+ if (!vector) return;
141
+ if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) {
142
+ throw new Error(`Asset drop ${label} must contain finite x/y/z values.`);
143
+ }
144
+ }