@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.
- package/contributions/blender-header-menus.tsx +13 -14
- package/contributions/blender-outliner-authoring.ts +27 -15
- package/contributions/blender-outliner-model.ts +1 -1
- package/contributions/blender-runtime.document.tsx +219 -75
- package/host/blender-runtime-host.ts +2 -122
- package/package.json +6 -6
- package/src/{render-view-split.ts → area-split.ts} +10 -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`
|
|
@@ -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
|
-
|
|
975
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
241
|
-
//
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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. `
|
|
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
|
/**
|
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.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.
|
|
73
|
-
"@volter/editor-threejs": "0.5.
|
|
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.
|
|
78
|
-
"@volter/editor-live": "0.5.
|
|
79
|
-
"@volter/editor-project": "0.5.
|
|
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
|
|
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,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
|
|
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
|
-
}
|