@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,60 +1,4 @@
1
- /**
2
- * THE OUTLINER IS BLENDER'S DATABLOCK TREE, DRAWN BY OUR HIERARCHY PANEL
3
- * (ARCHITECTURE-CORE §Blender north star, "Inspection parity, not editing
4
- * parity"; WORK.md §Blender in the tab is Blender, "Inspection parity", I3).
5
- *
6
- * The Model document's rows used to be the PRESENTER's three.js graph — "Model
7
- * › ModelLighting, Cube, Probe", a reading of Blender's data by the thing that
8
- * draws the photograph. What Blender's own Outliner shows is the VIEW LAYER:
9
- * Scene Collection, its collections, their objects, and under each object the
10
- * datablocks it carries. That tree is the engine's, `rna_outliner` answers it,
11
- * and this is the `HierarchyProvider` that hands it to `GameHierarchy`.
12
- *
13
- * IT REPLACES NOTHING ELSE. `ToolObject3DAuthoringProps.authoring` hands the
14
- * document the host's DEFAULT adapter for delegation, and everything that is
15
- * genuinely about the three.js presentation still goes to it: the transform
16
- * the gizmo reads, the object a raycast hit, the selection the viewport
17
- * outlines. What this overrides is the TREE and what a row means — because
18
- * only the engine knows there is a Subdivision modifier on that cube.
19
- *
20
- * THE TWO ID SPACES, and the one place they meet. A row's id is Blender's own
21
- * address (`bpy.data.objects["Cube"].modifiers["Subdivision"]`); the default
22
- * adapter's ids are the presenter's three ids. They meet at `object3D`: a row
23
- * that names an OBJECT resolves through the presented frame's own table
24
- * (`BlenderRuntimeView.objectForBlenderName`, a lookup by the frame's id and
25
- * never by `object.name`), and every delegation below goes through that one
26
- * function. A row with no object — a collection, a vertex group, a bone —
27
- * simply has none, which is the honest answer and the one the shell already
28
- * treats as "no object here".
29
- *
30
- * AND THE SPACES MEET BOTH WAYS, which is the half that was missing.
31
- * `selection.set` PUBLISHES the presentation's ids into the shell store (so
32
- * the viewport's own selection machinery keeps working), and the viewport
33
- * then asks THIS adapter about those ids: `editor-viewport.ts` computes its
34
- * gizmo subject from `store.selectedEntityId` and runs it through
35
- * `hierarchy.node` / `transforms.editability` / `_objectForAuthoringId`. So
36
- * every id arriving from outside is normalized by {@link rowIdFor} — a row id
37
- * passes through, a presentation id is resolved to its object and mapped back
38
- * to the row that names it.
39
- *
40
- * SELECTING A DATABLOCK SELECTS ITS OBJECT, which is Blender's own behaviour
41
- * rather than a convenience: `outliner_select.cc`'s activation walks back to
42
- * the owning object (`outliner_search_back_te(te, ID_OB)`, `:863`) and sets
43
- * the index on IT, which is why clicking a shape key in Blender moves the
44
- * Properties editor to that object. The door carries the owning object per row
45
- * (`BlenderOutlinerRow.object`) and that is what this selects.
46
- *
47
- * AND IT WRITES NOW — the "inspection parity, not editing parity" the first
48
- * line cites is the order the UI was BUILT in, and the editing half of it is
49
- * on the release's critical path as of the owner's 2026-09-21 amendment to
50
- * ARCHITECTURE-CORE §Blender north star goal 3 ("the key to the edit is just
51
- * macro adjustments"). Three providers here write, each one bpy call through a
52
- * door that already existed: {@link transforms} (`rna-set` of `matrix_world`,
53
- * B5), the `name` half of {@link inspector} (`rna-set` of `Object.name`), and
54
- * {@link structure} (`bpy.ops.*` through the script door — add, delete,
55
- * duplicate). Every one of them is Blender running its own code; none of them
56
- * reimplements an operator.
57
- */
1
+
58
2
 
59
3
  import type { BlenderOutlinerRow } from '@volter/blender-engine/browser/rna';
60
4
  import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-threejs/object3d-contributions';
@@ -119,30 +63,19 @@ function isObjectRow(row: BlenderOutlinerRow | undefined): row is BlenderOutline
119
63
  return row?.type === 'TSE_SOME_ID' && row.struct === 'Object' && row.object !== undefined;
120
64
  }
121
65
 
