@volter/editor-blender 0.1.132 → 0.1.134

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>
@@ -43,7 +43,7 @@ import type {
43
43
  BlenderUvLayout,
44
44
  } from '@volter/blender-engine/browser/rna';
45
45
  import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
46
- import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
46
+ import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
47
47
  import type { EditorCommandResult } from '@volter/editor-sdk/commands';
48
48
  import { editorHost } from '@volter/editor-sdk/host';
49
49
  import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
@@ -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 =>
@@ -739,6 +821,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
739
821
  }
740
822
 
741
823
  interface RuntimeView {
824
+ readonly root: THREE.Group;
742
825
  stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
743
826
  applyFrame(frame: unknown): unknown;
744
827
  snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
@@ -821,6 +904,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
821
904
  typeof (value as RuntimeView).applyFrame === 'function' &&
822
905
  typeof (value as RuntimeView).stageFrame === 'function' &&
823
906
  typeof (value as RuntimeView).snapshot === 'function' &&
907
+ (value as RuntimeView).root?.isObject3D === true &&
824
908
  typeof (value as RuntimeView).captureSnapshot === 'function' &&
825
909
  typeof (value as RuntimeView).recordPresentation === 'function' &&
826
910
  typeof (value as RuntimeView).recordPhotograph === 'function';
@@ -869,11 +953,20 @@ export function blenderRuntime(): BlenderRuntime {
869
953
  captureLifetime = lifetime;
870
954
  let photographing = false;
871
955
  let streamedFrame: { view: RuntimeView; session: string; revision: number } | null = null;
956
+ let renderFrame: { view: BlenderRuntimeView; source: RuntimeView; sourceFrame: ReturnType<RuntimeView['snapshot']>; documentId: string; session: string; revision: number } | null = null;
957
+ const discardRenderFrame = () => {
958
+ const owned = renderFrame;
959
+ renderFrame = null;
960
+ owned?.view.dispose();
961
+ owned?.view.root.removeFromParent();
962
+ };
872
963
  const discardStreamedFrame = () => {
873
964
  const staged = streamedFrame;
874
965
  streamedFrame = null;
875
966
  if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
967
+ discardRenderFrame();
876
968
  };
969
+ lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
877
970
  runtime = new BlenderRuntime({
878
971
  work: beginBlenderWork,
879
972
  history: (entries) => {
@@ -915,6 +1008,20 @@ export function blenderRuntime(): BlenderRuntime {
915
1008
  const view = await runtimeView();
916
1009
  lifetime.signal.throwIfAborted();
917
1010
  if (presentationDocumentId() !== documentId) throw new Error('Blender document changed during frame transfer');
1011
+ if (part.evaluation === 'render') {
1012
+ if (!renderFrame) {
1013
+ if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
1014
+ const detached = BlenderRuntimeView.forPhotograph();
1015
+ view.root.updateWorldMatrix(true, false);
1016
+ detached.root.matrix.copy(view.root.matrixWorld);
1017
+ renderFrame = { view: detached, source: view, sourceFrame: view.snapshot(), documentId, session: part.session, revision: part.revision };
1018
+ }
1019
+ if (renderFrame.source !== view || renderFrame.sourceFrame !== view.snapshot() || renderFrame.documentId !== documentId || renderFrame.session !== part.session || renderFrame.revision !== part.revision)
1020
+ throw new Error('Blender capture source changed during render transfer');
1021
+ renderFrame.view.stageFrame(part);
1022
+ return;
1023
+ }
1024
+ if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
918
1025
  if (streamedFrame && streamedFrame.view !== view)
919
1026
  throw new Error('Blender presenter changed during frame transfer');
920
1027
  view.stageFrame(part);
@@ -945,6 +1052,12 @@ export function blenderRuntime(): BlenderRuntime {
945
1052
  throw new Error('Blender document changed before its frame could be presented');
946
1053
  if (streamedFrame && streamedFrame.view !== view)
947
1054
  throw new Error('Blender presenter changed before its frame could be presented');
1055
+ const rendering = capture?.evaluation === 'render' ? renderFrame : null;
1056
+ if (capture?.evaluation === 'render' && !rendering)
1057
+ throw new Error('Render-evaluated frame arrived without its staged resources');
1058
+ if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
1059
+ throw new Error('Blender capture source changed before render presentation');
1060
+ const presenter = rendering?.view ?? view;
948
1061
  // WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
949
1062
  // beside whatever this present produced. The session only has a RECORD of
950
1063
  // what it sent; this view is the authority on what it actually holds, and
@@ -954,7 +1067,7 @@ export function blenderRuntime(): BlenderRuntime {
954
1067
  // all, rather than a wrong `null`.
955
1068
  const endApply = beginBlenderWork('applying frame to Model');
956
1069
  const applied = (() => {
957
- try { return view.applyFrame(frame) as { held?: unknown } | null | undefined; }
1070
+ try { return presenter.applyFrame(frame) as { held?: unknown } | null | undefined; }
958
1071
  finally { endApply(); }
959
1072
  })();
960
1073
  streamedFrame = null;
@@ -967,7 +1080,7 @@ export function blenderRuntime(): BlenderRuntime {
967
1080
  // THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
968
1081
  // a refused frame is not displayed and must not be described as if it
969
1082
  // were.
970
- view.recordPresentation(description);
1083
+ presenter.recordPresentation(description);
971
1084
  if (!capture) return answer(undefined);
972
1085
  lastCapture = capture;
973
1086
  if (!capture.render) return answer(undefined);
@@ -1009,7 +1122,7 @@ export function blenderRuntime(): BlenderRuntime {
1009
1122
  'A Blender render capture must carry the scene camera position, target and up',
1010
1123
  );
1011
1124
  if (photographing) throw new Error('A Blender render capture is already in progress');
1012
- const snapshot = view.captureSnapshot();
1125
+ const snapshot = rendering ? rendering.view.takeCaptureSnapshot() : view.captureSnapshot();
1013
1126
  photographing = true;
1014
1127
  lifetime.signal.addEventListener('abort', snapshot.dispose, { once: true });
1015
1128
  try {
@@ -1068,6 +1181,7 @@ export function blenderRuntime(): BlenderRuntime {
1068
1181
  photographing = false;
1069
1182
  lifetime.signal.removeEventListener('abort', snapshot.dispose);
1070
1183
  snapshot.dispose();
1184
+ if (rendering && renderFrame === rendering) renderFrame = null;
1071
1185
  }
1072
1186
  },
1073
1187
  // The worker's OWN stdout and stderr, forwarded so a developer can read
@@ -1077,6 +1191,19 @@ export function blenderRuntime(): BlenderRuntime {
1077
1191
  // traceback out of it.
1078
1192
  // biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
1079
1193
  log: (level, text) => console[level](`[blender] ${text}`),
1194
+ // A SCRIPT'S SAVE AS MOVED THE DOCUMENT. Said in the editor's console, where the change of
1195
+ // file is a fact a person needs; the tab follows once the script's call answers
1196
+ // (`followMovedDocument`), when the new file is in the project to open.
1197
+ documentMoved: (moved) => {
1198
+ const owner = runtime;
1199
+ if (!owner) return;
1200
+ unfollowedMove = { owner, from: moved.from, document: moved.document };
1201
+ editorHost().console.log(
1202
+ `Blender now has ${moved.document} open (a script saved or opened it there), so the model saves to ` +
1203
+ `${moved.document} from now on; ${moved.from} keeps what it last saved.`,
1204
+ 'blender-document',
1205
+ );
1206
+ },
1080
1207
  });
1081
1208
  const session = runtime;
1082
1209
  // MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
@@ -1122,7 +1249,8 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
1122
1249
  /**
1123
1250
  * The project-relative path of the `.blend` the SESSION'S PYTHON currently
1124
1251
  * 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
1252
+ * retargets as it runs (the session's document follows only once the call has
1253
+ * ended, and only inside the project). Answers `{}` when there
1126
1254
  * is no file (a scene modelled from scratch has never been saved) or when the
1127
1255
  * path is outside the project, because a record is better absent than wrong.
1128
1256
  */
@@ -1219,10 +1347,17 @@ export async function handleBlenderCommand(cmd: {
1219
1347
  // project's document is its own `src/models/<name>.blend`, and a
1220
1348
  // `blender-start` that named none is asking for the session this tab
1221
1349
  // is showing, not for a second file beside it.
1350
+ // After a script's Save As to a file no Model lists, the session this tab
1351
+ // shows holds that file (`unlistedFollow`), and a start naming none
1352
+ // asks for it, not for the file the tab was opened on.
1353
+ const shown =
1354
+ unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
1355
+ ? unlistedFollow.document
1356
+ : boundModel?.blend;
1222
1357
  const document =
1223
1358
  requestedDocument !== undefined
1224
1359
  ? requestedDocument
1225
- : (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT);
1360
+ : (shown ?? DEFAULT_BLENDER_DOCUMENT);
1226
1361
  return { ok: true, data: { ...(await session.start(project!, document)) } };
1227
1362
  }
1228
1363
  case 'blender-execute': {
@@ -1439,9 +1574,9 @@ export async function handleBlenderCommand(cmd: {
1439
1574
  // IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
1440
1575
  // is a measurement rather than a preference. `boundModel` is the
1441
1576
  // 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
1577
+ // `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
1578
+ // the tab only after the call (`followMovedDocument`), and never
1579
+ // for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
1445
1580
  // the SAME `.blend` as the input of all three of first-person's
1446
1581
  // characters — whichever one the tab happened to show — which is
1447
1582
  // 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.134",
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.132",
76
+ "@volter/editor-threejs": "0.5.195",
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.195",
82
+ "@volter/editor-project": "0.5.195",
83
+ "@volter/editor-sdk": "0.5.195",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",