@volter/editor-blender 0.1.149 → 0.1.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  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 +100 -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 +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  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 +142 -332
  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 +43 -23
  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 +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  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 +12 -32
  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 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -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 -43
  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/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  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 { modelPlayExtension, subscribeModelPlayExtension } from '../src/model-play-extension';
68
31
  import { AnimationEditorMenu, BlenderActionEditor, BlenderNlaEditor } from './blender-animation-editors';
69
- import { liveAnimation, subscribeLiveAnimation } from '../src/play-live';
70
- import { setWorkspaceAreaRatio } from '@volter/sdk/kit/workspace-areas';
71
32
  import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
72
33
  import {
73
34
  CHANNEL_HEIGHT,
@@ -90,11 +51,7 @@ import {
90
51
  } from './blender-timeline-geometry';
91
52
 
92
53
  export const point = 'workspace.document';
93
- /** Blender'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,152 +176,124 @@ 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 swaps animation editors for the Play tool’s Panel. Each mode applies its own area ratio; manual resizing holds until the next switch. Animation unmounts during Game mode and keeps any existing rig bind. */
398
297
  export default function BlenderBottomArea() {
399
298
  useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
400
299
  useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
@@ -404,18 +303,17 @@ export default function BlenderBottomArea() {
404
303
  // The Timeline keeps Blender's strip; the Action Editor and NLA list channels, so they take the
405
304
  // Game panel's share, as a person would drag the area up to read them.
406
305
  const tall = game || editor !== 'timeline';
407
- useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, tall ? GAME_PANEL_RATIO : null), [tall]);
408
- useEffect(() => () => setWorkspaceAreaRatio(BOTTOM_AREA, null), []);
409
- if (game) return <BlenderGamePanel />;
306
+ const Panel = useSyncExternalStore(subscribeModelPlayExtension, () => modelPlayExtension()?.Panel ?? null, () => null);
307
+ useEffect(() => editorHost().workspace.setAreaRatio(BOTTOM_AREA, tall ? PLAY_PANEL_RATIO : null), [tall]);
308
+ useEffect(() => () => editorHost().workspace.setAreaRatio(BOTTOM_AREA, null), []);
309
+ if (game) return Panel ? <Panel /> : null;
410
310
  if (editor === 'action') return <BlenderActionEditor />;
411
311
  if (editor === 'nla') return <BlenderNlaEditor />;
412
312
  return <BlenderTimeline />;
413
313
  }
414
314
 