122
- /** What the shell calls this row's TYPE on the inspector's identity row.
123
- * Blender's Outliner prints no type text at all (`blender.style.ts`'s
124
- * `hierarchyTypeSuffix: 'hidden'` measured that on `outliner.png`), so this
125
- * is only ever read where a type is genuinely asked for. */
126
- function typeLabel(row: BlenderOutlinerRow): string | undefined {
127
- if (row.struct) return row.struct;
128
- // A base row is a LABEL in Blender ("Modifiers", "Vertex Groups", "Pose"),
129
- // and `TSE_MODIFIER_BASE` is not a noun a person says. Nothing here.
130
- return undefined;
131
- }
132
-
133
66
  function nodeFor(row: BlenderOutlinerRow, parentId: string | null): EditorNode {
134
67
  return {
135
68
  id: row.id,
136
69
  label: row.name,
137
70
  // EVERY BLENDER ROW IS `entity`, INCLUDING THE COLLECTIONS. `folder` reads
138
- // as structural to the shell (`GameHierarchy.tsx`'s `STRUCTURAL_ROLES`),
71
+ // as structural to the shell (`Hierarchy.tsx`'s `STRUCTURAL_ROLES`),
139
72
  // and a structural row is drawn NO restriction columns — while Blender
140
73
  // draws a collection the same eye and camera it draws an object
141
74
  // (`outliner_draw.cc:1620-1700`). The role is what the shell branches on;
142
75
  // the KIND is what says what this is.
143
76
  role: 'entity',
144
77
  kind: blenderOutlinerKind(row.icon),
145
- ...(typeLabel(row) ? { typeLabel: typeLabel(row) as string } : {}),
78
+ ...(row.struct ? { typeLabel: row.struct } : {}),
146
79
  parentId,
147
80
  childIds: row.children.map((child) => child.id),
148
81
  // BLENDER'S OWN DEFAULT OPEN STATE, not the shell's shape heuristic: a
@@ -169,13 +102,13 @@ const CAPABILITIES: AuthoringCapabilities = {
169
102
  * `documentId` and `documentRootObject` are not on `AuthoringAdapter` — which
170
103
  * is exactly why `blender-properties-model.ts`'s `isObject3DAuthoring` checks
171
104
  * for them structurally — so they have to be declared somewhere for this
172
- * literal to typecheck. Here, once, rather than at each getter: the literal
173
- * used to carry a conditional spread, and a spread is what was silently
174
- * suppressing TypeScript's excess-property check on them.
105
+ * literal to typecheck. Here, once, rather than at each getter: a conditional
106
+ * spread in the literal would silently suppress TypeScript's excess-property
107
+ * check on them.
175
108
  */
176
109
  interface BlenderOutlinerAdapter extends AuthoringAdapter {
177
- readonly documentId: string | undefined;
178
- readonly documentRootObject: THREE.Object3D | null | undefined;
110
+ readonly documentId: string;
111
+ readonly documentRootObject: THREE.Object3D | null;
179
112
  }
180
113
 
181
114
  /**
@@ -190,7 +123,7 @@ interface BlenderOutlinerAdapter extends AuthoringAdapter {
190
123
  * provider's.
191
124
  */
192
125
  export interface BlenderOutlinerHandle {
193
- readonly documentId: string | undefined;
126
+ readonly documentId: string;
194
127
  /** The Blender object NAMES the panel's selection covers — the header's
195
128
  * subject for every Object-menu row, and for Select ▸ Invert. */
196
129
  selectedObjectNames(): readonly string[];
@@ -206,27 +139,17 @@ export interface BlenderOutlinerHandle {
206
139
  * `_rna_address`' own identifier escaping and `_outliner_unique`'s
207
140
  * disambiguating suffix, neither of which a caller can reconstruct. */
208
141
  rowIdForObject(name: string): string | null;
209
- readonly structure: StructureProvider;
210
- subscribe(listener: () => void): () => void;
142
+ readonly structure: StructureProvider & Required<Pick<StructureProvider, 'duplicateMany' | 'removeMany'>>;
211
143
  }
212
144
 
213
145
  /**
214
146
  * THE LIVE OUTLINERS, by the document each is the Outliner of.
215
147
  *
216
- * A Set rather than a Map because the adapter's `documentId` is a GETTER off
217
- * the default adapter (see {@link BlenderOutlinerAdapter}) and is not
218
- * necessarily resolved when the factory runs, so there is no key to register
219
- * under at that moment.
148
+ * Each stage retains its own handle, including stages showing the same document.
220
149
  */
221
150
  const liveOutliners = new Set<BlenderOutlinerHandle>();
222
151
 
223
- /**
224
- * A HANDLE ARRIVING IS NEWS. A header drawn before its document's stage made an Outliner has no
225
- * subject and draws nothing; the tree's own version does not move when a handle registers, so
226
- * without this it stayed empty until the next edit (measured: a cold load showed no View, Select,
227
- * Add or Object until a script changed the tree). Notified on the next turn, never inside the
228
- * render that made the handle.
229
- */
152
+ /** Publish handle registration on the next turn so headers waiting for an Outliner subject update outside the render pass. */
230
153
  let liveOutlinersVersion = 0;
231
154
  const liveOutlinerListeners = new Set<() => void>();
232
155
  function liveOutlinersChanged(): void {
@@ -271,41 +194,7 @@ const NO_TRANSFORM = (): Transform => ({
271
194
  scale: [1, 1, 1],
272
195
  });
273
196
 
274
- /**
275
- * A presented object's pose, back in BLENDER's space and in the shape the RNA
276
- * door's WRITE half takes.
277
- *
278
- * The presented root carries the signed axis permutation P that takes
279
- * Blender's Z-up into three's Y-up — written as an exact matrix in
280
- * `blender-runtime-view.ts`'s constructor precisely so it inverts exactly —
281
- * and every presented object hangs under it. So `P⁻¹ · object.matrixWorld` is
282
- * the matrix Blender calls `matrix_world`. The root's own WORLD matrix is what
283
- * is inverted, not the literal, so any transform the stage puts above it is
284
- * divided out too.
285
- *
286
- * THE TWO HALVES OF `matrix_world` USE OPPOSITE ORDERS, and that is measured
287
- * rather than reasoned (2026-09-21, live, through `blender-rna-set` on the
288
- * default cube):
289
- *
290
- * sent `[[1,0,0,0.5],[0,1,0,0],[0,0,1,0],[0,0,0,1]]` → `location` stayed
291
- * `[0,0,0]` and the next depsgraph evaluation put `matrix_world` back to
292
- * identity — Blender read the translation as the projective row, found
293
- * none to decompose, and recomputed the matrix from loc/rot/scale;
294
- * sent `[[1,0,0,0],[0,1,0,0],[0,0,1,0],[0.5,0,0,1]]` → `location` reads
295
- * `[0.5,0,0]` and `matrix_world` reads back `[1,0,0,0.5, …]`.
296
- *
297
- * So the WRITE goes into Blender's C `float[4][4]` in C order — entry `i` is
298
- * the i-th COLUMN of the `mathutils.Matrix` a Python read returns — while the
299
- * READ (`session.py`'s `_rna_value`, and the frame's
300
- * `[[float(v) for v in row] for row in obj.matrix_world]`) is that Matrix's
301
- * ROWS. The frame's reader is right to be row-major
302
- * (`blender-runtime-view.ts` feeds it to `Matrix4.set`, which is row-major);
303
- * this writer is its transpose.
304
- *
305
- * Which makes the code the simplest form of all: `THREE.Matrix4.elements` is
306
- * ALREADY column-major, so the four groups of four are the four entries
307
- * Blender wants, in order.
308
- */
197
+ /** Convert a presented world matrix back through the document root’s inverse, removing Blender-to-stage axes and any stage transform. The RNA write consumes columns while the read returns rows; Matrix4.elements already holds the required column-major order. */
309
198
  function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): number[][] {
310
199
  // The ancestors too: a presented object owns its matrix
311
200
  // (`matrixAutoUpdate = false`), so nothing recomposes it for us.
@@ -319,44 +208,7 @@ function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): n
319
208
  ];
320
209
  }
321
210
 
322
- /**
323
- * BLENDER'S ADD MENU, AS KINDS — every row `VIEW3D_MT_add` draws in Object
324
- * Mode, each with Blender's OWN label and the `bpy.ops` call its row runs
325
- * (`scripts/startup/bl_ui/space_view3d.py` at the engine's pin, cited per
326
- * row). The menu's SHAPE — Blender's order, its separators and which rows are
327
- * submenus — is `blender-header-menus.tsx`'s `ADD_MENU`, which names these
328
- * kinds; this table is what each one DOES.
329
- *
330
- * EVERY ONE IS BLENDER RUNNING ITS OWN OPERATOR. There is no second
331
- * implementation of a primitive's defaults, its vertex count or its shading
332
- * here; the kind names the operator and the engine does the rest.
333
- *
334
- * A NEW OBJECT LANDS AT THE 3D CURSOR, as in Blender: no row states a
335
- * `location`, and an unset one is `scene->cursor.location`
336
- * (`editors/object/object_add.cc`, `location_from_view`). The cursor is drawn
337
- * and placed on the Model document (`blender-runtime-cursor.ts`).
338
- *
339
- * A ROW THAT NEEDS A FILE IS DRAWN DISABLED WITH THE REASON, never as a
340
- * gesture that cannot complete (the shape B6 set for Set Origin ▸ Origin to 3D
341
- * Cursor). Blender's Image ▸ Reference/Background/Mesh Plane and Volume ▸
342
- * Import OpenVDB open a FILE BROWSER; this editor has no door to open one
343
- * through — `EditorHost.files` (`@volter/sdk/host`) reads and writes
344
- * project-relative paths and has no "pick a file" member at all — so those
345
- * rows carry {@link BlenderCreatableKind.refusal} instead. Measured at the
346
- * engine on 2026-09-21: `object.empty_image_add` answers "poll() failed,
347
- * context is incorrect / Please select at least one image", and
348
- * `image.import_as_mesh_planes` and `object.volume_import` both return
349
- * `CANCELLED` having created nothing — so the alternative was a row that
350
- * looked like it worked.
351
- *
352
- * Blender's OWN conditional rows are read as conditions, not copied: `Search…`
353
- * (`:2689`) draws only under `EXEC_REGION_WIN` and opens a menu-search dialog
354
- * this editor does not have; Armature and Camera draw as SUBMENUS only when an
355
- * add-on has extended them (`is_extended()`, `:2713`/`:2732`) and this engine
356
- * loads none, so both are the flat row Blender falls through to; Curve's
357
- * `Random` row (`:2471`) stands on `preferences.experimental.use_new_curves_tools`
358
- * and this build reports `bpy.app.build_options.experimental_features` False.
359
- */
211
+ /** The Add table maps Blender’s Object Mode menu kinds to bpy operators. Blender supplies defaults and active-collection placement at the 3D cursor. File-browser operators carry a named refusal because this host has no file-picker door. Read Blender’s conditional rows against the engine’s available add-ons and experimental features. */
360
212
  export interface BlenderCreatableKind {
361
213
  /** The `kind` string the shell round-trips into {@link StructureProvider.create}. */
362
214
  readonly kind: string;
@@ -594,13 +446,7 @@ export const CREATABLE_KINDS: readonly BlenderCreatableKind[] = [
594
446
  // `VIEW3D_MT_add`, `space_view3d.py:2716` — the flat row Blender falls
595
447
  // through to when no add-on has extended `VIEW3D_MT_armature_add`.
596
448
  { kind: 'armature', label: 'Armature', group: null, call: 'object.armature_add' },
597
- // `VIEW3D_MT_lattice_add`, `space_view3d.py:2656-2657`. The first row is the
598
- // GENERIC add operator with a type, not a `lattice_add` of its own. Its kind
599
- // is `lattice.lattice` and not `lattice` because the header stamps a row's
600
- // test id from its kind and a submenu's from its group label — measured
601
- // 2026-09-21: a bare `lattice` made `blender-add-lattice` name BOTH the
602
- // submenu trigger and the row inside it, and the row's own click re-opened
603
- // the submenu and added nothing.
449
+ // VIEW3D_MT_lattice_add uses the generic add operator. lattice.lattice gives the row a distinct test id from its lattice submenu.
604
450
  {
605
451
  kind: 'lattice.lattice',
606
452
  label: 'Lattice',
@@ -849,10 +695,7 @@ function addCall(kind: BlenderCreatableKind): string {
849
695
  return `bpy.ops.${call}(${args.join(', ')})`;
850
696
  }
851
697
 
852
- /** A Python string literal for one Blender name. JSON's own escaping is a
853
- * subset of Python's for every character a datablock name can hold, which is
854
- * why the transform writer above addresses `bpy.data.objects[…]` the same
855
- * way. */
698
+ /** Encode a Blender datablock name as a Python string literal. */
856
699
  function py(name: string): string {
857
700
  return JSON.stringify(name);
858
701
  }
@@ -861,11 +704,10 @@ function py(name: string): string {
861
704
  * THE STEP'S LABEL IS THE OPERATOR'S OWN NAME, the one Blender's Edit menu shows: Add › Cone is
862
705
  * "Undo Add Cone", not "Undo Blender Python". Blender's names do not follow the menu rows (a Point
863
706
  * light is "Add Light"), so they are Blender's, read from this build with
864
- * `bpy.ops.<id>.get_rna_type().name` (`ot->name`) for every operator these menus run and every
865
- * `object.*_add`. An id not here keeps the script door's label.
707
+ * `bpy.ops.<id>.get_rna_type().name` (`ot->name`) for the operators these menus run.
708
+ * An id not here keeps the script door's label.
866
709
  *
867
- * A TABLE, NOT A QUESTION PER CLICK: asking Blender before every operator cost a round trip each
868
- * time for a name that never changes within a build.
710
+ * Cache these build-stable operator names instead of querying Blender on each click.
869
711
  */
870
712
  const OPERATOR_NAMES: Readonly<Record<string, string>> = {
871
713
  'curve.primitive_bezier_circle_add': "Add B\u00e9zier Circle",
@@ -873,7 +715,6 @@ const OPERATOR_NAMES: Readonly<Record<string, string>> = {
873
715
  'curve.primitive_nurbs_circle_add': "Add Nurbs Circle",
874
716
  'curve.primitive_nurbs_curve_add': "Add Nurbs Curve",
875
717
  'curve.primitive_nurbs_path_add': "Add Path",
876
- 'image.import_as_mesh_planes': "Import Images as Planes",
877
718
  'mesh.primitive_circle_add': "Add Circle",
878
719
  'mesh.primitive_cone_add': "Add Cone",
879
720
  'mesh.primitive_cube_add': "Add Cube",
@@ -886,41 +727,25 @@ const OPERATOR_NAMES: Readonly<Record<string, string>> = {
886
727
  'mesh.primitive_uv_sphere_add': "Add UV Sphere",
887
728
  'object.armature_add': "Add Armature",
888
729
  'object.camera_add': "Add Camera",
889
- 'object.collection_add': "Add to Collection",
890
730
  'object.collection_instance_add': "Add Collection Instance",
891
- 'object.constraint_add': "Add Constraint",
892
731
  'object.curves_empty_hair_add': "Add Empty Curves",
893
- 'object.curves_random_add': "Add Random Curves",
894
- 'object.data_instance_add': "Add Object Data Instance",
895
- 'object.delete': "Delete",
896
- 'object.duplicate': "Duplicate Objects",
897
732
  'object.effector_add': "Add Effector",
898
733
  'object.empty_add': "Add Empty",
899
- 'object.empty_image_add': "Add Empty Image/Drop Image to Empty",
900
734
  'object.grease_pencil_add': "Add Grease Pencil",
901
- 'object.grease_pencil_dash_modifier_segment_add': "Add Segment",
902
- 'object.grease_pencil_time_modifier_segment_add': "Add Segment",
903
735
  'object.lattice_add_to_selected': "Add Lattice Deformer",
904
736
  'object.light_add': "Add Light",
905
737
  'object.lightprobe_add': "Add Light Probe",
906
738
  'object.location_clear': "Clear Location",
907
- 'object.material_slot_add': "Add Material Slot",
908
739
  'object.metaball_add': "Add Metaball",
909
- 'object.modifier_add': "Add Modifier",
910
740
  'object.origin_set': "Set Origin",
911
- 'object.particle_system_add': "Add Particle System Slot",
912
741
  'object.pointcloud_random_add': "Add Point Cloud",
913
742
  'object.quick_fur': "Quick Fur",
914
743
  'object.rotation_clear': "Clear Rotation",
915
744
  'object.scale_clear': "Clear Scale",
916
- 'object.shaderfx_add': "Add Effect",
917
- 'object.shape_key_add': "Add Shape Key",
918
745
  'object.speaker_add': "Add Speaker",
919
746
  'object.text_add': "Add Text",
920
747
  'object.transform_apply': "Apply Object Transform",
921
- 'object.vertex_group_add': "Add Vertex Group",
922
748
  'object.volume_add': "Add Volume",
923
- 'object.volume_import': "Import OpenVDB Volume",
924
749
  'surface.primitive_nurbs_surface_circle_add': "Add Surface Circle",
925
750
  'surface.primitive_nurbs_surface_curve_add': "Add Surface Curve",
926
751
  'surface.primitive_nurbs_surface_cylinder_add': "Add Surface Cylinder",
@@ -972,35 +797,7 @@ async function runBlenderOperator(
972
797
  return { made, error: null };
973
798
  }
974
799
 
975
- /**
976
- * THE SELECTION IS BLENDER'S, AND THIS IS THE WRITE (WORK.md §THE BLENDER
977
- * RELEASE, B11; ARCHITECTURE-CORE §Blender north star goal 1 — *"through the
978
- * Blender MCP interface an agent cannot tell it is using anything other than
979
- * real Blender"*).
980
- *
981
- * Every selection gesture on the Model document ends here: a viewport click, a
982
- * box, an Outliner row, Select ▸ All/None/Invert, and the selection an
983
- * operator leaves behind. ONE script per gesture, because a selection is one
984
- * act — `select_set` per object in the view layer and one assignment to
985
- * `view_layer.objects.active` — and because the script door PRESENTS when it
986
- * returns, so the frame that comes back already carries the new `selected` and
987
- * `active` for {@link blenderEngineSelection} to read.
988
- *
989
- * WHY IT IS A WRITE AT ALL, when `rna_outliner` deliberately takes the
990
- * caller's selection instead of making one: reading a tree must not mutate the
991
- * document, and that rule stands. This is not a read — it is the gesture
992
- * itself. Before it, WALK 5 measured a click that picked the cube in this
993
- * editor while `bpy.context.active_object` still answered `Point`, and the
994
- * Shader Editor and UV Editing — which key on Blender's ACTIVE object and
995
- * nothing else — drew a cube's material as "No material" and its unwrap as
996
- * blank. There is no second selection to reconcile now: the engine holds it.
997
- *
998
- * THE LOOP IS OVER `view_layer.objects`, not `bpy.data.objects`: an object
999
- * outside the view layer has no selection flag to set and `select_set` refuses
1000
- * it by name. `bpy.data.objects.get` for the active is the same care — a name
1001
- * the tree still holds but the engine has deleted answers None rather than
1002
- * raising, and None is what Blender's own "no active object" is.
1003
- */
800
+
1004
801
  async function writeBlenderSelection(
1005
802
  names: readonly string[],
1006
803
  active: string | null,
@@ -1030,12 +827,7 @@ async function writeBlenderSelection(
1030
827
  * menu runs (`blender-header-menus.tsx`: Apply, Clear, Set Origin), and the
1031
828
  * same shape {@link StructureProvider}'s own delete and duplicate use.
1032
829
  *
1033
- * THE CONTEXT OVERRIDE IS THE WHOLE POINT. Every one of these operators reads
1034
- * `context.selected_objects` (and `selected_editable_objects`); the engine runs
1035
- * headless with no 3D view, and its selection is not the panel's, so the
1036
- * subject is STATED per call rather than written into the engine first — a
1037
- * write the document would then save, and the one `rna_outliner` already
1038
- * refuses to make for the same reason.
830
+ * Supply the named objects through a context override for the headless operator.
1039
831
  *
1040
832
  * `call` is the bare `bpy.ops.…(…)` expression, so a caller reads exactly what
1041
833
  * Blender will run and the ack can quote it.
@@ -1069,9 +861,7 @@ const PROVENANCE: AuthoringProvenance = {
1069
861
  'Editing the model is a bpy call; the eye and render columns write the engine directly.',
1070
862
  };
1071
863
 
1072
- /** The eye is not what excluded a collection, and a row whose eye refuses must
1073
- * say which column did. The checkbox is beside it now (I3 follow-up (b)), so
1074
- * this names it rather than apologising for its absence. */
864
+ /** An eye refusal names the restriction column that excludes the collection; the checkbox is beside it. */
1075
865
  const EXCLUDED_REASON =
1076
866
  'This collection is excluded from the view layer (LayerCollection.exclude), so its viewport ' +
1077
867
  'visibility is not what is hiding it. Clear the Exclude checkbox on this row first.';
@@ -1080,7 +870,7 @@ const EXCLUDED_REASON =
1080
870
  * THE OUTLINER AUTHORING FOR A STAGE DRAWING `presented`: the rows are Blender's either way, and
1081
871
  * the view is where a row meets the three object its stage picks, outlines and moves. The Model
1082
872
  * document's own area draws the published view ({@link createBlenderOutlinerAuthoring}); a split
1083
- * area draws a follower of it (`BlenderRuntimeView.follow`, `blender-render-view.tsx`).
873
+ * area draws a follower of it (`BlenderRuntimeView.follow`).
1084
874
  */
1085
875
  export function blenderOutlinerAuthoringFor(
1086
876
  presented: () => PresentedView | null,
@@ -1121,7 +911,7 @@ export function blenderOutlinerAuthoringFor(
1121
911
  const rows = (): ReadonlyMap<string, BlenderOutlinerRow> => blenderOutlinerState().byId;
1122
912
 
1123
913
  /** The object a ROW belongs to, as a three object — the place the two id
1124
- * spaces meet (see the header). */
914
+ * spaces meet. */
1125
915
  const objectForRow = (id: string): THREE.Object3D | null => {
1126
916
  const row = rows().get(id);
1127
917
  const view = presented();
@@ -1130,8 +920,7 @@ export function blenderOutlinerAuthoringFor(
1130
920
  return row.object === undefined ? null : view.objectForBlenderName(row.object);
1131
921
  };
1132
922
 
1133
- /** The row for a presented OBJECT: its address is the object's own, so this
1134
- * is a map lookup rather than a walk. */
923
+ /** Find the object row whose presented Blender object has this identity. */
1135
924
  const rowIdForObject = (object: THREE.Object3D): string | null => {
1136
925
  const view = presented();
1137
926
  if (view === null) return null;
@@ -1149,19 +938,7 @@ export function blenderOutlinerAuthoringFor(
1149
938
  return null;
1150
939
  };
1151
940
 
1152
- /**
1153
- * THE SAME MEETING POINT, RUN BACKWARDS — normalize an id that arrived from
1154
- * outside into one of OUR rows (see the header's "both ways").
1155
- *
1156
- * A row id passes straight through. Anything else is the PRESENTATION's id,
1157
- * because that is what `selection.set` below publishes into the shell store
1158
- * — and the gizmo block reads `store.selectedEntityId`, so the viewport asks
1159
- * this adapter about ids it minted itself. Measured 2026-09-21 on a cold
1160
- * model scaffold: without this, `adapterOnlyToolOwner` (which gates on
1161
- * `hierarchy.node(id) !== null`) answered null for every selection, so the
1162
- * 3D tool context never claimed the surface and the gizmo stayed detached
1163
- * even with `capabilities.transform` true.
1164
- */
941
+ /** Normalize external presentation ids to Outliner row ids. Existing row ids pass through; foreign ids resolve through their presented object. */
1165
942
  const rowIdFor = (id: string): string | null => {
1166
943
  if (rows().has(id)) return id;
1167
944
  const object = threeObject(defaultAdapter.hierarchy, id);
@@ -1177,14 +954,12 @@ export function blenderOutlinerAuthoringFor(
1177
954
  /** An id in the DEFAULT adapter's space, for the reads it owns. */
1178
955
  const presentationIdFor = (id: string): string | null => {
1179
956
  const object = objectFor(id);
1180
- return object === null ? null : (defaultAdapter.hierarchy.idForObject3D?.(object) ?? null);
957
+ return object === null ? null : defaultAdapter.hierarchy.idForNativeObject(object);
1181
958
  };
1182
959
 
1183
960
  const hierarchy: HierarchyProvider = {
1184
961
  roots: () => {
1185
- // THE READ IS DRIVEN FROM HERE, for I1's reason: the panel is the only
1186
- // thing that always runs, and `showBlenderOutliner` is idempotent and
1187
- // schedules its work outside the render pass.
962
+ // Drive the idempotent Outliner read from the panel; it schedules work outside the render pass.
1188
963
  showBlenderOutliner(selectedObjects());
1189
964
  const state = blenderOutlinerState();
1190
965
  return (state.tree?.rows ?? []).map((row) => nodeFor(row, null));
@@ -1200,8 +975,8 @@ export function blenderOutlinerAuthoringFor(
1200
975
  const row = rowId === null ? undefined : state.byId.get(rowId);
1201
976
  return row === undefined ? null : nodeFor(row, state.parentOf.get(row.id) ?? null);
1202
977
  },
1203
- object3D: (id) => objectFor(id),
1204
- idForObject3D: (object: THREE.Object3D) => rowIdForObject(object),
978
+ nativeObject: (id) => objectFor(id),
979
+ idForNativeObject: (object: THREE.Object3D) => rowIdForObject(object),
1205
980
  };
1206
981
 
1207
982
  /** The Blender object NAMES our selection covers — what the door wants. */
@@ -1217,16 +992,16 @@ export function blenderOutlinerAuthoringFor(
1217
992
 
1218
993
  /** Keep the PRESENTATION in step: the viewport's selection outline and the
1219
994
  * gizmo bind to the default adapter's own ids, so a Blender row's object is
1220
- * pushed through its `idForObject3D`. The one place that push happens — a
995
+ * pushed through its `idForNativeObject`. The one place that push happens — a
1221
996
  * gesture and a frame both end here. */
1222
997
  const publishPresentation = (): void => {
1223
998
  const mapped: string[] = [];
1224
999
  for (const id of selected) {
1225
1000
  const object = objectFor(id);
1226
- const mappedId = object === null ? null : defaultAdapter.hierarchy.idForObject3D?.(object);
1001
+ const mappedId = object === null ? null : defaultAdapter.hierarchy.idForNativeObject(object);
1227
1002
  if (mappedId) mapped.push(mappedId);
1228
1003
  }
1229
- defaultAdapter.selection?.set(mapped, { intent: 'exact' });
1004
+ defaultAdapter.selection.set(mapped, { intent: 'exact' });
1230
1005
  notify();
1231
1006
  };
1232
1007
 
@@ -1248,9 +1023,9 @@ export function blenderOutlinerAuthoringFor(
1248
1023
  const syncFromEngine = (): void => {
1249
1024
  if (disposed || pendingSelection !== null) return;
1250
1025
  const engine = blenderEngineSelection();
1251
- const key = `${blenderOutlinerVersion()}${engine.active ?? ''}${[...engine.selected]
1026
+ const key = `${blenderOutlinerVersion()}\0${engine.active ?? ''}\0${[...engine.selected]
1252
1027
  .sort()
1253
- .join('')}`;
1028
+ .join('\u0001')}`;
1254
1029
  if (key === lastEngineKey) return;
1255
1030
  lastEngineKey = key;
1256
1031
  const names = engine.selected.filter((name) => name !== engine.active);
@@ -1267,22 +1042,7 @@ export function blenderOutlinerAuthoringFor(
1267
1042
  if (disposed) return;
1268
1043
  const request = Symbol('Blender selection');
1269
1044
  pendingSelection = request;
1270
- // NORMALIZED, because ids arrive in BOTH SPACES (the header's "both
1271
- // ways"). A hierarchy click and this adapter's own verbs speak ROW ids;
1272
- // the viewport's MARQUEE speaks the presentation's, because it picks
1273
- // three objects and hands back whatever the default adapter calls them.
1274
- //
1275
- // MEASURED 2026-09-21: a box select across the scene left `selection`
1276
- // holding nine `source-object:…` ids, so every row in the Outliner read
1277
- // as unselected, the Properties rail had no Blender subject, and the
1278
- // gizmo's own `rowIdFor` was the only thing in the file still working.
1279
- // `rowIdFor` is the one function that answers for either space, and this
1280
- // is the other end of the same seam `hierarchy.node` already normalizes.
1281
- // An id that resolves to no row is DROPPED rather than stored: a row id
1282
- // is this provider's whole currency and a foreign one is not a selection.
1283
- // The WRITE below still names a presented object that has no row yet.
1284
- // One row lookup per id: `rowIdForObject` scans the object rows, and a marquee over a
1285
- // large scene passes every object it covers.
1045
+ // Normalize row and presentation ids through rowIdFor once per id. Drop ids with no row; the write can still name a presented object awaiting its tree row.
1286
1046
  const rowIds = ids.map(rowIdFor);
1287
1047
  selected = rowIds.filter((id): id is string => id !== null);
1288
1048
  publishPresentation();
@@ -1311,12 +1071,7 @@ export function blenderOutlinerAuthoringFor(
1311
1071
  else if (!names.includes(name)) names.push(name);
1312
1072
  return;
1313
1073
  }
1314
- // A PRESENTED OBJECT PAST THE TREE'S PAGE IS STILL BLENDER'S. `rna_outliner`
1315
- // answers the first `_OUTLINER_PAGE` rows of every child list, so in a larger
1316
- // scene the viewport picks objects that have no row yet (Canyon Comet: 1,146 rows
1317
- // under its one collection, 64 answered, all six karts among the rest). The view
1318
- // names the object by identity; the tree read for the frame this write presents
1319
- // keeps the selection's rows (`page_tree`), and `syncFromEngine` takes the row up.
1074
+ // A presented object outside the Outliner page remains selectable by frame identity. The next tree read includes the selected rows, and syncFromEngine maps them into selection.
1320
1075
  const object = threeObject(defaultAdapter.hierarchy, id);
1321
1076
  const name = object === null ? null : (view?.blenderObjectName(object) ?? null);
1322
1077
  if (name === null) unnamed.push(id);
@@ -1360,12 +1115,7 @@ export function blenderOutlinerAuthoringFor(
1360
1115
  if (restrict === undefined) return [];
1361
1116
  const row = rows().get(id);
1362
1117
  const properties: PropertyDescriptor[] = [
1363
- // WRITABLE ON AN OBJECT ROW since 2026-09-21 — Blender renames a
1364
- // datablock on a double-click or F2 in its own Outliner, and the
1365
- // "this Outliner inspects" refusal this replaced stopped being true
1366
- // when the gizmo started writing. {@link InspectorProvider.editability}
1367
- // is where the per-row answer is, and it is what a COLLECTION row
1368
- // still refuses.
1118
+ // Object rows support rename. InspectorProvider.editability supplies the per-row refusal for other datablocks.
1369
1119
  { path: 'name', label: 'Name', type: 'string', readonly: !isObjectRow(row) },
1370
1120
  ];
1371
1121
  // The RESERVED paths the hierarchy's restriction columns read
@@ -1375,11 +1125,7 @@ export function blenderOutlinerAuthoringFor(
1375
1125
  properties.push({ path: 'visible', label: 'Show in Viewport', type: 'boolean' });
1376
1126
  if (restrict.render !== undefined)
1377
1127
  properties.push({ path: 'renderVisible', label: 'Show in Renders', type: 'boolean' });
1378
- // THE EXCLUDE CHECKBOX, on the rows Blender draws it on — layer
1379
- // collections, and only under the View Layer display
1380
- // (`outliner_draw.cc:1634-1653`, guarded by `SO_RESTRICT_ENABLE`, which
1381
- // is in the default set at `space_outliner.cc:399`). The door already
1382
- // answered `exclude` for those rows; what was missing was the column.
1128
+ // Draw the exclude checkbox only on layer collections in the View Layer display (outliner_draw.cc, SO_RESTRICT_ENABLE).
1383
1129
  if (restrict.exclude !== undefined)
1384
1130
  properties.push({ path: 'exclude', label: 'Exclude from View Layer', type: 'boolean' });
1385
1131
  return properties;
@@ -1403,15 +1149,7 @@ export function blenderOutlinerAuthoringFor(
1403
1149
  const row = rows().get(id);
1404
1150
  if (row === undefined) return { writable: false };
1405
1151
  if (path === 'name') {
1406
- // BLENDER RENAMES IN ITS OWN OUTLINER — a double-click on the name, or
1407
- // F2 (`outliner_item_rename`, `outliner_tools.cc`) — and the refusal
1408
- // that used to stand here ("this Outliner inspects") stopped being
1409
- // true the moment the transform gizmo started writing `matrix_world`.
1410
- // It is withdrawn for OBJECT rows, and only those: a collection, a
1411
- // modifier, a vertex group and a bone each rename through a different
1412
- // datablock's own `name`, and this unit does not reach them (WORK.md
1413
- // §THE BLENDER RELEASE, B6 — "Collections stay read-only for this
1414
- // unit, by name").
1152
+ // Object rows rename through their object datablock. Other row types require their own datablock writer and remain read-only.
1415
1153
  if (isObjectRow(row)) return { writable: true };
1416
1154
  return {
1417
1155
  writable: false,
@@ -1445,10 +1183,7 @@ export function blenderOutlinerAuthoringFor(
1445
1183
  const name = String(value);
1446
1184
  if (name === row.name || name.length === 0) return;
1447
1185
  await blenderRnaSet(`bpy.data.objects[${py(row.object)}]`, 'name', name);
1448
- // EVERY ROW ID THIS NAME OWNED IS NOW STALE — a row's id IS its RNA
1449
- // address (`bpy.data.objects["Cube"]`), so a rename changes it. Re-read
1450
- // and re-publish the selection, or the panels go on addressing a
1451
- // datablock `bpy.data.objects` no longer has.
1186
+ // RNA-address row ids change on rename. Re-read the tree and re-publish selection against the new addresses.
1452
1187
  await refreshBlenderOutliner([name]);
1453
1188
  const renamed = rowIdForName(name);
1454
1189
  if (renamed !== null) selection.set([renamed]);
@@ -1464,40 +1199,7 @@ export function blenderOutlinerAuthoringFor(
1464
1199
  },
1465
1200
  };
1466
1201
 
1467
- /**
1468
- * THE GIZMO IS ONE MORE bpy WRITER (owner, 2026-09-21: *"I don't see the
1469
- * gizmo in the blender app. Why not?"*).
1470
- *
1471
- * The precedent is the Object tab's Transform panel, transcribed by METHOD:
1472
- * a field there writes through `blender-properties-model.ts`'s
1473
- * `writeBlenderRnaProperty` → `blenderRnaSet` → `session.py::rna_set`, and
1474
- * the write PRESENTS, so the picture that comes back is the engine's own
1475
- * reading rather than ours. This provider is that same door driven by a
1476
- * drag: `apply` moves the PRESENTED object so the picture follows the
1477
- * pointer, and `endEdit` makes exactly ONE write when the gesture closes.
1478
- *
1479
- * WHAT IS WRITTEN IS `matrix_world`, not `location`/`rotation_euler`/`scale`.
1480
- * Blender's own setter (`rna_Object_matrix_world_update` →
1481
- * `BKE_object_apply_mat4`) decomposes a world matrix through the parent,
1482
- * `matrix_parent_inverse`, the delta transforms and the object's
1483
- * `rotation_mode`. That is Blender's math and it stays Blender's; we hand it
1484
- * the matrix.
1485
- *
1486
- * THE ORIENTATION IS GATED BY THE ROUND TRIP, not by a claim. `blenderRnaSet`
1487
- * bumps the RNA version and the write presents, so the frame that follows
1488
- * re-sets this object's matrix from the engine — if the rows/columns or the
1489
- * axis permutation were wrong, the object would JUMP the moment it came
1490
- * back. No jump is the proof.
1491
- *
1492
- * LOCKS ARE NOT READ, and that is stated rather than silently skipped:
1493
- * `lock_location`/`lock_rotation`/`lock_scale` constrain Blender's own
1494
- * interactive transform operators, not a matrix assignment, and the Outliner
1495
- * rows this adapter draws do not carry them.
1496
- *
1497
- * There is deliberately no `remove`: a Blender object has no absent
1498
- * transform channel to drop, so the protocol's removal door refuses by name
1499
- * instead of falling back to a value write.
1500
- */
1202
+ /** The gizmo previews changes on the presented object and writes matrix_world once at endEdit through the RNA door. Blender decomposes it through parent, delta transforms and rotation mode; the returned frame supplies the authoritative pose. Matrix assignment does not enforce interactive transform locks. Transform-channel removal refuses by name. */
1501
1203
  const transforms: TransformProvider = {
1502
1204
  get: (id) => {
1503
1205
  // THE DEFAULT ADAPTER'S OWN READER, on the mapped id. A presented object
@@ -1506,14 +1208,12 @@ export function blenderOutlinerAuthoringFor(
1506
1208
  // decomposes instead — inventing a second reader here is how the two
1507
1209
  // would drift.
1508
1210
  const mapped = presentationIdFor(id);
1509
- return (
1510
- (mapped === null ? undefined : defaultAdapter.transforms?.get(mapped)) ?? NO_TRANSFORM()
1511
- );
1211
+ return mapped === null ? NO_TRANSFORM() : defaultAdapter.transforms.get(mapped);
1512
1212
  },
1513
1213
  /** WHAT THIS DOES NOT ASK: the object's `lock_location`/`lock_rotation`/
1514
1214
  * `lock_scale`. Those constrain Blender's own interactive operators, not a
1515
1215
  * matrix assignment, and an Outliner row does not carry them — so a locked
1516
- * channel still moves here. See the header. */
1216
+ * channel still moves here. */
1517
1217
  editability: (id) => {
1518
1218
  const rowId = rowIdFor(id);
1519
1219
  const row = rowId === null ? undefined : rows().get(rowId);
@@ -1531,7 +1231,7 @@ export function blenderOutlinerAuthoringFor(
1531
1231
  * without writing — `dragging-changed` fires on pointerdown, and a write
1532
1232
  * that changes nothing still presents and still dirties the `.blend`.
1533
1233
  * Keyed by the OBJECT rather than the id, because the viewport addresses
1534
- * one gesture in both id spaces (see the header). */
1234
+ * one gesture in both id spaces. */
1535
1235
  beginEdit: (id) => {
1536
1236
  const object = objectFor(id);
1537
1237
  if (object !== null && !gestureStart.has(object)) {
@@ -1554,8 +1254,9 @@ export function blenderOutlinerAuthoringFor(
1554
1254
  // `matrixWorld`, would sit still with them.
1555
1255
  object.updateMatrix();
1556
1256
  object.updateMatrixWorld(true);
1557
- // And every other area drawing the model moves with it, as Blender redraws them all.
1558
- presented()?.mirrorPose?.(object);
1257
+ // And every other area drawing the model moves with it, as Blender redraws them all; the
1258
+ // move is signalled as a change of what the views draw, this one's included.
1259
+ presented()?.mirrorPose(object);
1559
1260
  },
1560
1261
  endEdit: async (id): Promise<WriteAck | undefined> => {
1561
1262
  const object = gestureObjects.get(id) ?? objectFor(id);
@@ -1613,41 +1314,13 @@ export function blenderOutlinerAuthoringFor(
1613
1314
  * is what keeps the Outliner, the Properties rail and the viewport's gizmo
1614
1315
  * looking at one subject.
1615
1316
  */
1616
- const selectAfterOperator = async (made: readonly string[]): Promise<string> => {
1317
+ const selectAfterOperator = async (made: readonly string[]): Promise<void> => {
1617
1318
  await refreshBlenderOutliner(made);
1618
1319
  const ids = made.map(rowIdForName).filter((id): id is string => id !== null);
1619
1320
  if (ids.length > 0) selection.set(ids);
1620
- return ids[0] ?? '';
1621
1321
  };
1622
1322
 
1623
- /**
1624
- * ADD, DELETE, DUPLICATE — BLENDER'S OWN OPERATORS, NAMED HERE AND RUN THERE
1625
- * (WORK.md §THE BLENDER RELEASE, B6; ARCHITECTURE-CORE §Blender north star
1626
- * goal 3 as amended: "can make MACRO adjustments at the screen — move,
1627
- * rotate, scale a part, add or remove one, rename it … each a bpy call
1628
- * through the doors that exist").
1629
- *
1630
- * Every verb is one `bpy.ops` call through the script door
1631
- * (`blenderExecute`), which presents afterwards, so what comes back is the
1632
- * engine's reading and never ours. The operators carry semantics we would
1633
- * otherwise have to reimplement and get subtly wrong — `object.delete`
1634
- * unparents a deleted object's children the way Blender does, which
1635
- * `bpy.data.objects.remove` does not; `object.duplicate` copies the object's
1636
- * data, its modifiers, its material slots and its parent relationship under
1637
- * the user's own duplication preferences — so naming the operator IS the
1638
- * implementation.
1639
- *
1640
- * THE CONTEXT OVERRIDE IS WHAT MAKES A ROW THE SUBJECT. `object.delete` and
1641
- * `object.duplicate` read `context.selected_objects`; the engine runs
1642
- * headless with no view3d and its selection is not the panel's, so each verb
1643
- * states its subject with `bpy.context.temp_override(...)` rather than
1644
- * writing the engine's selection first — a write the document would then
1645
- * save.
1646
- *
1647
- * The script door checkpoints Blender's native undo stack and records its
1648
- * callback in Code-OSS. These operators are undone by restoring Blender's
1649
- * state, never by constructing an inverse operator or replaying the script.
1650
- */
1323
+
1651
1324
  /**
1652
1325
  * ADD ▸ CAMERA LOOKS THE WAY THE VIEW LOOKS, AND IS THE SCENE'S CAMERA WHEN THERE IS NONE: what
1653
1326
  * `object_camera_add_exec` does with the 3D View it runs in (`object_add.cc`: `align` forced to
@@ -1657,7 +1330,7 @@ export function blenderOutlinerAuthoringFor(
1657
1330
  * location (the 3D cursor) kept, and the scene camera after it.
1658
1331
  */
1659
1332
  const cameraAddCall = (): string => {
1660
- const areaId = (defaultAdapter as { documentId?: string }).documentId?.replace(/^object3d-document:/, '');
1333
+ const areaId = defaultAdapter.documentId.replace(/^object3d-document:/, '');
1661
1334
  const camera = viewportStages().find((one) => one.documentId === areaId)?.rig().drawCamera() ?? null;
1662
1335
  const view = presented();
1663
1336
  const lines = ['bpy.ops.object.camera_add()', 'added = bpy.context.view_layer.objects.active'];
@@ -1671,7 +1344,7 @@ export function blenderOutlinerAuthoringFor(
1671
1344
  return lines.join('\n');
1672
1345
  };
1673
1346
 
1674
- const structure: StructureProvider = {
1347
+ const structure: BlenderOutlinerHandle['structure'] = {
1675
1348
  /**
1676
1349
  * BLENDER'S ADD MENU, FOR EVERY PARENT — `parentId` is deliberately not
1677
1350
  * read. Blender's Add adds to the ACTIVE COLLECTION, never under whatever
@@ -1708,12 +1381,7 @@ export function blenderOutlinerAuthoringFor(
1708
1381
  ack: (async (): Promise<WriteAck> => {
1709
1382
  const { made, error } = await runBlenderOperator(call);
1710
1383
  if (error !== null) return refuse(error);
1711
- // AN OPERATOR THAT MADE NOTHING IS A REFUSAL, not a quiet success.
1712
- // Blender's file-browser operators answer `{'CANCELLED'}` — a status
1713
- // the script door reports no error for — with `bpy.data.objects`
1714
- // unchanged, which is exactly the silent degrade `_absent_capability`
1715
- // exists to stop (`session.py`'s own note). The set difference this
1716
- // door already computes is what catches it.
1384
+ // Treat an operator with no new object as a refusal even when Blender returns CANCELLED without a script error.
1717
1385
  if (made.length === 0)
1718
1386
  return refuse(
1719
1387
  `Blender ran ${call} and created nothing. The operator reported no error, so it ` +
@@ -1728,7 +1396,7 @@ export function blenderOutlinerAuthoringFor(
1728
1396
  })(),
1729
1397
  };
1730
1398
  },
1731
- remove: (id) => structure.removeMany?.([id]) ?? undefined,
1399
+ remove: (id) => structure.removeMany([id]),
1732
1400
  /**
1733
1401
  * BLENDER'S OWN DELETE, for one row or a whole selection. `object.delete`
1734
1402
  * is the operator the X key runs (`blender_default.py:4552`, with
@@ -1740,7 +1408,8 @@ export function blenderOutlinerAuthoringFor(
1740
1408
  for (const id of ids) {
1741
1409
  const name = objectNameFor(id);
1742
1410
  if (name === null) {
1743
- const row = rowIdFor(id) === null ? undefined : rows().get(rowIdFor(id)!);
1411
+ const rowId = rowIdFor(id);
1412
+ const row = rowId === null ? undefined : rows().get(rowId);
1744
1413
  return refuse(
1745
1414
  `Only an object can be deleted here; this row is a ${row?.struct ?? row?.type ?? 'row'}. ` +
1746
1415
  'Deleting a collection, a modifier or a vertex group is that datablock’s own bpy call.',
@@ -1824,12 +1493,7 @@ export function blenderOutlinerAuthoringFor(
1824
1493
  persisted: true,
1825
1494
  };
1826
1495
  },
1827
- /** REFUSED BY NAME. Parenting in Blender is `object.parent_set`, and it is
1828
- * a gesture of its own with a type to choose (Object, Object Keep
1829
- * Transform, Armature Deform, …) and a `matrix_parent_inverse` to settle.
1830
- * A drag in a tree cannot mean one of those without saying which, so this
1831
- * unit does not guess (WORK.md §THE BLENDER RELEASE, B6 — parenting is
1832
- * not in it). */
1496
+ /** Refuse tree reparenting: Blender parenting requires an explicit operator type and matrix_parent_inverse choice. */
1833
1497
  reparent: () =>
1834
1498
  refuse(
1835
1499
  "Reparenting is Blender's `bpy.ops.object.parent_set`, which needs a parent TYPE " +
@@ -1839,22 +1503,12 @@ export function blenderOutlinerAuthoringFor(
1839
1503
  };
1840
1504
 
1841
1505
  const adapter: BlenderOutlinerAdapter = {
1842
- // THE DOCUMENT'S OWN IDENTITY CARRIES OVER, and forgetting it is what the
1843
- // walk caught first: this package's Properties sections resolve their
1844
- // subject by asking the ACTIVE adapter for `documentId`, `hierarchy.object3D`
1845
- // and `documentRootObject` (`blender-properties-model.ts`,
1846
- // `isObject3DAuthoring`) — a structural check, because a contributed
1847
- // package holds no host type. An adapter that overrides the tree and drops
1848
- // those two members is a DIFFERENT adapter to that check, and the whole
1849
- // Properties rail silently stopped matching: selecting the Rig drew the
1850
- // host's bare identity row instead of Blender's tabs (measured 2026-09-19).
1851
- // They are getters rather than copies because the default adapter answers
1852
- // both from live state.
1506
+ // Delegate the document identity and root; the hierarchy resolves each row's presented object.
1853
1507
  get documentId() {
1854
- return (defaultAdapter as { documentId?: string }).documentId;
1508
+ return defaultAdapter.documentId;
1855
1509
  },
1856
1510
  get documentRootObject() {
1857
- return (defaultAdapter as { documentRootObject?: THREE.Object3D | null }).documentRootObject;
1511
+ return defaultAdapter.documentRootObject;
1858
1512
  },
1859
1513
  capabilities: CAPABILITIES,
1860
1514
  provenance: PROVENANCE,
@@ -1866,7 +1520,7 @@ export function blenderOutlinerAuthoringFor(
1866
1520
  subscribe: (listener) => {
1867
1521
  listeners.add(listener);
1868
1522
  const stopTree = subscribeBlenderOutliner(listener);
1869
- const stopDefault = defaultAdapter.subscribe?.(listener) ?? (() => {});
1523
+ const stopDefault = defaultAdapter.subscribe(listener);
1870
1524
  return () => {
1871
1525
  listeners.delete(listener);
1872
1526
  stopTree();
@@ -1895,14 +1549,6 @@ export function blenderOutlinerAuthoringFor(
1895
1549
  },
1896
1550
  rowIdForObject: (name) => rowIdForName(name),
1897
1551
  structure,
1898
- subscribe: (listener) => {
1899
- listeners.add(listener);
1900
- const stopTree = subscribeBlenderOutliner(listener);
1901
- return () => {
1902
- listeners.delete(listener);
1903
- stopTree();
1904
- };
1905
- },
1906
1552
  };
1907
1553
  liveOutliners.add(handle);
1908
1554
  // A factory is called during the stage's React render. Notify the other roots