@volter/editor-blender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Blender integration
2
+
3
+ Blender modeling documents, inspectors, commands, layouts and presentation for
4
+ Volter Editor. The integration talks to the independent Blender engine over its
5
+ worker wire and contributes its editing surfaces through the editor SDK.
6
+
7
+ The package uses the project contracts and shared Three rendering/capture
8
+ utilities. It imports no editor-core internals and no game runtime. Its
9
+ contributions are enumerated in package.json; the eventual product composes
10
+ that list through the host's contribution loader.
11
+
12
+ Run `npm run typecheck -w @volter/editor-blender` through the active World.
13
+ See LICENSE for the AGPL code and Blender-derived GPL icon notices.
14
+
15
+ This is a private migration staging package. Full editor startup, live editing,
16
+ wire/branding migration and existing-consumer cutover remain unfinished. Passing
17
+ a package build is not proof that the complete editor is ready to release.
@@ -0,0 +1,483 @@
1
+ /**
2
+ * THE MODEL DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER, in Object Mode
3
+ * (WORK.md §THE BLENDER RELEASE, B6; ARCHITECTURE-CORE §Blender north star
4
+ * goal 3 as amended — the person "can make MACRO adjustments at the screen …
5
+ * each a bpy call through the doors that exist").
6
+ *
7
+ * `VIEW3D_MT_editor_menus` (`scripts/startup/bl_ui/space_view3d.py:1143-1210`)
8
+ * draws four menu words in Object Mode, in this order: **View**
9
+ * (`:1153`), **Select** (`:1166`, `VIEW3D_MT_select_object`), **Add**
10
+ * (`:1169`, `VIEW3D_MT_add`) and **Object** (`:1210`, `VIEW3D_MT_object`).
11
+ * Three of them are here.
12
+ *
13
+ * VIEW IS NOT DRAWN, and that is a placement decision rather than an omission.
14
+ * Its rows that this editor has are the CAMERA VIEW rows — Front / Right / Top
15
+ * / Perspective 3/4, and the Perspective–Orthographic pair — and they are
16
+ * already a menu in THIS SAME HEADER STRIP: the host draws
17
+ * `Object3DDocumentToolbar` at the strip's trailing edge for every document
18
+ * with a 3D session, which is exactly where Blender's viewport header carries
19
+ * them too (`DocumentHeaderStrip`'s own note). A View menu here would be the
20
+ * same control twice in one row, which is the defect the rule about a control
21
+ * having ONE home exists to prevent. Every other row of Blender's View menu —
22
+ * the sidebar and tool-shelf toggles, local view, the view-point and
23
+ * view-axis submenus, Frame Selected, Viewport Render — is either a control
24
+ * this unit does not act on or one that already has its own home in the strip.
25
+ *
26
+ * WHAT IS DRAWN IS WHAT ACTS. A row Blender draws that this unit does not act
27
+ * on is simply not here — never a row that opens a dialog we do not have, and
28
+ * never one that no-ops. Where a row exists in Blender and is REFUSED here it
29
+ * is drawn disabled with the refusal as its reason: Set Origin ▸ Origin to 3D
30
+ * Cursor (there is no 3D cursor yet) and the four Add rows that want a FILE
31
+ * (Image ▸ Reference…/Background…/Mesh Plane…, Volume ▸ Import OpenVDB… —
32
+ * `BlenderCreatableKind.refusal` carries each sentence). Add is the whole of
33
+ * `VIEW3D_MT_add` now, so "not here" means Blender's menu-search row and
34
+ * nothing else.
35
+ *
36
+ * EVERY ROW IS BLENDER RUNNING ITS OWN OPERATOR, through the doors B6 built:
37
+ * `StructureProvider` for Add / Duplicate / Delete, and
38
+ * `runBlenderObjectOperator` for Apply / Clear / Set Origin. Nothing here
39
+ * reimplements a transform apply or an origin move.
40
+ */
41
+
42
+ import type { ToolNotice } from '@volter/editor-sdk/contributions';
43
+ import {
44
+ AnchoredMenu,
45
+ EditorToolbar,
46
+ MenuItem,
47
+ MenuSeparator,
48
+ MenuSubmenu,
49
+ MenuTrigger,
50
+ } from '@volter/editor-sdk/widgets';
51
+ import { type ReactNode, type RefObject, useRef, useState, useSyncExternalStore } from 'react';
52
+ import {
53
+ type BlenderCreatableKind,
54
+ type BlenderOutlinerHandle,
55
+ blenderCreatableKinds,
56
+ blenderOutlinerHandle,
57
+ runBlenderObjectOperator,
58
+ } from './blender-outliner-authoring';
59
+ import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
60
+
61
+ type MenuId = 'select' | 'add' | 'object';
62
+
63
+ /**
64
+ * BLENDER'S OWN ROWS for the three transform submenus of `VIEW3D_MT_object`,
65
+ * each citing the line it is transcribed from. The rows Blender draws in those
66
+ * submenus that this unit does not act on — Apply's five delta rows, its
67
+ * Visual Transform / Visual Geometry to Mesh / Parent Inverse block, Clear's
68
+ * Origin row, Set Origin's two centre-of-mass rows — are not here, for the
69
+ * reason the header's own note gives.
70
+ */
71
+ const APPLY_ROWS: readonly { readonly label: string; readonly call: string }[] = [
72
+ // `VIEW3D_MT_object_apply`, `space_view3d.py:3206-3219`: one
73
+ // `object.transform_apply` per row with the three booleans set.
74
+ { label: 'Location', call: 'location=True, rotation=False, scale=False' },
75
+ { label: 'Rotation', call: 'location=False, rotation=True, scale=False' },
76
+ { label: 'Scale', call: 'location=False, rotation=False, scale=True' },
77
+ { label: 'All Transforms', call: 'location=True, rotation=True, scale=True' },
78
+ ];
79
+
80
+ const CLEAR_ROWS: readonly { readonly label: string; readonly call: string }[] = [
81
+ // `VIEW3D_MT_object_clear`, `space_view3d.py:2938-2940`. `clear_delta` is
82
+ // False on all three, which is the row's own property in that menu.
83
+ { label: 'Location', call: 'bpy.ops.object.location_clear(clear_delta=False)' },
84
+ { label: 'Rotation', call: 'bpy.ops.object.rotation_clear(clear_delta=False)' },
85
+ { label: 'Scale', call: 'bpy.ops.object.scale_clear(clear_delta=False)' },
86
+ ];
87
+
88
+ const ORIGIN_ROWS: readonly {
89
+ readonly label: string;
90
+ readonly type: string;
91
+ readonly refusal?: string;
92
+ }[] = [
93
+ // `VIEW3D_MT_object`, `space_view3d.py:2817` — one
94
+ // `operator_menu_enum("object.origin_set", "type")`, so the labels are the
95
+ // `type` enum's own UI names.
96
+ { label: 'Geometry to Origin', type: 'GEOMETRY_ORIGIN' },
97
+ { label: 'Origin to Geometry', type: 'ORIGIN_GEOMETRY' },
98
+ {
99
+ label: 'Origin to 3D Cursor',
100
+ type: 'ORIGIN_CURSOR',
101
+ refusal:
102
+ 'There is no 3D cursor in this editor yet, so there is no point to move the origin to. ' +
103
+ 'Blender places it with Shift+Right-click; that gesture is not built here.',
104
+ },
105
+ ];
106
+
107
+ /**
108
+ * `VIEW3D_MT_add`'S OWN ROWS, in its own order, with its own separators
109
+ * (`space_view3d.py:2699-2764`, read at the engine's pin) — EIGHTEEN
110
+ * top-level rows, each carrying the line it is transcribed from.
111
+ *
112
+ * Eighteen and not seventeen: `:2707` is Point Cloud
113
+ * (`object.pointcloud_random_add`), a flat row between Text and Volume that a
114
+ * count taken from Blender's manual misses. The pinned source is the
115
+ * reference, so it is drawn.
116
+ *
117
+ * A SUBMENU NAMES A GROUP OF {@link CREATABLE_KINDS}, which is where each
118
+ * row's label, call and refusal live; a flat row names one kind. The one
119
+ * dynamic submenu is Collection Instance, whose rows are the file's own
120
+ * collections (`liveCollectionKinds`).
121
+ *
122
+ * WHAT BLENDER DRAWS THAT IS NOT HERE, and why — the same rule the header's
123
+ * note states: `Search…` (`:2689`) draws only under `EXEC_REGION_WIN` and
124
+ * opens the menu-search dialog this editor has no door for. Everything else
125
+ * `VIEW3D_MT_add` draws is here, including the rows that refuse (they are
126
+ * drawn disabled with the engine's own reason rather than omitted, because a
127
+ * person looking for Reference… should find it and be told why it cannot run).
128
+ */
129
+ type AddRow =
130
+ /** `layout.separator()`, at this line — which is also the row's key. */
131
+ | { readonly separatorAt: string }
132
+ /** A row `VIEW3D_MT_add` draws directly, by its kind id. */
133
+ | { readonly kind: string; readonly at: string }
134
+ /** A submenu: the label Blender gives it, and the `CREATABLE_KINDS` group
135
+ * whose rows fill it (the same string). */
136
+ | { readonly submenu: string; readonly at: string };
137
+
138
+ const ADD_MENU: readonly AddRow[] = [
139
+ { submenu: 'Mesh', at: '2699' },
140
+ { submenu: 'Curve', at: '2702' },
141
+ { submenu: 'Surface', at: '2704' },
142
+ { submenu: 'Metaball', at: '2705' },
143
+ { kind: 'text', at: '2706' },
144
+ { kind: 'point-cloud', at: '2707' },
145
+ { submenu: 'Volume', at: '2708' },
146
+ { submenu: 'Grease Pencil', at: '2709' },
147
+ { separatorAt: '2711' },
148
+ // `:2713` asks `VIEW3D_MT_armature_add.is_extended()`; no add-on extends it
149
+ // in this engine, so Blender's own `else` branch — the flat row — is drawn.
150
+ { kind: 'armature', at: '2716' },
151
+ { submenu: 'Lattice', at: '2718' },
152
+ { separatorAt: '2720' },
153
+ { submenu: 'Empty', at: '2722' },
154
+ { submenu: 'Image', at: '2723' },
155
+ { separatorAt: '2725' },
156
+ { submenu: 'Light', at: '2727' },
157
+ { submenu: 'Light Probe', at: '2728' },
158
+ { separatorAt: '2730' },
159
+ // `:2732`'s `is_extended()` is false the same way, so `VIEW3D_MT_camera_add`
160
+ // draws its single row INTO this menu (`:2735`).
161
+ { kind: 'camera', at: '2735' },
162
+ { separatorAt: '2737' },
163
+ { kind: 'speaker', at: '2739' },
164
+ { separatorAt: '2741' },
165
+ { submenu: 'Force Field', at: '2743' },
166
+ { separatorAt: '2745' },
167
+ { submenu: 'Collection Instance', at: '2759' },
168
+ ];
169
+
170
+ /** BLENDER'S OWN TEXT for a Collection Instance row with no collection to
171
+ * instance (`space_view3d.py:2755`), drawn on a disabled row exactly as
172
+ * Blender draws it (`col.enabled = has_collections`, `:2749`). */
173
+ const NO_COLLECTIONS = 'No Collections to Instance';
174
+
175
+ /** One menu word in the bar, with its own anchor and its own panel. */
176
+ function MenuWord({
177
+ id,
178
+ open,
179
+ setOpen,
180
+ anchorRef,
181
+ children,
182
+ }: {
183
+ id: MenuId;
184
+ open: MenuId | null;
185
+ setOpen: (next: MenuId | null) => void;
186
+ anchorRef: RefObject<HTMLDivElement | null>;
187
+ children: ReactNode;
188
+ }) {
189
+ return (
190
+ <div ref={anchorRef} style={{ position: 'relative' }}>
191
+ <MenuTrigger
192
+ data-testid={`blender-menu-${id}`}
193
+ aria-expanded={open === id}
194
+ onClick={() => setOpen(open === id ? null : id)}
195
+ // BLENDER'S OWN BAR BEHAVIOUR: with one menu open, moving across the
196
+ // bar opens the next. The same rule `ApplicationMenus` transcribes for
197
+ // the title bar's words.
198
+ onPointerEnter={() => {
199
+ if (open) setOpen(id);
200
+ }}
201
+ style={{ textTransform: 'capitalize' }}
202
+ >
203
+ {id}
204
+ </MenuTrigger>
205
+ {open === id && (
206
+ // PORTALED, not `position: absolute` — the document header strip is
207
+ // 26 px tall and clips, the same fact `ApplicationMenus` records about
208
+ // the workbench's own title bar.
209
+ <AnchoredMenu
210
+ anchorRef={anchorRef}
211
+ dismissBoundaryRef={anchorRef}
212
+ align="start"
213
+ gap={0}
214
+ clamp
215
+ aria-label={id}
216
+ onDismiss={() => setOpen(null)}
217
+ >
218
+ {children}
219
+ </AnchoredMenu>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * WHAT A ROW DID, SAID OUT LOUD. Every row here is a bpy call whose success and
227
+ * whose REFUSAL are both invisible on screen — an added object appears, but a
228
+ * refused `transform_apply` on a linked mesh, a Blender traceback, or an
229
+ * operator the headless build has no context for all look exactly like a menu
230
+ * that closed. So each row's `WriteAck` is read and reported through the host's
231
+ * own notice door (`ToolContributionProps.notify`).
232
+ *
233
+ * MEASURED 2026-09-21, which is why this exists rather than being tidy: Add ▸
234
+ * Mesh ▸ UV Sphere created the sphere and never selected it, and the ONLY
235
+ * evidence was `bpy.data.objects` read from a second door — the menu itself
236
+ * reported nothing either way. An ack that says `persisted: false` now carries
237
+ * Blender's own sentence to the screen.
238
+ */
239
+ function reportAck(
240
+ notify: ((notice: ToolNotice) => () => void) | undefined,
241
+ label: string,
242
+ outcome: unknown,
243
+ ): void {
244
+ void (async () => {
245
+ const ack = (await outcome) as { destination?: string; persisted?: boolean } | undefined;
246
+ if (ack === undefined || typeof ack.destination !== 'string') return;
247
+ if (ack.persisted === true) return;
248
+ // THE ENGINE'S OWN SENTENCE, verbatim — a refusal's `destination` IS the
249
+ // reason (`StructureProvider`'s refusals and `runBlenderObjectOperator`).
250
+ notify?.({ tone: 'error', title: `${label} was refused`, detail: ack.destination });
251
+ })();
252
+ }
253
+
254
+ export function BlenderObjectModeHeader({
255
+ documentId,
256
+ notify,
257
+ }: {
258
+ documentId?: string | undefined;
259
+ notify?: ((notice: ToolNotice) => () => void) | undefined;
260
+ }) {
261
+ // The tree is what says which objects exist and which are selected, and both
262
+ // menus read it, so the bar re-renders on the Outliner's own version.
263
+ useSyncExternalStore(subscribeBlenderOutliner, blenderOutlinerVersion, blenderOutlinerVersion);
264
+ const [open, setOpen] = useState<MenuId | null>(null);
265
+ const rootRef = useRef<HTMLDivElement>(null);
266
+ const selectRef = useRef<HTMLDivElement>(null);
267
+ const addRef = useRef<HTMLDivElement>(null);
268
+ const objectRef = useRef<HTMLDivElement>(null);
269
+ const handle = blenderOutlinerHandle(documentId);
270
+ // NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
271
+ // document there is no subject for any of these rows, and a menu of rows
272
+ // that cannot act is the inert control this file's header rules out.
273
+ if (handle === null) return null;
274
+
275
+ const run = (
276
+ label: string,
277
+ action: (outliner: BlenderOutlinerHandle) => unknown,
278
+ ): (() => void) => {
279
+ return () => {
280
+ setOpen(null);
281
+ reportAck(notify, label, action(handle));
282
+ };
283
+ };
284
+ const selected = handle.selectedObjectNames();
285
+ const all = handle.allObjectNames();
286
+ const nothingSelected = selected.length === 0 ? 'Nothing is selected.' : undefined;
287
+
288
+ // THE ADD MENU'S ROWS. The kinds are read fresh on every render because one
289
+ // submenu's rows are the file's own collections, which change as the person
290
+ // works (`liveCollectionKinds`) — and this bar already re-renders on the
291
+ // Outliner's version, which is where that tree comes from.
292
+ const kinds = blenderCreatableKinds();
293
+ /** One row: Blender's label, its kind's call, and — where the kind refuses —
294
+ * drawn DISABLED with that sentence as its reason, the shape Set Origin ▸
295
+ * Origin to 3D Cursor set. */
296
+ const addItem = (kind: BlenderCreatableKind): ReactNode => (
297
+ <MenuItem
298
+ key={kind.kind}
299
+ data-testid={`blender-add-${kind.kind}`}
300
+ disabled={kind.refusal !== undefined}
301
+ title={kind.refusal}
302
+ onSelect={run(`Add ${kind.label}`, (outliner) => outliner.structure.create(kind.kind).ack)}
303
+ >
304
+ {kind.label}
305
+ </MenuItem>
306
+ );
307
+ const addGroup = (group: string): ReactNode[] =>
308
+ kinds
309
+ .filter((kind) => kind.group === group)
310
+ .flatMap((kind) =>
311
+ kind.separatorBefore === true
312
+ ? [<MenuSeparator key={`${kind.kind}-separator`} />, addItem(kind)]
313
+ : [addItem(kind)],
314
+ );
315
+ const addRowNode = (row: AddRow): ReactNode => {
316
+ if ('separatorAt' in row) return <MenuSeparator key={`separator-${row.separatorAt}`} />;
317
+ if ('kind' in row) {
318
+ const kind = kinds.find((one) => one.kind === row.kind);
319
+ return kind === undefined ? null : addItem(kind);
320
+ }
321
+ const rows = addGroup(row.submenu);
322
+ // THE FILE HAS NO COLLECTION TO INSTANCE, which is Blender's own disabled
323
+ // row rather than an empty submenu (`:2749-2757`). No other group can be
324
+ // empty — the rest are the static table's.
325
+ if (rows.length === 0)
326
+ return (
327
+ <MenuItem key={row.submenu} data-testid="blender-add-collection-instance" disabled>
328
+ {NO_COLLECTIONS}
329
+ </MenuItem>
330
+ );
331
+ return (
332
+ <MenuSubmenu
333
+ key={row.submenu}
334
+ label={row.submenu}
335
+ data-testid={`blender-add-${row.submenu.toLowerCase().replaceAll(' ', '-')}`}
336
+ >
337
+ {rows}
338
+ </MenuSubmenu>
339
+ );
340
+ };
341
+
342
+ return (
343
+ <EditorToolbar
344
+ compact
345
+ label="Blender Object Mode menus"
346
+ data-testid="blender-object-mode-menus"
347
+ ref={rootRef}
348
+ >
349
+ {/* SELECT — `VIEW3D_MT_select_object`, `space_view3d.py:1713-1715`. The
350
+ three rows are `object.select_all` with action SELECT / DESELECT /
351
+ INVERT, and they act on THE PANEL'S selection rather than running that
352
+ operator: the engine's own selection is a datablock state the document
353
+ would save, which is exactly why the tree door takes our selection as
354
+ a parameter instead of writing it (`session.py::rna_outliner`'s own
355
+ note). Blender's remaining rows — the three interactive select tools,
356
+ Select Active Camera, Mirror, Random, More/Less, and the four
357
+ by-property submenus — are not drawn. */}
358
+ <MenuWord id="select" open={open} setOpen={setOpen} anchorRef={selectRef}>
359
+ <MenuItem
360
+ data-testid="blender-select-all"
361
+ onSelect={run('Select All', (outliner) => outliner.selectObjects(all))}
362
+ >
363
+ All
364
+ </MenuItem>
365
+ <MenuItem
366
+ data-testid="blender-select-none"
367
+ onSelect={run('Select None', (outliner) => outliner.selectObjects([]))}
368
+ >
369
+ None
370
+ </MenuItem>
371
+ <MenuItem
372
+ data-testid="blender-select-invert"
373
+ onSelect={run('Select Invert', (outliner) =>
374
+ outliner.selectObjects(all.filter((name) => !selected.includes(name))),
375
+ )}
376
+ >
377
+ Invert
378
+ </MenuItem>
379
+ </MenuWord>
380
+
381
+ {/* ADD — `VIEW3D_MT_add`, `space_view3d.py:2679-2764`, EVERY ROW IT
382
+ DRAWS, in its order, with its separators and its submenus. The shape
383
+ is `ADD_MENU` above; what each row DOES is `CREATABLE_KINDS`. */}
384
+ <MenuWord id="add" open={open} setOpen={setOpen} anchorRef={addRef}>
385
+ {ADD_MENU.map((row) => addRowNode(row))}
386
+ </MenuWord>
387
+
388
+ {/* OBJECT — `VIEW3D_MT_object`, `space_view3d.py:2806-2878`, IN BLENDER'S
389
+ OWN ROW ORDER with the rows this unit does not act on absent: Set
390
+ Origin (`:2817`), Clear (`:2819`), Apply (`:2820`), then Duplicate
391
+ Objects (`:2824`) and Delete (`:2874`). Blender's Transform, Mirror,
392
+ Snap, Duplicate Linked, Join, the clipboard pair, Asset, Collection,
393
+ Library Override, Relations, Parent, Modifiers, Constraints, Track,
394
+ Make Links, the three shading rows, Animation, Rigid Body, Quick
395
+ Effects, Convert, Show/Hide, Clean Up and Delete Global are not
396
+ drawn. */}
397
+ <MenuWord id="object" open={open} setOpen={setOpen} anchorRef={objectRef}>
398
+ <MenuSubmenu label="Set Origin" data-testid="blender-object-origin">
399
+ {ORIGIN_ROWS.map((row) => (
400
+ <MenuItem
401
+ key={row.type}
402
+ data-testid={`blender-origin-${row.type}`}
403
+ disabled={row.refusal !== undefined || selected.length === 0}
404
+ title={row.refusal ?? nothingSelected}
405
+ onSelect={run(`Set Origin ▸ ${row.label}`, (outliner) =>
406
+ runBlenderObjectOperator(
407
+ outliner.selectedObjectNames(),
408
+ `bpy.ops.object.origin_set(type='${row.type}')`,
409
+ ),
410
+ )}
411
+ >
412
+ {row.label}
413
+ </MenuItem>
414
+ ))}
415
+ </MenuSubmenu>
416
+ <MenuSubmenu label="Clear" data-testid="blender-object-clear">
417
+ {CLEAR_ROWS.map((row) => (
418
+ <MenuItem
419
+ key={row.label}
420
+ data-testid={`blender-clear-${row.label.toLowerCase()}`}
421
+ disabled={selected.length === 0}
422
+ title={nothingSelected}
423
+ onSelect={run(`Clear ▸ ${row.label}`, (outliner) =>
424
+ runBlenderObjectOperator(outliner.selectedObjectNames(), row.call),
425
+ )}
426
+ >
427
+ {row.label}
428
+ </MenuItem>
429
+ ))}
430
+ </MenuSubmenu>
431
+ <MenuSubmenu label="Apply" data-testid="blender-object-apply">
432
+ {APPLY_ROWS.map((row) => (
433
+ <MenuItem
434
+ key={row.label}
435
+ data-testid={`blender-apply-${row.label.toLowerCase().replace(/ /g, '-')}`}
436
+ disabled={selected.length === 0}
437
+ title={nothingSelected}
438
+ onSelect={run(`Apply ▸ ${row.label}`, (outliner) =>
439
+ runBlenderObjectOperator(
440
+ outliner.selectedObjectNames(),
441
+ `bpy.ops.object.transform_apply(${row.call})`,
442
+ ),
443
+ )}
444
+ >
445
+ {row.label}
446
+ </MenuItem>
447
+ ))}
448
+ </MenuSubmenu>
449
+ <MenuSeparator />
450
+ <MenuItem
451
+ data-testid="blender-object-duplicate"
452
+ disabled={selected.length === 0}
453
+ title={nothingSelected}
454
+ onSelect={run('Duplicate Objects', async (outliner) => {
455
+ // One at a time, for the reason `duplicateSelection` awaits its own
456
+ // loop: each call runs a bpy script and the next must start from
457
+ // what the last produced.
458
+ for (const name of [...outliner.selectedObjectNames()]) {
459
+ const id = outliner.rowIdForObject(name);
460
+ if (id !== null) await outliner.structure.duplicate(id).ack;
461
+ }
462
+ })}
463
+ >
464
+ Duplicate Objects
465
+ </MenuItem>
466
+ <MenuItem
467
+ data-testid="blender-object-delete"
468
+ disabled={selected.length === 0}
469
+ title={nothingSelected}
470
+ onSelect={run('Delete', (outliner) => {
471
+ const ids = outliner
472
+ .selectedObjectNames()
473
+ .map((name) => outliner.rowIdForObject(name))
474
+ .filter((id): id is string => id !== null);
475
+ return outliner.structure.removeMany?.(ids);
476
+ })}
477
+ >
478
+ Delete
479
+ </MenuItem>
480
+ </MenuWord>
481
+ </EditorToolbar>
482
+ );
483
+ }