@volter/editor-blender 0.1.131 → 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>
@@ -57,6 +57,7 @@ export const commands: CommandContribution['commands'] = {
57
57
  // Stop drains accepted modeling work and persists it before teardown.
58
58
  'blender-stop': verb('none', 30 * 60_000),
59
59
  'blender-execute': verb('always', 30 * 60_000),
60
+ 'blender-native-preview': verb('none', 120_000),
60
61
  'blender-scene-info': verb('none', 60_000),
61
62
  'blender-object-info': verb('none', 60_000),
62
63
  'blender-screenshot-view': verb('always', 60_000),
@@ -117,9 +117,13 @@ async function blenderWasmStatus(env = process.env) {
117
117
  `${dir} holds neither ${BLENDER_WASM_FILES.join(", ")} (the standalone skew) nor ${BLENDER_WALI_ARTIFACT} (the substrate skew)`
118
118
  ]
119
119
  };
120
- return skew === "wali" ? waliStatus(dir) : emscriptenStatus(dir);
120
+ return skew === "wali" ? waliStatus(dir) : emscriptenStatus(dir, env);
121
121
  }
122
- async function emscriptenStatus(dir) {
122
+ var BROWSER_SUBSTRATE_VARIABLE = "BROWSER_SUBSTRATE_URL";
123
+ function servedFromTab(env = process.env) {
124
+ return (env[BROWSER_SUBSTRATE_VARIABLE] ?? "").trim() !== "";
125
+ }
126
+ async function emscriptenStatus(dir, env) {
123
127
  const sizes = {};
124
128
  const encoded = {};
125
129
  const missing = [];
@@ -132,7 +136,7 @@ async function emscriptenStatus(dir) {
132
136
  sizes[file] = found.size;
133
137
  if (found.encoding) encoded[file] = found.encoding;
134
138
  }
135
- return { available: missing.length === 0, skew: "emscripten", dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
139
+ return { available: missing.length === 0, skew: "emscripten", dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab(env) };
136
140
  }
137
141
  async function emscriptenDigests(dir) {
138
142
  const digests = {};
@@ -20,12 +20,9 @@
20
20
  * through `capture-active-document`, after `blender-screenshot-view` has
21
21
  * presented the model with the viewport's own camera.
22
22
  *
23
- * A RENDER photographs a detached revision through its own camera. three.js is
24
- * the renderer (ARCHITECTURE-CORE, "No second implementation of a substrate
25
- * capability ships"), so `bpy.ops.render.render()` arrives here as a present
26
- * whose capture carries the SCENE camera and `scene.render`'s resolution, and
27
- * the PNG goes straight back to the operator that asked — the one case where
28
- * presenting answers with pixels instead of remembering a view.
23
+ * VOLTER_THREE photographs a detached revision through its own camera.
24
+ * Native CYCLES renders inside the Blender worker. A native preview is a
25
+ * read of that document through Cycles, separate from its Three presentation.
29
26
  */
30
27
 
31
28
  import {
@@ -56,8 +53,7 @@ import {
56
53
  resolveSceneLinearDisplay,
57
54
  type LinearCaptureFrame,
58
55
  } from '@volter/editor-threejs/capture/scene';
59
- import { fitClipPlanes } from '@volter/editor-threejs/viewport/clip-planes';
60
- import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
56
+ import {blenderRenderCamera} from '@volter/blender-engine/browser/three/blender-render-camera';
61
57
  import * as THREE from 'three';
62
58
  import { blenderEngineSelection, refreshBlenderOutliner } from '../contributions/blender-outliner-model';
63
59
  import {
@@ -162,6 +158,7 @@ export async function photographSnapshot(
162
158
  await snapshot.prepare(renderCamera);
163
159
  assertCurrent();
164
160
  const captureOptions = {
161
+ prepareDraw:snapshot.prepareDraw,
165
162
  effect: snapshot.effect,
166
163
  width: render.width,
167
164
  height: render.height,
@@ -222,16 +219,29 @@ export function bindModelDocument(bound: ModelDocumentBinding | null): () => voi
222
219
  };
223
220
  }
224
221
 
225
- function modelDocumentConflict(): string | null {
226
- const held = runtime?.document;
227
- // The finder owns model:<path>, wrapped by the host as document:<entry>.
228
- // Read the active address too: activation precedes the contributed pane's
229
- // 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 {
230
227
  const activeId = editorHost().documents.activeId();
231
- const requested = activeId?.startsWith('document:model:')
228
+ return activeId?.startsWith('document:model:')
232
229
  ? activeId.slice('document:model:'.length)
233
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();
234
243
  if (!requested || !held || requested === held) return null;
244
+ if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
235
245
  // Say how to get Blender onto the document the editor shows, not only how to go back: an agent told
236
246
  // just "return to <held>" took it as the Blender door being unusable and built its level by
237
247
  // another route (2026-10-06, the WSL obby run).
@@ -513,12 +523,14 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
513
523
  * a first version read `answer.executed`/`answer.error` off the string, where
514
524
  * both are `undefined` — every operator RAN and every caller was told it had
515
525
  * failed, which is how an added sphere reached `bpy.data.objects` and the
516
- * Outliner and was never selected. `sessionDocumentPath` below parses the same
517
- * two prefixes and records the same lesson from its own bug; this is the
518
- * second time, so the parse lives at the door now and both read it.
526
+ * Outliner and was never selected. Read-only metadata uses dedicated native
527
+ * reads rather than this execute path, which always presents a mutation.
519
528
  */
520
529
  export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
521
- 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);
522
534
  noteBlenderRnaChanged();
523
535
  const failed = /^Error executing code:/.exec(text);
524
536
  return {
@@ -529,6 +541,71 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
529
541
  };
530
542
  }
531
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
+
532
609
  /** Coalesce a human gesture; Blender retains the states, Code-OSS the ordering. */
533
610
  export function beginBlenderGesture(): void {
534
611
  void blenderRuntime().historyGesture('history-begin').catch(error =>
@@ -1033,35 +1110,10 @@ export function blenderRuntime(): BlenderRuntime {
1033
1110
  );
1034
1111
  const focus = new THREE.Vector3(target[0], target[1], target[2]).applyMatrix4(toDocument);
1035
1112
  const upward = new THREE.Vector3(up[0], up[1], up[2]).applyMatrix3(documentBasis);
1036
- const aspect =
1037
- Math.min(2048, Math.round(render.width)) / Math.min(2048, Math.round(render.height));
1038
- // Fit clipping to this snapshot and photograph's eye, independently of
1039
- // the user's navigation. Editor furniture never enters this scene.
1040
- const bounds = contentWorldBounds(snapshot.root).getBoundingSphere(new THREE.Sphere());
1041
- const documentView = fitClipPlanes(eye.distanceTo(bounds.center), bounds.radius);
1042
- let renderCamera: THREE.Camera;
1043
- if (render.orthographic) {
1044
- // An ORTHO scene camera. `fov` carries `ortho_scale` as the VERTICAL
1045
- // extent in Blender units (`_vertical_extent` applies `sensor_fit`), so
1046
- // the frustum is stated outright instead of being reverse-derived from
1047
- // a perspective camera's distance and angle.
1048
- const halfHeight = render.fov / 2;
1049
- renderCamera = new THREE.OrthographicCamera(
1050
- -halfHeight * aspect,
1051
- halfHeight * aspect,
1052
- halfHeight,
1053
- -halfHeight,
1054
- documentView.near,
1055
- documentView.far,
1056
- );
1057
- } else {
1058
- renderCamera = new THREE.PerspectiveCamera(
1059
- render.fov,
1060
- aspect,
1061
- documentView.near,
1062
- documentView.far,
1063
- );
1064
- }
1113
+ // The native camera's near/far and lens shift describe this picture.
1114
+ // Geometry bounds are for interactive navigation, not render framing.
1115
+ const documentScale = eye.distanceTo(focus);
1116
+ const renderCamera = blenderRenderCamera(render, documentScale);
1065
1117
  renderCamera.up.copy(upward);
1066
1118
  renderCamera.position.copy(eye);
1067
1119
  renderCamera.lookAt(focus);
@@ -1107,6 +1159,19 @@ export function blenderRuntime(): BlenderRuntime {
1107
1159
  // traceback out of it.
1108
1160
  // biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
1109
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
+ },
1110
1175
  });
1111
1176
  const session = runtime;
1112
1177
  // MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
@@ -1152,36 +1217,14 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
1152
1217
  /**
1153
1218
  * The project-relative path of the `.blend` the SESSION'S PYTHON currently
1154
1219
  * holds — `bpy.data.filepath`, which a bake script's own `open_mainfile`
1155
- * 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
1156
1222
  * is no file (a scene modelled from scratch has never been saved) or when the
1157
1223
  * path is outside the project, because a record is better absent than wrong.
1158
1224
  */
1159
- async function sessionDocumentPath(session: {
1160
- execute(code: string, history?: boolean): Promise<string>;
1161
- }): Promise<{ document?: string }> {
1225
+ function sessionDocumentPath(filepath: string): { document?: string } {
1162
1226
  const project = editorHost().projectLocalState.projectRootPath();
1163
1227
  if (project === null) return {};
1164
- let answer: string;
1165
- try {
1166
- answer = await session.execute('import bpy\nprint(bpy.data.filepath)\n', false);
1167
- } catch {
1168
- return {};
1169
- }
1170
- // PARSE WHAT `execute` ACTUALLY ANSWERS, which is the MCP door's shape and
1171
- // not raw stdout: `Code executed successfully: <stdout>` on ONE line, or
1172
- // `Error executing code: …`. A first version filtered for lines starting
1173
- // with `/` and therefore matched nothing, because the path sits after that
1174
- // prefix on the same line — and a document that cannot be read is
1175
- // indistinguishable from a session that has none, so the failure was silent
1176
- // and the nine bakes recorded no input at all.
1177
- if (/^Error executing code:/m.test(answer)) return {};
1178
- const filepath =
1179
- answer
1180
- .replace(/^Code executed successfully:/, '')
1181
- .split('\n')
1182
- .map((line) => line.trim())
1183
- .filter((line) => line.startsWith('/'))
1184
- .pop() ?? '';
1185
1228
  if (filepath === '') return {};
1186
1229
  const root = project.endsWith('/') ? project : `${project}/`;
1187
1230
  if (!filepath.startsWith(root)) return {};
@@ -1272,10 +1315,17 @@ export async function handleBlenderCommand(cmd: {
1272
1315
  // project's document is its own `src/models/<name>.blend`, and a
1273
1316
  // `blender-start` that named none is asking for the session this tab
1274
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;
1275
1325
  const document =
1276
1326
  requestedDocument !== undefined
1277
1327
  ? requestedDocument
1278
- : (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT);
1328
+ : (shown ?? DEFAULT_BLENDER_DOCUMENT);
1279
1329
  return { ok: true, data: { ...(await session.start(project!, document)) } };
1280
1330
  }
1281
1331
  case 'blender-execute': {
@@ -1435,6 +1485,17 @@ export async function handleBlenderCommand(cmd: {
1435
1485
  const document = { kind: 'document', id: boundModel?.entryId ?? 'blender:runtime' };
1436
1486
  return { ok: true, data: { ...await session.screenshotView(maxSize), document } };
1437
1487
  }
1488
+ case 'blender-native-preview': {
1489
+ const camera = cmd['camera'];
1490
+ if (camera !== undefined && typeof camera !== 'string') throw new Error('Native preview camera must be a name');
1491
+ const preview = await session.nativePreview({
1492
+ width: cmd['width'] as number,
1493
+ height: cmd['height'] as number,
1494
+ samples: cmd['samples'] as number,
1495
+ ...(camera === undefined ? {} : { camera }),
1496
+ });
1497
+ return { ok: true, data: { ...preview } };
1498
+ }
1438
1499
  case 'blender-read-file': {
1439
1500
  // The bytes go out as a BODY, not inside this command's answer -- see
1440
1501
  // `/__editor/blender-file` in `server/routes/relay.ts` for the
@@ -1464,13 +1525,13 @@ export async function handleBlenderCommand(cmd: {
1464
1525
  // what it can honestly name is this session and the model state it had
1465
1526
  // reached — taken here, at the instant of the listing, rather than
1466
1527
  // asked for afterwards when another call may have moved it.
1467
- const entries = await session.listFiles(string(cmd, 'path'));
1468
- const presented = session.presented;
1528
+ const { entries, document } = await session.listFilesSnapshot(string(cmd, 'path'));
1469
1529
  return {
1470
1530
  ok: true,
1471
1531
  data: {
1472
1532
  entries,
1473
- ...(presented ? { session: presented.session, revision: presented.revision } : {}),
1533
+ session: document.session,
1534
+ revision: document.revision,
1474
1535
  // AND WHAT THE BYTES WERE MADE FROM. A session-written `.glb` is
1475
1536
  // exported from a `.blend`, and that path is the one fact that
1476
1537
  // lets a reader go DOWN by kind later — from a prefab's glTF to
@@ -1481,14 +1542,14 @@ export async function handleBlenderCommand(cmd: {
1481
1542
  // IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
1482
1543
  // is a measurement rather than a preference. `boundModel` is the
1483
1544
  // document the TAB opened; a bake script opens its own file with
1484
- // `wm.open_mainfile`, which `bind_document` deliberately leaves
1485
- // alone (it retargets `bpy.data.filepath`, not the session's
1486
- // 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
1487
1548
  // the SAME `.blend` as the input of all three of first-person's
1488
1549
  // characters — whichever one the tab happened to show — which is
1489
1550
  // worse than no record, because it is a confident wrong answer.
1490
1551
  // `bpy.data.filepath` is what the export actually came out of.
1491
- ...(await sessionDocumentPath(session)),
1552
+ ...sessionDocumentPath(document.filepath),
1492
1553
  },
1493
1554
  };
1494
1555
  }
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.131",
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.129",
76
- "@volter/editor-threejs": "0.5.192",
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.192",
82
- "@volter/editor-project": "0.5.192",
83
- "@volter/editor-sdk": "0.5.192",
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",
@@ -190,6 +190,10 @@ export interface BlenderWasmStatus {
190
190
  /** Emscripten: each file's SHA-256 as it decodes (`BUNDLE.json#rawFiles`,
191
191
  * `essentials.json`), so a page can keep the bytes it fetched once. */
192
192
  digests?: Record<string, string>;
193
+ /** Emscripten: this server runs in the page's own tab ({@link servedFromTab}). Only then does
194
+ * the page keep the files in its Cache Storage; served by a process on this machine, the
195
+ * browser's HTTP cache keeps them (`blender-emscripten-engine.mts`, `cachedArtifact`). */
196
+ inTab?: boolean;
193
197
  /** WALI only: the `sha256-` SRI of `blender.wasm`. The program loader
194
198
  * refuses any non-blob URL without one, so the session names the exact
195
199
  * bytes it ran. */
@@ -236,10 +240,22 @@ export async function blenderWasmStatus(
236
240
  `${BLENDER_WALI_ARTIFACT} (the substrate skew)`,
237
241
  ],
238
242
  };
239
- return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir);
243
+ return skew === 'wali' ? waliStatus(dir) : emscriptenStatus(dir, env);
240
244
  }
241
245
 
242
- async function emscriptenStatus(dir: string): Promise<BlenderWasmStatus> {
246
+ /**
247
+ * WHETHER THIS SERVER RUNS IN THE BROWSER TAB IT SERVES. browser-substrate names its page to the
248
+ * first process it starts (`BROWSER_SUBSTRATE_URL`, its `openProject`), the project's Procfile runs
249
+ * `edit` as that process, and `edit` hands its environment to the session it spawns
250
+ * (`launcher/launch.ts`). A session started on this machine has no such variable.
251
+ */
252
+ export const BROWSER_SUBSTRATE_VARIABLE = 'BROWSER_SUBSTRATE_URL';
253
+
254
+ export function servedFromTab(env: NodeJS.ProcessEnv = process.env): boolean {
255
+ return (env[BROWSER_SUBSTRATE_VARIABLE] ?? '').trim() !== '';
256
+ }
257
+
258
+ async function emscriptenStatus(dir: string, env: NodeJS.ProcessEnv): Promise<BlenderWasmStatus> {
243
259
  const sizes: Record<string, number> = {};
244
260
  const encoded: Record<string, 'br'> = {};
245
261
  const missing: string[] = [];
@@ -252,7 +268,7 @@ async function emscriptenStatus(dir: string): Promise<BlenderWasmStatus> {
252
268
  sizes[file] = found.size;
253
269
  if (found.encoding) encoded[file] = found.encoding;
254
270
  }
255
- return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir) };
271
+ return { available: missing.length === 0, skew: 'emscripten', dir, sizes, encoded, missing, digests: await emscriptenDigests(dir), inTab: servedFromTab(env) };
256
272
  }
257
273
 
258
274
  /** The decoded bytes' digests the build records, for the files it records them for. */