@volter/editor-blender 0.1.150 → 0.1.151
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 +3 -7
- package/contributions/blender-node-editor.document.tsx +28 -180
- 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 +100 -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 +15 -90
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +15 -147
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +217 -339
- 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 +142 -332
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +43 -23
- 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 +63 -0
- package/contributions/blender.status.tsx +9 -145
- 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 +12 -32
- 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 -30
- package/host/blender-runtime-host.ts +541 -372
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +49 -93
- package/package.json +1209 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -43
- 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/model-play-extension.ts +173 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +64 -38
- 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,
|
|
@@ -57,6 +31,7 @@ import {
|
|
|
57
31
|
useLayoutEffect,
|
|
58
32
|
useMemo,
|
|
59
33
|
useRef,
|
|
34
|
+
type RefObject,
|
|
60
35
|
useState,
|
|
61
36
|
useSyncExternalStore,
|
|
62
37
|
} from 'react';
|
|
@@ -64,46 +39,51 @@ import * as THREE from 'three';
|
|
|
64
39
|
import {
|
|
65
40
|
bindModelDocument,
|
|
66
41
|
blenderExecute,
|
|
67
|
-
blenderRna,
|
|
68
|
-
blenderRnaContext,
|
|
69
42
|
subscribeBlenderRna,
|
|
70
43
|
blenderViewShading,
|
|
71
44
|
openModelDocumentBlend,
|
|
72
45
|
modelDocumentMayOpen,
|
|
73
46
|
modelDocumentOwnsPresentation,
|
|
74
47
|
blenderActionClip,
|
|
48
|
+
blenderSceneMovie,
|
|
75
49
|
blenderRnaVersion,
|
|
50
|
+
blenderPresentDocument,
|
|
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
|
-
import { noteModelDocument } from '../src/play-mode';
|
|
86
|
-
import {
|
|
87
|
-
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/
|
|
63
|
+
import { noteModelDocument, noteModelOpening } from '../src/play-mode';
|
|
64
|
+
import { type DocumentPlayEditArea, modelPlayExtension, subscribeModelPlayExtension } from '../src/model-play-extension';
|
|
65
|
+
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/workspace-document-registry';
|
|
88
66
|
import {
|
|
89
67
|
documentSecondAreaHeader,
|
|
90
68
|
setDocumentSecondArea,
|
|
91
69
|
subscribeDocumentAreas,
|
|
92
|
-
} from '@volter/sdk/
|
|
70
|
+
} from '@volter/sdk/document-areas';
|
|
93
71
|
import { createPortal } from 'react-dom';
|
|
94
72
|
import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
|
|
95
73
|
import {
|
|
96
74
|
object3DDocumentSession,
|
|
75
|
+
registerObject3DDocumentPreparation,
|
|
97
76
|
subscribeObject3DDocumentSessions,
|
|
98
77
|
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
99
78
|
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
100
|
-
import { stageStore, subscribeStageStores } from '@volter/sdk/
|
|
101
|
-
import { surfaceAcceptsKey } from '@volter/sdk/
|
|
79
|
+
import { stageStore, subscribeStageStores } from '@volter/sdk/stage-store-registry';
|
|
80
|
+
import { surfaceAcceptsKey } from '@volter/sdk/surface-keyboard';
|
|
102
81
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
103
82
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
104
|
-
import { commandLine } from '@volter/sdk/
|
|
105
|
-
import { fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
|
|
106
|
-
import {
|
|
83
|
+
import { commandLine } from '@volter/sdk/product-command';
|
|
84
|
+
import { Button, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
|
|
85
|
+
import { modelDocumentSaveNotice, subscribeModelDocumentSaving } from '../src/model-document-saving';
|
|
86
|
+
import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/startup-failure';
|
|
107
87
|
|
|
108
88
|
/** This document's model open, as a source in the page's startup-failure registry. */
|
|
109
89
|
const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
|
|
@@ -132,37 +112,28 @@ const overlaysOpened = new WeakSet<object>();
|
|
|
132
112
|
export const point = 'workspace.document';
|
|
133
113
|
export const title = 'Blender Model';
|
|
134
114
|
|
|
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
|
-
*/
|
|
115
|
+
/** Object-mode menus mounted in the document toolbar. */
|
|
142
116
|
export const Toolbar: ToolDocumentToolbar = ({ documentId, notify }) => (
|
|
143
117
|
<BlenderObjectModeHeader documentId={documentId} notify={notify} />
|
|
144
118
|
);
|
|
145
119
|
/** The kind this document EDITS: every `model` table entry opens here. */
|
|
146
120
|
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
|
-
*/
|
|
121
|
+
|
|
122
|
+
/** 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. */
|
|
123
|
+
export const workingCopy: DocumentWorkingCopy = {
|
|
124
|
+
dirty: (document) => document.source !== undefined && modelDocumentDirty(document.source.path),
|
|
125
|
+
subscribe: subscribeModelDocumentDirty,
|
|
126
|
+
save: (document) => saveModelDocument(blendOf(document)),
|
|
127
|
+
revert: (document) => revertModelDocument(blendOf(document)),
|
|
128
|
+
backup: (document) => snapshotModelDocument(blendOf(document)),
|
|
129
|
+
restore: (document, content) => restoreModelDocument(blendOf(document), content),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function blendOf(document: { readonly source?: { readonly path: string } }): string {
|
|
133
|
+
if (!document.source) throw new Error('A model document without a .blend has no working copy.');
|
|
134
|
+
return document.source.path;
|
|
135
|
+
}
|
|
136
|
+
/** 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
137
|
export const inspectorRail = 'owned';
|
|
167
138
|
export const inspectorBuiltins: readonly string[] = [];
|
|
168
139
|
|
|
@@ -244,6 +215,19 @@ function readsOf(view: AreaView): AreaReads {
|
|
|
244
215
|
return reads;
|
|
245
216
|
}
|
|
246
217
|
|
|
218
|
+
/** A canvas as a PNG data URL, encoded by `toBlob` (off the main thread); null when it cannot be. */
|
|
219
|
+
function pngDataUrl(canvas: HTMLCanvasElement): Promise<string | null> {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
canvas.toBlob((blob) => {
|
|
222
|
+
if (!blob) { resolve(null); return; }
|
|
223
|
+
const reader = new FileReader();
|
|
224
|
+
reader.onload = () => resolve(String(reader.result));
|
|
225
|
+
reader.onerror = () => resolve(null);
|
|
226
|
+
reader.readAsDataURL(blob);
|
|
227
|
+
}, 'image/png');
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
247
231
|
export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
248
232
|
const { active, document, documentId, notify, publishContext } = props;
|
|
249
233
|
const blend = document?.source?.path;
|
|
@@ -253,6 +237,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
253
237
|
/** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
|
|
254
238
|
const [stillOpening, setStillOpening] = useState<string | null>(null);
|
|
255
239
|
const [attempt, setAttempt] = useState(0);
|
|
240
|
+
const saveNotice = useSyncExternalStore(subscribeModelDocumentSaving, () => modelDocumentSaveNotice(blend), () => null);
|
|
256
241
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
257
242
|
const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
|
|
258
243
|
const viewportVersion = useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
|
|
@@ -261,6 +246,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
261
246
|
callbacks.current = { notify, publishContext };
|
|
262
247
|
|
|
263
248
|
useEffect(() => editorHost().session.onEnded(clearModelDocumentPreview), []);
|
|
249
|
+
// WHILE ITS FILE OPENS, the Game panel's verbs wait for this document rather than answer that no model is open
|
|
250
|
+
// (`../src/play-mode.ts`, `noteModelOpening`); once presented, its viewport serves the panel (`noteModelDocument`).
|
|
251
|
+
const presented = opened?.key === key;
|
|
252
|
+
useEffect(() => (active === false || !documentId || presented ? undefined : noteModelOpening(documentId)), [active, documentId, presented]);
|
|
264
253
|
// Retain a bounded photograph while the stage is healthy. Teardown can
|
|
265
254
|
// already have changed its scene/camera, so it is too late to re-render then.
|
|
266
255
|
useEffect(() => {
|
|
@@ -285,7 +274,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
285
274
|
const context = copy.getContext('2d');
|
|
286
275
|
if (context) {
|
|
287
276
|
context.drawImage(frame, 0, 0, copy.width, copy.height);
|
|
288
|
-
|
|
277
|
+
// Encode the PNG with toBlob off the main thread to keep Play frames responsive.
|
|
278
|
+
const image = await pngDataUrl(copy);
|
|
279
|
+
if (cancelled || !image) return;
|
|
280
|
+
lastFrame.current = { project, entryId, path: blend ?? 'Model', image, capturedAt: performance.now() };
|
|
289
281
|
rememberModelDocumentPreview(lastFrame.current);
|
|
290
282
|
}
|
|
291
283
|
}
|
|
@@ -334,7 +326,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
334
326
|
declined = false;
|
|
335
327
|
try {
|
|
336
328
|
if (binding) {
|
|
337
|
-
|
|
329
|
+
const reload = attempt > 0 && modelDocumentSaveNotice(binding.blend) !== null;
|
|
330
|
+
if (!await openModelDocumentBlend(binding, publish, reload)) { declined = true; return; }
|
|
338
331
|
} else {
|
|
339
332
|
// The standing Model document is the explicit blender-start target.
|
|
340
333
|
publish();
|
|
@@ -365,10 +358,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
365
358
|
starting = false;
|
|
366
359
|
}
|
|
367
360
|
};
|
|
368
|
-
// A
|
|
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.
|
|
361
|
+
// A long open reports progress while its request runs. Retry is offered only for an actual failure, avoiding a second queued open.
|
|
372
362
|
const slow = setInterval(() => {
|
|
373
363
|
if (cancelled || finished) return;
|
|
374
364
|
setStillOpening(declined
|
|
@@ -409,6 +399,17 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
409
399
|
/>;
|
|
410
400
|
return <>
|
|
411
401
|
<BlenderModelViewport {...props} />
|
|
402
|
+
{saveNotice && <div role="alert" data-testid="blender-model-not-saving"
|
|
403
|
+
style={{ position: 'absolute', top: spaceVar[3], left: spaceVar[3], right: spaceVar[3], zIndex: 2,
|
|
404
|
+
background: themeVars.surface.chrome, color: themeVars.content.primary,
|
|
405
|
+
border: `1px solid ${themeVars.semantic.warning}`, padding: spaceVar[3], fontSize: fontSizeVar.sm }}>
|
|
406
|
+
<strong>Model is not being saved</strong>
|
|
407
|
+
<div style={{ marginTop: spaceVar[2], marginBottom: spaceVar[3], userSelect: 'text' }}>{saveNotice}</div>
|
|
408
|
+
<div style={{ marginBottom: spaceVar[3] }}>Reopening replaces this scene with the last saved file.</div>
|
|
409
|
+
<Button variant="secondary" onClick={() => { setOpened(null); setAttempt(value => value + 1); }}>
|
|
410
|
+
Reopen saved document
|
|
411
|
+
</Button>
|
|
412
|
+
</div>}
|
|
412
413
|
{!documentViewport(documentId) && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
|
|
413
414
|
preparingView preview={preview} retry={() => {}} returnToPreview={() => {}} />}
|
|
414
415
|
</>;
|
|
@@ -422,79 +423,73 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
422
423
|
* camera. Each area has its own stage, shading, navigation and camera view; the model, its
|
|
423
424
|
* selection and its history are the document's.
|
|
424
425
|
*
|
|
425
|
-
* PLAYING (the composed Play tool), the area shows a DETACHED copy of the model
|
|
426
|
-
* (`BlenderRuntimeView.detach`) on a stage of its own,
|
|
427
|
-
*
|
|
428
|
-
*
|
|
426
|
+
* PLAYING (the composed Play tool), the area shows a DETACHED copy of the model's World
|
|
427
|
+
* (`BlenderRuntimeView.detach`) on a stage of its own, where the level's game draws (the document Play extension).
|
|
428
|
+
* The model's own stage stays mounted underneath, hidden, so Stop returns to the same view of the same model:
|
|
429
|
+
* nothing a game does reaches Blender.
|
|
429
430
|
*/
|
|
430
431
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
431
432
|
const { documentId } = props;
|
|
432
433
|
const blend = props.document?.source?.path;
|
|
433
434
|
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
|
|
434
|
-
// the Game panel drives this id's run, and Game or
|
|
435
|
+
// the Game panel drives this id's run, and Game or Animation is chosen by whether this level has a game.
|
|
435
436
|
useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
|
|
436
|
-
// Rendered areas and Play use the
|
|
437
|
-
// photograph does. Solid keeps its studio transform. RNA also announces
|
|
438
|
-
// colour settings that change without changing the presented geometry.
|
|
437
|
+
// Rendered areas and Play 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
438
|
useEffect(() => {
|
|
440
439
|
if (!documentId) return;
|
|
441
440
|
let cancelled = false;
|
|
442
|
-
let
|
|
443
|
-
let
|
|
441
|
+
let applied: BlenderDisplayTransform | null = null;
|
|
442
|
+
let wanted: string | null = null;
|
|
443
|
+
let settled: Promise<void> = Promise.resolve();
|
|
444
444
|
const ids = [documentId, `${documentId}#area-2`, `${documentId}#play`];
|
|
445
445
|
const attach = () => {
|
|
446
|
-
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied
|
|
446
|
+
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied);
|
|
447
447
|
};
|
|
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
|
-
};
|
|
448
|
+
const follow = (): Promise<void> => {
|
|
449
|
+
const display = view.displaySettings();
|
|
450
|
+
if (!display) return settled;
|
|
473
451
|
const key = JSON.stringify(display);
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
452
|
+
if (key === wanted) return settled;
|
|
453
|
+
wanted = key;
|
|
454
|
+
settled = (async () => {
|
|
455
|
+
if (display.device !== 'sRGB')
|
|
456
|
+
throw new Error(`Blender display device ${display.device} has no browser display processor yet`);
|
|
457
|
+
const resolved = await createBlenderDisplayTransform(display);
|
|
458
|
+
if (cancelled || wanted !== key) { resolved.dispose(); return; }
|
|
459
|
+
const previous = applied;
|
|
460
|
+
applied = resolved;
|
|
461
|
+
attach();
|
|
462
|
+
previous?.dispose();
|
|
463
|
+
for (const id of ids) {
|
|
464
|
+
setViewPresentation(id, { modes: {
|
|
465
|
+
rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
466
|
+
preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
|
|
467
|
+
} });
|
|
468
|
+
}
|
|
469
|
+
})().catch((error: unknown) => editorHost().console.error(String(error), 'blender-colour'));
|
|
470
|
+
return settled;
|
|
487
471
|
};
|
|
488
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
472
|
+
// A photograph of each shading type is anti-aliased as Blender's viewport accumulates it.
|
|
473
|
+
const sample = () => {
|
|
474
|
+
const sampling = view.photographSampling();
|
|
475
|
+
for (const id of ids) object3DDocumentSession(id)?.setPhotographSampling(sampling);
|
|
476
|
+
};
|
|
477
|
+
const stopFrames = view.subscribeFrames(() => { sample(); void follow(); });
|
|
478
|
+
const stopSessions = subscribeObject3DDocumentSessions(() => { attach(); sample(); });
|
|
479
|
+
sample();
|
|
480
|
+
// A view presented on this document is drawn (and photographed) once the frame on screen has
|
|
481
|
+
// its display transform and everything it is still decoding or settling (`drawReady`).
|
|
482
|
+
const stopPreparation = registerObject3DDocumentPreparation(documentId, async () => {
|
|
483
|
+
await follow();
|
|
484
|
+
await view.drawReady();
|
|
485
|
+
});
|
|
486
|
+
void follow();
|
|
492
487
|
return () => {
|
|
493
|
-
cancelled = true;
|
|
488
|
+
cancelled = true; stopFrames(); stopSessions(); stopPreparation();
|
|
494
489
|
const previous = applied;
|
|
495
490
|
applied = null;
|
|
496
491
|
attach();
|
|
497
|
-
previous?.
|
|
492
|
+
previous?.dispose();
|
|
498
493
|
};
|
|
499
494
|
}, [documentId]);
|
|
500
495
|
const split = useSyncExternalStore(
|
|
@@ -518,17 +513,26 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
518
513
|
};
|
|
519
514
|
}, [split, documentId]);
|
|
520
515
|
const playing = useSyncExternalStore(
|
|
521
|
-
|
|
522
|
-
() => (documentId ?
|
|
516
|
+
subscribeModelPlayExtension,
|
|
517
|
+
() => (documentId ? modelPlayExtension()?.playing(documentId) ?? false : false),
|
|
523
518
|
() => false,
|
|
524
519
|
);
|
|
525
520
|
const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
|
|
526
521
|
const playAspect = useSyncExternalStore(
|
|
527
|
-
|
|
528
|
-
() => (documentId ?
|
|
522
|
+
subscribeModelPlayExtension,
|
|
523
|
+
() => (documentId ? modelPlayExtension()?.aspectRatio?.(documentId) ?? null : null),
|
|
529
524
|
() => null,
|
|
530
525
|
);
|
|
531
526
|
const [playedReady, setPlayedReady] = useState(false);
|
|
527
|
+
/** THE PLAY OVERLAY: a layer over the editing area and the play area alike, where the Play tool puts its view
|
|
528
|
+
* controls (`DocumentPlayStage.overlay`), so they stand whichever area is in front. It takes no press itself;
|
|
529
|
+
* the controls in it take their own. */
|
|
530
|
+
const playOverlay = useRef<HTMLDivElement>(null);
|
|
531
|
+
/** WHERE THE MODEL'S OWN AREA STANDS WHILE THE GAME RUNS (`DocumentPlayStage.edit`): hidden behind the game, live in
|
|
532
|
+
* front of it, or drawing in a box of the stage (a tile), taking no press; null while the tool has placed it
|
|
533
|
+
* nowhere, and again when the game ends. A tool that places it switches it at once; otherwise it fades as the game
|
|
534
|
+
* draws its first frame. */
|
|
535
|
+
const [editArea, setEditArea] = useState<DocumentPlayEditArea | null>(null);
|
|
532
536
|
/**
|
|
533
537
|
* RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
|
|
534
538
|
* below is detached per generation, so a restart disposes the played copy and detaches a fresh
|
|
@@ -540,12 +544,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
540
544
|
* therefore taken in two steps: a layout effect photographs the old play stage while it still
|
|
541
545
|
* stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
|
|
542
546
|
* 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
|
|
544
|
-
* the current generation, stands still meanwhile (`play-script.ts`).
|
|
547
|
+
* frame (`onPlayReady`), and the model area stays hidden throughout.
|
|
545
548
|
*/
|
|
546
549
|
const generation = useSyncExternalStore(
|
|
547
|
-
|
|
548
|
-
() => (documentId ?
|
|
550
|
+
subscribeModelPlayExtension,
|
|
551
|
+
() => (documentId ? modelPlayExtension()?.transport?.generation(documentId) ?? 0 : 0),
|
|
549
552
|
() => 0,
|
|
550
553
|
);
|
|
551
554
|
const [shownGeneration, setShownGeneration] = useState(generation);
|
|
@@ -573,7 +576,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
573
576
|
* update), so the cover would stand over it while the panel said Playing. The runner says so
|
|
574
577
|
* on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
|
|
575
578
|
*/
|
|
576
|
-
const transport =
|
|
579
|
+
const transport = modelPlayExtension()?.transport;
|
|
577
580
|
const subscribeFailure = useCallback(
|
|
578
581
|
(listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
|
|
579
582
|
[transport],
|
|
@@ -599,6 +602,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
599
602
|
const playId = `${documentId}#play`;
|
|
600
603
|
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
601
604
|
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
605
|
+
// The game brings its own objects: the copy is the World alone.
|
|
602
606
|
const copy = view.detach();
|
|
603
607
|
const restart = restarting.current;
|
|
604
608
|
restarting.current = false;
|
|
@@ -613,17 +617,20 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
613
617
|
return () => {
|
|
614
618
|
// Read at cleanup: the layout effect above has already marked a restart, whose new copy
|
|
615
619
|
// takes over in this same commit with the play mode unchanged.
|
|
616
|
-
if (restarting.current &&
|
|
620
|
+
if (restarting.current && modelPlayExtension()?.playing(documentId)) {
|
|
617
621
|
retired.current.push(copy);
|
|
618
622
|
return;
|
|
619
623
|
}
|
|
620
624
|
copy.dispose();
|
|
621
625
|
for (const old of retired.current.splice(0)) old.dispose();
|
|
622
626
|
awaitingDraw.current = false;
|
|
627
|
+
setEditArea(null);
|
|
623
628
|
setPlayed(null);
|
|
624
629
|
setRestartStill(null);
|
|
625
630
|
view.setPlaying(false);
|
|
626
631
|
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
632
|
+
// Present the document after Play ends. Preserve action revisions so the next edit does not rebake unchanged clips.
|
|
633
|
+
void blenderPresentDocument().catch(() => {});
|
|
627
634
|
};
|
|
628
635
|
}, [playing, documentId, shownGeneration]);
|
|
629
636
|
useEffect(() => {
|
|
@@ -636,7 +643,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
636
643
|
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
637
644
|
useEffect(() => {
|
|
638
645
|
if (!documentId) return;
|
|
639
|
-
return () =>
|
|
646
|
+
return () => modelPlayExtension()?.setPlaying(documentId, false);
|
|
640
647
|
}, [documentId]);
|
|
641
648
|
const game = playing ? played : null;
|
|
642
649
|
const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
|
|
@@ -650,7 +657,8 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
650
657
|
if (!modelViewport) return;
|
|
651
658
|
// registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
|
|
652
659
|
const capture = modelViewport.capture;
|
|
653
|
-
const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) =>
|
|
660
|
+
const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) =>
|
|
661
|
+
documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
|
|
654
662
|
modelViewport.capture = playCapture;
|
|
655
663
|
return () => {
|
|
656
664
|
if (modelViewport.capture !== playCapture) return;
|
|
@@ -658,22 +666,23 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
658
666
|
else delete modelViewport.capture;
|
|
659
667
|
};
|
|
660
668
|
}, [game, documentId]);
|
|
669
|
+
// (the model's keys are the player's while the game is in front, and the editor's while the area is live)
|
|
670
|
+
useEffect(() => { if (game) view.setPlaying(editArea !== 'live'); }, [game, editArea, view]);
|
|
661
671
|
if (!documentId) return null;
|
|
662
672
|
const second = split ? follower : null;
|
|
663
673
|
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
664
674
|
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
665
675
|
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const hidden = game !== null && playedReady;
|
|
676
|
+
// Make the hidden Model area inert so its subtree receives neither pointer presses nor focus. A live overlay or tile uses the box supplied by the Play tool.
|
|
677
|
+
const tile = game !== null && typeof editArea === 'object' ? editArea : null;
|
|
678
|
+
const hidden = game !== null && playedReady && (editArea === null || editArea === 'hidden');
|
|
679
|
+
const modelBox = tile === null ? { inset: 12 } : {
|
|
680
|
+
left: `calc(12px + (100% - 24px) * ${tile.left})`, top: `calc(12px + (100% - 24px) * ${tile.top})`,
|
|
681
|
+
width: `calc((100% - 24px) * ${tile.width})`, height: `calc((100% - 24px) * ${tile.height})`,
|
|
682
|
+
};
|
|
674
683
|
return (
|
|
675
684
|
<div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
676
|
-
<div key="model" inert={hidden || undefined} style={game ? { position: 'absolute',
|
|
685
|
+
<div key="model" inert={hidden || tile !== null || undefined} style={game ? { position: 'absolute', ...modelBox, zIndex: 2, opacity: hidden ? 0 : 1, pointerEvents: hidden || tile !== null ? 'none' : 'auto', transition: editArea === null ? 'opacity 160ms ease-out' : 'none' } : second ? area : { display: 'contents' }}>
|
|
677
686
|
<BlenderViewportArea {...props} view={view} main />
|
|
678
687
|
</div>
|
|
679
688
|
{game && (
|
|
@@ -682,15 +691,14 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
682
691
|
<div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
|
|
683
692
|
<div data-volter-play-frame style={playFrame}>
|
|
684
693
|
<BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
|
|
685
|
-
onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }}
|
|
694
|
+
onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }}
|
|
695
|
+
onPlayEdit={setEditArea} playOverlay={playOverlay} />
|
|
686
696
|
</div>
|
|
687
697
|
</div>
|
|
688
698
|
)}
|
|
699
|
+
{game && <div key="play-overlay" ref={playOverlay} data-testid="blender-play-overlay" style={{ position: 'absolute', inset: 12, zIndex: 5, pointerEvents: 'none' }} />}
|
|
689
700
|
{game && failure !== null && (
|
|
690
|
-
//
|
|
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.
|
|
701
|
+
// Display a failed Play start over the area until it draws; the Game panel repeats the same refusal.
|
|
694
702
|
<div key="play-failure" role="alert" data-testid="blender-play-failure"
|
|
695
703
|
style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
696
704
|
background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
|
|
@@ -713,17 +721,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
713
721
|
);
|
|
714
722
|
}
|
|
715
723
|
|
|
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
724
|
/**
|
|
723
725
|
* THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
|
|
724
726
|
*
|
|
725
727
|
* The area is declared to the host as the document's second area
|
|
726
|
-
* (`@volter/sdk/
|
|
728
|
+
* (`@volter/sdk/document-areas`). Its HEADER stands in the document's header row over
|
|
727
729
|
* this area: the host reserves this area's width at the row's trailing edge, so the first area's
|
|
728
730
|
* header ends where the first area does, and this renders into the region it draws there: the
|
|
729
731
|
* editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
|
|
@@ -789,17 +791,19 @@ function BlenderViewportArea({
|
|
|
789
791
|
main,
|
|
790
792
|
playing = false,
|
|
791
793
|
onPlayReady,
|
|
792
|
-
|
|
793
|
-
|
|
794
|
+
onPlayEdit,
|
|
795
|
+
playOverlay,
|
|
796
|
+
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void;
|
|
797
|
+
readonly onPlayEdit?: (area: DocumentPlayEditArea) => void; readonly playOverlay?: RefObject<HTMLDivElement | null> }) {
|
|
794
798
|
const reads = readsOf(view);
|
|
795
799
|
const build = useCallback(
|
|
796
800
|
() => ({
|
|
797
801
|
root: view.root,
|
|
798
|
-
prepareDraw: (camera: THREE.Camera, options?:
|
|
802
|
+
prepareDraw: (camera: THREE.Camera, options?: BlenderDrawOptions) => view.prepareDraw(camera, options),
|
|
799
803
|
// What the view draws changes with each frame and the work after it,
|
|
800
|
-
// and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing
|
|
801
|
-
//
|
|
802
|
-
//
|
|
804
|
+
// and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing, and
|
|
805
|
+
// a source that announces nothing is drawn every frame (`ToolObject3DPreviewSource.onChange`):
|
|
806
|
+
// the play stage's frames are what start the Play tool's run once the stage stands.
|
|
803
807
|
...(playing
|
|
804
808
|
? {}
|
|
805
809
|
: {
|
|
@@ -872,9 +876,8 @@ function BlenderViewportArea({
|
|
|
872
876
|
};
|
|
873
877
|
}, [main, playing, documentId, view]);
|
|
874
878
|
/**
|
|
875
|
-
* THE
|
|
876
|
-
*
|
|
877
|
-
* so the script's camera is not argued with, and Escape stops the game.
|
|
879
|
+
* THE LEVEL'S GAME PLAYS IN THIS STAGE (the document Play extension), once the stage stands: the Play tool draws the
|
|
880
|
+
* game in its container, in place of the hidden copy, and Escape stops the game.
|
|
878
881
|
*/
|
|
879
882
|
const notifyRef = useRef(notify);
|
|
880
883
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -882,15 +885,15 @@ function BlenderViewportArea({
|
|
|
882
885
|
notifyRef.current = notify;
|
|
883
886
|
const playReadyRef = useRef(onPlayReady);
|
|
884
887
|
playReadyRef.current = onPlayReady;
|
|
885
|
-
const
|
|
886
|
-
|
|
888
|
+
const playEditRef = useRef(onPlayEdit);
|
|
889
|
+
playEditRef.current = onPlayEdit;
|
|
887
890
|
useEffect(() => {
|
|
888
891
|
if (!playing || !documentId) return;
|
|
889
892
|
if (blend === undefined) {
|
|
890
893
|
notifyRef.current?.({
|
|
891
894
|
tone: 'error',
|
|
892
|
-
title: 'This model has no
|
|
893
|
-
detail: 'A
|
|
895
|
+
title: 'This model has no game',
|
|
896
|
+
detail: 'A level\'s game is its project\'s, and this model has no file of its own in the project.',
|
|
894
897
|
});
|
|
895
898
|
return;
|
|
896
899
|
}
|
|
@@ -901,116 +904,26 @@ function BlenderViewportArea({
|
|
|
901
904
|
surfaceRef.current?.appendChild(layers);
|
|
902
905
|
setPlayReady(false);
|
|
903
906
|
let stopped = false;
|
|
904
|
-
let preparing = false;
|
|
905
907
|
let stopScript: (() => void) | null = null;
|
|
906
|
-
let
|
|
907
|
-
let animation: PlayAnimation | null = null;
|
|
908
|
+
let hiddenCopy: boolean | null = null;
|
|
908
909
|
const start = (): void => {
|
|
909
|
-
if (stopScript ||
|
|
910
|
+
if (stopScript || stopped) return;
|
|
910
911
|
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
911
912
|
if (!stage || !view.root.parent) return;
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
});
|
|
913
|
+
hiddenCopy = view.root.visible;
|
|
914
|
+
view.root.visible = false;
|
|
915
|
+
stopScript = modelPlayExtension()?.run({
|
|
916
|
+
documentId: modelId,
|
|
917
|
+
container: layers,
|
|
918
|
+
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
919
|
+
edit: (area) => { if (!stopped) playEditRef.current?.(area); },
|
|
920
|
+
...(playOverlay?.current ? { overlay: playOverlay.current } : {}),
|
|
921
|
+
sourcePath: blend,
|
|
922
|
+
report: (title, detail) => {
|
|
923
|
+
editorHost().console.error(`${title}: ${detail}`, 'blender-play');
|
|
924
|
+
notifyRef.current?.({ tone: 'error', title, detail });
|
|
925
|
+
},
|
|
926
|
+
}) ?? null;
|
|
1014
927
|
};
|
|
1015
928
|
start();
|
|
1016
929
|
let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
|
|
@@ -1027,14 +940,13 @@ function BlenderViewportArea({
|
|
|
1027
940
|
const stopStages = onViewportStages(waitForDraw);
|
|
1028
941
|
const onEscape = (event: KeyboardEvent): void => {
|
|
1029
942
|
if (event.key !== 'Escape' || !surfaceAcceptsKey(event)) return;
|
|
1030
|
-
// Decided once the event has finished dispatching: a
|
|
943
|
+
// Decided once the event has finished dispatching: a game that claims Escape (a
|
|
1031
944
|
// 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.
|
|
1033
|
-
// (three playtests of a Cyclotron game lost a mission each to it).
|
|
945
|
+
// frees a game's mouse look is the person taking their mouse back. Neither stops Play.
|
|
1034
946
|
if (globalThis.document.pointerLockElement || performance.now() - lastPointerUnlockAt < 500) return;
|
|
1035
947
|
setTimeout(() => {
|
|
1036
948
|
if (event.defaultPrevented) return;
|
|
1037
|
-
const extension =
|
|
949
|
+
const extension = modelPlayExtension();
|
|
1038
950
|
if (extension?.escape) extension.escape(modelId);
|
|
1039
951
|
else extension?.setPlaying(modelId, false);
|
|
1040
952
|
}, 0);
|
|
@@ -1050,12 +962,8 @@ function BlenderViewportArea({
|
|
|
1050
962
|
stopPrepareFrames?.();
|
|
1051
963
|
window.removeEventListener('keydown', onEscape, true);
|
|
1052
964
|
stopScript?.();
|
|
1053
|
-
|
|
1054
|
-
if (liveAnimation() === animation) setLiveAnimation(null);
|
|
1055
|
-
animation?.dispose();
|
|
1056
|
-
animation = null;
|
|
965
|
+
if (hiddenCopy !== null) { view.root.visible = hiddenCopy; hiddenCopy = null; }
|
|
1057
966
|
layers.remove();
|
|
1058
|
-
if (orbit) orbit.enabled = true;
|
|
1059
967
|
};
|
|
1060
968
|
}, [playing, documentId, blend, view]);
|
|
1061
969
|
/**
|
|
@@ -1095,20 +1003,7 @@ function BlenderViewportArea({
|
|
|
1095
1003
|
opened();
|
|
1096
1004
|
return subscribeStageStores(opened);
|
|
1097
1005
|
}, [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
|
-
*/
|
|
1006
|
+
/** 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
1007
|
useEffect(() => {
|
|
1113
1008
|
if (!documentId) return;
|
|
1114
1009
|
const groups = view.overlayGroups();
|
|
@@ -1135,8 +1030,9 @@ function BlenderViewportArea({
|
|
|
1135
1030
|
if (!documentId || !main) return;
|
|
1136
1031
|
let live = true;
|
|
1137
1032
|
void blenderSkin.sync(view, {
|
|
1138
|
-
clip: (armature) => blenderActionClip({ object: armature }),
|
|
1033
|
+
clip: (armature) => blenderActionClip(armature === null ? {} : { object: armature }),
|
|
1139
1034
|
bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
|
|
1035
|
+
movie: () => blenderSceneMovie(),
|
|
1140
1036
|
}).catch((thrown: unknown) => {
|
|
1141
1037
|
if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
|
|
1142
1038
|
});
|
|
@@ -1316,16 +1212,7 @@ function BlenderViewportArea({
|
|
|
1316
1212
|
cameraView={reads.cameraView}
|
|
1317
1213
|
build={build}
|
|
1318
1214
|
audit={false}
|
|
1319
|
-
//
|
|
1320
|
-
// reason the Properties rail is Blender's rail: the rows this panel drew
|
|
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.
|
|
1215
|
+
// The authoring hierarchy is Blender’s View Layer Outliner; the default adapter supplies presentation access. A playing copy has no authoring adapter and uses the read-only stage.
|
|
1329
1216
|
{...(playing ? {} : { authoring })}
|
|
1330
1217
|
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
1331
1218
|
// FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
|
|
@@ -1342,33 +1229,24 @@ function BlenderViewportArea({
|
|
|
1342
1229
|
const saved = view.savedView();
|
|
1343
1230
|
if (!saved) return null;
|
|
1344
1231
|
const camera = { fov: blenderViewFieldOfView(saved.lens) };
|
|
1232
|
+
// Solid and Wireframe use the saved view's resolved theme, World or viewport colour.
|
|
1233
|
+
const behind = { backdrop: { source: 'color' as const, color: saved.background } };
|
|
1345
1234
|
return main && saved.drawMode ? {
|
|
1346
1235
|
camera, drawMode: saved.drawMode,
|
|
1347
|
-
modes: {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1236
|
+
modes: {
|
|
1237
|
+
solid: behind,
|
|
1238
|
+
wireframe: behind,
|
|
1239
|
+
preview: {
|
|
1240
|
+
lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
|
|
1241
|
+
backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
|
|
1242
|
+
},
|
|
1243
|
+
},
|
|
1244
|
+
} : { camera, modes: { solid: behind, wireframe: behind } };
|
|
1352
1245
|
})()}
|
|
1353
1246
|
// Every entry this document opens is a `model` stage, the standing `blender:runtime`
|
|
1354
1247
|
// address included (its id carries no `model:` prefix).
|
|
1355
1248
|
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.
|
|
1249
|
+
// 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
1250
|
dressing={{
|
|
1373
1251
|
environment: false,
|
|
1374
1252
|
keyLight: false,
|