@volter/editor-blender 0.1.132 → 0.1.133

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.
@@ -163,15 +163,24 @@ function gameState(documentId: string): unknown {
163
163
  };
164
164
  }
165
165
 
166
- /** The bot's switch and who drives: `bot` while autoplay is on, `person` otherwise, null when
167
- * nothing plays. `by` is who made the last change (`takeover`: a person's key or pointer);
168
- * `why` says why there is no bot to switch, null when there is one. */
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. */
169
178
  function autoplayState(documentId: string): unknown {
170
179
  const found = extension();
171
180
  const autoplay = found?.transport?.autoplay?.(documentId) ?? NO_BOT;
172
181
  const playing = found?.playing(documentId) ?? false;
173
182
  const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
174
- return { ...autoplay, driver: playing ? (autoplay.on ? 'bot' : 'person') : null, why };
183
+ return { ...autoplay, driver: driverOf(playing, autoplay), why };
175
184
  }
176
185
 
177
186
  /**
@@ -299,8 +308,9 @@ registerViewVerbs({
299
308
  },
300
309
  },
301
310
  {
302
- // THE EDITOR'S AUTOPLAY SWITCH: on drives the game's own bot (`play.autoplay`), off hands
303
- // the game back to the person. Off at every Play and Restart.
311
+ // THE EDITOR'S AUTOPLAY SWITCH: on drives one of the game's bot behaviours (`play.autoplay`)
312
+ // for at most `for` simulation seconds, off hands the game back to the person. Off at every
313
+ // Play and Restart. `behavior` is required when the bot offers several.
304
314
  id: 'autoplay',
305
315
  run: (args) => {
306
316
  const documentId = verbDocument(args);
@@ -308,6 +318,11 @@ registerViewVerbs({
308
318
  const on = raw === true || raw === 'on' || raw === 'true' ? true
309
319
  : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
310
320
  if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on|off\`); got ${String(raw)}.`);
321
+ const behavior = typeof args?.['behavior'] === 'string' && args['behavior'] !== '' ? args['behavior'] : null;
322
+ const rawLimit = args?.['for'] ?? args?.['limit'];
323
+ const limit = rawLimit === undefined || rawLimit === null ? null : Number(rawLimit);
324
+ if (limit !== null && !(Number.isFinite(limit) && limit > 0))
325
+ throw new Error(`autoplay's \`for\` is a positive number of simulation seconds; got ${JSON.stringify(rawLimit)}.`);
311
326
  const found = verbExtension();
312
327
  const { transport } = found;
313
328
  if (transport.setAutoplay === undefined)
@@ -315,7 +330,7 @@ registerViewVerbs({
315
330
  // Refused with the reason, stopped or no bot, as `play state`'s `autoplay.why` gives it.
316
331
  const why = on ? autoplayWhy(found.playing(documentId), transport.clock(documentId), transport.autoplay?.(documentId) ?? NO_BOT) : null;
317
332
  if (why !== null) throw new Error(why);
318
- transport.setAutoplay(documentId, on, 'cli');
333
+ transport.setAutoplay(documentId, on, 'cli', on ? { behavior, limit } : undefined);
319
334
  return gameState(documentId);
320
335
  },
321
336
  },
@@ -635,8 +650,32 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
635
650
  : unavailable === 'not-running'
636
651
  ? armed ? 'Armed — turns on once the game is running' : 'Available once the game is running'
637
652
  : null;
653
+ // THE BEHAVIOUR TO RUN, when the bot offers several: chosen here, as `play autoplay on <behaviour>`
654
+ // names it. Shown with the one driving (or armed) selected, else the bot's first.
655
+ const behaviors = autoplay.behaviors ?? [];
656
+ const [picked, setPicked] = useState<string | null>(null);
657
+ const behavior = autoplay.on || armed ? autoplay.behavior ?? null : picked !== null && behaviors.includes(picked) ? picked : behaviors[0] ?? null;
658
+ const driver = driverOf(playing, autoplay);
659
+ // Said while nobody drives; once the person's input reaches the game, the driver line says they are.
660
+ const limitNote = driver !== 'none' ? null
661
+ : autoplay.by === 'limit' && autoplay.limit ? `Autoplay stopped at its ${Math.round(autoplay.limit)} s limit`
662
+ // An arm the start could not take says why, rather than leaving the person to wonder.
663
+ : autoplay.refused ? `Autoplay didn’t start: ${autoplay.refused}` : null;
664
+ const botLine = [autoplay.behavior, autoplay.state].filter(Boolean).join(' — ');
638
665
  return (
639
666
  <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
667
+ {behaviors.length > 1 && (
668
+ <select
669
+ data-testid="model-play-autoplay-behavior"
670
+ title="The bot behaviour Autoplay runs"
671
+ value={behavior ?? ''}
672
+ disabled={autoplay.on}
673
+ onChange={(event) => setPicked(event.target.value)}
674
+ style={{ background: TIMELINE_CHROME.widget, color: TIMELINE_CHROME.widgetText, border: 'none', borderRadius: TIMELINE_CHROME.widgetRadius, font: 'inherit', minWidth: 0 }}
675
+ >
676
+ {behaviors.map((name) => <option key={name} value={name}>{name}</option>)}
677
+ </select>
678
+ )}
640
679
  {/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
641
680
  <span title={label} style={{ display: 'inline-flex' }}>
642
681
  {/* KEEP THE KEYBOARD OFF THIS BUTTON (`GameButton`'s `keepFocus`). Left focused, a person
@@ -652,8 +691,8 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
652
691
  // between the render and the click.
653
692
  onClick={() => {
654
693
  const now = transport.autoplay?.(documentId) ?? NO_BOT;
655
- if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed);
656
- else transport.setAutoplay?.(documentId, !now.on, 'panel');
694
+ if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed, { behavior });
695
+ else transport.setAutoplay?.(documentId, !now.on, 'panel', now.on ? undefined : { behavior });
657
696
  }}
658
697
  >
659
698
  Autoplay
@@ -664,12 +703,17 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
664
703
  {note}
665
704
  </span>
666
705
  ) : playing && (
706
+ // WHO DRIVES, AND WHAT THE BOT SAYS IT IS DOING: "Bot driving · win — heading to nest 2".
707
+ // Nobody drives a game that runs on no input; the person only once their input reached it.
667
708
  <span
668
709
  data-testid="model-play-driver"
669
- data-driver={autoplay.on ? 'bot' : 'person'}
670
- style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap' }}
710
+ data-driver={driver ?? undefined}
711
+ title={autoplay.state ? `The bot: ${autoplay.state}` : undefined}
712
+ style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
671
713
  >
672
- {autoplay.on ? 'Bot driving' : 'You’re driving'}
714
+ {driver === 'bot' ? `Bot driving${botLine ? ` · ${botLine}` : ''}`
715
+ : limitNote ? `${limitNote}${autoplay.state ? ` — ${autoplay.state}` : ''}`
716
+ : driver === 'person' ? 'You’re driving' : 'Nobody’s driving'}
673
717
  </span>
674
718
  )}
675
719
  </div>
@@ -158,6 +158,7 @@ export async function photographSnapshot(
158
158
  await snapshot.prepare(renderCamera);
159
159
  assertCurrent();
160
160
  const captureOptions = {
161
+ prepareDraw:snapshot.prepareDraw,
161
162
  effect: snapshot.effect,
162
163
  width: render.width,
163
164
  height: render.height,
@@ -218,16 +219,29 @@ export function bindModelDocument(bound: ModelDocumentBinding | null): () => voi
218
219
  };
219
220
  }
220
221
 
221
- function modelDocumentConflict(): string | null {
222
- const held = runtime?.document;
223
- // The finder owns model:<path>, wrapped by the host as document:<entry>.
224
- // Read the active address too: activation precedes the contributed pane's
225
- // effect, so an immediate command must not slip through that binding gap.
222
+ /** The `.blend` the editor is showing as the Model: the active tab's own file, or the bound one.
223
+ * The finder owns model:<path>, wrapped by the host as document:<entry>. Read the active address
224
+ * too: activation precedes the contributed pane's effect, so an immediate command must not slip
225
+ * through that binding gap. */
226
+ function shownModelBlend(): string | undefined {
226
227
  const activeId = editorHost().documents.activeId();
227
- const requested = activeId?.startsWith('document:model:')
228
+ return activeId?.startsWith('document:model:')
228
229
  ? activeId.slice('document:model:'.length)
229
230
  : boundModel?.blend;
231
+ }
232
+
233
+ /**
234
+ * A script's Save As into a project `.blend` that no Model document lists (`followMovedDocument`): Blender edits
235
+ * that file and saves there, and the tab it left (`from`) keeps showing Blender, since there is no tab to move to.
236
+ * Without this, every later call was refused as a conflict naming a file no tab can show.
237
+ */
238
+ let unlistedFollow: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
239
+
240
+ function modelDocumentConflict(): string | null {
241
+ const held = runtime?.document;
242
+ const requested = shownModelBlend();
230
243
  if (!requested || !held || requested === held) return null;
244
+ if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
231
245
  // Say how to get Blender onto the document the editor shows, not only how to go back: an agent told
232
246
  // just "return to <held>" took it as the Blender door being unusable and built its level by
233
247
  // another route (2026-10-06, the WSL obby run).
@@ -513,7 +527,10 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
513
527
  * reads rather than this execute path, which always presents a mutation.
514
528
  */
515
529
  export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
516
- const text = await blenderRuntime().execute(code, history, label);
530
+ const session = blenderRuntime();
531
+ const text = await session.execute(code, history, label);
532
+ // Before the caller hears back: its next call must find the tab on the file Blender now holds.
533
+ await followMovedDocument(session);
517
534
  noteBlenderRnaChanged();
518
535
  const failed = /^Error executing code:/.exec(text);
519
536
  return {
@@ -524,6 +541,71 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
524
541
  };
525
542
  }
