@volter/editor-blender 0.1.21 → 0.1.23
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-header-menus.tsx +13 -14
- package/contributions/blender-outliner-authoring.ts +49 -17
- package/contributions/blender-outliner-model.ts +1 -1
- package/contributions/blender-runtime.document.tsx +250 -75
- package/contributions/blender-screenshot.menu.ts +32 -0
- package/host/blender-runtime-host.ts +3 -123
- package/node/blender-mcp.ts +7 -2
- package/package.json +8 -6
- package/src/{render-view-split.ts → area-split.ts} +11 -10
- package/contributions/blender-render-view.tsx +0 -151
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
* with a 3D session, which is exactly where Blender's viewport header carries them too
|
|
17
17
|
* (`DocumentHeaderStrip`'s own note), so repeating them here would be the same control twice in
|
|
18
18
|
* one row. What View owns that no other control does is **Area** (`VIEW3D_MT_view`'s
|
|
19
|
-
* `layout.menu("INFO_MT_area")`, `space_view3d.py:1520`; `space_info.py`'s
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* rows of `INFO_MT_area` (Horizontal Split, the new-window, maximize and fullscreen toggles) and
|
|
19
|
+
* `layout.menu("INFO_MT_area")`, `space_view3d.py:1520`; `space_info.py`'s `INFO_MT_area`): the
|
|
20
|
+
* split that puts a second area beside this one. Blender's standard way to watch the render while
|
|
21
|
+
* modeling is exactly that — split the viewport and set the new area to Rendered shading through
|
|
22
|
+
* the camera — so Vertical Split opens a second 3D viewport beside the model, set up that way
|
|
23
|
+
* (`blender-runtime.document.tsx`), and Close Area closes it. The other rows of `INFO_MT_area` (Horizontal Split, the new-window, maximize and fullscreen toggles) and
|
|
25
24
|
* the rest of View — the sidebar and tool-shelf toggles, local view, the view-point and view-axis
|
|
26
25
|
* submenus, Frame Selected, Viewport Render — are not drawn.
|
|
27
26
|
*
|
|
@@ -58,7 +57,7 @@ import {
|
|
|
58
57
|
runBlenderObjectOperator,
|
|
59
58
|
} from './blender-outliner-authoring';
|
|
60
59
|
import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
|
|
61
|
-
import {
|
|
60
|
+
import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
62
61
|
|
|
63
62
|
type MenuId = 'view' | 'select' | 'add' | 'object';
|
|
64
63
|
|
|
@@ -266,8 +265,8 @@ export function BlenderObjectModeHeader({
|
|
|
266
265
|
const addRef = useRef<HTMLDivElement>(null);
|
|
267
266
|
const objectRef = useRef<HTMLDivElement>(null);
|
|
268
267
|
const split = useSyncExternalStore(
|
|
269
|
-
|
|
270
|
-
() => (documentId === undefined ? false :
|
|
268
|
+
subscribeAreaSplit,
|
|
269
|
+
() => (documentId === undefined ? false : areaSplit(documentId)),
|
|
271
270
|
() => false,
|
|
272
271
|
);
|
|
273
272
|
const handle = blenderOutlinerHandle(documentId);
|
|
@@ -350,17 +349,17 @@ export function BlenderObjectModeHeader({
|
|
|
350
349
|
data-testid="blender-object-mode-menus"
|
|
351
350
|
ref={rootRef}
|
|
352
351
|
>
|
|
353
|
-
{/* VIEW ▸ AREA — `INFO_MT_area`'s split and close, for the
|
|
354
|
-
(this file's header). */}
|
|
352
|
+
{/* VIEW ▸ AREA — `INFO_MT_area`'s split and close, for the second 3D viewport beside the
|
|
353
|
+
model (this file's header). */}
|
|
355
354
|
<MenuWord id="view" open={open} setOpen={setOpen} anchorRef={viewRef}>
|
|
356
355
|
<MenuSubmenu label="Area" data-testid="blender-view-area">
|
|
357
356
|
<MenuItem
|
|
358
357
|
data-testid="blender-area-vertical-split"
|
|
359
358
|
disabled={split || documentId === undefined}
|
|
360
|
-
title={split ? '
|
|
359
|
+
title={split ? 'This area is already split.' : undefined}
|
|
361
360
|
onSelect={() => {
|
|
362
361
|
setOpen(null);
|
|
363
|
-
if (documentId !== undefined)
|
|
362
|
+
if (documentId !== undefined) setAreaSplit(documentId, true);
|
|
364
363
|
}}
|
|
365
364
|
>
|
|
366
365
|
Vertical Split
|
|
@@ -372,7 +371,7 @@ export function BlenderObjectModeHeader({
|
|
|
372
371
|
title={split ? undefined : 'There is no second area to close.'}
|
|
373
372
|
onSelect={() => {
|
|
374
373
|
setOpen(null);
|
|
375
|
-
if (documentId !== undefined)
|
|
374
|
+
if (documentId !== undefined) setAreaSplit(documentId, false);
|
|
376
375
|
}}
|
|
377
376
|
>
|
|
378
377
|
Close Area
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
blenderOutlinerVersion,
|
|
84
84
|
blenderPresentedView,
|
|
85
85
|
onBlenderFrame,
|
|
86
|
+
type PresentedView,
|
|
86
87
|
refreshBlenderOutliner,
|
|
87
88
|
showBlenderOutliner,
|
|
88
89
|
subscribeBlenderOutliner,
|
|
@@ -219,18 +220,19 @@ export interface BlenderOutlinerHandle {
|
|
|
219
220
|
const liveOutliners = new Set<BlenderOutlinerHandle>();
|
|
220
221
|
|
|
221
222
|
/**
|
|
222
|
-
* The Outliner handle for a document, for the header that draws over it
|
|
223
|
+
* The Outliner handle for a document, for the header that draws over it: the one its own stage
|
|
224
|
+
* made (a stage's adapter names its document `object3d-document:<id>`).
|
|
223
225
|
*
|
|
224
|
-
* The fallback when no handle names that document is
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* answers null rather than guessing.
|
|
226
|
+
* The fallback when no handle names that document is ANY live one, and it is sound rather than
|
|
227
|
+
* lax: the Blender engine is one session bound to one `.blend` at a time
|
|
228
|
+
* (`blender-runtime-host.ts`'s `bindModelDocument`), so every live handle is an Outliner of that
|
|
229
|
+
* one engine's tree, whether it is the Model document's area or the area split beside it
|
|
230
|
+
* (`blender-runtime.document.tsx`), and a header's menus act on the engine by name.
|
|
230
231
|
*/
|
|
231
232
|
export function blenderOutlinerHandle(documentId?: string): BlenderOutlinerHandle | null {
|
|
232
|
-
for (const handle of liveOutliners)
|
|
233
|
-
|
|
233
|
+
for (const handle of liveOutliners)
|
|
234
|
+
if (handle.documentId === documentId || handle.documentId === `object3d-document:${documentId}`) return handle;
|
|
235
|
+
return liveOutliners.values().next().value ?? null;
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
/** The transform of a subject this adapter does not own. `TransformProvider.get`
|
|
@@ -829,6 +831,26 @@ function py(name: string): string {
|
|
|
829
831
|
return JSON.stringify(name);
|
|
830
832
|
}
|
|
831
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
|
+
|
|
832
854
|
/**
|
|
833
855
|
* RUN ONE OPERATOR AND SAY WHICH OBJECTS IT MADE.
|
|
834
856
|
*
|
|
@@ -847,7 +869,7 @@ function py(name: string): string {
|
|
|
847
869
|
*/
|
|
848
870
|
async function runBlenderOperator(
|
|
849
871
|
body: string,
|
|
850
|
-
label
|
|
872
|
+
label?: string,
|
|
851
873
|
): Promise<{ readonly made: readonly string[]; readonly error: string | null }> {
|
|
852
874
|
const code = [
|
|
853
875
|
'before = {o.name for o in bpy.data.objects}',
|
|
@@ -855,7 +877,7 @@ async function runBlenderOperator(
|
|
|
855
877
|
'made = [o.name for o in bpy.data.objects if o.name not in before]',
|
|
856
878
|
'print("\\n".join(made))',
|
|
857
879
|
].join('\n');
|
|
858
|
-
const answer = await blenderExecute(code, true, label);
|
|
880
|
+
const answer = await blenderExecute(code, true, label ?? (await operatorLabel(body)));
|
|
859
881
|
// THE ENGINE'S REFUSAL, VERBATIM. `session.py::execute` answers with the
|
|
860
882
|
// traceback in `error` rather than raising, and a paraphrase here is how a
|
|
861
883
|
// refusal becomes a shrug.
|
|
@@ -971,9 +993,16 @@ const EXCLUDED_REASON =
|
|
|
971
993
|
'This collection is excluded from the view layer (LayerCollection.exclude), so its viewport ' +
|
|
972
994
|
'visibility is not what is hiding it. Clear the Exclude checkbox on this row first.';
|
|
973
995
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
996
|
+
/**
|
|
997
|
+
* THE OUTLINER AUTHORING FOR A STAGE DRAWING `presented`: the rows are Blender's either way, and
|
|
998
|
+
* the view is where a row meets the three object its stage picks, outlines and moves. The Model
|
|
999
|
+
* document's own area draws the published view ({@link createBlenderOutlinerAuthoring}); a split
|
|
1000
|
+
* area draws a follower of it (`BlenderRuntimeView.follow`, `blender-render-view.tsx`).
|
|
1001
|
+
*/
|
|
1002
|
+
export function blenderOutlinerAuthoringFor(
|
|
1003
|
+
presented: () => PresentedView | null,
|
|
1004
|
+
): ToolObject3DDocumentAuthoringFactory {
|
|
1005
|
+
return ({ defaultAdapter }) => {
|
|
977
1006
|
const listeners = new Set<() => void>();
|
|
978
1007
|
/**
|
|
979
1008
|
* THE ENGINE'S SELECTION, CACHED IN ROW IDS — never a second selection.
|
|
@@ -1008,7 +1037,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
|
|
|
1008
1037
|
* spaces meet (see the header). */
|
|
1009
1038
|
const objectForRow = (id: string): THREE.Object3D | null => {
|
|
1010
1039
|
const row = rows().get(id);
|
|
1011
|
-
const view =
|
|
1040
|
+
const view = presented();
|
|
1012
1041
|
if (row === undefined || view === null) return null;
|
|
1013
1042
|
if (row.type === 'TSE_VIEW_COLLECTION_BASE') return view.root;
|
|
1014
1043
|
return row.object === undefined ? null : view.objectForBlenderName(row.object);
|
|
@@ -1017,7 +1046,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
|
|
|
1017
1046
|
/** The row for a presented OBJECT: its address is the object's own, so this
|
|
1018
1047
|
* is a map lookup rather than a walk. */
|
|
1019
1048
|
const rowIdForObject = (object: THREE.Object3D): string | null => {
|
|
1020
|
-
const view =
|
|
1049
|
+
const view = presented();
|
|
1021
1050
|
if (view === null) return null;
|
|
1022
1051
|
for (const row of rows().values())
|
|
1023
1052
|
if (isObjectRow(row) && view.objectForBlenderName(row.object) === object) return row.id;
|
|
@@ -1403,7 +1432,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
|
|
|
1403
1432
|
endEdit: async (id): Promise<WriteAck | undefined> => {
|
|
1404
1433
|
const object = gestureObjects.get(id) ?? objectFor(id);
|
|
1405
1434
|
gestureObjects.delete(id);
|
|
1406
|
-
const view =
|
|
1435
|
+
const view = presented();
|
|
1407
1436
|
if (object === null) return undefined;
|
|
1408
1437
|
const started = gestureStart.get(object);
|
|
1409
1438
|
gestureStart.delete(object);
|
|
@@ -1748,3 +1777,6 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
|
|
|
1748
1777
|
},
|
|
1749
1778
|
};
|
|
1750
1779
|
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
export const createBlenderOutlinerAuthoring = blenderOutlinerAuthoringFor(blenderPresentedView);
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
* typed for the same reason `blender-properties-model.ts` narrows it: the
|
|
29
29
|
* document that published it and the reader are two modules meeting over one
|
|
30
30
|
* object, and `@volter/editor-blender` holds no host import. */
|
|
31
|
-
interface PresentedView {
|
|
31
|
+
export interface PresentedView {
|
|
32
32
|
readonly root: THREE.Object3D;
|
|
33
33
|
subscribeFrames(listener: () => void): () => void;
|
|
34
34
|
objectForBlenderName(name: string): THREE.Object3D | null;
|
|
@@ -26,19 +26,31 @@
|
|
|
26
26
|
|
|
27
27
|
// How the `model` stage this document builds behaves (its starting presentation).
|
|
28
28
|
import { blenderViewFieldOfView } from '../src/presentation';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type BlenderRuntimeView,
|
|
31
|
+
blenderModelView,
|
|
32
|
+
} from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
30
33
|
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
|
|
31
34
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
35
|
+
import {
|
|
36
|
+
documentViewport,
|
|
37
|
+
documentViewportsVersion,
|
|
38
|
+
subscribeDocumentViewports,
|
|
39
|
+
} from '@volter/editor-sdk/kit/document-viewports';
|
|
32
40
|
import {
|
|
33
41
|
DOCUMENT_STUDIO_PRESET,
|
|
42
|
+
setViewPresentation,
|
|
34
43
|
subscribeViewportPresentation,
|
|
35
44
|
viewPresentation,
|
|
45
|
+
viewPresentationSnapshot,
|
|
36
46
|
} from '@volter/editor-sdk/kit/viewport-presentation';
|
|
37
47
|
import {
|
|
38
48
|
type MouseEvent as ReactMouseEvent,
|
|
39
49
|
type PointerEvent as ReactPointerEvent,
|
|
50
|
+
Suspense,
|
|
40
51
|
useCallback,
|
|
41
52
|
useEffect,
|
|
53
|
+
useMemo,
|
|
42
54
|
useRef,
|
|
43
55
|
useState,
|
|
44
56
|
useSyncExternalStore,
|
|
@@ -46,11 +58,21 @@ import {
|
|
|
46
58
|
import * as THREE from 'three';
|
|
47
59
|
import { bindModelDocument, blenderExecute, openModelDocumentBlend } from '../host/blender-runtime-host';
|
|
48
60
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
49
|
-
import { createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
61
|
+
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
50
62
|
import { blenderSkin } from './blender-runtime-skin';
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
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';
|
|
53
70
|
import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
71
|
+
import {
|
|
72
|
+
object3DDocumentSession,
|
|
73
|
+
subscribeObject3DDocumentSessions,
|
|
74
|
+
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
75
|
+
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
54
76
|
|
|
55
77
|
export const point = 'workspace.document';
|
|
56
78
|
export const title = 'Blender Model';
|
|
@@ -96,51 +118,68 @@ export const inspectorBuiltins: readonly string[] = [];
|
|
|
96
118
|
// Timeline binds a skeleton into the same presented graph and there is exactly
|
|
97
119
|
// one set of presented objects (`blender-runtime-skin.ts`).
|
|
98
120
|
const view = blenderModelView;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
type AreaView = BlenderRuntimeView;
|
|
122
|
+
|
|
123
|
+
/** What an area's stage reads from the view it draws, made once per view. */
|
|
124
|
+
interface AreaReads {
|
|
125
|
+
readonly subject: () => string | null;
|
|
126
|
+
readonly gridScale: (worldPerDevicePixel: number) => string | null;
|
|
127
|
+
readonly cameraView: NonNullable<ToolObject3DAuthoringProps['cameraView']>;
|
|
128
|
+
}
|
|
129
|
+
const areaReads = new WeakMap<AreaView, AreaReads>();
|
|
130
|
+
function readsOf(view: AreaView): AreaReads {
|
|
131
|
+
let reads = areaReads.get(view);
|
|
132
|
+
if (reads) return reads;
|
|
133
|
+
reads = {
|
|
134
|
+
/** THE SUBJECT LINE'S FRAME IS THE ONE ON SCREEN: the skin's playhead, which during playback
|
|
135
|
+
* runs ahead of `frame_current` (written once, on pause), as Blender's own `(frame)` does. */
|
|
136
|
+
subject: () => {
|
|
137
|
+
const playhead = blenderSkin.playhead();
|
|
138
|
+
return view.subjectLine(playhead === null ? null : Math.floor(playhead));
|
|
139
|
+
},
|
|
140
|
+
gridScale: (worldPerDevicePixel: number) => view.gridUnitName(worldPerDevicePixel),
|
|
141
|
+
/** BLENDER'S CAMERA VIEW (`blender-runtime-camera-view.ts`): which camera, and how it fits. */
|
|
142
|
+
cameraView: {
|
|
143
|
+
camera: () => view.cameraViewCamera(),
|
|
144
|
+
zoom: view.cameraViewZoom,
|
|
145
|
+
pan: view.cameraViewPan,
|
|
146
|
+
showing: (camera: string | null) => view.setCameraViewShowing(camera),
|
|
147
|
+
// A LOCKED camera view moves the camera (`ED_view3d_camera_lock_sync`: its scale kept); the
|
|
148
|
+
// navigation's end is the one step Blender's history records.
|
|
149
|
+
setPose: (
|
|
150
|
+
camera: string,
|
|
151
|
+
position: readonly [number, number, number],
|
|
152
|
+
quaternion: readonly [number, number, number, number],
|
|
153
|
+
final: boolean,
|
|
154
|
+
) => {
|
|
155
|
+
const { location, rotation } = view.blenderPose(position, quaternion);
|
|
156
|
+
return blenderExecute(
|
|
157
|
+
'from mathutils import Matrix, Quaternion, Vector\n' +
|
|
158
|
+
`import bpy\nobject = bpy.data.objects[${JSON.stringify(camera)}]\n` +
|
|
159
|
+
`object.matrix_world = Matrix.LocRotScale(Vector((${location.join(', ')})), ` +
|
|
160
|
+
`Quaternion((${rotation.join(', ')})), object.matrix_world.to_scale())`,
|
|
161
|
+
final,
|
|
162
|
+
'Lock Camera to View',
|
|
163
|
+
).then(
|
|
164
|
+
(answer) => {
|
|
165
|
+
if (answer.error !== null)
|
|
166
|
+
editorHost().console.error(`Blender refused the camera's pose: ${answer.error}`, 'blender-camera');
|
|
167
|
+
},
|
|
168
|
+
(error: unknown) =>
|
|
169
|
+
editorHost().console.error(`The camera's pose was not written to Blender: ${String(error)}`, 'blender-camera'),
|
|
170
|
+
);
|
|
132
171
|
},
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
172
|
+
view: (
|
|
173
|
+
camera: string,
|
|
174
|
+
region: { readonly width: number; readonly height: number },
|
|
175
|
+
zoom: number,
|
|
176
|
+
offset: readonly [number, number],
|
|
177
|
+
) => view.cameraView(camera, region, zoom, offset),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
areaReads.set(view, reads);
|
|
181
|
+
return reads;
|
|
182
|
+
}
|
|
144
183
|
|
|
145
184
|
export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
146
185
|
const { active, document, documentId, notify, publishContext } = props;
|
|
@@ -190,12 +229,133 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
190
229
|
return <BlenderModelViewport {...props} />;
|
|
191
230
|
}
|
|
192
231
|
|
|
193
|
-
|
|
232
|
+
/**
|
|
233
|
+
* THE MODEL DOCUMENT'S AREA: one 3D viewport, and the whole screen unless the area is split
|
|
234
|
+
* (View ▸ Area ▸ Vertical Split, `src/area-split.ts`). Split, the second area is the same
|
|
235
|
+
* viewport over a follower of the presented view (`BlenderRuntimeView.follow`), opened as
|
|
236
|
+
* Blender's standard always-on render preview is set up: Rendered shading, through the scene
|
|
237
|
+
* camera. Each area has its own stage, shading, navigation and camera view; the model, its
|
|
238
|
+
* selection and its history are the document's.
|
|
239
|
+
*/
|
|
240
|
+
function BlenderModelViewport(props: ToolContributionProps) {
|
|
241
|
+
const { documentId } = props;
|
|
242
|
+
const split = useSyncExternalStore(
|
|
243
|
+
subscribeAreaSplit,
|
|
244
|
+
() => (documentId ? areaSplit(documentId) : false),
|
|
245
|
+
() => false,
|
|
246
|
+
);
|
|
247
|
+
const [follower, setFollower] = useState<ReturnType<AreaView['follow']> | null>(null);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!split || !documentId) return;
|
|
250
|
+
// The area's shading is its CHOICE, the door a shading cell uses, made before its stage binds
|
|
251
|
+
// so the stage's session takes it; an area whose shading was already chosen keeps it.
|
|
252
|
+
const secondId = `${documentId}#area-2`;
|
|
253
|
+
if (viewPresentationSnapshot(secondId).drawMode === undefined)
|
|
254
|
+
setViewPresentation(secondId, { drawMode: 'rendered' });
|
|
255
|
+
const second = view.follow();
|
|
256
|
+
setFollower(second);
|
|
257
|
+
return () => {
|
|
258
|
+
setFollower(null);
|
|
259
|
+
second.dispose();
|
|
260
|
+
};
|
|
261
|
+
}, [split, documentId]);
|
|
262
|
+
if (!documentId) return null;
|
|
263
|
+
const second = split ? follower : null;
|
|
264
|
+
// SPLIT, THE TWO AREAS SHARE THE SLOT the stage alone fills otherwise: each stage's root bleeds
|
|
265
|
+
// 12 px past its positioned parent (`inset: -12px`), so each area is that parent inset by 12,
|
|
266
|
+
// and the two stand 2 px apart, Blender's gap between areas. Unsplit, both wrappers are
|
|
267
|
+
// `contents` and the one stage is placed as it always was.
|
|
268
|
+
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
269
|
+
return (
|
|
270
|
+
<div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
271
|
+
<div style={second ? area : { display: 'contents' }}>
|
|
272
|
+
<BlenderViewportArea {...props} view={view} main />
|
|
273
|
+
</div>
|
|
274
|
+
{second && (
|
|
275
|
+
<div style={area} data-testid="blender-second-area">
|
|
276
|
+
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
277
|
+
<SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* THE SECOND AREA'S OWN HEADER, as each of Blender's areas has one: the editor menus (their
|
|
286
|
+
* View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading and helpers, the
|
|
287
|
+
* header controls its own stage registered, so its shading cells change this area and not the
|
|
288
|
+
* other.
|
|
289
|
+
*/
|
|
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
|
+
*/
|
|
302
|
+
function SecondAreaChrome({
|
|
303
|
+
documentId,
|
|
304
|
+
areaId,
|
|
305
|
+
notify,
|
|
306
|
+
}: {
|
|
307
|
+
readonly documentId: string;
|
|
308
|
+
readonly areaId: string;
|
|
309
|
+
readonly notify: ToolContributionProps['notify'];
|
|
310
|
+
}) {
|
|
311
|
+
useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
|
|
312
|
+
const header = useSyncExternalStore(subscribeDocumentAreas, () => documentSecondAreaHeader(documentId));
|
|
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]);
|
|
331
|
+
return (
|
|
332
|
+
<>
|
|
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' }}>
|
|
337
|
+
<BlenderObjectModeHeader documentId={documentId} notify={notify} />
|
|
338
|
+
{HeaderControls && (
|
|
339
|
+
<Suspense fallback={null}>
|
|
340
|
+
<HeaderControls documentId={areaId} />
|
|
341
|
+
</Suspense>
|
|
342
|
+
)}
|
|
343
|
+
</div>,
|
|
344
|
+
header,
|
|
345
|
+
)}
|
|
346
|
+
</>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function BlenderViewportArea({
|
|
194
351
|
active,
|
|
195
352
|
document,
|
|
196
353
|
documentId,
|
|
197
354
|
surfaces,
|
|
198
|
-
|
|
355
|
+
view,
|
|
356
|
+
main,
|
|
357
|
+
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
|
|
358
|
+
const reads = readsOf(view);
|
|
199
359
|
const build = useCallback(
|
|
200
360
|
() => ({
|
|
201
361
|
root: view.root,
|
|
@@ -211,7 +371,7 @@ function BlenderModelViewport({
|
|
|
211
371
|
},
|
|
212
372
|
dispose() {},
|
|
213
373
|
}),
|
|
214
|
-
[],
|
|
374
|
+
[view],
|
|
215
375
|
);
|
|
216
376
|
const blend = document?.source?.path;
|
|
217
377
|
// Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
|
|
@@ -235,15 +395,38 @@ function BlenderModelViewport({
|
|
|
235
395
|
stopFrame?.();
|
|
236
396
|
};
|
|
237
397
|
},
|
|
238
|
-
[documentId],
|
|
398
|
+
[documentId, view],
|
|
239
399
|
);
|
|
240
|
-
const subject = useSyncExternalStore(subscribeSubject,
|
|
241
|
-
//
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
() => false,
|
|
400
|
+
const subject = useSyncExternalStore(subscribeSubject, reads.subject);
|
|
401
|
+
// THE SECOND AREA'S ROWS MEET ITS OWN GRAPH: the same Outliner, over the view this stage draws.
|
|
402
|
+
const authoring = useMemo(
|
|
403
|
+
() => (main ? createBlenderOutlinerAuthoring : blenderOutlinerAuthoringFor(() => view)),
|
|
404
|
+
[main, view],
|
|
246
405
|
);
|
|
406
|
+
/**
|
|
407
|
+
* THE SECOND AREA OPENS AS BLENDER'S RENDER PREVIEW IS SET UP: Rendered shading (chosen as the
|
|
408
|
+
* area is made, `BlenderModelViewport`) and looking through the scene camera
|
|
409
|
+
* (`view3d.view_camera`), entered once the stage stands and there is a camera to look through.
|
|
410
|
+
* Afterwards it is an ordinary area: leaving the camera view or changing the shading is the
|
|
411
|
+
* person's.
|
|
412
|
+
*/
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (main || !documentId) return;
|
|
415
|
+
let entered = false;
|
|
416
|
+
const enter = (): void => {
|
|
417
|
+
if (entered) return;
|
|
418
|
+
const session = object3DDocumentSession(documentId);
|
|
419
|
+
if (session === null || view.cameraViewCamera() === null) return;
|
|
420
|
+
entered = session.toggleCameraView();
|
|
421
|
+
};
|
|
422
|
+
enter();
|
|
423
|
+
const stopSessions = subscribeObject3DDocumentSessions(enter);
|
|
424
|
+
const stopFrames = view.subscribeFrames(enter);
|
|
425
|
+
return () => {
|
|
426
|
+
stopSessions();
|
|
427
|
+
stopFrames();
|
|
428
|
+
};
|
|
429
|
+
}, [main, documentId, view]);
|
|
247
430
|
/**
|
|
248
431
|
* THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
|
|
249
432
|
* (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
|
|
@@ -273,7 +456,7 @@ function BlenderModelViewport({
|
|
|
273
456
|
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
274
457
|
for (const { kind } of groups) stage?.setHelper(kind, null);
|
|
275
458
|
};
|
|
276
|
-
}, [documentId]);
|
|
459
|
+
}, [documentId, view]);
|
|
277
460
|
/**
|
|
278
461
|
* ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
|
|
279
462
|
*
|
|
@@ -286,7 +469,8 @@ function BlenderModelViewport({
|
|
|
286
469
|
* notification rather than assuming an order.
|
|
287
470
|
*/
|
|
288
471
|
useEffect(() => {
|
|
289
|
-
|
|
472
|
+
// The skin poses the presented view's graph; a follower's has no skeleton bound.
|
|
473
|
+
if (!documentId || !main) return;
|
|
290
474
|
const { transport } = editorHost();
|
|
291
475
|
let detach: (() => void) | null = null;
|
|
292
476
|
const attach = (): void => {
|
|
@@ -301,7 +485,7 @@ function BlenderModelViewport({
|
|
|
301
485
|
stop();
|
|
302
486
|
detach?.();
|
|
303
487
|
};
|
|
304
|
-
}, [documentId]);
|
|
488
|
+
}, [documentId, main]);
|
|
305
489
|
/**
|
|
306
490
|
* BLENDER'S RENDERED SHADING IS THE SCENE'S OWN LIGHT. When this stage's view lights by the
|
|
307
491
|
* `scene` (the Rendered shading cell, or the Lighting row's Scene), the presenter holds the viewport
|
|
@@ -343,7 +527,7 @@ function BlenderModelViewport({
|
|
|
343
527
|
view.holdRendered(null);
|
|
344
528
|
view.setWorkbench(false);
|
|
345
529
|
};
|
|
346
|
-
}, [documentId]);
|
|
530
|
+
}, [documentId, view]);
|
|
347
531
|
/**
|
|
348
532
|
* SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
|
|
349
533
|
* `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
|
|
@@ -403,15 +587,8 @@ function BlenderModelViewport({
|
|
|
403
587
|
if (!documentId) return null;
|
|
404
588
|
const Surface = surfaces.Object3DAuthoring;
|
|
405
589
|
return (
|
|
406
|
-
// SPLIT, THE TWO AREAS SHARE THE SLOT the stage alone fills otherwise: the stage's own root
|
|
407
|
-
// bleeds 12 px past its positioned parent (`inset: -12px`), so the modeling area is that
|
|
408
|
-
// parent inset by 12 and the Render view starts 2 px past the stage's bleed, Blender's gap
|
|
409
|
-
// between areas. Unsplit, both wrappers are `contents` and the stage is placed as before.
|
|
410
590
|
<div
|
|
411
|
-
style={
|
|
412
|
-
>
|
|
413
|
-
<div
|
|
414
|
-
style={split ? { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } : { display: 'contents' }}
|
|
591
|
+
style={{ display: 'contents' }}
|
|
415
592
|
onPointerDownCapture={onPointerDownCapture}
|
|
416
593
|
onPointerUpCapture={onPointerUpCapture}
|
|
417
594
|
onPointerCancelCapture={dropCursorPress}
|
|
@@ -426,8 +603,8 @@ function BlenderModelViewport({
|
|
|
426
603
|
// THE OVERLAY'S SUBJECT AND GRID LINES ARE BLENDER'S: the engine composes the subject from
|
|
427
604
|
// the scene (`session.py` `_subject_line`) and names the grid's step in the scene's units.
|
|
428
605
|
{...(subject === null ? {} : { subject })}
|
|
429
|
-
gridScale={gridScale}
|
|
430
|
-
cameraView={cameraView}
|
|
606
|
+
gridScale={reads.gridScale}
|
|
607
|
+
cameraView={reads.cameraView}
|
|
431
608
|
build={build}
|
|
432
609
|
audit={false}
|
|
433
610
|
// ON A MODEL DOCUMENT THE HIERARCHY IS BLENDER'S OUTLINER, for the same
|
|
@@ -438,7 +615,7 @@ function BlenderModelViewport({
|
|
|
438
615
|
// provider; the default adapter it delegates to still answers everything
|
|
439
616
|
// about the presentation (WORK.md §Blender in the tab is Blender,
|
|
440
617
|
// "Inspection parity", I3).
|
|
441
|
-
authoring={
|
|
618
|
+
authoring={authoring}
|
|
442
619
|
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
443
620
|
// FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
|
|
444
621
|
// three fits.
|
|
@@ -477,7 +654,5 @@ function BlenderModelViewport({
|
|
|
477
654
|
}}
|
|
478
655
|
/>
|
|
479
656
|
</div>
|
|
480
|
-
{split && <BlenderRenderView />}
|
|
481
|
-
</div>
|
|
482
657
|
);
|
|
483
658
|
}
|
|
@@ -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
|
+
};
|
|
@@ -192,9 +192,8 @@ async function displayPhotograph(
|
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
194
|
* THE PHOTOGRAPH ITSELF: a prepared snapshot, through `renderCamera`, resolved by the scene's own
|
|
195
|
-
* view transform. `
|
|
196
|
-
*
|
|
197
|
-
* `assertCurrent` throws when the caller's snapshot went stale while the lighting was prepared.
|
|
195
|
+
* view transform. `assertCurrent` throws when the caller's snapshot went stale while the
|
|
196
|
+
* lighting was prepared.
|
|
198
197
|
*/
|
|
199
198
|
export async function photographSnapshot(
|
|
200
199
|
scene: THREE.Scene,
|
|
@@ -250,125 +249,6 @@ export async function photographSnapshot(
|
|
|
250
249
|
return { base64: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: 'image/png' };
|
|
251
250
|
}
|
|
252
251
|
|
|
253
|
-
/** What the Render view shows: the scene camera's picture, or the render's reason there is none. */
|
|
254
|
-
export type SceneCameraRender =
|
|
255
|
-
| {
|
|
256
|
-
readonly kind: 'image';
|
|
257
|
-
readonly url: string;
|
|
258
|
-
readonly width: number;
|
|
259
|
-
readonly height: number;
|
|
260
|
-
readonly projection: 'perspective' | 'orthographic';
|
|
261
|
-
}
|
|
262
|
-
| { readonly kind: 'refused'; readonly reason: string };
|
|
263
|
-
|
|
264
|
-
/** `session.py::_photograph`'s names for the view transforms the renderer has a curve for. */
|
|
265
|
-
const VIEW_TRANSFORMS: Record<string, RenderRequest['toneMapping']> = {
|
|
266
|
-
Standard: 'none',
|
|
267
|
-
AgX: 'agx',
|
|
268
|
-
Filmic: 'filmic',
|
|
269
|
-
'Khronos PBR Neutral': 'neutral',
|
|
270
|
-
};
|
|
271
|
-
/** The Render view's longest side, in device pixels: a preview, not the output resolution. */
|
|
272
|
-
const RENDER_VIEW_MAXIMUM_EDGE = 1600;
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* THE RENDER, LIVE: Blender's Rendered shading seen through the scene camera, the view a person
|
|
276
|
-
* splits beside the modeling viewport to watch the render while they model. In Blender that area
|
|
277
|
-
* runs the scene's render engine in the viewport; here three.js is the render engine, so this
|
|
278
|
-
* takes the render's own photograph (`photographSnapshot`, the one `bpy.ops.render.render` takes)
|
|
279
|
-
* of the view's current revision, through the camera a camera view looks through, with the
|
|
280
|
-
* scene's own view transform, look, exposure and film transparency, `session.py::_photograph`'s
|
|
281
|
-
* reading of `scene.view_settings` transcribed. It is fitted to `region` (device pixels) at the
|
|
282
|
-
* render's own shape. Nothing it does is recorded: no Render Result, no history, no engine call
|
|
283
|
-
* but the RNA reads of the scene's colour management.
|
|
284
|
-
*/
|
|
285
|
-
export async function renderSceneCamera(
|
|
286
|
-
view: BlenderRuntimeView,
|
|
287
|
-
region: { readonly width: number; readonly height: number },
|
|
288
|
-
): Promise<SceneCameraRender> {
|
|
289
|
-
const refused = (reason: string): SceneCameraRender => ({ kind: 'refused', reason });
|
|
290
|
-
const camera = view.cameraViewCamera();
|
|
291
|
-
// The sentence this editor's render gives for the same scene (`session.py::_photograph`).
|
|
292
|
-
const noCamera = 'The scene has no camera, so there is nothing to render';
|
|
293
|
-
if (camera === null) return refused(noCamera);
|
|
294
|
-
// The render's shape: on a square region the camera's frame spans the fitted side.
|
|
295
|
-
const probe = view.cameraView(camera, { width: 1000, height: 1000 }, 1, [0, 0]);
|
|
296
|
-
if (probe === null) return refused(noCamera);
|
|
297
|
-
const aspect = probe.frame.width / probe.frame.height;
|
|
298
|
-
const scale = Math.min(1, RENDER_VIEW_MAXIMUM_EDGE / Math.max(region.width, region.height));
|
|
299
|
-
const fitted = Math.min(region.width, region.height * aspect) * scale;
|
|
300
|
-
const width = Math.max(1, Math.round(fitted));
|
|
301
|
-
const height = Math.max(1, Math.round(fitted / aspect));
|
|
302
|
-
const through = view.cameraView(camera, { width, height }, 1, [0, 0]);
|
|
303
|
-
if (through === null) return refused(noCamera);
|
|
304
|
-
|
|
305
|
-
const context = await blenderRnaContext();
|
|
306
|
-
if (context === null) return refused('Blender is starting');
|
|
307
|
-
const [viewSettings, renderSettings] = await Promise.all([
|
|
308
|
-
blenderRna(`${context.scene}.view_settings`),
|
|
309
|
-
blenderRna(`${context.scene}.render`),
|
|
310
|
-
]);
|
|
311
|
-
const read = (struct: BlenderRnaView | null, identifier: string): unknown =>
|
|
312
|
-
struct?.kind === 'struct'
|
|
313
|
-
? struct.groups.flatMap((group) => group.rows).find((row) => row.identifier === identifier)?.value
|
|
314
|
-
: undefined;
|
|
315
|
-
const transform = String(read(viewSettings, 'view_transform'));
|
|
316
|
-
const toneMapping = VIEW_TRANSFORMS[transform];
|
|
317
|
-
if (toneMapping === undefined) return refused(`Blender view transform ${transform} has no curve in the renderer`);
|
|
318
|
-
const look = String(read(viewSettings, 'look') ?? 'None');
|
|
319
|
-
const exposure = Number(read(viewSettings, 'exposure') ?? 0);
|
|
320
|
-
const render: RenderRequest = {
|
|
321
|
-
width,
|
|
322
|
-
height,
|
|
323
|
-
fov: 0,
|
|
324
|
-
toneMapping,
|
|
325
|
-
exposure: 2 ** (Number.isFinite(exposure) ? exposure : 0),
|
|
326
|
-
orthographic: through.projection === 'orthographic',
|
|
327
|
-
transparent: read(renderSettings, 'film_transparent') === true,
|
|
328
|
-
...(look === 'None' || look === 'AgX - Base Contrast' ? {} : { look }),
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
const snapshot = view.captureSnapshot();
|
|
332
|
-
try {
|
|
333
|
-
const scene = new THREE.Scene();
|
|
334
|
-
scene.add(snapshot.root);
|
|
335
|
-
scene.updateMatrixWorld(true);
|
|
336
|
-
// The render's clipping, fitted to this snapshot from the camera's eye as a render's is.
|
|
337
|
-
const eye = new THREE.Vector3(...through.position);
|
|
338
|
-
const bounds = contentWorldBounds(snapshot.root).getBoundingSphere(new THREE.Sphere());
|
|
339
|
-
const { near, far } = fitClipPlanes(eye.distanceTo(bounds.center), bounds.radius);
|
|
340
|
-
// THE CAMERA VIEW'S WINDOW, lens shift included (`blenderCameraView`), as the projection.
|
|
341
|
-
const { left, right, top, bottom } = through.window;
|
|
342
|
-
let renderCamera: THREE.Camera;
|
|
343
|
-
if (through.projection === 'orthographic') {
|
|
344
|
-
renderCamera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
|
|
345
|
-
} else {
|
|
346
|
-
const perspective = new THREE.PerspectiveCamera(
|
|
347
|
-
THREE.MathUtils.radToDeg(2 * Math.atan((top - bottom) / 2)),
|
|
348
|
-
(right - left) / (top - bottom),
|
|
349
|
-
near,
|
|
350
|
-
far,
|
|
351
|
-
);
|
|
352
|
-
perspective.projectionMatrix.makePerspective(left * near, right * near, top * near, bottom * near, near, far);
|
|
353
|
-
perspective.projectionMatrixInverse.copy(perspective.projectionMatrix).invert();
|
|
354
|
-
renderCamera = perspective;
|
|
355
|
-
}
|
|
356
|
-
renderCamera.position.copy(eye);
|
|
357
|
-
renderCamera.quaternion.set(...through.quaternion);
|
|
358
|
-
renderCamera.updateMatrixWorld(true);
|
|
359
|
-
const picture = await photographSnapshot(scene, renderCamera, snapshot, render);
|
|
360
|
-
return {
|
|
361
|
-
kind: 'image',
|
|
362
|
-
url: `data:${picture.mimeType};base64,${picture.base64}`,
|
|
363
|
-
width,
|
|
364
|
-
height,
|
|
365
|
-
projection: through.projection,
|
|
366
|
-
};
|
|
367
|
-
} finally {
|
|
368
|
-
snapshot.dispose();
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
252
|
export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
|
|
373
253
|
|
|
374
254
|
/**
|
|
@@ -1153,7 +1033,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1153
1033
|
// resolve, and `blender_tools.py` deliberately keeps a failing SCRIPT's
|
|
1154
1034
|
// traceback out of it.
|
|
1155
1035
|
// biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
|
|
1156
|
-
log: (level, text) =>
|
|
1036
|
+
log: (level, text) => console[level](`[blender] ${text}`),
|
|
1157
1037
|
});
|
|
1158
1038
|
const session = runtime;
|
|
1159
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.23",
|
|
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.21",
|
|
74
|
+
"@volter/editor-threejs": "0.5.83",
|
|
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.83",
|
|
79
|
+
"@volter/editor-project": "0.5.83",
|
|
80
|
+
"@volter/editor-sdk": "0.5.83",
|
|
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
|
},
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHETHER A MODEL DOCUMENT'S AREA IS SPLIT
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* WHETHER A MODEL DOCUMENT'S AREA IS SPLIT into two 3D viewports side by side (View ▸ Area ▸
|
|
3
|
+
* Vertical Split, `blender-header-menus.tsx`; drawn by `blender-runtime.document.tsx`). Blender
|
|
4
|
+
* keeps its areas in the file's screen; this editor's screen is the workbench's, so the split is
|
|
5
|
+
* kept per document on this machine, the way the workbench keeps its own layout, and never
|
|
6
|
+
* written into the `.blend`.
|
|
7
7
|
*/
|
|
8
|
-
const STORAGE_PREFIX = 'volter.blender.
|
|
8
|
+
const STORAGE_PREFIX = 'volter.blender.area-split:';
|
|
9
9
|
const listeners = new Set<() => void>();
|
|
10
10
|
const split = new Map<string, boolean>();
|
|
11
11
|
|
|
@@ -17,7 +17,7 @@ function stored(documentId: string): boolean {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function
|
|
20
|
+
export function areaSplit(documentId: string): boolean {
|
|
21
21
|
let value = split.get(documentId);
|
|
22
22
|
if (value === undefined) {
|
|
23
23
|
value = stored(documentId);
|
|
@@ -26,8 +26,8 @@ export function renderViewSplit(documentId: string): boolean {
|
|
|
26
26
|
return value;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function
|
|
30
|
-
if (
|
|
29
|
+
export function setAreaSplit(documentId: string, value: boolean): void {
|
|
30
|
+
if (areaSplit(documentId) === value) return;
|
|
31
31
|
split.set(documentId, value);
|
|
32
32
|
try {
|
|
33
33
|
if (value) globalThis.localStorage?.setItem(STORAGE_PREFIX + documentId, '1');
|
|
@@ -38,9 +38,10 @@ export function setRenderViewSplit(documentId: string, value: boolean): void {
|
|
|
38
38
|
for (const listener of [...listeners]) listener();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
41
|
+
export function subscribeAreaSplit(listener: () => void): () => void {
|
|
42
42
|
listeners.add(listener);
|
|
43
43
|
return () => {
|
|
44
44
|
listeners.delete(listener);
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
+
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE RENDER VIEW — the area a person splits beside the modeling viewport (View ▸ Area ▸ Vertical
|
|
3
|
-
* Split) to watch the render while they model: Blender's standard always-on render preview, a 3D
|
|
4
|
-
* viewport in Rendered shading looking through the scene camera.
|
|
5
|
-
*
|
|
6
|
-
* In Blender that area runs the scene's render engine live. Here three.js IS the render engine,
|
|
7
|
-
* so the area shows the render's own photograph (`renderSceneCamera`, which takes it where
|
|
8
|
-
* `bpy.ops.render.render` does), re-taken whenever the engine presents a new frame and whenever
|
|
9
|
-
* the area changes size. It is the render, not a viewport lit like one: what this area shows is
|
|
10
|
-
* what F12 would write, at this area's size.
|
|
11
|
-
*
|
|
12
|
-
* It re-renders on FRAMES, not on every presenter change: a snapshot's own texture decodes and
|
|
13
|
-
* program links announce themselves on the same global signal (`blender-presenter-change.ts`),
|
|
14
|
-
* so listening there would have each render request the next one forever, and the snapshot
|
|
15
|
-
* already waits for its own work before it is photographed.
|
|
16
|
-
*/
|
|
17
|
-
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
18
|
-
import { useEffect, useRef, useState } from 'react';
|
|
19
|
-
import { renderSceneCamera, type SceneCameraRender } from '../host/blender-runtime-host';
|
|
20
|
-
|
|
21
|
-
const view = blenderModelView;
|
|
22
|
-
|
|
23
|
-
/** Blender 5.2's default theme: the 3D View's `back`, and its overlay text. */
|
|
24
|
-
const BACKGROUND = '#3d3d3d';
|
|
25
|
-
const TEXT = '#ffffff';
|
|
26
|
-
|
|
27
|
-
export function BlenderRenderView() {
|
|
28
|
-
const areaRef = useRef<HTMLDivElement>(null);
|
|
29
|
-
const [shown, setShown] = useState<SceneCameraRender | null>(null);
|
|
30
|
-
const [rendering, setRendering] = useState(false);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
const area = areaRef.current;
|
|
34
|
-
if (!area) return;
|
|
35
|
-
let disposed = false;
|
|
36
|
-
let dirty = false;
|
|
37
|
-
let running = false;
|
|
38
|
-
// ONE RENDER AT A TIME, and the latest request wins: a frame that lands mid-render marks the
|
|
39
|
-
// area dirty and is rendered next, so a drag shows its end state rather than a queue.
|
|
40
|
-
const run = async (): Promise<void> => {
|
|
41
|
-
if (running) return;
|
|
42
|
-
running = true;
|
|
43
|
-
setRendering(true);
|
|
44
|
-
try {
|
|
45
|
-
while (dirty && !disposed) {
|
|
46
|
-
dirty = false;
|
|
47
|
-
const scale = globalThis.devicePixelRatio || 1;
|
|
48
|
-
const region = {
|
|
49
|
-
width: Math.max(1, Math.round(area.clientWidth * scale)),
|
|
50
|
-
height: Math.max(1, Math.round(area.clientHeight * scale)),
|
|
51
|
-
};
|
|
52
|
-
let result: SceneCameraRender;
|
|
53
|
-
try {
|
|
54
|
-
result = await renderSceneCamera(view, region);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
result = { kind: 'refused', reason: error instanceof Error ? error.message : String(error) };
|
|
57
|
-
}
|
|
58
|
-
if (!disposed) setShown(result);
|
|
59
|
-
}
|
|
60
|
-
} finally {
|
|
61
|
-
running = false;
|
|
62
|
-
if (!disposed) setRendering(false);
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
const invalidate = (): void => {
|
|
66
|
-
dirty = true;
|
|
67
|
-
void run();
|
|
68
|
-
};
|
|
69
|
-
const stopFrames = view.subscribeFrames(invalidate);
|
|
70
|
-
const resize = new ResizeObserver(invalidate);
|
|
71
|
-
resize.observe(area);
|
|
72
|
-
invalidate();
|
|
73
|
-
return () => {
|
|
74
|
-
disposed = true;
|
|
75
|
-
stopFrames();
|
|
76
|
-
resize.disconnect();
|
|
77
|
-
};
|
|
78
|
-
}, []);
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<div
|
|
82
|
-
ref={areaRef}
|
|
83
|
-
data-testid="blender-render-view"
|
|
84
|
-
aria-label="Render view"
|
|
85
|
-
style={{
|
|
86
|
-
position: 'relative',
|
|
87
|
-
flex: '1 1 0',
|
|
88
|
-
minWidth: 0,
|
|
89
|
-
// The split's wrapper bleeds 12 px past the slot as the stage does; the top 12 sit under
|
|
90
|
-
// the header strip, so this area starts where the stage's own corner text does.
|
|
91
|
-
marginTop: 12,
|
|
92
|
-
background: BACKGROUND,
|
|
93
|
-
overflow: 'hidden',
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
{shown?.kind === 'image' && (
|
|
97
|
-
<img
|
|
98
|
-
src={shown.url}
|
|
99
|
-
alt="The scene camera's render"
|
|
100
|
-
data-testid="blender-render-view-image"
|
|
101
|
-
style={{
|
|
102
|
-
position: 'absolute',
|
|
103
|
-
inset: 0,
|
|
104
|
-
width: '100%',
|
|
105
|
-
height: '100%',
|
|
106
|
-
objectFit: 'contain',
|
|
107
|
-
}}
|
|
108
|
-
/>
|
|
109
|
-
)}
|
|
110
|
-
{shown?.kind === 'refused' && (
|
|
111
|
-
<div
|
|
112
|
-
role="status"
|
|
113
|
-
data-testid="blender-render-view-refusal"
|
|
114
|
-
style={{
|
|
115
|
-
position: 'absolute',
|
|
116
|
-
inset: 0,
|
|
117
|
-
display: 'flex',
|
|
118
|
-
alignItems: 'center',
|
|
119
|
-
justifyContent: 'center',
|
|
120
|
-
padding: 16,
|
|
121
|
-
textAlign: 'center',
|
|
122
|
-
color: TEXT,
|
|
123
|
-
opacity: 0.8,
|
|
124
|
-
fontSize: 12,
|
|
125
|
-
}}
|
|
126
|
-
>
|
|
127
|
-
{shown.reason}
|
|
128
|
-
</div>
|
|
129
|
-
)}
|
|
130
|
-
{/* THE VIEW'S NAME, Blender's own for a camera view (`view3d_get_name`: Camera Perspective or
|
|
131
|
-
Camera Orthographic), and while a render is running, that it is. */}
|
|
132
|
-
<div
|
|
133
|
-
style={{
|
|
134
|
-
position: 'absolute',
|
|
135
|
-
top: 6,
|
|
136
|
-
left: 8,
|
|
137
|
-
color: TEXT,
|
|
138
|
-
fontSize: 11,
|
|
139
|
-
lineHeight: '14px',
|
|
140
|
-
textShadow: '0 0 2px #000, 0 0 2px #000',
|
|
141
|
-
pointerEvents: 'none',
|
|
142
|
-
}}
|
|
143
|
-
>
|
|
144
|
-
{shown?.kind === 'image' && (
|
|
145
|
-
<div>{shown.projection === 'orthographic' ? 'Camera Orthographic' : 'Camera Perspective'}</div>
|
|
146
|
-
)}
|
|
147
|
-
{rendering && <div data-testid="blender-render-view-rendering">Rendering…</div>}
|
|
148
|
-
</div>
|
|
149
|
-
</div>
|
|
150
|
-
);
|
|
151
|
-
}
|