@volter/editor-blender 0.1.141 → 0.1.142

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 (51) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +1 -1
  3. package/contributions/blender-animation-editors.tsx +387 -0
  4. package/contributions/blender-game-panel.tsx +25 -24
  5. package/contributions/blender-header-menus.tsx +2 -2
  6. package/contributions/blender-icons.source.mjs +1 -1
  7. package/contributions/blender-model-opening.tsx +3 -3
  8. package/contributions/blender-node-editor.document.tsx +1 -1
  9. package/contributions/blender-outliner-authoring.ts +4 -4
  10. package/contributions/blender-outliner-model.ts +1 -1
  11. package/contributions/blender-play-skin.ts +48 -6
  12. package/contributions/blender-pose.ts +7 -0
  13. package/contributions/blender-properties-context.ts +1 -1
  14. package/contributions/blender-properties-model.ts +2 -2
  15. package/contributions/blender-properties-tab.tsx +2 -2
  16. package/contributions/blender-properties-view.tsx +1 -1
  17. package/contributions/blender-runtime-skin.ts +35 -1
  18. package/contributions/blender-runtime.document.tsx +17 -13
  19. package/contributions/blender-screenshot.menu.ts +3 -3
  20. package/contributions/blender-timeline-geometry.ts +2 -2
  21. package/contributions/blender-timeline.document.tsx +41 -10
  22. package/contributions/blender-uv-editor.document.tsx +1 -1
  23. package/contributions/blender-version.status.tsx +2 -2
  24. package/contributions/blender.command.ts +3 -3
  25. package/contributions/blender.environment.ts +2 -2
  26. package/contributions/blender.keymap.ts +2 -2
  27. package/contributions/blender.status.tsx +2 -2
  28. package/contributions/blender.style.ts +3 -3
  29. package/contributions/model.layout.ts +2 -2
  30. package/contributions/models.finder.ts +1 -1
  31. package/contributions/properties-data.inspector.tsx +1 -1
  32. package/contributions/properties-scene.inspector.tsx +1 -1
  33. package/contributions/sculpt.layout.ts +1 -1
  34. package/contributions/shading.layout.ts +1 -1
  35. package/contributions/texture.layout.ts +1 -1
  36. package/contributions/uv-editing.layout.ts +1 -1
  37. package/dist-node/serving.mjs +2 -2
  38. package/host/blender-runtime-host.ts +4 -4
  39. package/node/blender-mcp.ts +3 -3
  40. package/package.json +6 -6
  41. package/serving/blender-routes.ts +4 -4
  42. package/serving/index.ts +2 -2
  43. package/src/layouts.tsx +3 -3
  44. package/src/looks.ts +1 -1
  45. package/src/play-live.ts +27 -0
  46. package/src/play-mode.ts +23 -15
  47. package/src/presentation.ts +2 -2
  48. package/src/regions.ts +1 -1
  49. package/src/timeline-view-state.ts +7 -1
  50. package/src/uv-view-state.ts +1 -1
  51. package/view/blender-view-routes.ts +2 -2
@@ -23,7 +23,7 @@
23
23
  * baked, the armature keeps the pose it has.
24
24
  *
25
25
  * THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
