@volter/editor-blender 0.1.140 → 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
package/LICENSE CHANGED
@@ -34,6 +34,17 @@ ARTWORK is not.
34
34
  be copied into an Apache-2.0 or MIT part of this repository —
35
35
  `packages/editor`'s own icon set included.
36
36
 
37
+ ONE SOURCE FILE FOLLOWS BLENDER'S OWN CODE.
38
+
39
+ `contributions/blender-pose.ts` evaluates a pose as Blender does, and is
40
+ written from Blender's source: its damped-track constraint follows
41
+ `damptrack_do_transform` statement by statement, and its NLA stack, channel
42
+ blending, bone matrices and F-Curve evaluation follow the Blender functions
43
+ its header names. It is a derivative work of GPL-2.0-or-later code and,
44
+ exercising Blender's "or later", is conveyed under the GNU General Public
45
+ License, version 3 or later, not the AGPL. The file says so in its header.
46
+ It may not be copied into an Apache-2.0 or MIT part of this repository.
47
+
37
48
  THE COMBINATION, AND WHY THIS PACKAGE CARRIES TWO LICENCES. Our own code in
38
49
  this package is licensed under the GNU Affero General Public License,
39
50
  version 3 only.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Blender modeling documents, inspectors, commands, layouts and presentation for
4
4
  Volter Editor. The integration talks to the independent Blender engine over its
5
- worker wire and contributes its editing surfaces through the editor SDK.
5
+ worker wire and contributes its editing surfaces through the SDK.
6
6
 
7
7
  The package uses the project contracts and shared Three rendering/capture
8
8
  utilities. It imports no editor-core internals and no game runtime. Its
