@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
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
|
+
}
|