@volter/editor-blender 0.1.129 → 0.1.131
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/contributions/blender-game-panel.tsx +764 -0
- package/contributions/blender-header-menus.tsx +7 -6
- package/contributions/blender-model-opening.tsx +12 -2
- package/contributions/blender-node-editor.document.tsx +1 -1
- package/contributions/blender-outliner-authoring.ts +6 -7
- package/contributions/blender-properties-context.ts +45 -0
- package/contributions/blender-properties-model.ts +15 -3
- package/contributions/blender-properties-view.tsx +84 -10
- package/contributions/blender-runtime.document.tsx +236 -29
- package/contributions/blender-timeline.document.tsx +33 -3
- package/contributions/blender-uv-editor.document.tsx +1 -1
- package/contributions/model.layout.ts +4 -0
- package/contributions/models.finder.ts +5 -1
- package/contributions/properties-object.inspector.tsx +26 -27
- package/contributions/properties-scene.inspector.tsx +9 -2
- package/dist-node/serving.mjs +17 -1
- package/host/blender-runtime-host.ts +39 -99
- package/node/blender-mcp.ts +58 -12
- package/package.json +11 -8
- package/serving/index.ts +29 -0
- package/src/node-view-state.ts +1 -1
- package/src/play-mode.ts +146 -0
- package/src/timeline-view-state.ts +1 -1
- package/src/uv-view-state.ts +1 -1
- package/view/blender-view-routes.ts +180 -0
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
* `StructureProvider` for Add / Duplicate / Delete, and
|
|
38
38
|
* `runBlenderObjectOperator` for Apply / Clear / Set Origin. Nothing here
|
|
39
39
|
* reimplements a transform apply or an origin move.
|
|
40
|
+
*
|
|
41
|
+
* NO GAME CONTROLS (owner, 2026-10-06: "it's very confusing that the timeline at the bottom has
|
|
42
|
+
* a play button AND there's a play button at the top"). The header carries no Play of its own,
|
|
43
|
+
* nor View's Play row: in Game mode the one Play is the Game panel's (`blender-game-panel.tsx`),
|
|
44
|
+
* in Movie mode the one transport is the Timeline's. The Game / Movie switch that chooses
|
|
45
|
+
* between them is the bottom area's, at the leading edge of its header row (`PlayModeSwitch`),
|
|
46
|
+
* because switching modes swaps that area and nothing here.
|
|
40
47
|
*/
|
|
41
48
|
|
|
42
49
|
import type { ToolNotice } from '@volter/editor-sdk/contributions';
|
|
@@ -60,7 +67,6 @@ import {
|
|
|
60
67
|
} from './blender-outliner-authoring';
|
|
61
68
|
import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
|
|
62
69
|
import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
63
|
-
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
64
70
|
|
|
65
71
|
type MenuId = 'view' | 'select' | 'add' | 'object';
|
|
66
72
|
|
|
@@ -274,9 +280,6 @@ export function BlenderObjectModeHeader({
|
|
|
274
280
|
() => (documentId === undefined ? false : areaSplit(documentId)),
|
|
275
281
|
() => false,
|
|
276
282
|
);
|
|
277
|
-
const playExtension = useSyncExternalStore(subscribeDocumentPlayExtensions, () => documentPlayExtension('model'), () => null);
|
|
278
|
-
const PlayControl = playExtension?.Control;
|
|
279
|
-
const PlayMenu = playExtension?.Menu;
|
|
280
283
|
const handle = blenderOutlinerHandle(documentId);
|
|
281
284
|
// NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
|
|
282
285
|
// document there is no subject for any of these rows, and a menu of rows
|
|
@@ -385,7 +388,6 @@ export function BlenderObjectModeHeader({
|
|
|
385
388
|
Close Area
|
|
386
389
|
</MenuItem>
|
|
387
390
|
</MenuSubmenu>
|
|
388
|
-
{PlayMenu && <><MenuSeparator /><PlayMenu documentId={documentId} onClose={() => setOpen(null)} /></>}
|
|
389
391
|
</MenuWord>
|
|
390
392
|
{/* SELECT — `VIEW3D_MT_select_object`, `space_view3d.py:1713-1715`. The
|
|
391
393
|
three rows are `object.select_all` with action SELECT / DESELECT /
|
|
@@ -516,7 +518,6 @@ export function BlenderObjectModeHeader({
|
|
|
516
518
|
Delete
|
|
517
519
|
</MenuItem>
|
|
518
520
|
</MenuWord>
|
|
519
|
-
{PlayControl && <PlayControl documentId={documentId} onClose={() => setOpen(null)} />}
|
|
520
521
|
</EditorToolbar>
|
|
521
522
|
);
|
|
522
523
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { ModelDocumentPreview } from '../src/model-document-preview';
|
|
2
2
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
3
|
-
import { Button, StateSurface, fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
|
|
3
|
+
import { Button, StateSurface, fontMono, fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
|
|
4
4
|
import type { AuthoringAdapter, EditorNode } from '@volter/editor-project/adapter';
|
|
5
5
|
import { AssetEditorSubject } from '@volter/editor-sdk/kit/components/AssetEditorShell';
|
|
6
6
|
import { useMemo } from 'react';
|
|
7
7
|
|
|
8
8
|
/** A loading/error surface has no authoring stage and publishes no model. */
|
|
9
|
-
export function BlenderModelOpening({ documentId, path, error, preview, retry, returnToPreview, preparingView = false }: {
|
|
9
|
+
export function BlenderModelOpening({ documentId, path, error, preview, retry, returnToPreview, preparingView = false, note = null }: {
|
|
10
10
|
readonly documentId: string;
|
|
11
11
|
readonly path: string;
|
|
12
12
|
readonly error: string | null;
|
|
@@ -14,6 +14,8 @@ export function BlenderModelOpening({ documentId, path, error, preview, retry, r
|
|
|
14
14
|
readonly retry: () => void;
|
|
15
15
|
readonly returnToPreview: () => void;
|
|
16
16
|
readonly preparingView?: boolean;
|
|
17
|
+
/** What a slow open is waiting on — progress beside the spinner, not an error. */
|
|
18
|
+
readonly note?: string | null;
|
|
17
19
|
}) {
|
|
18
20
|
const title = error ? 'Could not open model' : preparingView ? 'Preparing model view' : 'Opening model';
|
|
19
21
|
// This document has not published an authoring stage yet. Its panels still
|
|
@@ -44,6 +46,14 @@ export function BlenderModelOpening({ documentId, path, error, preview, retry, r
|
|
|
44
46
|
description={<>
|
|
45
47
|
<div style={{ color: themeVars.content.primary, marginBottom: spaceVar[3] }}>{path}</div>
|
|
46
48
|
{error ? modelOpeningErrorMessage(error) : preparingView ? 'The file is open. Preparing its viewport.' : preview ? 'The previous model stays visible while this file opens.' : 'Preparing Blender and opening this file. The model will appear here when it is ready.'}
|
|
49
|
+
{/* THE CAUSE, VERBATIM, where the person is looking: the sentence above is a
|
|
50
|
+
category, and "details are available in the console" sent people to a console
|
|
51
|
+
they could not see (2026-10-06 audit). Selectable, so it can be copied. */}
|
|
52
|
+
{!error && note && <div data-testid="blender-model-opening-note" style={{ marginTop: spaceVar[3], color: themeVars.content.muted, userSelect: 'text' }}>{note}</div>}
|
|
53
|
+
{error && <div data-testid="blender-model-opening-detail"
|
|
54
|
+
style={{ marginTop: spaceVar[3], fontFamily: fontMono, fontSize: fontSizeVar.sm, color: themeVars.content.muted, whiteSpace: 'pre-wrap', userSelect: 'text', textAlign: 'left' }}>
|
|
55
|
+
{error}
|
|
56
|
+
</div>}
|
|
47
57
|
</>}
|
|
48
58
|
action={error ? <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: spaceVar[3] }}>
|
|
49
59
|
{preview && <Button onClick={returnToPreview}>Return to previous model</Button>}
|
|
@@ -110,7 +110,7 @@ export const title = 'Shader Editor';
|
|
|
110
110
|
// THE VIEW'S PRODUCT DOOR (WORK.md §The core is Code-OSS U8, ruling 1). This
|
|
111
111
|
// view's verbs are published ONCE, here, where the view itself is contributed:
|
|
112
112
|
// under the Code-OSS frame each becomes a `volter.blender-node-view.<verb>`
|
|
113
|
-
// command the bridge dispatches into the view, and standalone `
|
|
113
|
+
// command the bridge dispatches into the view, and standalone `cyclotron edit` —
|
|
114
114
|
// which has no command service — reaches the SAME table through the session's
|
|
115
115
|
// `blender-node-view` verb. One table, two doors, which is why the remaining
|
|
116
116
|
// read-only editors (UV Editing, Animation, Texture Paint) add no session verb
|
|
@@ -1010,9 +1010,9 @@ async function writeBlenderSelection(
|
|
|
1010
1010
|
'_vl = bpy.context.view_layer',
|
|
1011
1011
|
'for _o in _vl.objects:',
|
|
1012
1012
|
' _o.select_set(_o.name in _sel)',
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1013
|
+
// object_select.cc::base_deselect_all_ex changes BASE_SELECTED without
|
|
1014
|
+
// clearing the active base. Properties continues to inspect that object.
|
|
1015
|
+
...(active === null ? [] : [`_vl.objects.active = bpy.data.objects.get(${py(active)})`]),
|
|
1016
1016
|
].join('\n');
|
|
1017
1017
|
const answer = await blenderExecute(body, false);
|
|
1018
1018
|
// THE ENGINE'S REFUSAL, VERBATIM — never a shrug. A selection that did not
|
|
@@ -1242,9 +1242,8 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1242
1242
|
* `EditorShellStore.selectedEntityId` — the subject the Properties rail and
|
|
1243
1243
|
* the gizmo take — is the LAST member of the selected set, and Blender's
|
|
1244
1244
|
* active object is the same idea under its own name. An active object that
|
|
1245
|
-
* is not selected
|
|
1246
|
-
*
|
|
1247
|
-
* disagreement this whole unit exists to end.
|
|
1245
|
+
* is not selected stays outside this list: Properties reads the active
|
|
1246
|
+
* object from the frame independently, without adding an outline or gizmo.
|
|
1248
1247
|
*/
|
|
1249
1248
|
const syncFromEngine = (): void => {
|
|
1250
1249
|
if (disposed || pendingSelection !== null) return;
|
|
@@ -1255,7 +1254,7 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1255
1254
|
if (key === lastEngineKey) return;
|
|
1256
1255
|
lastEngineKey = key;
|
|
1257
1256
|
const names = engine.selected.filter((name) => name !== engine.active);
|
|
1258
|
-
if (engine.active !== null) names.push(engine.active);
|
|
1257
|
+
if (engine.active !== null && engine.selected.includes(engine.active)) names.push(engine.active);
|
|
1259
1258
|
const next = names.map(rowIdForName).filter((id): id is string => id !== null);
|
|
1260
1259
|
if (next.length === selected.length && next.every((id, at) => id === selected[at])) return;
|
|
1261
1260
|
selected = next;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Blender's Properties context remains inspectable without a selection.
|
|
2
|
+
* Reuse the same RNA sections and Properties projection as selected objects. */
|
|
3
|
+
import { registerNullSubjectProvider } from '@volter/editor-sdk/kit/inspection/null-subject';
|
|
4
|
+
import { blenderOutlinerState } from './blender-outliner-model';
|
|
5
|
+
import { blenderOutlinerKind } from './blender-outliner-authoring';
|
|
6
|
+
import {
|
|
7
|
+
blenderPropertiesState,
|
|
8
|
+
blenderRnaViewFor,
|
|
9
|
+
resolveBlenderSubject,
|
|
10
|
+
writeBlenderRnaProperty,
|
|
11
|
+
} from './blender-properties-model';
|
|
12
|
+
|
|
13
|
+
registerNullSubjectProvider({
|
|
14
|
+
match: ({ adapter }) => resolveBlenderSubject(null, adapter) !== null,
|
|
15
|
+
describe: ({ adapter }) => {
|
|
16
|
+
const subject = resolveBlenderSubject(null, adapter);
|
|
17
|
+
if (subject === null) return null;
|
|
18
|
+
const row = subject.kind === 'object'
|
|
19
|
+
? [...blenderOutlinerState().byId.values()].find(
|
|
20
|
+
(entry) => entry.struct === 'Object' && entry.object === subject.name,
|
|
21
|
+
)
|
|
22
|
+
: undefined;
|
|
23
|
+
const scenePath = blenderPropertiesState().context?.scene;
|
|
24
|
+
const scene = scenePath ? blenderRnaViewFor(scenePath) : undefined;
|
|
25
|
+
const title = subject.kind === 'object'
|
|
26
|
+
? subject.name
|
|
27
|
+
: (scene?.kind === 'struct' ? scene.name : null) ?? 'Scene';
|
|
28
|
+
return {
|
|
29
|
+
id: `blender-properties:${JSON.stringify(subject)}`,
|
|
30
|
+
title,
|
|
31
|
+
identity: {
|
|
32
|
+
kindLabel: subject.kind === 'object' ? 'Object' : 'Scene',
|
|
33
|
+
kind: blenderOutlinerKind(row?.icon ?? 'SCENE_DATA'),
|
|
34
|
+
rename: {
|
|
35
|
+
readOnly: subject.kind !== 'object',
|
|
36
|
+
set: async (name: string) => {
|
|
37
|
+
if (subject.kind === 'object' && name.length > 0)
|
|
38
|
+
await writeBlenderRnaProperty(`bpy.data.objects[${JSON.stringify(subject.name)}]`, 'name', name);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
sections: [],
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* pulling React into its closure, and the sections read it through one
|
|
8
8
|
* `useSyncExternalStore` (`blender-properties-view.tsx`).
|
|
9
9
|
*
|
|
10
|
-
* THE SUBJECT IS
|
|
10
|
+
* THE SUBJECT IS THE PRESENTED BLENDER CONTEXT. The person clicks a node
|
|
11
11
|
* in our viewport; the adapter hands that node's `THREE.Object3D`; the
|
|
12
12
|
* presented view answers which Blender object that three object IS
|
|
13
13
|
* (`BlenderRuntimeView.blenderObjectName`, a lookup by object identity in the
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* exactly as `buttons_context.cc` builds one around
|
|
17
17
|
* `BKE_view_layer_active_object_get`. Reading our selection never WRITES the
|
|
18
18
|
* engine's active object: that is a mutation, and the document would save it.
|
|
19
|
+
* With no selected node, the presented frame's active object remains the
|
|
20
|
+
* Properties subject; no active object resolves the scene instead.
|
|
19
21
|
*
|
|
20
22
|
* WHEN IT RE-READS. Three times, and each is a signal rather than a poll:
|
|
21
23
|
* - a PRESENT (`BlenderRuntimeView.subscribeFrames`) — every mutation
|
|
@@ -53,13 +55,15 @@ interface PresentedView {
|
|
|
53
55
|
readonly root: THREE.Object3D;
|
|
54
56
|
subscribeFrames(listener: () => void): () => void;
|
|
55
57
|
blenderObjectName(object: THREE.Object3D): string | null;
|
|
58
|
+
blenderSelection(): { readonly active: string | null };
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const isPresentedView = (value: unknown): value is PresentedView =>
|
|
59
62
|
typeof value === 'object' &&
|
|
60
63
|
value !== null &&
|
|
61
64
|
typeof (value as PresentedView).subscribeFrames === 'function' &&
|
|
62
|
-
typeof (value as PresentedView).blenderObjectName === 'function'
|
|
65
|
+
typeof (value as PresentedView).blenderObjectName === 'function' &&
|
|
66
|
+
typeof (value as PresentedView).blenderSelection === 'function';
|
|
63
67
|
|
|
64
68
|
/** The active authoring adapter, as much of it as this module reads. The
|
|
65
69
|
* adapter is deliberately `unknown` at the contribution point (adapter-native
|
|
@@ -109,9 +113,17 @@ export function resolveBlenderSubject(
|
|
|
109
113
|
node: { readonly id: string } | null,
|
|
110
114
|
adapter: unknown,
|
|
111
115
|
): BlenderSubject | null {
|
|
112
|
-
if (
|
|
116
|
+
if (!isObject3DAuthoring(adapter)) return null;
|
|
113
117
|
const view = presentedView();
|
|
114
118
|
if (view === null) return null;
|
|
119
|
+
if (node === null) {
|
|
120
|
+
// buttons_context_path_object follows the active object, independently of
|
|
121
|
+
// BASE_SELECTED. The frame carries both; no active object still leaves
|
|
122
|
+
// the scene's Properties tabs. Scope this to the presented Model document.
|
|
123
|
+
if (adapter.documentRootObject !== view.root) return null;
|
|
124
|
+
const active = view.blenderSelection().active;
|
|
125
|
+
return active === null ? { kind: 'scene' } : { kind: 'object', name: active };
|
|
126
|
+
}
|
|
115
127
|
if (node.id === adapter.documentId)
|
|
116
128
|
return adapter.documentRootObject === view.root ? { kind: 'scene' } : null;
|
|
117
129
|
// A COLLECTION ROW HAS NO OBJECT, so it has to be resolved before the object
|
|
@@ -54,7 +54,8 @@ import type {
|
|
|
54
54
|
BlenderRnaView,
|
|
55
55
|
} from '@volter/blender-engine/browser/rna';
|
|
56
56
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
57
|
-
import {
|
|
57
|
+
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
|
|
58
|
+
import { ColorPicker, EditorIcon, hexToRgb, parseAlpha, toHex } from '@volter/editor-sdk/widgets';
|
|
58
59
|
import {
|
|
59
60
|
type BlenderSubject,
|
|
60
61
|
blenderPropertiesState,
|
|
@@ -135,6 +136,7 @@ function Panel({
|
|
|
135
136
|
closed = false,
|
|
136
137
|
note,
|
|
137
138
|
count,
|
|
139
|
+
headerControl,
|
|
138
140
|
children,
|
|
139
141
|
}: {
|
|
140
142
|
readonly title: string;
|
|
@@ -147,6 +149,7 @@ function Panel({
|
|
|
147
149
|
/** A list panel's length, beside its title — Blender's `template_list`
|
|
148
150
|
* shows the rows and the count is what a collapsed panel can still say. */
|
|
149
151
|
readonly count?: number;
|
|
152
|
+
readonly headerControl?: React.ReactNode;
|
|
150
153
|
readonly children: React.ReactNode;
|
|
151
154
|
}) {
|
|
152
155
|
const [open, setOpen] = useState(!closed);
|
|
@@ -162,6 +165,7 @@ function Panel({
|
|
|
162
165
|
overflow: 'hidden',
|
|
163
166
|
}}
|
|
164
167
|
>
|
|
168
|
+
<div style={{ position: 'relative' }}>
|
|
165
169
|
<button
|
|
166
170
|
type="button"
|
|
167
171
|
onClick={() => setOpen(!open)}
|
|
@@ -196,13 +200,16 @@ function Panel({
|
|
|
196
200
|
>
|
|
197
201
|
{open ? '▼' : '▶'}
|
|
198
202
|
</span>
|
|
199
|
-
<span style={{ fontWeight: 400 }} title={note}>
|
|
203
|
+
<span style={{ fontWeight: 400, marginLeft: headerControl ? CHECKBOX + TRIANGLE_GAP : 0 }} title={note}>
|
|
200
204
|
{title}
|
|
201
205
|
</span>
|
|
202
206
|
{count === undefined ? null : (
|
|
203
207
|
<span style={{ opacity: READONLY_ALPHA, marginLeft: 'auto' }}>{count}</span>
|
|
204
208
|
)}
|
|
205
209
|
</button>
|
|
210
|
+
{headerControl ? <div style={{position: 'absolute', left: PANEL_LABEL_X, top: 0,
|
|
211
|
+
height: PANEL_HEADER, display: 'flex', alignItems: 'center'}}>{headerControl}</div> : null}
|
|
212
|
+
</div>
|
|
206
213
|
{open ? <div style={{ padding: PANEL_PAD }}>{children}</div> : null}
|
|
207
214
|
</div>
|
|
208
215
|
);
|
|
@@ -576,7 +583,10 @@ function ColourWidget({ row, write }: WidgetProps) {
|
|
|
576
583
|
* scalar into a three-element array, which is a refusal rather than the lock
|
|
577
584
|
* the person asked for. Each toggle here writes its own index (the door's
|
|
578
585
|
* `index` argument, `session.py::rna_set`'s `getattr(target, id)[index]`). */
|
|
579
|
-
function ArrayRows({ row, label, title, write }: WidgetProps & {
|
|
586
|
+
function ArrayRows({ row, path, label, title, write, locks }: WidgetProps & {
|
|
587
|
+
label: string; title: string;
|
|
588
|
+
locks?: readonly { row: BlenderRnaRow; index?: number }[] | undefined;
|
|
589
|
+
}) {
|
|
580
590
|
const values = (row.value as readonly (number | boolean)[] | null) ?? [];
|
|
581
591
|
return (
|
|
582
592
|
<>
|
|
@@ -603,12 +613,31 @@ function ArrayRows({ row, label, title, write }: WidgetProps & { label: string;
|
|
|
603
613
|
onWrite={(next, at) => write(next, at)}
|
|
604
614
|
/>
|
|
605
615
|
)}
|
|
616
|
+
<LockToggle path={path} lock={locks?.[index]} />
|
|
606
617
|
</Row>
|
|
607
618
|
))}
|
|
608
619
|
</>
|
|
609
620
|
);
|
|
610
621
|
}
|
|
611
622
|
|
|
623
|
+
function lockValue(lock: { row: BlenderRnaRow; index?: number }): boolean {
|
|
624
|
+
return lock.index === undefined ? lock.row.value === true
|
|
625
|
+
: Array.isArray(lock.row.value) && lock.row.value[lock.index] === true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function LockToggle({path, lock}: {path: string;
|
|
629
|
+
lock: {row: BlenderRnaRow; index?: number} | undefined}) {
|
|
630
|
+
if (lock === undefined) return null;
|
|
631
|
+
const value = lockValue(lock);
|
|
632
|
+
return <button type="button" aria-label={fieldName(lock.row, lock.index)} aria-pressed={value}
|
|
633
|
+
disabled={lock.row.readonly}
|
|
634
|
+
onClick={() => void writeBlenderRnaProperty(path, lock.row.identifier, !value, lock.index)}
|
|
635
|
+
style={{height: UNIT, width: UNIT, flexShrink: 0, padding: 0, background: 'transparent',
|
|
636
|
+
border: 'none', color: INK, cursor: 'pointer'}}>
|
|
637
|
+
<EditorIcon icon={value ? faLock : faLockOpen} size="sm" />
|
|
638
|
+
</button>;
|
|
639
|
+
}
|
|
640
|
+
|
|
612
641
|
function componentLabel(label: string, index: number, subtype: string): string {
|
|
613
642
|
const letter = arrayItemChar(subtype, index);
|
|
614
643
|
if (letter === undefined) return index === 0 ? label : `${label} ${index}`;
|
|
@@ -642,14 +671,18 @@ function PropertyRow({
|
|
|
642
671
|
row,
|
|
643
672
|
path,
|
|
644
673
|
onOpen,
|
|
674
|
+
label: overrideLabel,
|
|
675
|
+
locks,
|
|
645
676
|
}: {
|
|
646
677
|
readonly row: BlenderRnaRow;
|
|
647
678
|
readonly path: string;
|
|
648
679
|
readonly onOpen: (path: string) => void;
|
|
680
|
+
readonly label?: string | undefined;
|
|
681
|
+
readonly locks?: readonly { row: BlenderRnaRow; index?: number }[] | undefined;
|
|
649
682
|
}) {
|
|
650
683
|
const write = (next: unknown, index?: number) =>
|
|
651
684
|
void writeBlenderRnaProperty(path, row.identifier, next, index);
|
|
652
|
-
const label = row.name || row.identifier;
|
|
685
|
+
const label = overrideLabel ?? (row.name || row.identifier);
|
|
653
686
|
const title = `${row.identifier} — ${row.type}${row.subtype === 'NONE' ? '' : `/${row.subtype}`}${
|
|
654
687
|
row.readonly ? ' (read-only)' : ''
|
|
655
688
|
}${row.description ? `\n${row.description}` : ''}`;
|
|
@@ -680,7 +713,7 @@ function PropertyRow({
|
|
|
680
713
|
<ColourWidget {...props} />
|
|
681
714
|
</Row>
|
|
682
715
|
);
|
|
683
|
-
return <ArrayRows {...props} label={label} title={title} />;
|
|
716
|
+
return <ArrayRows {...props} label={label} title={title} locks={locks} />;
|
|
684
717
|
}
|
|
685
718
|
return (
|
|
686
719
|
<Row label={label} title={title} readonly={row.readonly} identifier={row.identifier}>
|
|
@@ -794,7 +827,18 @@ function CollectionRow({
|
|
|
794
827
|
* what this is for; the alternative — a nested block per struct — would put
|
|
795
828
|
* the rows in an order Blender does not use.
|
|
796
829
|
*/
|
|
797
|
-
export type BlenderCuratedProperty = string | {
|
|
830
|
+
export type BlenderCuratedProperty = string | {
|
|
831
|
+
readonly from?: string;
|
|
832
|
+
readonly property: string;
|
|
833
|
+
/** Blender's layout.prop(text=...) label. */
|
|
834
|
+
readonly label?: string;
|
|
835
|
+
/** A draw branch over a live RNA value, e.g. Object.rotation_mode. */
|
|
836
|
+
readonly when?: Omit<BlenderPanelCondition, 'from'>;
|
|
837
|
+
/** Whole-array lock toggles drawn beside the vector, not as extra rows. */
|
|
838
|
+
readonly lock?: string;
|
|
839
|
+
/** Quaternion/axis-angle W has a separate scalar lock. */
|
|
840
|
+
readonly firstLock?: string;
|
|
841
|
+
};
|
|
798
842
|
|
|
799
843
|
const identifierOf = (entry: BlenderCuratedProperty): string =>
|
|
800
844
|
typeof entry === 'string' ? entry : entry.property;
|
|
@@ -821,6 +865,8 @@ export interface BlenderCuratedPanel {
|
|
|
821
865
|
readonly properties: readonly BlenderCuratedProperty[];
|
|
822
866
|
/** Blender's `bl_options = {'DEFAULT_CLOSED'}`. */
|
|
823
867
|
readonly closed?: boolean;
|
|
868
|
+
/** A draw_header checkbox, such as Scene.use_gravity. */
|
|
869
|
+
readonly headerProperty?: string;
|
|
824
870
|
/** A `bl_parent_id` child panel. */
|
|
825
871
|
readonly sub?: readonly BlenderCuratedPanel[];
|
|
826
872
|
/**
|
|
@@ -1061,18 +1107,37 @@ function CuratedPanel({
|
|
|
1061
1107
|
// EACH ENTRY IS READ FROM THE DATABLOCK BLENDER READS IT FROM: the panel's
|
|
1062
1108
|
// own, or the one a qualified entry names ({@link BlenderCuratedProperty}).
|
|
1063
1109
|
// Order is Blender's list, whichever struct a row comes from.
|
|
1064
|
-
const found: { identifier: string; row: BlenderRnaRow; path: string
|
|
1110
|
+
const found: { identifier: string; row: BlenderRnaRow; path: string;
|
|
1111
|
+
label?: string; locks?: readonly { row: BlenderRnaRow; index?: number }[] }[] = [];
|
|
1065
1112
|
const missing: string[] = [];
|
|
1066
1113
|
for (const item of panel.properties) {
|
|
1067
1114
|
const identifier = identifierOf(item);
|
|
1068
|
-
const where = typeof item === 'string'
|
|
1115
|
+
const where = typeof item === 'string' || item.from === undefined
|
|
1116
|
+
? entry : paths.find((path) => path.label === item.from);
|
|
1069
1117
|
const source = where === undefined ? undefined : blenderRnaViewFor(where.path);
|
|
1118
|
+
const rows = source?.kind === 'struct' ? source.groups.flatMap((group) => group.rows) : [];
|
|
1119
|
+
if (typeof item !== 'string' && item.when) {
|
|
1120
|
+
const value = rows.find((row) => row.identifier === item.when?.property)?.value;
|
|
1121
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') continue;
|
|
1122
|
+
if (item.when.is && !item.when.is.includes(value)) continue;
|
|
1123
|
+
if (item.when.isNot?.includes(value)) continue;
|
|
1124
|
+
}
|
|
1070
1125
|
const row =
|
|
1071
1126
|
source?.kind === 'struct'
|
|
1072
1127
|
? source.groups.flatMap((group) => group.rows).find((it) => it.identifier === identifier)
|
|
1073
1128
|
: undefined;
|
|
1074
1129
|
if (row === undefined || where === undefined) missing.push(identifier);
|
|
1075
|
-
else
|
|
1130
|
+
else {
|
|
1131
|
+
const lock = typeof item === 'string' ? undefined : rows.find((row) => row.identifier === item.lock);
|
|
1132
|
+
const firstLock = typeof item === 'string' ? undefined : rows.find((row) => row.identifier === item.firstLock);
|
|
1133
|
+
found.push({ identifier, row, path: where.path,
|
|
1134
|
+
...(typeof item === 'string' || item.label === undefined ? {} : { label: item.label }),
|
|
1135
|
+
...(lock === undefined ? {} : { locks: [
|
|
1136
|
+
...(firstLock === undefined ? [] : [{ row: firstLock }]),
|
|
1137
|
+
...Array.from({length: lock.arrayLength}, (_, index) => ({row: lock, index})),
|
|
1138
|
+
] }),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1076
1141
|
}
|
|
1077
1142
|
if (view === undefined)
|
|
1078
1143
|
return (
|
|
@@ -1087,6 +1152,14 @@ function CuratedPanel({
|
|
|
1087
1152
|
<Panel
|
|
1088
1153
|
title={panel.title}
|
|
1089
1154
|
closed={panel.closed === true}
|
|
1155
|
+
headerControl={(() => {
|
|
1156
|
+
const row = view.kind === 'struct' ? view.groups.flatMap(group => group.rows)
|
|
1157
|
+
.find(row => row.identifier === panel.headerProperty) : undefined;
|
|
1158
|
+
return row ? <span onClick={event => event.stopPropagation()}><Checkbox
|
|
1159
|
+
value={row.value === true} readonly={row.readonly} label={row.name}
|
|
1160
|
+
onChange={value => void writeBlenderRnaProperty(entry.path, row.identifier, value)}
|
|
1161
|
+
/></span> : null;
|
|
1162
|
+
})()}
|
|
1090
1163
|
note={
|
|
1091
1164
|
missing.length === 0
|
|
1092
1165
|
? `${entry.path}`
|
|
@@ -1094,7 +1167,8 @@ function CuratedPanel({
|
|
|
1094
1167
|
}
|
|
1095
1168
|
>
|
|
1096
1169
|
{found.map((item) => (
|
|
1097
|
-
<PropertyRow key={item.identifier} row={item.row} path={item.path} onOpen={onOpen}
|
|
1170
|
+
<PropertyRow key={item.identifier} row={item.row} path={item.path} onOpen={onOpen}
|
|
1171
|
+
label={item.label} locks={item.locks} />
|
|
1098
1172
|
))}
|
|
1099
1173
|
{/* KEYED BY POSITION, NOT BY TITLE: Blender ships two panels with the
|
|
1100
1174
|
same `bl_label` under different engines and expects the poll to pick
|