@@ -0,0 +1,387 @@
1
+ /**
2
+ * BLENDER'S ANIMATION EDITORS beside the Timeline, in the bottom area's Animation view: the editor-
3
+ * type menu Blender's area header has, the Dope Sheet's ACTION EDITOR and the NLA editor.
4
+ *
5
+ * - **Action Editor** (`SpaceAction.mode == 'ACTION'`): one armature's active action. Its
6
+ * selector is Blender's: picking an action writes `animation_data.action` (and its slot), so the
7
+ * character plays it; the channel list is one row per bone with the frames it keys.
8
+ * - **NLA** (`SPACE_NLA`): each armature's tracks, bottom track last as Blender lists them, their
9
+ * strips over scene frames, and the active action's own line on top.
10
+ *
11
+ * WHILE A GAME RUNS both show what the game is animating, read-only, the way a game engine's
12
+ * animator view highlights the live state: each character's clips as its last update posed them
13
+ * (`blender-play-skin.ts`'s `live`), with the frame and influence of each. Nothing is written
14
+ * then: the game owns its copy.
15
+ */
16
+ import { type ReactNode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
17
+ import { blenderRnaSet, blenderRnaVersion, subscribeBlenderRna } from '../host/blender-runtime-host';
18
+ import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
19
+ import {
20
+ type AnimationEditor,
21
+ setTimelineViewState,
22
+ subscribeTimelineView,
23
+ timelineViewState,
24
+ timelineViewVersion,
25
+ } from '../src/timeline-view-state';
26
+ import { PlayModeSwitch } from './blender-game-panel';
27
+ import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
28
+ import {
29
+ CHANNEL_HEIGHT,
30
+ DIAMOND_RADIUS,
31
+ DIAMOND_STROKE,
32
+ HEADER_HEIGHT,
33
+ KEY_COLORS,
34
+ PLAYHEAD,
35
+ SCRUB_HEIGHT,
36
+ TIMELINE_CHROME,
37
+ TIMELINE_THEME,
38
+ } from './blender-timeline-geometry';
39
+
40
+ const EDITORS: readonly { readonly id: AnimationEditor; readonly label: string }[] = [
41
+ { id: 'timeline', label: 'Timeline' },
42
+ { id: 'action', label: 'Action Editor' },
43
+ { id: 'nla', label: 'Nonlinear Animation' },
44
+ ];
45
+ /** The channel region's width: Blender's default `ACHANNEL_NAMEWIDTH` area for a Dope Sheet. */
46
+ const CHANNELS_WIDTH = 200;
47
+
48
+ /** BLENDER'S EDITOR-TYPE MENU, limited to its animation editors: which one this area shows. */
49
+ export function AnimationEditorMenu() {
50
+ useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
51
+ const { editor } = timelineViewState();
52
+ return (
53
+ <select
54
+ aria-label="Animation editor"
55
+ data-testid="animation-editor-type"
56
+ value={editor}
57
+ onChange={(event) => setTimelineViewState({ editor: event.target.value as AnimationEditor })}
58
+ style={{
59
+ height: TIMELINE_CHROME.unit,
60
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
61
+ borderRadius: TIMELINE_CHROME.widgetRadius,
62
+ background: TIMELINE_CHROME.widget,
63
+ color: TIMELINE_CHROME.widgetText,
64
+ font: 'inherit',
65
+ }}
66
+ >
67
+ {EDITORS.map((one) => <option key={one.id} value={one.id}>{one.label}</option>)}
68
+ </select>
69
+ );
70
+ }
71
+
72
+ /** Re-render on every frame while a game runs (its animation changes each update), else never. */
73
+ function useLive() {
74
+ const live = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null);
75
+ const [, setTick] = useState(0);
76
+ useEffect(() => {
77
+ if (!live) return;
78
+ let frame = requestAnimationFrame(function tick() {
79
+ setTick((n) => (n + 1) % 1_000_000);
80
+ frame = requestAnimationFrame(tick);
81
+ });
82
+ return () => cancelAnimationFrame(frame);
83
+ }, [live]);
84
+ return live;
85
+ }
86
+
87
+ function useMeasured() {
88
+ const ref = useRef<HTMLDivElement | null>(null);
89
+ const [size, setSize] = useState({ w: 0, h: 0 });
90
+ useEffect(() => {
91
+ const element = ref.current;
92
+ if (!element) return;
93
+ const observer = new ResizeObserver(() => setSize({ w: element.clientWidth, h: element.clientHeight }));
94
+ observer.observe(element);
95
+ setSize({ w: element.clientWidth, h: element.clientHeight });
96
+ return () => observer.disconnect();
97
+ }, []);
98
+ return { ref, size };
99
+ }
100
+
101
+ function Header({ children }: { readonly children?: ReactNode }) {
102
+ return (
103
+ <div
104
+ style={{
105
+ height: HEADER_HEIGHT,
106
+ flex: `0 0 ${HEADER_HEIGHT}px`,
107
+ display: 'flex',
108
+ alignItems: 'center',
109
+ gap: TIMELINE_CHROME.headerGap,
110
+ padding: TIMELINE_CHROME.headerPadding,
111
+ background: TIMELINE_THEME.header,
112
+ borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
113
+ }}
114
+ >
115
+ <PlayModeSwitch title="Animation" />
116
+ <AnimationEditorMenu />
117
+ {children}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function Frame({ children, header }: { readonly children: ReactNode; readonly header: ReactNode }) {
123
+ return (
124
+ <div
125
+ style={{
126
+ position: 'absolute',
127
+ inset: 0,
128
+ display: 'flex',
129
+ flexDirection: 'column',
130
+ overflow: 'hidden',
131
+ background: TIMELINE_THEME.back,
132
+ color: TIMELINE_CHROME.text,
133
+ font: '11px Inter, system-ui, sans-serif',
134
+ }}
135
+ >
136
+ {header}
137
+ {children}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ /** A frame ruler over [start, end] in `width` pixels: majors every step that keeps labels apart. */
143
+ function ruler(start: number, end: number, width: number) {
144
+ const span = Math.max(1, end - start);
145
+ const pixelsPerFrame = Math.max(1e-6, width / (span * 1.1));
146
+ const first = start - span * 0.05;
147
+ const toX = (frame: number) => (frame - first) * pixelsPerFrame;
148
+ const steps = [1, 2, 5, 10, 20, 25, 50, 100, 250, 500, 1000];
149
+ const step = steps.find((one) => one * pixelsPerFrame >= 40) ?? 1000;
150
+ const majors: number[] = [];
151
+ // Unmeasured (zero width) draws no ruler; a measured one has at most width / 40 labels.
152
+ if (width > 0)
153
+ for (let frame = Math.ceil(first / step) * step; toX(frame) <= width && majors.length < 500; frame += step) majors.push(frame);
154
+ return { toX, majors, toFrame: (x: number) => first + x / pixelsPerFrame };
155
+ }
156
+
157
+ function Playhead({ x, frame, height }: { readonly x: number; readonly frame: number; readonly height: number }) {
158
+ const text = `${Math.round(frame)}`;
159
+ const width = Math.max(PLAYHEAD.minPillWidth, text.length * 6 + 2 * PLAYHEAD.textPadding);
160
+ return (
161
+ <g>
162
+ <rect x={x - PLAYHEAD.stalkWidth / 2} y={SCRUB_HEIGHT} width={PLAYHEAD.stalkWidth} height={Math.max(0, height - SCRUB_HEIGHT)} fill={TIMELINE_THEME.playhead} />
163
+ <rect x={x - width / 2} y={PLAYHEAD.margin} width={width} height={SCRUB_HEIGHT - 2 * PLAYHEAD.margin} rx={PLAYHEAD.radius} fill={TIMELINE_THEME.playhead} />
164
+ <text x={x} y={15.5} textAnchor="middle" fill={TIMELINE_THEME.playheadText} fontSize={11}>{text}</text>
165
+ </g>
166
+ );
167
+ }
168
+
169
+ function Grid({ majors, toX, width, height }: { readonly majors: number[]; readonly toX: (f: number) => number; readonly width: number; readonly height: number }) {
170
+ return (
171
+ <g>
172
+ {majors.map((frame) => <line key={`g${frame}`} x1={toX(frame)} y1={SCRUB_HEIGHT} x2={toX(frame)} y2={height} stroke={TIMELINE_THEME.grid} />)}
173
+ <rect x={0} y={0} width={width} height={SCRUB_HEIGHT} fill={TIMELINE_THEME.scrubBack} />
174
+ {majors.map((frame) => <text key={`t${frame}`} x={toX(frame)} y={15.5} textAnchor="middle" fill={TIMELINE_THEME.scrubText} fontSize={11}>{frame}</text>)}
175
+ </g>
176
+ );
177
+ }
178
+
179
+ function diamond(x: number, y: number, key: string) {
180
+ const r = DIAMOND_RADIUS;
181
+ return <polygon key={key} points={`${x},${y - r} ${x + r},${y} ${x},${y + r} ${x - r},${y}`} fill={KEY_COLORS['KEYFRAME']!.fill} stroke={TIMELINE_THEME.keyBorder} strokeWidth={DIAMOND_STROKE} />;
182
+ }
183
+
184
+ const address = (armature: string) => `bpy.data.objects[${JSON.stringify(armature)}].animation_data`;
185
+
186
+ /**
187
+ * THE DOPE SHEET'S ACTION EDITOR for the armature the Timeline describes. Its selector is
188
+ * Blender's: an action picked here becomes the armature's `animation_data.action`.
189
+ */
190
+ export function BlenderActionEditor() {
191
+ useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
192
+ useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
193
+ const live = useLive();
194
+ const { ref, size } = useMeasured();
195
+ const [refusal, setRefusal] = useState<string | null>(null);
196
+ const play = blenderSkin.state();
197
+ const armature = blenderSkin.subject ?? play.armature;
198
+ const running = live?.live().find((one) => one.armature === armature) ?? null;
199
+ // READ-ONLY WHILE ANY GAME RUNS: the game owns its copy, and the document is not edited under it.
200
+ const gameRunning = live !== null;
201
+ const top = running?.layers.at(-1) ?? null;
202
+ const action = running ? top?.action ?? null : play.action;
203
+ const channels = useMemo(
204
+ () => (running && action ? live!.channels(running.armature, action) : blenderSkin.channels()),
205
+ // The live clip's keys do not change while it plays; the edited clip's change with its revision.
206
+ [running ? action : play.action, play.engineCalls, live],
207
+ );
208
+ const frames = channels.flatMap((channel) => channel.keys);
209
+ const start = frames.length ? Math.min(...frames) : play.start;
210
+ const end = frames.length ? Math.max(...frames) : play.end;
211
+ const width = Math.max(0, size.w - CHANNELS_WIDTH);
212
+ const { toX, majors, toFrame } = ruler(start, end, width);
213
+ const frame = running ? top?.frame ?? start : play.frame;
214
+ const rowTop = (i: number) => SCRUB_HEIGHT + CHANNEL_HEIGHT * (i + 1);
215
+ const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (channels.length + 2);
216
+ const summary = [...new Set(frames)].sort((a, b) => a - b);
217
+
218
+ const pick = async (name: string) => {
219
+ if (!armature || gameRunning) return;
220
+ setRefusal(null);
221
+ // ONE WRITE, as Blender's own selector makes it: assigning the action lets Blender choose the
222
+ // slot that fits this armature (measured: `OBArmature` for a UAL1 clip on a renamed rig).
223
+ try {
224
+ await blenderRnaSet(address(armature), 'action', `bpy.data.actions[${JSON.stringify(name)}]`);
225
+ } catch (error) {
226
+ setRefusal(`${name} could not be assigned to ${armature}: ${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ };
229
+
230
+ const header = (
231
+ <Header>
232
+ <span style={{ color: TIMELINE_CHROME.textHi }}>{armature ?? 'No armature'}</span>
233
+ <select
234
+ aria-label="Action"
235
+ data-testid="action-editor-action"
236
+ value={action ?? ''}
237
+ disabled={!armature || gameRunning}
238
+ title={gameRunning ? 'A game is running; it chooses the clips while it runs' : 'The action this armature plays (animation_data.action)'}
239
+ onChange={(event) => void pick(event.target.value)}
240
+ style={{
241
+ height: TIMELINE_CHROME.unit,
242
+ minWidth: 180,
243
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
244
+ borderRadius: TIMELINE_CHROME.widgetRadius,
245
+ background: TIMELINE_CHROME.widget,
246
+ color: TIMELINE_CHROME.widgetText,
247
+ font: 'inherit',
248
+ }}
249
+ >
250
+ {action === null ? <option value="">No action</option> : null}
251
+ {(running && action ? [action] : blenderSkin.actionNames()).map((name) => <option key={name} value={name}>{name}</option>)}
252
+ </select>
253
+ {running ? (
254
+ <span data-testid="action-editor-live" style={{ color: TIMELINE_THEME.playhead }}>
255
+ ● game running: {running.layers.map((layer) => `${layer.action} @${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}`).join(' + ')}
256
+ </span>
257
+ ) : null}
258
+ <div style={{ flex: 1 }} />
259
+ {refusal ? <span style={{ color: TIMELINE_CHROME.refusal }}>{refusal}</span> : null}
260
+ </Header>
261
+ );
262
+
263
+ return (
264
+ <Frame header={header}>
265
+ <div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
266
+ <div style={{ display: 'flex', minHeight: height }}>
267
+ <div style={{ width: CHANNELS_WIDTH, flex: `0 0 ${CHANNELS_WIDTH}px`, borderRight: `1px solid ${TIMELINE_CHROME.rule}` }}>
268
+ <div style={{ height: SCRUB_HEIGHT, background: TIMELINE_THEME.scrubBack }} />
269
+ <div style={{ height: CHANNEL_HEIGHT, paddingLeft: 6, color: TIMELINE_CHROME.textHi }}>Summary</div>
270
+ {channels.map((channel) => (
271
+ <div key={channel.bone} style={{ height: CHANNEL_HEIGHT, paddingLeft: 18, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.bone}</div>
272
+ ))}
273
+ </div>
274
+ <svg
275
+ width={width}
276
+ height={height}
277
+ style={{ display: 'block', cursor: gameRunning ? 'default' : 'ew-resize' }}
278
+ aria-label="Action Editor"
279
+ onPointerDown={(event) => {
280
+ if (gameRunning) return;
281
+ const box = event.currentTarget.getBoundingClientRect();
282
+ blenderSkin.transport?.seekFrame(Math.round(toFrame(event.clientX - box.left)));
283
+ }}
284
+ >
285
+ <rect x={0} y={0} width={width} height={height} fill={TIMELINE_THEME.back} />
286
+ <Grid majors={majors} toX={toX} width={width} height={height} />
287
+ {summary.map((key) => diamond(toX(key), rowTop(0) - CHANNEL_HEIGHT / 2, `s${key}`))}
288
+ {channels.map((channel, i) => channel.keys.map((key) => diamond(toX(key), rowTop(i + 1) - CHANNEL_HEIGHT / 2, `${channel.bone}${key}`)))}
289
+ <Playhead x={toX(frame)} frame={frame} height={height} />
290
+ </svg>
291
+ </div>
292
+ </div>
293
+ </Frame>
294
+ );
295
+ }
296
+
297
+ /** THE NLA EDITOR: every armature's tracks and strips over scene frames, and its active action. */
298
+ export function BlenderNlaEditor() {
299
+ useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
300
+ useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
301
+ const live = useLive();
302
+ const { ref, size } = useMeasured();
303
+ const play = blenderSkin.state();
304
+ const stacks = blenderSkin.stacks();
305
+ const running = live?.live() ?? null;
306
+ const width = Math.max(0, size.w - CHANNELS_WIDTH);
307
+ const { toX, majors } = ruler(play.start, play.end, width);
308
+ type Row = { label: string; kind: 'armature' | 'action' | 'track'; strips: { name: string; start: number; end: number; mute: boolean }[]; live?: string; mute?: boolean };
309
+ const rows: Row[] = [];
310
+ for (const stack of stacks) {
311
+ const layers = running?.find((one) => one.armature === stack.armature)?.layers ?? null;
312
+ const influence = (track: string | null) => {
313
+ const layer = layers?.find((one) => one.track === track);
314
+ return layer ? `@${layer.frame.toFixed(1)} ×${layer.influence.toFixed(2)}` : undefined;
315
+ };
316
+ rows.push({ label: stack.armature, kind: 'armature', strips: [] });
317
+ const activeAction = layers ? layers.find((one) => one.track === null)?.action ?? null : stack.action;
318
+ const activeLive = influence(null);
319
+ rows.push({ label: activeAction ? `[Action] ${activeAction}` : '[Action] (none)', kind: 'action', strips: [], ...(activeLive ? { live: activeLive } : {}) });
320
+ for (const track of [...(stack.animation?.tracks ?? [])].reverse()) {
321
+ const playingHere = influence(track.name);
322
+ rows.push({
323
+ label: track.name,
324
+ kind: 'track',
325
+ mute: track.mute,
326
+ strips: track.strips.map((strip) => ({ name: strip.name, start: strip.start, end: strip.end, mute: strip.mute })),
327
+ ...(playingHere ? { live: playingHere } : {}),
328
+ });
329
+ }
330
+ }
331
+ const height = SCRUB_HEIGHT + CHANNEL_HEIGHT * (rows.length + 1);
332
+ const header = (
333
+ <Header>
334
+ {running ? <span data-testid="nla-live" style={{ color: TIMELINE_THEME.playhead }}>● game running: each playing track shows the game's frame and influence; the strips are the file's layout</span> : null}
335
+ </Header>
336
+ );
337
+ return (
338
+ <Frame header={header}>
339
+ <div ref={ref} style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
340
+ <div style={{ display: 'flex', minHeight: height }}>
341
+ <div style={{ width: CHANNELS_WIDTH, flex: `0 0 ${CHANNELS_WIDTH}px`, borderRight: `1px solid ${TIMELINE_CHROME.rule}` }}>
342
+ <div style={{ height: SCRUB_HEIGHT, background: TIMELINE_THEME.scrubBack }} />
343
+ {rows.map((row, i) => (
344
+ <div
345
+ key={`${row.label}${i}`}
346
+ data-testid={`nla-row-${row.kind}`}
347
+ style={{
348
+ height: CHANNEL_HEIGHT,
349
+ paddingLeft: row.kind === 'armature' ? 6 : 18,
350
+ whiteSpace: 'nowrap',
351
+ overflow: 'hidden',
352
+ textOverflow: 'ellipsis',
353
+ color: row.kind === 'armature' ? TIMELINE_CHROME.textHi : row.mute ? '#6a6a6a' : TIMELINE_CHROME.text,
354
+ }}
355
+ >
356
+ {row.label}{row.live ? <span style={{ color: TIMELINE_THEME.playhead }}> {row.live}</span> : null}
357
+ </div>
358
+ ))}
359
+ </div>
360
+ <svg width={width} height={height} style={{ display: 'block' }} aria-label="NLA">
361
+ <rect x={0} y={0} width={width} height={height} fill={TIMELINE_THEME.back} />
362
+ <Grid majors={majors} toX={toX} width={width} height={height} />
363
+ {rows.map((row, i) => {
364
+ const y = SCRUB_HEIGHT + CHANNEL_HEIGHT * i + 2;
365
+ return row.strips.map((strip) => (
366
+ <g key={`${i}${strip.name}${strip.start}`}>
367
+ <rect
368
+ x={toX(strip.start)}
369
+ y={y}
370
+ width={Math.max(2, toX(strip.end) - toX(strip.start))}
371
+ height={CHANNEL_HEIGHT - 4}
372
+ rx={2}
373
+ fill={strip.mute || row.mute ? '#4a4a4a' : running ? (row.live ? '#5a7fb8' : '#555555') : '#7b7b7b'}
374
+ opacity={running ? 0.6 : 1}
375
+ stroke="#1a1a1a"
376
+ />
377
+ <text x={toX(strip.start) + 4} y={y + CHANNEL_HEIGHT - 7} fill="#ffffff" fontSize={10}>{strip.name}</text>
378
+ </g>
379
+ ));
380
+ })}
381
+ {running ? null : <Playhead x={toX(play.frame)} frame={play.frame} height={height} />}
382
+ </svg>
383
+ </div>
384
+ </div>
385
+ </Frame>
386
+ );
387
+ }
@@ -7,12 +7,12 @@
7
7
  * ## One Play, here
8
8
  *
9
9
  * The confusion it ends was two Plays on one screen: the header's (the play script) and the
10
- * Timeline's (the file's animation). A mode now chooses which one exists. In Movie mode the
11
- * Timeline's transport is the only one; in Game mode this panel's Play is the only one, and the
12
- * Timeline is not mounted. The 3D viewport's header carries neither (`blender-header-menus.tsx`
10
+ * Timeline's (the file's animation). A mode now chooses which one is shown. In Animation mode the
11
+ * Timeline's transport is the one shown; in Game mode this panel's Play is, and the Timeline is
12
+ * not mounted. A game started in Game mode keeps running in Animation mode. The 3D viewport's header carries neither (`blender-header-menus.tsx`
13
13
  * is its menus alone). Escape still stops a game, from the stage, as before.
14
14
  *
15
- * ## The Game | Movie switch is the bottom area's own
15
+ * ## The Game | Animation switch is the bottom area's own
16
16
  *
17
17
  * Switching modes swaps this area and nothing else, so the switch stands at the leading edge of
18
18
  * the area's header row, in place of its title — in this panel and in the Timeline alike
@@ -24,15 +24,15 @@
24
24
  *
25
25
  * Blender's Timeline strip (`model.layout.ts`'s measured 0.0719 — 63 px of an 880-px column)
26
26
  * showed this panel's play log as one clipped line. So Game mode asks the area for
27
- * {@link GAME_PANEL_RATIO} (`setWorkspaceAreaRatio`, by the bottom area's document) and Movie
27
+ * {@link GAME_PANEL_RATIO} (`setWorkspaceAreaRatio`, by the bottom area's document) and Animation
28
28
  * mode gives Blender's back: each switch stands the area up at its mode's size, as a workspace
29
29
  * switch does, and the sash stays the person's in between.
30
30
  *
31
31
  * ## What it drives is the Play tool's own run
32
32
  *
33
33
  * Every control here is a call on the Play tool's door (`DocumentPlayExtension` and its
34
- * `transport`, `@volter/editor-sdk/kit/document-play-extension`), the same calls the runner
35
- * reads every frame (`@volter/editor-model-play`'s `model-play.ts`), so Pause freezes the
34
+ * `transport`, `@volter/sdk/kit/document-play-extension`), the same calls the runner
35
+ * reads every frame (`@volter/play`'s `model-play.ts`), so Pause freezes the
36
36
  * simulation itself — the `dt` the script is handed — and the clock drawn here is that
37
37
  * simulation's: the seconds the script was given and the updates it ran. The verbs below are
38
38
  * the same calls behind command ids (`volter.model-play.<verb>`), which is how an agent drives
@@ -64,7 +64,7 @@
64
64
  * playhead's blue, which is Blender's own selected-widget colour in the same theme.
65
65
  */
66
66
  import { blenderSkin } from './blender-runtime-skin';
67
- import { registerViewVerbs } from '@volter/editor-sdk/views';
67
+ import { registerViewVerbs } from '@volter/sdk/views';
68
68
  import {
69
69
  type DocumentPlayAutoplay,
70
70
  type DocumentPlayClock,
@@ -73,7 +73,7 @@ import {
73
73
  type DocumentPlayTransport,
74
74
  documentPlayExtension,
75
75
  subscribeDocumentPlayExtensions,
76
- } from '@volter/editor-sdk/kit/document-play-extension';
76
+ } from '@volter/sdk/kit/document-play-extension';
77
77
  import {
78
78
  type MouseEvent,
79
79
  type ReactNode,
@@ -89,6 +89,7 @@ import {
89
89
  type ModelPlayMode,
90
90
  modelDocumentSource,
91
91
  modelPlayMode,
92
+ playModeOf,
92
93
  playModeVersion,
93
94
  servedModelDocument,
94
95
  setChosenPlayMode,
@@ -112,12 +113,11 @@ function extension(): DocumentPlayExtension | null {
112
113
  }
113
114
 
114
115
  /**
115
- * FLIP A DOCUMENT BETWEEN GAME AND MOVIE, and leave nothing running that the new mode has no
116
- * control for: a game playing when Movie is chosen is stopped (Movie has no Stop), and the
117
- * file's animation playing when Game is chosen is paused (Game has no Timeline).
116
+ * FLIP A DOCUMENT BETWEEN GAME AND ANIMATION. A running game keeps running when Animation is
117
+ * chosen: the animation editors then show what it animates. The file's animation playing in the
118
+ * Timeline when Game is chosen is paused (Game has no Timeline transport).
118
119
  */
119
120
  export function switchPlayMode(documentId: string, mode: ModelPlayMode): void {
120
- if (mode === 'movie' && extension()?.playing(documentId)) extension()?.setPlaying(documentId, false);
121
121
  if (mode === 'game' && blenderSkin.transport?.snapshot().playbackState === 'playing') blenderSkin.transport.pause();
122
122
  setChosenPlayMode(documentId, mode);
123
123
  }
@@ -138,9 +138,9 @@ function verbDocument(args: Record<string, unknown> | undefined): string {
138
138
  function verbExtension(): DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> } {
139
139
  const found = extension();
140
140
  if (found === null)
141
- throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/editor-model-play.');
141
+ throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/play.');
142
142
  if (found.transport === undefined)
143
- throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/editor-model-play.');
143
+ throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/play.');
144
144
  return found as DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> };
145
145
  }
146
146
 
@@ -220,8 +220,9 @@ registerViewVerbs({
220
220
  const documentId = verbDocument(args);
221
221
  const mode = args?.['mode'] ?? args?.['to'];
222
222
  if (mode !== undefined) {
223
- if (mode !== 'game' && mode !== 'movie') throw new Error(`mode is \`game\` or \`movie\`; got ${String(mode)}.`);
224
- switchPlayMode(documentId, mode);
223
+ const asked = playModeOf(mode);
224
+ if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
225
+ switchPlayMode(documentId, asked);
225
226
  }
226
227
  return gameState(documentId);
227
228
  },
@@ -232,9 +233,9 @@ registerViewVerbs({
232
233
  run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'game'); return gameState(documentId); },
233
234
  },
234
235
  {
235
- id: 'movie-mode',
236
- title: 'Game: Switch to Movie Mode',
237
- run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'movie'); return gameState(documentId); },
236
+ id: 'animation-mode',
237
+ title: 'Game: Switch to Animation Mode',
238
+ run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'animation'); return gameState(documentId); },
238
239
  },