26
- * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
26
+ * (`@volter/play`'s `play-script.ts`), so pause, step, speed and Restart hold for
27
27
  * animation exactly as they do for the script.
28
28
  */
29
29
  import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
@@ -32,7 +32,7 @@ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/bl
32
32
  import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
33
33
  import type * as THREE from 'three';
34
34
  import { ArmaturePose, nlaLayers, poseClip, type ConstraintOverride, type PoseClip, type PoseLayer } from './blender-pose';
35
- import { editorHost } from '@volter/editor-sdk/host';
35
+ import { editorHost } from '@volter/sdk/host';
36
36
  import { remedy } from './blender-runtime-skin';
37
37
 
38
38
  export interface PlayActionOptions {
@@ -74,10 +74,28 @@ export interface PlayAnimation {
74
74
  track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
75
75
  constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
76
76
  update(dt: number): void;
77
+ /** What each character is playing now, bottom layer first: the animation editors' live view. */
78
+ live(): readonly LiveArmature[];
79
+ /** The frames an action keys, per bone, for one armature (empty until it was baked). */
80
+ channels(armature: string, action: string): { bone: string; keys: number[] }[];
77
81
  readonly warnings: readonly string[];
78
82
  dispose(): void;
79
83
  }
80
84
 
85
+ /** One character's animation at the game's last update. */
86
+ export interface LiveArmature {
87
+ readonly armature: string;
88
+ /** The layers it was posed from, bottom first: each clip, its frame, influence and blend. */
89
+ readonly layers: readonly {
90
+ /** Where the layer comes from: an NLA track's name, or `null` for the active action. */
91
+ readonly track: string | null;
92
+ readonly action: string;
93
+ readonly frame: number;
94
+ readonly influence: number;
95
+ readonly blend: string;
96
+ }[];
97
+ }
98
+
81
99
  /** A weight that moves toward its target at a rate (a crossfade). */
82
100
  interface Ramp { weight: number; target: number; rate: number }
83
101
  const ramp = (weight: number, target: number, fade: number): Ramp => ({ weight, target, rate: fade > 0 ? 1 / fade : 0 });
@@ -101,6 +119,8 @@ interface Armature {
101
119
  line: Playing[];
102
120
  readonly tracks: Map<string, Track>;
103
121
  readonly constraints: Map<string, ConstraintOverride>;
122
+ /** The layers of the last pose, for `live`. */
123
+ posed: LiveArmature['layers'];
104
124
  }
105
125
 
106
126
  /** The copy's animation. `bake` reads one action of one armature through the clip door. */
@@ -114,6 +134,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
114
134
  const armatures = new Map<string, Armature>();
115
135
  let time = 0;
116
136
  let disposed = false;
137
+ const lastSources = new Map<Armature, (string | null)[]>();
117
138
 
118
139
  const fail = (armature: Armature, action: string, why: string): void => {
119
140
  armature.clips.set(action, null);
@@ -138,7 +159,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
138
159
  const pose = new ArmaturePose(rig);
139
160
  pose.facts(entry);
140
161
  for (const thing of pose.unsupported()) warnings.push(`${rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
141
- const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map() };
162
+ const armature: Armature = { rig, pose, facts: entry, clips: new Map(), failed: new Map(), line: [], tracks: new Map(), constraints: new Map(), posed: [] };
142
163
  // EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
143
164
  if (entry.action) armature.line = [{ action: entry.action, from: 0, loop: true, speed: 1, weight: ramp(1, 1, 0) }];
144
165
  armatures.set(rig.armature, armature);
@@ -157,12 +178,15 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
157
178
  /** Every layer of one armature now, bottom to top; null while a clip it needs is being baked. */
158
179
  const layersOf = (armature: Armature): PoseLayer[] | null => {
159
180
  const layers: PoseLayer[] = [];
181
+ /** Each layer's source, by index: a track's name, or null for the active action. */
182
+ const sources: (string | null)[] = [];
183
+ let source: string | null = null;
160
184
  let waiting = false;
161
185
  /** Whether an enabled track has strips (or a game's track plays): what places the active action. */
162
186
  let evaluated = false;
163
187
  const take = (layer: PoseLayer | null | undefined): void => {
164
188
  if (layer === undefined) waiting = true;
165
- else if (layer) layers.push(layer);
189
+ else if (layer) { layers.push(layer); sources.push(source); }
166
190
  };
167
191
  const animation = armature.facts.animation;
168
192
  // THE SCENE FRAME, wrapping at the scene's end as Blender's playback wraps.
@@ -173,6 +197,7 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
173
197
  // ENABLED as Blender says: under a solo only the soloed track, muted or not.
174
198
  if (solo ? !track.solo : (set ? set.mute : track.mute)) continue;
175
199
  const influence = set ? set.influence.weight : 1;
200
+ source = track.name;
176
201
  if (set?.action) {
177
202
  const blend = track.strips[0]?.blendType ?? 'REPLACE';
178
203
  if (set.previous) take(playedLayer(armature, set.previous, influence, blend));
@@ -183,10 +208,11 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
183
208
  const stack = nlaLayers({ ...animation!, tracks: [{ ...track, mute: false, solo: false }] }, frame, (action) => clipOf(armature, action));
184
209
  if (stack.waiting) waiting = true;
185
210
  if (stack.evaluated) evaluated = true;
186
- for (const layer of stack.layers) layers.push({ ...layer, influence: layer.influence * influence });
211
+ for (const layer of stack.layers) { layers.push({ ...layer, influence: layer.influence * influence }); sources.push(track.name); }
187
212
  }
188
- for (const set of armature.tracks.values()) {
213
+ for (const [name, set] of armature.tracks) {
189
214
  if (!set.own || set.mute || !set.action) continue;
215
+ source = name;
190
216
  if (set.previous) take(playedLayer(armature, set.previous, set.influence.weight, 'REPLACE'));
191
217
  take(playedLayer(armature, set.action, set.influence.weight, 'REPLACE'));
192
218
  evaluated = true;
@@ -195,8 +221,10 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
195
221
  // no enabled track has strips, else at its influence and blend type over them.
196
222
  const nla = !!animation?.useNla;
197
223
  const strips = nla && evaluated;
224
+ source = null;
198
225
  for (const played of nla && solo ? [] : armature.line)
199
226
  take(playedLayer(armature, played, strips ? animation!.influence : 1, strips ? animation!.blendType : 'REPLACE'));
227
+ lastSources.set(armature, sources);
200
228
  return waiting ? null : layers;
201
229
  };
202
230
 
@@ -321,12 +349,26 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
321
349
  }
322
350
  const layers = layersOf(armature);
323
351
  if (!layers) continue;
352
+ const sources = lastSources.get(armature) ?? [];
353
+ armature.posed = layers.map((layer, i) => ({ track: sources[i] ?? null, action: layer.clip.action, frame: layer.frame, influence: layer.influence, blend: layer.blend }));
324
354
  armature.pose.apply(layers, {
325
355
  object: (name) => view.objectForBlenderName(name),
326
356
  override: (bone, name) => armature.constraints.get(`${bone}\u0000${name}`),
327
357
  });
328
358
  }
329
359
  },
360
+ channels(name, action) {
361
+ const clip = armatures.get(name)?.clips.get(action);
362
+ if (!clip) return [];
363
+ const bones = new Map<string, Set<number>>();
364
+ for (const channel of clip.channels) {
365
+ let frames = bones.get(channel.bone);
366
+ if (!frames) bones.set(channel.bone, frames = new Set());
367
+ for (const curve of channel.curves) if (curve) for (let i = 0; i < curve.keys.length; i += 6) frames.add(Math.round(curve.keys[i]! * 1000) / 1000);
368
+ }
369
+ return [...bones].map(([bone, frames]) => ({ bone, keys: [...frames].sort((a, b) => a - b) }));
370
+ },
371
+ live: () => [...armatures.values()].map((armature) => ({ armature: armature.rig.armature, layers: armature.posed })),
330
372
  dispose() {
331
373
  disposed = true;
332
374
  armatures.clear();
@@ -20,6 +20,13 @@
20
20
  * A clip holds an action's F-Curves whole (`session.py`'s `rna_action_clip`): keys, handles,
21
21
  * interpolation, extrapolation and Cycles modifiers, evaluated as `fcurve_eval_keyframes` and
22
22
  * `fcm_cycles_time` evaluate them, so a pose is Blender's at any frame, whole or fractional.
23
+ *
24
+ * LICENCE. This file follows Blender's source (the functions named above), so it is a derivative
25
+ * work of Blender and is GPL-3.0-or-later, not the AGPL the package's own code carries
26
+ * (`../LICENSE`). Blender, Copyright (C) Blender Authors, GPL-2.0-or-later. It must not be
27
+ * copied into an Apache-2.0 or MIT package.
28
+ *
29
+ * SPDX-License-Identifier: GPL-3.0-or-later
23
30
  */
24
31
  import type { BlenderActionClip, BlenderClipCurve } from '@volter/blender-engine/browser/rna';
25
32
  import type {
@@ -1,6 +1,6 @@
1
1
  /** Blender's Properties context remains inspectable without a selection.
2
2
  * Reuse the same RNA sections and Properties projection as selected objects. */
3
- import { registerNullSubjectProvider } from '@volter/editor-sdk/kit/inspection/null-subject';
3
+ import { registerNullSubjectProvider } from '@volter/sdk/kit/inspection/null-subject';
4
4
  import { blenderOutlinerState } from './blender-outliner-model';
5
5
  import { blenderOutlinerKind } from './blender-outliner-authoring';
6
6
  import {
@@ -34,7 +34,7 @@
34
34
  */
35
35
 
36
36
  import type { BlenderRnaContext, BlenderRnaView } from '@volter/blender-engine/browser/rna';
37
- import { editorHost } from '@volter/editor-sdk/host';
37
+ import { editorHost } from '@volter/sdk/host';
38
38
  import type * as THREE from 'three';
39
39
  import {
40
40
  blenderPresentationDocumentId,
@@ -192,7 +192,7 @@ function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = fal
192
192
  for (const listener of [...listeners]) listener();
193
193
  // THE TAB RAIL IS THE SECTION LIST, and section matching is the host's
194
194
  // composition — which no store of ours can move. This is the one door that
195
- // re-derives it (`@volter/editor-sdk/host`, `documents.contextChanged`).
195
+ // re-derives it (`@volter/sdk/host`, `documents.contextChanged`).
196
196
  if (tabsMayHaveChanged) editorHost().documents.contextChanged(blenderPresentationDocumentId());
197
197
  }
198
198
 
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * WHY A TAB IS A SECTION, and not a rail of our own: under the `properties`
7
7
  * presentation the inspector's sections ARE the vertical icon rail, 1:1
8
- * (`packages/editor-sdk/src/kit/components/InspectionProjection.tsx`,
8
+ * (`packages/sdk/src/kit/components/InspectionProjection.tsx`,
9
9
  * `PropertiesColumn`). So the way to make Blender's tab rail appear is to
10
10
  * contribute Blender's tabs as sections — which is also exactly the shape I2
11
11
  * needs, because each of these modules then grows a CURATED body over the same
@@ -38,7 +38,7 @@
38
38
  import type {
39
39
  ToolContributionNode,
40
40
  ToolInspectorContributionProps,
41
- } from '@volter/editor-sdk/contributions';
41
+ } from '@volter/sdk/contributions';
42
42
  import {
43
43
  blenderPropertiesState,
44
44
  resolveBlenderSubject,
@@ -55,7 +55,7 @@ import type {
55
55
  } from '@volter/blender-engine/browser/rna';
56
56
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
57
57
  import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
58
- import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/editor-sdk/widgets';
58
+ import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/sdk/widgets';
59
59
  import {
60
60
  type BlenderSubject,
61
61
  blenderPropertiesState,
@@ -42,7 +42,7 @@
42
42
  import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
43
43
  import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
44
44
  import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
45
- import { editorHost, type StageTransportHandle } from '@volter/editor-sdk/host';
45
+ import { editorHost, type StageTransportHandle } from '@volter/sdk/host';
46
46
  import { blenderRnaSet } from '../host/blender-runtime-host';
47
47
  import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
48
48
 
@@ -370,6 +370,40 @@ export class BlenderSkinDirector {
370
370
  this.#pose();
371
371
  }
372
372
 
373
+ // ------------------------------------------------------------ what the animation editors read
374
+
375
+ /** The armature the editors describe (the active object's, else the one with an action). */
376
+ get subject(): string | null {
377
+ return this.#armature;
378
+ }
379
+
380
+ /** Every action in the file, by name: what the Action Editor's selector offers. */
381
+ actionNames(): string[] {
382
+ return Object.keys(this.#view?.animationFacts().actions ?? {}).sort((a, b) => a.localeCompare(b));
383
+ }
384
+
385
+ /** The Action Editor's channels: the subject's active action, per bone, the frames it keys. */
386
+ channels(): { bone: string; keys: number[] }[] {
387
+ const bones = new Map<string, Set<number>>();
388
+ for (const track of this.#clip?.tracks ?? []) {
389
+ const raw = atob(track.keysBase64);
390
+ const bytes = new Uint8Array(raw.length);
391
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
392
+ const keys = new Float32Array(bytes.buffer, 0, bytes.length >> 2);
393
+ let frames = bones.get(track.bone);
394
+ if (!frames) bones.set(track.bone, frames = new Set());
395
+ for (let i = 0; i < keys.length; i += 6) frames.add(Math.round(keys[i]! * 1000) / 1000);
396
+ }
397
+ return [...bones].map(([bone, frames]) => ({ bone, keys: [...frames].sort((a, b) => a - b) }));
398
+ }
399
+
400
+ /** Every armature with its NLA stack and active action, as the frame describes them. */
401
+ stacks(): { armature: string; action: string | null; animation: BlenderArmature['animation'] }[] {
402
+ const facts = this.#view?.animationFacts();
403
+ if (!facts) return [];
404
+ return Object.entries(facts.armatures).map(([armature, entry]) => ({ armature, action: entry.action ?? null, animation: entry.animation }));
405
+ }
406
+
373
407
  /** The scene's name, for the one address the Timeline writes to. */
374
408
  get scene(): string | null {
375
409
  return this.#clip?.scene ?? null;
@@ -26,6 +26,7 @@
26
26
 
27
27
  // How the `model` stage this document builds behaves (its starting presentation).
28
28
  import './blender-properties-context';
29
+ import { liveAnimation, setLiveAnimation } from '../src/play-live';
29
30
  import { blenderViewFieldOfView } from '../src/presentation';
30
31
  import {
31
32
  type BlenderRuntimeView,
@@ -33,20 +34,20 @@ import {
33
34
  } from '@volter/blender-engine/browser/three/blender-runtime-view';
34
35
  import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
35
36
  import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
36
- import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
37
- import { editorHost } from '@volter/editor-sdk/host';
37
+ import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
38
+ import { editorHost } from '@volter/sdk/host';
38
39
  import {
39
40
  documentViewport,
40
41
  documentViewportsVersion,
41
42
  subscribeDocumentViewports,
42
- } from '@volter/editor-sdk/kit/document-viewports';
43
+ } from '@volter/sdk/kit/document-viewports';
43
44
  import {
44
45
  DOCUMENT_STUDIO_PRESET,
45
46
  setViewPresentation,
46
47
  subscribeViewportPresentation,
47
48
  viewPresentation,
48
49
  viewPresentationSnapshot,
49
- } from '@volter/editor-sdk/kit/viewport-presentation';
50
+ } from '@volter/sdk/kit/viewport-presentation';
50
51
  import {
51
52
  type MouseEvent as ReactMouseEvent,
52
53
  type PointerEvent as ReactPointerEvent,
@@ -79,13 +80,13 @@ import { blenderSkin } from './blender-runtime-skin';
79
80
  import { playAnimation, type PlayAnimation } from './blender-play-skin';
80
81
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
81
82
  import { noteModelDocument } from '../src/play-mode';
82
- import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
83
- import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
83
+ import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
84
+ import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/kit/workspace-document-registry';
84
85
  import {
85
86
  documentSecondAreaHeader,
86
87
  setDocumentSecondArea,
87
88
  subscribeDocumentAreas,
88
- } from '@volter/editor-sdk/kit/document-areas';
89
+ } from '@volter/sdk/kit/document-areas';
89
90
  import { createPortal } from 'react-dom';
90
91
  import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
91
92
  import {
@@ -93,13 +94,13 @@ import {
93
94
  subscribeObject3DDocumentSessions,
94
95
  } from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
95
96
  import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
96
- import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
97
- import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
97
+ import { stageStore, subscribeStageStores } from '@volter/sdk/kit/stage-store-registry';
98
+ import { surfaceAcceptsKey } from '@volter/sdk/kit/surface-keyboard';
98
99
  import { BlenderModelOpening } from './blender-model-opening';
99
100
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
100
- import { commandLine } from '@volter/editor-sdk/kit/product-command';
101
- import { fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
102
- import { clearStartupFailure, reportStartupFailure } from '@volter/editor-sdk/kit/startup-failure';
101
+ import { commandLine } from '@volter/sdk/kit/product-command';
102
+ import { fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
103
+ import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/kit/startup-failure';
103
104
 
104
105
  /** This document's model open, as a source in the page's startup-failure registry. */
105
106
  const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
@@ -719,7 +720,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
719
720
  * THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
720
721
  *
721
722
  * The area is declared to the host as the document's second area
722
- * (`@volter/editor-sdk/kit/document-areas`). Its HEADER stands in the document's header row over
723
+ * (`@volter/sdk/kit/document-areas`). Its HEADER stands in the document's header row over
723
724
  * this area: the host reserves this area's width at the row's trailing edge, so the first area's
724
725
  * header ends where the first area does, and this renders into the region it draws there: the
725
726
  * editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
@@ -914,6 +915,7 @@ function BlenderViewportArea({
914
915
  void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
915
916
  if (stopped) return;
916
917
  animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
918
+ setLiveAnimation(animation);
917
919
  stopScript = documentPlayExtension('model')?.run({
918
920
  ...(animation ? { animation } : {}),
919
921
  documentId: modelId,
@@ -973,6 +975,8 @@ function BlenderViewportArea({
973
975
  stopPrepareFrames?.();
974
976
  window.removeEventListener('keydown', onEscape, true);
975
977
  stopScript?.();
978
+ // Only this run's animation is cleared: a restart's new run may already hold the slot.
979
+ if (liveAnimation() === animation) setLiveAnimation(null);
976
980
  animation?.dispose();
977
981
  animation = null;
978
982
  layers.remove();
@@ -6,9 +6,9 @@
6
6
  * goes to the project's `screenshots/` folder and the console says which file it is. Like every
7
7
  * photograph of the document it flashes the document and shows itself in the corner.
8
8
  */
9
- import type { MenuContribution } from '@volter/editor-sdk/chrome';
10
- import { editorHost } from '@volter/editor-sdk/host';
11
- import { announcePhotograph } from '@volter/editor-sdk/kit/photograph-notice';
9
+ import type { MenuContribution } from '@volter/sdk/chrome';
10
+ import { editorHost } from '@volter/sdk/host';
11
+ import { announcePhotograph } from '@volter/sdk/kit/photograph-notice';
12
12
 
13
13
  export const point = 'workspace.menu';
14
14
 
@@ -4,8 +4,8 @@
4
4
  * own frame (5.2.1 on this box, `scripts/blender-reference-frames.py`).
5
5
  *
6
6
  * This is the first unit of the inspection arc with a sighted read behind it.
7
- * I1-I5 each recorded "`/Volumes/PeakSSD` was not mounted, so this is graded
8
- * against Blender's SOURCE alone"; Blender is installed here, the script above
7
+ * I1-I5 each recorded that the reference frames were not at hand, so each was graded
8
+ * against Blender's SOURCE alone; Blender is installed here, the script above
9
9
  * drives its real GUI, and the numbers below carry three things each: the
10
10
  * source that defines them, the pixel that confirms them, and what we draw.
11
11
  *
@@ -38,8 +38,8 @@
38
38
  * you are looking.
39
39
  */
40
40
 
41
- import type { StageTransportHandle } from '@volter/editor-sdk/host';
42
- import { registerViewVerbs } from '@volter/editor-sdk/views';
41
+ import type { StageTransportHandle } from '@volter/sdk/host';
42
+ import { registerViewVerbs } from '@volter/sdk/views';
43
43
  import {
44
44
  type ReactNode,
45
45
  useCallback,
@@ -65,7 +65,9 @@ import {
65
65
  } from '../src/timeline-view-state';
66
66
  import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
67
67
  import { BlenderGamePanel, GAME_PANEL_RATIO, PlayModeSwitch } from './blender-game-panel';
68
- import { setWorkspaceAreaRatio } from '@volter/editor-sdk/kit/workspace-areas';
68
+ import { AnimationEditorMenu, BlenderActionEditor, BlenderNlaEditor } from './blender-animation-editors';
69
+ import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
70
+ import { setWorkspaceAreaRatio } from '@volter/sdk/kit/workspace-areas';
69
71
  import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
70
72
  import {
71
73
  CHANNEL_HEIGHT,
@@ -114,6 +116,7 @@ const REFUSALS = {
114
116
 
115
117
  /** The scene clock is available with or without an action. Only a missing
116
118
  * scene/transport prevents timeline navigation. */
119
+ const GAME_RUNNING = 'A game is running; the Timeline is read-only until it stops (Stop in the Game panel).';
117
120
  const NOT_PLAYABLE =
118
121
  'No scene transport is attached yet; open the model document first.';
119
122
 
@@ -216,6 +219,7 @@ function report(): unknown {
216
219
  size: view.size,
217
220
  refusal: view.refusal,
218
221
  drawn: view.drawn,
222
+ editor: view.editor,
219
223
  };
220
224
  }
221
225
 
@@ -237,6 +241,7 @@ function report(): unknown {
237
241
  * are fixed by this one guard: `frame`, `jump-start`, `jump-end`,
238
242
  * `next-keyframe`, `prev-keyframe`. */
239
243
  function scrubTo(frame: number): unknown {
244
+ if (liveAnimation() !== null) return refuse(GAME_RUNNING);
240
245
  if (!blenderSkin.playable) return refuse(NOT_PLAYABLE);
241
246
  const handle = transport();
242
247
  if (!handle) return refuse('No stage transport is attached yet; open the model document first.');
@@ -249,6 +254,18 @@ registerViewVerbs({
249
254
  title,
250
255
  verbs: [
251
256
  { id: 'state', run: () => report() },
257
+ {
258
+ // BLENDER'S EDITOR-TYPE MENU for this area: the Timeline, the Action Editor or the NLA.
259
+ id: 'editor',
260
+ title: 'Animation: Show Editor',
261
+ run: (args) => {
262
+ const editor = args?.['editor'] ?? args?.['to'];
263
+ if (editor !== 'timeline' && editor !== 'action' && editor !== 'nla')
264
+ return refuse('editor is `timeline`, `action` (the Action Editor) or `nla`.');
265
+ setTimelineViewState({ editor });
266
+ return report();
267
+ },
268
+ },
252
269
  {
253
270
  id: 'frame',
254
271
  title: 'Timeline: Set Frame',
@@ -263,6 +280,7 @@ registerViewVerbs({
263
280
  id: 'play',
264
281
  title: 'Timeline: Play',
265
282
  run: () => {
283
+ if (liveAnimation() !== null) throw new Error(GAME_RUNNING);
266
284
  if (!blenderSkin.playable) throw new Error(NOT_PLAYABLE);
267
285
  const handle = transport();
268
286
  if (!handle)
@@ -364,7 +382,8 @@ registerViewVerbs({
364
382
  const BOTTOM_AREA = 'timeline';
365
383
 
366
384
  /**
367
- * THE BOTTOM AREA IS THE TIMELINE IN MOVIE MODE AND THE GAME PANEL IN GAME MODE
385
+ * THE BOTTOM AREA IS BLENDER'S ANIMATION EDITORS IN ANIMATION MODE (the Timeline, the Action
386
+ * Editor or the NLA, by the editor-type menu) AND THE GAME PANEL IN GAME MODE
368
387
  * (`../src/play-mode.ts`, for the model document on screen). Same area: the
369
388
  * layout's `areas` entry names this document either way. NOT THE SAME SIZE:
370
389
  * Blender's measured Timeline strip (`model.layout.ts`) left the Game panel's
@@ -378,11 +397,19 @@ const BOTTOM_AREA = 'timeline';
378
397
  */
379
398
  export default function BlenderBottomArea() {
380
399
  useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
400
+ useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
381
401
  const documentId = servedModelDocument();
382
402
  const game = documentId !== null && modelPlayMode(documentId) === 'game';
383
- useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, game ? GAME_PANEL_RATIO : null), [game]);
403
+ const { editor } = timelineViewState();
404
+ // The Timeline keeps Blender's strip; the Action Editor and NLA list channels, so they take the
405
+ // Game panel's share, as a person would drag the area up to read them.
406
+ const tall = game || editor !== 'timeline';
407
+ useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, tall ? GAME_PANEL_RATIO : null), [tall]);
384
408
  useEffect(() => () => setWorkspaceAreaRatio(BOTTOM_AREA, null), []);
385
- return game ? <BlenderGamePanel /> : <BlenderTimeline />;
409
+ if (game) return <BlenderGamePanel />;
410
+ if (editor === 'action') return <BlenderActionEditor />;
411
+ if (editor === 'nla') return <BlenderNlaEditor />;
412
+ return <BlenderTimeline />;
386
413
  }
387
414
 
388
415
  function BlenderTimeline() {
@@ -562,7 +589,7 @@ function BlenderTimeline() {
562
589
  const scrubFromPointer = useCallback(
563
590
  (clientX: number) => {
564
591
  const element = canvas.current;
565
- if (!element) return;
592
+ if (!element || liveAnimation() !== null) return;
566
593
  const box = element.getBoundingClientRect();
567
594
  transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
568
595
  },
@@ -878,14 +905,17 @@ function TimelineHeader() {
878
905
  useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
879
906
  const play = blenderSkin.state();
880
907
  const unit = TIMELINE_CHROME.unit;
908
+ // WHILE A GAME RUNS the transport is held: the game owns its copy, and moving the document's
909
+ // frame under it would put two clocks on one picture.
910
+ const gameRunning = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null) !== null;
881
911
  // Static scenes have a timeline too. Disable only while its scene/transport
882
912
  // is unavailable, using the same condition as the command door.
883
- const playable = blenderSkin.playable;
913
+ const playable = blenderSkin.playable && !gameRunning;
884
914
  const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
885
915
  <button
886
916
  key={key}
887
917
  type="button"
888
- title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
918
+ title={playable ? label : `${label} — ${gameRunning ? GAME_RUNNING : NOT_PLAYABLE}`}
889
919
  aria-label={label}
890
920
  disabled={!playable}
891
921
  onClick={onClick}
@@ -923,9 +953,10 @@ function TimelineHeader() {
923
953
  borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
924
954
  }}
925
955
  >
926
- {/* GAME | MOVIE in place of the title, as in the Game panel, so the person switches back
956
+ {/* GAME | ANIMATION in place of the title, as in the Game panel, so the person switches back
927
957
  from either (`blender-game-panel.tsx`); without a Play tool or a model, the title. */}
928
958
  <PlayModeSwitch title="Timeline" />
959
+ <AnimationEditorMenu />
929
960
  <TimelineViewMenu />
930
961
  {/* TWO SPACERS, which is what CENTRES the transport. Blender's Timeline
931
962
  header is three clusters: the menus at the leading edge, the
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { BlenderUvLayout } from '@volter/blender-engine/browser/rna';
33
- import { registerViewVerbs } from '@volter/editor-sdk/views';
33
+ import { registerViewVerbs } from '@volter/sdk/views';
34
34
  import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
35
35
  import {
36
36
  blenderRnaVersion,
@@ -20,7 +20,7 @@
20
20
  * host imports (`validate-editor-closure.mjs`), the first package to reach
21
21
  * that destination, and a token NAME is the published contract while the
22
22
  * handle object is the host's own sugar. The widget kit's move to
23
- * `@volter/editor-sdk/widgets` is deferred (WORK.md §Skews as packages, item 5);
23
+ * `@volter/sdk/widgets` is deferred (WORK.md §Skews as packages, item 5);
24
24
  * until it lands, a package styling inline writes the `var()`.
25
25
  *
26
26
  * Nothing before a project is open (there is no version to tell the truth
@@ -33,7 +33,7 @@
33
33
  * only the version, and the host's counters are untouched.
34
34
  */
35
35
 
36
- import { editorHost } from '@volter/editor-sdk/host';
36
+ import { editorHost } from '@volter/sdk/host';
37
37
  import { useSyncExternalStore } from 'react';
38
38
 
39
39
  export const point = 'workspace.status';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE BLENDER VERBS of the session wire (`@volter/editor-sdk/commands`, a
2
+ * THE BLENDER VERBS of the session wire (`@volter/sdk/commands`, a
3
3
  * `workspace.command` contribution): `blender-start`, `blender-stop`,
4
4
  * `blender-execute`, `blender-scene-info`, `blender-object-info`,
5
5
  * `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
@@ -26,13 +26,13 @@
26
26
  * `@editor/shell-store-door` to hand to the runtime host, which needed it for
27
27
  * one call — presenting the Model document — and that call is now
28
28
  * `host.workspace.open`, an address the door routes. A handler's whole context
29
- * is the command it is given plus `@volter/editor-sdk/host`.
29
+ * is the command it is given plus `@volter/sdk/host`.
30
30
  */
31
31
  import type {
32
32
  CommandContribution,
33
33
  CommandDerivedRefresh,
34
34
  CommandSpec,
35
- } from '@volter/editor-sdk/commands';
35
+ } from '@volter/sdk/commands';
36
36
  import { handleBlenderCommand } from '../host/blender-runtime-host';
37
37
 
38
38
  export const point = 'workspace.command';
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * BLENDER'S WORLD STUDIO LIGHTS: the eight HDRIs Blender ships for Material Preview
3
3
  * (`datafiles/studiolights/world`, copied from Blender 5.2), registered as environment images
4
- * (`@volter/editor-sdk/kit/environment-images`) so a view can light by one and draw it behind
4
+ * (`@volter/sdk/kit/environment-images`) so a view can light by one and draw it behind
5
5
  * the scene. Material Preview opens on Forest. All CC0, by Greg Zaal (Poly Haven); the terms
6
6
  * and each original are in `studiolights/world/license.txt`.
7
7
  */
8
- import type { EnvironmentImageSet } from '@volter/editor-sdk/kit/environment-images';
8
+ import type { EnvironmentImageSet } from '@volter/sdk/kit/environment-images';
9
9
  import city from './studiolights/world/city.exr?url';
10
10
  import courtyard from './studiolights/world/courtyard.exr?url';
11
11
  import forest from './studiolights/world/forest.exr?url';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The BLENDER keymap (`@volter/editor-sdk/looks`, a `workspace.keymap`
2
+ * The BLENDER keymap (`@volter/sdk/looks`, a `workspace.keymap`
3
3
  * contribution). Only the chords that differ from the editor's own are
4
4
  * declared; the host fills the rest from its default table.
5
5
  *
@@ -7,7 +7,7 @@
7
7
  * `scripts/presets/keyconfig/keymap_data/blender_default.py` at the engine's
8
8
  * pin (v5.2.0) — the file that IS the "Blender" keyconfig.
9
9
  */
10
- import type { KeymapContribution } from '@volter/editor-sdk/looks';
10
+ import type { KeymapContribution } from '@volter/sdk/looks';
11
11
 
12
12
  export const point = 'workspace.keymap';
13
13
  export const keymap: KeymapContribution = {
@@ -22,9 +22,9 @@
22
22
  * about THIS editor, so the labels are read from this editor's own contract,
23
23
  * never transcribed from the reference:
24
24
  *
25
- * - the KEYMAP (`keymap-presets.ts`, `@volter/editor-sdk/looks`) binds no
25
+ * - the KEYMAP (`keymap-presets.ts`, `@volter/sdk/looks`) binds no
26
26
  * mouse buttons and structurally cannot: a `KeyChord` is
27
- * `{ key, code?, mod?, shift?, alt? }` (`editor-sdk/src/looks.ts:76-82`)
27
+ * `{ key, code?, mod?, shift?, alt? }` (`sdk/src/looks.ts:76-82`)
28
28
  * with no button field, so no keymap — the Blender one included — can
29
29
  * move a mouse binding. Nothing to read there.
30
30
  * - so the source is the VIEWPORT's own mouse contract: