@volter/editor-blender 0.1.21 → 0.1.22

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.
@@ -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
- * `INFO_MT_area`): the split that puts a second area beside
21
- * this one. Blender's standard way to watch the render while modeling is exactly that — split the
22
- * viewport and set the new area to Rendered shading through the camera — so Vertical Split opens
23
- * the Render view beside the model (`blender-render-view.tsx`) and Close Area closes it. The other
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 { renderViewSplit, setRenderViewSplit, subscribeRenderViewSplit } from '../src/render-view-split';
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
- subscribeRenderViewSplit,
270
- () => (documentId === undefined ? false : renderViewSplit(documentId)),
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 Render view beside the model
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 ? 'The Render view is already beside this area.' : undefined}
359
+ title={split ? 'This area is already split.' : undefined}
361
360
  onSelect={() => {
362
361
  setOpen(null);
363
- if (documentId !== undefined) setRenderViewSplit(documentId, true);
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) setRenderViewSplit(documentId, false);
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 the SINGLE live one, and
225
- * it is sound rather than lax: the Blender engine is one session bound to one
226
- * `.blend` at a time (`blender-runtime-host.ts`'s `bindModelDocument`), so a
227
- * second Model document with its own Outliner over the same engine is not a
228
- * state this package can be in. With two, a document that cannot name itself
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) if (handle.documentId === documentId) return handle;
233
- return liveOutliners.size === 1 ? [...liveOutliners][0]! : null;
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`
@@ -971,9 +973,16 @@ const EXCLUDED_REASON =
971
973
  'This collection is excluded from the view layer (LayerCollection.exclude), so its viewport ' +
972
974
  'visibility is not what is hiding it. Clear the Exclude checkbox on this row first.';
973
975
 
974
- export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactory = ({
975
- defaultAdapter,
976
- }) => {
976
+ /**
977
+ * THE OUTLINER AUTHORING FOR A STAGE DRAWING `presented`: the rows are Blender's either way, and
978
+ * the view is where a row meets the three object its stage picks, outlines and moves. The Model
979
+ * document's own area draws the published view ({@link createBlenderOutlinerAuthoring}); a split
980
+ * area draws a follower of it (`BlenderRuntimeView.follow`, `blender-render-view.tsx`).
981
+ */
982
+ export function blenderOutlinerAuthoringFor(
983
+ presented: () => PresentedView | null,
984
+ ): ToolObject3DDocumentAuthoringFactory {
985
+ return ({ defaultAdapter }) => {
977
986
  const listeners = new Set<() => void>();
978
987
  /**
979
988
  * THE ENGINE'S SELECTION, CACHED IN ROW IDS — never a second selection.
@@ -1008,7 +1017,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1008
1017
  * spaces meet (see the header). */
1009
1018
  const objectForRow = (id: string): THREE.Object3D | null => {
1010
1019
  const row = rows().get(id);
1011
- const view = blenderPresentedView();
1020
+ const view = presented();
1012
1021
  if (row === undefined || view === null) return null;
1013
1022
  if (row.type === 'TSE_VIEW_COLLECTION_BASE') return view.root;
1014
1023
  return row.object === undefined ? null : view.objectForBlenderName(row.object);
@@ -1017,7 +1026,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1017
1026
  /** The row for a presented OBJECT: its address is the object's own, so this
1018
1027
  * is a map lookup rather than a walk. */
1019
1028
  const rowIdForObject = (object: THREE.Object3D): string | null => {
1020
- const view = blenderPresentedView();
1029
+ const view = presented();
1021
1030
  if (view === null) return null;
1022
1031
  for (const row of rows().values())
1023
1032
  if (isObjectRow(row) && view.objectForBlenderName(row.object) === object) return row.id;
@@ -1403,7 +1412,7 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1403
1412
  endEdit: async (id): Promise<WriteAck | undefined> => {
1404
1413
  const object = gestureObjects.get(id) ?? objectFor(id);
1405
1414
  gestureObjects.delete(id);
1406
- const view = blenderPresentedView();
1415
+ const view = presented();
1407
1416
  if (object === null) return undefined;
1408
1417
  const started = gestureStart.get(object);
1409
1418
  gestureStart.delete(object);
@@ -1748,3 +1757,6 @@ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactor
1748
1757
  },
1749
1758
  };
1750
1759
  };
1760
+ }
1761
+
1762
+ 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 { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
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,15 @@ 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 { BlenderRenderView } from './blender-render-view';
52
- import { renderViewSplit, subscribeRenderViewSplit } from '../src/render-view-split';
63
+ import { areaSplit, subscribeAreaSplit } from '../src/area-split';
53
64
  import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
65
+ import {
66
+ object3DDocumentSession,
67
+ subscribeObject3DDocumentSessions,
68
+ } from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
69
+ import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
54
70
 
55
71
  export const point = 'workspace.document';
56
72
  export const title = 'Blender Model';
@@ -96,51 +112,68 @@ export const inspectorBuiltins: readonly string[] = [];
96
112
  // Timeline binds a skeleton into the same presented graph and there is exactly
97
113
  // one set of presented objects (`blender-runtime-skin.ts`).
98
114
  const view = blenderModelView;
99
- /** THE SUBJECT LINE'S FRAME IS THE ONE ON SCREEN: the skin's playhead, which during playback
100
- * runs ahead of `frame_current` (written once, on pause), as Blender's own `(frame)` does. */
101
- const subjectOfView = () => {
102
- const playhead = blenderSkin.playhead();
103
- return view.subjectLine(playhead === null ? null : Math.floor(playhead));
104
- };
105
- const gridScale = (worldPerDevicePixel: number) => view.gridUnitName(worldPerDevicePixel);
106
- /** BLENDER'S CAMERA VIEW (`blender-runtime-camera-view.ts`): which camera, and how it fits. */
107
- const cameraView = {
108
- camera: () => view.cameraViewCamera(),
109
- zoom: view.cameraViewZoom,
110
- pan: view.cameraViewPan,
111
- showing: (camera: string | null) => view.setCameraViewShowing(camera),
112
- // A LOCKED camera view moves the camera (`ED_view3d_camera_lock_sync`: its scale kept); the
113
- // navigation's end is the one step Blender's history records.
114
- setPose: (
115
- camera: string,
116
- position: readonly [number, number, number],
117
- quaternion: readonly [number, number, number, number],
118
- final: boolean,
119
- ) => {
120
- const { location, rotation } = view.blenderPose(position, quaternion);
121
- return blenderExecute(
122
- 'from mathutils import Matrix, Quaternion, Vector\n' +
123
- `import bpy\nobject = bpy.data.objects[${JSON.stringify(camera)}]\n` +
124
- `object.matrix_world = Matrix.LocRotScale(Vector((${location.join(', ')})), ` +
125
- `Quaternion((${rotation.join(', ')})), object.matrix_world.to_scale())`,
126
- final,
127
- 'Lock Camera to View',
128
- ).then(
129
- (answer) => {
130
- if (answer.error !== null)
131
- editorHost().console.error(`Blender refused the camera's pose: ${answer.error}`, 'blender-camera');
115
+ type AreaView = BlenderRuntimeView;
116
+
117
+ /** What an area's stage reads from the view it draws, made once per view. */
118
+ interface AreaReads {
119
+ readonly subject: () => string | null;
120
+ readonly gridScale: (worldPerDevicePixel: number) => string | null;
121
+ readonly cameraView: NonNullable<ToolObject3DAuthoringProps['cameraView']>;
122
+ }
123
+ const areaReads = new WeakMap<AreaView, AreaReads>();
124
+ function readsOf(view: AreaView): AreaReads {
125
+ let reads = areaReads.get(view);
126
+ if (reads) return reads;
127
+ reads = {
128
+ /** THE SUBJECT LINE'S FRAME IS THE ONE ON SCREEN: the skin's playhead, which during playback
129
+ * runs ahead of `frame_current` (written once, on pause), as Blender's own `(frame)` does. */
130
+ subject: () => {
131
+ const playhead = blenderSkin.playhead();
132
+ return view.subjectLine(playhead === null ? null : Math.floor(playhead));
133
+ },
134
+ gridScale: (worldPerDevicePixel: number) => view.gridUnitName(worldPerDevicePixel),
135
+ /** BLENDER'S CAMERA VIEW (`blender-runtime-camera-view.ts`): which camera, and how it fits. */
136
+ cameraView: {
137
+ camera: () => view.cameraViewCamera(),
138
+ zoom: view.cameraViewZoom,
139
+ pan: view.cameraViewPan,
140
+ showing: (camera: string | null) => view.setCameraViewShowing(camera),
141
+ // A LOCKED camera view moves the camera (`ED_view3d_camera_lock_sync`: its scale kept); the
142
+ // navigation's end is the one step Blender's history records.
143
+ setPose: (
144
+ camera: string,
145
+ position: readonly [number, number, number],
146
+ quaternion: readonly [number, number, number, number],
147
+ final: boolean,
148
+ ) => {
149
+ const { location, rotation } = view.blenderPose(position, quaternion);
150
+ return blenderExecute(
151
+ 'from mathutils import Matrix, Quaternion, Vector\n' +
152
+ `import bpy\nobject = bpy.data.objects[${JSON.stringify(camera)}]\n` +
153
+ `object.matrix_world = Matrix.LocRotScale(Vector((${location.join(', ')})), ` +
154
+ `Quaternion((${rotation.join(', ')})), object.matrix_world.to_scale())`,
155
+ final,
156
+ 'Lock Camera to View',
157
+ ).then(
158
+ (answer) => {
159
+ if (answer.error !== null)
160
+ editorHost().console.error(`Blender refused the camera's pose: ${answer.error}`, 'blender-camera');
161
+ },
162
+ (error: unknown) =>
163
+ editorHost().console.error(`The camera's pose was not written to Blender: ${String(error)}`, 'blender-camera'),
164
+ );
132
165
  },
133
- (error: unknown) =>
134
- editorHost().console.error(`The camera's pose was not written to Blender: ${String(error)}`, 'blender-camera'),
135
- );
136
- },
137
- view: (
138
- camera: string,
139
- region: { readonly width: number; readonly height: number },
140
- zoom: number,
141
- offset: readonly [number, number],
142
- ) => view.cameraView(camera, region, zoom, offset),
143
- };
166
+ view: (
167
+ camera: string,
168
+ region: { readonly width: number; readonly height: number },
169
+ zoom: number,
170
+ offset: readonly [number, number],
171
+ ) => view.cameraView(camera, region, zoom, offset),
172
+ },
173
+ };
174
+ areaReads.set(view, reads);
175
+ return reads;
176
+ }
144
177
 
