@volter/editor-blender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,1729 @@
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
+ */
58
+
59
+ import type { BlenderOutlinerRow } from '@volter/blender-engine/browser/rna';
60
+ import type { ToolObject3DDocumentAuthoringFactory } from '@volter/editor-sdk/contributions';
61
+ import { editorHost } from '@volter/editor-sdk/host';
62
+ import type {
63
+ AuthoringAdapter,
64
+ AuthoringCapabilities,
65
+ AuthoringProvenance,
66
+ EditorNode,
67
+ HierarchyProvider,
68
+ InspectorProvider,
69
+ PropertyDescriptor,
70
+ SelectionProvider,
71
+ StructuralIdWrite,
72
+ StructureProvider,
73
+ Transform,
74
+ TransformProvider,
75
+ WriteAck,
76
+ } from '@volter/editor-project/adapter';
77
+ import type * as THREE from 'three';
78
+ import { blenderExecute, blenderRnaSet } from '../host/blender-runtime-host';
79
+ import {
80
+ blenderEngineSelection,
81
+ blenderOutlinerState,
82
+ blenderOutlinerVersion,
83
+ blenderPresentedView,
84
+ onBlenderFrame,
85
+ refreshBlenderOutliner,
86
+ showBlenderOutliner,
87
+ subscribeBlenderOutliner,
88
+ writeBlenderOutlinerColumn,
89
+ } from './blender-outliner-model';
90
+
91
+ /**
92
+ * A ROW'S GLYPH IS BLENDER'S OWN MARK, named through the adapter's `kind`.
93
+ *
94
+ * `tree_element_get_icon` (`outliner_draw.cc:2619-2945`) picks an `ICON_*` per
95
+ * row; the door reports that name; this spells it as the icon-set glyph name
96
+ * `@volter/editor-blender`'s set draws (`blender-icons.source.mjs`, where the outliner
97
+ * marks are traced from `release/datafiles/icons_svg/<name>.svg`). A set that
98
+ * does not carry the name falls through to Font Awesome's own, which is what
99
+ * `hierarchy-kind-icon.ts`'s `blender-` rule guarantees.
100
+ */
101
+ export function blenderOutlinerKind(icon: string): string {
102
+ return `blender-${icon.toLowerCase().replace(/_/g, '-')}`;
103
+ }
104
+
105
+ /**
106
+ * IS THIS ROW A BLENDER OBJECT? — the one predicate every writer in this file
107
+ * gates on, because an object is the only datablock this unit adds, deletes,
108
+ * duplicates or renames. `rna_outliner`'s object rows are the ones that carry
109
+ * `struct: 'Object'` and their own name in `object` (`session.py`'s
110
+ * `_outliner_object`); a collection, a modifier, a vertex group and a bone all
111
+ * answer no.
112
+ */
113
+ function isObjectRow(row: BlenderOutlinerRow | undefined): row is BlenderOutlinerRow & {
114
+ object: string;
115
+ } {
116
+ return row?.type === 'TSE_SOME_ID' && row.struct === 'Object' && row.object !== undefined;
117
+ }
118
+
119
+ /** What the shell calls this row's TYPE on the inspector's identity row.
120
+ * Blender's Outliner prints no type text at all (`blender.style.ts`'s
121
+ * `hierarchyTypeSuffix: 'hidden'` measured that on `outliner.png`), so this
122
+ * is only ever read where a type is genuinely asked for. */
123
+ function typeLabel(row: BlenderOutlinerRow): string | undefined {
124
+ if (row.struct) return row.struct;
125
+ // A base row is a LABEL in Blender ("Modifiers", "Vertex Groups", "Pose"),
126
+ // and `TSE_MODIFIER_BASE` is not a noun a person says. Nothing here.
127
+ return undefined;
128
+ }
129
+
130
+ function nodeFor(row: BlenderOutlinerRow, parentId: string | null): EditorNode {
131
+ return {
132
+ id: row.id,
133
+ label: row.name,
134
+ // EVERY BLENDER ROW IS `entity`, INCLUDING THE COLLECTIONS. `folder` reads
135
+ // as structural to the shell (`GameHierarchy.tsx`'s `STRUCTURAL_ROLES`),
136
+ // and a structural row is drawn NO restriction columns — while Blender
137
+ // draws a collection the same eye and camera it draws an object
138
+ // (`outliner_draw.cc:1620-1700`). The role is what the shell branches on;
139
+ // the KIND is what says what this is.
140
+ role: 'entity',
141
+ kind: blenderOutlinerKind(row.icon),
142
+ ...(typeLabel(row) ? { typeLabel: typeLabel(row) as string } : {}),
143
+ parentId,
144
+ childIds: row.children.map((child) => child.id),
145
+ // BLENDER'S OWN DEFAULT OPEN STATE, not the shell's shape heuristic: a
146
+ // fresh tree-store element is CLOSED (`outliner_tree.cc:139`) and only the
147
+ // Scene Collection and the layer collections clear that flag
148
+ // (`tree_display_view_layer.cc:130,167`).
149
+ defaultExpanded: row.expanded,
150
+ };
151
+ }
152
+
153
+ const CAPABILITIES: AuthoringCapabilities = {
154
+ // The transform writes through `rna-set`, the same way the restriction
155
+ // columns write through `outliner_set` — see {@link transforms} below.
156
+ transform: true,
157
+ inspectorFields: false,
158
+ // The restriction columns write through `outliner_set`, and that write
159
+ // reaches the engine and the `.blend` the session saves.
160
+ persist: true,
161
+ };
162
+
163
+ /**
164
+ * THE ADAPTER, PLUS THE TWO MEMBERS THE PROPERTIES RAIL LOOKS FOR.
165
+ *
166
+ * `documentId` and `documentRootObject` are not on `AuthoringAdapter` — which
167
+ * is exactly why `blender-properties-model.ts`'s `isObject3DAuthoring` checks
168
+ * for them structurally — so they have to be declared somewhere for this
169
+ * literal to typecheck. Here, once, rather than at each getter: the literal
170
+ * used to carry a conditional spread, and a spread is what was silently
171
+ * suppressing TypeScript's excess-property check on them.
172
+ */
173
+ interface BlenderOutlinerAdapter extends AuthoringAdapter {
174
+ readonly documentId: string | undefined;
175
+ readonly documentRootObject: THREE.Object3D | null | undefined;
176
+ }
177
+
178
+ /**
179
+ * THE MODEL DOCUMENT'S OUTLINER, AS THE HEADER MENUS REACH IT.
180
+ *
181
+ * Blender's 3D viewport header draws Select, Add and Object
182
+ * (`VIEW3D_MT_editor_menus`, `space_view3d.py:1153-1210`), and every row in
183
+ * them acts on the SAME subject the Outliner does. The header is a different
184
+ * React tree from the hierarchy panel and gets no adapter in its props, so the
185
+ * adapter publishes this handle and the menus drive it — rather than the menus
186
+ * growing a second selection and a second set of operators beside the
187
+ * provider's.
188
+ */
189
+ export interface BlenderOutlinerHandle {
190
+ readonly documentId: string | undefined;
191
+ /** The Blender object NAMES the panel's selection covers — the header's
192
+ * subject for every Object-menu row, and for Select ▸ Invert. */
193
+ selectedObjectNames(): readonly string[];
194
+ /** Every object in the View Layer tree, in the tree's own order — Select ▸
195
+ * All, and the complement Invert takes. */
196
+ allObjectNames(): readonly string[];
197
+ /** Publish a selection by object NAME (the header's own currency). */
198
+ selectObjects(names: readonly string[]): void;
199
+ /** The ROW id an object name addresses — what {@link StructureProvider}
200
+ * takes. The header speaks in names because that is Blender's currency and
201
+ * the engine's; the provider speaks in rows, and this is where they meet.
202
+ * Never spelled as a literal address by a caller: a row id carries
203
+ * `_rna_address`' own identifier escaping and `_outliner_unique`'s
204
+ * disambiguating suffix, neither of which a caller can reconstruct. */
205
+ rowIdForObject(name: string): string | null;
206
+ readonly structure: StructureProvider;
207
+ subscribe(listener: () => void): () => void;
208
+ }
209
+
210
+ /**
211
+ * THE LIVE OUTLINERS, by the document each is the Outliner of.
212
+ *
213
+ * A Set rather than a Map because the adapter's `documentId` is a GETTER off
214
+ * the default adapter (see {@link BlenderOutlinerAdapter}) and is not
215
+ * necessarily resolved when the factory runs, so there is no key to register
216
+ * under at that moment.
217
+ */
218
+ const liveOutliners = new Set<BlenderOutlinerHandle>();
219
+
220
+ /**
221
+ * The Outliner handle for a document, for the header that draws over it.
222
+ *
223
+ * The fallback when no handle names that document is the SINGLE live one, and
224
+ * it is sound rather than lax: the Blender engine is one session bound to one
225
+ * `.blend` at a time (`blender-runtime-host.ts`'s `bindModelDocument`), so a
226
+ * second Model document with its own Outliner over the same engine is not a
227
+ * state this package can be in. With two, a document that cannot name itself
228
+ * answers null rather than guessing.
229
+ */
230
+ export function blenderOutlinerHandle(documentId?: string): BlenderOutlinerHandle | null {
231
+ for (const handle of liveOutliners) if (handle.documentId === documentId) return handle;
232
+ return liveOutliners.size === 1 ? [...liveOutliners][0]! : null;
233
+ }
234
+
235
+ /** The transform of a subject this adapter does not own. `TransformProvider.get`
236
+ * has no absent answer, and the caller has already been told through
237
+ * `editability` that there is nothing to move here. A fresh object per call —
238
+ * the arrays are the caller's to keep. */
239
+ const NO_TRANSFORM = (): Transform => ({
240
+ position: [0, 0, 0],
241
+ rotation: [0, 0, 0, 1],
242
+ scale: [1, 1, 1],
243
+ });
244
+
245
+ /**
246
+ * A presented object's pose, back in BLENDER's space and in the shape the RNA
247
+ * door's WRITE half takes.
248
+ *
249
+ * The presented root carries the signed axis permutation P that takes
250
+ * Blender's Z-up into three's Y-up — written as an exact matrix in
251
+ * `blender-runtime-view.ts`'s constructor precisely so it inverts exactly —
252
+ * and every presented object hangs under it. So `P⁻¹ · object.matrixWorld` is
253
+ * the matrix Blender calls `matrix_world`. The root's own WORLD matrix is what
254
+ * is inverted, not the literal, so any transform the stage puts above it is
255
+ * divided out too.
256
+ *
257
+ * THE TWO HALVES OF `matrix_world` USE OPPOSITE ORDERS, and that is measured
258
+ * rather than reasoned (2026-09-21, live, through `blender-rna-set` on the
259
+ * default cube):
260
+ *
261
+ * sent `[[1,0,0,0.5],[0,1,0,0],[0,0,1,0],[0,0,0,1]]` → `location` stayed
262
+ * `[0,0,0]` and the next depsgraph evaluation put `matrix_world` back to
263
+ * identity — Blender read the translation as the projective row, found
264
+ * none to decompose, and recomputed the matrix from loc/rot/scale;
265
+ * sent `[[1,0,0,0],[0,1,0,0],[0,0,1,0],[0.5,0,0,1]]` → `location` reads
266
+ * `[0.5,0,0]` and `matrix_world` reads back `[1,0,0,0.5, …]`.
267
+ *
268
+ * So the WRITE goes into Blender's C `float[4][4]` in C order — entry `i` is
269
+ * the i-th COLUMN of the `mathutils.Matrix` a Python read returns — while the
270
+ * READ (`session.py`'s `_rna_value`, and the frame's
271
+ * `[[float(v) for v in row] for row in obj.matrix_world]`) is that Matrix's
272
+ * ROWS. The frame's reader is right to be row-major
273
+ * (`blender-runtime-view.ts` feeds it to `Matrix4.set`, which is row-major);
274
+ * this writer is its transpose.
275
+ *
276
+ * Which makes the code the simplest form of all: `THREE.Matrix4.elements` is
277
+ * ALREADY column-major, so the four groups of four are the four entries
278
+ * Blender wants, in order.
279
+ */
280
+ function blenderWorldMatrixRows(object: THREE.Object3D, root: THREE.Object3D): number[][] {
281
+ // The ancestors too: a presented object owns its matrix
282
+ // (`matrixAutoUpdate = false`), so nothing recomposes it for us.
283
+ object.updateWorldMatrix(true, false);
284
+ const e = root.matrixWorld.clone().invert().multiply(object.matrixWorld).elements;
285
+ return [
286
+ [e[0]!, e[1]!, e[2]!, e[3]!],
287
+ [e[4]!, e[5]!, e[6]!, e[7]!],
288
+ [e[8]!, e[9]!, e[10]!, e[11]!],
289
+ [e[12]!, e[13]!, e[14]!, e[15]!],
290
+ ];
291
+ }
292
+
293
+ /**
294
+ * BLENDER'S ADD MENU, AS KINDS — every row `VIEW3D_MT_add` draws in Object
295
+ * Mode, each with Blender's OWN label and the `bpy.ops` call its row runs
296
+ * (`scripts/startup/bl_ui/space_view3d.py` at the engine's pin, cited per
297
+ * row). The menu's SHAPE — Blender's order, its separators and which rows are
298
+ * submenus — is `blender-header-menus.tsx`'s `ADD_MENU`, which names these
299
+ * kinds; this table is what each one DOES.
300
+ *
301
+ * EVERY ONE IS BLENDER RUNNING ITS OWN OPERATOR. There is no second
302
+ * implementation of a primitive's defaults, its vertex count or its shading
303
+ * here; the kind names the operator and the engine does the rest.
304
+ *
305
+ * `location=(0, 0, 0)` IS STATED RATHER THAN DEFAULTED, because Blender's own
306
+ * answer is different: `VIEW3D_MT_add` runs every one of these with
307
+ * `operator_context = 'EXEC_REGION_WIN'` and each `*_add` operator's `invoke`
308
+ * places the new object at the 3D CURSOR. There is no 3D cursor in this editor
309
+ * yet — it is not this unit's (WORK.md §B6) — so the origin is what the rows
310
+ * below ask for explicitly instead of inheriting whatever `scene.cursor`
311
+ * happens to hold. When a cursor arrives, these lose the argument.
312
+ *
313
+ * A ROW THAT NEEDS A FILE IS DRAWN DISABLED WITH THE REASON, never as a
314
+ * gesture that cannot complete (the shape B6 set for Set Origin ▸ Origin to 3D
315
+ * Cursor). Blender's Image ▸ Reference/Background/Mesh Plane and Volume ▸
316
+ * Import OpenVDB open a FILE BROWSER; this editor has no door to open one
317
+ * through — `EditorHost.files` (`@volter/editor-sdk/host`) reads and writes
318
+ * project-relative paths and has no "pick a file" member at all — so those
319
+ * rows carry {@link BlenderCreatableKind.refusal} instead. Measured at the
320
+ * engine on 2026-09-21: `object.empty_image_add` answers "poll() failed,
321
+ * context is incorrect / Please select at least one image", and
322
+ * `image.import_as_mesh_planes` and `object.volume_import` both return
323
+ * `CANCELLED` having created nothing — so the alternative was a row that
324
+ * looked like it worked.
325
+ *
326
+ * Blender's OWN conditional rows are read as conditions, not copied: `Search…`
327
+ * (`:2689`) draws only under `EXEC_REGION_WIN` and opens a menu-search dialog
328
+ * this editor does not have; Armature and Camera draw as SUBMENUS only when an
329
+ * add-on has extended them (`is_extended()`, `:2713`/`:2732`) and this engine
330
+ * loads none, so both are the flat row Blender falls through to; Curve's
331
+ * `Random` row (`:2471`) stands on `preferences.experimental.use_new_curves_tools`
332
+ * and this build reports `bpy.app.build_options.experimental_features` False.
333
+ */
334
+ export interface BlenderCreatableKind {
335
+ /** The `kind` string the shell round-trips into {@link StructureProvider.create}. */
336
+ readonly kind: string;
337
+ /** Blender's own row text. */
338
+ readonly label: string;
339
+ /** Which of `VIEW3D_MT_add`'s submenus the row sits in — Blender's own
340
+ * submenu label — for a menu that draws the structure (the document's
341
+ * header); the flat creation palette ignores it, Blender's labels being
342
+ * distinct across this set. `null` is a row `VIEW3D_MT_add` draws directly. */
343
+ readonly group: string | null;
344
+ /** The call, complete. */
345
+ readonly call: string;
346
+ /** `false` where Blender's operator has NO `location` property to place with:
347
+ * the generic add options come from `add_generic_props`
348
+ * (`editors/object/object_add.cc:431`), and the two rows here whose operator
349
+ * never calls it are `object.quick_fur` and `image.import_as_mesh_planes`.
350
+ * Passing one anyway is a `TypeError` from Blender, not a placement. */
351
+ readonly placesAtOrigin?: boolean;
352
+ /** This editor cannot run the row, and this is the sentence saying why — the
353
+ * row is drawn DISABLED with it as its reason and the operator is never
354
+ * called. */
355
+ readonly refusal?: string;
356
+ /** Blender draws a `layout.separator()` above this row inside its submenu
357
+ * (Mesh `:2437`, Curve `:2458` and `:2464`, Grease Pencil `:2636`). */
358
+ readonly separatorBefore?: boolean;
359
+ }
360
+
361
+ /** The refusal every FILE row carries, naming the door that does not exist so
362
+ * the reader knows what would close it rather than that "it doesn't work". */
363
+ function needsAFilePicker(what: string, blenderSaid: string): string {
364
+ return (
365
+ `${what} needs a file, and this editor has no door to ask for one: the host's file ` +
366
+ `door (\`EditorHost.files\`, \`@volter/editor-sdk/host\`) reads and writes ` +
367
+ `project-relative paths and has no file-picker member, so there is nothing to open ` +
368
+ `the workbench's own dialog through. Blender's row opens a file browser; run without ` +
369
+ `one, the engine answers "${blenderSaid}".`
370
+ );
371
+ }
372
+
373
+ export const CREATABLE_KINDS: readonly BlenderCreatableKind[] = [
374
+ // `VIEW3D_MT_mesh_add`, `space_view3d.py:2428-2441`.
375
+ { kind: 'mesh.plane', label: 'Plane', group: 'Mesh', call: 'mesh.primitive_plane_add' },
376
+ { kind: 'mesh.cube', label: 'Cube', group: 'Mesh', call: 'mesh.primitive_cube_add' },
377
+ { kind: 'mesh.circle', label: 'Circle', group: 'Mesh', call: 'mesh.primitive_circle_add' },
378
+ {
379
+ kind: 'mesh.uv-sphere',
380
+ label: 'UV Sphere',
381
+ group: 'Mesh',
382
+ call: 'mesh.primitive_uv_sphere_add',
383
+ },
384
+ {
385
+ kind: 'mesh.ico-sphere',
386
+ label: 'Ico Sphere',
387
+ group: 'Mesh',
388
+ call: 'mesh.primitive_ico_sphere_add',
389
+ },
390
+ { kind: 'mesh.cylinder', label: 'Cylinder', group: 'Mesh', call: 'mesh.primitive_cylinder_add' },
391
+ { kind: 'mesh.cone', label: 'Cone', group: 'Mesh', call: 'mesh.primitive_cone_add' },
392
+ { kind: 'mesh.torus', label: 'Torus', group: 'Mesh', call: 'mesh.primitive_torus_add' },
393
+ // `layout.separator()`, `:2437`.
394
+ {
395
+ kind: 'mesh.grid',
396
+ label: 'Grid',
397
+ group: 'Mesh',
398
+ call: 'mesh.primitive_grid_add',
399
+ separatorBefore: true,
400
+ },
401
+ { kind: 'mesh.monkey', label: 'Monkey', group: 'Mesh', call: 'mesh.primitive_monkey_add' },
402
+ // `VIEW3D_MT_curve_add`, `space_view3d.py:2455-2467`.
403
+ {
404
+ kind: 'curve.bezier',
405
+ label: 'Bézier',
406
+ group: 'Curve',
407
+ call: 'curve.primitive_bezier_curve_add',
408
+ },
409
+ {
410
+ kind: 'curve.bezier-circle',
411
+ label: 'Circle',
412
+ group: 'Curve',
413
+ call: 'curve.primitive_bezier_circle_add',
414
+ },
415
+ {
416
+ // `layout.separator()`, `:2458`.
417
+ kind: 'curve.nurbs-curve',
418
+ label: 'Nurbs Curve',
419
+ group: 'Curve',
420
+ call: 'curve.primitive_nurbs_curve_add',
421
+ separatorBefore: true,
422
+ },
423
+ {
424
+ kind: 'curve.nurbs-circle',
425
+ label: 'Nurbs Circle',
426
+ group: 'Curve',
427
+ call: 'curve.primitive_nurbs_circle_add',
428
+ },
429
+ { kind: 'curve.path', label: 'Path', group: 'Curve', call: 'curve.primitive_nurbs_path_add' },
430
+ {
431
+ // `layout.separator()`, `:2464`.
432
+ kind: 'curve.empty-hair',
433
+ label: 'Empty Hair',
434
+ group: 'Curve',
435
+ call: 'object.curves_empty_hair_add',
436
+ separatorBefore: true,
437
+ },
438
+ // `object.quick_fur` (`object_quick_effects.cc`) declares no generic add
439
+ // options, so no `location`. Both hair rows are `poll`ed on an active MESH —
440
+ // Blender greys them out there and the engine refuses them by name here
441
+ // ("No active mesh object"), which the row reports through `reportAck`.
442
+ {
443
+ kind: 'curve.fur',
444
+ label: 'Fur',
445
+ group: 'Curve',
446
+ call: 'object.quick_fur',
447
+ placesAtOrigin: false,
448
+ },
449
+ // `VIEW3D_MT_surface_add`, `space_view3d.py:2484-2492`.
450
+ {
451
+ kind: 'surface.nurbs-curve',
452
+ label: 'Nurbs Curve',
453
+ group: 'Surface',
454
+ call: 'surface.primitive_nurbs_surface_curve_add',
455
+ },
456
+ {
457
+ kind: 'surface.nurbs-circle',
458
+ label: 'Nurbs Circle',
459
+ group: 'Surface',
460
+ call: 'surface.primitive_nurbs_surface_circle_add',
461
+ },
462
+ {
463
+ kind: 'surface.nurbs-surface',
464
+ label: 'Nurbs Surface',
465
+ group: 'Surface',
466
+ call: 'surface.primitive_nurbs_surface_surface_add',
467
+ },
468
+ {
469
+ kind: 'surface.nurbs-cylinder',
470
+ label: 'Nurbs Cylinder',
471
+ group: 'Surface',
472
+ call: 'surface.primitive_nurbs_surface_cylinder_add',
473
+ },
474
+ {
475
+ kind: 'surface.nurbs-sphere',
476
+ label: 'Nurbs Sphere',
477
+ group: 'Surface',
478
+ call: 'surface.primitive_nurbs_surface_sphere_add',
479
+ },
480
+ {
481
+ kind: 'surface.nurbs-torus',
482
+ label: 'Nurbs Torus',
483
+ group: 'Surface',
484
+ call: 'surface.primitive_nurbs_surface_torus_add',
485
+ },
486
+ // `VIEW3D_MT_metaball_add`, `space_view3d.py:2529` — one
487
+ // `operator_enum("object.metaball_add", "type")`, so the five labels are
488
+ // `rna_enum_metaelem_type_items`' own UI names (`rna_object.cc:196-203`).
489
+ {
490
+ kind: 'metaball.ball',
491
+ label: 'Ball',
492
+ group: 'Metaball',
493
+ call: "object.metaball_add, type='BALL'",
494
+ },
495
+ {
496
+ kind: 'metaball.capsule',
497
+ label: 'Capsule',
498
+ group: 'Metaball',
499
+ call: "object.metaball_add, type='CAPSULE'",
500
+ },
501
+ {
502
+ kind: 'metaball.plane',
503
+ label: 'Plane',
504
+ group: 'Metaball',
505
+ call: "object.metaball_add, type='PLANE'",
506
+ },
507
+ {
508
+ kind: 'metaball.ellipsoid',
509
+ label: 'Ellipsoid',
510
+ group: 'Metaball',
511
+ call: "object.metaball_add, type='ELLIPSOID'",
512
+ },
513
+ {
514
+ kind: 'metaball.cube',
515
+ label: 'Cube',
516
+ group: 'Metaball',
517
+ call: "object.metaball_add, type='CUBE'",
518
+ },
519
+ // `VIEW3D_MT_add`, `space_view3d.py:2706` and `:2707` — two flat rows.
520
+ { kind: 'text', label: 'Text', group: null, call: 'object.text_add' },
521
+ { kind: 'point-cloud', label: 'Point Cloud', group: null, call: 'object.pointcloud_random_add' },
522
+ // `VIEW3D_MT_volume_add`, `space_view3d.py:2618-2622`. The import row needs a
523
+ // file AND this build has no OpenVDB, so its refusal names both.
524
+ {
525
+ kind: 'volume.import',
526
+ label: 'Import OpenVDB...',
527
+ group: 'Volume',
528
+ call: 'object.volume_import',
529
+ refusal:
530
+ `${needsAFilePicker('Importing an OpenVDB volume', 'CANCELLED, with nothing created')} ` +
531
+ 'This build also has no OpenVDB at all (`bpy.app.build_options.openvdb` is False), ' +
532
+ 'so a file would not be read even with a picker.',
533
+ },
534
+ { kind: 'volume.empty', label: 'Empty', group: 'Volume', call: 'object.volume_add' },
535
+ // `VIEW3D_MT_grease_pencil_add`, `space_view3d.py:2633-2643` — six
536
+ // `object.grease_pencil_add` rows, each with its own `type`.
537
+ {
538
+ kind: 'grease-pencil.blank',
539
+ label: 'Blank',
540
+ group: 'Grease Pencil',
541
+ call: "object.grease_pencil_add, type='EMPTY'",
542
+ },
543
+ {
544
+ kind: 'grease-pencil.stroke',
545
+ label: 'Stroke',
546
+ group: 'Grease Pencil',
547
+ call: "object.grease_pencil_add, type='STROKE'",
548
+ },
549
+ {
550
+ kind: 'grease-pencil.monkey',
551
+ label: 'Monkey',
552
+ group: 'Grease Pencil',
553
+ call: "object.grease_pencil_add, type='MONKEY'",
554
+ },
555
+ {
556
+ // `layout.separator()`, `:2636`.
557
+ kind: 'grease-pencil.lineart-scene',
558
+ label: 'Scene Line Art',
559
+ group: 'Grease Pencil',
560
+ call: "object.grease_pencil_add, type='LINEART_SCENE'",
561
+ separatorBefore: true,
562
+ },
563
+ {
564
+ kind: 'grease-pencil.lineart-collection',
565
+ label: 'Collection Line Art',
566
+ group: 'Grease Pencil',
567
+ call: "object.grease_pencil_add, type='LINEART_COLLECTION'",
568
+ },
569
+ {
570
+ kind: 'grease-pencil.lineart-object',
571
+ label: 'Object Line Art',
572
+ group: 'Grease Pencil',
573
+ call: "object.grease_pencil_add, type='LINEART_OBJECT'",
574
+ },
575
+ // `VIEW3D_MT_add`, `space_view3d.py:2716` — the flat row Blender falls
576
+ // through to when no add-on has extended `VIEW3D_MT_armature_add`.
577
+ { kind: 'armature', label: 'Armature', group: null, call: 'object.armature_add' },
578
+ // `VIEW3D_MT_lattice_add`, `space_view3d.py:2656-2657`. The first row is the
579
+ // GENERIC add operator with a type, not a `lattice_add` of its own. Its kind
580
+ // is `lattice.lattice` and not `lattice` because the header stamps a row's
581
+ // test id from its kind and a submenu's from its group label — measured
582
+ // 2026-09-21: a bare `lattice` made `blender-add-lattice` name BOTH the
583
+ // submenu trigger and the row inside it, and the row's own click re-opened
584
+ // the submenu and added nothing.
585
+ {
586
+ kind: 'lattice.lattice',
587
+ label: 'Lattice',
588
+ group: 'Lattice',
589
+ call: "object.add, type='LATTICE'",
590
+ },
591
+ {
592
+ kind: 'lattice.deform-selected',
593
+ label: 'Lattice Deform Selected',
594
+ group: 'Lattice',
595
+ call: 'object.lattice_add_to_selected',
596
+ },
597
+ // `VIEW3D_MT_empty_add`, `space_view3d.py:2670-2676` — seven display types.
598
+ // Blender's eighth empty, Image, is a row of the IMAGE submenu (`:2778`).
599
+ {
600
+ kind: 'empty.plain-axes',
601
+ label: 'Plain Axes',
602
+ group: 'Empty',
603
+ call: "object.empty_add, type='PLAIN_AXES'",
604
+ },
605
+ {
606
+ kind: 'empty.arrows',
607
+ label: 'Arrows',
608
+ group: 'Empty',
609
+ call: "object.empty_add, type='ARROWS'",
610
+ },
611
+ {
612
+ kind: 'empty.single-arrow',
613
+ label: 'Single Arrow',
614
+ group: 'Empty',
615
+ call: "object.empty_add, type='SINGLE_ARROW'",
616
+ },
617
+ {
618
+ kind: 'empty.circle',
619
+ label: 'Circle',
620
+ group: 'Empty',
621
+ call: "object.empty_add, type='CIRCLE'",
622
+ },
623
+ { kind: 'empty.cube', label: 'Cube', group: 'Empty', call: "object.empty_add, type='CUBE'" },
624
+ {
625
+ kind: 'empty.sphere',
626
+ label: 'Sphere',
627
+ group: 'Empty',
628
+ call: "object.empty_add, type='SPHERE'",
629
+ },
630
+ { kind: 'empty.cone', label: 'Cone', group: 'Empty', call: "object.empty_add, type='CONE'" },
631
+ // `VIEW3D_MT_image_add`, `space_view3d.py:2775-2778`. Three of the four open
632
+ // a file browser in Blender and are refused here by name; the fourth is an
633
+ // ordinary empty and adds.
634
+ {
635
+ kind: 'image.reference',
636
+ label: 'Reference...',
637
+ group: 'Image',
638
+ call: 'object.empty_image_add, background=False',
639
+ refusal: needsAFilePicker(
640
+ 'A reference image',
641
+ 'poll() failed, context is incorrect / Please select at least one image',
642
+ ),
643
+ },
644
+ {
645
+ kind: 'image.background',
646
+ label: 'Background...',
647
+ group: 'Image',
648
+ call: 'object.empty_image_add, background=True',
649
+ refusal: needsAFilePicker(
650
+ 'A background image',
651
+ 'poll() failed, context is incorrect / Please select at least one image',
652
+ ),
653
+ },
654
+ {
655
+ kind: 'image.mesh-plane',
656
+ label: 'Mesh Plane...',
657
+ group: 'Image',
658
+ call: 'image.import_as_mesh_planes',
659
+ placesAtOrigin: false,
660
+ refusal: needsAFilePicker('An image as a mesh plane', 'CANCELLED, with nothing created'),
661
+ },
662
+ {
663
+ kind: 'image.empty-image',
664
+ label: 'Empty Image',
665
+ group: 'Image',
666
+ call: "object.empty_add, type='IMAGE'",
667
+ },
668
+ // `VIEW3D_MT_light_add`, `space_view3d.py:2574-2583` — one
669
+ // `operator_enum("object.light_add", "type")`, so the four labels are the
670
+ // `Light.type` enum's own UI names.
671
+ { kind: 'light.point', label: 'Point', group: 'Light', call: "object.light_add, type='POINT'" },
672
+ { kind: 'light.sun', label: 'Sun', group: 'Light', call: "object.light_add, type='SUN'" },
673
+ { kind: 'light.spot', label: 'Spot', group: 'Light', call: "object.light_add, type='SPOT'" },
674
+ { kind: 'light.area', label: 'Area', group: 'Light', call: "object.light_add, type='AREA'" },
675
+ // `VIEW3D_MT_lightprobe_add`, `space_view3d.py:2596` — one
676
+ // `operator_enum("object.lightprobe_add", "type")`, so the three labels are
677
+ // `rna_enum_lightprobes_type_items`' own (`rna_object.cc:206-211`).
678
+ {
679
+ kind: 'light-probe.sphere',
680
+ label: 'Sphere',
681
+ group: 'Light Probe',
682
+ call: "object.lightprobe_add, type='SPHERE'",
683
+ },
684
+ {
685
+ kind: 'light-probe.plane',
686
+ label: 'Plane',
687
+ group: 'Light Probe',
688
+ call: "object.lightprobe_add, type='PLANE'",
689
+ },
690
+ {
691
+ kind: 'light-probe.volume',
692
+ label: 'Volume',
693
+ group: 'Light Probe',
694
+ call: "object.lightprobe_add, type='VOLUME'",
695
+ },
696
+ // `VIEW3D_MT_camera_add`, `space_view3d.py:2607`, drawn INTO `VIEW3D_MT_add`
697
+ // at `:2735` because no add-on extended it.
698
+ { kind: 'camera', label: 'Camera', group: null, call: 'object.camera_add' },
699
+ // `VIEW3D_MT_add`, `space_view3d.py:2739`.
700
+ { kind: 'speaker', label: 'Speaker', group: null, call: 'object.speaker_add' },
701
+ // `VIEW3D_MT_add`, `space_view3d.py:2743` — one
702
+ // `operator_menu_enum("object.effector_add", "type")`, so the thirteen labels
703
+ // and their ORDER are the operator's own `field_type_items`
704
+ // (`editors/object/object_add.cc:171-185`) — which is not the FieldSettings
705
+ // enum beside it in RNA: that one is alphabetical and carries a None.
706
+ {
707
+ kind: 'force-field.force',
708
+ label: 'Force',
709
+ group: 'Force Field',
710
+ call: "object.effector_add, type='FORCE'",
711
+ },
712
+ {
713
+ kind: 'force-field.wind',
714
+ label: 'Wind',
715
+ group: 'Force Field',
716
+ call: "object.effector_add, type='WIND'",
717
+ },
718
+ {
719
+ kind: 'force-field.vortex',
720
+ label: 'Vortex',
721
+ group: 'Force Field',
722
+ call: "object.effector_add, type='VORTEX'",
723
+ },
724
+ {
725
+ kind: 'force-field.magnetic',
726
+ label: 'Magnetic',
727
+ group: 'Force Field',
728
+ call: "object.effector_add, type='MAGNET'",
729
+ },
730
+ {
731
+ kind: 'force-field.harmonic',
732
+ label: 'Harmonic',
733
+ group: 'Force Field',
734
+ call: "object.effector_add, type='HARMONIC'",
735
+ },
736
+ {
737
+ kind: 'force-field.charge',
738
+ label: 'Charge',
739
+ group: 'Force Field',
740
+ call: "object.effector_add, type='CHARGE'",
741
+ },
742
+ {
743
+ kind: 'force-field.lennard-jones',
744
+ label: 'Lennard-Jones',
745
+ group: 'Force Field',
746
+ call: "object.effector_add, type='LENNARDJ'",
747
+ },
748
+ {
749
+ kind: 'force-field.texture',
750
+ label: 'Texture',
751
+ group: 'Force Field',
752
+ call: "object.effector_add, type='TEXTURE'",
753
+ },
754
+ {
755
+ kind: 'force-field.curve-guide',
756
+ label: 'Curve Guide',
757
+ group: 'Force Field',
758
+ call: "object.effector_add, type='GUIDE'",
759
+ },
760
+ {
761
+ kind: 'force-field.boid',
762
+ label: 'Boid',
763
+ group: 'Force Field',
764
+ call: "object.effector_add, type='BOID'",
765
+ },
766
+ {
767
+ kind: 'force-field.turbulence',
768
+ label: 'Turbulence',
769
+ group: 'Force Field',
770
+ call: "object.effector_add, type='TURBULENCE'",
771
+ },
772
+ {
773
+ kind: 'force-field.drag',
774
+ label: 'Drag',
775
+ group: 'Force Field',
776
+ call: "object.effector_add, type='DRAG'",
777
+ },
778
+ {
779
+ kind: 'force-field.fluid-flow',
780
+ label: 'Fluid Flow',
781
+ group: 'Force Field',
782
+ call: "object.effector_add, type='FLUID'",
783
+ },
784
+ ];
785
+
786
+ /**
787
+ * COLLECTION INSTANCE IS A ROW PER COLLECTION, so its kinds are not in the
788
+ * table above: Blender's row is `operator_menu_enum("object.
789
+ * collection_instance_add", "collection")` (`space_view3d.py:2759-2764`), whose
790
+ * items are `bpy.data.collections` — the file's own, which change as the person
791
+ * works. A kind is this prefix plus the collection's name, and
792
+ * {@link liveCollectionKinds} is what enumerates them.
793
+ */
794
+ const COLLECTION_INSTANCE_PREFIX = 'collection-instance:';
795
+
796
+ /**
797
+ * The collections the Outliner is holding, as Add rows.
798
+ *
799
+ * READ FROM THE TREE, not from a second engine call: a layer-collection row
800
+ * carries the collection DATABLOCK it shows (`BlenderOutlinerRow.data`), and
801
+ * `bpy.data.collections[…]` is exactly the set Blender's own
802
+ * `bool(bpy.data.collections)` tests — the scene's master collection addresses
803
+ * as `…scenes[…].collection` and is correctly not one of them.
804
+ */
805
+ function liveCollectionKinds(): readonly BlenderCreatableKind[] {
806
+ const seen = new Map<string, BlenderCreatableKind>();
807
+ for (const row of blenderOutlinerState().byId.values()) {
808
+ if (row.type !== 'TSE_LAYER_COLLECTION') continue;
809
+ const address = row.data;
810
+ if (address === undefined || !address.startsWith('bpy.data.collections[')) continue;
811
+ if (seen.has(row.name)) continue;
812
+ seen.set(row.name, {
813
+ kind: `${COLLECTION_INSTANCE_PREFIX}${row.name}`,
814
+ label: row.name,
815
+ group: 'Collection Instance',
816
+ call: `object.collection_instance_add, collection=${py(row.name)}`,
817
+ });
818
+ }
819
+ return [...seen.values()];
820
+ }
821
+
822
+ /** Every kind this document can be asked for right now — the static table plus
823
+ * the file's own collections. */
824
+ export function blenderCreatableKinds(): readonly BlenderCreatableKind[] {
825
+ return [...CREATABLE_KINDS, ...liveCollectionKinds()];
826
+ }
827
+
828
+ /** The complete `bpy.ops` expression a kind runs, origin included. */
829
+ function addCall(kind: BlenderCreatableKind): string {
830
+ const [call, ...args] = kind.call.split(', ');
831
+ const all = kind.placesAtOrigin === false ? args : [...args, 'location=(0, 0, 0)'];
832
+ return `bpy.ops.${call}(${all.join(', ')})`;
833
+ }
834
+
835
+ /** A Python string literal for one Blender name. JSON's own escaping is a
836
+ * subset of Python's for every character a datablock name can hold, which is
837
+ * why the transform writer above addresses `bpy.data.objects[…]` the same
838
+ * way. */
839
+ function py(name: string): string {
840
+ return JSON.stringify(name);
841
+ }
842
+
843
+ /**
844
+ * RUN ONE OPERATOR AND SAY WHICH OBJECTS IT MADE.
845
+ *
846
+ * WHY THE ANSWER IS A SET DIFFERENCE and not `bpy.context.active_object.name`:
847
+ * the question a structural verb has to answer is "which datablock did this
848
+ * operator create", and the active object is a different question whose answer
849
+ * depends on what the operator left in the view layer — which is exactly the
850
+ * part a headless context is least trustworthy about. Diffing `bpy.data.objects`
851
+ * across the call answers the first question directly, and it answers it for an
852
+ * operator that makes SEVERAL objects (a multi-object duplicate) without a
853
+ * second mechanism.
854
+ *
855
+ * The script runs through the one script door (`blenderExecute`), which
856
+ * presents afterwards, so the picture and the tree that follow are the
857
+ * engine's own reading.
858
+ */
859
+ async function runBlenderOperator(
860
+ body: string,
861
+ ): Promise<{ readonly made: readonly string[]; readonly error: string | null }> {
862
+ const code = [
863
+ 'before = {o.name for o in bpy.data.objects}',
864
+ body,
865
+ 'made = [o.name for o in bpy.data.objects if o.name not in before]',
866
+ 'print("\\n".join(made))',
867
+ ].join('\n');
868
+ const answer = await blenderExecute(code);
869
+ // THE ENGINE'S REFUSAL, VERBATIM. `session.py::execute` answers with the
870
+ // traceback in `error` rather than raising, and a paraphrase here is how a
871
+ // refusal becomes a shrug.
872
+ if (answer.error !== null) return { made: [], error: answer.error };
873
+ const made = answer.result
874
+ .split('\n')
875
+ .map((line) => line.trim())
876
+ .filter((line) => line.length > 0);
877
+ return { made, error: null };
878
+ }
879
+
880
+ /**
881
+ * THE SELECTION IS BLENDER'S, AND THIS IS THE WRITE (WORK.md §THE BLENDER
882
+ * RELEASE, B11; ARCHITECTURE-CORE §Blender north star goal 1 — *"through the
883
+ * Blender MCP interface an agent cannot tell it is using anything other than
884
+ * real Blender"*).
885
+ *
886
+ * Every selection gesture on the Model document ends here: a viewport click, a
887
+ * box, an Outliner row, Select ▸ All/None/Invert, and the selection an
888
+ * operator leaves behind. ONE script per gesture, because a selection is one
889
+ * act — `select_set` per object in the view layer and one assignment to
890
+ * `view_layer.objects.active` — and because the script door PRESENTS when it
891
+ * returns, so the frame that comes back already carries the new `selected` and
892
+ * `active` for {@link blenderEngineSelection} to read.
893
+ *
894
+ * WHY IT IS A WRITE AT ALL, when `rna_outliner` deliberately takes the
895
+ * caller's selection instead of making one: reading a tree must not mutate the
896
+ * document, and that rule stands. This is not a read — it is the gesture
897
+ * itself. Before it, WALK 5 measured a click that picked the cube in this
898
+ * editor while `bpy.context.active_object` still answered `Point`, and the
899
+ * Shader Editor and UV Editing — which key on Blender's ACTIVE object and
900
+ * nothing else — drew a cube's material as "No material" and its unwrap as
901
+ * blank. There is no second selection to reconcile now: the engine holds it.
902
+ *
903
+ * THE LOOP IS OVER `view_layer.objects`, not `bpy.data.objects`: an object
904
+ * outside the view layer has no selection flag to set and `select_set` refuses
905
+ * it by name. `bpy.data.objects.get` for the active is the same care — a name
906
+ * the tree still holds but the engine has deleted answers None rather than
907
+ * raising, and None is what Blender's own "no active object" is.
908
+ */
909
+ async function writeBlenderSelection(
910
+ names: readonly string[],
911
+ active: string | null,
912
+ ): Promise<void> {
913
+ const body = [
914
+ `_sel = {${names.map(py).join(', ')}}`,
915
+ '_vl = bpy.context.view_layer',
916
+ 'for _o in _vl.objects:',
917
+ ' _o.select_set(_o.name in _sel)',
918
+ active === null
919
+ ? '_vl.objects.active = None'
920
+ : `_vl.objects.active = bpy.data.objects.get(${py(active)})`,
921
+ ].join('\n');
922
+ const answer = await blenderExecute(body);
923
+ // THE ENGINE'S REFUSAL, VERBATIM — never a shrug. A selection that did not
924
+ // land is the difference between the Properties rail showing this object and
925
+ // showing the last one, so it is said out loud on the editor's console.
926
+ if (answer.error !== null)
927
+ editorHost().console.error(
928
+ `Blender refused the selection write: ${answer.error}`,
929
+ 'blender-outliner',
930
+ );
931
+ }
932
+
933
+ /**
934
+ * ONE OPERATOR ON A NAMED SET OF OBJECTS — what the 3D viewport header's Object
935
+ * menu runs (`blender-header-menus.tsx`: Apply, Clear, Set Origin), and the
936
+ * same shape {@link StructureProvider}'s own delete and duplicate use.
937
+ *
938
+ * THE CONTEXT OVERRIDE IS THE WHOLE POINT. Every one of these operators reads
939
+ * `context.selected_objects` (and `selected_editable_objects`); the engine runs
940
+ * headless with no 3D view, and its selection is not the panel's, so the
941
+ * subject is STATED per call rather than written into the engine first — a
942
+ * write the document would then save, and the one `rna_outliner` already
943
+ * refuses to make for the same reason.
944
+ *
945
+ * `call` is the bare `bpy.ops.…(…)` expression, so a caller reads exactly what
946
+ * Blender will run and the ack can quote it.
947
+ */
948
+ export async function runBlenderObjectOperator(
949
+ names: readonly string[],
950
+ call: string,
951
+ ): Promise<WriteAck> {
952
+ if (names.length === 0)
953
+ return {
954
+ destination: 'Nothing is selected, so there was nothing to run it on.',
955
+ persisted: false,
956
+ };
957
+ const body = [
958
+ `targets = [bpy.data.objects[n] for n in [${names.map(py).join(', ')}]]`,
959
+ 'with bpy.context.temp_override(selected_objects=targets, ' +
960
+ 'selected_editable_objects=targets, active_object=targets[0], object=targets[0]):',
961
+ ` ${call}`,
962
+ ].join('\n');
963
+ const { error } = await runBlenderOperator(body);
964
+ if (error !== null) return { destination: error, persisted: false };
965
+ await refreshBlenderOutliner(names);
966
+ return { destination: `Blender — ${call}; the session saves the .blend`, persisted: true };
967
+ }
968
+
969
+ const PROVENANCE: AuthoringProvenance = {
970
+ source: 'document',
971
+ label: 'blender',
972
+ detail:
973
+ "These rows are Blender's own View Layer tree, read from the engine through RNA. " +
974
+ 'Editing the model is a bpy call; the eye and render columns write the engine directly.',
975
+ };
976
+
977
+ /** The eye is not what excluded a collection, and a row whose eye refuses must
978
+ * say which column did. The checkbox is beside it now (I3 follow-up (b)), so
979
+ * this names it rather than apologising for its absence. */
980
+ const EXCLUDED_REASON =
981
+ 'This collection is excluded from the view layer (LayerCollection.exclude), so its viewport ' +
982
+ 'visibility is not what is hiding it. Clear the Exclude checkbox on this row first.';
983
+
984
+ export const createBlenderOutlinerAuthoring: ToolObject3DDocumentAuthoringFactory = ({
985
+ defaultAdapter,
986
+ }) => {
987
+ const listeners = new Set<() => void>();
988
+ /**
989
+ * THE ENGINE'S SELECTION, CACHED IN ROW IDS — never a second selection.
990
+ *
991
+ * The state is the frame's (`blenderEngineSelection`); this is the shape the
992
+ * panels read it in, and {@link syncFromEngine} overwrites it from every
993
+ * frame and every tree read. A gesture fills it optimistically so the
994
+ * outline moves with the pointer rather than a round trip later, and the
995
+ * frame the gesture's own write presents then replaces it with the engine's
996
+ * answer — which is the same answer, unless Blender refused, in which case
997
+ * what the person sees is what Blender did.
998
+ */
999
+ let selected: readonly string[] = [];
1000
+ /** The engine answer {@link selected} was last derived from, tree version
1001
+ * included: a name only becomes a row id once the tree holding that row has
1002
+ * been read, so the same engine selection must be re-derived when the tree
1003
+ * moves under it. */
1004
+ let lastEngineKey: string | null = null;
1005
+ /** The world matrix each live gesture started from — see `beginEdit`. */
1006
+ const gestureStart = new Map<THREE.Object3D, THREE.Matrix4>();
1007
+
1008
+ const notify = (): void => {
1009
+ for (const listener of [...listeners]) listener();
1010
+ };
1011
+
1012
+ const rows = (): ReadonlyMap<string, BlenderOutlinerRow> => blenderOutlinerState().byId;
1013
+
1014
+ /** The object a ROW belongs to, as a three object — the place the two id
1015
+ * spaces meet (see the header). */
1016
+ const objectForRow = (id: string): THREE.Object3D | null => {
1017
+ const row = rows().get(id);
1018
+ const view = blenderPresentedView();
1019
+ if (row === undefined || view === null) return null;
1020
+ if (row.type === 'TSE_VIEW_COLLECTION_BASE') return view.root;
1021
+ return row.object === undefined ? null : view.objectForBlenderName(row.object);
1022
+ };
1023
+
1024
+ /** The row for a presented OBJECT: its address is the object's own, so this
1025
+ * is a map lookup rather than a walk. */
1026
+ const rowIdForObject = (object: THREE.Object3D): string | null => {
1027
+ const view = blenderPresentedView();
1028
+ if (view === null) return null;
1029
+ for (const row of rows().values())
1030
+ if (isObjectRow(row) && view.objectForBlenderName(row.object) === object) return row.id;
1031
+ return null;
1032
+ };
1033
+
1034
+ /** The row that names a Blender OBJECT, by NAME — the third direction of the
1035
+ * header's two id spaces, and the one a freshly created or renamed object
1036
+ * needs: it exists in the engine (and in the tree) before it exists as a
1037
+ * presented three object. */
1038
+ const rowIdForName = (name: string): string | null => {
1039
+ for (const row of rows().values()) if (isObjectRow(row) && row.object === name) return row.id;
1040
+ return null;
1041
+ };
1042
+
1043
+ /**
1044
+ * THE SAME MEETING POINT, RUN BACKWARDS — normalize an id that arrived from
1045
+ * outside into one of OUR rows (see the header's "both ways").
1046
+ *
1047
+ * A row id passes straight through. Anything else is the PRESENTATION's id,
1048
+ * because that is what `selection.set` below publishes into the shell store
1049
+ * — and the gizmo block reads `store.selectedEntityId`, so the viewport asks
1050
+ * this adapter about ids it minted itself. Measured 2026-09-21 on a cold
1051
+ * model scaffold: without this, `adapterOnlyToolOwner` (which gates on
1052
+ * `hierarchy.node(id) !== null`) answered null for every selection, so the
1053
+ * 3D tool context never claimed the surface and the gizmo stayed detached
1054
+ * even with `capabilities.transform` true.
1055
+ */
1056
+ const rowIdFor = (id: string): string | null => {
1057
+ if (rows().has(id)) return id;
1058
+ const object = defaultAdapter.hierarchy.object3D?.(id) ?? null;
1059
+ return object === null ? null : rowIdForObject(object);
1060
+ };
1061
+
1062
+ /** The presented object an id names, in EITHER space. */
1063
+ const objectFor = (id: string): THREE.Object3D | null => {
1064
+ const rowId = rowIdFor(id);
1065
+ return rowId === null ? null : objectForRow(rowId);
1066
+ };
1067
+
1068
+ /** An id in the DEFAULT adapter's space, for the reads it owns. */
1069
+ const presentationIdFor = (id: string): string | null => {
1070
+ const object = objectFor(id);
1071
+ return object === null ? null : (defaultAdapter.hierarchy.idForObject3D?.(object) ?? null);
1072
+ };
1073
+
1074
+ const hierarchy: HierarchyProvider = {
1075
+ roots: () => {
1076
+ // THE READ IS DRIVEN FROM HERE, for I1's reason: the panel is the only
1077
+ // thing that always runs, and `showBlenderOutliner` is idempotent and
1078
+ // schedules its work outside the render pass.
1079
+ showBlenderOutliner(selectedObjects());
1080
+ const state = blenderOutlinerState();
1081
+ return (state.tree?.rows ?? []).map((row) => nodeFor(row, null));
1082
+ },
1083
+ node: (id) => {
1084
+ const state = blenderOutlinerState();
1085
+ // NORMALIZED, because the viewport asks with the ids this adapter
1086
+ // published into the store — see {@link rowIdFor}. The node it answers
1087
+ // with is the ROW's, id included: there is one tree here, and a second
1088
+ // identity for the same subject is what the header's two spaces exist
1089
+ // to keep out.
1090
+ const rowId = state.byId.has(id) ? id : rowIdFor(id);
1091
+ const row = rowId === null ? undefined : state.byId.get(rowId);
1092
+ return row === undefined ? null : nodeFor(row, state.parentOf.get(row.id) ?? null);
1093
+ },
1094
+ object3D: (id) => objectFor(id),
1095
+ idForObject3D: (object) => rowIdForObject(object),
1096
+ };
1097
+
1098
+ /** The Blender object NAMES our selection covers — what the door wants. */
1099
+ function selectedObjects(): readonly string[] {
1100
+ const table = rows();
1101
+ const names = new Set<string>();
1102
+ for (const id of selected) {
1103
+ const name = table.get(id)?.object;
1104
+ if (name !== undefined) names.add(name);
1105
+ }
1106
+ return [...names];
1107
+ }
1108
+
1109
+ /** Keep the PRESENTATION in step: the viewport's selection outline and the
1110
+ * gizmo bind to the default adapter's own ids, so a Blender row's object is
1111
+ * pushed through its `idForObject3D`. The one place that push happens — a
1112
+ * gesture and a frame both end here. */
1113
+ const publishPresentation = (): void => {
1114
+ const mapped: string[] = [];
1115
+ for (const id of selected) {
1116
+ const object = objectFor(id);
1117
+ const mappedId = object === null ? null : defaultAdapter.hierarchy.idForObject3D?.(object);
1118
+ if (mappedId) mapped.push(mappedId);
1119
+ }
1120
+ defaultAdapter.selection?.set(mapped, { intent: 'exact' });
1121
+ notify();
1122
+ };
1123
+
1124
+ /**
1125
+ * THE READ: the engine's selection, turned into the rows the panels draw.
1126
+ *
1127
+ * Called on every frame and on every tree read, and it does nothing when
1128
+ * neither the engine's answer nor the tree has moved — so a gesture's own
1129
+ * optimistic cache survives until the frame its write presented arrives, and
1130
+ * is then replaced by what Blender actually did.
1131
+ *
1132
+ * THE ACTIVE OBJECT GOES LAST, because that is what the shell means by one:
1133
+ * `EditorShellStore.selectedEntityId` — the subject the Properties rail and
1134
+ * the gizmo take — is the LAST member of the selected set, and Blender's
1135
+ * active object is the same idea under its own name. An active object that
1136
+ * is not selected still lands in the list: it is the subject the Properties
1137
+ * editor is showing, and a rail pointing at a row nobody can see is the
1138
+ * disagreement this whole unit exists to end.
1139
+ */
1140
+ const syncFromEngine = (): void => {
1141
+ const engine = blenderEngineSelection();
1142
+ const key = `${blenderOutlinerVersion()}${engine.active ?? ''}${[...engine.selected]
1143
+ .sort()
1144
+ .join('')}`;
1145
+ if (key === lastEngineKey) return;
1146
+ lastEngineKey = key;
1147
+ const names = engine.selected.filter((name) => name !== engine.active);
1148
+ if (engine.active !== null) names.push(engine.active);
1149
+ const next = names.map(rowIdForName).filter((id): id is string => id !== null);
1150
+ if (next.length === selected.length && next.every((id, at) => id === selected[at])) return;
1151
+ selected = next;
1152
+ publishPresentation();
1153
+ };
1154
+
1155
+ const selection: SelectionProvider = {
1156
+ get: () => [...selected],
1157
+ set: (ids) => {
1158
+ // NORMALIZED, because ids arrive in BOTH SPACES (the header's "both
1159
+ // ways"). A hierarchy click and this adapter's own verbs speak ROW ids;
1160
+ // the viewport's MARQUEE speaks the presentation's, because it picks
1161
+ // three objects and hands back whatever the default adapter calls them.
1162
+ //
1163
+ // MEASURED 2026-09-21: a box select across the scene left `selection`
1164
+ // holding nine `source-object:…` ids, so every row in the Outliner read
1165
+ // as unselected, the Properties rail had no Blender subject, and the
1166
+ // gizmo's own `rowIdFor` was the only thing in the file still working.
1167
+ // `rowIdFor` is the one function that answers for either space, and this
1168
+ // is the other end of the same seam `hierarchy.node` already normalizes.
1169
+ // An id that resolves to no row is DROPPED rather than stored: a row id
1170
+ // is this provider's whole currency and a foreign one is not a selection.
1171
+ selected = ids.map(rowIdFor).filter((id): id is string => id !== null);
1172
+ publishPresentation();
1173
+ // AND IT WRITES BLENDER'S (see {@link writeBlenderSelection}). The
1174
+ // subject is the OWNING OBJECT of each selected row, which is Blender's
1175
+ // own Outliner behaviour — clicking a modifier or a shape key activates
1176
+ // the object that carries it (`outliner_select.cc`'s
1177
+ // `outliner_search_back_te(te, ID_OB)`), and `BlenderOutlinerRow.object`
1178
+ // is that owner.
1179
+ //
1180
+ // A ROW THAT OWNS NO OBJECT IS REFUSED BY NAME rather than silently
1181
+ // dropped: a collection is a selection in Blender's Outliner but not in
1182
+ // its object selection, and a person who clicked one and saw the
1183
+ // Properties rail keep the last object deserves the sentence.
1184
+ const table = rows();
1185
+ const names: string[] = [];
1186
+ const ownerless: string[] = [];
1187
+ for (const id of selected) {
1188
+ const row = table.get(id);
1189
+ const name = row?.object;
1190
+ if (name === undefined) ownerless.push(row?.name ?? id);
1191
+ else if (!names.includes(name)) names.push(name);
1192
+ }
1193
+ if (ownerless.length > 0)
1194
+ editorHost().console.warn(
1195
+ `${ownerless.join(', ')} ${ownerless.length === 1 ? 'is' : 'are'} not an object, so ` +
1196
+ "Blender's object selection does not carry " +
1197
+ `${ownerless.length === 1 ? 'it' : 'them'}. Selecting a collection sets the ACTIVE ` +
1198
+ 'COLLECTION in Blender, which is its own gesture and not in this editor yet.',
1199
+ 'blender-outliner',
1200
+ );
1201
+ // The ACTIVE is the LAST row the gesture named, which is the shell's own
1202
+ // reading of `selectedEntityId` and Blender's of an active object.
1203
+ void writeBlenderSelection(names, names.length === 0 ? null : names[names.length - 1]!);
1204
+ },
1205
+ };
1206
+
1207
+ const restrictOf = (id: string) => rows().get(id)?.restrict;
1208
+
1209
+ const inspector: InspectorProvider = {
1210
+ properties: (id): PropertyDescriptor[] => {
1211
+ const restrict = restrictOf(id);
1212
+ if (restrict === undefined) return [];
1213
+ const row = rows().get(id);
1214
+ const properties: PropertyDescriptor[] = [
1215
+ // WRITABLE ON AN OBJECT ROW since 2026-09-21 — Blender renames a
1216
+ // datablock on a double-click or F2 in its own Outliner, and the
1217
+ // "this Outliner inspects" refusal this replaced stopped being true
1218
+ // when the gizmo started writing. {@link InspectorProvider.editability}
1219
+ // is where the per-row answer is, and it is what a COLLECTION row
1220
+ // still refuses.
1221
+ { path: 'name', label: 'Name', type: 'string', readonly: !isObjectRow(row) },
1222
+ ];
1223
+ // The RESERVED paths the hierarchy's restriction columns read
1224
+ // (`AuthoringAdapter`'s "Reserved inspector paths convention"). Declared
1225
+ // only where Blender draws that column on that row type.
1226
+ if (restrict.hide !== undefined)
1227
+ properties.push({ path: 'visible', label: 'Show in Viewport', type: 'boolean' });
1228
+ if (restrict.render !== undefined)
1229
+ properties.push({ path: 'renderVisible', label: 'Show in Renders', type: 'boolean' });
1230
+ // THE EXCLUDE CHECKBOX, on the rows Blender draws it on — layer
1231
+ // collections, and only under the View Layer display
1232
+ // (`outliner_draw.cc:1634-1653`, guarded by `SO_RESTRICT_ENABLE`, which
1233
+ // is in the default set at `space_outliner.cc:399`). The door already
1234
+ // answered `exclude` for those rows; what was missing was the column.
1235
+ if (restrict.exclude !== undefined)
1236
+ properties.push({ path: 'exclude', label: 'Exclude from View Layer', type: 'boolean' });
1237
+ return properties;
1238
+ },
1239
+ get: (id, path) => {
1240
+ const row = rows().get(id);
1241
+ if (row === undefined) return undefined;
1242
+ if (path === 'name') return row.name;
1243
+ if (path === 'visible')
1244
+ return row.restrict?.hide === undefined ? undefined : !row.restrict.hide;
1245
+ if (path === 'renderVisible')
1246
+ return row.restrict?.render === undefined ? undefined : !row.restrict.render;
1247
+ // REPORTED IN BLENDER'S OWN SENSE: `true` is EXCLUDED, the way
1248
+ // `LayerCollection.exclude` reads, so the panel's checkbox is checked
1249
+ // when the value is false. The eye and the camera are inverted above
1250
+ // because those paths are named for what is SHOWN.
1251
+ if (path === 'exclude') return row.restrict?.exclude;
1252
+ return undefined;
1253
+ },
1254
+ editability: (id, path) => {
1255
+ const row = rows().get(id);
1256
+ if (row === undefined) return { writable: false };
1257
+ if (path === 'name') {
1258
+ // BLENDER RENAMES IN ITS OWN OUTLINER — a double-click on the name, or
1259
+ // F2 (`outliner_item_rename`, `outliner_tools.cc`) — and the refusal
1260
+ // that used to stand here ("this Outliner inspects") stopped being
1261
+ // true the moment the transform gizmo started writing `matrix_world`.
1262
+ // It is withdrawn for OBJECT rows, and only those: a collection, a
1263
+ // modifier, a vertex group and a bone each rename through a different
1264
+ // datablock's own `name`, and this unit does not reach them (WORK.md
1265
+ // §THE BLENDER RELEASE, B6 — "Collections stay read-only for this
1266
+ // unit, by name").
1267
+ if (isObjectRow(row)) return { writable: true };
1268
+ return {
1269
+ writable: false,
1270
+ reason:
1271
+ `Renaming a ${row.struct ?? row.type} is not in this Outliner yet — only an object ` +
1272
+ 'row renames here. Rename it with bpy.',
1273
+ };
1274
+ }
1275
+ if (row.notInCollection)
1276
+ return {
1277
+ writable: false,
1278
+ // `outliner_draw.cc:1282-1286` — Blender draws this row no columns.
1279
+ reason:
1280
+ 'This row is a child drawn under its parent although it is not in that collection, ' +
1281
+ "and Blender's Outliner draws it no restriction columns.",
1282
+ };
1283
+ if (path === 'visible' && row.restrict?.exclude === true)
1284
+ return { writable: false, reason: EXCLUDED_REASON };
1285
+ return { writable: true };
1286
+ },
1287
+ set: async (id, path, value) => {
1288
+ const row = rows().get(id);
1289
+ if (row === undefined) return;
1290
+ if (path === 'name') {
1291
+ // ONE `rna-set` OF `Object.name`, the same door the Object tab's own
1292
+ // Name field writes through. Blender's `ID.name` setter is what
1293
+ // enforces uniqueness — a second "Cube" becomes "Cube.001" — so the
1294
+ // name that comes back can differ from the one typed, which is why
1295
+ // the tree is re-read afterwards rather than assumed.
1296
+ if (!isObjectRow(row)) return;
1297
+ const name = String(value);
1298
+ if (name === row.name || name.length === 0) return;
1299
+ await blenderRnaSet(`bpy.data.objects[${py(row.object)}]`, 'name', name);
1300
+ // EVERY ROW ID THIS NAME OWNED IS NOW STALE — a row's id IS its RNA
1301
+ // address (`bpy.data.objects["Cube"]`), so a rename changes it. Re-read
1302
+ // and re-publish the selection, or the panels go on addressing a
1303
+ // datablock `bpy.data.objects` no longer has.
1304
+ await refreshBlenderOutliner([name]);
1305
+ const renamed = rowIdForName(name);
1306
+ if (renamed !== null) selection.set([renamed]);
1307
+ return;
1308
+ }
1309
+ if (path === 'exclude') {
1310
+ await writeBlenderOutlinerColumn(row.path, 'exclude', value === true);
1311
+ return;
1312
+ }
1313
+ const column = path === 'visible' ? 'hide' : path === 'renderVisible' ? 'render' : null;
1314
+ if (column === null) return;
1315
+ await writeBlenderOutlinerColumn(row.path, column, value === false);
1316
+ },
1317
+ };
1318
+
1319
+ /**
1320
+ * THE GIZMO IS ONE MORE bpy WRITER (owner, 2026-09-21: *"I don't see the
1321
+ * gizmo in the blender app. Why not?"*).
1322
+ *
1323
+ * The precedent is the Object tab's Transform panel, transcribed by METHOD:
1324
+ * a field there writes through `blender-properties-model.ts`'s
1325
+ * `writeBlenderRnaProperty` → `blenderRnaSet` → `session.py::rna_set`, and
1326
+ * the write PRESENTS, so the picture that comes back is the engine's own
1327
+ * reading rather than ours. This provider is that same door driven by a
1328
+ * drag: `apply` moves the PRESENTED object so the picture follows the
1329
+ * pointer, and `endEdit` makes exactly ONE write when the gesture closes.
1330
+ *
1331
+ * WHAT IS WRITTEN IS `matrix_world`, not `location`/`rotation_euler`/`scale`.
1332
+ * Blender's own setter (`rna_Object_matrix_world_update` →
1333
+ * `BKE_object_apply_mat4`) decomposes a world matrix through the parent,
1334
+ * `matrix_parent_inverse`, the delta transforms and the object's
1335
+ * `rotation_mode`. That is Blender's math and it stays Blender's; we hand it
1336
+ * the matrix.
1337
+ *
1338
+ * THE ORIENTATION IS GATED BY THE ROUND TRIP, not by a claim. `blenderRnaSet`
1339
+ * bumps the RNA version and the write presents, so the frame that follows
1340
+ * re-sets this object's matrix from the engine — if the rows/columns or the
1341
+ * axis permutation were wrong, the object would JUMP the moment it came
1342
+ * back. No jump is the proof.
1343
+ *
1344
+ * LOCKS ARE NOT READ, and that is stated rather than silently skipped:
1345
+ * `lock_location`/`lock_rotation`/`lock_scale` constrain Blender's own
1346
+ * interactive transform operators, not a matrix assignment, and the Outliner
1347
+ * rows this adapter draws do not carry them.
1348
+ *
1349
+ * There is deliberately no `remove`: a Blender object has no absent
1350
+ * transform channel to drop, so the protocol's removal door refuses by name
1351
+ * instead of falling back to a value write.
1352
+ */
1353
+ const transforms: TransformProvider = {
1354
+ get: (id) => {
1355
+ // THE DEFAULT ADAPTER'S OWN READER, on the mapped id. A presented object
1356
+ // owns its matrix (`matrixAutoUpdate = false`), so the vector fields are
1357
+ // not the truth and `three-projection-core.ts`'s `readLocalTransform`
1358
+ // decomposes instead — inventing a second reader here is how the two
1359
+ // would drift.
1360
+ const mapped = presentationIdFor(id);
1361
+ return (
1362
+ (mapped === null ? undefined : defaultAdapter.transforms?.get(mapped)) ?? NO_TRANSFORM()
1363
+ );
1364
+ },
1365
+ /** WHAT THIS DOES NOT ASK: the object's `lock_location`/`lock_rotation`/
1366
+ * `lock_scale`. Those constrain Blender's own interactive operators, not a
1367
+ * matrix assignment, and an Outliner row does not carry them — so a locked
1368
+ * channel still moves here. See the header. */
1369
+ editability: (id) => {
1370
+ const rowId = rowIdFor(id);
1371
+ const row = rowId === null ? undefined : rows().get(rowId);
1372
+ if (isObjectRow(row) && objectForRow(row.id) !== null) {
1373
+ return { writable: true };
1374
+ }
1375
+ return {
1376
+ writable: false,
1377
+ reason: `Only an object has a transform; this row is a ${row?.struct ?? row?.type ?? 'row'}.`,
1378
+ };
1379
+ },
1380
+ /** Nothing in the engine is driving this object between frames, so there
1381
+ * is no controller to pause. What this DOES take is the pose the gesture
1382
+ * started from, so a click on a gizmo handle that never moved closes
1383
+ * without writing — `dragging-changed` fires on pointerdown, and a write
1384
+ * that changes nothing still presents and still dirties the `.blend`.
1385
+ * Keyed by the OBJECT rather than the id, because the viewport addresses
1386
+ * one gesture in both id spaces (see the header). */
1387
+ beginEdit: (id) => {
1388
+ const object = objectFor(id);
1389
+ if (object !== null) gestureStart.set(object, object.matrixWorld.clone());
1390
+ },
1391
+ apply: (id, transform) => {
1392
+ const object = objectFor(id);
1393
+ if (object === null) return;
1394
+ object.position.fromArray(transform.position);
1395
+ object.quaternion.fromArray(transform.rotation);
1396
+ object.scale.fromArray(transform.scale);
1397
+ // WITHOUT THIS THE PICTURE DOES NOT FOLLOW THE POINTER. A presented
1398
+ // object owns its matrix (`blender-runtime-view.ts`: `matrixAutoUpdate =
1399
+ // false`, the matrix set from the frame and then decomposed into the
1400
+ // vector fields), so three never recomposes it — the three writes above
1401
+ // would move nothing on screen, and the gizmo, which reads
1402
+ // `matrixWorld`, would sit still with them.
1403
+ object.updateMatrix();
1404
+ object.updateMatrixWorld(true);
1405
+ },
1406
+ endEdit: async (id): Promise<WriteAck | undefined> => {
1407
+ const object = objectFor(id);
1408
+ const view = blenderPresentedView();
1409
+ if (object === null || view === null) return undefined;
1410
+ const started = gestureStart.get(object);
1411
+ gestureStart.delete(object);
1412
+ object.updateWorldMatrix(true, false);
1413
+ if (started?.equals(object.matrixWorld) === true) return undefined;
1414
+ const name = view.blenderObjectName(object);
1415
+ if (name === null) return undefined;
1416
+ // The engine's own address, quoted the way `_rna_resolve` parses it.
1417
+ const path = `bpy.data.objects[${JSON.stringify(name)}]`;
1418
+ try {
1419
+ await blenderRnaSet(path, 'matrix_world', blenderWorldMatrixRows(object, view.root));
1420
+ return {
1421
+ destination: `Blender — ${path}.matrix_world; the session saves the .blend`,
1422
+ persisted: true,
1423
+ };
1424
+ } catch (error) {
1425
+ // The engine's refusal, verbatim — never paraphrased and never acked.
1426
+ return {
1427
+ destination: error instanceof Error ? error.message : String(error),
1428
+ persisted: false,
1429
+ };
1430
+ }
1431
+ },
1432
+ };
1433
+
1434
+ /** The Blender object NAME an id addresses, or null for a row that is not an
1435
+ * object (a collection, a modifier, a vertex group). The structural verbs
1436
+ * below all refuse a non-object row, and this is the one place they ask. */
1437
+ const objectNameFor = (id: string): string | null => {
1438
+ const rowId = rowIdFor(id);
1439
+ const row = rowId === null ? undefined : rows().get(rowId);
1440
+ return isObjectRow(row) ? row.object : null;
1441
+ };
1442
+
1443
+ /** What a refusal answers with: the sentence, and nothing persisted. */
1444
+ const refuse = (reason: string): WriteAck => ({ destination: reason, persisted: false });
1445
+
1446
+ /**
1447
+ * THE TREE AFTER AN OPERATOR, AND THE SELECTION BLENDER WOULD LEAVE.
1448
+ *
1449
+ * An `*_add` or a `duplicate` leaves its new object selected and active in
1450
+ * Blender, so this re-reads the tree for the new names (awaited, because the
1451
+ * row does not exist until it lands — `refreshBlenderOutliner`) and then
1452
+ * publishes the selection through this adapter's own `selection.set`, which
1453
+ * is what keeps the Outliner, the Properties rail and the viewport's gizmo
1454
+ * looking at one subject.
1455
+ */
1456
+ const selectAfterOperator = async (made: readonly string[]): Promise<string> => {
1457
+ await refreshBlenderOutliner(made);
1458
+ const ids = made.map(rowIdForName).filter((id): id is string => id !== null);
1459
+ if (ids.length > 0) selection.set(ids);
1460
+ return ids[0] ?? '';
1461
+ };
1462
+
1463
+ /**
1464
+ * ADD, DELETE, DUPLICATE — BLENDER'S OWN OPERATORS, NAMED HERE AND RUN THERE
1465
+ * (WORK.md §THE BLENDER RELEASE, B6; ARCHITECTURE-CORE §Blender north star
1466
+ * goal 3 as amended: "can make MACRO adjustments at the screen — move,
1467
+ * rotate, scale a part, add or remove one, rename it … each a bpy call
1468
+ * through the doors that exist").
1469
+ *
1470
+ * Every verb is one `bpy.ops` call through the script door
1471
+ * (`blenderExecute`), which presents afterwards, so what comes back is the
1472
+ * engine's reading and never ours. The operators carry semantics we would
1473
+ * otherwise have to reimplement and get subtly wrong — `object.delete`
1474
+ * unparents a deleted object's children the way Blender does, which
1475
+ * `bpy.data.objects.remove` does not; `object.duplicate` copies the object's
1476
+ * data, its modifiers, its material slots and its parent relationship under
1477
+ * the user's own duplication preferences — so naming the operator IS the
1478
+ * implementation.
1479
+ *
1480
+ * THE CONTEXT OVERRIDE IS WHAT MAKES A ROW THE SUBJECT. `object.delete` and
1481
+ * `object.duplicate` read `context.selected_objects`; the engine runs
1482
+ * headless with no view3d and its selection is not the panel's, so each verb
1483
+ * states its subject with `bpy.context.temp_override(...)` rather than
1484
+ * writing the engine's selection first — a write the document would then
1485
+ * save.
1486
+ *
1487
+ * THERE IS NO UNDO, and that is recorded rather than silently absent: the
1488
+ * engine runs `--background` with no undo stack, and this editor's history
1489
+ * has no door a contributed package can push an element through (WORK.md
1490
+ * §THE BLENDER RELEASE, B5's OPEN (a)). Every verb here is as un-undoable as
1491
+ * the gizmo drag beside it.
1492
+ */
1493
+ const structure: StructureProvider = {
1494
+ /**
1495
+ * BLENDER'S ADD MENU, FOR EVERY PARENT — `parentId` is deliberately not
1496
+ * read. Blender's Add adds to the ACTIVE COLLECTION, never under whatever
1497
+ * was selected when the menu opened, so answering a different list per row
1498
+ * would advertise a placement this does not make. Parenting is
1499
+ * `object.parent_set`, its own gesture, and `reparent` below refuses by
1500
+ * name for the same reason.
1501
+ */
1502
+ creatableKinds: () => blenderCreatableKinds().map(({ kind, label }) => ({ kind, label })),
1503
+ create: (kind): StructuralIdWrite => {
1504
+ const entry = blenderCreatableKinds().find((one) => one.kind === kind);
1505
+ // A SYNCHRONOUS ID IS NOT AVAILABLE HERE, and `StructuralIdWrite` has a
1506
+ // member for saying so: the object does not exist until Blender has run
1507
+ // the operator, so the id is `''` and the ack is the real answer. The
1508
+ // new row is SELECTED when it lands, which is what a caller that wanted
1509
+ // the id was going to do with it.
1510
+ if (entry === undefined)
1511
+ return {
1512
+ id: '',
1513
+ ack: refuse(
1514
+ `${kind} is not a row of Blender's Add menu that this editor adds. Its rows are: ` +
1515
+ `${blenderCreatableKinds()
1516
+ .map((one) => one.label)
1517
+ .join(', ')}.`,
1518
+ ),
1519
+ };
1520
+ // A ROW THIS EDITOR CANNOT RUN NEVER RUNS. The menu draws it disabled
1521
+ // with this same sentence, so this arm is what answers a caller reaching
1522
+ // the kind through the door instead of the menu.
1523
+ if (entry.refusal !== undefined) return { id: '', ack: refuse(entry.refusal) };
1524
+ const call = addCall(entry);
1525
+ return {
1526
+ id: '',
1527
+ ack: (async (): Promise<WriteAck> => {
1528
+ const { made, error } = await runBlenderOperator(call);
1529
+ if (error !== null) return refuse(error);
1530
+ // AN OPERATOR THAT MADE NOTHING IS A REFUSAL, not a quiet success.
1531
+ // Blender's file-browser operators answer `{'CANCELLED'}` — a status
1532
+ // the script door reports no error for — with `bpy.data.objects`
1533
+ // unchanged, which is exactly the silent degrade `_absent_capability`
1534
+ // exists to stop (`session.py`'s own note). The set difference this
1535
+ // door already computes is what catches it.
1536
+ if (made.length === 0)
1537
+ return refuse(
1538
+ `Blender ran ${call} and created nothing. The operator reported no error, so it ` +
1539
+ 'cancelled — most often because it wanted a file browser or a context this ' +
1540
+ 'headless engine has no window for.',
1541
+ );
1542
+ await selectAfterOperator(made);
1543
+ return {
1544
+ destination: `Blender — ${call}; the session saves the .blend`,
1545
+ persisted: true,
1546
+ };
1547
+ })(),
1548
+ };
1549
+ },
1550
+ remove: (id) => structure.removeMany?.([id]) ?? undefined,
1551
+ /**
1552
+ * BLENDER'S OWN DELETE, for one row or a whole selection. `object.delete`
1553
+ * is the operator the X key runs (`blender_default.py:4552`, with
1554
+ * `use_global` False), and running it once for the whole set is also what
1555
+ * makes this ONE gesture rather than N.
1556
+ */
1557
+ removeMany: async (ids): Promise<WriteAck> => {
1558
+ const names: string[] = [];
1559
+ for (const id of ids) {
1560
+ const name = objectNameFor(id);
1561
+ if (name === null) {
1562
+ const row = rowIdFor(id) === null ? undefined : rows().get(rowIdFor(id)!);
1563
+ return refuse(
1564
+ `Only an object can be deleted here; this row is a ${row?.struct ?? row?.type ?? 'row'}. ` +
1565
+ 'Deleting a collection, a modifier or a vertex group is that datablock’s own bpy call.',
1566
+ );
1567
+ }
1568
+ names.push(name);
1569
+ }
1570
+ if (names.length === 0) return refuse('Nothing selected to delete.');
1571
+ const targets = `[bpy.data.objects[n] for n in [${names.map(py).join(', ')}]]`;
1572
+ const body = [
1573
+ `targets = ${targets}`,
1574
+ 'with bpy.context.temp_override(selected_objects=targets, active_object=targets[0]):',
1575
+ ' bpy.ops.object.delete(use_global=False)',
1576
+ ].join('\n');
1577
+ const { error } = await runBlenderOperator(body);
1578
+ if (error !== null) return refuse(error);
1579
+ // The deleted rows are gone; re-read with NO selection so nothing points
1580
+ // at a name `bpy.data.objects` no longer has.
1581
+ selection.set([]);
1582
+ await refreshBlenderOutliner([]);
1583
+ return {
1584
+ destination:
1585
+ 'Blender — bpy.ops.object.delete(use_global=False); the session saves the .blend',
1586
+ persisted: true,
1587
+ };
1588
+ },
1589
+ /**
1590
+ * BLENDER'S OWN DUPLICATE — Shift+D's operator (`blender_default.py:4563`,
1591
+ * `object.duplicate_move`, whose duplicate half is this). `linked=False` is
1592
+ * the full copy the menu's "Duplicate Objects" row makes; Blender's Linked
1593
+ * Duplicates is a separate row and not this unit's.
1594
+ */
1595
+ duplicate: (id): StructuralIdWrite => {
1596
+ const name = objectNameFor(id);
1597
+ // THE SOURCE ID IS THE ANSWER WHEN THERE IS NO COPY, because `duplicate`
1598
+ // has no refusal channel of its own and `''` there would be read as one
1599
+ // (`StructuralIdWrite`'s own note). The ack carries the refusal.
1600
+ if (name === null)
1601
+ return {
1602
+ id,
1603
+ ack: refuse(
1604
+ 'Only an object can be duplicated here. Duplicating a collection is ' +
1605
+ '`bpy.ops.object.collection_instance_add`, its own gesture.',
1606
+ ),
1607
+ };
1608
+ const body = [
1609
+ `source = bpy.data.objects[${py(name)}]`,
1610
+ 'with bpy.context.temp_override(selected_objects=[source], active_object=source):',
1611
+ ' bpy.ops.object.duplicate(linked=False)',
1612
+ ].join('\n');
1613
+ return {
1614
+ id,
1615
+ ack: (async (): Promise<WriteAck> => {
1616
+ const { made, error } = await runBlenderOperator(body);
1617
+ if (error !== null) return refuse(error);
1618
+ await selectAfterOperator(made);
1619
+ return {
1620
+ destination: `Blender — bpy.ops.object.duplicate(linked=False) on ${name}; the session saves the .blend`,
1621
+ persisted: true,
1622
+ };
1623
+ })(),
1624
+ };
1625
+ },
1626
+ /** REFUSED BY NAME. Parenting in Blender is `object.parent_set`, and it is
1627
+ * a gesture of its own with a type to choose (Object, Object Keep
1628
+ * Transform, Armature Deform, …) and a `matrix_parent_inverse` to settle.
1629
+ * A drag in a tree cannot mean one of those without saying which, so this
1630
+ * unit does not guess (WORK.md §THE BLENDER RELEASE, B6 — parenting is
1631
+ * not in it). */
1632
+ reparent: () =>
1633
+ refuse(
1634
+ "Reparenting is Blender's `bpy.ops.object.parent_set`, which needs a parent TYPE " +
1635
+ '(Object, Object Keep Transform, Armature Deform, …) that a drag does not say. ' +
1636
+ 'Set the parent on the object’s Relations panel, or with bpy.',
1637
+ ),
1638
+ };
1639
+
1640
+ const adapter: BlenderOutlinerAdapter = {
1641
+ // THE DOCUMENT'S OWN IDENTITY CARRIES OVER, and forgetting it is what the
1642
+ // walk caught first: this package's Properties sections resolve their
1643
+ // subject by asking the ACTIVE adapter for `documentId`, `hierarchy.object3D`
1644
+ // and `documentRootObject` (`blender-properties-model.ts`,
1645
+ // `isObject3DAuthoring`) — a structural check, because a contributed
1646
+ // package holds no host type. An adapter that overrides the tree and drops
1647
+ // those two members is a DIFFERENT adapter to that check, and the whole
1648
+ // Properties rail silently stopped matching: selecting the Rig drew the
1649
+ // host's bare identity row instead of Blender's tabs (measured 2026-09-19).
1650
+ // They are getters rather than copies because the default adapter answers
1651
+ // both from live state.
1652
+ get documentId() {
1653
+ return (defaultAdapter as { documentId?: string }).documentId;
1654
+ },
1655
+ get documentRootObject() {
1656
+ return (defaultAdapter as { documentRootObject?: THREE.Object3D | null }).documentRootObject;
1657
+ },
1658
+ capabilities: CAPABILITIES,
1659
+ provenance: PROVENANCE,
1660
+ hierarchy,
1661
+ selection,
1662
+ inspector,
1663
+ transforms,
1664
+ structure,
1665
+ subscribe: (listener) => {
1666
+ listeners.add(listener);
1667
+ const stopTree = subscribeBlenderOutliner(listener);
1668
+ const stopDefault = defaultAdapter.subscribe?.(listener) ?? (() => {});
1669
+ return () => {
1670
+ listeners.delete(listener);
1671
+ stopTree();
1672
+ stopDefault();
1673
+ };
1674
+ },
1675
+ };
1676
+
1677
+ // THE HEADER'S DOOR ONTO THIS OUTLINER (see {@link BlenderOutlinerHandle}).
1678
+ // It exposes what the 3D viewport header's menus need and nothing else: the
1679
+ // subject, the tree's objects, a way to publish a selection, and the same
1680
+ // `structure` the hierarchy panel drives.
1681
+ const handle: BlenderOutlinerHandle = {
1682
+ get documentId() {
1683
+ return adapter.documentId;
1684
+ },
1685
+ selectedObjectNames: () => selectedObjects(),
1686
+ allObjectNames: () => {
1687
+ const names: string[] = [];
1688
+ for (const row of rows().values()) if (isObjectRow(row)) names.push(row.object);
1689
+ return names;
1690
+ },
1691
+ selectObjects: (names) => {
1692
+ const ids = names.map(rowIdForName).filter((id): id is string => id !== null);
1693
+ selection.set(ids);
1694
+ },
1695
+ rowIdForObject: (name) => rowIdForName(name),
1696
+ structure,
1697
+ subscribe: (listener) => {
1698
+ listeners.add(listener);
1699
+ const stopTree = subscribeBlenderOutliner(listener);
1700
+ return () => {
1701
+ listeners.delete(listener);
1702
+ stopTree();
1703
+ };
1704
+ },
1705
+ };
1706
+ liveOutliners.add(handle);
1707
+
1708
+ // THE ENGINE'S SELECTION ARRIVES ON ITS OWN, and this is what listens for
1709
+ // it: a frame (every present carries `selected`/`active`) and a tree read
1710
+ // (which is what turns a NAME into a row id). Both are needed — a present
1711
+ // with no structural change ships a new selection against the same tree,
1712
+ // and an agent's `bpy.ops.mesh.primitive_monkey_add()` ships a new
1713
+ // selection against a tree that does not hold its row until the read lands.
1714
+ // Taken here rather than inside `adapter.subscribe` because the cache must
1715
+ // follow the engine whether or not a panel is currently mounted over it.
1716
+ const stopEngineFrames = onBlenderFrame(syncFromEngine);
1717
+ const stopEngineTree = subscribeBlenderOutliner(syncFromEngine);
1718
+ syncFromEngine();
1719
+
1720
+ return {
1721
+ adapter,
1722
+ dispose: () => {
1723
+ stopEngineFrames();
1724
+ stopEngineTree();
1725
+ liveOutliners.delete(handle);
1726
+ listeners.clear();
1727
+ },
1728
+ };
1729
+ };