@volter/editor-blender 0.1.18 → 0.1.20

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.
@@ -8,20 +8,22 @@
8
8
  * draws four menu words in Object Mode, in this order: **View**
9
9
  * (`:1153`), **Select** (`:1166`, `VIEW3D_MT_select_object`), **Add**
10
10
  * (`:1169`, `VIEW3D_MT_add`) and **Object** (`:1210`, `VIEW3D_MT_object`).
11
- * Three of them are here.
11
+ * All four are here.
12
12
  *
13
- * VIEW IS NOT DRAWN, and that is a placement decision rather than an omission.
14
- * Its rows that this editor has are the CAMERA VIEW rows — Front / Right / Top
15
- * / Perspective 3/4, and the Perspective–Orthographic pair — and they are
16
- * already a menu in THIS SAME HEADER STRIP: the host draws
17
- * `Object3DDocumentToolbar` at the strip's trailing edge for every document
18
- * with a 3D session, which is exactly where Blender's viewport header carries
19
- * them too (`DocumentHeaderStrip`'s own note). A View menu here would be the
20
- * same control twice in one row, which is the defect the rule about a control
21
- * having ONE home exists to prevent. Every other row of Blender's View menu —
22
- * the sidebar and tool-shelf toggles, local view, the view-point and
23
- * view-axis submenus, Frame Selected, Viewport Render — is either a control
24
- * this unit does not act on or one that already has its own home in the strip.
13
+ * VIEW CARRIES ITS AREA ROWS AND NOTHING ELSE. Its camera-view rows — Front / Right / Top /
14
+ * Perspective 3/4, and the Perspective–Orthographic pair — are already a menu in THIS SAME HEADER
15
+ * STRIP: the host draws `Object3DDocumentToolbar` at the strip's trailing edge for every document
16
+ * with a 3D session, which is exactly where Blender's viewport header carries them too
17
+ * (`DocumentHeaderStrip`'s own note), so repeating them here would be the same control twice in
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
25
+ * the rest of View — the sidebar and tool-shelf toggles, local view, the view-point and view-axis
26
+ * submenus, Frame Selected, Viewport Render — are not drawn.
25
27
  *
26
28
  * WHAT IS DRAWN IS WHAT ACTS. A row Blender draws that this unit does not act
27
29
  * on is simply not here — never a row that opens a dialog we do not have, and
@@ -56,8 +58,9 @@ import {
56
58
  runBlenderObjectOperator,
57
59
  } from './blender-outliner-authoring';
58
60
  import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
61
+ import { renderViewSplit, setRenderViewSplit, subscribeRenderViewSplit } from '../src/render-view-split';
59
62
 
60
- type MenuId = 'select' | 'add' | 'object';
63
+ type MenuId = 'view' | 'select' | 'add' | 'object';
61
64
 
62
65
  /**
63
66
  * BLENDER'S OWN ROWS for the three transform submenus of `VIEW3D_MT_object`,
@@ -258,9 +261,15 @@ export function BlenderObjectModeHeader({
258
261
  useSyncExternalStore(subscribeBlenderOutliner, blenderOutlinerVersion, blenderOutlinerVersion);
259
262
  const [open, setOpen] = useState<MenuId | null>(null);
260
263
  const rootRef = useRef<HTMLDivElement>(null);
264
+ const viewRef = useRef<HTMLDivElement>(null);
261
265
  const selectRef = useRef<HTMLDivElement>(null);
262
266
  const addRef = useRef<HTMLDivElement>(null);
263
267
  const objectRef = useRef<HTMLDivElement>(null);
268
+ const split = useSyncExternalStore(
269
+ subscribeRenderViewSplit,
270
+ () => (documentId === undefined ? false : renderViewSplit(documentId)),
271
+ () => false,
272
+ );
264
273
  const handle = blenderOutlinerHandle(documentId);
265
274
  // NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
266
275
  // document there is no subject for any of these rows, and a menu of rows
@@ -341,6 +350,35 @@ export function BlenderObjectModeHeader({
341
350
  data-testid="blender-object-mode-menus"
342
351
  ref={rootRef}
343
352
  >
353
+ {/* VIEW ▸ AREA — `INFO_MT_area`'s split and close, for the Render view beside the model
354
+ (this file's header). */}
355
+ <MenuWord id="view" open={open} setOpen={setOpen} anchorRef={viewRef}>
356
+ <MenuSubmenu label="Area" data-testid="blender-view-area">
357
+ <MenuItem
358
+ data-testid="blender-area-vertical-split"
359
+ disabled={split || documentId === undefined}
360
+ title={split ? 'The Render view is already beside this area.' : undefined}
361
+ onSelect={() => {
362
+ setOpen(null);
363
+ if (documentId !== undefined) setRenderViewSplit(documentId, true);
364
+ }}
365
+ >
366
+ Vertical Split
367
+ </MenuItem>
368
+ <MenuSeparator />
369
+ <MenuItem
370
+ data-testid="blender-area-close"
371
+ disabled={!split}
372
+ title={split ? undefined : 'There is no second area to close.'}
373
+ onSelect={() => {
374
+ setOpen(null);
375
+ if (documentId !== undefined) setRenderViewSplit(documentId, false);
376
+ }}
377
+ >
378
+ Close Area
379
+ </MenuItem>
380
+ </MenuSubmenu>
381
+ </MenuWord>
344
382
  {/* SELECT — `VIEW3D_MT_select_object`, `space_view3d.py:1713-1715`. The
345
383
  three rows are `object.select_all` with action SELECT / DESELECT /
346
384
  INVERT, and they act on THE PANEL'S selection rather than running that
@@ -0,0 +1,151 @@
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
+ }
@@ -48,6 +48,8 @@ import { bindModelDocument, blenderExecute, openModelDocumentBlend } from '../ho
48
48
  import { BlenderObjectModeHeader } from './blender-header-menus';
49
49
  import { createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
50
50
  import { blenderSkin } from './blender-runtime-skin';
51
+ import { BlenderRenderView } from './blender-render-view';
52
+ import { renderViewSplit, subscribeRenderViewSplit } from '../src/render-view-split';
51
53
  import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
52
54
 
53
55
  export const point = 'workspace.document';
@@ -236,6 +238,12 @@ function BlenderModelViewport({
236
238
  [documentId],
237
239
  );
238
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,
246
+ );
239
247
  /**
240
248
  * THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
241
249
  * (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
@@ -395,8 +403,15 @@ function BlenderModelViewport({
395
403
  if (!documentId) return null;
396
404
  const Surface = surfaces.Object3DAuthoring;
397
405
  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
+ <div
411
+ style={split ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}
412
+ >
398
413
  <div
399
- style={{ display: 'contents' }}
414
+ style={split ? { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } : { display: 'contents' }}
400
415
  onPointerDownCapture={onPointerDownCapture}
401
416
  onPointerUpCapture={onPointerUpCapture}
402
417
  onPointerCancelCapture={dropCursorPress}
@@ -462,5 +477,7 @@ function BlenderModelViewport({
462
477
  }}
463
478
  />
464
479
  </div>
480
+ {split && <BlenderRenderView />}
481
+ </div>
465
482
  );
466
483
  }
@@ -33,6 +33,7 @@ import {
33
33
  type CaptureRequest,
34
34
  type PresentAnswer,
35
35
  } from '@volter/blender-engine/browser';
36
+ import type { RenderRequest } from '@volter/blender-engine/browser/protocol';
36
37
  import type {
37
38
  BlenderActionClip,
38
39
  BlenderNodeTree,
@@ -189,6 +190,185 @@ async function displayPhotograph(
189
190
  return answer;
190
191
  }
191
192
 
193
+ /**
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.
198
+ */
199
+ export async function photographSnapshot(
200
+ scene: THREE.Scene,
201
+ renderCamera: THREE.Camera,
202
+ snapshot: ReturnType<BlenderRuntimeView['captureSnapshot']>,
203
+ render: RenderRequest,
204
+ assertCurrent: () => void = () => {},
205
+ ): Promise<{ base64: string; mimeType: string }> {
206
+ // THE SCENE'S OWN VIEW TRANSFORM, for the duration of the photograph.
207
+ // Blender states one (`view_settings.view_transform`, AgX by default) and
208
+ // three.js has a curve of the same name, so the render is tone mapped the
209
+ // way the scene asks rather than the way the modeling viewport prefers —
210
+ // without this, every lit pixel differs by the gap between two unrelated
211
+ // curves and no difference in the image can be attributed to anything
212
+ // else. Python refuses a transform with no curve here, so the map is total.
213
+ const mappings: Record<string, THREE.ToneMapping> = {
214
+ none: THREE.NoToneMapping,
215
+ agx: THREE.AgXToneMapping,
216
+ neutral: THREE.NeutralToneMapping,
217
+ filmic: THREE.NoToneMapping,
218
+ };
219
+ await snapshot.prepare(renderCamera);
220
+ assertCurrent();
221
+ const captureOptions = {
222
+ effect: snapshot.effect,
223
+ width: render.width,
224
+ height: render.height,
225
+ transparent: render.transparent === true,
226
+ toneMapping: mappings[render.toneMapping] ?? THREE.AgXToneMapping,
227
+ exposure: render.exposure,
228
+ };
229
+ // BLENDER'S OWN VIEW TRANSFORM, off the linear frame. three's AgX is
230
+ // Filament's approximation and lands 23 to 48 levels of 255 away from
231
+ // Blender (42 at middle grey); the config's actual transform is a 57^3
232
+ // LUT over a log allocation, and a LOOK grades in a log space with two
233
+ // LUT inversions in it. Neither is reachable from resolved bytes, and
234
+ // both are reachable from the half-float target `captureImage` already
235
+ // allocates -- so an AgX render reads that and runs
236
+ // `blender-agx.ts`, which is verified byte-for-byte against OCIO.
237
+ // AND WHENEVER THE SCENE-REFERRED FRAME IS WANTED, whatever the
238
+ // transform: `captureImage` answers with resolved bytes and nothing
239
+ // else, so a Standard render asked for an EXR — or asked to be
240
+ // composited — has no linear frame in it at all. Reading the
241
+ // half-float target is the only path that has one.
242
+ const display =
243
+ render.toneMapping === 'agx' || render.toneMapping === 'filmic' || render.linear === true
244
+ ? await displayPhotograph(captureSceneLinear(scene, renderCamera, captureOptions), render)
245
+ : null;
246
+ assertCurrent();
247
+ if (display) return display;
248
+ const dataUrl = captureSceneImage(scene, renderCamera, captureOptions);
249
+ if (!dataUrl) throw new Error('Blender render could not capture its image');
250
+ return { base64: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: 'image/png' };
251
+ }
252
+
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
+
192
372
  export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
193
373
 
194
374
  /**
@@ -959,58 +1139,8 @@ export function blenderRuntime(): BlenderRuntime {
959
1139
  orthographic: render.orthographic,
960
1140
  },
961
1141
  });
962
- // THE SCENE'S OWN VIEW TRANSFORM, for the duration of the photograph.
963
- // Blender states one (`view_settings.view_transform`, AgX by default) and
964
- // three.js has a curve of the same name, so the render is tone mapped the
965
- // way the scene asks rather than the way the modeling viewport prefers —
966
- // without this, every lit pixel differs by the gap between two unrelated
967
- // curves and no difference in the image can be attributed to anything
968
- // else. Python refuses a transform with no curve here, so the map is total.
969
- const mappings: Record<string, THREE.ToneMapping> = {
970
- none: THREE.NoToneMapping,
971
- agx: THREE.AgXToneMapping,
972
- neutral: THREE.NeutralToneMapping,
973
- filmic: THREE.NoToneMapping,
974
- };
975
- await snapshot.prepare(renderCamera);
976
- assertBinding();
977
- const captureOptions = {
978
- effect: snapshot.effect,
979
- width: render.width,
980
- height: render.height,
981
- transparent: render.transparent === true,
982
- toneMapping: mappings[render.toneMapping] ?? THREE.AgXToneMapping,
983
- exposure: render.exposure,
984
- };
985
- // BLENDER'S OWN VIEW TRANSFORM, off the linear frame. three's AgX is
986
- // Filament's approximation and lands 23 to 48 levels of 255 away from
987
- // Blender (42 at middle grey); the config's actual transform is a 57^3
988
- // LUT over a log allocation, and a LOOK grades in a log space with two
989
- // LUT inversions in it. Neither is reachable from resolved bytes, and
990
- // both are reachable from the half-float target `captureImage` already
991
- // allocates -- so an AgX render reads that and runs
992
- // `blender-agx.ts`, which is verified byte-for-byte against OCIO.
993
- // AND WHENEVER THE SCENE-REFERRED FRAME IS WANTED, whatever the
994
- // transform: `captureImage` answers with resolved bytes and nothing
995
- // else, so a Standard render asked for an EXR — or asked to be
996
- // composited — has no linear frame in it at all. Reading the
997
- // half-float target is the only path that has one.
998
- const display =
999
- render.toneMapping === 'agx' || render.toneMapping === 'filmic' || render.linear === true
1000
- ? await displayPhotograph(
1001
- captureSceneLinear(scene, renderCamera, captureOptions),
1002
- render,
1003
- )
1004
- : null;
1005
- assertBinding();
1006
- if (display) return answer({ ...display, camera: photographedFrom });
1007
- const dataUrl = captureSceneImage(scene, renderCamera, captureOptions);
1008
- if (!dataUrl) throw new Error('Blender render could not capture its image');
1009
- return answer({
1010
- base64: dataUrl.slice(dataUrl.indexOf(',') + 1),
1011
- mimeType: 'image/png',
1012
- camera: photographedFrom,
1013
- });
1142
+ const display = await photographSnapshot(scene, renderCamera, snapshot, render, assertBinding);
1143
+ return answer({ ...display, camera: photographedFrom });
1014
1144
  } finally {
1015
1145
  photographing = false;
1016
1146
  lifetime.signal.removeEventListener('abort', snapshot.dispose);
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.18",
5
+ "version": "0.1.20",
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.16",
73
- "@volter/editor-threejs": "0.5.78",
72
+ "@volter/blender-engine": "0.1.18",
73
+ "@volter/editor-threejs": "0.5.80",
74
74
  "zod": "^4.3.6"
75
75
  },
76
76
  "peerDependencies": {
77
- "@volter/editor-sdk": "0.5.78",
78
- "@volter/editor-live": "0.5.78",
79
- "@volter/editor-project": "0.5.78",
77
+ "@volter/editor-sdk": "0.5.80",
78
+ "@volter/editor-live": "0.5.80",
79
+ "@volter/editor-project": "0.5.80",
80
80
  "react": "^19.0.0",
81
81
  "three": "^0.180.0",
82
82
  "vite": "^6.4.3"
@@ -0,0 +1,46 @@
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`.
7
+ */
8
+ const STORAGE_PREFIX = 'volter.blender.render-view-split:';
9
+ const listeners = new Set<() => void>();
10
+ const split = new Map<string, boolean>();
11
+
12
+ function stored(documentId: string): boolean {
13
+ try {
14
+ return globalThis.localStorage?.getItem(STORAGE_PREFIX + documentId) === '1';
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function renderViewSplit(documentId: string): boolean {
21
+ let value = split.get(documentId);
22
+ if (value === undefined) {
23
+ value = stored(documentId);
24
+ split.set(documentId, value);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function setRenderViewSplit(documentId: string, value: boolean): void {
30
+ if (renderViewSplit(documentId) === value) return;
31
+ split.set(documentId, value);
32
+ try {
33
+ if (value) globalThis.localStorage?.setItem(STORAGE_PREFIX + documentId, '1');
34
+ else globalThis.localStorage?.removeItem(STORAGE_PREFIX + documentId);
35
+ } catch {
36
+ // A page without storage keeps the split for its own life.
37
+ }
38
+ for (const listener of [...listeners]) listener();
39
+ }
40
+
41
+ export function subscribeRenderViewSplit(listener: () => void): () => void {
42
+ listeners.add(listener);
43
+ return () => {
44
+ listeners.delete(listener);
45
+ };
46
+ }