@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.
- package/LICENSE +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- 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
|
+
};
|