@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,45 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE TIMELINE — Blender's, at the bottom of the Model workspace (owner,
|
|
3
|
-
* 2026-09-20: "doesn't blender have a timeline at the bottom?"). It does: the
|
|
4
|
-
* default Layout screen's `DOPESHEET_EDITOR` in `TIMELINE` mode, and the model
|
|
5
|
-
* editor is not Blender-shaped without it.
|
|
6
|
-
*
|
|
7
|
-
* ## It is an AREA, so it is an editor group
|
|
8
|
-
*
|
|
9
|
-
* RULED 2026-09-19 (orchestrator): "A Blender editor AREA is an editor group;
|
|
10
|
-
* the drawer holds utilities." Blender's Layout screen puts the Timeline
|
|
11
|
-
* full-width UNDER the 3D viewport, and `model.layout.ts` declares it as an
|
|
12
|
-
* `areas` entry with Blender's own measured proportion. This is a
|
|
13
|
-
* `workspace.document`, never a drawer utility.
|
|
14
|
-
*
|
|
15
|
-
* ## THREE.JS PLAYS IT; BLENDER HOLDS IT
|
|
16
|
-
*
|
|
17
|
-
* Owner rule, 2026-09-20: "we visualize with three.js, not Blender." The scrub
|
|
18
|
-
* does NOT ask Blender to evaluate frame N and re-export the mesh — it moves an
|
|
19
|
-
* `AnimationMixer` over a `SkinnedMesh` the presenter bound once
|
|
20
|
-
* (`blender-runtime-skin.ts`), so a scrub and a played frame cost ZERO calls
|
|
21
|
-
* into the engine. `state` reports `engineCalls` precisely so that claim is
|
|
22
|
-
* checkable rather than asserted.
|
|
23
|
-
*
|
|
24
|
-
* Blender's `scene.frame_current` IS written — ONCE, on pause and at
|
|
25
|
-
* scrub-end, through the RNA door — so bpy readers and the Properties rail
|
|
26
|
-
* agree with what the person is looking at. Never per frame.
|
|
27
|
-
*
|
|
28
|
-
* ## Every constant it draws with is `./blender-timeline-geometry.ts`
|
|
29
|
-
*
|
|
30
|
-
* Read from Blender's source at the engine's pin AND confirmed against a pixel
|
|
31
|
-
* in Blender's own frame — this is the first unit of the inspection arc with a
|
|
32
|
-
* sighted read behind it (`scripts/blender-reference-frames.py`).
|
|
33
|
-
*
|
|
34
|
-
* ## It never edits, and every gesture that would is refused BY NAME
|
|
35
|
-
*
|
|
36
|
-
* Setting the range, inserting a key and moving a key all write the file.
|
|
37
|
-
* `frame` is the ONE verb that changes anything, and what it changes is where
|
|
38
|
-
* you are looking.
|
|
39
|
-
*/
|
|
1
|
+
/** The Timeline is the Model workspace’s bottom editor area. three.js plays animation; Blender’s frame bookmark is written only at pause or scrub end. Geometry comes from blender-timeline-geometry.ts. Transport and frame navigation are supported; range and key edits refuse by name. */
|
|
40
2
|
|
|
41
|
-
import type
|
|
42
|
-
import {
|
|
3
|
+
import { editorHost, type StageTransportHandle } from '@volter/sdk/host';
|
|
4
|
+
import type { ViewVerb } from '@volter/sdk/commands';
|
|
43
5
|
import {
|
|
44
6
|
type ReactNode,
|
|
45
7
|
useCallback,
|
|
@@ -64,10 +26,9 @@ import {
|
|
|
64
26
|
timelineViewVersion,
|
|
65
27
|
} from '../src/timeline-view-state';
|
|
66
28
|
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
67
|
-
import {
|
|
29
|
+
import { PLAY_PANEL_RATIO, PlayModeSwitch } from './blender-play-area';
|
|
30
|
+
import { modelPlayExtension, subscribeModelPlayExtension } from '../src/model-play-extension';
|
|
68
31
|
import { AnimationEditorMenu, BlenderActionEditor, BlenderNlaEditor } from './blender-animation-editors';
|
|
69
|
-
import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
|
|
70
|
-
import { setWorkspaceAreaRatio } from '@volter/sdk/kit/workspace-areas';
|
|
71
32
|
import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
|
|
72
33
|
import {
|
|
73
34
|
CHANNEL_HEIGHT,
|
|
@@ -90,11 +51,7 @@ import {
|
|
|
90
51
|
} from './blender-timeline-geometry';
|
|
91
52
|
|
|
92
53
|
export const point = 'workspace.document';
|
|
93
|
-
/** Blender
|
|
94
|
-
* reads "Timeline" when `SpaceAction.mode` is `TIMELINE`
|
|
95
|
-
* (`rna_space.cc:257`), which is the mode the Layout workspace opens it in
|
|
96
|
-
* (measured on the engine: that screen's `DOPESHEET_EDITOR` area answers
|
|
97
|
-
* `ui_type: 'TIMELINE'`). */
|
|
54
|
+
/** Blender’s SPACE_ACTION title is Timeline when SpaceAction.mode is TIMELINE. */
|
|
98
55
|
export const title = 'Timeline';
|
|
99
56
|
|
|
100
57
|
const ZOOM_MIN = 0.25;
|
|
@@ -116,20 +73,10 @@ const REFUSALS = {
|
|
|
116
73
|
|
|
117
74
|
/** The scene clock is available with or without an action. Only a missing
|
|
118
75
|
* scene/transport prevents timeline navigation. */
|
|
119
|
-
const GAME_RUNNING = 'A game is running; the Timeline is read-only until it stops (Stop in the Game panel).';
|
|
120
76
|
const NOT_PLAYABLE =
|
|
121
77
|
'No scene transport is attached yet; open the model document first.';
|
|
122
78
|
|
|
123
|
-
/**
|
|
124
|
-
* THE TRANSPORT THIS LOOK DRIVES — the Model document's, attached by
|
|
125
|
-
* `blenderSkin.attachTo` (Step 6). The Timeline cannot name a document id (it
|
|
126
|
-
* binds to the `blenderModelView` singleton), so it reads the handle the skin
|
|
127
|
-
* was given rather than looking one up.
|
|
128
|
-
*
|
|
129
|
-
* `null` before the Model document attaches. A gesture then refuses by name
|
|
130
|
-
* instead of silently doing nothing — a Timeline with no transport is a
|
|
131
|
-
* Timeline over nothing.
|
|
132
|
-
*/
|
|
79
|
+
/** Read the Model transport attached through blenderSkin.attachTo. Before attachment, gestures refuse by name. */
|
|
133
80
|
function transport(): StageTransportHandle | null {
|
|
134
81
|
return blenderSkin.transport;
|
|
135
82
|
}
|
|
@@ -170,10 +117,7 @@ const KEY_TYPE_RANK: Record<string, number> = {
|
|
|
170
117
|
* WHAT THE FILTER DOES NOT TOUCH is what PLAYS: the mixer holds the bound
|
|
171
118
|
* action whatever the row draws, in Blender and here.
|
|
172
119
|
*
|
|
173
|
-
* The
|
|
174
|
-
* unselected, Blender's own Timeline drew a ruler, a playhead, the range
|
|
175
|
-
* shading and an EMPTY summary row while its Dope Sheet drew all five keys in
|
|
176
|
-
* the same file.
|
|
120
|
+
* The selection filter defaults to ON, matching Blender’s Timeline.
|
|
177
121
|
*/
|
|
178
122
|
function summaryColumns(
|
|
179
123
|
summary: ReturnType<typeof blenderSkin.state>['summary'],
|
|
@@ -223,25 +167,8 @@ function report(): unknown {
|
|
|
223
167
|
};
|
|
224
168
|
}
|
|
225
169
|
|
|
226
|
-
/**
|
|
227
|
-
* stage's own render loop draws the next frame from them. Blender is not
|
|
228
|
-
* called — `state().engineCalls` is how that is checked.
|
|
229
|
-
*
|
|
230
|
-
* IT REFUSES WITHOUT A SCENE TRANSPORT, for the reason {@link NOT_PLAYABLE}
|
|
231
|
-
* states and with that one sentence: `#seek` returns at its first line with
|
|
232
|
-
* no mixer (`blender-runtime-skin.ts`), so the seek was a no-op and
|
|
233
|
-
* `report()` then answered `frame: 1` — the playhead's honest position and a
|
|
234
|
-
* complete lie about the gesture. Measured on walk 5 over a fresh
|
|
235
|
-
* `cyclotron create` scaffold: `volter.timeline.frame {frame:120}` and
|
|
236
|
-
* `volter.timeline.jump-end` (frame 250) both answered `frame: 1`,
|
|
237
|
-
* `refusal: null`. This is the half of walk 4's W5 (#7740) that the header
|
|
238
|
-
* got and the VERBS did not — there the buttons were disabled wearing this
|
|
239
|
-
* sentence as their title while `play` threw it, and the five verbs that
|
|
240
|
-
* route through here kept answering as if they had moved something. All five
|
|
241
|
-
* are fixed by this one guard: `frame`, `jump-start`, `jump-end`,
|
|
242
|
-
* `next-keyframe`, `prev-keyframe`. */
|
|
170
|
+
/** Scrub through the scene mixer with no Blender call. Refuse without a playable transport rather than report a frame that the gesture could not reach. */
|
|
243
171
|
function scrubTo(frame: number): unknown {
|
|
244
|
-
if (liveAnimation() !== null) return refuse(GAME_RUNNING);
|
|
245
172
|
if (!blenderSkin.playable) return refuse(NOT_PLAYABLE);
|
|
246
173
|
const handle = transport();
|
|
247
174
|
if (!handle) return refuse('No stage transport is attached yet; open the model document first.');
|
|
@@ -249,152 +176,124 @@ function scrubTo(frame: number): unknown {
|
|
|
249
176
|
return report();
|
|
250
177
|
}
|
|
251
178
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return refuse('editor is `timeline`, `action` (the Action Editor) or `nla`.');
|
|
265
|
-
setTimelineViewState({ editor });
|
|
266
|
-
return report();
|
|
267
|
-
},
|
|
179
|
+
/** The view's verbs: the Code-OSS commands `volter.timeline.<verb>`, registered by editor-blender's page half. */
|
|
180
|
+
export const TIMELINE_VIEW_VERBS: readonly ViewVerb[] = [
|
|
181
|
+
{ id: 'state', run: () => report() },
|
|
182
|
+
{
|
|
183
|
+
// BLENDER'S EDITOR-TYPE MENU for this area: the Timeline, the Action Editor or the NLA.
|
|
184
|
+
id: 'editor',
|
|
185
|
+
run: (args) => {
|
|
186
|
+
const editor = args?.['editor'] ?? args?.['to'];
|
|
187
|
+
if (editor !== 'timeline' && editor !== 'action' && editor !== 'nla')
|
|
188
|
+
return refuse('editor is `timeline`, `action` (the Action Editor) or `nla`.');
|
|
189
|
+
setTimelineViewState({ editor });
|
|
190
|
+
return report();
|
|
268
191
|
},
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'frame',
|
|
195
|
+
run: (args) => {
|
|
196
|
+
const frame = Number(args?.['frame'] ?? args?.['to']);
|
|
197
|
+
if (!Number.isFinite(frame))
|
|
198
|
+
throw new Error('frame needs a numeric `frame` — a Blender frame number.');
|
|
199
|
+
return scrubTo(frame);
|
|
278
200
|
},
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return report();
|
|
290
|
-
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'play',
|
|
204
|
+
run: () => {
|
|
205
|
+
if (!blenderSkin.playable) throw new Error(NOT_PLAYABLE);
|
|
206
|
+
const handle = transport();
|
|
207
|
+
if (!handle)
|
|
208
|
+
throw new Error('No stage transport is attached yet; open the model document first.');
|
|
209
|
+
handle.play();
|
|
210
|
+
return report();
|
|
291
211
|
},
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'pause',
|
|
215
|
+
run: () => {
|
|
216
|
+
// The bookmark write is not this look's: pausing SETTLES the
|
|
217
|
+
// transport, and the skin's `onSettled` subscription is what writes
|
|
218
|
+
// `frame_current` once.
|
|
219
|
+
const handle = transport();
|
|
220
|
+
if (!handle) return refuse('No stage transport is attached yet.');
|
|
221
|
+
handle.pause();
|
|
222
|
+
return report();
|
|
304
223
|
},
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'jump-start',
|
|
227
|
+
run: () => scrubTo(blenderSkin.state().start),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'jump-end',
|
|
231
|
+
run: () => scrubTo(blenderSkin.state().end),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'next-keyframe',
|
|
235
|
+
run: () => {
|
|
236
|
+
const frame = blenderSkin.frame();
|
|
237
|
+
const next = summaryFrames().find((key) => key > frame + 1e-4);
|
|
238
|
+
if (next === undefined) return report();
|
|
239
|
+
return scrubTo(next);
|
|
309
240
|
},
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'prev-keyframe',
|
|
244
|
+
run: () => {
|
|
245
|
+
const frame = blenderSkin.frame();
|
|
246
|
+
const previous = summaryFrames()
|
|
247
|
+
.reverse()
|
|
248
|
+
.find((key) => key < frame - 1e-4);
|
|
249
|
+
if (previous === undefined) return report();
|
|
250
|
+
return scrubTo(previous);
|
|
314
251
|
},
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (next === undefined) return report();
|
|
322
|
-
return scrubTo(next);
|
|
323
|
-
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'view-all',
|
|
255
|
+
run: () => {
|
|
256
|
+
requestTimelineViewAll();
|
|
257
|
+
return report();
|
|
324
258
|
},
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'zoom',
|
|
262
|
+
run: (args) => {
|
|
263
|
+
const to = Number(args?.['to'] ?? args?.['zoom']);
|
|
264
|
+
if (!Number.isFinite(to))
|
|
265
|
+
throw new Error('zoom needs a numeric `to` — CSS pixels per frame, 0.25 … 200.');
|
|
266
|
+
const { transform, size } = timelineViewState();
|
|
267
|
+
const centre = transform.startFrame + size.w / 2 / transform.pixelsPerFrame;
|
|
268
|
+
const pixelsPerFrame = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, to));
|
|
269
|
+
setTimelineViewState({
|
|
270
|
+
transform: { pixelsPerFrame, startFrame: centre - size.w / 2 / pixelsPerFrame },
|
|
271
|
+
});
|
|
272
|
+
return report();
|
|
336
273
|
},
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'pan',
|
|
277
|
+
run: (args) => {
|
|
278
|
+
const frames = Number(args?.['frames'] ?? 0);
|
|
279
|
+
if (!Number.isFinite(frames)) throw new Error('pan needs a numeric `frames`.');
|
|
280
|
+
const { transform } = timelineViewState();
|
|
281
|
+
setTimelineViewState({
|
|
282
|
+
transform: { ...transform, startFrame: transform.startFrame + frames },
|
|
283
|
+
});
|
|
284
|
+
return report();
|
|
344
285
|
},
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
throw new Error('zoom needs a numeric `to` — CSS pixels per frame, 0.25 … 200.');
|
|
352
|
-
const { transform, size } = timelineViewState();
|
|
353
|
-
const centre = transform.startFrame + size.w / 2 / transform.pixelsPerFrame;
|
|
354
|
-
const pixelsPerFrame = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, to));
|
|
355
|
-
setTimelineViewState({
|
|
356
|
-
transform: { pixelsPerFrame, startFrame: centre - size.w / 2 / pixelsPerFrame },
|
|
357
|
-
});
|
|
358
|
-
return report();
|
|
359
|
-
},
|
|
360
|
-
},
|
|
361
|
-
{
|
|
362
|
-
id: 'pan',
|
|
363
|
-
title: 'Timeline: Pan',
|
|
364
|
-
run: (args) => {
|
|
365
|
-
const frames = Number(args?.['frames'] ?? 0);
|
|
366
|
-
if (!Number.isFinite(frames)) throw new Error('pan needs a numeric `frames`.');
|
|
367
|
-
const { transform } = timelineViewState();
|
|
368
|
-
setTimelineViewState({
|
|
369
|
-
transform: { ...transform, startFrame: transform.startFrame + frames },
|
|
370
|
-
});
|
|
371
|
-
return report();
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
...(Object.keys(REFUSALS) as (keyof typeof REFUSALS)[]).map((id) => ({
|
|
375
|
-
id,
|
|
376
|
-
run: () => refuse(REFUSALS[id]),
|
|
377
|
-
})),
|
|
378
|
-
],
|
|
379
|
-
});
|
|
286
|
+
},
|
|
287
|
+
...(Object.keys(REFUSALS) as (keyof typeof REFUSALS)[]).map((id) => ({
|
|
288
|
+
id,
|
|
289
|
+
run: () => refuse(REFUSALS[id]),
|
|
290
|
+
})),
|
|
291
|
+
];
|
|
380
292
|
|
|
381
293
|
/** This area's id in `model.layout.ts`, the one workspace that places this document. */
|
|
382
294
|
const BOTTOM_AREA = 'timeline';
|
|
383
295
|
|
|
384
|
-
/**
|
|
385
|
-
* THE BOTTOM AREA IS BLENDER'S ANIMATION EDITORS IN ANIMATION MODE (the Timeline, the Action
|
|
386
|
-
* Editor or the NLA, by the editor-type menu) AND THE GAME PANEL IN GAME MODE
|
|
387
|
-
* (`../src/play-mode.ts`, for the model document on screen). Same area: the
|
|
388
|
-
* layout's `areas` entry names this document either way. NOT THE SAME SIZE:
|
|
389
|
-
* Blender's measured Timeline strip (`model.layout.ts`) left the Game panel's
|
|
390
|
-
* play log one clipped line, so Game mode asks the area for the panel's own
|
|
391
|
-
* share (`GAME_PANEL_RATIO`) and Movie mode hands Blender's back. Each switch
|
|
392
|
-
* stands the area up at its mode's size; a person's resize holds until the
|
|
393
|
-
* next switch. The Timeline unmounts in Game mode; a document that opens as a
|
|
394
|
-
* game therefore never pays for the rig bind (the cost note in
|
|
395
|
-
* `BlenderTimeline`), and one bound earlier keeps its bind, which the Timeline
|
|
396
|
-
* never undoes.
|
|
397
|
-
*/
|
|
296
|
+
/** The bottom area swaps animation editors for the Play tool’s Panel. Each mode applies its own area ratio; manual resizing holds until the next switch. Animation unmounts during Game mode and keeps any existing rig bind. */
|
|
398
297
|
export default function BlenderBottomArea() {
|
|
399
298
|
useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
|
|
400
299
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
@@ -404,18 +303,17 @@ export default function BlenderBottomArea() {
|
|
|
404
303
|
// The Timeline keeps Blender's strip; the Action Editor and NLA list channels, so they take the
|
|
405
304
|
// Game panel's share, as a person would drag the area up to read them.
|
|
406
305
|
const tall = game || editor !== 'timeline';
|
|
407
|
-
|
|
408
|
-
useEffect(() => ()
|
|
409
|
-
|
|
306
|
+
const Panel = useSyncExternalStore(subscribeModelPlayExtension, () => modelPlayExtension()?.Panel ?? null, () => null);
|
|
307
|
+
useEffect(() => editorHost().workspace.setAreaRatio(BOTTOM_AREA, tall ? PLAY_PANEL_RATIO : null), [tall]);
|
|
308
|
+
useEffect(() => () => editorHost().workspace.setAreaRatio(BOTTOM_AREA, null), []);
|
|
309
|
+
if (game) return Panel ? <Panel /> : null;
|
|
410
310
|
if (editor === 'action') return <BlenderActionEditor />;
|
|
411
311
|
if (editor === 'nla') return <BlenderNlaEditor />;
|
|
412
312
|
return <BlenderTimeline />;
|
|
413
313
|
}
|
|
414
314
|
|
|
415
315
|
function BlenderTimeline() {
|
|
416
|
-
//
|
|
417
|
-
// every presented frame bumps it, which is exactly when a rig or an action
|
|
418
|
-
// can have moved.
|
|
316
|
+
// RNA writes and presented frames bump the version that invalidates rig and action reads.
|
|
419
317
|
useSyncExternalStore(
|
|
420
318
|
subscribeBlenderRna,
|
|
421
319
|
blenderRnaVersion,
|
|
@@ -450,10 +348,7 @@ function BlenderTimeline() {
|
|
|
450
348
|
// the measured box, which the verb does not have.
|
|
451
349
|
const request = timelineViewAllRequest();
|
|
452
350
|
useEffect(() => {
|
|
453
|
-
//
|
|
454
|
-
// for a real ask. It was `0` against a request counter that also starts at
|
|
455
|
-
// 0, with a `request !== 0` escape — which re-framed on every render and
|
|
456
|
-
// was one of the two feedback loops the first walk found.
|
|
351
|
+
// Start framed at −1 so the first pass frames, then wait for the view-all request counter to change.
|
|
457
352
|
if (request === framed.current) return;
|
|
458
353
|
framed.current = request;
|
|
459
354
|
const { w } = timelineViewState().size;
|
|
@@ -476,8 +371,7 @@ function BlenderTimeline() {
|
|
|
476
371
|
);
|
|
477
372
|
|
|
478
373
|
// THE SUMMARY ROW'S OWN COLUMNS — the door's per-object report put through
|
|
479
|
-
// Blender's selection filter.
|
|
480
|
-
// stays what the mixer plays; this is what the row DRAWS.
|
|
374
|
+
// Blender's selection filter. Keyframe columns describe the row; sampled tracks drive playback.
|
|
481
375
|
const summary = useMemo(
|
|
482
376
|
() => summaryColumns(play.summary, onlySelected),
|
|
483
377
|
[play.summary, onlySelected],
|
|
@@ -505,13 +399,7 @@ function BlenderTimeline() {
|
|
|
505
399
|
for (let frame = first; frame <= transform.startFrame + viewFrames; frame += major)
|
|
506
400
|
majors.push(frame);
|
|
507
401
|
const minors: number[] = [];
|
|
508
|
-
//
|
|
509
|
-
// only while `(pixel_width / view_width) * (major / divisor)` — the minor
|
|
510
|
-
// step's own PIXEL distance — stays above `MIN_MAJOR_LINE_DISTANCE / 5`.
|
|
511
|
-
// It is the STEP's distance, not the frame's; dropping the step from the
|
|
512
|
-
// product hid every minor line at any zoom below 7 px per frame, which is
|
|
513
|
-
// most of them (measured on the probe: 4.76 px/frame, minor step 6, so
|
|
514
|
-
// 28.6 px between minor lines against a 7-px floor).
|
|
402
|
+
// Draw minor lines only when their pixel spacing exceeds MIN_MAJOR_LINE_DISTANCE / 5 (view2d_draw.cc). Include the minor step in the spacing calculation.
|
|
515
403
|
if (minor !== null && minor * transform.pixelsPerFrame >= MIN_MAJOR_LINE_DISTANCE / 5) {
|
|
516
404
|
const firstMinor = Math.ceil(transform.startFrame / minor) * minor;
|
|
517
405
|
for (let frame = firstMinor; frame <= transform.startFrame + viewFrames; frame += minor)
|
|
@@ -534,17 +422,17 @@ function BlenderTimeline() {
|
|
|
534
422
|
"Nothing in this file carries an action, so the summary row is empty — which is Blender's own empty state for a Timeline over a still file.",
|
|
535
423
|
);
|
|
536
424
|
warnings.push(
|
|
537
|
-
'
|
|
425
|
+
'Playback samples exported object, camera, material and shape-key tracks. Modifiers and geometry drivers that require per-frame Blender evaluation are not re-evaluated by the preview.',
|
|
538
426
|
);
|
|
539
427
|
warnings.push(
|
|
540
|
-
|
|
428
|
+
'The View menu owns Only Show Selected; its filter applies to the summary row, while sampled playback tracks remain unchanged.',
|
|
541
429
|
);
|
|
542
430
|
if (onlySelected && summary.objects.length === 0 && play.summary.length > 0)
|
|
543
431
|
warnings.push(
|
|
544
432
|
`The summary row is empty because nothing animated is SELECTED, which is Blender's own answer with this filter on. ${play.summary.map((entry) => entry.object).join(', ')} carr${play.summary.length === 1 ? 'ies' : 'y'} an action; select it, or turn the filter off.`,
|
|
545
433
|
);
|
|
546
434
|
warnings.push(
|
|
547
|
-
"
|
|
435
|
+
"Movie markers are carried in the scene data; this view does not draw markers, the cache bar or preview range.",
|
|
548
436
|
);
|
|
549
437
|
const next = {
|
|
550
438
|
drawn: {
|
|
@@ -573,14 +461,7 @@ function BlenderTimeline() {
|
|
|
573
461
|
warnings,
|
|
574
462
|
},
|
|
575
463
|
};
|
|
576
|
-
//
|
|
577
|
-
// the fix for a REAL LOOP the first walk found: this effect's inputs
|
|
578
|
-
// include `play`, which is a fresh object every render, so an
|
|
579
|
-
// unconditional publish re-rendered, which re-ran the effect, which
|
|
580
|
-
// published again. React caught it by name ("Maximum update depth
|
|
581
|
-
// exceeded ... Tool crashed: blender-timeline.document.tsx") and the whole
|
|
582
|
-
// area painted the crash card instead of a Timeline. A published
|
|
583
|
-
// measurement is a VALUE, so comparing values is what stops it.
|
|
464
|
+
// Publish drawing measurements only when their values change; fresh render objects alone must not create another store publication.
|
|
584
465
|
if (JSON.stringify(next.drawn) !== JSON.stringify(timelineViewState().drawn))
|
|
585
466
|
setTimelineViewState(next);
|
|
586
467
|
}, [ruler, play, summary, onlySelected]);
|
|
@@ -589,7 +470,7 @@ function BlenderTimeline() {
|
|
|
589
470
|
const scrubFromPointer = useCallback(
|
|
590
471
|
(clientX: number) => {
|
|
591
472
|
const element = canvas.current;
|
|
592
|
-
if (!element
|
|
473
|
+
if (!element) return;
|
|
593
474
|
const box = element.getBoundingClientRect();
|
|
594
475
|
transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
|
|
595
476
|
},
|
|
@@ -604,11 +485,7 @@ function BlenderTimeline() {
|
|
|
604
485
|
<div
|
|
605
486
|
data-testid="blender-timeline"
|
|
606
487
|
style={{
|
|
607
|
-
//
|
|
608
|
-
// difference between a drawer panel and an EDITOR GROUP that the UV
|
|
609
|
-
// view's walk found: in an area's auto-height content box `100%`
|
|
610
|
-
// resolves to ZERO and the view reads everything it needs with nowhere
|
|
611
|
-
// to draw it.
|
|
488
|
+
// Fill the host box with absolute positioning; percentage height has no fixed reference in an auto-height editor area.
|
|
612
489
|
position: 'absolute',
|
|
613
490
|
inset: 0,
|
|
614
491
|
display: 'flex',
|
|
@@ -640,8 +517,7 @@ function BlenderTimeline() {
|
|
|
640
517
|
// NOTHING IS WRITTEN HERE. Scrub-end is the TRANSPORT's — the
|
|
641
518
|
// quiet after the last seek settles it, and the skin's `onSettled`
|
|
642
519
|
// subscription writes `frame_current` once. A second write here
|
|
643
|
-
// would race that one and
|
|
644
|
-
// write this step removed.
|
|
520
|
+
// would race that one and write twice per gesture.
|
|
645
521
|
}}
|
|
646
522
|
>
|
|
647
523
|
<title>Timeline</title>
|
|
@@ -696,34 +572,22 @@ function BlenderTimeline() {
|
|
|
696
572
|
y={15.5}
|
|
697
573
|
textAnchor="middle"
|
|
698
574
|
fill={TIMELINE_THEME.scrubText}
|
|
699
|
-
//
|
|
700
|
-
// the baseline is `rect.ymin + 4 * UI_SCALE_FAC` and the label is
|
|
701
|
-
// centred on the line by `x − trunc(width / 2)`. Confirmed in
|
|
702
|
-
// Blender's frame: the glyph rows are 34…41 under a 26-px header,
|
|
703
|
-
// i.e. a baseline 15.5 px below the region's top edge.
|
|
575
|
+
// draw_horizontal_scale_indicators places the baseline at rect.ymin + 4 × UI_SCALE_FAC and centers labels by x − trunc(width / 2).
|
|
704
576
|
fontSize={11}
|
|
705
577
|
>
|
|
706
578
|
{Math.round(frame)}
|
|
707
579
|
</text>
|
|
708
580
|
))}
|
|
709
|
-
{/*
|
|
710
|
-
edge — Blender draws one there for the collapsed summary channel
|
|
711
|
-
(`ANIM_channel_draw`'s expand icon; measured on Blender 5.2.0 LTS
|
|
712
|
-
as a small `>` at x~4 on the channel row). It is DRAWN, not
|
|
713
|
-
clickable: expanding the summary would list per-object channels,
|
|
714
|
-
which is the Dope Sheet's job and is not built — the same reason
|
|
715
|
-
`insert-key` and friends are recorded refusals rather than
|
|
716
|
-
controls. */}
|
|
581
|
+
{/* The summary disclosure is drawn read-only. Per-object channels belong to the Action Editor; channel and key edits refuse by name. */}
|
|
717
582
|
<path
|
|
718
583
|
d={`M3,${rowCentre - 3.5} L6.5,${rowCentre} L3,${rowCentre + 3.5} Z`}
|
|
719
584
|
fill={TIMELINE_THEME.scrubText}
|
|
720
585
|
/>
|
|
721
586
|
{/* THE SUMMARY ROW'S KEYS. Blender's Timeline draws exactly one
|
|
722
|
-
channel
|
|
723
|
-
|
|
587
|
+
channel (`anim_filter.cc:3845-3849`; the names region is hidden in
|
|
588
|
+
Timeline mode, `space_action.cc:785-790`) — and its diamonds are the shader's shape. The columns
|
|
724
589
|
are the SELECTION-FILTERED merge (`summaryColumns`), which is
|
|
725
|
-
what `show_keys_from_selected_only` decides;
|
|
726
|
-
plays is `play.keyframes` and is unaffected. */}
|
|
590
|
+
what `show_keys_from_selected_only` decides; sampled playback tracks are unaffected. */}
|
|
727
591
|
{summary.columns.map((key) => {
|
|
728
592
|
const factor = KEY_SIZE_FACTOR[key.type] ?? 1;
|
|
729
593
|
const radius = DIAMOND_RADIUS * factor;
|
|
@@ -786,47 +650,7 @@ function BlenderTimeline() {
|
|
|
786
650
|
);
|
|
787
651
|
}
|
|
788
652
|
|
|
789
|
-
/**
|
|
790
|
-
* THE HEADER — `space_time.py`'s `playback_controls`, read as the
|
|
791
|
-
* SPECIFICATION of what to draw and never run (the standing ruling). Its order
|
|
792
|
-
* is that function's, top to bottom: the Playback popover, a spacer, the
|
|
793
|
-
* auto-key toggle, the SIX transport buttons
|
|
794
|
-
* (`screen.frame_jump` REW → `screen.keyframe_jump` PREV_KEYFRAME →
|
|
795
|
-
* `screen.animation_play` PLAY_REVERSE → PLAY → `keyframe_jump` NEXT_KEYFRAME
|
|
796
|
-
* → `frame_jump` FF), the time-jump pair, the snap toggle, a spacer, the frame
|
|
797
|
-
* field, and the Start/End pair.
|
|
798
|
-
*
|
|
799
|
-
* WHAT IS DRAWN AND WHAT IS NOT. The six transport buttons and the frame field
|
|
800
|
-
* are real — they are the scrub, which is the one thing this surface changes.
|
|
801
|
-
* Start and End are READ-ONLY readouts, because writing them writes the file
|
|
802
|
-
* (`set-range` refuses by name). The Playback and snap popovers are not drawn
|
|
803
|
-
* at all: every control in them sets a preference or a tool setting that this
|
|
804
|
-
* view does not read, and a popover that opens onto nothing is worse than an
|
|
805
|
-
* absent one. Named here rather than painted.
|
|
806
|
-
*
|
|
807
|
-
* THE BUTTONS ARE 20 px — `UI_UNIT_X` (`wm_window.cc:779`'s `widget_unit`),
|
|
808
|
-
* confirmed in Blender's own frame: the block of six spans exactly 120 px.
|
|
809
|
-
* Their glyphs are TRACED from Blender's own icon sources the way I2's tab
|
|
810
|
-
* glyphs are — see `blender-icons.source.mjs`; until that trace covers the
|
|
811
|
-
* transport family they are drawn here as the plain geometric marks
|
|
812
|
-
* `release/datafiles/icons_svg/{rew,prev_keyframe,play,ff}.svg` are built from
|
|
813
|
-
* (a triangle, a triangle with a bar, a pair), which is a KNOWN gap and is in
|
|
814
|
-
* the view's own warnings rather than implied to be Blender's mark.
|
|
815
|
-
*/
|
|
816
|
-
/**
|
|
817
|
-
* THE VIEW MENU — Blender's `TIME_MT_view`, and the reason this exists is a
|
|
818
|
-
* deviation the old header admitted in its own comment: `show_keys_from_selected_only`
|
|
819
|
-
* belongs in a View MENU (`space_time.py`'s `layout.prop`), and with no menu
|
|
820
|
-
* region it was drawn as a lone toggle at the leading edge instead.
|
|
821
|
-
*
|
|
822
|
-
* Measured against Blender 5.2.0 LTS at 1728x997: its header's leading cluster
|
|
823
|
-
* is an editor-type dropdown then `View`, `Marker` and `Playback`. This builds
|
|
824
|
-
* `View` and ONLY `View`, because View is the one whose items this surface
|
|
825
|
-
* actually has — `Only Show Selected` and `Frame All`. `Marker` and `Playback`
|
|
826
|
-
* would be empty shells over behaviour that does not exist here, and a menu
|
|
827
|
-
* that exists to look like Blender is exactly the invented affordance the
|
|
828
|
-
* program forbids. Their absence is a STATED gap in WORK.md, not a pretence.
|
|
829
|
-
*/
|
|
653
|
+
/** View exposes Only Show Selected and Frame All. Marker and Playback menus are omitted because their actions are unsupported. */
|
|
830
654
|
function TimelineViewMenu() {
|
|
831
655
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
832
656
|
const { onlySelected } = timelineViewState();
|
|
@@ -900,22 +724,20 @@ function TimelineViewMenu() {
|
|
|
900
724
|
);
|
|
901
725
|
}
|
|
902
726
|
|
|
727
|
+
/** Transport and frame controls follow Blender's Timeline order and draw inline SVG marks; range readouts stay read-only. */
|
|
903
728
|
function TimelineHeader() {
|
|
904
729
|
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
905
730
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
906
731
|
const play = blenderSkin.state();
|
|
907
732
|
const unit = TIMELINE_CHROME.unit;
|
|
908
|
-
// WHILE A GAME RUNS the transport is held: the game owns its copy, and moving the document's
|
|
909
|
-
// frame under it would put two clocks on one picture.
|
|
910
|
-
const gameRunning = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null) !== null;
|
|
911
733
|
// Static scenes have a timeline too. Disable only while its scene/transport
|
|
912
734
|
// is unavailable, using the same condition as the command door.
|
|
913
|
-
const playable = blenderSkin.playable
|
|
735
|
+
const playable = blenderSkin.playable;
|
|
914
736
|
const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
|
|
915
737
|
<button
|
|
916
738
|
key={key}
|
|
917
739
|
type="button"
|
|
918
|
-
title={playable ? label : `${label} — ${
|
|
740
|
+
title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
|
|
919
741
|
aria-label={label}
|
|
920
742
|
disabled={!playable}
|
|
921
743
|
onClick={onClick}
|
|
@@ -954,16 +776,11 @@ function TimelineHeader() {
|
|
|
954
776
|
}}
|
|
955
777
|
>
|
|
956
778
|
{/* GAME | ANIMATION in place of the title, as in the Game panel, so the person switches back
|
|
957
|
-
from either (`blender-
|
|
779
|
+
from either (`blender-play-area.tsx`); without a Play tool or a model, the title. */}
|
|
958
780
|
<PlayModeSwitch title="Timeline" />
|
|
959
781
|
<AnimationEditorMenu />
|
|
960
782
|
<TimelineViewMenu />
|
|
961
|
-
{/*
|
|
962
|
-
header is three clusters: the menus at the leading edge, the
|
|
963
|
-
transport in the MIDDLE, and the frame/range fields trailing
|
|
964
|
-
(measured against Blender 5.2.0 LTS at 1728x997 — its transport sits
|
|
965
|
-
at roughly x 860-1160 of 2832, dead centre; ours was hard against
|
|
966
|
-
the right edge beside the frame field). */}
|
|
783
|
+
{/* Two spacers centre transport between the leading menus and trailing frame/range fields. */}
|
|
967
784
|
<div style={{ flex: 1 }} />
|
|
968
785
|
<div style={{ display: 'flex' }}>
|
|
969
786
|
{button(
|
|
@@ -972,11 +789,7 @@ function TimelineHeader() {
|
|
|
972
789
|
() => transport()?.seekFrame(play.start),
|
|
973
790
|
'Jump to Start',
|
|
974
791
|
)}
|
|
975
|
-
{/*
|
|
976
|
-
jump-to-start/end's bar-and-triangle. We drew the same path for
|
|
977
|
-
both, so four buttons carried two glyphs and a person could not
|
|
978
|
-
tell "go to the start" from "go to the previous key" — caught by
|
|
979
|
-
putting our header beside Blender's, not by reading the code. */}
|
|
792
|
+
{/* Previous and next keyframe use double triangles, distinct from jump-to-boundary bar-and-triangle marks. */}
|
|
980
793
|
{button(
|
|
981
794
|
'prev-key',
|
|
982
795
|
mark('M5 2.5v7L1.5 6zM8.5 3.2L11 6L8.5 8.8L6 6z'),
|
|
@@ -988,10 +801,7 @@ function TimelineHeader() {
|
|
|
988
801
|
},
|
|
989
802
|
'Jump to Previous Keyframe',
|
|
990
803
|
)}
|
|
991
|
-
{/*
|
|
992
|
-
engine's clock has carried the direction all along
|
|
993
|
-
(`AnimationClock.play(direction)`); nothing new is invented here,
|
|
994
|
-
the button just asks for the arm that already existed. */}
|
|
804
|
+
{/* Reverse playback uses the stage transport's direction. */}
|
|
995
805
|
{playable && playing()
|
|
996
806
|
? button('pause-rev', mark('M3 2h2v8H3zM7 2h2v8H7z'), () => transport()?.pause(), 'Pause')
|
|
997
807
|
: button(
|