@volter/editor-blender 0.1.0
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 +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- package/src/uv-view-state.ts +125 -0
|
@@ -0,0 +1,1056 @@
|
|
|
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
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
42
|
+
import type { StageTransportHandle } from '@volter/editor-sdk/host';
|
|
43
|
+
import { registerViewVerbs } from '@volter/editor-sdk/views';
|
|
44
|
+
import {
|
|
45
|
+
type ReactNode,
|
|
46
|
+
useCallback,
|
|
47
|
+
useEffect,
|
|
48
|
+
useMemo,
|
|
49
|
+
useRef,
|
|
50
|
+
useState,
|
|
51
|
+
useSyncExternalStore,
|
|
52
|
+
} from 'react';
|
|
53
|
+
import {
|
|
54
|
+
blenderActionClip,
|
|
55
|
+
blenderRig,
|
|
56
|
+
blenderRnaVersion,
|
|
57
|
+
subscribeBlenderRna,
|
|
58
|
+
} from '../host/blender-runtime-host';
|
|
59
|
+
import {
|
|
60
|
+
refuseTimelineGesture,
|
|
61
|
+
requestTimelineViewAll,
|
|
62
|
+
setTimelineOnlySelected,
|
|
63
|
+
setTimelineViewState,
|
|
64
|
+
subscribeTimelineView,
|
|
65
|
+
timelineViewAllRequest,
|
|
66
|
+
timelineViewState,
|
|
67
|
+
timelineViewVersion,
|
|
68
|
+
} from '../src/timeline-view-state';
|
|
69
|
+
import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
|
|
70
|
+
import {
|
|
71
|
+
CHANNEL_HEIGHT,
|
|
72
|
+
DIAMOND_RADIUS,
|
|
73
|
+
DIAMOND_SPRITE,
|
|
74
|
+
DIAMOND_STROKE,
|
|
75
|
+
FIRST_CHANNEL_TOP,
|
|
76
|
+
gridStep,
|
|
77
|
+
HEADER_HEIGHT,
|
|
78
|
+
KEY_COLORS,
|
|
79
|
+
KEY_SIZE_FACTOR,
|
|
80
|
+
LABEL_PADDING,
|
|
81
|
+
MIN_MAJOR_LINE_DISTANCE,
|
|
82
|
+
minorStep,
|
|
83
|
+
OUT_OF_RANGE,
|
|
84
|
+
PLAYHEAD,
|
|
85
|
+
SCRUB_HEIGHT,
|
|
86
|
+
TIMELINE_CHROME,
|
|
87
|
+
TIMELINE_THEME,
|
|
88
|
+
} from './blender-timeline-geometry';
|
|
89
|
+
|
|
90
|
+
export const point = 'workspace.document';
|
|
91
|
+
/** Blender's own name for this editor: `rna_space.cc`'s `SPACE_ACTION` item
|
|
92
|
+
* reads "Timeline" when `SpaceAction.mode` is `TIMELINE`
|
|
93
|
+
* (`rna_space.cc:257`), which is the mode the Layout workspace opens it in
|
|
94
|
+
* (measured on the engine: that screen's `DOPESHEET_EDITOR` area answers
|
|
95
|
+
* `ui_type: 'TIMELINE'`). */
|
|
96
|
+
export const title = 'Timeline';
|
|
97
|
+
|
|
98
|
+
const ZOOM_MIN = 0.25;
|
|
99
|
+
const ZOOM_MAX = 200;
|
|
100
|
+
|
|
101
|
+
const REFUSALS = {
|
|
102
|
+
'set-range':
|
|
103
|
+
"Setting the frame range writes `scene.frame_start`/`frame_end`, which the document would save. This is Blender's Timeline as an INSPECTION surface: the range is drawn, never set. Change it in bpy and the Timeline re-reads it.",
|
|
104
|
+
'insert-key':
|
|
105
|
+
'Inserting a keyframe writes the action. Editing parity is not the program — key it in bpy (`pose_bone.keyframe_insert`) and the Timeline re-reads the action on the next present.',
|
|
106
|
+
'move-key':
|
|
107
|
+
"Moving a keyframe writes the F-Curve's control points. The summary row draws the keys the action HAS; the Dope Sheet that would move them is the Animation workspace's, and it is not built.",
|
|
108
|
+
'delete-key': 'Deleting a keyframe writes the action. Editing parity is not the program.',
|
|
109
|
+
'set-interpolation':
|
|
110
|
+
"Setting a key's interpolation writes the F-Curve. Note also that this view PLAYS a per-frame bake (`rna_action_clip`), so interpolation is not a thing it reads back.",
|
|
111
|
+
'select-key':
|
|
112
|
+
'Selecting a keyframe writes `Keyframe.select_control_point`. The view draws the flags the data carries — a selected key takes `.common.anim.keyframe_selected` — and changes none of them.',
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* WHY A TRANSPORT GESTURE DOES NOTHING OVER A FILE WITH NO ACTION, in one
|
|
117
|
+
* sentence, with ONE author: the `play` verb throws it and the header's
|
|
118
|
+
* controls wear it as their title while they are disabled.
|
|
119
|
+
*
|
|
120
|
+
* It exists because the two halves disagreed. `vgai.timeline.play` refused by
|
|
121
|
+
* name while the BUTTON called `transport()?.play()` straight through — the
|
|
122
|
+
* clock went to `playing`, both glyphs flipped to Pause, and the playhead
|
|
123
|
+
* stayed on frame 1 forever, because `#seek`/the play tick return early with
|
|
124
|
+
* no mixer to move (`blender-runtime-skin.ts`). Measured on U8's walk 3
|
|
125
|
+
* (2026-09-20) over a `--template models` scaffold: `vgai.timeline.state`
|
|
126
|
+
* answered `action: null` with that warning while the header showed a running
|
|
127
|
+
* transport. A control whose success can be invisible must say so.
|
|
128
|
+
*/
|
|
129
|
+
const NOT_PLAYABLE =
|
|
130
|
+
'Nothing here is playable: no object in the scene carries an action this view could bind a mixer to.';
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* THE TRANSPORT THIS LOOK DRIVES — the Model document's, attached by
|
|
134
|
+
* `blenderSkin.attachTo` (Step 6). The Timeline cannot name a document id (it
|
|
135
|
+
* binds to the `blenderModelView` singleton), so it reads the handle the skin
|
|
136
|
+
* was given rather than looking one up.
|
|
137
|
+
*
|
|
138
|
+
* `null` before the Model document attaches. A gesture then refuses by name
|
|
139
|
+
* instead of silently doing nothing — a Timeline with no transport is a
|
|
140
|
+
* Timeline over nothing.
|
|
141
|
+
*/
|
|
142
|
+
function transport(): StageTransportHandle | null {
|
|
143
|
+
return blenderSkin.transport;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function playing(): boolean {
|
|
147
|
+
return transport()?.snapshot().playbackState === 'playing';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function refuse(text: string): unknown {
|
|
151
|
+
refuseTimelineGesture(text);
|
|
152
|
+
return report();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `Keyframe.type`'s significance order, as `session.py`'s `_KEY_TYPE_RANK`
|
|
156
|
+
* mirrors it from `keyframes_keylist.cc`'s `KEYFRAME_STATE` merge — a column
|
|
157
|
+
* drawn from several curves, or from several OBJECTS, takes the most
|
|
158
|
+
* significant type any of them carries. */
|
|
159
|
+
const KEY_TYPE_RANK: Record<string, number> = {
|
|
160
|
+
JITTER: 1,
|
|
161
|
+
GENERATED: 2,
|
|
162
|
+
MOVING_HOLD: 3,
|
|
163
|
+
BREAKDOWN: 4,
|
|
164
|
+
KEYFRAME: 5,
|
|
165
|
+
EXTREME: 6,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* THE SUMMARY ROW, FILTERED BY SELECTION — Blender's `show_keys_from_selected_only`.
|
|
170
|
+
*
|
|
171
|
+
* Blender's Timeline reads that flag off the SCENE (`ac->scene->flag &
|
|
172
|
+
* SCE_KEYS_NO_SELONLY` → `ADS_FILTER_ONLYSEL`, `anim_filter.cc:254-270`) and
|
|
173
|
+
* then skips any object whose base is not selected (`anim_filter.cc:2307`);
|
|
174
|
+
* the surviving objects' channels are merged into ONE row by
|
|
175
|
+
* `summary_to_keylist` (`keyframes_keylist.cc:1019`), which is the union of
|
|
176
|
+
* frames with the most significant type and `sel` OR-ed — so this merges the
|
|
177
|
+
* same way across objects that `session.py` already merges across curves.
|
|
178
|
+
*
|
|
179
|
+
* WHAT THE FILTER DOES NOT TOUCH is what PLAYS: the mixer holds the bound
|
|
180
|
+
* action whatever the row draws, in Blender and here.
|
|
181
|
+
*
|
|
182
|
+
* The reference read is why the default is ON: with the probe's rig
|
|
183
|
+
* unselected, Blender's own Timeline drew a ruler, a playhead, the range
|
|
184
|
+
* shading and an EMPTY summary row while its Dope Sheet drew all five keys in
|
|
185
|
+
* the same file.
|
|
186
|
+
*/
|
|
187
|
+
function summaryColumns(
|
|
188
|
+
summary: ReturnType<typeof blenderSkin.state>['summary'],
|
|
189
|
+
onlySelected: boolean,
|
|
190
|
+
): { columns: readonly { frame: number; type: string; select: boolean }[]; objects: string[] } {
|
|
191
|
+
const objects: string[] = [];
|
|
192
|
+
const merged = new Map<number, { frame: number; type: string; select: boolean }>();
|
|
193
|
+
for (const entry of summary) {
|
|
194
|
+
if (onlySelected && !entry.selected) continue;
|
|
195
|
+
objects.push(entry.object);
|
|
196
|
+
for (const column of entry.keyframes) {
|
|
197
|
+
const existing = merged.get(column.frame);
|
|
198
|
+
if (!existing) {
|
|
199
|
+
merged.set(column.frame, { ...column });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
existing.select = existing.select || column.select;
|
|
203
|
+
if ((KEY_TYPE_RANK[column.type] ?? 0) > (KEY_TYPE_RANK[existing.type] ?? 0))
|
|
204
|
+
existing.type = column.type;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { columns: [...merged.values()].sort((a, b) => a.frame - b.frame), objects };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The frames the summary row is currently drawing — what `next-keyframe` and
|
|
211
|
+
* `prev-keyframe` navigate, because the row and the jump are the same
|
|
212
|
+
* question. Blender's `screen.keyframe_jump` builds its keylist through the
|
|
213
|
+
* very same filtered walk. */
|
|
214
|
+
function summaryFrames(): number[] {
|
|
215
|
+
return summaryColumns(blenderSkin.state().summary, timelineViewState().onlySelected).columns.map(
|
|
216
|
+
(column) => column.frame,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** THE ONE REPORT: the mixer's state plus what the drawing measured itself at.
|
|
221
|
+
* Both halves in one answer, because a caller asking "where is the playhead"
|
|
222
|
+
* and a caller asking "did the ruler step correctly" are the same caller. */
|
|
223
|
+
function report(): unknown {
|
|
224
|
+
const view = timelineViewState();
|
|
225
|
+
return {
|
|
226
|
+
...blenderSkin.state(),
|
|
227
|
+
transform: view.transform,
|
|
228
|
+
size: view.size,
|
|
229
|
+
refusal: view.refusal,
|
|
230
|
+
drawn: view.drawn,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** THE SCRUB. It moves the mixer and forces the bones' world matrices; the
|
|
235
|
+
* stage's own render loop draws the next frame from them. Blender is not
|
|
236
|
+
* called — `state().engineCalls` is how that is checked.
|
|
237
|
+
*
|
|
238
|
+
* IT REFUSES OVER A FILE WITH NO ACTION, for the reason {@link NOT_PLAYABLE}
|
|
239
|
+
* states and with that one sentence: `#seek` returns at its first line with
|
|
240
|
+
* no mixer (`blender-runtime-skin.ts`), so the seek was a no-op and
|
|
241
|
+
* `report()` then answered `frame: 1` — the playhead's honest position and a
|
|
242
|
+
* complete lie about the gesture. Measured on walk 5 over a fresh
|
|
243
|
+
* `model-editor create` scaffold: `vgai.timeline.frame {frame:120}` and
|
|
244
|
+
* `vgai.timeline.jump-end` (frame 250) both answered `frame: 1`,
|
|
245
|
+
* `refusal: null`. This is the half of walk 4's W5 (#7740) that the header
|
|
246
|
+
* got and the VERBS did not — there the buttons were disabled wearing this
|
|
247
|
+
* sentence as their title while `play` threw it, and the five verbs that
|
|
248
|
+
* route through here kept answering as if they had moved something. All five
|
|
249
|
+
* are fixed by this one guard: `frame`, `jump-start`, `jump-end`,
|
|
250
|
+
* `next-keyframe`, `prev-keyframe`. */
|
|
251
|
+
function scrubTo(frame: number): unknown {
|
|
252
|
+
if (!blenderSkin.playable) return refuse(NOT_PLAYABLE);
|
|
253
|
+
const handle = transport();
|
|
254
|
+
if (!handle) return refuse('No stage transport is attached yet; open the model document first.');
|
|
255
|
+
handle.seekFrame(frame);
|
|
256
|
+
return report();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
registerViewVerbs({
|
|
260
|
+
view: 'timeline',
|
|
261
|
+
title,
|
|
262
|
+
verbs: [
|
|
263
|
+
{ id: 'state', run: () => report() },
|
|
264
|
+
{
|
|
265
|
+
id: 'frame',
|
|
266
|
+
title: 'Timeline: Set Frame',
|
|
267
|
+
run: (args) => {
|
|
268
|
+
const frame = Number(args?.['frame'] ?? args?.['to']);
|
|
269
|
+
if (!Number.isFinite(frame))
|
|
270
|
+
throw new Error('frame needs a numeric `frame` — a Blender frame number.');
|
|
271
|
+
return scrubTo(frame);
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'play',
|
|
276
|
+
title: 'Timeline: Play',
|
|
277
|
+
run: () => {
|
|
278
|
+
if (!blenderSkin.playable) throw new Error(NOT_PLAYABLE);
|
|
279
|
+
const handle = transport();
|
|
280
|
+
if (!handle)
|
|
281
|
+
throw new Error('No stage transport is attached yet; open the model document first.');
|
|
282
|
+
handle.play();
|
|
283
|
+
return report();
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 'pause',
|
|
288
|
+
title: 'Timeline: Pause',
|
|
289
|
+
run: () => {
|
|
290
|
+
// The bookmark write is no longer this look's: pausing SETTLES the
|
|
291
|
+
// transport, and the skin's `onSettled` subscription is what writes
|
|
292
|
+
// `frame_current` once.
|
|
293
|
+
const handle = transport();
|
|
294
|
+
if (!handle) return refuse('No stage transport is attached yet.');
|
|
295
|
+
handle.pause();
|
|
296
|
+
return report();
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'jump-start',
|
|
301
|
+
title: 'Timeline: Jump to Start',
|
|
302
|
+
run: () => scrubTo(blenderSkin.state().start),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'jump-end',
|
|
306
|
+
title: 'Timeline: Jump to End',
|
|
307
|
+
run: () => scrubTo(blenderSkin.state().end),
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'next-keyframe',
|
|
311
|
+
title: 'Timeline: Next Keyframe',
|
|
312
|
+
run: () => {
|
|
313
|
+
const frame = blenderSkin.frame();
|
|
314
|
+
const next = summaryFrames().find((key) => key > frame + 1e-4);
|
|
315
|
+
if (next === undefined) return report();
|
|
316
|
+
return scrubTo(next);
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: 'prev-keyframe',
|
|
321
|
+
title: 'Timeline: Previous Keyframe',
|
|
322
|
+
run: () => {
|
|
323
|
+
const frame = blenderSkin.frame();
|
|
324
|
+
const previous = summaryFrames()
|
|
325
|
+
.reverse()
|
|
326
|
+
.find((key) => key < frame - 1e-4);
|
|
327
|
+
if (previous === undefined) return report();
|
|
328
|
+
return scrubTo(previous);
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: 'view-all',
|
|
333
|
+
title: 'Timeline: View All',
|
|
334
|
+
run: () => {
|
|
335
|
+
requestTimelineViewAll();
|
|
336
|
+
return report();
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'zoom',
|
|
341
|
+
title: 'Timeline: Zoom',
|
|
342
|
+
run: (args) => {
|
|
343
|
+
const to = Number(args?.['to'] ?? args?.['zoom']);
|
|
344
|
+
if (!Number.isFinite(to))
|
|
345
|
+
throw new Error('zoom needs a numeric `to` — CSS pixels per frame, 0.25 … 200.');
|
|
346
|
+
const { transform, size } = timelineViewState();
|
|
347
|
+
const centre = transform.startFrame + size.w / 2 / transform.pixelsPerFrame;
|
|
348
|
+
const pixelsPerFrame = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, to));
|
|
349
|
+
setTimelineViewState({
|
|
350
|
+
transform: { pixelsPerFrame, startFrame: centre - size.w / 2 / pixelsPerFrame },
|
|
351
|
+
});
|
|
352
|
+
return report();
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: 'pan',
|
|
357
|
+
title: 'Timeline: Pan',
|
|
358
|
+
run: (args) => {
|
|
359
|
+
const frames = Number(args?.['frames'] ?? 0);
|
|
360
|
+
if (!Number.isFinite(frames)) throw new Error('pan needs a numeric `frames`.');
|
|
361
|
+
const { transform } = timelineViewState();
|
|
362
|
+
setTimelineViewState({
|
|
363
|
+
transform: { ...transform, startFrame: transform.startFrame + frames },
|
|
364
|
+
});
|
|
365
|
+
return report();
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
...(Object.keys(REFUSALS) as (keyof typeof REFUSALS)[]).map((id) => ({
|
|
369
|
+
id,
|
|
370
|
+
run: () => refuse(REFUSALS[id]),
|
|
371
|
+
})),
|
|
372
|
+
],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
export default function BlenderTimeline() {
|
|
376
|
+
// THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
|
|
377
|
+
// every presented frame bumps it, which is exactly when a rig or an action
|
|
378
|
+
// can have moved.
|
|
379
|
+
const rnaVersion = useSyncExternalStore(
|
|
380
|
+
subscribeBlenderRna,
|
|
381
|
+
blenderRnaVersion,
|
|
382
|
+
blenderRnaVersion,
|
|
383
|
+
);
|
|
384
|
+
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
385
|
+
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
386
|
+
const canvas = useRef<HTMLDivElement | null>(null);
|
|
387
|
+
const framed = useRef(-1);
|
|
388
|
+
const { transform, size, refusal, onlySelected } = timelineViewState();
|
|
389
|
+
const play = blenderSkin.state();
|
|
390
|
+
|
|
391
|
+
// THE BIND LIVES WITH THE TIMELINE, not with every present, and that is a
|
|
392
|
+
// cost decision stated rather than implied: `rna_rig` walks every vertex of
|
|
393
|
+
// every rigged mesh and `rna_action_clip` bakes every bone per frame, so
|
|
394
|
+
// paying for both on each of a session's hundreds of `blender-execute`
|
|
395
|
+
// presents would be a round trip nobody asked for. A project with no
|
|
396
|
+
// Timeline open therefore presents exactly as it did before this unit, with
|
|
397
|
+
// ordinary `THREE.Mesh`es.
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
let live = true;
|
|
400
|
+
void blenderSkin
|
|
401
|
+
.bind(blenderModelView, {
|
|
402
|
+
rig: () => blenderRig(),
|
|
403
|
+
clip: () => blenderActionClip(),
|
|
404
|
+
})
|
|
405
|
+
.catch((thrown: unknown) => {
|
|
406
|
+
if (!live) return;
|
|
407
|
+
refuseTimelineGesture(
|
|
408
|
+
`The Timeline could not read this file's rig: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
return () => {
|
|
412
|
+
live = false;
|
|
413
|
+
};
|
|
414
|
+
}, [rnaVersion]);
|
|
415
|
+
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
const element = canvas.current;
|
|
418
|
+
if (!element) return;
|
|
419
|
+
const observer = new ResizeObserver(() => {
|
|
420
|
+
const next = { w: element.clientWidth, h: element.clientHeight };
|
|
421
|
+
const current = timelineViewState().size;
|
|
422
|
+
if (current.w !== next.w || current.h !== next.h) setTimelineViewState({ size: next });
|
|
423
|
+
});
|
|
424
|
+
observer.observe(element);
|
|
425
|
+
setTimelineViewState({ size: { w: element.clientWidth, h: element.clientHeight } });
|
|
426
|
+
return () => observer.disconnect();
|
|
427
|
+
}, []);
|
|
428
|
+
|
|
429
|
+
// VIEW ALL — Blender's `action.view_all` over the SCENE range with the same
|
|
430
|
+
// margin its own `view_all` uses; the ASK is a counter because framing needs
|
|
431
|
+
// the measured box, which the verb does not have.
|
|
432
|
+
const request = timelineViewAllRequest();
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
// `framed` starts at −1 so the FIRST pass frames and every later one waits
|
|
435
|
+
// for a real ask. It was `0` against a request counter that also starts at
|
|
436
|
+
// 0, with a `request !== 0` escape — which re-framed on every render and
|
|
437
|
+
// was one of the two feedback loops the first walk found.
|
|
438
|
+
if (request === framed.current) return;
|
|
439
|
+
framed.current = request;
|
|
440
|
+
const { w } = timelineViewState().size;
|
|
441
|
+
if (w === 0) return;
|
|
442
|
+
const span = Math.max(1, play.end - play.start);
|
|
443
|
+
const pixelsPerFrame = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, (w * 0.9) / span));
|
|
444
|
+
setTimelineViewState({
|
|
445
|
+
transform: { pixelsPerFrame, startFrame: play.start - (w * 0.05) / pixelsPerFrame },
|
|
446
|
+
framedAt: Date.now(),
|
|
447
|
+
});
|
|
448
|
+
}, [request, play.start, play.end]);
|
|
449
|
+
|
|
450
|
+
const toX = useCallback(
|
|
451
|
+
(frame: number) => (frame - transform.startFrame) * transform.pixelsPerFrame,
|
|
452
|
+
[transform],
|
|
453
|
+
);
|
|
454
|
+
const toFrame = useCallback(
|
|
455
|
+
(x: number) => transform.startFrame + x / transform.pixelsPerFrame,
|
|
456
|
+
[transform],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// THE SUMMARY ROW'S OWN COLUMNS — the door's per-object report put through
|
|
460
|
+
// Blender's selection filter. `play.keyframes` is the SUBJECT's action and
|
|
461
|
+
// stays what the mixer plays; this is what the row DRAWS.
|
|
462
|
+
const summary = useMemo(
|
|
463
|
+
() => summaryColumns(play.summary, onlySelected),
|
|
464
|
+
[play.summary, onlySelected],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const ruler = useMemo(() => {
|
|
468
|
+
if (size.w === 0) return null;
|
|
469
|
+
const viewFrames = size.w / transform.pixelsPerFrame;
|
|
470
|
+
// `get_min_line_distance_x` (`view2d_draw.cc:485`): the larger of
|
|
471
|
+
// `MIN_MAJOR_LINE_DISTANCE` and the widest label plus its padding. The
|
|
472
|
+
// label is a frame integer at 11 px, and Blender measures the string it
|
|
473
|
+
// would actually draw at both ends of the view.
|
|
474
|
+
const widest = Math.max(
|
|
475
|
+
`${Math.round(transform.startFrame)}`.length,
|
|
476
|
+
`${Math.round(transform.startFrame + viewFrames)}`.length,
|
|
477
|
+
);
|
|
478
|
+
const minDistance = Math.max(MIN_MAJOR_LINE_DISTANCE, widest * 6 + LABEL_PADDING);
|
|
479
|
+
// `base` is the scene FPS: `ED_time_scrub_draw` passes it
|
|
480
|
+
// (`space_action.cc:296-298`), which is why Blender's ruler prefers
|
|
481
|
+
// second-aligned steps.
|
|
482
|
+
const major = gridStep(Math.round(play.fps), size.w + 1, viewFrames, minDistance);
|
|
483
|
+
const minor = minorStep(major);
|
|
484
|
+
const first = Math.ceil(transform.startFrame / major) * major;
|
|
485
|
+
const majors: number[] = [];
|
|
486
|
+
for (let frame = first; frame <= transform.startFrame + viewFrames; frame += major)
|
|
487
|
+
majors.push(frame);
|
|
488
|
+
const minors: number[] = [];
|
|
489
|
+
// `view2d_draw_lines` (`view2d_draw.cc:273-275`): the minor lines draw
|
|
490
|
+
// only while `(pixel_width / view_width) * (major / divisor)` — the minor
|
|
491
|
+
// step's own PIXEL distance — stays above `MIN_MAJOR_LINE_DISTANCE / 5`.
|
|
492
|
+
// It is the STEP's distance, not the frame's; dropping the step from the
|
|
493
|
+
// product hid every minor line at any zoom below 7 px per frame, which is
|
|
494
|
+
// most of them (measured on the probe: 4.76 px/frame, minor step 6, so
|
|
495
|
+
// 28.6 px between minor lines against a 7-px floor).
|
|
496
|
+
if (minor !== null && minor * transform.pixelsPerFrame >= MIN_MAJOR_LINE_DISTANCE / 5) {
|
|
497
|
+
const firstMinor = Math.ceil(transform.startFrame / minor) * minor;
|
|
498
|
+
for (let frame = firstMinor; frame <= transform.startFrame + viewFrames; frame += minor)
|
|
499
|
+
if (Math.abs(frame / major - Math.round(frame / major)) > 1e-6) minors.push(frame);
|
|
500
|
+
}
|
|
501
|
+
return { major, minor, majors, minors };
|
|
502
|
+
}, [size.w, transform, play.fps]);
|
|
503
|
+
|
|
504
|
+
const bodyTop = SCRUB_HEIGHT;
|
|
505
|
+
const rowTop = FIRST_CHANNEL_TOP;
|
|
506
|
+
const rowCentre = rowTop + CHANNEL_HEIGHT / 2;
|
|
507
|
+
|
|
508
|
+
// WHAT THE VIEW ACTUALLY DREW, published so a parity table is a measurement
|
|
509
|
+
// of the shipped drawing rather than a second run of the same arithmetic.
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (!ruler) return;
|
|
512
|
+
const warnings: string[] = [...play.warnings];
|
|
513
|
+
if (!play.action)
|
|
514
|
+
warnings.push(
|
|
515
|
+
"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.",
|
|
516
|
+
);
|
|
517
|
+
warnings.push(
|
|
518
|
+
'While a clip plays, the picture is three.js evaluating the skin over the mesh columns Blender exported at the BIND frame. Anything else Blender would re-evaluate per frame — shape keys, a Displace or Cloth modifier reading the frame, a geometry driver — does not follow the playhead.',
|
|
519
|
+
);
|
|
520
|
+
warnings.push(
|
|
521
|
+
`Blender puts this view's ONLY-SHOW-SELECTED toggle in the Timeline header's View MENU (\`TIME_MT_view\`'s \`layout.prop(scene, "show_keys_from_selected_only")\`, \`space_time.py\`); this header has no menu region, so the same toggle is drawn as a header button at the leading edge, where that menu sits. The filter itself is Blender's — \`ADS_FILTER_ONLYSEL\`, \`anim_filter.cc:254-270\`, tested per object at \`:2307\`.`,
|
|
522
|
+
);
|
|
523
|
+
if (onlySelected && summary.objects.length === 0 && play.summary.length > 0)
|
|
524
|
+
warnings.push(
|
|
525
|
+
`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.`,
|
|
526
|
+
);
|
|
527
|
+
warnings.push(
|
|
528
|
+
"Markers, the cache bar and the preview range are drawn by Blender's Timeline and not here; the door carries none of them.",
|
|
529
|
+
);
|
|
530
|
+
const next = {
|
|
531
|
+
drawn: {
|
|
532
|
+
action: play.action,
|
|
533
|
+
object: play.object,
|
|
534
|
+
armature: play.armature,
|
|
535
|
+
frame: play.frame,
|
|
536
|
+
start: play.start,
|
|
537
|
+
end: play.end,
|
|
538
|
+
fps: play.fps,
|
|
539
|
+
playing: playing(),
|
|
540
|
+
keyframes: summary.columns.map((key) => key.frame),
|
|
541
|
+
onlySelected,
|
|
542
|
+
summaryObjects: summary.objects,
|
|
543
|
+
majorStep: ruler.major,
|
|
544
|
+
minorStep: ruler.minor,
|
|
545
|
+
majorLines: ruler.majors.length,
|
|
546
|
+
minorLines: ruler.minors.length,
|
|
547
|
+
labels: ruler.majors.length,
|
|
548
|
+
diamonds: summary.columns.length,
|
|
549
|
+
diamondRadius: DIAMOND_RADIUS,
|
|
550
|
+
diamondSprite: DIAMOND_SPRITE,
|
|
551
|
+
bones: play.bones,
|
|
552
|
+
tracks: play.tracks,
|
|
553
|
+
engineCalls: play.engineCalls,
|
|
554
|
+
warnings,
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
// PUBLISHED ONLY WHEN IT CHANGED, and that is not an optimisation — it is
|
|
558
|
+
// the fix for a REAL LOOP the first walk found: this effect's inputs
|
|
559
|
+
// include `play`, which is a fresh object every render, so an
|
|
560
|
+
// unconditional publish re-rendered, which re-ran the effect, which
|
|
561
|
+
// published again. React caught it by name ("Maximum update depth
|
|
562
|
+
// exceeded ... Tool crashed: blender-timeline.document.tsx") and the whole
|
|
563
|
+
// area painted the crash card instead of a Timeline. A published
|
|
564
|
+
// measurement is a VALUE, so comparing values is what stops it.
|
|
565
|
+
if (JSON.stringify(next.drawn) !== JSON.stringify(timelineViewState().drawn))
|
|
566
|
+
setTimelineViewState(next);
|
|
567
|
+
}, [ruler, play, summary, onlySelected]);
|
|
568
|
+
|
|
569
|
+
const dragging = useRef(false);
|
|
570
|
+
const scrubFromPointer = useCallback(
|
|
571
|
+
(clientX: number) => {
|
|
572
|
+
const element = canvas.current;
|
|
573
|
+
if (!element) return;
|
|
574
|
+
const box = element.getBoundingClientRect();
|
|
575
|
+
transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
|
|
576
|
+
},
|
|
577
|
+
[toFrame],
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const pillText = `${Math.round(play.frame)}`;
|
|
581
|
+
const pillWidth = Math.max(PLAYHEAD.minPillWidth, pillText.length * 6 + 2 * PLAYHEAD.textPadding);
|
|
582
|
+
const playheadX = toX(play.frame);
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
<div
|
|
586
|
+
data-testid="blender-timeline"
|
|
587
|
+
style={{
|
|
588
|
+
// FILLED FROM THE HOST BOX, NOT `height: 100%` — the measured
|
|
589
|
+
// difference between a drawer panel and an EDITOR GROUP that the UV
|
|
590
|
+
// view's walk found: in an area's auto-height content box `100%`
|
|
591
|
+
// resolves to ZERO and the view reads everything it needs with nowhere
|
|
592
|
+
// to draw it.
|
|
593
|
+
position: 'absolute',
|
|
594
|
+
inset: 0,
|
|
595
|
+
display: 'flex',
|
|
596
|
+
flexDirection: 'column',
|
|
597
|
+
overflow: 'hidden',
|
|
598
|
+
background: TIMELINE_THEME.back,
|
|
599
|
+
color: TIMELINE_CHROME.text,
|
|
600
|
+
font: '11px Inter, system-ui, sans-serif',
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
<TimelineHeader />
|
|
604
|
+
<div ref={canvas} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
605
|
+
<svg
|
|
606
|
+
width={size.w}
|
|
607
|
+
height={size.h}
|
|
608
|
+
style={{ display: 'block', cursor: 'ew-resize' }}
|
|
609
|
+
aria-label="Timeline"
|
|
610
|
+
onPointerDown={(event) => {
|
|
611
|
+
dragging.current = true;
|
|
612
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
613
|
+
scrubFromPointer(event.clientX);
|
|
614
|
+
}}
|
|
615
|
+
onPointerMove={(event) => {
|
|
616
|
+
if (dragging.current) scrubFromPointer(event.clientX);
|
|
617
|
+
}}
|
|
618
|
+
onPointerUp={(event) => {
|
|
619
|
+
dragging.current = false;
|
|
620
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
621
|
+
// NOTHING IS WRITTEN HERE. Scrub-end is the TRANSPORT's — the
|
|
622
|
+
// quiet after the last seek settles it, and the skin's `onSettled`
|
|
623
|
+
// subscription writes `frame_current` once. A second write here
|
|
624
|
+
// would race that one and re-introduce the per-gesture double
|
|
625
|
+
// write this step removed.
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
<title>Timeline</title>
|
|
629
|
+
{/* THE REGION'S CLEAR, then the OUT-OF-RANGE shading over it
|
|
630
|
+
(`ANIM_draw_framerange`, `anim_draw.cc:172-190`). */}
|
|
631
|
+
<rect x={0} y={0} width={size.w} height={size.h} fill={TIMELINE_THEME.back} />
|
|
632
|
+
<rect
|
|
633
|
+
x={0}
|
|
634
|
+
y={bodyTop}
|
|
635
|
+
width={Math.max(0, toX(play.start))}
|
|
636
|
+
height={Math.max(0, size.h - bodyTop)}
|
|
637
|
+
fill={OUT_OF_RANGE}
|
|
638
|
+
/>
|
|
639
|
+
<rect
|
|
640
|
+
x={toX(play.end)}
|
|
641
|
+
y={bodyTop}
|
|
642
|
+
width={Math.max(0, size.w - toX(play.end))}
|
|
643
|
+
height={Math.max(0, size.h - bodyTop)}
|
|
644
|
+
fill={OUT_OF_RANGE}
|
|
645
|
+
/>
|
|
646
|
+
{/* THE GRID — minor first, then major, the order
|
|
647
|
+
`view2d_draw_lines` uses (`view2d_draw.cc:260-292`). */}
|
|
648
|
+
{ruler?.minors.map((frame) => (
|
|
649
|
+
<line
|
|
650
|
+
key={`n${frame}`}
|
|
651
|
+
x1={toX(frame)}
|
|
652
|
+
y1={bodyTop}
|
|
653
|
+
x2={toX(frame)}
|
|
654
|
+
y2={size.h}
|
|
655
|
+
stroke={TIMELINE_THEME.gridMinor}
|
|
656
|
+
strokeWidth={1}
|
|
657
|
+
/>
|
|
658
|
+
))}
|
|
659
|
+
{ruler?.majors.map((frame) => (
|
|
660
|
+
<line
|
|
661
|
+
key={`m${frame}`}
|
|
662
|
+
x1={toX(frame)}
|
|
663
|
+
y1={bodyTop}
|
|
664
|
+
x2={toX(frame)}
|
|
665
|
+
y2={size.h}
|
|
666
|
+
stroke={TIMELINE_THEME.grid}
|
|
667
|
+
strokeWidth={1}
|
|
668
|
+
/>
|
|
669
|
+
))}
|
|
670
|
+
{/* THE SCRUB STRIP — `ED_time_scrub_draw`'s own background
|
|
671
|
+
(`time_scrub_ui.cc:52-65`, `TH_TIME_SCRUB_BACKGROUND`). */}
|
|
672
|
+
<rect x={0} y={0} width={size.w} height={SCRUB_HEIGHT} fill={TIMELINE_THEME.scrubBack} />
|
|
673
|
+
{ruler?.majors.map((frame) => (
|
|
674
|
+
<text
|
|
675
|
+
key={`t${frame}`}
|
|
676
|
+
x={toX(frame)}
|
|
677
|
+
y={15.5}
|
|
678
|
+
textAnchor="middle"
|
|
679
|
+
fill={TIMELINE_THEME.scrubText}
|
|
680
|
+
// `draw_horizontal_scale_indicators` (`view2d_draw.cc:340-348`):
|
|
681
|
+
// the baseline is `rect.ymin + 4 * UI_SCALE_FAC` and the label is
|
|
682
|
+
// centred on the line by `x − trunc(width / 2)`. Confirmed in
|
|
683
|
+
// Blender's frame: the glyph rows are 34…41 under a 26-px header,
|
|
684
|
+
// i.e. a baseline 15.5 px below the region's top edge.
|
|
685
|
+
fontSize={11}
|
|
686
|
+
>
|
|
687
|
+
{Math.round(frame)}
|
|
688
|
+
</text>
|
|
689
|
+
))}
|
|
690
|
+
{/* THE SUMMARY ROW'S DISCLOSURE TRIANGLE, at the region's leading
|
|
691
|
+
edge — Blender draws one there for the collapsed summary channel
|
|
692
|
+
(`ANIM_channel_draw`'s expand icon; measured on Blender 5.2.0 LTS
|
|
693
|
+
as a small `>` at x~4 on the channel row). It is DRAWN, not
|
|
694
|
+
clickable: expanding the summary would list per-object channels,
|
|
695
|
+
which is the Dope Sheet's job and is not built — the same reason
|
|
696
|
+
`insert-key` and friends are recorded refusals rather than
|
|
697
|
+
controls. */}
|
|
698
|
+
<path
|
|
699
|
+
d={`M3,${rowCentre - 3.5} L6.5,${rowCentre} L3,${rowCentre + 3.5} Z`}
|
|
700
|
+
fill={TIMELINE_THEME.scrubText}
|
|
701
|
+
/>
|
|
702
|
+
{/* THE SUMMARY ROW'S KEYS. Blender's Timeline draws exactly one
|
|
703
|
+
channel — see `TIMELINE_ROWS` for the two source facts that make
|
|
704
|
+
that so — and its diamonds are the shader's shape. The columns
|
|
705
|
+
are the SELECTION-FILTERED merge (`summaryColumns`), which is
|
|
706
|
+
what `show_keys_from_selected_only` decides; the action the mixer
|
|
707
|
+
plays is `play.keyframes` and is unaffected. */}
|
|
708
|
+
{summary.columns.map((key) => {
|
|
709
|
+
const factor = KEY_SIZE_FACTOR[key.type] ?? 1;
|
|
710
|
+
const radius = DIAMOND_RADIUS * factor;
|
|
711
|
+
const colors = KEY_COLORS[key.type] ?? KEY_COLORS['KEYFRAME']!;
|
|
712
|
+
const x = toX(key.frame);
|
|
713
|
+
return (
|
|
714
|
+
<polygon
|
|
715
|
+
key={`k${key.frame}`}
|
|
716
|
+
points={`${x},${rowCentre - radius} ${x + radius},${rowCentre} ${x},${rowCentre + radius} ${x - radius},${rowCentre}`}
|
|
717
|
+
fill={key.select ? colors.selected : colors.fill}
|
|
718
|
+
stroke={TIMELINE_THEME.keyBorder}
|
|
719
|
+
strokeWidth={DIAMOND_STROKE * factor}
|
|
720
|
+
/>
|
|
721
|
+
);
|
|
722
|
+
})}
|
|
723
|
+
{/* THE PLAYHEAD: stalk, pill, number, tip — `draw_playhead_stalk` /
|
|
724
|
+
`_box` / `_tip` (`time_scrub_ui.cc:118-206`). */}
|
|
725
|
+
<rect
|
|
726
|
+
x={playheadX - PLAYHEAD.stalkWidth / 2}
|
|
727
|
+
y={SCRUB_HEIGHT}
|
|
728
|
+
width={PLAYHEAD.stalkWidth}
|
|
729
|
+
height={Math.max(0, size.h - SCRUB_HEIGHT)}
|
|
730
|
+
fill={TIMELINE_THEME.playhead}
|
|
731
|
+
/>
|
|
732
|
+
<rect
|
|
733
|
+
x={playheadX - pillWidth / 2}
|
|
734
|
+
y={PLAYHEAD.margin}
|
|
735
|
+
width={pillWidth}
|
|
736
|
+
height={SCRUB_HEIGHT - 2 * PLAYHEAD.margin}
|
|
737
|
+
rx={PLAYHEAD.radius}
|
|
738
|
+
fill={TIMELINE_THEME.playhead}
|
|
739
|
+
/>
|
|
740
|
+
<polygon
|
|
741
|
+
points={`${playheadX - PLAYHEAD.tipHalfWidth},${SCRUB_HEIGHT - PLAYHEAD.margin} ${playheadX + PLAYHEAD.tipHalfWidth},${SCRUB_HEIGHT - PLAYHEAD.margin} ${playheadX},${SCRUB_HEIGHT - PLAYHEAD.margin + PLAYHEAD.tipHeight}`}
|
|
742
|
+
fill={TIMELINE_THEME.playhead}
|
|
743
|
+
/>
|
|
744
|
+
<text
|
|
745
|
+
x={playheadX}
|
|
746
|
+
y={15.5}
|
|
747
|
+
textAnchor="middle"
|
|
748
|
+
fill={TIMELINE_THEME.playheadText}
|
|
749
|
+
fontSize={11}
|
|
750
|
+
>
|
|
751
|
+
{pillText}
|
|
752
|
+
</text>
|
|
753
|
+
</svg>
|
|
754
|
+
</div>
|
|
755
|
+
{refusal ? (
|
|
756
|
+
<div
|
|
757
|
+
style={{
|
|
758
|
+
borderTop: `1px solid ${TIMELINE_CHROME.rule}`,
|
|
759
|
+
padding: TIMELINE_CHROME.statusPadding,
|
|
760
|
+
color: TIMELINE_CHROME.refusal,
|
|
761
|
+
}}
|
|
762
|
+
>
|
|
763
|
+
{refusal}
|
|
764
|
+
</div>
|
|
765
|
+
) : null}
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* THE HEADER — `space_time.py`'s `playback_controls`, read as the
|
|
772
|
+
* SPECIFICATION of what to draw and never run (the standing ruling). Its order
|
|
773
|
+
* is that function's, top to bottom: the Playback popover, a spacer, the
|
|
774
|
+
* auto-key toggle, the SIX transport buttons
|
|
775
|
+
* (`screen.frame_jump` REW → `screen.keyframe_jump` PREV_KEYFRAME →
|
|
776
|
+
* `screen.animation_play` PLAY_REVERSE → PLAY → `keyframe_jump` NEXT_KEYFRAME
|
|
777
|
+
* → `frame_jump` FF), the time-jump pair, the snap toggle, a spacer, the frame
|
|
778
|
+
* field, and the Start/End pair.
|
|
779
|
+
*
|
|
780
|
+
* WHAT IS DRAWN AND WHAT IS NOT. The six transport buttons and the frame field
|
|
781
|
+
* are real — they are the scrub, which is the one thing this surface changes.
|
|
782
|
+
* Start and End are READ-ONLY readouts, because writing them writes the file
|
|
783
|
+
* (`set-range` refuses by name). The Playback and snap popovers are not drawn
|
|
784
|
+
* at all: every control in them sets a preference or a tool setting that this
|
|
785
|
+
* view does not read, and a popover that opens onto nothing is worse than an
|
|
786
|
+
* absent one. Named here rather than painted.
|
|
787
|
+
*
|
|
788
|
+
* THE BUTTONS ARE 20 px — `UI_UNIT_X` (`wm_window.cc:779`'s `widget_unit`),
|
|
789
|
+
* confirmed in Blender's own frame: the block of six spans exactly 120 px.
|
|
790
|
+
* Their glyphs are TRACED from Blender's own icon sources the way I2's tab
|
|
791
|
+
* glyphs are — see `blender-icons.source.mjs`; until that trace covers the
|
|
792
|
+
* transport family they are drawn here as the plain geometric marks
|
|
793
|
+
* `release/datafiles/icons_svg/{rew,prev_keyframe,play,ff}.svg` are built from
|
|
794
|
+
* (a triangle, a triangle with a bar, a pair), which is a KNOWN gap and is in
|
|
795
|
+
* the view's own warnings rather than implied to be Blender's mark.
|
|
796
|
+
*/
|
|
797
|
+
/**
|
|
798
|
+
* THE VIEW MENU — Blender's `TIME_MT_view`, and the reason this exists is a
|
|
799
|
+
* deviation the old header admitted in its own comment: `show_keys_from_selected_only`
|
|
800
|
+
* belongs in a View MENU (`space_time.py`'s `layout.prop`), and with no menu
|
|
801
|
+
* region it was drawn as a lone toggle at the leading edge instead.
|
|
802
|
+
*
|
|
803
|
+
* Measured against Blender 5.2.0 LTS at 1728x997: its header's leading cluster
|
|
804
|
+
* is an editor-type dropdown then `View`, `Marker` and `Playback`. This builds
|
|
805
|
+
* `View` and ONLY `View`, because View is the one whose items this surface
|
|
806
|
+
* actually has — `Only Show Selected` and `Frame All`. `Marker` and `Playback`
|
|
807
|
+
* would be empty shells over behaviour that does not exist here, and a menu
|
|
808
|
+
* that exists to look like Blender is exactly the invented affordance the
|
|
809
|
+
* program forbids. Their absence is a STATED gap in WORK.md, not a pretence.
|
|
810
|
+
*/
|
|
811
|
+
function TimelineViewMenu() {
|
|
812
|
+
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
813
|
+
const { onlySelected } = timelineViewState();
|
|
814
|
+
const [open, setOpen] = useState(false);
|
|
815
|
+
const item = (label: string, checked: boolean | null, run: () => void) => (
|
|
816
|
+
<button
|
|
817
|
+
type="button"
|
|
818
|
+
onClick={() => {
|
|
819
|
+
run();
|
|
820
|
+
setOpen(false);
|
|
821
|
+
}}
|
|
822
|
+
style={{
|
|
823
|
+
display: 'flex',
|
|
824
|
+
alignItems: 'center',
|
|
825
|
+
gap: 'var(--vgai-space-2)',
|
|
826
|
+
width: '100%',
|
|
827
|
+
padding: 'var(--vgai-space-1) var(--vgai-space-3)',
|
|
828
|
+
border: 'none',
|
|
829
|
+
background: 'transparent',
|
|
830
|
+
color: TIMELINE_CHROME.widgetText,
|
|
831
|
+
font: 'inherit',
|
|
832
|
+
textAlign: 'left',
|
|
833
|
+
cursor: 'pointer',
|
|
834
|
+
}}
|
|
835
|
+
>
|
|
836
|
+
<span style={{ width: 12, display: 'inline-block' }}>
|
|
837
|
+
{checked === null ? '' : checked ? '✓' : ''}
|
|
838
|
+
</span>
|
|
839
|
+
{label}
|
|
840
|
+
</button>
|
|
841
|
+
);
|
|
842
|
+
return (
|
|
843
|
+
<span style={{ position: 'relative' }}>
|
|
844
|
+
<button
|
|
845
|
+
type="button"
|
|
846
|
+
aria-haspopup="menu"
|
|
847
|
+
aria-expanded={open}
|
|
848
|
+
onClick={() => setOpen((was) => !was)}
|
|
849
|
+
style={{
|
|
850
|
+
border: 'none',
|
|
851
|
+
background: 'transparent',
|
|
852
|
+
color: TIMELINE_CHROME.text,
|
|
853
|
+
font: 'inherit',
|
|
854
|
+
padding: '0 var(--vgai-space-2)',
|
|
855
|
+
cursor: 'pointer',
|
|
856
|
+
}}
|
|
857
|
+
>
|
|
858
|
+
View
|
|
859
|
+
</button>
|
|
860
|
+
{open ? (
|
|
861
|
+
<span
|
|
862
|
+
role="menu"
|
|
863
|
+
style={{
|
|
864
|
+
position: 'absolute',
|
|
865
|
+
top: '100%',
|
|
866
|
+
left: 0,
|
|
867
|
+
zIndex: 20,
|
|
868
|
+
minWidth: 210,
|
|
869
|
+
display: 'flex',
|
|
870
|
+
flexDirection: 'column',
|
|
871
|
+
background: TIMELINE_CHROME.widget,
|
|
872
|
+
border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
|
|
873
|
+
boxShadow: '0 6px 18px rgba(0,0,0,0.45)',
|
|
874
|
+
}}
|
|
875
|
+
>
|
|
876
|
+
{item('Only Show Selected', onlySelected, () => setTimelineOnlySelected(!onlySelected))}
|
|
877
|
+
{item('Frame All', null, () => requestTimelineViewAll())}
|
|
878
|
+
</span>
|
|
879
|
+
) : null}
|
|
880
|
+
</span>
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function TimelineHeader() {
|
|
885
|
+
useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
|
|
886
|
+
useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
|
|
887
|
+
const play = blenderSkin.state();
|
|
888
|
+
const unit = TIMELINE_CHROME.unit;
|
|
889
|
+
// THE TRANSPORT IS ONLY AS TRUE AS THE FILE. With no action there is no
|
|
890
|
+
// mixer, so every seek and the play tick return early; a control that still
|
|
891
|
+
// accepted the click reported a state the picture never took (see
|
|
892
|
+
// NOT_PLAYABLE). Disabled, wearing the reason as its title, is what the
|
|
893
|
+
// `play` verb already answers.
|
|
894
|
+
const playable = blenderSkin.playable;
|
|
895
|
+
const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
|
|
896
|
+
<button
|
|
897
|
+
key={key}
|
|
898
|
+
type="button"
|
|
899
|
+
title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
|
|
900
|
+
aria-label={label}
|
|
901
|
+
disabled={!playable}
|
|
902
|
+
onClick={onClick}
|
|
903
|
+
style={{
|
|
904
|
+
width: unit,
|
|
905
|
+
height: unit,
|
|
906
|
+
padding: 0,
|
|
907
|
+
border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
|
|
908
|
+
background: TIMELINE_CHROME.widget,
|
|
909
|
+
color: TIMELINE_CHROME.widgetText,
|
|
910
|
+
display: 'grid',
|
|
911
|
+
placeItems: 'center',
|
|
912
|
+
cursor: playable ? 'pointer' : 'not-allowed',
|
|
913
|
+
opacity: playable ? 1 : 0.4,
|
|
914
|
+
}}
|
|
915
|
+
>
|
|
916
|
+
{glyph}
|
|
917
|
+
</button>
|
|
918
|
+
);
|
|
919
|
+
const mark = (path: string) => (
|
|
920
|
+
<svg width={12} height={12} viewBox="0 0 12 12" aria-hidden="true">
|
|
921
|
+
<path d={path} fill={TIMELINE_CHROME.widgetText} />
|
|
922
|
+
</svg>
|
|
923
|
+
);
|
|
924
|
+
return (
|
|
925
|
+
<div
|
|
926
|
+
style={{
|
|
927
|
+
height: HEADER_HEIGHT,
|
|
928
|
+
flex: `0 0 ${HEADER_HEIGHT}px`,
|
|
929
|
+
display: 'flex',
|
|
930
|
+
alignItems: 'center',
|
|
931
|
+
gap: TIMELINE_CHROME.headerGap,
|
|
932
|
+
padding: TIMELINE_CHROME.headerPadding,
|
|
933
|
+
background: TIMELINE_THEME.header,
|
|
934
|
+
borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
|
|
935
|
+
}}
|
|
936
|
+
>
|
|
937
|
+
<span style={{ color: TIMELINE_CHROME.text }}>Timeline</span>
|
|
938
|
+
<TimelineViewMenu />
|
|
939
|
+
{/* TWO SPACERS, which is what CENTRES the transport. Blender's Timeline
|
|
940
|
+
header is three clusters: the menus at the leading edge, the
|
|
941
|
+
transport in the MIDDLE, and the frame/range fields trailing
|
|
942
|
+
(measured against Blender 5.2.0 LTS at 1728x997 — its transport sits
|
|
943
|
+
at roughly x 860-1160 of 2832, dead centre; ours was hard against
|
|
944
|
+
the right edge beside the frame field). */}
|
|
945
|
+
<div style={{ flex: 1 }} />
|
|
946
|
+
<div style={{ display: 'flex' }}>
|
|
947
|
+
{button(
|
|
948
|
+
'jump-start',
|
|
949
|
+
mark('M2 2h1.5v8H2zM10 2v8L4.5 6z'),
|
|
950
|
+
() => transport()?.seekFrame(play.start),
|
|
951
|
+
'Jump to Start',
|
|
952
|
+
)}
|
|
953
|
+
{/* PREV/NEXT KEYFRAME are a DOUBLE triangle in Blender, distinct from
|
|
954
|
+
jump-to-start/end's bar-and-triangle. We drew the same path for
|
|
955
|
+
both, so four buttons carried two glyphs and a person could not
|
|
956
|
+
tell "go to the start" from "go to the previous key" — caught by
|
|
957
|
+
putting our header beside Blender's, not by reading the code. */}
|
|
958
|
+
{button(
|
|
959
|
+
'prev-key',
|
|
960
|
+
mark('M5 2.5v7L1.5 6zM8.5 3.2L11 6L8.5 8.8L6 6z'),
|
|
961
|
+
() => {
|
|
962
|
+
const previous = summaryFrames()
|
|
963
|
+
.reverse()
|
|
964
|
+
.find((key) => key < blenderSkin.frame() - 1e-4);
|
|
965
|
+
if (previous !== undefined) transport()?.seekFrame(previous);
|
|
966
|
+
},
|
|
967
|
+
'Jump to Previous Keyframe',
|
|
968
|
+
)}
|
|
969
|
+
{/* PLAY REVERSE, which Blender has beside play and we did not. The
|
|
970
|
+
engine's clock has carried the direction all along
|
|
971
|
+
(`AnimationClock.play(direction)`); nothing new is invented here,
|
|
972
|
+
the button just asks for the arm that already existed. */}
|
|
973
|
+
{playable && playing()
|
|
974
|
+
? button('pause-rev', mark('M3 2h2v8H3zM7 2h2v8H7z'), () => transport()?.pause(), 'Pause')
|
|
975
|
+
: button(
|
|
976
|
+
'play-reverse',
|
|
977
|
+
mark('M9 2L2 6l7 4z'),
|
|
978
|
+
() => transport()?.play('reverse'),
|
|
979
|
+
'Play Reverse',
|
|
980
|
+
)}
|
|
981
|
+
{playable && playing()
|
|
982
|
+
? button('pause', mark('M3 2h2v8H3zM7 2h2v8H7z'), () => transport()?.pause(), 'Pause')
|
|
983
|
+
: button('play', mark('M3 2l7 4-7 4z'), () => transport()?.play(), 'Play')}
|
|
984
|
+
{button(
|
|
985
|
+
'next-key',
|
|
986
|
+
mark('M7 2.5v7L10.5 6zM3.5 3.2L6 6L3.5 8.8L1 6z'),
|
|
987
|
+
() => {
|
|
988
|
+
const next = summaryFrames().find((key) => key > blenderSkin.frame() + 1e-4);
|
|
989
|
+
if (next !== undefined) transport()?.seekFrame(next);
|
|
990
|
+
},
|
|
991
|
+
'Jump to Next Keyframe',
|
|
992
|
+
)}
|
|
993
|
+
{button(
|
|
994
|
+
'jump-end',
|
|
995
|
+
mark('M10 2H8.5v8H10zM2 2v8L7.5 6z'),
|
|
996
|
+
() => transport()?.seekFrame(play.end),
|
|
997
|
+
'Jump to End',
|
|
998
|
+
)}
|
|
999
|
+
</div>
|
|
1000
|
+
{/* THE FRAME-STEP PAIR, Blender's second transport group: one frame back
|
|
1001
|
+
and one frame forward. `seekFrame` already clamps into the clip's
|
|
1002
|
+
range, so the ends need no special case here. */}
|
|
1003
|
+
<div style={{ display: 'flex' }}>
|
|
1004
|
+
{button(
|
|
1005
|
+
'step-back',
|
|
1006
|
+
mark('M2 2h1.5v8H2zM9.5 2v8L4.5 6z'),
|
|
1007
|
+
() => transport()?.seekFrame(Math.round(play.frame) - 1),
|
|
1008
|
+
'Step Back One Frame',
|
|
1009
|
+
)}
|
|
1010
|
+
{button(
|
|
1011
|
+
'step-forward',
|
|
1012
|
+
mark('M10 2H8.5v8H10zM2.5 2v8L7.5 6z'),
|
|
1013
|
+
() => transport()?.seekFrame(Math.round(play.frame) + 1),
|
|
1014
|
+
'Step Forward One Frame',
|
|
1015
|
+
)}
|
|
1016
|
+
</div>
|
|
1017
|
+
<div style={{ flex: 1 }} />
|
|
1018
|
+
{/* THE FRAME FIELD accepts a typed number — the one control on this
|
|
1019
|
+
header that writes anything, and what it writes is where you are
|
|
1020
|
+
looking. `scale_x = 0.95` on a `UI_UNIT_X` base is 76 px
|
|
1021
|
+
(`space_time.py`'s `row.prop(scene, "frame_current")`); Blender's own
|
|
1022
|
+
frame measures 75. */}
|
|
1023
|
+
<input
|
|
1024
|
+
type="number"
|
|
1025
|
+
value={Math.round(play.frame)}
|
|
1026
|
+
onChange={(event) => {
|
|
1027
|
+
const frame = Number(event.target.value);
|
|
1028
|
+
if (Number.isFinite(frame)) transport()?.seekFrame(frame);
|
|
1029
|
+
}}
|
|
1030
|
+
aria-label="Current frame"
|
|
1031
|
+
disabled={!playable}
|
|
1032
|
+
title={playable ? undefined : NOT_PLAYABLE}
|
|
1033
|
+
style={{
|
|
1034
|
+
width: 75,
|
|
1035
|
+
height: unit,
|
|
1036
|
+
textAlign: 'center',
|
|
1037
|
+
border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
|
|
1038
|
+
borderRadius: TIMELINE_CHROME.widgetRadius,
|
|
1039
|
+
background: TIMELINE_CHROME.widget,
|
|
1040
|
+
color: TIMELINE_CHROME.widgetText,
|
|
1041
|
+
font: 'inherit',
|
|
1042
|
+
cursor: playable ? 'text' : 'not-allowed',
|
|
1043
|
+
opacity: playable ? 1 : 0.4,
|
|
1044
|
+
}}
|
|
1045
|
+
/>
|
|
1046
|
+
{/* START AND END ARE READ-ONLY. Writing them writes the file. */}
|
|
1047
|
+
<span
|
|
1048
|
+
onClick={() => refuseTimelineGesture(REFUSALS['set-range'])}
|
|
1049
|
+
onKeyDown={() => undefined}
|
|
1050
|
+
style={{ color: TIMELINE_CHROME.text, cursor: 'not-allowed' }}
|
|
1051
|
+
>
|
|
1052
|
+
Start {play.start} · End {play.end}
|
|
1053
|
+
</span>
|
|
1054
|
+
</div>
|
|
1055
|
+
);
|
|
1056
|
+
}
|