@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.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. 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
+ }