@volter/editor-blender 0.1.22 → 0.1.24
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.
- package/contributions/blender-outliner-authoring.ts +22 -2
- package/contributions/blender-runtime.document.tsx +49 -18
- package/contributions/blender-screenshot.menu.ts +32 -0
- package/host/blender-runtime-host.ts +1 -1
- package/node/blender-mcp.ts +7 -2
- package/package.json +8 -6
- package/src/area-split.ts +1 -0
|
@@ -831,6 +831,26 @@ function py(name: string): string {
|
|
|
831
831
|
return JSON.stringify(name);
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
/** Blender's own name for each operator, as its undo history labels the step (`ot->name`). */
|
|
835
|
+
const operatorNames = new Map<string, string>();
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* THE STEP'S LABEL IS THE OPERATOR'S OWN NAME, the one Blender's Edit menu shows: Add › Cone is
|
|
839
|
+
* "Undo Add Cone", not "Undo Blender Python". Asked of Blender, not kept in a table here, because
|
|
840
|
+
* its names do not follow the menu rows (a Point light is "Add Light").
|
|
841
|
+
*/
|
|
842
|
+
async function operatorLabel(body: string): Promise<string> {
|
|
843
|
+
const id = /bpy\.ops\.([a-z_]+\.[a-z0-9_]+)\(/.exec(body)?.[1];
|
|
844
|
+
if (id === undefined) return 'Blender Python';
|
|
845
|
+
const known = operatorNames.get(id);
|
|
846
|
+
if (known !== undefined) return known;
|
|
847
|
+
const answer = await blenderExecute(`import bpy\nprint(bpy.ops.${id}.get_rna_type().name)`, false);
|
|
848
|
+
const name = answer.error === null ? answer.result.trim() : '';
|
|
849
|
+
if (name === '') return 'Blender Python';
|
|
850
|
+
operatorNames.set(id, name);
|
|
851
|
+
return name;
|
|
852
|
+
}
|
|
853
|
+
|
|
834
854
|
/**
|
|
835
855
|
* RUN ONE OPERATOR AND SAY WHICH OBJECTS IT MADE.
|
|
836
856
|
*
|
|
@@ -849,7 +869,7 @@ function py(name: string): string {
|
|
|
849
869
|
*/
|
|
850
870
|
async function runBlenderOperator(
|
|
851
871
|
body: string,
|
|
852
|
-
label
|
|
872
|
+
label?: string,
|
|
853
873
|
): Promise<{ readonly made: readonly string[]; readonly error: string | null }> {
|
|
854
874
|
const code = [
|
|
855
875
|
'before = {o.name for o in bpy.data.objects}',
|
|
@@ -857,7 +877,7 @@ async function runBlenderOperator(
|
|
|
857
877
|
'made = [o.name for o in bpy.data.objects if o.name not in before]',
|
|
858
878
|
'print("\\n".join(made))',
|
|
859
879
|
].join('\n');
|
|
860
|
-
const answer = await blenderExecute(code, true, label);
|
|
880
|
+
const answer = await blenderExecute(code, true, label ?? (await operatorLabel(body)));
|
|
861
881
|
// THE ENGINE'S REFUSAL, VERBATIM. `session.py::execute` answers with the
|
|
862
882
|
// traceback in `error` rather than raising, and a paraphrase here is how a
|
|
863
883
|
// refusal becomes a shrug.
|
|
@@ -61,6 +61,12 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
|
61
61
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
62
62
|
import { blenderSkin } from './blender-runtime-skin';
|
|
63
63
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
64
|
+
import {
|
|
65
|
+
documentSecondAreaHeader,
|
|
66
|
+
setDocumentSecondArea,
|
|
67
|
+
subscribeDocumentAreas,
|
|
68
|
+
} from '@volter/editor-sdk/kit/document-areas';
|
|
69
|
+
import { createPortal } from 'react-dom';
|
|
64
70
|
import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
65
71
|
import {
|
|
66
72
|
object3DDocumentSession,
|
|
@@ -266,7 +272,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
266
272
|
<BlenderViewportArea {...props} view={view} main />
|
|
267
273
|
</div>
|
|
268
274
|
{second && (
|
|
269
|
-
<div style={
|
|
275
|
+
<div style={area} data-testid="blender-second-area">
|
|
270
276
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
271
277
|
<SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
|
|
272
278
|
</div>
|
|
@@ -281,9 +287,18 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
281
287
|
* header controls its own stage registered, so its shading cells change this area and not the
|
|
282
288
|
* other.
|
|
283
289
|
*/
|
|
284
|
-
/**
|
|
285
|
-
|
|
286
|
-
|
|
290
|
+
/**
|
|
291
|
+
* THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
|
|
292
|
+
*
|
|
293
|
+
* The area is declared to the host as the document's second area
|
|
294
|
+
* (`@volter/editor-sdk/kit/document-areas`). Its HEADER stands in the document's header row over
|
|
295
|
+
* this area: the host reserves this area's width at the row's trailing edge, so the first area's
|
|
296
|
+
* header ends where the first area does, and this renders into the region it draws there: the
|
|
297
|
+
* editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
|
|
298
|
+
* and helpers, the header controls its own stage registered, so its shading cells change this
|
|
299
|
+
* area and not the other. Its SHELF is an element the host fills with its own tool strip: the
|
|
300
|
+
* tools belong to the workspace in Blender, not to an area, so both shelves are the one strip.
|
|
301
|
+
*/
|
|
287
302
|
function SecondAreaChrome({
|
|
288
303
|
documentId,
|
|
289
304
|
areaId,
|
|
@@ -294,24 +309,40 @@ function SecondAreaChrome({
|
|
|
294
309
|
readonly notify: ToolContributionProps['notify'];
|
|
295
310
|
}) {
|
|
296
311
|
useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
|
|
312
|
+
const header = useSyncExternalStore(subscribeDocumentAreas, () => documentSecondAreaHeader(documentId));
|
|
297
313
|
const HeaderControls = documentViewport(areaId)?.HeaderControls;
|
|
314
|
+
const shelfRef = useRef<HTMLDivElement>(null);
|
|
315
|
+
// THE RESERVED WIDTH IS THIS AREA'S: from its stage's left edge (the area bleeds 12 px past its
|
|
316
|
+
// box, as the stage does) to the row's end, kept as the split or the window moves.
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
const shelf = shelfRef.current;
|
|
319
|
+
const area = shelf?.parentElement;
|
|
320
|
+
if (!area) return;
|
|
321
|
+
const measure = (): void =>
|
|
322
|
+
setDocumentSecondArea(documentId, { headerWidth: area.getBoundingClientRect().width + 12, shelf });
|
|
323
|
+
measure();
|
|
324
|
+
const observer = new ResizeObserver(measure);
|
|
325
|
+
observer.observe(area);
|
|
326
|
+
return () => {
|
|
327
|
+
observer.disconnect();
|
|
328
|
+
setDocumentSecondArea(documentId, null);
|
|
329
|
+
};
|
|
330
|
+
}, [documentId]);
|
|
298
331
|
return (
|
|
299
332
|
<>
|
|
300
|
-
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
data-testid={`document-header:${areaId}`}
|
|
304
|
-
style={{ position: 'absolute', top: -12 - AREA_HEADER, height: AREA_HEADER, left: -12, right: -12 }}
|
|
305
|
-
>
|
|
306
|
-
{/* Blender's area header opens on the same editor menus; they act on the document. */}
|
|
307
|
-
<div className="volter-dock-document-toolbar-own">
|
|
333
|
+
<div ref={shelfRef} className="volter-dock-document-shelf" role="toolbar" aria-orientation="vertical" aria-label="Document tools" />
|
|
334
|
+
{header &&
|
|
335
|
+
createPortal(
|
|
336
|
+
<div className="volter-dock-document-toolbar-own" data-testid={`document-header:${areaId}`} style={{ display: 'flex', alignItems: 'center' }}>
|
|
308
337
|
<BlenderObjectModeHeader documentId={documentId} notify={notify} />
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
338
|
+
{HeaderControls && (
|
|
339
|
+
<Suspense fallback={null}>
|
|
340
|
+
<HeaderControls documentId={areaId} />
|
|
341
|
+
</Suspense>
|
|
342
|
+
)}
|
|
343
|
+
</div>,
|
|
344
|
+
header,
|
|
345
|
+
)}
|
|
315
346
|
</>
|
|
316
347
|
);
|
|
317
348
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window ▸ Save Screenshot (Editor)… — Blender's own row (`TOPBAR_MT_window`, `screen.screenshot_area`),
|
|
3
|
+
* the person's door to the photograph an agent takes with `get_viewport_screenshot`.
|
|
4
|
+
*
|
|
5
|
+
* Blender asks for a path in its file browser; the files door has no file picker, so the picture
|
|
6
|
+
* goes to the project's `screenshots/` folder and the console says which file it is. Like every
|
|
7
|
+
* photograph of the document it flashes the document and shows itself in the corner.
|
|
8
|
+
*/
|
|
9
|
+
import type { MenuContribution } from '@volter/editor-sdk/chrome';
|
|
10
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
11
|
+
import { announcePhotograph } from '@volter/editor-sdk/kit/photograph-notice';
|
|
12
|
+
|
|
13
|
+
export const point = 'workspace.menu';
|
|
14
|
+
|
|
15
|
+
export const menu: MenuContribution = {
|
|
16
|
+
menu: 'window',
|
|
17
|
+
items: [
|
|
18
|
+
{
|
|
19
|
+
id: 'blender-save-screenshot-editor',
|
|
20
|
+
label: 'Save Screenshot (Editor)…',
|
|
21
|
+
testId: 'menu-save-screenshot-editor',
|
|
22
|
+
execute: async () => {
|
|
23
|
+
const host = editorHost();
|
|
24
|
+
const capture = await host.documents.captureActive();
|
|
25
|
+
announcePhotograph(capture.base64);
|
|
26
|
+
const path = `screenshots/screenshot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
27
|
+
await host.files.write(path, Uint8Array.from(atob(capture.base64), (c) => c.charCodeAt(0)));
|
|
28
|
+
host.console.log(`Saved the screenshot to ${path}.`, 'blender-screenshot');
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
@@ -1033,7 +1033,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1033
1033
|
// resolve, and `blender_tools.py` deliberately keeps a failing SCRIPT's
|
|
1034
1034
|
// traceback out of it.
|
|
1035
1035
|
// biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
|
|
1036
|
-
log: (level, text) =>
|
|
1036
|
+
log: (level, text) => console[level](`[blender] ${text}`),
|
|
1037
1037
|
});
|
|
1038
1038
|
const session = runtime;
|
|
1039
1039
|
// MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
|
package/node/blender-mcp.ts
CHANGED
|
@@ -300,9 +300,14 @@ export async function serveBlenderMcp(
|
|
|
300
300
|
command: string,
|
|
301
301
|
): Promise<void> {
|
|
302
302
|
const runtimeIdentity = `Blender ${blenderBundle.blender} (source ${blenderBundle.source}), compiled to WebAssembly and running headless in the editor tab; three.js takes its photographs. Factory bpy.context.scene.render.engine: '${blenderBundle.factoryEngine}'. Documents are .blend. save_as_mainfile records the actual saved path in bpy.data.filepath; open_mainfile reopens it.`;
|
|
303
|
+
// THE PHOTOGRAPH STAYS IN THE EDITOR. An agent asked to "take a photograph" with no word on where
|
|
304
|
+
// it goes rendered one, wrote it into the project and ran the OS `open` on it: Preview started
|
|
305
|
+
// outside the editor. The editor already shows a picture as one of its documents.
|
|
306
|
+
const photographs = `A photograph is shown to the person in the editor, never in another app: get_viewport_screenshot returns one, and an image file written inside the project opens as an editor document with \`npx ${command} eval 'editor.openAsset("<project-relative path>")'\`. Do not run the OS \`open\` on it.`;
|
|
307
|
+
const instructions = `${runtimeIdentity}\n${photographs}`;
|
|
303
308
|
const tools = (blenderTools as ToolShape[]).map((tool) =>
|
|
304
309
|
tool.name === 'execute_blender_code'
|
|
305
|
-
? { ...tool, description: `${
|
|
310
|
+
? { ...tool, description: `${instructions}\n${tool.description}` }
|
|
306
311
|
: tool,
|
|
307
312
|
);
|
|
308
313
|
const defaults = blenderDefaults as Record<string, string>;
|
|
@@ -317,7 +322,7 @@ export async function serveBlenderMcp(
|
|
|
317
322
|
{ name: 'BlenderMCP', version: BLENDER_MCP_VERSION },
|
|
318
323
|
{
|
|
319
324
|
capabilities: { tools: {}, prompts: {} },
|
|
320
|
-
instructions
|
|
325
|
+
instructions,
|
|
321
326
|
},
|
|
322
327
|
);
|
|
323
328
|
|
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.24",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"./contributions/blender-timeline.document.tsx",
|
|
37
37
|
"./contributions/blender-uv-editor.document.tsx",
|
|
38
38
|
"./contributions/blender-runtime.document.tsx",
|
|
39
|
+
"./contributions/blender-screenshot.menu.ts",
|
|
39
40
|
"./contributions/blender-version.status.tsx",
|
|
40
41
|
"./contributions/blender.command.ts",
|
|
41
42
|
"./contributions/blender.environment.ts",
|
|
@@ -69,15 +70,16 @@
|
|
|
69
70
|
},
|
|
70
71
|
"dependencies": {
|
|
71
72
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
72
|
-
"@volter/blender-engine": "0.1.
|
|
73
|
-
"@volter/editor-threejs": "0.5.
|
|
73
|
+
"@volter/blender-engine": "0.1.22",
|
|
74
|
+
"@volter/editor-threejs": "0.5.84",
|
|
74
75
|
"zod": "^4.3.6"
|
|
75
76
|
},
|
|
76
77
|
"peerDependencies": {
|
|
77
|
-
"@volter/editor-
|
|
78
|
-
"@volter/editor-
|
|
79
|
-
"@volter/editor-
|
|
78
|
+
"@volter/editor-live": "0.5.84",
|
|
79
|
+
"@volter/editor-project": "0.5.84",
|
|
80
|
+
"@volter/editor-sdk": "0.5.84",
|
|
80
81
|
"react": "^19.0.0",
|
|
82
|
+
"react-dom": "^19.0.0",
|
|
81
83
|
"three": "^0.180.0",
|
|
82
84
|
"vite": "^6.4.3"
|
|
83
85
|
},
|
package/src/area-split.ts
CHANGED