239
240
  {
240
241
  // PLAY IS GAME MODE'S CONTROL, so asking for it puts the document in Game mode: the person
@@ -326,7 +327,7 @@ registerViewVerbs({
326
327
  const found = verbExtension();
327
328
  const { transport } = found;
328
329
  if (transport.setAutoplay === undefined)
329
- throw new Error('The installed Play tool has no autoplay; update @volter/editor-model-play.');
330
+ throw new Error('The installed Play tool has no autoplay; update @volter/play.');
330
331
  // Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
331
332
  const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
332
333
  if (why !== null) throw new Error(why);
@@ -439,9 +440,9 @@ export function PlayModeSwitch({ title }: { readonly title: string }) {
439
440
  </GameButton>
440
441
  );
441
442
  return (
442
- <div role="group" aria-label="Game or Movie mode" data-testid="model-play-mode" style={{ display: 'flex' }}>
443
- {cell('game', 'Game', 'Game mode: this area is the Game panel, with Play, pause, step, speed, restart and autoplay')}
444
- {cell('movie', 'Movie', 'Movie mode: this area is the Timeline, playing the file’s animation')}
443
+ <div role="group" aria-label="Game or Animation" data-testid="model-play-mode" style={{ display: 'flex' }}>
444
+ {cell('game', 'Game', 'Game: this area is the Game panel, with Play, pause, step, speed, restart and autoplay')}
445
+ {cell('animation', 'Animation', 'Animation: this area is Blender’s animation editors (Timeline, Action Editor, NLA); a running game keeps running and they show what it animates')}
445
446
  </div>
446
447
  );
447
448
  }
@@ -46,7 +46,7 @@
46
46
  * because switching modes swaps that area and nothing here.
47
47
  */
48
48
 
49
- import type { ToolNotice } from '@volter/editor-sdk/contributions';
49
+ import type { ToolNotice } from '@volter/sdk/contributions';
50
50
  import {
51
51
  AnchoredMenu,
52
52
  EditorToolbar,
@@ -54,7 +54,7 @@ import {
54
54
  MenuSeparator,
55
55
  MenuSubmenu,
56
56
  MenuTrigger,
57
- } from '@volter/editor-sdk/widgets';
57
+ } from '@volter/sdk/widgets';
58
58
  import { type ReactNode, type RefObject, useRef, useState, useSyncExternalStore } from 'react';
59
59
  import {
60
60
  type BlenderCreatableKind,
@@ -2507,7 +2507,7 @@ g(
2507
2507
  * glyph — and an explicit site tone wins by contract. A tone there would
2508
2508
  * never paint.
2509
2509
  */
2510
- /** The union `IconCategoryTone` declares (`@volter/editor-sdk/looks`), and the
2510
+ /** The union `IconCategoryTone` declares (`@volter/sdk/looks`), and the
2511
2511
  * reason `blender.style.ts` may narrow this JSON with an assertion: a tone
2512
2512
  * outside it never reaches the artifact. */
2513
2513
  const TONES = ['object', 'modifier', 'material', 'tool', 'operator', 'data', 'scene', 'collection', 'select'];
@@ -1,8 +1,8 @@
1
1
  import type { ModelDocumentPreview } from '../src/model-document-preview';
2
2
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
3
- import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
4
- import type { AuthoringAdapter, EditorNode } from '@volter/editor-project/adapter';
5
- import { AssetEditorSubject } from '@volter/editor-sdk/kit/components/AssetEditorShell';
3
+ import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
4
+ import type { AuthoringAdapter, EditorNode } from '@volter/project/adapter';
5
+ import { AssetEditorSubject } from '@volter/sdk/kit/components/AssetEditorShell';
6
6
  import { useMemo } from 'react';
7
7
 
8
8
  /** A loading/error surface has no authoring stage and publishes no model. */
@@ -38,7 +38,7 @@
38
38
  */
39
39
 
40
40
  import type { BlenderNode, BlenderNodeTree } from '@volter/blender-engine/browser/rna';
41
- import { registerViewVerbs } from '@volter/editor-sdk/views';
41
+ import { registerViewVerbs } from '@volter/sdk/views';
42
42
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
43
43
  import {
44
44
  blenderNodeTree,
@@ -58,7 +58,7 @@
58
58
 
59
59
  import type { BlenderOutlinerRow } from '@volter/blender-engine/browser/rna';
60
60
  import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-threejs/object3d-contributions';
61
- import { editorHost } from '@volter/editor-sdk/host';
61
+ import { editorHost } from '@volter/sdk/host';
62
62
  import type {
63
63
  AuthoringAdapter,
64
64
  AuthoringCapabilities,
@@ -73,7 +73,7 @@ import type {
73
73
  Transform,
74
74
  TransformProvider,
75
75
  WriteAck,
76
- } from '@volter/editor-project/adapter';
76
+ } from '@volter/project/adapter';
77
77
  import type * as THREE from 'three';
78
78
  import { threeObject } from '@volter/editor-threejs/adapter/three-contract';
79
79
  import { viewportStages } from '@volter/editor-threejs/viewport-door';
@@ -340,7 +340,7 @@ function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): n
340
340
  * gesture that cannot complete (the shape B6 set for Set Origin ▸ Origin to 3D
341
341
  * Cursor). Blender's Image ▸ Reference/Background/Mesh Plane and Volume ▸
342
342
  * Import OpenVDB open a FILE BROWSER; this editor has no door to open one
343
- * through — `EditorHost.files` (`@volter/editor-sdk/host`) reads and writes
343
+ * through — `EditorHost.files` (`@volter/sdk/host`) reads and writes
344
344
  * project-relative paths and has no "pick a file" member at all — so those
345
345
  * rows carry {@link BlenderCreatableKind.refusal} instead. Measured at the
346
346
  * engine on 2026-09-21: `object.empty_image_add` answers "poll() failed,
@@ -383,7 +383,7 @@ export interface BlenderCreatableKind {
383
383
  function needsAFilePicker(what: string, blenderSaid: string): string {
384
384
  return (
385
385
  `${what} needs a file, and this editor has no door to ask for one: the host's file ` +
386
- `door (\`EditorHost.files\`, \`@volter/editor-sdk/host\`) reads and writes ` +
386
+ `door (\`EditorHost.files\`, \`@volter/sdk/host\`) reads and writes ` +
387
387
  `project-relative paths and has no file-picker member, so there is nothing to open ` +
388
388
  `the workbench's own dialog through. Blender's row opens a file browser; run without ` +
389
389
  `one, the engine answers "${blenderSaid}".`
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { BlenderOutlinerRow, BlenderOutlinerTree } from '@volter/blender-engine/browser/rna';
18
- import { editorHost } from '@volter/editor-sdk/host';
18
+ import { editorHost } from '@volter/sdk/host';
19
19
  import type * as THREE from 'three';
20
20
  import {
21
21
  blenderOutliner,