526
543
 
544
+ /**
545
+ * A Save As the worker reported (`BlenderRuntimeOptions.documentMoved`) that the open Model tab
546
+ * has not followed yet. Taken by the {@link blenderExecute} whose script moved it, once that
547
+ * call's own save has landed the new file in the project.
548
+ */
549
+ let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
550
+
551
+ /**
552
+ * THE MODEL TAB FOLLOWS BLENDER'S SAVE AS. The runtime already holds the new file and its saves
553
+ * go there (`session.py::follow_open_file`); what is left is the tab. Until it moves, the editor
554
+ * is showing the old file while Blender edits the new one, which `modelDocumentConflict` refuses
555
+ * on every later call. So the new file's own Model document is opened: it binds to the session
556
+ * that already holds that file (no stop, no reload), and the old tab, no longer active, releases
557
+ * its binding. Re-activating the old tab later opens the old file the ordinary way.
558
+ *
559
+ * Only a tab that was showing the old file (or the standing Model of a project that had no
560
+ * `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A new file
561
+ * no Model document lists (outside the project's model finder) is said, not guessed at.
562
+ */
563
+ async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
564
+ const move = unfollowedMove;
565
+ if (!move || move.owner !== owner) return;
566
+ unfollowedMove = null;
567
+ const host = editorHost();
568
+ // The tab showing Blender: the file Blender left, or, after an earlier Save As to an unlisted file, the tab
569
+ // that one left.
570
+ const shownFrom = unlistedFollow !== null && unlistedFollow.owner === owner && unlistedFollow.document === move.from ? unlistedFollow.from : move.from;
571
+ unlistedFollow = null;
572
+ const stillShowingOld = () => {
573
+ if (runtime !== owner || owner.document !== move.document) return false;
574
+ const shown = shownModelBlend();
575
+ return shown === shownFrom || (shown === undefined && host.documents.activeId() === BLENDER_RUNTIME_DOCUMENT_ID);
576
+ };
577
+ if (!stillShowingOld()) return;
578
+ try {
579
+ // The table re-reads the project's files, which now include the file this call saved.
580
+ const table = await host.project.documentTable();
581
+ const entry = table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
582
+ if (!entry) {
583
+ if (!stillShowingOld()) return;
584
+ unlistedFollow = { owner, from: shownFrom, document: move.document };
585
+ host.console.warn(
586
+ `Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
587
+ `(the project's model finder does not include its folder), so the ${shownFrom} tab keeps showing ` +
588
+ `Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
589
+ `under src/models/ to give it a Model tab of its own.`,
590
+ 'blender-document',
591
+ );
592
+ return;
593
+ }
594
+ if (!stillShowingOld()) return;
595
+ if (!await host.workspace.open({ kind: 'document', id: entry.id })) {
596
+ keepShowing();
597
+ host.console.warn(`Blender now has ${move.document} open, but its Model document ${entry.id} did not open; the ${shownFrom} tab keeps showing Blender, which edits ${move.document}.`, 'blender-document');
598
+ }
599
+ } catch (error) {
600
+ keepShowing();
601
+ host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
602
+ }
603
+ /** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
604
+ function keepShowing(): void {
605
+ if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move!.document };
606
+ }
607
+ }
608
+
527
609
  /** Coalesce a human gesture; Blender retains the states, Code-OSS the ordering. */
528
610
  export function beginBlenderGesture(): void {
529
611
  void blenderRuntime().historyGesture('history-begin').catch(error =>
@@ -1077,6 +1159,19 @@ export function blenderRuntime(): BlenderRuntime {
1077
1159
  // traceback out of it.
1078
1160
  // biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
1079
1161
  log: (level, text) => console[level](`[blender] ${text}`),
1162
+ // A SCRIPT'S SAVE AS MOVED THE DOCUMENT. Said in the editor's console, where the change of
1163
+ // file is a fact a person needs; the tab follows once the script's call answers
1164
+ // (`followMovedDocument`), when the new file is in the project to open.
1165
+ documentMoved: (moved) => {
1166
+ const owner = runtime;
1167
+ if (!owner) return;
1168
+ unfollowedMove = { owner, from: moved.from, document: moved.document };
1169
+ editorHost().console.log(
1170
+ `Blender now has ${moved.document} open (a script saved or opened it there), so the model saves to ` +
1171
+ `${moved.document} from now on; ${moved.from} keeps what it last saved.`,
1172
+ 'blender-document',
1173
+ );
1174
+ },
1080
1175
  });
1081
1176
  const session = runtime;
1082
1177
  // MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
@@ -1122,7 +1217,8 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
1122
1217
  /**
1123
1218
  * The project-relative path of the `.blend` the SESSION'S PYTHON currently
1124
1219
  * holds — `bpy.data.filepath`, which a bake script's own `open_mainfile`
1125
- * retargets and the session's bound document does not. Answers `{}` when there
1220
+ * retargets as it runs (the session's document follows only once the call has
1221
+ * ended, and only inside the project). Answers `{}` when there
1126
1222
  * is no file (a scene modelled from scratch has never been saved) or when the
1127
1223
  * path is outside the project, because a record is better absent than wrong.
1128
1224
  */
@@ -1219,10 +1315,17 @@ export async function handleBlenderCommand(cmd: {
1219
1315
  // project's document is its own `src/models/<name>.blend`, and a
1220
1316
  // `blender-start` that named none is asking for the session this tab
1221
1317
  // is showing, not for a second file beside it.
1318
+ // After a script's Save As to a file no Model lists, the session this tab
1319
+ // shows holds that file (`unlistedFollow`), and a start naming none
1320
+ // asks for it, not for the file the tab was opened on.
1321
+ const shown =
1322
+ unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
1323
+ ? unlistedFollow.document
1324
+ : boundModel?.blend;
1222
1325
  const document =
1223
1326
  requestedDocument !== undefined
1224
1327
  ? requestedDocument
1225
- : (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT);
1328
+ : (shown ?? DEFAULT_BLENDER_DOCUMENT);
1226
1329
  return { ok: true, data: { ...(await session.start(project!, document)) } };
1227
1330
  }
1228
1331
  case 'blender-execute': {
@@ -1439,9 +1542,9 @@ export async function handleBlenderCommand(cmd: {
1439
1542
  // IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
1440
1543
  // is a measurement rather than a preference. `boundModel` is the
1441
1544
  // document the TAB opened; a bake script opens its own file with
1442
- // `wm.open_mainfile`, which `bind_document` deliberately leaves
1443
- // alone (it retargets `bpy.data.filepath`, not the session's
1444
- // document). Measured 2026-09-19: reading `boundModel` recorded
1545
+ // `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
1546
+ // the tab only after the call (`followMovedDocument`), and never
1547
+ // for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
1445
1548
  // the SAME `.blend` as the input of all three of first-person's
1446
1549
  // characters — whichever one the tab happened to show — which is
1447
1550
  // worse than no record, because it is a confident wrong answer.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.132",
5
+ "version": "0.1.133",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -72,15 +72,15 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "1.32.1",
75
- "@volter/blender-engine": "0.1.130",
76
- "@volter/editor-threejs": "0.5.193",
75
+ "@volter/blender-engine": "0.1.131",
76
+ "@volter/editor-threejs": "0.5.194",
77
77
  "zod": "^4.3.6",
78
78
  "@fortawesome/free-solid-svg-icons": "^7.2.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@volter/editor-live": "0.5.193",
82
- "@volter/editor-project": "0.5.193",
83
- "@volter/editor-sdk": "0.5.193",
81
+ "@volter/editor-live": "0.5.194",
82
+ "@volter/editor-project": "0.5.194",
83
+ "@volter/editor-sdk": "0.5.194",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",