@volter/editor-blender 0.1.131 → 0.1.132

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.
@@ -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 {
@@ -513,9 +509,8 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
513
509
  * a first version read `answer.executed`/`answer.error` off the string, where
514
510
  * both are `undefined` — every operator RAN and every caller was told it had
515
511
  * 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.
512
+ * Outliner and was never selected. Read-only metadata uses dedicated native
513
+ * reads rather than this execute path, which always presents a mutation.
519
514
  */
520
515
  export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
521
516
  const text = await blenderRuntime().execute(code, history, label);
@@ -1033,35 +1028,10 @@ export function blenderRuntime(): BlenderRuntime {
1033
1028
  );
1034
1029
  const focus = new THREE.Vector3(target[0], target[1], target[2]).applyMatrix4(toDocument);
1035
1030
  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
- }
1031
+ // The native camera's near/far and lens shift describe this picture.
1032
+ // Geometry bounds are for interactive navigation, not render framing.
1033
+ const documentScale = eye.distanceTo(focus);
1034
+ const renderCamera = blenderRenderCamera(render, documentScale);
1065
1035
  renderCamera.up.copy(upward);
1066
1036
  renderCamera.position.copy(eye);
1067
1037
  renderCamera.lookAt(focus);
@@ -1156,32 +1126,9 @@ const string = (cmd: Record<string, unknown>, key: string): string => {
1156
1126
  * is no file (a scene modelled from scratch has never been saved) or when the
1157
1127
  * path is outside the project, because a record is better absent than wrong.
1158
1128
  */
1159
- async function sessionDocumentPath(session: {
1160
- execute(code: string, history?: boolean): Promise<string>;
1161
- }): Promise<{ document?: string }> {
1129
+ function sessionDocumentPath(filepath: string): { document?: string } {
1162
1130
  const project = editorHost().projectLocalState.projectRootPath();
1163
1131
  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
1132
  if (filepath === '') return {};
1186
1133
  const root = project.endsWith('/') ? project : `${project}/`;
1187
1134
  if (!filepath.startsWith(root)) return {};
@@ -1435,6 +1382,17 @@ export async function handleBlenderCommand(cmd: {
1435
1382
  const document = { kind: 'document', id: boundModel?.entryId ?? 'blender:runtime' };
1436
1383
  return { ok: true, data: { ...await session.screenshotView(maxSize), document } };
1437
1384
  }
1385
+ case 'blender-native-preview': {
1386
+ const camera = cmd['camera'];
1387
+ if (camera !== undefined && typeof camera !== 'string') throw new Error('Native preview camera must be a name');
1388
+ const preview = await session.nativePreview({
1389
+ width: cmd['width'] as number,
1390
+ height: cmd['height'] as number,
1391
+ samples: cmd['samples'] as number,
1392
+ ...(camera === undefined ? {} : { camera }),
1393
+ });
1394
+ return { ok: true, data: { ...preview } };
1395
+ }
1438
1396
  case 'blender-read-file': {
1439
1397
  // The bytes go out as a BODY, not inside this command's answer -- see
1440
1398
  // `/__editor/blender-file` in `server/routes/relay.ts` for the
@@ -1464,13 +1422,13 @@ export async function handleBlenderCommand(cmd: {
1464
1422
  // what it can honestly name is this session and the model state it had
1465
1423
  // reached — taken here, at the instant of the listing, rather than
1466
1424
  // asked for afterwards when another call may have moved it.
1467
- const entries = await session.listFiles(string(cmd, 'path'));
1468
- const presented = session.presented;
1425
+ const { entries, document } = await session.listFilesSnapshot(string(cmd, 'path'));
1469
1426
  return {
1470
1427
  ok: true,
1471
1428
  data: {
1472
1429
  entries,
1473
- ...(presented ? { session: presented.session, revision: presented.revision } : {}),
1430
+ session: document.session,
1431
+ revision: document.revision,
1474
1432
  // AND WHAT THE BYTES WERE MADE FROM. A session-written `.glb` is
1475
1433
  // exported from a `.blend`, and that path is the one fact that
1476
1434
  // lets a reader go DOWN by kind later — from a prefab's glTF to
@@ -1488,7 +1446,7 @@ export async function handleBlenderCommand(cmd: {
1488
1446
  // characters — whichever one the tab happened to show — which is
1489
1447
  // worse than no record, because it is a confident wrong answer.
1490
1448
  // `bpy.data.filepath` is what the export actually came out of.
1491
- ...(await sessionDocumentPath(session)),
1449
+ ...sessionDocumentPath(document.filepath),
1492
1450
  },
1493
1451
  };
1494
1452
  }
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.132",
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.130",
76
+ "@volter/editor-threejs": "0.5.193",
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.193",
82
+ "@volter/editor-project": "0.5.193",
83
+ "@volter/editor-sdk": "0.5.193",
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. */