@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),
|
package/dist-node/serving.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
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 {
|
|
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.
|
|
517
|
-
*
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
...
|
|
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.
|
|
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.
|
|
76
|
-
"@volter/editor-threejs": "0.5.
|
|
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.
|
|
82
|
-
"@volter/editor-project": "0.5.
|
|
83
|
-
"@volter/editor-sdk": "0.5.
|
|
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
|
-
|
|
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. */
|