@volter/editor-blender 0.1.150 → 0.1.152

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 (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +99 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +145 -333
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +45 -31
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +3 -30
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -48
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -1,809 +0,0 @@
1
- /**
2
- * THE GAME PANEL — what the bottom area shows in GAME mode (`../src/play-mode.ts`), in place of
3
- * the Timeline (owner, 2026-10-06: "In game mode, the bottom panel is replaced with a game
4
- * version of something like that. Perhaps that's where the 'autoplay' controls and info will
5
- * live instead").
6
- *
7
- * ## One Play, here
8
- *
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 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
- * is its menus alone). Escape still stops a game, from the stage, as before.
14
- *
15
- * ## The Game | Animation switch is the bottom area's own
16
- *
17
- * Switching modes swaps this area and nothing else, so the switch stands at the leading edge of
18
- * the area's header row, in place of its title — in this panel and in the Timeline alike
19
- * ({@link PlayModeSwitch}), so the person switches back from whichever one is showing. It is
20
- * drawn only when a Play tool is installed and a model is on screen; otherwise there is nothing
21
- * to switch and the Timeline keeps its title.
22
- *
23
- * ## It is tall enough to read
24
- *
25
- * Blender's Timeline strip (`model.layout.ts`'s measured 0.0719 — 63 px of an 880-px column)
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 Animation
28
- * mode gives Blender's back: each switch stands the area up at its mode's size, as a workspace
29
- * switch does, and the sash stays the person's in between.
30
- *
31
- * ## What it drives is the Play tool's own run
32
- *
33
- * Every control here is a call on the Play tool's door (`DocumentPlayExtension` and its
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
- * simulation itself — the `dt` the script is handed — and the clock drawn here is that
37
- * simulation's: the seconds the script was given and the updates it ran. The verbs below are
38
- * the same calls behind command ids (`volter.model-play.<verb>`), which is how an agent drives
39
- * exactly what the person does: `cyclotron play pause`, or
40
- * `await editor.command('volter.model-play.pause')` under `eval`.
41
- *
42
- * ## Autoplay is the editor's switch, and the person always wins
43
- *
44
- * A game offers its bot (`play.autoplay(controller)` in its play script); whether the bot drives
45
- * is this panel's toggle (or `volter.model-play.autoplay`), never a game key, and it is off at
46
- * every Play and Restart. A person's key or pointer in the game turns it off on the spot (the
47
- * Play tool's runner does that), so the driver line reads "You're driving" and stays so until
48
- * someone switches the bot on again. A game offers its bot only once its play script runs, so
49
- * the panel SAYS when there is none to switch: stopped, "available once the game is running"
50
- * (and pressing Autoplay then arms it for the next start); running, "No autoplay — this game
51
- * doesn't provide a bot". `play state` carries the same reason as `autoplay.why`.
52
- *
53
- * ## The play log, live
54
- *
55
- * The right of the panel is the run's play log as it is written (the Play tool's `log` door):
56
- * the newest entries of this document, newest at the bottom, filtered by kind. It follows the
57
- * newest entry until the person scrolls up to read, and follows again once they scroll back to
58
- * the bottom.
59
- *
60
- * ## It is drawn as the Timeline is
61
- *
62
- * Same area, same header band, the same widget table (`blender-timeline-geometry.ts`), so the
63
- * bottom of the screen changes its job and not its family. The pressed speed cell takes the
64
- * playhead's blue, which is Blender's own selected-widget colour in the same theme.
65
- */
66
- import { blenderSkin } from './blender-runtime-skin';
67
- import { registerViewVerbs } from '@volter/sdk/views';
68
- import {
69
- type DocumentPlayAutoplay,
70
- type DocumentPlayClock,
71
- type DocumentPlayExtension,
72
- type DocumentPlayLog,
73
- type DocumentPlayTransport,
74
- documentPlayExtension,
75
- subscribeDocumentPlayExtensions,
76
- } from '@volter/sdk/kit/document-play-extension';
77
- import {
78
- type MouseEvent,
79
- type ReactNode,
80
- useCallback,
81
- useEffect,
82
- useLayoutEffect,
83
- useReducer,
84
- useRef,
85
- useState,
86
- useSyncExternalStore,
87
- } from 'react';
88
- import {
89
- type ModelPlayMode,
90
- modelDocumentSource,
91
- modelPlayMode,
92
- playModeOf,
93
- playModeVersion,
94
- servedModelDocument,
95
- setChosenPlayMode,
96
- subscribePlayMode,
97
- } from '../src/play-mode';
98
- import { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } from './blender-timeline-geometry';
99
-
100
- /**
101
- * THE GAME PANEL'S SHARE OF THE COLUMN, asked of the bottom area in Game mode. A 1000-px window
102
- * gives the centre column about 935 px, and 0.2 of it is 187: the 26-px header, the status
103
- * band's padding, the log's own row (one widget, 20 px) and seven 12-px log lines at their
104
- * 1.45 line height with room to spare. Taller windows show more lines; the sash still moves.
105
- */
106
- export const GAME_PANEL_RATIO = 0.2;
107
-
108
- const STILL: DocumentPlayClock = { time: 0, tick: 0, paused: false, speed: 1 };
109
- const NO_BOT: DocumentPlayAutoplay = { on: false, available: false, by: null };
110
-
111
- function extension(): DocumentPlayExtension | null {
112
- return documentPlayExtension('model');
113
- }
114
-
115
- /**
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).
119
- */
120
- export function switchPlayMode(documentId: string, mode: ModelPlayMode): void {
121
- if (mode === 'game' && blenderSkin.transport?.snapshot().playbackState === 'playing') blenderSkin.transport.pause();
122
- setChosenPlayMode(documentId, mode);
123
- }
124
-
125
- // ---------------------------------------------------------------------------------------------
126
- // THE VERBS — `volter.model-play.<verb>`, the panel's controls behind command ids.
127
- // ---------------------------------------------------------------------------------------------
128
-
129
- /** Which document a verb acts on: the one it names, else the model document on screen. */
130
- function verbDocument(args: Record<string, unknown> | undefined): string {
131
- const named = args?.['document'];
132
- if (typeof named === 'string' && named !== '') return named;
133
- const served = servedModelDocument();
134
- if (served === null) throw new Error('No model document is open; open one, or name it with `document`.');
135
- return served;
136
- }
137
-
138
- function verbExtension(): DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> } {
139
- const found = extension();
140
- if (found === null)
141
- throw new Error('This project has no Play tool: `cyclotron add-play` adds @volter/play.');
142
- if (found.transport === undefined)
143
- throw new Error('The installed Play tool has no pause, step, speed or restart; update @volter/play.');
144
- return found as DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> };
145
- }
146
-
147
- /** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
148
- * lands on the next drawn frame (a step runs there), so read `state` again to see it. */
149
- function gameState(documentId: string): unknown {
150
- const found = extension();
151
- const blend = modelDocumentSource(documentId);
152
- return {
153
- document: documentId,
154
- mode: modelPlayMode(documentId),
155
- playing: found?.playing(documentId) ?? false,
156
- script: blend === undefined || found?.scriptPath === undefined ? null : {
157
- path: found.scriptPath(blend),
158
- exists: found.hasScript?.(blend) ?? null,
159
- },
160
- clock: found?.transport?.clock(documentId) ?? STILL,
161
- speeds: found?.transport?.speeds ?? [],
162
- autoplay: autoplayState(documentId),
163
- };
164
- }
165
-
166
- /** Who drives a playing game: the bot while autoplay is on; the person once their key or pointer
167
- * has reached the game this run; otherwise nobody — the game runs on no input. A tool that
168
- * predates `person` cannot say, and counts autoplay off as the person driving, as it always did. */
169
- function driverOf(playing: boolean, autoplay: DocumentPlayAutoplay): 'bot' | 'person' | 'none' | null {
170
- if (!playing) return null;
171
- if (autoplay.on) return 'bot';
172
- return autoplay.person === false ? 'none' : 'person';
173
- }
174
-
175
- /** The bot's switch and who drives (`driverOf`), null when nothing plays. `by` is who made the
176
- * last change (`takeover`: a person's key or pointer; `limit`: the run's seconds spent); `state`
177
- * what the bot says it is doing; `why` says why there is no bot to switch, null when there is one. */
178
- function autoplayState(documentId: string): unknown {
179
- const found = extension();
180
- const autoplay = found?.transport?.autoplay?.(documentId) ?? NO_BOT;
181
- const playing = found?.playing(documentId) ?? false;
182
- const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
183
- return { ...autoplay, driver: driverOf(playing, autoplay), why };
184
- }
185
-
186
- /**
187
- * WHY THERE IS NO BOT TO SWITCH ON: `not-running` while stopped or still starting (a game offers
188
- * its bot only once its play script runs), `no-bot` once it runs without one, null when it offers
189
- * one. A tool that predates `running` counts a playing run without a failure as running.
190
- */
191
- function autoplayUnavailable(playing: boolean, clock: DocumentPlayClock, autoplay: DocumentPlayAutoplay): 'not-running' | 'no-bot' | null {
192
- if (autoplay.available) return null;
193
- return playing && (clock.running ?? !clock.failure) ? 'no-bot' : 'not-running';
194
- }
195
-
196
- /** {@link autoplayUnavailable} as the CLI says it. */
197
- function autoplayWhy(playing: boolean, clock: DocumentPlayClock, autoplay: DocumentPlayAutoplay): string | null {
198
- switch (autoplayUnavailable(playing, clock, autoplay)) {
199
- case null: return null;
200
- case 'no-bot': return 'No autoplay — this game doesn’t provide a bot: its play script registers none with play.autoplay(controller).';
201
- case 'not-running': return !playing
202
- ? 'Autoplay is available once the game is running, and nothing is playing; `play` starts it.'
203
- : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? `: ${clock.failure}` : '.'}`;
204
- }
205
- }
206
-
207
- function requirePlaying(documentId: string, verb: string): void {
208
- if (!verbExtension().playing(documentId))
209
- throw new Error(`Nothing is playing in ${documentId}, so there is nothing to ${verb}; \`play\` starts it.`);
210
- }
211
-
212
- registerViewVerbs({
213
- view: 'model-play',
214
- title: 'Game',
215
- verbs: [
216
- { id: 'state', run: (args) => gameState(verbDocument(args)) },
217
- {
218
- id: 'mode',
219
- run: (args) => {
220
- const documentId = verbDocument(args);
221
- const mode = args?.['mode'] ?? args?.['to'];
222
- if (mode !== undefined) {
223
- const asked = playModeOf(mode);
224
- if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
225
- switchPlayMode(documentId, asked);
226
- }
227
- return gameState(documentId);
228
- },
229
- },
230
- {
231
- id: 'game-mode',
232
- title: 'Game: Switch to Game Mode',
233
- run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'game'); return gameState(documentId); },
234
- },
235
- {
236
- id: 'animation-mode',
237
- title: 'Game: Switch to Animation Mode',
238
- run: (args) => { const documentId = verbDocument(args); switchPlayMode(documentId, 'animation'); return gameState(documentId); },
239
- },
240
- {
241
- // PLAY IS GAME MODE'S CONTROL, so asking for it puts the document in Game mode: the person
242
- // then sees the panel that owns the run an agent started.
243
- id: 'play',
244
- title: 'Game: Play',
245
- run: (args) => {
246
- const documentId = verbDocument(args);
247
- const found = verbExtension();
248
- if (modelPlayMode(documentId) !== 'game') switchPlayMode(documentId, 'game');
249
- if (!found.playing(documentId)) found.setPlaying(documentId, true);
250
- return gameState(documentId);
251
- },
252
- },
253
- {
254
- id: 'stop',
255
- title: 'Game: Stop',
256
- run: (args) => {
257
- const documentId = verbDocument(args);
258
- verbExtension().setPlaying(documentId, false);
259
- return gameState(documentId);
260
- },
261
- },
262
- {
263
- id: 'pause',
264
- title: 'Game: Pause',
265
- run: (args) => {
266
- const documentId = verbDocument(args);
267
- requirePlaying(documentId, 'pause');
268
- verbExtension().transport.setPaused(documentId, true);
269
- return gameState(documentId);
270
- },
271
- },
272
- {
273
- id: 'resume',
274
- title: 'Game: Resume',
275
- run: (args) => {
276
- const documentId = verbDocument(args);
277
- requirePlaying(documentId, 'resume');
278
- verbExtension().transport.setPaused(documentId, false);
279
- return gameState(documentId);
280
- },
281
- },
282
- {
283
- // `count` steps queue and run one per drawn frame, so each is seen.
284
- id: 'step',
285
- title: 'Game: Step One Tick',
286
- run: (args) => {
287
- const documentId = verbDocument(args);
288
- requirePlaying(documentId, 'step');
289
- const { transport } = verbExtension();
290
- if (!transport.clock(documentId).paused) throw new Error('step runs one tick of a PAUSED game; `pause` first.');
291
- const raw = args?.['count'];
292
- const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
293
- if (!Number.isInteger(count) || count < 1 || count > 600) throw new Error(`step's \`count\` is a whole number from 1 to 600; got ${JSON.stringify(raw)}.`);
294
- for (let index = 0; index < count; index++) transport.step(documentId);
295
- return gameState(documentId);
296
- },
297
- },
298
- {
299
- id: 'speed',
300
- run: (args) => {
301
- const documentId = verbDocument(args);
302
- const { transport } = verbExtension();
303
- const raw = args?.['speed'] ?? args?.['to'];
304
- const speed = typeof raw === 'string' ? Number(raw.replace(/x$/i, '')) : Number(raw);
305
- if (!transport.speeds.includes(speed))
306
- throw new Error(`speed is one of ${transport.speeds.join(', ')} (simulation seconds per real second); got ${JSON.stringify(raw)}.`);
307
- transport.setSpeed(documentId, speed);
308
- return gameState(documentId);
309
- },
310
- },
311
- {
312
- // THE EDITOR'S AUTOPLAY SWITCH: on drives one of the game's bot behaviours (`play.autoplay`)
313
- // for at most `for` simulation seconds, off hands the game back to the person. Off at every
314
- // Play and Restart. `behavior` is required when the bot offers several.
315
- id: 'autoplay',
316
- run: (args) => {
317
- const documentId = verbDocument(args);
318
- const raw = args?.['on'];
319
- const on = raw === true || raw === 'on' || raw === 'true' ? true
320
- : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
321
- if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on|off\`); got ${String(raw)}.`);
322
- const behavior = typeof args?.['behavior'] === 'string' && args['behavior'] !== '' ? args['behavior'] : null;
323
- const rawLimit = args?.['for'] ?? args?.['limit'];
324
- const limit = rawLimit === undefined || rawLimit === null ? null : Number(rawLimit);
325
- if (limit !== null && !(Number.isFinite(limit) && limit > 0))
326
- throw new Error(`autoplay's \`for\` is a positive number of simulation seconds; got ${JSON.stringify(rawLimit)}.`);
327
- const found = verbExtension();
328
- const { transport } = found;
329
- if (transport.setAutoplay === undefined)
330
- throw new Error('The installed Play tool has no autoplay; update @volter/play.');
331
- // Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
332
- const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
333
- if (why !== null) throw new Error(why);
334
- transport.setAutoplay(documentId, on, 'cli', on ? { behavior, limit } : undefined);
335
- return gameState(documentId);
336
- },
337
- },
338
- {
339
- id: 'restart',
340
- title: 'Game: Restart',
341
- run: (args) => {
342
- const documentId = verbDocument(args);
343
- const found = verbExtension();
344
- if (modelPlayMode(documentId) !== 'game') switchPlayMode(documentId, 'game');
345
- found.transport.restart(documentId);
346
- return gameState(documentId);
347
- },
348
- },
349
- ],
350
- });
351
-
352
- // ---------------------------------------------------------------------------------------------
353
- // THE PANEL
354
- // ---------------------------------------------------------------------------------------------
355
-
356
- /** A panel button keeps the keyboard where it was. Focus left on a button would turn the
357
- * player's next Space — a jump, in most games — into a second click on Pause. */
358
- const keepFocus = (event: MouseEvent): void => event.preventDefault();
359
-
360
- /** A 12-px mark in the widget's ink, the way the Timeline header draws its transport. */
361
- function mark(path: string): ReactNode {
362
- return (
363
- <svg width={12} height={12} viewBox="0 0 12 12" aria-hidden="true">
364
- <path d={path} fill={TIMELINE_CHROME.widgetText} />
365
- </svg>
366
- );
367
- }
368
- const MARKS = {
369
- play: 'M3 2l7 4-7 4z',
370
- stop: 'M2.5 2.5h7v7h-7z',
371
- pause: 'M3 2h2v8H3zM7 2h2v8H7z',
372
- step: 'M10 2H8.5v8H10zM2.5 2v8L7.5 6z',
373
- restart: 'M6 1.5V0l3 2.25-3 2.25V3a3 3 0 1 0 3 3h1.5A4.5 4.5 0 1 1 6 1.5z',
374
- } as const;
375
-
376
- function GameButton({ label, testId, disabled, pressed, wide, onClick, children }: {
377
- readonly label: string;
378
- readonly testId: string;
379
- readonly disabled?: boolean;
380
- readonly pressed?: boolean;
381
- readonly wide?: boolean;
382
- readonly onClick: () => void;
383
- readonly children: ReactNode;
384
- }) {
385
- const unit = TIMELINE_CHROME.unit;
386
- return (
387
- <button
388
- type="button"
389
- data-testid={testId}
390
- title={label}
391
- aria-label={label}
392
- aria-pressed={pressed}
393
- disabled={disabled}
394
- onMouseDown={keepFocus}
395
- onClick={onClick}
396
- style={{
397
- minWidth: unit,
398
- height: unit,
399
- padding: wide ? '0 6px' : 0,
400
- border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
401
- background: pressed ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widget,
402
- color: TIMELINE_CHROME.widgetText,
403
- font: 'inherit',
404
- display: 'inline-flex',
405
- alignItems: 'center',
406
- justifyContent: 'center',
407
- gap: 4,
408
- cursor: disabled ? 'not-allowed' : 'pointer',
409
- opacity: disabled ? 0.4 : 1,
410
- }}
411
- >
412
- {children}
413
- </button>
414
- );
415
- }
416
-
417
- /**
418
- * GAME | MOVIE, at the leading edge of the bottom area's header row, in the Game panel and the
419
- * Timeline alike (the head of this file says why it is here). Two joined cells with the current
420
- * one lit in the playhead's blue — the same widgets as the transport beside it, at the same
421
- * height, so it reads as one of this row's controls and not a smaller one. It serves the model
422
- * document the bottom area serves. With no Play tool installed, or no model on screen, there is
423
- * nothing to switch, and the row keeps its `title` instead.
424
- */
425
- export function PlayModeSwitch({ title }: { readonly title: string }) {
426
- useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
427
- const found = useSyncExternalStore(subscribeDocumentPlayExtensions, extension, () => null);
428
- const documentId = servedModelDocument();
429
- if (found === null || documentId === null) return <span style={{ color: TIMELINE_CHROME.text }}>{title}</span>;
430
- const mode = modelPlayMode(documentId);
431
- const cell = (value: ModelPlayMode, label: string, tip: string) => (
432
- <GameButton
433
- testId={`model-play-mode-${value}`}
434
- label={tip}
435
- pressed={mode === value}
436
- wide
437
- onClick={() => switchPlayMode(documentId, value)}
438
- >
439
- {label}
440
- </GameButton>
441
- );
442
- return (
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')}
446
- </div>
447
- );
448
- }
449
-
450
- /** Simulation seconds as a clock reads them: `m:ss.ss`, hours only when there are some. */
451
- function clockText(seconds: number): string {
452
- const whole = Math.floor(seconds / 60);
453
- const rest = (seconds - whole * 60).toFixed(2).padStart(5, '0');
454
- if (whole < 60) return `${whole}:${rest}`;
455
- return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}:${rest}`;
456
- }
457
-
458
- function speedText(speed: number): string {
459
- return `${speed}×`;
460
- }
461
-
462
- export function BlenderGamePanel() {
463
- useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
464
- const documentId = servedModelDocument();
465
- const found = useSyncExternalStore(subscribeDocumentPlayExtensions, extension, () => null);
466
- const transport = found?.transport;
467
- const playing = documentId !== null && (found?.playing(documentId) ?? false);
468
- const subscribeClock = useCallback(
469
- (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
470
- [transport],
471
- );
472
- const clock = useSyncExternalStore(
473
- subscribeClock,
474
- () => (documentId !== null && transport ? transport.clock(documentId) : STILL),
475
- () => STILL,
476
- );
477
- // Its changes are announced with the clock's.
478
- const autoplay = useSyncExternalStore(
479
- subscribeClock,
480
- () => (documentId !== null && transport?.autoplay ? transport.autoplay(documentId) : NO_BOT),
481
- () => NO_BOT,
482
- );
483
- const blend = documentId === null ? undefined : modelDocumentSource(documentId);
484
- const scriptPath = blend === undefined ? null : (found?.scriptPath?.(blend) ?? null);
485
- const scriptExists = blend === undefined ? null : (found?.hasScript?.(blend) ?? null);
486
- const canPlay = documentId !== null && found !== null;
487
- const status = documentId === null
488
- ? 'No model document is open.'
489
- : scriptExists === false && scriptPath !== null
490
- ? `No play script yet — Play runs ${scriptPath}. \`cyclotron add-play\` adds an example.`
491
- : playing && clock.failure
492
- // The run plays but no game runs (`DocumentPlayClock.failure`): say so, not "Playing".
493
- ? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the script) to retry, or Stop.`
494
- : playing
495
- ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath ?? ''}`.trim()
496
- : scriptPath === null ? 'Stopped.' : `Stopped · Play runs ${scriptPath}`;
497
- return (
498
- <div
499
- data-testid="blender-game-panel"
500
- data-playing={playing || undefined}
501
- data-paused={(playing && clock.paused) || undefined}
502
- data-failed={(playing && Boolean(clock.failure)) || undefined}
503
- style={{
504
- // Filled from the host box, as the Timeline is and for the reason its own comment gives.
505
- position: 'absolute',
506
- inset: 0,
507
- display: 'flex',
508
- flexDirection: 'column',
509
- overflow: 'hidden',
510
- background: TIMELINE_THEME.back,
511
- color: TIMELINE_CHROME.text,
512
- font: '11px Inter, system-ui, sans-serif',
513
- }}
514
- >
515
- <div
516
- style={{
517
- height: HEADER_HEIGHT,
518
- flex: `0 0 ${HEADER_HEIGHT}px`,
519
- display: 'flex',
520
- alignItems: 'center',
521
- gap: TIMELINE_CHROME.headerGap,
522
- padding: TIMELINE_CHROME.headerPadding,
523
- background: TIMELINE_THEME.header,
524
- borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
525
- }}
526
- >
527
- <PlayModeSwitch title="Game" />
528
- {/* THE TRANSPORT IN THE MIDDLE, between two spacers, where the Timeline centres its own. */}
529
- <div style={{ flex: 1 }} />
530
- <div style={{ display: 'flex' }} role="group" aria-label="Game transport">
531
- <GameButton
532
- testId="model-play-button"
533
- label={playing ? 'Stop playing (Escape)' : 'Play this model’s script'}
534
- pressed={playing}
535
- wide
536
- disabled={!canPlay}
537
- // The run's state NOW, not the render's: a game can stop between the two (Escape, an
538
- // agent's `stop`, a failed script), and a toggle read from a stale render does nothing.
539
- onClick={() => { if (documentId !== null && found) found.setPlaying(documentId, !found.playing(documentId)); }}
540
- >
541
- {mark(playing ? MARKS.stop : MARKS.play)}
542
- {playing ? 'Stop' : 'Play'}
543
- </GameButton>
544
- {transport && (
545
- <>
546
- <GameButton
547
- testId="model-play-pause"
548
- label={clock.paused ? 'Resume' : 'Pause'}
549
- pressed={playing && clock.paused}
550
- disabled={!playing}
551
- onClick={() => { if (documentId !== null) transport.setPaused(documentId, !transport.clock(documentId).paused); }}
552
- >
553
- {mark(clock.paused ? MARKS.play : MARKS.pause)}
554
- </GameButton>
555
- <GameButton
556
- testId="model-play-step"
557
- label="Step one tick (while paused)"
558
- disabled={!playing || !clock.paused}
559
- onClick={() => { if (documentId !== null) transport.step(documentId); }}
560
- >
561
- {mark(MARKS.step)}
562
- </GameButton>
563
- <GameButton
564
- testId="model-play-restart"
565
- label="Restart on a fresh copy of the model"
566
- disabled={!playing}
567
- onClick={() => { if (documentId !== null) transport.restart(documentId); }}
568
- >
569
- {mark(MARKS.restart)}
570
- </GameButton>
571
- </>
572
- )}
573
- </div>
574
- {transport && (
575
- <div style={{ display: 'flex' }} role="group" aria-label="Game speed">
576
- {transport.speeds.map((speed) => (
577
- <GameButton
578
- key={speed}
579
- testId={`model-play-speed-${speed}`}
580
- label={`Speed ${speedText(speed)}`}
581
- pressed={clock.speed === speed}
582
- wide
583
- disabled={documentId === null}
584
- onClick={() => { if (documentId !== null) transport.setSpeed(documentId, speed); }}
585
- >
586
- {speedText(speed)}
587
- </GameButton>
588
- ))}
589
- </div>
590
- )}
591
- <div style={{ flex: 1 }} />
592
- {/* THE CLOCK IS THE SIMULATION'S: the seconds the script was handed and the updates it
593
- ran. It stands still while paused and runs at the speed. */}
594
- <span
595
- data-testid="model-play-clock"
596
- style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
597
- >
598
- {clockText(clock.time)} · tick {clock.tick}
599
- </span>
600
- </div>
601
- <div
602
- style={{
603
- flex: 1,
604
- minHeight: 0,
605
- display: 'flex',
606
- alignItems: 'stretch',
607
- gap: TIMELINE_CHROME.headerGap,
608
- padding: TIMELINE_CHROME.statusPadding,
609
- overflow: 'hidden',
610
- }}
611
- >
612
- <div style={{ flex: '0 1 260px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
613
- <span data-testid="model-play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
614
- {status}
615
- </span>
616
- {transport?.autoplay && documentId !== null && (
617
- <AutoplayControl documentId={documentId} playing={playing} clock={clock} autoplay={autoplay} transport={transport} />
618
- )}
619
- </div>
620
- {found?.log && documentId !== null && <PlayLogView documentId={documentId} log={found.log} />}
621
- </div>
622
- </div>
623
- );
624
- }
625
-
626
- // ---------------------------------------------------------------------------------------------
627
- // AUTOPLAY AND THE PLAY LOG
628
- // ---------------------------------------------------------------------------------------------
629
-
630
- function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
631
- readonly documentId: string;
632
- readonly playing: boolean;
633
- readonly clock: DocumentPlayClock;
634
- readonly autoplay: DocumentPlayAutoplay;
635
- readonly transport: DocumentPlayTransport;
636
- }) {
637
- const unavailable = autoplayUnavailable(playing, clock, autoplay);
638
- const armed = autoplay.armed === true;
639
- // STOPPED, the button ARMS: the next start switches the bot on once its script offers one.
640
- const canArm = !playing && transport.armAutoplay !== undefined;
641
- const label = unavailable === 'no-bot'
642
- ? 'This game provides no bot: its play script registers none with play.autoplay(controller)'
643
- : unavailable === 'not-running'
644
- ? canArm
645
- ? armed ? 'Autoplay is armed for the next Play; press to disarm' : 'Turn autoplay on as soon as the game is running (if it offers a bot)'
646
- : 'Autoplay is available once the game is running'
647
- : autoplay.on ? 'Stop autoplay and drive yourself' : 'Let the game’s bot drive (any key or click in the game takes over)';
648
- // SAID, NOT ONLY HOVERED: why there is nothing to switch.
649
- const note = unavailable === 'no-bot'
650
- ? 'No autoplay — this game doesn’t provide a bot'
651
- : unavailable === 'not-running'
652
- ? armed ? 'Armed — turns on once the game is running' : 'Available once the game is running'
653
- : null;
654
- // THE BEHAVIOUR TO RUN, when the bot offers several: chosen here, as `play autoplay on <behaviour>`
655
- // names it. Shown with the one driving (or armed) selected, else the bot's first.
656
- const behaviors = autoplay.behaviors ?? [];
657
- const [picked, setPicked] = useState<string | null>(null);
658
- const behavior = autoplay.on || armed ? autoplay.behavior ?? null : picked !== null && behaviors.includes(picked) ? picked : behaviors[0] ?? null;
659
- const driver = driverOf(playing, autoplay);
660
- // Said while nobody drives; once the person's input reaches the game, the driver line says they are.
661
- const limitNote = driver !== 'none' ? null
662
- : autoplay.by === 'limit' && autoplay.limit ? `Autoplay stopped at its ${Math.round(autoplay.limit)} s limit`
663
- // An arm the start could not take says why, rather than leaving the person to wonder.
664
- : autoplay.refused ? `Autoplay didn’t start: ${autoplay.refused}` : null;
665
- const botLine = [autoplay.behavior, autoplay.state].filter(Boolean).join(' — ');
666
- return (
667
- <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
668
- {behaviors.length > 1 && (
669
- <select
670
- data-testid="model-play-autoplay-behavior"
671
- title="The bot behaviour Autoplay runs"
672
- value={behavior ?? ''}
673
- disabled={autoplay.on}
674
- onChange={(event) => setPicked(event.target.value)}
675
- style={{ background: TIMELINE_CHROME.widget, color: TIMELINE_CHROME.widgetText, border: 'none', borderRadius: TIMELINE_CHROME.widgetRadius, font: 'inherit', minWidth: 0 }}
676
- >
677
- {behaviors.map((name) => <option key={name} value={name}>{name}</option>)}
678
- </select>
679
- )}
680
- {/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
681
- <span title={label} style={{ display: 'inline-flex' }}>
682
- {/* KEEP THE KEYBOARD OFF THIS BUTTON (`GameButton`'s `keepFocus`). Left focused, a person
683
- taking over with Space — a key the game hears, which turns autoplay off — would also
684
- click this button and turn autoplay straight back on. */}
685
- <GameButton
686
- testId="model-play-autoplay"
687
- label={label}
688
- pressed={autoplay.on || armed}
689
- wide
690
- disabled={canArm ? false : !playing || !autoplay.available || transport.setAutoplay === undefined}
691
- // The switch's state NOW, as the transport's buttons read theirs: a takeover can land
692
- // between the render and the click.
693
- onClick={() => {
694
- const now = transport.autoplay?.(documentId) ?? NO_BOT;
695
- if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed, { behavior });
696
- else transport.setAutoplay?.(documentId, !now.on, 'panel', now.on ? undefined : { behavior });
697
- }}
698
- >
699
- Autoplay
700
- </GameButton>
701
- </span>
702
- {note !== null ? (
703
- <span data-testid="model-play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
704
- {note}
705
- </span>
706
- ) : playing && (
707
- // WHO DRIVES, AND WHAT THE BOT SAYS IT IS DOING: "Bot driving · win — heading to nest 2".
708
- // Nobody drives a game that runs on no input; the person only once their input reached it.
709
- <span
710
- data-testid="model-play-driver"
711
- data-driver={driver ?? undefined}
712
- title={autoplay.state ? `The bot: ${autoplay.state}` : undefined}
713
- style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
714
- >
715
- {driver === 'bot' ? `Bot driving${botLine ? ` · ${botLine}` : ''}`
716
- : limitNote ? `${limitNote}${autoplay.state ? ` — ${autoplay.state}` : ''}`
717
- : driver === 'person' ? 'You’re driving' : 'Nobody’s driving'}
718
- </span>
719
- )}
720
- </div>
721
- );
722
- }
723
-
724
- /** Enough to read back what just happened; the whole log is `cyclotron play-log`. */
725
- const LOG_LINES = 200;
726
-
727
- function factsText(facts: Record<string, unknown> | undefined): string {
728
- if (facts === undefined) return '';
729
- try { return JSON.stringify(facts); } catch { return ''; }
730
- }
731
-
732
- function PlayLogView({ documentId, log }: { readonly documentId: string; readonly log: DocumentPlayLog }) {
733
- const [kind, setKind] = useState('');
734
- // Writes land several to a frame; the view redraws at most once per drawn frame.
735
- const [, redraw] = useReducer((count: number) => count + 1, 0);
736
- useEffect(() => {
737
- let frame = 0;
738
- const stop = log.subscribe(() => {
739
- if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
740
- });
741
- return () => { stop(); cancelAnimationFrame(frame); };
742
- }, [log]);
743
- const tail = log.tail(documentId, LOG_LINES, kind === '' ? undefined : kind);
744
- const list = useRef<HTMLDivElement>(null);
745
- // Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
746
- const following = useRef(true);
747
- useLayoutEffect(() => {
748
- const element = list.current;
749
- if (element && following.current) element.scrollTop = element.scrollHeight;
750
- });
751
- const newest = tail.entries.at(-1)?.seq;
752
- return (
753
- <div data-testid="model-play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
754
- <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
755
- <span>Play log</span>
756
- <select
757
- data-testid="model-play-log-kind"
758
- aria-label="Show entries of kind"
759
- value={kind}
760
- // Blurred once chosen, so the player's next arrow key moves the game, not this list.
761
- onChange={(event) => { setKind(event.target.value); event.target.blur(); }}
762
- style={{
763
- height: TIMELINE_CHROME.unit,
764
- background: TIMELINE_CHROME.widget,
765
- color: TIMELINE_CHROME.widgetText,
766
- border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
767
- font: 'inherit',
768
- }}
769
- >
770
- <option value="">All kinds</option>
771
- {[...new Set([...tail.kinds, ...(kind === '' ? [] : [kind])])].map((one) => <option key={one} value={one}>{one}</option>)}
772
- </select>
773
- <span style={{ color: TIMELINE_CHROME.widgetText }}>
774
- {tail.total === 0 ? 'Nothing logged yet' : `${tail.total} written${tail.total > LOG_LINES ? ` · newest ${LOG_LINES} shown` : ''}`}
775
- </span>
776
- </div>
777
- <div
778
- ref={list}
779
- data-testid="model-play-log-entries"
780
- data-newest={newest}
781
- onScroll={(event) => {
782
- const element = event.currentTarget;
783
- following.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 4;
784
- }}
785
- style={{
786
- flex: 1,
787
- minHeight: 0,
788
- overflowY: 'auto',
789
- font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
790
- background: TIMELINE_THEME.header,
791
- border: `1px solid ${TIMELINE_CHROME.rule}`,
792
- padding: '2px 6px',
793
- }}
794
- >
795
- {tail.entries.map((entry) => (
796
- <div
797
- key={entry.seq}
798
- data-kind={entry.kind}
799
- style={{ display: 'flex', gap: 10, whiteSpace: 'nowrap', color: entry.source === 'play' ? TIMELINE_CHROME.widgetText : TIMELINE_CHROME.text }}
800
- >
801
- <span style={{ flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' }}>{clockText(entry.simT)}</span>
802
- <span style={{ flex: '0 0 auto' }}>{entry.kind}</span>
803
- <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} title={factsText(entry.facts)}>{factsText(entry.facts)}</span>
804
- </div>
805
- ))}
806
- </div>
807
- </div>
808
- );
809
- }