@volter/editor-blender 0.1.150 → 0.1.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +99 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +64 -0
- package/contributions/blender.status.tsx +8 -150
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +3 -30
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,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 { playTool, subscribePlayTool } from '../src/play-tool';
|
|
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,173 +176,146 @@ 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 is the Play tool’s Panel under every document but a model in its Animation mode, which shows Blender's animation editors. 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);
|
|
401
300
|
const documentId = servedModelDocument();
|
|
402
|
-
const
|
|
301
|
+
const Panel = useSyncExternalStore(subscribePlayTool, () => playTool()?.Panel ?? null, () => null);
|
|
302
|
+
// (Play is the project's: its Game panel stands under every document, and below a model in its Game mode; a model's
|
|
303
|
+
// Animation mode is Blender's animation editors)
|
|
304
|
+
const game = Panel !== null && (documentId === null || modelPlayMode(documentId) === 'game');
|
|
403
305
|
const { editor } = timelineViewState();
|
|
404
306
|
// The Timeline keeps Blender's strip; the Action Editor and NLA list channels, so they take the
|
|
405
307
|
// Game panel's share, as a person would drag the area up to read them.
|
|
406
308
|
const tall = game || editor !== 'timeline';
|
|
407
|
-
useEffect(() =>
|
|
408
|
-
useEffect(() => () =>
|
|
409
|
-
if (game) return <
|
|
309
|
+
useEffect(() => editorHost().workspace.setAreaRatio(BOTTOM_AREA, tall ? PLAY_PANEL_RATIO : null), [tall]);
|
|
310
|
+
useEffect(() => () => editorHost().workspace.setAreaRatio(BOTTOM_AREA, null), []);
|
|
311
|
+
if (game) return Panel ? <Panel /> : null;
|
|
410
312
|
if (editor === 'action') return <BlenderActionEditor />;
|
|
411
313
|
if (editor === 'nla') return <BlenderNlaEditor />;
|
|
412
314
|
return <BlenderTimeline />;
|
|
413
315
|
}
|
|
414
316
|
|
|
415
317
|
function BlenderTimeline() {
|
|
416
|
-
//
|
|
417
|
-
// every presented frame bumps it, which is exactly when a rig or an action
|
|
418
|
-
// can have moved.
|
|
318
|
+
// RNA writes and presented frames bump the version that invalidates rig and action reads.
|
|
419
319
|
useSyncExternalStore(
|
|
420
320
|
subscribeBlenderRna,
|
|
421
321
|
blenderRnaVersion,
|
|
@@ -450,10 +350,7 @@ function BlenderTimeline() {
|
|
|
450
350
|
// the measured box, which the verb does not have.
|
|
451
351
|
const request = timelineViewAllRequest();
|
|
452
352
|
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.
|
|
353
|
+
// Start framed at −1 so the first pass frames, then wait for the view-all request counter to change.
|
|
457
354
|
if (request === framed.current) return;
|
|
458
355
|
framed.current = request;
|
|
459
356
|
const { w } = timelineViewState().size;
|
|
@@ -476,8 +373,7 @@ function BlenderTimeline() {
|
|
|
476
373
|
);
|
|
477
374
|
|
|
478
375
|
// 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.
|
|
376
|
+
// Blender's selection filter. Keyframe columns describe the row; sampled tracks drive playback.
|
|
481
377
|
const summary = useMemo(
|
|
482
378
|
() => summaryColumns(play.summary, onlySelected),
|
|
483
379
|
[play.summary, onlySelected],
|
|
@@ -505,13 +401,7 @@ function BlenderTimeline() {
|
|
|
505
401
|
for (let frame = first; frame <= transform.startFrame + viewFrames; frame += major)
|
|
506
402
|
majors.push(frame);
|
|
507
403
|
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).
|
|
404
|
+
// 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
405
|
if (minor !== null && minor * transform.pixelsPerFrame >= MIN_MAJOR_LINE_DISTANCE / 5) {
|
|
516
406
|
const firstMinor = Math.ceil(transform.startFrame / minor) * minor;
|
|
517
407
|
for (let frame = firstMinor; frame <= transform.startFrame + viewFrames; frame += minor)
|
|
@@ -534,17 +424,17 @@ function BlenderTimeline() {
|
|
|
534
424
|
"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
425
|
);
|
|
536
426
|
warnings.push(
|
|
537
|
-
'
|
|
427
|
+
'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
428
|
);
|
|
539
429
|
warnings.push(
|
|
540
|
-
|
|
430
|
+
'The View menu owns Only Show Selected; its filter applies to the summary row, while sampled playback tracks remain unchanged.',
|
|
541
431
|
);
|
|
542
432
|
if (onlySelected && summary.objects.length === 0 && play.summary.length > 0)
|
|
543
433
|
warnings.push(
|
|
544
434
|
`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
435
|
);
|
|
546
436
|
warnings.push(
|
|
547
|
-
"
|
|
437
|
+
"Movie markers are carried in the scene data; this view does not draw markers, the cache bar or preview range.",
|
|
548
438
|
);
|
|
549
439
|
const next = {
|
|
550
440
|
drawn: {
|
|
@@ -573,14 +463,7 @@ function BlenderTimeline() {
|
|
|
573
463
|
warnings,
|
|
574
464
|
},
|
|
575
465
|
};
|
|
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.
|
|
466
|
+
// Publish drawing measurements only when their values change; fresh render objects alone must not create another store publication.
|
|
584
467
|
if (JSON.stringify(next.drawn) !== JSON.stringify(timelineViewState().drawn))
|
|
585
468
|
setTimelineViewState(next);
|
|
586
469
|
}, [ruler, play, summary, onlySelected]);
|
|
@@ -589,7 +472,7 @@ function BlenderTimeline() {
|
|
|
589
472
|
const scrubFromPointer = useCallback(
|
|
590
473
|
(clientX: number) => {
|
|
591
474
|
const element = canvas.current;
|
|
592
|
-
if (!element
|
|
475
|
+
if (!element) return;
|
|
593
476
|
const box = element.getBoundingClientRect();
|
|
594
477
|
transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
|
|
595
478
|
},
|
|
@@ -604,11 +487,7 @@ function BlenderTimeline() {
|
|
|
604
487
|
<div
|
|
605
488
|
data-testid="blender-timeline"
|
|
606
489
|
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.
|
|
490
|
+
// Fill the host box with absolute positioning; percentage height has no fixed reference in an auto-height editor area.
|
|
612
491
|
position: 'absolute',
|
|
613
492
|
inset: 0,
|
|
614
493
|
display: 'flex',
|
|
@@ -640,8 +519,7 @@ function BlenderTimeline() {
|
|
|
640
519
|
// NOTHING IS WRITTEN HERE. Scrub-end is the TRANSPORT's — the
|
|
641
520
|
// quiet after the last seek settles it, and the skin's `onSettled`
|
|
642
521
|
// subscription writes `frame_current` once. A second write here
|
|
643
|
-
// would race that one and
|
|
644
|
-
// write this step removed.
|
|
522
|
+
// would race that one and write twice per gesture.
|
|
645
523
|
}}
|
|
646
524
|
>
|
|
647
525
|
<title>Timeline</title>
|
|
@@ -696,34 +574,22 @@ function BlenderTimeline() {
|
|
|
696
574
|
y={15.5}
|
|
697
575
|
textAnchor="middle"
|
|
698
576
|
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.
|
|
577
|
+
// draw_horizontal_scale_indicators places the baseline at rect.ymin + 4 × UI_SCALE_FAC and centers labels by x − trunc(width / 2).
|
|
704
578
|
fontSize={11}
|
|
705
579
|
>
|
|
706
580
|
{Math.round(frame)}
|
|
707
581
|
</text>
|
|
708
582
|
))}
|
|
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. */}
|
|
583
|
+
{/* The summary disclosure is drawn read-only. Per-object channels belong to the Action Editor; channel and key edits refuse by name. */}
|
|
717
584
|
<path
|
|
718
585
|
d={`M3,${rowCentre - 3.5} L6.5,${rowCentre} L3,${rowCentre + 3.5} Z`}
|
|
719
586
|
fill={TIMELINE_THEME.scrubText}
|
|
720
587
|
/>
|
|
721
588
|
{/* THE SUMMARY ROW'S KEYS. Blender's Timeline draws exactly one
|
|
722
|
-
channel
|
|
723
|
-
|
|
589
|
+
channel (`anim_filter.cc:3845-3849`; the names region is hidden in
|
|
590
|
+
Timeline mode, `space_action.cc:785-790`) — and its diamonds are the shader's shape. The columns
|
|
724
591
|
are the SELECTION-FILTERED merge (`summaryColumns`), which is
|
|
725
|
-
what `show_keys_from_selected_only` decides;
|
|
726
|
-
plays is `play.keyframes` and is unaffected. */}
|
|
592
|
+
what `show_keys_from_selected_only` decides; sampled playback tracks are unaffected. */}
|
|
727
593
|
{summary.columns.map((key) => {
|
|
728
594
|
const factor = KEY_SIZE_FACTOR[key.type] ?? 1;
|
|
729
595
|
const radius = DIAMOND_RADIUS * factor;
|
|
@@ -786,47 +652,7 @@ function BlenderTimeline() {
|
|
|
786
652
|
);
|
|
787
653
|
}
|
|
788
654
|
|
|
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
|
-
*/
|
|
655
|
+
/** View exposes Only Show Selected and Frame All. Marker and Playback menus are omitted because their actions are unsupported. */
|
|
830
656
|
function TimelineViewMenu() {
|
|
831
657
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
832
658
|
const { onlySelected } = timelineViewState();
|
|
@@ -900,22 +726,20 @@ function TimelineViewMenu() {
|
|
|
900
726
|
);
|
|
901
727
|
}
|
|
902
728
|
|
|
729
|
+
/** Transport and frame controls follow Blender's Timeline order and draw inline SVG marks; range readouts stay read-only. */
|
|
903
730
|
function TimelineHeader() {
|
|
904
731
|
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
905
732
|
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
906
733
|
const play = blenderSkin.state();
|
|
907
734
|
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
735
|
// Static scenes have a timeline too. Disable only while its scene/transport
|
|
912
736
|
// is unavailable, using the same condition as the command door.
|
|
913
|
-
const playable = blenderSkin.playable
|
|
737
|
+
const playable = blenderSkin.playable;
|
|
914
738
|
const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
|
|
915
739
|
<button
|
|
916
740
|
key={key}
|
|
917
741
|
type="button"
|
|
918
|
-
title={playable ? label : `${label} — ${
|
|
742
|
+
title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
|
|
919
743
|
aria-label={label}
|
|
920
744
|
disabled={!playable}
|
|
921
745
|
onClick={onClick}
|
|
@@ -954,16 +778,11 @@ function TimelineHeader() {
|
|
|
954
778
|
}}
|
|
955
779
|
>
|
|
956
780
|
{/* GAME | ANIMATION in place of the title, as in the Game panel, so the person switches back
|
|
957
|
-
from either (`blender-
|
|
781
|
+
from either (`blender-play-area.tsx`); without a Play tool or a model, the title. */}
|
|
958
782
|
<PlayModeSwitch title="Timeline" />
|
|
959
783
|
<AnimationEditorMenu />
|
|
960
784
|
<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). */}
|
|
785
|
+
{/* Two spacers centre transport between the leading menus and trailing frame/range fields. */}
|
|
967
786
|
<div style={{ flex: 1 }} />
|
|
968
787
|
<div style={{ display: 'flex' }}>
|
|
969
788
|
{button(
|
|
@@ -972,11 +791,7 @@ function TimelineHeader() {
|
|
|
972
791
|
() => transport()?.seekFrame(play.start),
|
|
973
792
|
'Jump to Start',
|
|
974
793
|
)}
|
|
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. */}
|
|
794
|
+
{/* Previous and next keyframe use double triangles, distinct from jump-to-boundary bar-and-triangle marks. */}
|
|
980
795
|
{button(
|
|
981
796
|
'prev-key',
|
|
982
797
|
mark('M5 2.5v7L1.5 6zM8.5 3.2L11 6L8.5 8.8L6 6z'),
|
|
@@ -988,10 +803,7 @@ function TimelineHeader() {
|
|
|
988
803
|
},
|
|
989
804
|
'Jump to Previous Keyframe',
|
|
990
805
|
)}
|
|
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. */}
|
|
806
|
+
{/* Reverse playback uses the stage transport's direction. */}
|
|
995
807
|
{playable && playing()
|
|
996
808
|
? button('pause-rev', mark('M3 2h2v8H3zM7 2h2v8H7z'), () => transport()?.pause(), 'Pause')
|
|
997
809
|
: button(
|