@volter/editor-blender 0.1.150 → 0.1.152
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/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +99 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +64 -0
- package/contributions/blender.status.tsx +8 -150
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +3 -30
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,53 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE MODEL DOCUMENT — Blender's, and Blender's alone (ARCHITECTURE-CORE §The
|
|
3
|
-
* project model, "A model is Blender data, and a prefab is not a model";
|
|
4
|
-
* WORK.md §Blender in the tab is Blender, "The mesh kit retires", M1).
|
|
5
|
-
*
|
|
6
|
-
* This module declares `documentKind = 'model'`, so the host opens every
|
|
7
|
-
* `model` entry the project's table lists as its OWN document — titled by the
|
|
8
|
-
* entry's label, one per entry — and mounts this with the entry as
|
|
9
|
-
* `props.document`. A `model` entry names a `.blend`
|
|
10
|
-
* (`models.finder.ts`), and opening the document OPENS THAT FILE IN THE
|
|
11
|
-
* ENGINE: `session.py` opens the named file at start and saves back to it, so
|
|
12
|
-
* this is the WS-F save path run backwards.
|
|
13
|
-
*
|
|
14
|
-
* bpy owns every mutation; this document displays them. A gesture is a bpy
|
|
15
|
-
* call the engine records — there is no cage, no append-per-gesture and no
|
|
16
|
-
* TypeScript geometry module. The bpy scripts that authored a `.blend` are
|
|
17
|
-
* ordinary project files beside it (`src/models/<name>.py`); this document
|
|
18
|
-
* does not run them, a person or an agent does, through the session's Blender
|
|
19
|
-
* (`volter blender-mcp`).
|
|
20
|
-
*
|
|
21
|
-
* WITHOUT AN ENTRY it is still the document `blender-start` presents into: a
|
|
22
|
-
* project that lists no `.blend` of its own gets one Model document at the
|
|
23
|
-
* standing `blender:runtime` address (`models.finder.ts` lists it), and
|
|
24
|
-
* the session opens its default `models/model.blend`.
|
|
25
|
-
*/
|
|
26
1
|
|
|
27
|
-
|
|
2
|
+
|
|
28
3
|
import './blender-properties-context';
|
|
29
|
-
import { liveAnimation, setLiveAnimation } from '../src/play-live';
|
|
30
4
|
import { blenderViewFieldOfView } from '../src/presentation';
|
|
31
5
|
import {
|
|
6
|
+
type BlenderDrawOptions,
|
|
32
7
|
type BlenderRuntimeView,
|
|
33
8
|
blenderModelView,
|
|
34
9
|
} from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
35
|
-
import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
|
|
36
10
|
import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
37
|
-
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
|
|
11
|
+
import type { DocumentWorkingCopy, ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
|
|
38
12
|
import { editorHost } from '@volter/sdk/host';
|
|
39
13
|
import {
|
|
40
14
|
documentViewport,
|
|
41
15
|
documentViewportsVersion,
|
|
42
16
|
subscribeDocumentViewports,
|
|
43
|
-
} from '@volter/sdk/
|
|
17
|
+
} from '@volter/sdk/document-viewports';
|
|
44
18
|
import {
|
|
45
19
|
DOCUMENT_STUDIO_PRESET,
|
|
46
20
|
setViewPresentation,
|
|
47
21
|
subscribeViewportPresentation,
|
|
48
22
|
viewPresentation,
|
|
49
23
|
viewPresentationSnapshot,
|
|
50
|
-
} from '@volter/sdk/
|
|
24
|
+
} from '@volter/sdk/viewport-presentation';
|
|
51
25
|
import {
|
|
52
26
|
type MouseEvent as ReactMouseEvent,
|
|
53
27
|
type PointerEvent as ReactPointerEvent,
|
|
@@ -63,52 +37,53 @@ import {
|
|
|
63
37
|
import * as THREE from 'three';
|
|
64
38
|
import {
|
|
65
39
|
bindModelDocument,
|
|
40
|
+
blenderStarting,
|
|
41
|
+
subscribeBlenderStarting,
|
|
66
42
|
blenderExecute,
|
|
67
|
-
blenderRna,
|
|
68
|
-
blenderRnaContext,
|
|
69
43
|
subscribeBlenderRna,
|
|
70
44
|
blenderViewShading,
|
|
71
45
|
openModelDocumentBlend,
|
|
72
46
|
modelDocumentMayOpen,
|
|
73
47
|
modelDocumentOwnsPresentation,
|
|
74
48
|
blenderActionClip,
|
|
49
|
+
blenderSceneMovie,
|
|
75
50
|
blenderRnaVersion,
|
|
51
|
+
modelDocumentDirty,
|
|
52
|
+
restoreModelDocument,
|
|
53
|
+
revertModelDocument,
|
|
54
|
+
saveModelDocument,
|
|
55
|
+
snapshotModelDocument,
|
|
56
|
+
subscribeModelDocumentDirty,
|
|
76
57
|
} from '../host/blender-runtime-host';
|
|
77
58
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
78
59
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
79
60
|
import { blenderSkin } from './blender-runtime-skin';
|
|
80
|
-
import { playAnimation, type PlayAnimation, type PlayClipCache } from './blender-play-skin';
|
|
81
61
|
|
|
82
|
-
/** Clip reads per model document, kept across Plays (`PlayClipCache`). */
|
|
83
|
-
const playClipCaches = new Map<string, PlayClipCache>();
|
|
84
62
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
85
63
|
import { noteModelDocument } from '../src/play-mode';
|
|
86
|
-
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
|
|
87
|
-
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/kit/workspace-document-registry';
|
|
88
64
|
import {
|
|
89
65
|
documentSecondAreaHeader,
|
|
90
66
|
setDocumentSecondArea,
|
|
91
67
|
subscribeDocumentAreas,
|
|
92
|
-
} from '@volter/sdk/
|
|
68
|
+
} from '@volter/sdk/document-areas';
|
|
93
69
|
import { createPortal } from 'react-dom';
|
|
94
70
|
import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
95
71
|
import {
|
|
96
72
|
object3DDocumentSession,
|
|
73
|
+
registerObject3DDocumentPreparation,
|
|
97
74
|
subscribeObject3DDocumentSessions,
|
|
98
75
|
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
99
76
|
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
100
|
-
import { stageStore, subscribeStageStores } from '@volter/sdk/
|
|
101
|
-
import { surfaceAcceptsKey } from '@volter/sdk/kit/surface-keyboard';
|
|
77
|
+
import { stageStore, subscribeStageStores } from '@volter/sdk/stage-store-registry';
|
|
102
78
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
103
79
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
104
|
-
import {
|
|
105
|
-
import {
|
|
106
|
-
import {
|
|
80
|
+
import { Button, EditorBanner } from '@volter/sdk/widgets';
|
|
81
|
+
import { useProgress } from '@volter/sdk/progress';
|
|
82
|
+
import { modelDocumentSaveNotice, subscribeModelDocumentSaving } from '../src/model-document-saving';
|
|
83
|
+
import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/startup-failure';
|
|
107
84
|
|
|
108
85
|
/** This document's model open, as a source in the page's startup-failure registry. */
|
|
109
86
|
const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
|
|
110
|
-
/** How long "Opening model…" stands before the pane also says what it is waiting on. */
|
|
111
|
-
const MODEL_OPEN_NOTE_AFTER_MS = 30_000;
|
|
112
87
|
import {
|
|
113
88
|
clearModelDocumentPreview,
|
|
114
89
|
modelDocumentPreview,
|
|
@@ -117,52 +92,34 @@ import {
|
|
|
117
92
|
type ModelDocumentPreview,
|
|
118
93
|
} from '../src/model-document-preview';
|
|
119
94
|
|
|
120
|
-
/** A detached copy's identity as a React key (the play area is keyed on the copy it draws). */
|
|
121
|
-
const playCopyKeys = new WeakMap<object, number>();
|
|
122
|
-
let nextPlayCopyKey = 0;
|
|
123
|
-
function playCopyKey(copy: object): number {
|
|
124
|
-
let key = playCopyKeys.get(copy);
|
|
125
|
-
if (key === undefined) { key = ++nextPlayCopyKey; playCopyKeys.set(copy, key); }
|
|
126
|
-
return key;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
95
|
/** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
|
|
130
96
|
const overlaysOpened = new WeakSet<object>();
|
|
131
97
|
|
|
132
98
|
export const point = 'workspace.document';
|
|
133
99
|
export const title = 'Blender Model';
|
|
134
100
|
|
|
135
|
-
/**
|
|
136
|
-
* THE DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER — Select, Add and
|
|
137
|
-
* Object, the menu words `VIEW3D_MT_editor_menus` draws in Object Mode. The
|
|
138
|
-
* rows, their order and what View's absence means are
|
|
139
|
-
* `blender-header-menus.tsx`'s own header; this is the one line the host's
|
|
140
|
-
* strip mounts (`ToolDocumentToolbar`, `DocumentHeaderStrip`).
|
|
141
|
-
*/
|
|
101
|
+
/** Object-mode menus mounted in the document toolbar. */
|
|
142
102
|
export const Toolbar: ToolDocumentToolbar = ({ documentId, notify }) => (
|
|
143
103
|
<BlenderObjectModeHeader documentId={documentId} notify={notify} />
|
|
144
104
|
);
|
|
145
105
|
/** The kind this document EDITS: every `model` table entry opens here. */
|
|
146
106
|
export const documentKind = 'model';
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* a Blender tab does not already answer for, and the Preview in particular is
|
|
164
|
-
* the document's own viewport, which is the whole centre of the screen.
|
|
165
|
-
*/
|
|
107
|
+
|
|
108
|
+
/** The Model is a workbench working copy: the workbench owns dirty state, Save, Revert, autosave and backup. Blender writes the file; the session’s delta route carries only changed chunks. */
|
|
109
|
+
export const workingCopy: DocumentWorkingCopy = {
|
|
110
|
+
dirty: (document) => document.source !== undefined && modelDocumentDirty(document.source.path),
|
|
111
|
+
subscribe: subscribeModelDocumentDirty,
|
|
112
|
+
save: (document) => saveModelDocument(blendOf(document)),
|
|
113
|
+
revert: (document) => revertModelDocument(blendOf(document)),
|
|
114
|
+
backup: (document) => snapshotModelDocument(blendOf(document)),
|
|
115
|
+
restore: (document, content) => restoreModelDocument(blendOf(document), content),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function blendOf(document: { readonly source?: { readonly path: string } }): string {
|
|
119
|
+
if (!document.source) throw new Error('A model document without a .blend has no working copy.');
|
|
120
|
+
return document.source.path;
|
|
121
|
+
}
|
|
122
|
+
/** The Model document owns its Properties rail. Compose only this package’s sections and no host inspector builtins; Blender’s tabs inspect the data while the central viewport supplies the preview. */
|
|
166
123
|
export const inspectorRail = 'owned';
|
|
167
124
|
export const inspectorBuiltins: readonly string[] = [];
|
|
168
125
|
|
|
@@ -244,15 +201,30 @@ function readsOf(view: AreaView): AreaReads {
|
|
|
244
201
|
return reads;
|
|
245
202
|
}
|
|
246
203
|
|
|
204
|
+
/** A canvas as a PNG data URL, encoded by `toBlob` (off the main thread); null when it cannot be. */
|
|
205
|
+
function pngDataUrl(canvas: HTMLCanvasElement): Promise<string | null> {
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
canvas.toBlob((blob) => {
|
|
208
|
+
if (!blob) { resolve(null); return; }
|
|
209
|
+
const reader = new FileReader();
|
|
210
|
+
reader.onload = () => resolve(String(reader.result));
|
|
211
|
+
reader.onerror = () => resolve(null);
|
|
212
|
+
reader.readAsDataURL(blob);
|
|
213
|
+
}, 'image/png');
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
247
217
|
export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
248
218
|
const { active, document, documentId, notify, publishContext } = props;
|
|
249
219
|
const blend = document?.source?.path;
|
|
250
220
|
const entryId = document?.id;
|
|
251
221
|
const key = JSON.stringify([documentId, entryId, blend]);
|
|
252
222
|
const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
|
|
253
|
-
/** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
|
|
254
|
-
const [stillOpening, setStillOpening] = useState<string | null>(null);
|
|
255
223
|
const [attempt, setAttempt] = useState(0);
|
|
224
|
+
/** Open the model again, as often as it is asked: an error's Retry, the open's wait's Retry, or a reopen. */
|
|
225
|
+
const retry = useCallback(() => { setOpened(null); setAttempt(value => value + 1); }, []);
|
|
226
|
+
const blenderStart = useSyncExternalStore(subscribeBlenderStarting, blenderStarting, () => false);
|
|
227
|
+
const saveNotice = useSyncExternalStore(subscribeModelDocumentSaving, () => modelDocumentSaveNotice(blend), () => null);
|
|
256
228
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
257
229
|
const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
|
|
258
230
|
const viewportVersion = useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
|
|
@@ -285,7 +257,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
285
257
|
const context = copy.getContext('2d');
|
|
286
258
|
if (context) {
|
|
287
259
|
context.drawImage(frame, 0, 0, copy.width, copy.height);
|
|
288
|
-
|
|
260
|
+
// Encode the PNG with toBlob off the main thread to keep frames responsive.
|
|
261
|
+
const image = await pngDataUrl(copy);
|
|
262
|
+
if (cancelled || !image) return;
|
|
263
|
+
lastFrame.current = { project, entryId, path: blend ?? 'Model', image, capturedAt: performance.now() };
|
|
289
264
|
rememberModelDocumentPreview(lastFrame.current);
|
|
290
265
|
}
|
|
291
266
|
}
|
|
@@ -317,24 +292,22 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
317
292
|
const unbind = bindModelDocument(binding);
|
|
318
293
|
const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
|
|
319
294
|
setOpened(null);
|
|
320
|
-
setStillOpening(null);
|
|
321
295
|
// Native focus can arrive after the contributed pane mounts. A declined
|
|
322
296
|
// open is not an in-flight load: retry when the host activates this model,
|
|
323
|
-
// rather than leaving
|
|
297
|
+
// rather than leaving the open's wait latched forever. Utility focus
|
|
324
298
|
// after a successful open must not tear down the model's published view.
|
|
325
299
|
const documents = editorHost().documents;
|
|
326
300
|
let starting = false;
|
|
327
301
|
let finished = false;
|
|
328
|
-
let declined = false;
|
|
329
302
|
const path = blend ?? 'the model';
|
|
330
303
|
const open = async () => {
|
|
331
304
|
if (cancelled || starting || finished) return;
|
|
332
|
-
if (binding && !modelDocumentMayOpen(binding))
|
|
305
|
+
if (binding && !modelDocumentMayOpen(binding)) return;
|
|
333
306
|
starting = true;
|
|
334
|
-
declined = false;
|
|
335
307
|
try {
|
|
336
308
|
if (binding) {
|
|
337
|
-
|
|
309
|
+
const reload = attempt > 0 && modelDocumentSaveNotice(binding.blend) !== null;
|
|
310
|
+
if (!await openModelDocumentBlend(binding, publish, reload)) return;
|
|
338
311
|
} else {
|
|
339
312
|
// The standing Model document is the explicit blender-start target.
|
|
340
313
|
publish();
|
|
@@ -365,16 +338,6 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
365
338
|
starting = false;
|
|
366
339
|
}
|
|
367
340
|
};
|
|
368
|
-
// A SLOW OPEN SAYS WHAT IT IS WAITING ON (2026-10-06 audit: "Opening model…" had no word
|
|
369
|
-
// past its spinner, and a declined open sat there indefinitely). It is progress, never an
|
|
370
|
-
// error, and it offers no Retry (#147 review): the open is still running, and a second one
|
|
371
|
-
// would queue behind it. A real failure arrives through the catch above.
|
|
372
|
-
const slow = setInterval(() => {
|
|
373
|
-
if (cancelled || finished) return;
|
|
374
|
-
setStillOpening(declined
|
|
375
|
-
? `Still waiting: Blender is holding another model, and opens ${path} when it is free.`
|
|
376
|
-
: `Still opening ${path}: Blender is working (a large file, or a cold start). ${commandLine('status')} shows what it is busy with.`);
|
|
377
|
-
}, MODEL_OPEN_NOTE_AFTER_MS);
|
|
378
341
|
// Opening publishes context to other React roots. Start outside this
|
|
379
342
|
// effect (or a host activation listener's synchronous notification stack)
|
|
380
343
|
// so those roots cannot synchronously commit through a pending commit here.
|
|
@@ -388,18 +351,28 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
388
351
|
requestOpen();
|
|
389
352
|
return () => {
|
|
390
353
|
cancelled = true;
|
|
391
|
-
clearInterval(slow);
|
|
392
354
|
unsubscribe();
|
|
393
355
|
unpublish?.();
|
|
394
356
|
unbind();
|
|
395
357
|
};
|
|
396
358
|
}, [active, blend, documentId, entryId, key, attempt]);
|
|
397
359
|
|
|
360
|
+
// THE OPEN'S ONE WAIT, from asking for the file until its viewport is drawn: Blender's start
|
|
361
|
+
// first when it is still under way (the first open, or one after a reload or a failure), then
|
|
362
|
+
// the file's own load, then its view. Retry opens it again, as often as it is chosen.
|
|
363
|
+
const failed = opened?.key === key && opened.error !== null;
|
|
364
|
+
const preparing = opened?.key === key && !failed && !!documentId && !documentViewport(documentId);
|
|
365
|
+
useProgress(active !== false && !!documentId && !failed && (opened?.key !== key || preparing) ? {
|
|
366
|
+
documentId,
|
|
367
|
+
title: `Opening ${blend ?? 'the model'}`,
|
|
368
|
+
message: preparing ? 'Preparing its view' : blenderStart ? 'Starting Blender' : undefined,
|
|
369
|
+
actions: [{ label: 'Retry opening', run: retry }],
|
|
370
|
+
} : null);
|
|
371
|
+
|
|
398
372
|
if (active === false || !documentId) return null;
|
|
399
373
|
if (opened?.key !== key || opened.error) return <BlenderModelOpening
|
|
400
374
|
documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
|
|
401
|
-
|
|
402
|
-
retry={() => { setOpened(null); setAttempt(value => value + 1); }}
|
|
375
|
+
retry={retry}
|
|
403
376
|
returnToPreview={() => {
|
|
404
377
|
if (!preview) return;
|
|
405
378
|
void editorHost().workspace.open({ kind: 'document', id: preview.entryId }).then(opened => {
|
|
@@ -409,8 +382,13 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
409
382
|
/>;
|
|
410
383
|
return <>
|
|
411
384
|
<BlenderModelViewport {...props} />
|
|
412
|
-
{
|
|
413
|
-
|
|
385
|
+
{saveNotice && <EditorBanner tone="warning" data-testid="blender-model-not-saving"
|
|
386
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2 }}
|
|
387
|
+
actions={<Button variant="secondary" onClick={retry}>Reopen saved document</Button>}>
|
|
388
|
+
<strong>Model is not being saved.</strong> <span style={{ userSelect: 'text' }}>{saveNotice}</span> Reopening replaces this scene with the last saved file.
|
|
389
|
+
</EditorBanner>}
|
|
390
|
+
{preparing && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
|
|
391
|
+
preparingView preview={preview} retry={retry} returnToPreview={() => {}} />}
|
|
414
392
|
</>;
|
|
415
393
|
}
|
|
416
394
|
|
|
@@ -421,80 +399,69 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
421
399
|
* Blender's standard always-on render preview is set up: Rendered shading, through the scene
|
|
422
400
|
* camera. Each area has its own stage, shading, navigation and camera view; the model, its
|
|
423
401
|
* selection and its history are the document's.
|
|
424
|
-
*
|
|
425
|
-
* PLAYING (the composed Play tool), the area shows a DETACHED copy of the model
|
|
426
|
-
* (`BlenderRuntimeView.detach`) on a stage of its own, which the project's play script moves
|
|
427
|
-
* (the document Play extension). The model's own stage stays mounted underneath, hidden, so Stop
|
|
428
|
-
* returns to the same view of the same model: nothing a game does reaches Blender.
|
|
429
402
|
*/
|
|
430
403
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
431
404
|
const { documentId } = props;
|
|
432
405
|
const blend = props.document?.source?.path;
|
|
433
|
-
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
|
|
434
|
-
//
|
|
406
|
+
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`): Game or Animation,
|
|
407
|
+
// by whether the project has a game.
|
|
435
408
|
useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
|
|
436
|
-
// Rendered areas
|
|
437
|
-
// photograph does. Solid keeps its studio transform. RNA also announces
|
|
438
|
-
// colour settings that change without changing the presented geometry.
|
|
409
|
+
// Rendered areas use the display transform carried by the presented frame. Solid uses its studio transform. Document preparation waits for that frame’s transform before photographs.
|
|
439
410
|
useEffect(() => {
|
|
440
411
|
if (!documentId) return;
|
|
441
412
|
let cancelled = false;
|
|
442
|
-
let
|
|
443
|
-
let
|
|
444
|
-
|
|
413
|
+
let applied: BlenderDisplayTransform | null = null;
|
|
414
|
+
let wanted: string | null = null;
|
|
415
|
+
let settled: Promise<void> = Promise.resolve();
|
|
416
|
+
const ids = [documentId, `${documentId}#area-2`];
|
|
445
417
|
const attach = () => {
|
|
446
|
-
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied
|
|
418
|
+
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied);
|
|
447
419
|
};
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
if (!context) return;
|
|
452
|
-
const [settings, displaySettings] = await Promise.all([
|
|
453
|
-
blenderRna(`${context.scene}.view_settings`),
|
|
454
|
-
blenderRna(`${context.scene}.display_settings`),
|
|
455
|
-
]);
|
|
456
|
-
if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
|
|
457
|
-
if (displaySettings?.kind === 'struct') {
|
|
458
|
-
const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
|
|
459
|
-
if (typeof device === 'string' && device !== 'sRGB')
|
|
460
|
-
throw new Error(`Blender display device ${device} has no browser display processor yet`);
|
|
461
|
-
}
|
|
462
|
-
const rows = settings.groups.flatMap(group => group.rows);
|
|
463
|
-
const transform = rows.find(row => row.identifier === 'view_transform')?.value;
|
|
464
|
-
const stops = rows.find(row => row.identifier === 'exposure')?.value;
|
|
465
|
-
const look = rows.find(row => row.identifier === 'look')?.value;
|
|
466
|
-
const gamma = rows.find(row => row.identifier === 'gamma')?.value;
|
|
467
|
-
const exposure = typeof stops === 'number' ? 2 ** stops : 1;
|
|
468
|
-
const display = {
|
|
469
|
-
transform: typeof transform === 'string' ? transform : 'AgX',
|
|
470
|
-
look: typeof look === 'string' ? look : 'None',
|
|
471
|
-
exposure, gamma: typeof gamma === 'number' ? gamma : 1,
|
|
472
|
-
};
|
|
420
|
+
const follow = (): Promise<void> => {
|
|
421
|
+
const display = view.displaySettings();
|
|
422
|
+
if (!display) return settled;
|
|
473
423
|
const key = JSON.stringify(display);
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
424
|
+
if (key === wanted) return settled;
|
|
425
|
+
wanted = key;
|
|
426
|
+
settled = (async () => {
|
|
427
|
+
if (display.device !== 'sRGB')
|
|
428
|
+
throw new Error(`Blender display device ${display.device} has no browser display processor yet`);
|
|
429
|
+
const resolved = await createBlenderDisplayTransform(display);
|
|
430
|
+
if (cancelled || wanted !== key) { resolved.dispose(); return; }
|
|
431
|
+
const previous = applied;
|
|
432
|
+
applied = resolved;
|
|
433
|
+
attach();
|
|
434
|
+
previous?.dispose();
|
|
435
|
+
for (const id of ids) {
|
|
436
|
+
setViewPresentation(id, { modes: {
|
|
437
|
+
rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
438
|
+
preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
439
|
+
} });
|
|
440
|
+
}
|
|
441
|
+
})().catch((error: unknown) => editorHost().console.error(String(error), 'blender-colour'));
|
|
442
|
+
return settled;
|
|
487
443
|
};
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
444
|
+
// A photograph of each shading type is anti-aliased as Blender's viewport accumulates it.
|
|
445
|
+
const sample = () => {
|
|
446
|
+
const sampling = view.photographSampling();
|
|
447
|
+
for (const id of ids) object3DDocumentSession(id)?.setPhotographSampling(sampling);
|
|
448
|
+
};
|
|
449
|
+
const stopFrames = view.subscribeFrames(() => { sample(); void follow(); });
|
|
450
|
+
const stopSessions = subscribeObject3DDocumentSessions(() => { attach(); sample(); });
|
|
451
|
+
sample();
|
|
452
|
+
// A view presented on this document is drawn (and photographed) once the frame on screen has
|
|
453
|
+
// its display transform and everything it is still decoding or settling (`drawReady`).
|
|
454
|
+
const stopPreparation = registerObject3DDocumentPreparation(documentId, async () => {
|
|
455
|
+
await follow();
|
|
456
|
+
await view.drawReady();
|
|
457
|
+
});
|
|
458
|
+
void follow();
|
|
492
459
|
return () => {
|
|
493
|
-
cancelled = true;
|
|
460
|
+
cancelled = true; stopFrames(); stopSessions(); stopPreparation();
|
|
494
461
|
const previous = applied;
|
|
495
462
|
applied = null;
|
|
496
463
|
attach();
|
|
497
|
-
previous?.
|
|
464
|
+
previous?.dispose();
|
|
498
465
|
};
|
|
499
466
|
}, [documentId]);
|
|
500
467
|
const split = useSyncExternalStore(
|
|
@@ -517,213 +484,29 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
517
484
|
second.dispose();
|
|
518
485
|
};
|
|
519
486
|
}, [split, documentId]);
|
|
520
|
-
const playing = useSyncExternalStore(
|
|
521
|
-
subscribeDocumentPlayExtensions,
|
|
522
|
-
() => (documentId ? documentPlayExtension('model')?.playing(documentId) ?? false : false),
|
|
523
|
-
() => false,
|
|
524
|
-
);
|
|
525
|
-
const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
|
|
526
|
-
const playAspect = useSyncExternalStore(
|
|
527
|
-
subscribeDocumentPlayExtensions,
|
|
528
|
-
() => (documentId ? documentPlayExtension('model')?.aspectRatio?.(documentId) ?? null : null),
|
|
529
|
-
() => null,
|
|
530
|
-
);
|
|
531
|
-
const [playedReady, setPlayedReady] = useState(false);
|
|
532
|
-
/**
|
|
533
|
-
* RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
|
|
534
|
-
* below is detached per generation, so a restart disposes the played copy and detaches a fresh
|
|
535
|
-
* one from the model, which never moved, while playing stays true throughout.
|
|
536
|
-
*
|
|
537
|
-
* THE OLD GAME STAYS ON SCREEN UNTIL THE NEW ONE HAS DRAWN. Between the two the new copy's
|
|
538
|
-
* stage is still preparing its rendered draw, and the model area is what lies under the play
|
|
539
|
-
* area — so a restart would flash the editing model between two games. The generation is
|
|
540
|
-
* therefore taken in two steps: a layout effect photographs the old play stage while it still
|
|
541
|
-
* stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
|
|
542
|
-
* detaches the new copy. The photograph covers the play area until the new runner's first
|
|
543
|
-
* frame (`onPlayReady`), the model area stays hidden throughout, and the old runner, no longer
|
|
544
|
-
* the current generation, stands still meanwhile (`play-script.ts`).
|
|
545
|
-
*/
|
|
546
|
-
const generation = useSyncExternalStore(
|
|
547
|
-
subscribeDocumentPlayExtensions,
|
|
548
|
-
() => (documentId ? documentPlayExtension('model')?.transport?.generation(documentId) ?? 0 : 0),
|
|
549
|
-
() => 0,
|
|
550
|
-
);
|
|
551
|
-
const [shownGeneration, setShownGeneration] = useState(generation);
|
|
552
|
-
const [restartStill, setRestartStill] = useState<string | null>(null);
|
|
553
|
-
const restarting = useRef(false);
|
|
554
|
-
// A restarted game that has not drawn yet: its stage holds no game to photograph, so a second
|
|
555
|
-
// Restart in that window keeps the cover it has (or none) rather than photographing a blank.
|
|
556
|
-
const awaitingDraw = useRef(false);
|
|
557
|
-
useLayoutEffect(() => {
|
|
558
|
-
if (generation === shownGeneration) return;
|
|
559
|
-
if (playing && documentId) {
|
|
560
|
-
restarting.current = true;
|
|
561
|
-
if (!awaitingDraw.current) {
|
|
562
|
-
let still: string | null = null;
|
|
563
|
-
try { still = documentViewport(`${documentId}#play`)?.capture?.() ?? null; }
|
|
564
|
-
catch { /* No photograph: the new stage shows as it prepares, still never the model. */ }
|
|
565
|
-
setRestartStill(still);
|
|
566
|
-
}
|
|
567
|
-
awaitingDraw.current = true;
|
|
568
|
-
}
|
|
569
|
-
setShownGeneration(generation);
|
|
570
|
-
}, [generation, shownGeneration, playing, documentId]);
|
|
571
|
-
/**
|
|
572
|
-
* A RESTARTED GAME THAT FAILS never draws (its script did not start, or threw on its first
|
|
573
|
-
* update), so the cover would stand over it while the panel said Playing. The runner says so
|
|
574
|
-
* on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
|
|
575
|
-
*/
|
|
576
|
-
const transport = documentPlayExtension('model')?.transport;
|
|
577
|
-
const subscribeFailure = useCallback(
|
|
578
|
-
(listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
|
|
579
|
-
[transport],
|
|
580
|
-
);
|
|
581
|
-
const failure = useSyncExternalStore(
|
|
582
|
-
subscribeFailure,
|
|
583
|
-
() => (documentId && transport ? transport.clock(documentId).failure ?? null : null),
|
|
584
|
-
() => null,
|
|
585
|
-
);
|
|
586
|
-
useEffect(() => {
|
|
587
|
-
if (failure === null) return;
|
|
588
|
-
awaitingDraw.current = false;
|
|
589
|
-
setRestartStill(null);
|
|
590
|
-
}, [failure]);
|
|
591
|
-
/**
|
|
592
|
-
* COPIES A RESTART RETIRED, disposed only once the play area that drew them has unmounted:
|
|
593
|
-
* the commit that renders the new copy removes the old area first, and this runs after it.
|
|
594
|
-
* Disposed earlier, the old stage could draw a disposed copy for a frame.
|
|
595
|
-
*/
|
|
596
|
-
const retired = useRef<ReturnType<AreaView['detach']>[]>([]);
|
|
597
|
-
useEffect(() => {
|
|
598
|
-
if (!playing || !documentId) { restarting.current = false; return; }
|
|
599
|
-
const playId = `${documentId}#play`;
|
|
600
|
-
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
601
|
-
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
602
|
-
const copy = view.detach();
|
|
603
|
-
const restart = restarting.current;
|
|
604
|
-
restarting.current = false;
|
|
605
|
-
// A restart keeps the model area hidden: the old game's photograph stands in until ready.
|
|
606
|
-
if (!restart) setPlayedReady(false);
|
|
607
|
-
setPlayed(copy);
|
|
608
|
-
if (!restart) {
|
|
609
|
-
// The stage's mode is PLAY for as long as a copy stands, so the keys are the player's.
|
|
610
|
-
view.setPlaying(true);
|
|
611
|
-
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
612
|
-
}
|
|
613
|
-
return () => {
|
|
614
|
-
// Read at cleanup: the layout effect above has already marked a restart, whose new copy
|
|
615
|
-
// takes over in this same commit with the play mode unchanged.
|
|
616
|
-
if (restarting.current && documentPlayExtension('model')?.playing(documentId)) {
|
|
617
|
-
retired.current.push(copy);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
copy.dispose();
|
|
621
|
-
for (const old of retired.current.splice(0)) old.dispose();
|
|
622
|
-
awaitingDraw.current = false;
|
|
623
|
-
setPlayed(null);
|
|
624
|
-
setRestartStill(null);
|
|
625
|
-
view.setPlaying(false);
|
|
626
|
-
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
627
|
-
};
|
|
628
|
-
}, [playing, documentId, shownGeneration]);
|
|
629
|
-
useEffect(() => {
|
|
630
|
-
for (const old of retired.current.filter(one => one !== played)) old.dispose();
|
|
631
|
-
retired.current = retired.current.filter(one => one === played);
|
|
632
|
-
}, [played]);
|
|
633
|
-
// And whatever is still retired when the document itself goes (declared after the copy's own
|
|
634
|
-
// effect, so its cleanup has already handed over anything mid-restart).
|
|
635
|
-
useEffect(() => () => { for (const old of retired.current.splice(0)) old.dispose(); }, []);
|
|
636
|
-
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
637
|
-
useEffect(() => {
|
|
638
|
-
if (!documentId) return;
|
|
639
|
-
return () => documentPlayExtension('model')?.setPlaying(documentId, false);
|
|
640
|
-
}, [documentId]);
|
|
641
|
-
const game = playing ? played : null;
|
|
642
|
-
const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
|
|
643
|
-
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
|
|
644
|
-
width: `min(100cqw, calc(100cqh * ${playAspect}))`,
|
|
645
|
-
height: `min(100cqh, calc(100cqw / ${playAspect}))`,
|
|
646
|
-
} as const;
|
|
647
|
-
useEffect(() => {
|
|
648
|
-
if (!game || !documentId) return;
|
|
649
|
-
const modelViewport = documentViewport(documentId);
|
|
650
|
-
if (!modelViewport) return;
|
|
651
|
-
// registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
|
|
652
|
-
const capture = modelViewport.capture;
|
|
653
|
-
const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
|
|
654
|
-
modelViewport.capture = playCapture;
|
|
655
|
-
return () => {
|
|
656
|
-
if (modelViewport.capture !== playCapture) return;
|
|
657
|
-
if (capture) modelViewport.capture = capture;
|
|
658
|
-
else delete modelViewport.capture;
|
|
659
|
-
};
|
|
660
|
-
}, [game, documentId]);
|
|
661
487
|
if (!documentId) return null;
|
|
662
488
|
const second = split ? follower : null;
|
|
663
|
-
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
664
|
-
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
665
489
|
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
666
|
-
// THE HIDDEN MODEL AREA IS INERT, not merely `pointer-events: none`. It lies OVER the play
|
|
667
|
-
// area (z-index 2, faded out once the game has drawn), and the stage inside it sets its own
|
|
668
|
-
// `pointer-events: auto` (`StageHost`'s viewport box), which a parent's `none` does not reach:
|
|
669
|
-
// a real click on the game hit the invisible editing canvas, never the play surface, so the
|
|
670
|
-
// runner's takeover (`play-script.ts`, which asks whether the press landed in the game) never
|
|
671
|
-
// saw it and the game never got it — while keys, read off the window, took over fine. `inert`
|
|
672
|
-
// takes the whole subtree out of hit testing and focus.
|
|
673
|
-
const hidden = game !== null && playedReady;
|
|
674
490
|
return (
|
|
675
|
-
<div style={
|
|
676
|
-
<div key="model"
|
|
491
|
+
<div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
492
|
+
<div key="model" style={second ? area : { display: 'contents' }}>
|
|
677
493
|
<BlenderViewportArea {...props} view={view} main />
|
|
678
494
|
</div>
|
|
679
|
-
{game && (
|
|
680
|
-
// Keyed on the COPY, so a restart mounts a new stage and runner exactly when the new copy
|
|
681
|
-
// is the one rendered — never over the old, disposed one.
|
|
682
|
-
<div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
|
|
683
|
-
<div data-volter-play-frame style={playFrame}>
|
|
684
|
-
<BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
|
|
685
|
-
onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }} onPlayReturn={() => setPlayedReady(false)} />
|
|
686
|
-
</div>
|
|
687
|
-
</div>
|
|
688
|
-
)}
|
|
689
|
-
{game && failure !== null && (
|
|
690
|
-
// A PLAY WITH NO GAME, said over the area itself. When the script did not start (or the
|
|
691
|
-
// stage could not be prepared) the game never draws and the model area stays in front,
|
|
692
|
-
// so the area looked like plain editing while Play was on and the reason was a toast
|
|
693
|
-
// that had already gone (2026-10-06 audit). The Game panel says the same in its line.
|
|
694
|
-
<div key="play-failure" role="alert" data-testid="blender-play-failure"
|
|
695
|
-
style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
696
|
-
background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
|
|
697
|
-
color: themeVars.content.primary, fontSize: fontSizeVar.sm, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }}>
|
|
698
|
-
<strong style={{ color: themeVars.semantic.danger }}>Play is not running.</strong> {failure} Restart (or save the script) to try again, or Stop.
|
|
699
|
-
</div>
|
|
700
|
-
)}
|
|
701
|
-
{game && restartStill !== null && (
|
|
702
|
-
<div key="restart-still" style={{ position: 'absolute', inset: 12, containerType: 'size', zIndex: 3, pointerEvents: 'none' }} data-testid="blender-play-restart-still">
|
|
703
|
-
<img alt="" src={restartStill} style={{ ...playFrame, display: 'block', objectFit: 'fill' }} />
|
|
704
|
-
</div>
|
|
705
|
-
)}
|
|
706
495
|
{second && (
|
|
707
|
-
<div key="second" style={
|
|
496
|
+
<div key="second" style={area} data-testid="blender-second-area">
|
|
708
497
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
709
|
-
|
|
498
|
+
<SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
|
|
710
499
|
</div>
|
|
711
500
|
)}
|
|
712
501
|
</div>
|
|
713
502
|
);
|
|
714
503
|
}
|
|
715
504
|
|
|
716
|
-
/**
|
|
717
|
-
* THE SECOND AREA'S OWN HEADER, as each of Blender's areas has one: the editor menus (their
|
|
718
|
-
* View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading and helpers, the
|
|
719
|
-
* header controls its own stage registered, so its shading cells change this area and not the
|
|
720
|
-
* other.
|
|
721
|
-
*/
|
|
722
505
|
/**
|
|
723
506
|
* THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
|
|
724
507
|
*
|
|
725
508
|
* The area is declared to the host as the document's second area
|
|
726
|
-
* (`@volter/sdk/
|
|
509
|
+
* (`@volter/sdk/document-areas`). Its HEADER stands in the document's header row over
|
|
727
510
|
* this area: the host reserves this area's width at the row's trailing edge, so the first area's
|
|
728
511
|
* header ends where the first area does, and this renders into the region it draws there: the
|
|
729
512
|
* editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
|
|
@@ -783,38 +566,28 @@ function BlenderViewportArea({
|
|
|
783
566
|
active,
|
|
784
567
|
document,
|
|
785
568
|
documentId,
|
|
786
|
-
notify,
|
|
787
569
|
surfaces,
|
|
788
570
|
view,
|
|
789
571
|
main,
|
|
790
|
-
|
|
791
|
-
onPlayReady,
|
|
792
|
-
onPlayReturn,
|
|
793
|
-
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
|
|
572
|
+
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
|
|
794
573
|
const reads = readsOf(view);
|
|
795
574
|
const build = useCallback(
|
|
796
575
|
() => ({
|
|
797
576
|
root: view.root,
|
|
798
|
-
prepareDraw: (camera: THREE.Camera, options?:
|
|
577
|
+
prepareDraw: (camera: THREE.Camera, options?: BlenderDrawOptions) => view.prepareDraw(camera, options),
|
|
799
578
|
// What the view draws changes with each frame and the work after it,
|
|
800
|
-
// and with the skin's poses when the Timeline scrubs.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
return () => {
|
|
810
|
-
stopView();
|
|
811
|
-
stopSkin();
|
|
812
|
-
};
|
|
813
|
-
},
|
|
814
|
-
}),
|
|
579
|
+
// and with the skin's poses when the Timeline scrubs.
|
|
580
|
+
onChange(listener: () => void) {
|
|
581
|
+
const stopView = view.onChange(listener);
|
|
582
|
+
const stopSkin = blenderSkin.subscribe(listener);
|
|
583
|
+
return () => {
|
|
584
|
+
stopView();
|
|
585
|
+
stopSkin();
|
|
586
|
+
};
|
|
587
|
+
},
|
|
815
588
|
dispose() {},
|
|
816
589
|
}),
|
|
817
|
-
[view
|
|
590
|
+
[view],
|
|
818
591
|
);
|
|
819
592
|
const blend = document?.source?.path;
|
|
820
593
|
// Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
|
|
@@ -854,8 +627,7 @@ function BlenderViewportArea({
|
|
|
854
627
|
* person's.
|
|
855
628
|
*/
|
|
856
629
|
useEffect(() => {
|
|
857
|
-
|
|
858
|
-
if (main || playing || !documentId) return;
|
|
630
|
+
if (main || !documentId) return;
|
|
859
631
|
let entered = false;
|
|
860
632
|
const enter = (): void => {
|
|
861
633
|
if (entered) return;
|
|
@@ -870,194 +642,7 @@ function BlenderViewportArea({
|
|
|
870
642
|
stopSessions();
|
|
871
643
|
stopFrames();
|
|
872
644
|
};
|
|
873
|
-
}, [main,
|
|
874
|
-
/**
|
|
875
|
-
* THE PLAYING COPY IS MOVED BY THE PROJECT'S PLAY SCRIPT (the document Play extension), on this
|
|
876
|
-
* stage's own frame hook, once the stage stands. The stage's orbit stands down while it runs,
|
|
877
|
-
* so the script's camera is not argued with, and Escape stops the game.
|
|
878
|
-
*/
|
|
879
|
-
const notifyRef = useRef(notify);
|
|
880
|
-
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
881
|
-
const [playReady, setPlayReady] = useState(false);
|
|
882
|
-
notifyRef.current = notify;
|
|
883
|
-
const playReadyRef = useRef(onPlayReady);
|
|
884
|
-
playReadyRef.current = onPlayReady;
|
|
885
|
-
const playReturnRef = useRef(onPlayReturn);
|
|
886
|
-
playReturnRef.current = onPlayReturn;
|
|
887
|
-
useEffect(() => {
|
|
888
|
-
if (!playing || !documentId) return;
|
|
889
|
-
if (blend === undefined) {
|
|
890
|
-
notifyRef.current?.({
|
|
891
|
-
tone: 'error',
|
|
892
|
-
title: 'This model has no play script',
|
|
893
|
-
detail: 'A play script stands beside a model\'s .blend file, and this model has no file of its own.',
|
|
894
|
-
});
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
const modelId = documentId.replace(/#play$/, '');
|
|
898
|
-
const layers = window.document.createElement('div');
|
|
899
|
-
Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
|
|
900
|
-
layers.dataset['testid'] = 'model-play-roots';
|
|
901
|
-
surfaceRef.current?.appendChild(layers);
|
|
902
|
-
setPlayReady(false);
|
|
903
|
-
let stopped = false;
|
|
904
|
-
let preparing = false;
|
|
905
|
-
let stopScript: (() => void) | null = null;
|
|
906
|
-
let orbit: { enabled: boolean } | null = null;
|
|
907
|
-
let animation: PlayAnimation | null = null;
|
|
908
|
-
const start = (): void => {
|
|
909
|
-
if (stopScript || preparing || stopped) return;
|
|
910
|
-
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
911
|
-
if (!stage || !view.root.parent) return;
|
|
912
|
-
preparing = true;
|
|
913
|
-
orbit = stage.rig().orbit;
|
|
914
|
-
orbit.enabled = false;
|
|
915
|
-
// THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
|
|
916
|
-
// armature plays the action the file assigns it until the game sets another
|
|
917
|
-
// (`blender-play-skin.ts`). Nothing is read before the game starts.
|
|
918
|
-
// A LOADING PHASE, said on screen: the game starts only once its stage is drawn and every
|
|
919
|
-
// character's starting clips are baked (`PlayAnimation.prepare`). Started before them, a
|
|
920
|
-
// character whose clip Blender had not answered yet stood frozen in its rest pose, and a
|
|
921
|
-
// clip whose read failed stayed frozen for the whole run. That is the suspected cause of
|
|
922
|
-
// "sometimes none of the animations work" (a busy Blender was measured to delay the clips).
|
|
923
|
-
// THE WAIT IS BOUNDED AND SAID: after LOADING_SAY the overlay names the clips still
|
|
924
|
-
// awaited; at LOADING_LIMIT the game starts without them and the console names them (each
|
|
925
|
-
// still plays once Blender answers). A thrown `prepare` is named too; the game always starts.
|
|
926
|
-
const loading = window.document.createElement('div');
|
|
927
|
-
loading.dataset['testid'] = 'model-play-loading';
|
|
928
|
-
loading.textContent = 'Loading…';
|
|
929
|
-
Object.assign(loading.style, {
|
|
930
|
-
position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
931
|
-
background: 'rgba(0, 0, 0, 0.35)', color: '#e6e6e6', font: '600 14px system-ui, sans-serif', pointerEvents: 'none',
|
|
932
|
-
});
|
|
933
|
-
layers.appendChild(loading);
|
|
934
|
-
void view.prepareRendered(stage.rig().drawCamera(), 'render').then(async () => {
|
|
935
|
-
if (stopped) return;
|
|
936
|
-
// No session answers null: a read that failed, asked again, not a clip that animates nothing.
|
|
937
|
-
// A SETUP THAT THROWS still starts the game, without animation and said so; left
|
|
938
|
-
// outside any catch it left the loading overlay up and the script never ran.
|
|
939
|
-
let cache = playClipCaches.get(modelId);
|
|
940
|
-
if (!cache) playClipCaches.set(modelId, cache = new Map());
|
|
941
|
-
let loaded: PlayAnimation | null = null;
|
|
942
|
-
try {
|
|
943
|
-
loaded = playAnimation(view, async (armature, action) => {
|
|
944
|
-
const clip = await blenderActionClip({ object: armature, action, summary: false });
|
|
945
|
-
if (clip === null) throw new Error("Blender's session is not started");
|
|
946
|
-
return clip;
|
|
947
|
-
}, cache);
|
|
948
|
-
} catch (error) {
|
|
949
|
-
editorHost().console.error(`The game's animation could not be set up, so the game starts without it: ${error instanceof Error ? error.message : String(error)}`, 'blender-animation');
|
|
950
|
-
}
|
|
951
|
-
animation = loaded;
|
|
952
|
-
if (!loaded) {
|
|
953
|
-
loading.remove();
|
|
954
|
-
if (stopped) return;
|
|
955
|
-
}
|
|
956
|
-
if (loaded) {
|
|
957
|
-
const anim = loaded;
|
|
958
|
-
const LOADING_SAY = 3_000, LOADING_LIMIT = 20_000;
|
|
959
|
-
const clips = (names: readonly string[]): string => `${names.length} clip${names.length === 1 ? '' : 's'}`;
|
|
960
|
-
const say = window.setInterval(() => {
|
|
961
|
-
const pending = anim.pending();
|
|
962
|
-
if (pending.length > 0) loading.textContent = `Loading… waiting on Blender for ${clips(pending)}: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ` and ${pending.length - 6} more` : ''}`;
|
|
963
|
-
}, LOADING_SAY);
|
|
964
|
-
let limit = 0;
|
|
965
|
-
const outcome = await Promise.race([
|
|
966
|
-
anim.prepare().then(({ failed }) => ({ failed, waiting: [] as readonly string[] }), (error: unknown) => ({ failed: [`the loading phase failed: ${error instanceof Error ? error.message : String(error)}`], waiting: [] as readonly string[] })),
|
|
967
|
-
new Promise<{ failed: readonly string[]; waiting: readonly string[] }>((resolve) => {
|
|
968
|
-
limit = window.setTimeout(() => resolve({ failed: [], waiting: anim.pending() }), LOADING_LIMIT);
|
|
969
|
-
}),
|
|
970
|
-
]);
|
|
971
|
-
window.clearInterval(say);
|
|
972
|
-
window.clearTimeout(limit);
|
|
973
|
-
loading.remove();
|
|
974
|
-
if (stopped) return;
|
|
975
|
-
if (outcome.failed.length > 0)
|
|
976
|
-
editorHost().console.warn(`The game started without ${clips(outcome.failed)} it starts on: ${outcome.failed.join('; ')}`, 'blender-animation');
|
|
977
|
-
if (outcome.waiting.length > 0)
|
|
978
|
-
editorHost().console.warn(`The game started after ${LOADING_LIMIT / 1000} s still waiting on Blender for ${clips(outcome.waiting)}; each plays once Blender answers: ${outcome.waiting.join(', ')}`, 'blender-animation');
|
|
979
|
-
anim.prefetch();
|
|
980
|
-
}
|
|
981
|
-
setLiveAnimation(animation);
|
|
982
|
-
stopScript = documentPlayExtension('model')?.run({
|
|
983
|
-
...(animation ? { animation } : {}),
|
|
984
|
-
documentId: modelId,
|
|
985
|
-
container: layers,
|
|
986
|
-
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
987
|
-
returning: () => { if (!stopped) playReturnRef.current?.(); },
|
|
988
|
-
sourcePath: blend,
|
|
989
|
-
root: view.root,
|
|
990
|
-
camera: () => stage.rig().drawCamera(),
|
|
991
|
-
editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
|
|
992
|
-
onFrame: (fn) => stage.onFrame(fn),
|
|
993
|
-
ownMaterial: (material) => ownedSurfaceMaterial(material as THREE.Material),
|
|
994
|
-
report: (title, detail) => {
|
|
995
|
-
editorHost().console.error(`${title}: ${detail}`, 'blender-play');
|
|
996
|
-
notifyRef.current?.({ tone: 'error', title, detail });
|
|
997
|
-
},
|
|
998
|
-
}) ?? null;
|
|
999
|
-
}, error => {
|
|
1000
|
-
loading.remove();
|
|
1001
|
-
if (stopped) return;
|
|
1002
|
-
const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
|
|
1003
|
-
editorHost().console.error(failure, 'model-play');
|
|
1004
|
-
// SAID WHERE THE PERSON IS LOOKING: on the run's clock, which the Game panel and the
|
|
1005
|
-
// play area draw, and in the play log. Turning Play off (what this did until
|
|
1006
|
-
// 2026-10-06) erased the only trace a person could have seen.
|
|
1007
|
-
const extension = documentPlayExtension('model');
|
|
1008
|
-
if (extension?.transport?.fail) extension.transport.fail(modelId, blend, failure);
|
|
1009
|
-
else {
|
|
1010
|
-
notifyRef.current?.({ tone: 'error', title: 'Play could not start', detail: failure });
|
|
1011
|
-
extension?.setPlaying(modelId, false);
|
|
1012
|
-
}
|
|
1013
|
-
});
|
|
1014
|
-
};
|
|
1015
|
-
start();
|
|
1016
|
-
let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
|
|
1017
|
-
let stopPrepareFrames: (() => void) | undefined;
|
|
1018
|
-
const waitForDraw = () => {
|
|
1019
|
-
const stage = viewportStages().find(one => one.documentId === documentId);
|
|
1020
|
-
if (stage === waitingStage) return;
|
|
1021
|
-
stopPrepareFrames?.();
|
|
1022
|
-
waitingStage = stage;
|
|
1023
|
-
stopPrepareFrames = stage?.onFrame(start);
|
|
1024
|
-
start();
|
|
1025
|
-
};
|
|
1026
|
-
waitForDraw();
|
|
1027
|
-
const stopStages = onViewportStages(waitForDraw);
|
|
1028
|
-
const onEscape = (event: KeyboardEvent): void => {
|
|
1029
|
-
if (event.key !== 'Escape' || !surfaceAcceptsKey(event)) return;
|
|
1030
|
-
// Decided once the event has finished dispatching: a play script that claims Escape (a
|
|
1031
|
-
// game's pause, registered after this listener) prevents its default, and the Escape that
|
|
1032
|
-
// frees a game's mouse look is the person taking their mouse back. Both used to stop Play
|
|
1033
|
-
// (three playtests of a Cyclotron game lost a mission each to it).
|
|
1034
|
-
if (globalThis.document.pointerLockElement || performance.now() - lastPointerUnlockAt < 500) return;
|
|
1035
|
-
setTimeout(() => {
|
|
1036
|
-
if (event.defaultPrevented) return;
|
|
1037
|
-
const extension = documentPlayExtension('model');
|
|
1038
|
-
if (extension?.escape) extension.escape(modelId);
|
|
1039
|
-
else extension?.setPlaying(modelId, false);
|
|
1040
|
-
}, 0);
|
|
1041
|
-
};
|
|
1042
|
-
let lastPointerUnlockAt = -Infinity;
|
|
1043
|
-
const onLockChange = (): void => { if (!globalThis.document.pointerLockElement) lastPointerUnlockAt = performance.now(); };
|
|
1044
|
-
globalThis.document.addEventListener('pointerlockchange', onLockChange);
|
|
1045
|
-
window.addEventListener('keydown', onEscape, true);
|
|
1046
|
-
return () => {
|
|
1047
|
-
globalThis.document.removeEventListener('pointerlockchange', onLockChange);
|
|
1048
|
-
stopped = true;
|
|
1049
|
-
stopStages();
|
|
1050
|
-
stopPrepareFrames?.();
|
|
1051
|
-
window.removeEventListener('keydown', onEscape, true);
|
|
1052
|
-
stopScript?.();
|
|
1053
|
-
// Only this run's animation is cleared: a restart's new run may already hold the slot.
|
|
1054
|
-
if (liveAnimation() === animation) setLiveAnimation(null);
|
|
1055
|
-
animation?.dispose();
|
|
1056
|
-
animation = null;
|
|
1057
|
-
layers.remove();
|
|
1058
|
-
if (orbit) orbit.enabled = true;
|
|
1059
|
-
};
|
|
1060
|
-
}, [playing, documentId, blend, view]);
|
|
645
|
+
}, [main, documentId, view]);
|
|
1061
646
|
/**
|
|
1062
647
|
* A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
|
|
1063
648
|
* on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
|
|
@@ -1095,20 +680,7 @@ function BlenderViewportArea({
|
|
|
1095
680
|
opened();
|
|
1096
681
|
return subscribeStageStores(opened);
|
|
1097
682
|
}, [main, documentId]);
|
|
1098
|
-
/**
|
|
1099
|
-
* THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
|
|
1100
|
-
* (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
|
|
1101
|
-
*
|
|
1102
|
-
* Blender's viewport overlay has a Bones checkbox (`View3DOverlay.
|
|
1103
|
-
* show_bones`, `rna_space.cc:5125-5129`, drawn by `space_view3d.py:7161`)
|
|
1104
|
-
* and this editor's Helpers menu already has a Skeletons row; a weight
|
|
1105
|
-
* display had no member and gained one (`weights`). Handing each group to
|
|
1106
|
-
* the stage through `setHelper` is the whole wiring: the host marks it
|
|
1107
|
-
* editor-owned — out of the hierarchy, out of the raycast — sets its
|
|
1108
|
-
* visibility from that kind's checkbox on the spot, keeps it across the
|
|
1109
|
-
* stage's remounts, and toggles it afterwards. Nothing here reads or
|
|
1110
|
-
* mirrors the toggle's state, which is why there is no second copy of it.
|
|
1111
|
-
*/
|
|
683
|
+
/** Register bone and weight overlays as helpers. The host owns their visibility, excludes them from hierarchy and raycast, and restores them across stage remounts. */
|
|
1112
684
|
useEffect(() => {
|
|
1113
685
|
if (!documentId) return;
|
|
1114
686
|
const groups = view.overlayGroups();
|
|
@@ -1135,8 +707,9 @@ function BlenderViewportArea({
|
|
|
1135
707
|
if (!documentId || !main) return;
|
|
1136
708
|
let live = true;
|
|
1137
709
|
void blenderSkin.sync(view, {
|
|
1138
|
-
clip: (armature) => blenderActionClip({ object: armature }),
|
|
710
|
+
clip: (armature) => blenderActionClip(armature === null ? {} : { object: armature }),
|
|
1139
711
|
bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
|
|
712
|
+
movie: () => blenderSceneMovie(),
|
|
1140
713
|
}).catch((thrown: unknown) => {
|
|
1141
714
|
if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1142
715
|
});
|
|
@@ -1218,7 +791,7 @@ function BlenderViewportArea({
|
|
|
1218
791
|
const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
|
|
1219
792
|
// Shading changes lighting, not which objects an editing viewport shows.
|
|
1220
793
|
// Only the detached game uses render visibility, as a clean capture does.
|
|
1221
|
-
view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting,
|
|
794
|
+
view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, 'viewport');
|
|
1222
795
|
// BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
|
|
1223
796
|
// presenter draws every surface by it (`blender-workbench-material.ts`).
|
|
1224
797
|
view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
|
|
@@ -1233,7 +806,7 @@ function BlenderViewportArea({
|
|
|
1233
806
|
view.holdRendered(null);
|
|
1234
807
|
view.setWorkbench(false);
|
|
1235
808
|
};
|
|
1236
|
-
}, [documentId, view
|
|
809
|
+
}, [documentId, view]);
|
|
1237
810
|
/**
|
|
1238
811
|
* SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
|
|
1239
812
|
* `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
|
|
@@ -1246,7 +819,7 @@ function BlenderViewportArea({
|
|
|
1246
819
|
*/
|
|
1247
820
|
const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
|
|
1248
821
|
const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
|
|
1249
|
-
|
|
822
|
+
event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
|
|
1250
823
|
const onPointerDownCapture = (event: ReactPointerEvent): void => {
|
|
1251
824
|
if (!cursorChord(event)) {
|
|
1252
825
|
cursorPress.current = null;
|
|
@@ -1294,9 +867,7 @@ function BlenderViewportArea({
|
|
|
1294
867
|
const Surface = surfaces.Object3DAuthoring;
|
|
1295
868
|
return (
|
|
1296
869
|
<div
|
|
1297
|
-
|
|
1298
|
-
data-play-ready={playing ? playReady : undefined}
|
|
1299
|
-
style={playing ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
|
|
870
|
+
style={{ display: 'contents' }}
|
|
1300
871
|
onPointerDownCapture={onPointerDownCapture}
|
|
1301
872
|
onPointerUpCapture={onPointerUpCapture}
|
|
1302
873
|
onPointerCancelCapture={dropCursorPress}
|
|
@@ -1305,7 +876,6 @@ function BlenderViewportArea({
|
|
|
1305
876
|
>
|
|
1306
877
|
<Surface
|
|
1307
878
|
active={active ?? true}
|
|
1308
|
-
fillContainer={playing}
|
|
1309
879
|
documentId={documentId}
|
|
1310
880
|
sourcePath={blend ?? 'blender:runtime'}
|
|
1311
881
|
displayName={document?.label ?? 'Model'}
|
|
@@ -1316,17 +886,8 @@ function BlenderViewportArea({
|
|
|
1316
886
|
cameraView={reads.cameraView}
|
|
1317
887
|
build={build}
|
|
1318
888
|
audit={false}
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
// were the PRESENTER's three.js graph — a reading of Blender's data by
|
|
1322
|
-
// the thing that photographs it — where Blender's own Outliner shows the
|
|
1323
|
-
// VIEW LAYER's datablock tree. `blender-outliner-authoring.ts` is the
|
|
1324
|
-
// provider; the default adapter it delegates to still answers everything
|
|
1325
|
-
// about the presentation (WORK.md §Blender in the tab is Blender,
|
|
1326
|
-
// "Inspection parity", I3).
|
|
1327
|
-
// A PLAYING COPY HAS NO AUTHORING: it is read-only to the editor, and what moves it is its
|
|
1328
|
-
// script. With none given the stage is the native read-only one.
|
|
1329
|
-
{...(playing ? {} : { authoring })}
|
|
889
|
+
// The authoring hierarchy is Blender’s View Layer Outliner; the default adapter supplies presentation access.
|
|
890
|
+
authoring={authoring}
|
|
1330
891
|
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
1331
892
|
// FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
|
|
1332
893
|
// three fits.
|
|
@@ -1342,33 +903,24 @@ function BlenderViewportArea({
|
|
|
1342
903
|
const saved = view.savedView();
|
|
1343
904
|
if (!saved) return null;
|
|
1344
905
|
const camera = { fov: blenderViewFieldOfView(saved.lens) };
|
|
906
|
+
// Solid and Wireframe use the saved view's resolved theme, World or viewport colour.
|
|
907
|
+
const behind = { backdrop: { source: 'color' as const, color: saved.background } };
|
|
1345
908
|
return main && saved.drawMode ? {
|
|
1346
909
|
camera, drawMode: saved.drawMode,
|
|
1347
|
-
modes: {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
910
|
+
modes: {
|
|
911
|
+
solid: behind,
|
|
912
|
+
wireframe: behind,
|
|
913
|
+
preview: {
|
|
914
|
+
lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
|
|
915
|
+
backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
} : { camera, modes: { solid: behind, wireframe: behind } };
|
|
1352
919
|
})()}
|
|
1353
920
|
// Every entry this document opens is a `model` stage, the standing `blender:runtime`
|
|
1354
921
|
// address included (its id carries no `model:` prefix).
|
|
1355
922
|
stageKind={documentKind}
|
|
1356
|
-
//
|
|
1357
|
-
// light: `light_ambient` is (0,0,0), there is no IBL, and the model is
|
|
1358
|
-
// lit by four studio lights stated in VIEW space. So this document
|
|
1359
|
-
// turns the editor's studio dressing off — its RoomEnvironment IBL and
|
|
1360
|
-
// its one warm key, which between them were the blow-out row 1
|
|
1361
|
-
// measured (the factory cube read (240,240,239)/(231,231,231)/
|
|
1362
|
-
// (227,226,225) against Blender's (141,143,145)/(129,131,131)/
|
|
1363
|
-
// (111,112,113), a spread of 13 against 30) — and hands the stage
|
|
1364
|
-
// Blender's own four instead. `keyLight: false` also stands down the
|
|
1365
|
-
// editor's design-time light rig, which answers the same question.
|
|
1366
|
-
// The BACKGROUND stays the dressing's, because the Blender palette
|
|
1367
|
-
// already paints the viewport its own flat grey.
|
|
1368
|
-
//
|
|
1369
|
-
// THE VIEW TRANSFORM IS EACH SHADING MODE'S OWN (`src/presentation.ts`: Standard for Solid,
|
|
1370
|
-
// AgX for Material Preview and Rendered), so the dressing states none: a document mapper
|
|
1371
|
-
// would outrank every mode's.
|
|
923
|
+
// Solid shading uses Blender’s four view-space studio lights without IBL or an editor key light. Keep the palette background; each shading mode owns its own view transform.
|
|
1372
924
|
dressing={{
|
|
1373
925
|
environment: false,
|
|
1374
926
|
keyLight: false,
|