@volter/editor-blender 0.1.150 → 0.1.152

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