145
178
  export default function BlenderModelDocument(props: ToolContributionProps) {
146
179
  const { active, document, documentId, notify, publishContext } = props;
@@ -190,12 +223,108 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
190
223
  return <BlenderModelViewport {...props} />;
191
224
  }
192
225
 
193
- function BlenderModelViewport({
226
+ /**
227
+ * THE MODEL DOCUMENT'S AREA: one 3D viewport, and the whole screen unless the area is split
228
+ * (View ▸ Area ▸ Vertical Split, `src/area-split.ts`). Split, the second area is the same
229
+ * viewport over a follower of the presented view (`BlenderRuntimeView.follow`), opened as
230
+ * Blender's standard always-on render preview is set up: Rendered shading, through the scene
231
+ * camera. Each area has its own stage, shading, navigation and camera view; the model, its
232
+ * selection and its history are the document's.
233
+ */
234
+ function BlenderModelViewport(props: ToolContributionProps) {
235
+ const { documentId } = props;
236
+ const split = useSyncExternalStore(
237
+ subscribeAreaSplit,
238
+ () => (documentId ? areaSplit(documentId) : false),
239
+ () => false,
240
+ );
241
+ const [follower, setFollower] = useState<ReturnType<AreaView['follow']> | null>(null);
242
+ useEffect(() => {
243
+ if (!split || !documentId) return;
244
+ // The area's shading is its CHOICE, the door a shading cell uses, made before its stage binds
245
+ // so the stage's session takes it; an area whose shading was already chosen keeps it.
246
+ const secondId = `${documentId}#area-2`;
247
+ if (viewPresentationSnapshot(secondId).drawMode === undefined)
248
+ setViewPresentation(secondId, { drawMode: 'rendered' });
249
+ const second = view.follow();
250
+ setFollower(second);
251
+ return () => {
252
+ setFollower(null);
253
+ second.dispose();
254
+ };
255
+ }, [split, documentId]);
256
+ if (!documentId) return null;
257
+ const second = split ? follower : null;
258
+ // SPLIT, THE TWO AREAS SHARE THE SLOT the stage alone fills otherwise: each stage's root bleeds
259
+ // 12 px past its positioned parent (`inset: -12px`), so each area is that parent inset by 12,
260
+ // and the two stand 2 px apart, Blender's gap between areas. Unsplit, both wrappers are
261
+ // `contents` and the one stage is placed as it always was.
262
+ const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
263
+ return (
264
+ <div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
265
+ <div style={second ? area : { display: 'contents' }}>
266
+ <BlenderViewportArea {...props} view={view} main />
267
+ </div>
268
+ {second && (
269
+ <div style={{ ...area, marginTop: 24 + AREA_HEADER }} data-testid="blender-second-area">
270
+ <BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
271
+ <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
272
+ </div>
273
+ )}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ /**
279
+ * THE SECOND AREA'S OWN HEADER, as each of Blender's areas has one: the editor menus (their
280
+ * View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading and helpers, the
281
+ * header controls its own stage registered, so its shading cells change this area and not the
282
+ * other.
283
+ */
284
+ /** The height of an area's own header row: the document strip's (`volter-dock-document-toolbar`). */
285
+ const AREA_HEADER = 26;
286
+
287
+ function SecondAreaChrome({
288
+ documentId,
289
+ areaId,
290
+ notify,
291
+ }: {
292
+ readonly documentId: string;
293
+ readonly areaId: string;
294
+ readonly notify: ToolContributionProps['notify'];
295
+ }) {
296
+ useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
297
+ const HeaderControls = documentViewport(areaId)?.HeaderControls;
298
+ return (
299
+ <>
300
+ {HeaderControls && (
301
+ <div
302
+ className="volter-dock-document-toolbar"
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">
308
+ <BlenderObjectModeHeader documentId={documentId} notify={notify} />
309
+ </div>
310
+ <Suspense fallback={null}>
311
+ <HeaderControls documentId={areaId} />
312
+ </Suspense>
313
+ </div>
314
+ )}
315
+ </>
316
+ );
317
+ }
318
+
319
+ function BlenderViewportArea({
194
320
  active,
195
321
  document,
196
322
  documentId,
197
323
  surfaces,
198
- }: ToolContributionProps) {
324
+ view,
325
+ main,
326
+ }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
327
+ const reads = readsOf(view);
199
328
  const build = useCallback(
200
329
  () => ({
201
330
  root: view.root,
@@ -211,7 +340,7 @@ function BlenderModelViewport({
211
340
  },
212
341
  dispose() {},
213
342
  }),
214
- [],
343
+ [view],
215
344
  );
216
345
  const blend = document?.source?.path;
217
346
  // Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
@@ -235,15 +364,38 @@ function BlenderModelViewport({
235
364
  stopFrame?.();
236
365
  };
237
366
  },
238
- [documentId],
367
+ [documentId, view],
239
368
  );
240
- const subject = useSyncExternalStore(subscribeSubject, subjectOfView);
241
- // VIEW ▸ AREA ▸ VERTICAL SPLIT: the Render view beside the model (`blender-render-view.tsx`).
242
- const split = useSyncExternalStore(
243
- subscribeRenderViewSplit,
244
- () => (documentId ? renderViewSplit(documentId) : false),
245
- () => false,
369
+ const subject = useSyncExternalStore(subscribeSubject, reads.subject);
370
+ // THE SECOND AREA'S ROWS MEET ITS OWN GRAPH: the same Outliner, over the view this stage draws.
371
+ const authoring = useMemo(
372
+ () => (main ? createBlenderOutlinerAuthoring : blenderOutlinerAuthoringFor(() => view)),
373
+ [main, view],
246
374
  );
375
+ /**
376
+ * THE SECOND AREA OPENS AS BLENDER'S RENDER PREVIEW IS SET UP: Rendered shading (chosen as the
377
+ * area is made, `BlenderModelViewport`) and looking through the scene camera
378
+ * (`view3d.view_camera`), entered once the stage stands and there is a camera to look through.
379
+ * Afterwards it is an ordinary area: leaving the camera view or changing the shading is the
380
+ * person's.
381
+ */
382
+ useEffect(() => {
383
+ if (main || !documentId) return;
384
+ let entered = false;
385
+ const enter = (): void => {
386
+ if (entered) return;
387
+ const session = object3DDocumentSession(documentId);
388
+ if (session === null || view.cameraViewCamera() === null) return;
389
+ entered = session.toggleCameraView();
390
+ };
391
+ enter();
392
+ const stopSessions = subscribeObject3DDocumentSessions(enter);
393
+ const stopFrames = view.subscribeFrames(enter);
394
+ return () => {
395
+ stopSessions();
396
+ stopFrames();
397
+ };
398
+ }, [main, documentId, view]);
247
399
  /**
248
400
  * THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
249
401
  * (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
@@ -273,7 +425,7 @@ function BlenderModelViewport({
273
425
  const stage = viewportStages().find((one) => one.documentId === documentId);
274
426
  for (const { kind } of groups) stage?.setHelper(kind, null);
275
427
  };
276
- }, [documentId]);
428
+ }, [documentId, view]);
277
429
  /**
278
430
  * ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
279
431
  *
@@ -286,7 +438,8 @@ function BlenderModelViewport({
286
438
  * notification rather than assuming an order.
287
439
  */
288
440
  useEffect(() => {
289
- if (!documentId) return;
441
+ // The skin poses the presented view's graph; a follower's has no skeleton bound.
442
+ if (!documentId || !main) return;
290
443
  const { transport } = editorHost();
291
444
  let detach: (() => void) | null = null;
292
445
  const attach = (): void => {
@@ -301,7 +454,7 @@ function BlenderModelViewport({
301
454
  stop();
302
455
  detach?.();
303
456
  };
304
- }, [documentId]);
457
+ }, [documentId, main]);
305
458
  /**
306
459
  * BLENDER'S RENDERED SHADING IS THE SCENE'S OWN LIGHT. When this stage's view lights by the
307
460
  * `scene` (the Rendered shading cell, or the Lighting row's Scene), the presenter holds the viewport
@@ -343,7 +496,7 @@ function BlenderModelViewport({
343
496
  view.holdRendered(null);
344
497
  view.setWorkbench(false);
345
498
  };
346
- }, [documentId]);
499
+ }, [documentId, view]);
347
500
  /**
348
501
  * SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
349
502
  * `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
@@ -403,15 +556,8 @@ function BlenderModelViewport({
403
556
  if (!documentId) return null;
404
557
  const Surface = surfaces.Object3DAuthoring;
405
558
  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
559
  <div
411
- style={split ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}
412
- >
413
- <div
414
- style={split ? { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } : { display: 'contents' }}
560
+ style={{ display: 'contents' }}
415
561
  onPointerDownCapture={onPointerDownCapture}
416
562
  onPointerUpCapture={onPointerUpCapture}
417
563
  onPointerCancelCapture={dropCursorPress}
@@ -426,8 +572,8 @@ function BlenderModelViewport({
426
572
  // THE OVERLAY'S SUBJECT AND GRID LINES ARE BLENDER'S: the engine composes the subject from
427
573
  // the scene (`session.py` `_subject_line`) and names the grid's step in the scene's units.
428
574
  {...(subject === null ? {} : { subject })}
429
- gridScale={gridScale}
430
- cameraView={cameraView}
575
+ gridScale={reads.gridScale}
576
+ cameraView={reads.cameraView}
431
577
  build={build}
432
578
  audit={false}
433
579
  // ON A MODEL DOCUMENT THE HIERARCHY IS BLENDER'S OUTLINER, for the same
@@ -438,7 +584,7 @@ function BlenderModelViewport({
438
584
  // provider; the default adapter it delegates to still answers everything
439
585
  // about the presentation (WORK.md §Blender in the tab is Blender,
440
586
  // "Inspection parity", I3).
441
- authoring={createBlenderOutlinerAuthoring}
587
+ authoring={authoring}
442
588
  cameraDirection={[0.8187, 0.4458, 0.3617]}
443
589
  // FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
444
590
  // three fits.
@@ -477,7 +623,5 @@ function BlenderModelViewport({
477
623
  }}
478
624
  />
479
625
  </div>
480
- {split && <BlenderRenderView />}
481
- </div>
482
626
  );
483
627
  }
@@ -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. `bpy.ops.render.render` and the Render view beside the viewport
196
- * (`blender-render-view.tsx`) both take their pictures here, so the two cannot disagree.
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
  /**
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.21",
5
+ "version": "0.1.22",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -69,14 +69,14 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@modelcontextprotocol/sdk": "1.30.0",
72
- "@volter/blender-engine": "0.1.19",
73
- "@volter/editor-threejs": "0.5.81",
72
+ "@volter/blender-engine": "0.1.20",
73
+ "@volter/editor-threejs": "0.5.82",
74
74
  "zod": "^4.3.6"
75
75
  },
76
76
  "peerDependencies": {
77
- "@volter/editor-sdk": "0.5.81",
78
- "@volter/editor-live": "0.5.81",
79
- "@volter/editor-project": "0.5.81",
77
+ "@volter/editor-sdk": "0.5.82",
78
+ "@volter/editor-live": "0.5.82",
79
+ "@volter/editor-project": "0.5.82",
80
80
  "react": "^19.0.0",
81
81
  "three": "^0.180.0",
82
82
  "vite": "^6.4.3"
@@ -1,11 +1,11 @@
1
1
  /**
2
- * WHETHER A MODEL DOCUMENT'S AREA IS SPLIT, with the Render view beside the modeling viewport
3
- * (View ▸ Area ▸ Vertical Split, `blender-header-menus.tsx`; drawn by
4
- * `blender-runtime.document.tsx`). Blender keeps its areas in the file's screen; this editor's
5
- * screen is the workbench's, so the split is kept per document on this machine, the way the
6
- * workbench keeps its own layout, and never written into the `.blend`.
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.render-view-split:';
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 renderViewSplit(documentId: string): boolean {
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 setRenderViewSplit(documentId: string, value: boolean): void {
30
- if (renderViewSplit(documentId) === value) return;
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,7 +38,7 @@ export function setRenderViewSplit(documentId: string, value: boolean): void {
38
38
  for (const listener of [...listeners]) listener();
39
39
  }
40
40
 
41
- export function subscribeRenderViewSplit(listener: () => void): () => void {
41
+ export function subscribeAreaSplit(listener: () => void): () => void {
42
42
  listeners.add(listener);
43
43
  return () => {
44
44
  listeners.delete(listener);
@@ -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
- }