415
315
  function BlenderTimeline() {
416
- // 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.
316
+ // RNA writes and presented frames bump the version that invalidates rig and action reads.
419
317
  useSyncExternalStore(
420
318
  subscribeBlenderRna,
421
319
  blenderRnaVersion,
@@ -450,10 +348,7 @@ function BlenderTimeline() {
450
348
  // the measured box, which the verb does not have.
451
349
  const request = timelineViewAllRequest();
452
350
  useEffect(() => {
453
- // `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.
351
+ // Start framed at −1 so the first pass frames, then wait for the view-all request counter to change.
457
352
  if (request === framed.current) return;
458
353
  framed.current = request;
459
354
  const { w } = timelineViewState().size;
@@ -476,8 +371,7 @@ function BlenderTimeline() {
476
371
  );
477
372
 
478
373
  // THE SUMMARY ROW'S OWN COLUMNS — the door's per-object report put through
479
- // Blender's selection filter. `play.keyframes` is the SUBJECT's action and
480
- // stays what the mixer plays; this is what the row DRAWS.
374
+ // Blender's selection filter. Keyframe columns describe the row; sampled tracks drive playback.
481
375
  const summary = useMemo(
482
376
  () => summaryColumns(play.summary, onlySelected),
483
377
  [play.summary, onlySelected],
@@ -505,13 +399,7 @@ function BlenderTimeline() {
505
399
  for (let frame = first; frame <= transform.startFrame + viewFrames; frame += major)
506
400
  majors.push(frame);
507
401
  const minors: number[] = [];
508
- // `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).
402
+ // Draw minor lines only when their pixel spacing exceeds MIN_MAJOR_LINE_DISTANCE / 5 (view2d_draw.cc). Include the minor step in the spacing calculation.
515
403
  if (minor !== null && minor * transform.pixelsPerFrame >= MIN_MAJOR_LINE_DISTANCE / 5) {
516
404
  const firstMinor = Math.ceil(transform.startFrame / minor) * minor;
517
405
  for (let frame = firstMinor; frame <= transform.startFrame + viewFrames; frame += minor)
@@ -534,17 +422,17 @@ function BlenderTimeline() {
534
422
  "Nothing in this file carries an action, so the summary row is empty — which is Blender's own empty state for a Timeline over a still file.",
535
423
  );
536
424
  warnings.push(
537
- '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.',
425
+ 'Playback samples exported object, camera, material and shape-key tracks. Modifiers and geometry drivers that require per-frame Blender evaluation are not re-evaluated by the preview.',
538
426
  );
539
427
  warnings.push(
540
- `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\`.`,
428
+ 'The View menu owns Only Show Selected; its filter applies to the summary row, while sampled playback tracks remain unchanged.',
541
429
  );
542
430
  if (onlySelected && summary.objects.length === 0 && play.summary.length > 0)
543
431
  warnings.push(
544
432
  `The summary row is empty because nothing animated is SELECTED, which is Blender's own answer with this filter on. ${play.summary.map((entry) => entry.object).join(', ')} carr${play.summary.length === 1 ? 'ies' : 'y'} an action; select it, or turn the filter off.`,
545
433
  );
546
434
  warnings.push(
547
- "Markers, the cache bar and the preview range are drawn by Blender's Timeline and not here; the door carries none of them.",
435
+ "Movie markers are carried in the scene data; this view does not draw markers, the cache bar or preview range.",
548
436
  );
549
437
  const next = {
550
438
  drawn: {
@@ -573,14 +461,7 @@ function BlenderTimeline() {
573
461
  warnings,
574
462
  },
575
463
  };
576
- // 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.
464
+ // Publish drawing measurements only when their values change; fresh render objects alone must not create another store publication.
584
465
  if (JSON.stringify(next.drawn) !== JSON.stringify(timelineViewState().drawn))
585
466
  setTimelineViewState(next);
586
467
  }, [ruler, play, summary, onlySelected]);
@@ -589,7 +470,7 @@ function BlenderTimeline() {
589
470
  const scrubFromPointer = useCallback(
590
471
  (clientX: number) => {
591
472
  const element = canvas.current;
592
- if (!element || liveAnimation() !== null) return;
473
+ if (!element) return;
593
474
  const box = element.getBoundingClientRect();
594
475
  transport()?.seekFrame(Math.round(toFrame(clientX - box.left)));
595
476
  },
@@ -604,11 +485,7 @@ function BlenderTimeline() {
604
485
  <div
605
486
  data-testid="blender-timeline"
606
487
  style={{
607
- // 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.
488
+ // Fill the host box with absolute positioning; percentage height has no fixed reference in an auto-height editor area.
612
489
  position: 'absolute',
613
490
  inset: 0,
614
491
  display: 'flex',
@@ -640,8 +517,7 @@ function BlenderTimeline() {
640
517
  // NOTHING IS WRITTEN HERE. Scrub-end is the TRANSPORT's — the
641
518
  // quiet after the last seek settles it, and the skin's `onSettled`
642
519
  // subscription writes `frame_current` once. A second write here
643
- // would race that one and re-introduce the per-gesture double
644
- // write this step removed.
520
+ // would race that one and write twice per gesture.
645
521
  }}
646
522
  >
647
523
  <title>Timeline</title>
@@ -696,34 +572,22 @@ function BlenderTimeline() {
696
572
  y={15.5}
697
573
  textAnchor="middle"
698
574
  fill={TIMELINE_THEME.scrubText}
699
- // `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.
575
+ // draw_horizontal_scale_indicators places the baseline at rect.ymin + 4 × UI_SCALE_FAC and centers labels by x − trunc(width / 2).
704
576
  fontSize={11}
705
577
  >
706
578
  {Math.round(frame)}
707
579
  </text>
708
580
  ))}
709
- {/* 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. */}
581
+ {/* The summary disclosure is drawn read-only. Per-object channels belong to the Action Editor; channel and key edits refuse by name. */}
717
582
  <path
718
583
  d={`M3,${rowCentre - 3.5} L6.5,${rowCentre} L3,${rowCentre + 3.5} Z`}
719
584
  fill={TIMELINE_THEME.scrubText}
720
585
  />
721
586
  {/* THE SUMMARY ROW'S KEYS. Blender's Timeline draws exactly one
722
- channel — see `TIMELINE_ROWS` for the two source facts that make
723
- that so — and its diamonds are the shader's shape. The columns
587
+ channel (`anim_filter.cc:3845-3849`; the names region is hidden in
588
+ Timeline mode, `space_action.cc:785-790`) — and its diamonds are the shader's shape. The columns
724
589
  are the SELECTION-FILTERED merge (`summaryColumns`), which is
725
- what `show_keys_from_selected_only` decides; the action the mixer
726
- plays is `play.keyframes` and is unaffected. */}
590
+ what `show_keys_from_selected_only` decides; sampled playback tracks are unaffected. */}
727
591
  {summary.columns.map((key) => {
728
592
  const factor = KEY_SIZE_FACTOR[key.type] ?? 1;
729
593
  const radius = DIAMOND_RADIUS * factor;
@@ -786,47 +650,7 @@ function BlenderTimeline() {
786
650
  );
787
651
  }
788
652
 
789
- /**
790
- * THE HEADER — `space_time.py`'s `playback_controls`, read as the
791
- * SPECIFICATION of what to draw and never run (the standing ruling). Its order
792
- * is that function's, top to bottom: the Playback popover, a spacer, the
793
- * auto-key toggle, the SIX transport buttons
794
- * (`screen.frame_jump` REW → `screen.keyframe_jump` PREV_KEYFRAME →
795
- * `screen.animation_play` PLAY_REVERSE → PLAY → `keyframe_jump` NEXT_KEYFRAME
796
- * → `frame_jump` FF), the time-jump pair, the snap toggle, a spacer, the frame
797
- * field, and the Start/End pair.
798
- *
799
- * WHAT IS DRAWN AND WHAT IS NOT. The six transport buttons and the frame field
800
- * are real — they are the scrub, which is the one thing this surface changes.
801
- * Start and End are READ-ONLY readouts, because writing them writes the file
802
- * (`set-range` refuses by name). The Playback and snap popovers are not drawn
803
- * at all: every control in them sets a preference or a tool setting that this
804
- * view does not read, and a popover that opens onto nothing is worse than an
805
- * absent one. Named here rather than painted.
806
- *
807
- * THE BUTTONS ARE 20 px — `UI_UNIT_X` (`wm_window.cc:779`'s `widget_unit`),
808
- * confirmed in Blender's own frame: the block of six spans exactly 120 px.
809
- * Their glyphs are TRACED from Blender's own icon sources the way I2's tab
810
- * glyphs are — see `blender-icons.source.mjs`; until that trace covers the
811
- * transport family they are drawn here as the plain geometric marks
812
- * `release/datafiles/icons_svg/{rew,prev_keyframe,play,ff}.svg` are built from
813
- * (a triangle, a triangle with a bar, a pair), which is a KNOWN gap and is in
814
- * the view's own warnings rather than implied to be Blender's mark.
815
- */
816
- /**
817
- * THE VIEW MENU — Blender's `TIME_MT_view`, and the reason this exists is a
818
- * deviation the old header admitted in its own comment: `show_keys_from_selected_only`
819
- * belongs in a View MENU (`space_time.py`'s `layout.prop`), and with no menu
820
- * region it was drawn as a lone toggle at the leading edge instead.
821
- *
822
- * Measured against Blender 5.2.0 LTS at 1728x997: its header's leading cluster
823
- * is an editor-type dropdown then `View`, `Marker` and `Playback`. This builds
824
- * `View` and ONLY `View`, because View is the one whose items this surface
825
- * actually has — `Only Show Selected` and `Frame All`. `Marker` and `Playback`
826
- * would be empty shells over behaviour that does not exist here, and a menu
827
- * that exists to look like Blender is exactly the invented affordance the
828
- * program forbids. Their absence is a STATED gap in WORK.md, not a pretence.
829
- */
653
+ /** View exposes Only Show Selected and Frame All. Marker and Playback menus are omitted because their actions are unsupported. */
830
654
  function TimelineViewMenu() {
831
655
  useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
832
656
  const { onlySelected } = timelineViewState();
@@ -900,22 +724,20 @@ function TimelineViewMenu() {
900
724
  );
901
725
  }
902
726
 
727
+ /** Transport and frame controls follow Blender's Timeline order and draw inline SVG marks; range readouts stay read-only. */
903
728
  function TimelineHeader() {
904
729
  useSyncExternalStore(subscribeBlenderSkin, blenderSkinVersion, blenderSkinVersion);
905
730
  useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
906
731
  const play = blenderSkin.state();
907
732
  const unit = TIMELINE_CHROME.unit;
908
- // WHILE A GAME RUNS the transport is held: the game owns its copy, and moving the document's
909
- // frame under it would put two clocks on one picture.
910
- const gameRunning = useSyncExternalStore(subscribeLiveAnimation, liveAnimation, () => null) !== null;
911
733
  // Static scenes have a timeline too. Disable only while its scene/transport
912
734
  // is unavailable, using the same condition as the command door.
913
- const playable = blenderSkin.playable && !gameRunning;
735
+ const playable = blenderSkin.playable;
914
736
  const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
915
737
  <button
916
738
  key={key}
917
739
  type="button"
918
- title={playable ? label : `${label} — ${gameRunning ? GAME_RUNNING : NOT_PLAYABLE}`}
740
+ title={playable ? label : `${label} — ${NOT_PLAYABLE}`}
919
741
  aria-label={label}
920
742
  disabled={!playable}
921
743
  onClick={onClick}
@@ -954,16 +776,11 @@ function TimelineHeader() {
954
776
  }}
955
777
  >
956
778
  {/* GAME | ANIMATION in place of the title, as in the Game panel, so the person switches back
957
- from either (`blender-game-panel.tsx`); without a Play tool or a model, the title. */}
779
+ from either (`blender-play-area.tsx`); without a Play tool or a model, the title. */}
958
780
  <PlayModeSwitch title="Timeline" />
959
781
  <AnimationEditorMenu />
960
782
  <TimelineViewMenu />
961
- {/* 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). */}
783
+ {/* Two spacers centre transport between the leading menus and trailing frame/range fields. */}
967
784
  <div style={{ flex: 1 }} />
968
785
  <div style={{ display: 'flex' }}>
969
786
  {button(
@@ -972,11 +789,7 @@ function TimelineHeader() {
972
789
  () => transport()?.seekFrame(play.start),
973
790
  'Jump to Start',
974
791
  )}
975
- {/* 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. */}
792
+ {/* Previous and next keyframe use double triangles, distinct from jump-to-boundary bar-and-triangle marks. */}
980
793
  {button(
981
794
  'prev-key',
982
795
  mark('M5 2.5v7L1.5 6zM8.5 3.2L11 6L8.5 8.8L6 6z'),
@@ -988,10 +801,7 @@ function TimelineHeader() {
988
801
  },
989
802
  'Jump to Previous Keyframe',
990
803
  )}
991
- {/* 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. */}
804
+ {/* Reverse playback uses the stage transport's direction. */}
995
805
  {playable && playing()
996
806
  ? button('pause-rev', mark('M3 2h2v8H3zM7 2h2v8H7z'), () => transport()?.pause(), 'Pause')
997
807
  : button(