@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,1191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GENERIC PROPERTIES VIEW — every `bl_rna` property of the active
|
|
3
|
+
* datablock, drawn in OUR panels (WORK.md §Blender in the tab is Blender,
|
|
4
|
+
* "Inspection parity", I1; ARCHITECTURE-CORE §Blender north star, "Inspection
|
|
5
|
+
* parity, not editing parity"). Blender's UI layer is never run, ported or
|
|
6
|
+
* recorded: what arrives is `session.py`'s RNA rows, and what is drawn here is
|
|
7
|
+
* this file's own widgets.
|
|
8
|
+
*
|
|
9
|
+
* ## The geometry is Blender's SOURCE, not an eyedropper
|
|
10
|
+
*
|
|
11
|
+
* Every number below cites the file and constant it came from, read at the
|
|
12
|
+
* engine's own pin (Blender 5.2.0, `fbe6228777e7`, `~/volter/blender-src`).
|
|
13
|
+
* That is the rule as of 2026-09-19: a theme value or a metric traces to
|
|
14
|
+
* `userdef_default_theme.c` / `interface_*.cc`, and a frame is what confirms
|
|
15
|
+
* the result rather than what supplies the number.
|
|
16
|
+
*
|
|
17
|
+
* UI_UNIT_Y 20 `UI_interface_c.hh:2346` (`U.widget_unit`, 1x)
|
|
18
|
+
* row gap 2 `interface_style.cc:110` (`buttonspacey`)
|
|
19
|
+
* label column 40% `interface_layout.cc:79` (UI_ITEM_PROP_SEP_DIVIDE)
|
|
20
|
+
* label alignment right `interface_layout.cc:3394` (LayoutAlign::Right)
|
|
21
|
+
* panel header 25 `interface_intern.hh:152` (PNL_HEADER = 1.25 UI_UNIT_Y)
|
|
22
|
+
* panel label x 20 `interface_panel.cc:1054` (UI_UNIT_X * 1.0)
|
|
23
|
+
* sub-panel indent 14 `interface_panel.cc:1058` (+0.7 UI_UNIT_X)
|
|
24
|
+
* panel padding 8 `interface_style.cc:111` (`panelspace`)
|
|
25
|
+
* panel radius 4 `interface_panel.cc:1103` (panel_roundness 0.4 x unit x 0.5)
|
|
26
|
+
* widget radius 0.2 x h `interface_widgets.cc` widget_radius_from_rcti,
|
|
27
|
+
* `userdef_default_theme.c:100` wcol_num.roundness
|
|
28
|
+
* text margin 8 `interface_intern.hh:1503` (UI_TEXT_MARGIN_X 0.4 x unit)
|
|
29
|
+
* font 11 `UI_interface_c.hh:425` (UI_DEFAULT_TEXT_POINTS)
|
|
30
|
+
* read-only alpha 0.5 `interface_widgets.cc` widget_alpha_factor /
|
|
31
|
+
* widget_color_disabled — EVERY widget colour at
|
|
32
|
+
* half alpha, which is why a read-only row here is
|
|
33
|
+
* DIMMED rather than a disabled control.
|
|
34
|
+
*
|
|
35
|
+
* Colours, from `release/datafiles/userdef/userdef_default_theme.c`:
|
|
36
|
+
* panel header/back #3d3d3d :281,:282 sub-panel back #0000001f :283
|
|
37
|
+
* panel outline #ffffff11 :284 panel title #e6e6e6 :285
|
|
38
|
+
* widget inner #545454 :95 widget outline #3d3d3d :93
|
|
39
|
+
* widget text #e6e6e6 :98 number slider #4772b3 :97
|
|
40
|
+
* checkbox mark #ffffff :77 (wcol_option.item)
|
|
41
|
+
*
|
|
42
|
+
* The values are literal rather than `var(--vgai-…)` because the palette is
|
|
43
|
+
* the FRAME's grading (U8/U9) and eyedropped: `blender.palette.json` carries
|
|
44
|
+
* `surface.panel` #2f2f2f where the source says `.space_properties.back` is
|
|
45
|
+
* #303030, and `content.primary` #e5e5e5 against `.text` #e6e6e6. Where a
|
|
46
|
+
* token and the source agree, the token is used.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type {
|
|
50
|
+
BlenderRnaCollectionValue,
|
|
51
|
+
BlenderRnaContext,
|
|
52
|
+
BlenderRnaPointer,
|
|
53
|
+
BlenderRnaRow,
|
|
54
|
+
BlenderRnaView,
|
|
55
|
+
} from '@volter/blender-engine/browser/rna';
|
|
56
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
57
|
+
import {
|
|
58
|
+
type BlenderSubject,
|
|
59
|
+
blenderPropertiesState,
|
|
60
|
+
blenderPropertiesVersion,
|
|
61
|
+
blenderRnaViewFor,
|
|
62
|
+
showBlenderSubject,
|
|
63
|
+
subscribeBlenderProperties,
|
|
64
|
+
writeBlenderRnaProperty,
|
|
65
|
+
} from './blender-properties-model';
|
|
66
|
+
|
|
67
|
+
const UNIT = 20;
|
|
68
|
+
const ROW_GAP = 2;
|
|
69
|
+
const LABEL_COLUMN = '40%';
|
|
70
|
+
const PANEL_HEADER = 25;
|
|
71
|
+
const PANEL_LABEL_X = 20;
|
|
72
|
+
const PANEL_PAD = 8;
|
|
73
|
+
const PANEL_RADIUS = 4;
|
|
74
|
+
const TEXT_MARGIN = 8;
|
|
75
|
+
const FONT = 11;
|
|
76
|
+
const READONLY_ALPHA = 0.5;
|
|
77
|
+
|
|
78
|
+
const INK = '#e6e6e6';
|
|
79
|
+
const PANEL_BAND = '#3d3d3d';
|
|
80
|
+
const PANEL_OUTLINE = '#ffffff11';
|
|
81
|
+
const SUB_PANEL_BACK = 'rgba(0,0,0,0.12)';
|
|
82
|
+
const WIDGET_INNER = '#545454';
|
|
83
|
+
const WIDGET_OUTLINE = '#3d3d3d';
|
|
84
|
+
const WIDGET_ITEM = '#4772b3';
|
|
85
|
+
/** `wcol_state.error` (`userdef_default_theme.c:234`) — the ink Blender puts
|
|
86
|
+
* on a widget whose state is an error. The door's refusals are drawn in it. */
|
|
87
|
+
const ERROR_INK = '#991616';
|
|
88
|
+
/** The collapse triangle's optical size and the gap after it. Blender draws
|
|
89
|
+
* the triangle as an icon inside `panel_label_offset`'s UI_UNIT_X lead-in
|
|
90
|
+
* (`interface_panel.cc:1049-1060`) rather than as text; 8 is that mark's
|
|
91
|
+
* height at a 25 px header, and 6 is the space it leaves before the label. */
|
|
92
|
+
const TRIANGLE_SIZE = 8;
|
|
93
|
+
const TRIANGLE_GAP = 6;
|
|
94
|
+
|
|
95
|
+
/** `widget_radius_from_rcti`: `roundness * height`, and `wcol_num.roundness`
|
|
96
|
+
* is 0.2 for every widget this view draws. */
|
|
97
|
+
const widgetRadius = (height: number) => Math.round(height * 0.2);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* WHAT BLENDER DRAWS NOWHERE, WE DRAW NOWHERE — RNA's own `PROP_HIDDEN`
|
|
101
|
+
* (`BlenderRnaRow.hidden`, `rna_rna.cc:795-799`). The door reports the whole
|
|
102
|
+
* surface and this is the one place it is narrowed, so "hidden" stays
|
|
103
|
+
* Blender's answer rather than a list of ours.
|
|
104
|
+
*
|
|
105
|
+
* AND THE SECOND RULE, ruled on after I2 measured it: UNDRAWN STATE
|
|
106
|
+
* (`BlenderRnaRow.undrawn`). The `PROP_HIDDEN` flag above does NOT cover a
|
|
107
|
+
* modifier's panel-open booleans (`open_advanced_panel` and its siblings) —
|
|
108
|
+
* there is not one `PROP_HIDDEN` in the whole of `rna_modifier.cc`, because
|
|
109
|
+
* `rna_def_modifier_panel_open_prop` (`rna_modifier.cc:2694-2705`) sets
|
|
110
|
+
* `PROP_NO_DEG_UPDATE` and an sdna bit and no flag. What RNA does say is that
|
|
111
|
+
* they carry no `RNA_def_property_ui_text` at all, so
|
|
112
|
+
* `rna_define.cc:1311-1312`'s definition-time defaults still stand: `name`
|
|
113
|
+
* equal to the identifier, `description` empty. A property Blender never gave
|
|
114
|
+
* UI text to is a property Blender never meant to draw, and the generic view
|
|
115
|
+
* omits it here for exactly the same reason it omits a hidden one. The
|
|
116
|
+
* curated lists need no change: `bl_ui` names none of them.
|
|
117
|
+
*/
|
|
118
|
+
const shown = (rows: readonly BlenderRnaRow[]): readonly BlenderRnaRow[] =>
|
|
119
|
+
rows.filter((row) => row.hidden !== true && row.undrawn !== true);
|
|
120
|
+
|
|
121
|
+
export function useBlenderProperties() {
|
|
122
|
+
return useSyncExternalStore(
|
|
123
|
+
subscribeBlenderProperties,
|
|
124
|
+
blenderPropertiesVersion,
|
|
125
|
+
blenderPropertiesVersion,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Blender's panel -------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function Panel({
|
|
132
|
+
title,
|
|
133
|
+
sub = false,
|
|
134
|
+
closed = false,
|
|
135
|
+
note,
|
|
136
|
+
count,
|
|
137
|
+
children,
|
|
138
|
+
}: {
|
|
139
|
+
readonly title: string;
|
|
140
|
+
readonly sub?: boolean;
|
|
141
|
+
/** Blender's `bl_options = {'DEFAULT_CLOSED'}` — a panel that opens shut. */
|
|
142
|
+
readonly closed?: boolean;
|
|
143
|
+
/** The header's tooltip; the curated panels say which of Blender's
|
|
144
|
+
* properties this datablock does not carry. */
|
|
145
|
+
readonly note?: string;
|
|
146
|
+
/** A list panel's length, beside its title — Blender's `template_list`
|
|
147
|
+
* shows the rows and the count is what a collapsed panel can still say. */
|
|
148
|
+
readonly count?: number;
|
|
149
|
+
readonly children: React.ReactNode;
|
|
150
|
+
}) {
|
|
151
|
+
const [open, setOpen] = useState(!closed);
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
style={{
|
|
155
|
+
marginBottom: PANEL_PAD,
|
|
156
|
+
borderRadius: PANEL_RADIUS,
|
|
157
|
+
// `panel_outline` #ffffff11 (`userdef_default_theme.c:284`), drawn by
|
|
158
|
+
// `panel_draw_border` around the header AND body as one box.
|
|
159
|
+
boxShadow: sub ? 'none' : `inset 0 0 0 1px ${PANEL_OUTLINE}`,
|
|
160
|
+
background: sub ? SUB_PANEL_BACK : 'transparent',
|
|
161
|
+
overflow: 'hidden',
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={() => setOpen(!open)}
|
|
167
|
+
style={{
|
|
168
|
+
display: 'flex',
|
|
169
|
+
alignItems: 'center',
|
|
170
|
+
width: '100%',
|
|
171
|
+
height: PANEL_HEADER,
|
|
172
|
+
// `panel_header` #3d3d3d (`userdef_default_theme.c:281`).
|
|
173
|
+
background: sub ? 'transparent' : PANEL_BAND,
|
|
174
|
+
border: 'none',
|
|
175
|
+
// `panel_label_offset`: the label sits UI_UNIT_X in, and a sub-panel
|
|
176
|
+
// a further 0.7 UI_UNIT_X (`interface_panel.cc:1049-1060`). The
|
|
177
|
+
// triangle lives in the space before it.
|
|
178
|
+
padding: `0 ${TEXT_MARGIN}px 0 ${sub ? PANEL_LABEL_X + 14 - 20 : 0}px`,
|
|
179
|
+
color: INK,
|
|
180
|
+
font: `${FONT}px inherit`,
|
|
181
|
+
textAlign: 'left',
|
|
182
|
+
cursor: 'pointer',
|
|
183
|
+
gap: TRIANGLE_GAP,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<span
|
|
187
|
+
aria-hidden="true"
|
|
188
|
+
style={{
|
|
189
|
+
width: PANEL_LABEL_X - 8,
|
|
190
|
+
textAlign: 'center',
|
|
191
|
+
// Blender's collapse triangle: down when open, right when closed.
|
|
192
|
+
fontSize: TRIANGLE_SIZE,
|
|
193
|
+
opacity: 0.85,
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
{open ? '▼' : '▶'}
|
|
197
|
+
</span>
|
|
198
|
+
<span style={{ fontWeight: 400 }} title={note}>
|
|
199
|
+
{title}
|
|
200
|
+
</span>
|
|
201
|
+
{count === undefined ? null : (
|
|
202
|
+
<span style={{ opacity: READONLY_ALPHA, marginLeft: 'auto' }}>{count}</span>
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
{open ? <div style={{ padding: PANEL_PAD }}>{children}</div> : null}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- one property row ------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
function Row({
|
|
213
|
+
label,
|
|
214
|
+
title,
|
|
215
|
+
readonly,
|
|
216
|
+
identifier,
|
|
217
|
+
children,
|
|
218
|
+
}: {
|
|
219
|
+
readonly label: string;
|
|
220
|
+
readonly title?: string;
|
|
221
|
+
readonly readonly: boolean;
|
|
222
|
+
/** The RNA identifier this row draws, when it draws one — the name the
|
|
223
|
+
* product's chrome door reports so a reading of the rail is addressed the
|
|
224
|
+
* way `bpy` addresses it, not by the label's English. */
|
|
225
|
+
readonly identifier?: string;
|
|
226
|
+
readonly children: React.ReactNode;
|
|
227
|
+
}) {
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
// WHAT THIS ROW IS, published so it can be READ. The rail is a VS Code
|
|
231
|
+
// view (`editor-document-probe.ts` scope `'rail'`), and before these
|
|
232
|
+
// three attributes a reading of it could recover a label only as the
|
|
233
|
+
// row's text and could not say which RNA property that label belonged
|
|
234
|
+
// to or whether the field was writable. They change nothing about what
|
|
235
|
+
// is drawn.
|
|
236
|
+
data-rna-field={identifier ?? ''}
|
|
237
|
+
data-field-label={label}
|
|
238
|
+
data-field-readonly={readonly ? 'true' : undefined}
|
|
239
|
+
style={{
|
|
240
|
+
display: 'flex',
|
|
241
|
+
alignItems: 'center',
|
|
242
|
+
minHeight: UNIT,
|
|
243
|
+
marginBottom: ROW_GAP,
|
|
244
|
+
gap: TEXT_MARGIN,
|
|
245
|
+
// `widget_color_disabled` puts EVERY colour at half alpha, so a
|
|
246
|
+
// read-only row is the same widget, dimmed — never a greyed-out
|
|
247
|
+
// control of a different shape.
|
|
248
|
+
opacity: readonly ? READONLY_ALPHA : 1,
|
|
249
|
+
}}
|
|
250
|
+
title={title}
|
|
251
|
+
>
|
|
252
|
+
<span
|
|
253
|
+
style={{
|
|
254
|
+
flex: `0 0 ${LABEL_COLUMN}`,
|
|
255
|
+
// `LayoutAlign::Right` on the split's label column.
|
|
256
|
+
textAlign: 'right',
|
|
257
|
+
color: INK,
|
|
258
|
+
fontSize: FONT,
|
|
259
|
+
overflow: 'hidden',
|
|
260
|
+
textOverflow: 'ellipsis',
|
|
261
|
+
whiteSpace: 'nowrap',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{label}
|
|
265
|
+
</span>
|
|
266
|
+
<span style={{ flex: '1 1 0', minWidth: 0, display: 'flex', gap: ROW_GAP }}>{children}</span>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const fieldStyle: React.CSSProperties = {
|
|
272
|
+
height: UNIT,
|
|
273
|
+
width: '100%',
|
|
274
|
+
minWidth: 0,
|
|
275
|
+
boxSizing: 'border-box',
|
|
276
|
+
background: WIDGET_INNER,
|
|
277
|
+
border: `1px solid ${WIDGET_OUTLINE}`,
|
|
278
|
+
borderRadius: widgetRadius(UNIT),
|
|
279
|
+
color: INK,
|
|
280
|
+
fontSize: FONT,
|
|
281
|
+
padding: `0 ${TEXT_MARGIN}px`,
|
|
282
|
+
// Blender's number and text buttons centre their value.
|
|
283
|
+
textAlign: 'center',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function StaticValue({ children }: { readonly children: React.ReactNode }) {
|
|
287
|
+
return (
|
|
288
|
+
<span
|
|
289
|
+
style={{
|
|
290
|
+
...fieldStyle,
|
|
291
|
+
display: 'flex',
|
|
292
|
+
alignItems: 'center',
|
|
293
|
+
justifyContent: 'center',
|
|
294
|
+
overflow: 'hidden',
|
|
295
|
+
textOverflow: 'ellipsis',
|
|
296
|
+
whiteSpace: 'nowrap',
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{children}
|
|
300
|
+
</span>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Blender's checkbox: a square of the row's height shrunk by
|
|
305
|
+
* `(h - 2*pixelsize)/6` a side (`widget_optionbut`, `interface_widgets.cc`) —
|
|
306
|
+
* 14 px at a 20 px row — with the mark in `wcol_option.item` #ffffff. */
|
|
307
|
+
const CHECKBOX = UNIT - 2 * Math.floor((UNIT - 2) / 6);
|
|
308
|
+
|
|
309
|
+
function Checkbox({
|
|
310
|
+
value,
|
|
311
|
+
readonly,
|
|
312
|
+
label,
|
|
313
|
+
onChange,
|
|
314
|
+
}: {
|
|
315
|
+
readonly value: boolean;
|
|
316
|
+
readonly readonly: boolean;
|
|
317
|
+
/** The row's own label, on the CONTROL. A field whose value lives in
|
|
318
|
+
* `input.value` reports that value and no name, so without this a reading
|
|
319
|
+
* of the rail could not say which property a number belonged to without a
|
|
320
|
+
* second query for the row around it. It is also simply what an unlabelled
|
|
321
|
+
* form control owes a screen reader. */
|
|
322
|
+
readonly label?: string;
|
|
323
|
+
readonly onChange: (next: boolean) => void;
|
|
324
|
+
}) {
|
|
325
|
+
return (
|
|
326
|
+
<input
|
|
327
|
+
type="checkbox"
|
|
328
|
+
checked={value}
|
|
329
|
+
readOnly={readonly}
|
|
330
|
+
{...(label === undefined ? {} : { 'aria-label': label })}
|
|
331
|
+
onChange={(event) => !readonly && onChange(event.target.checked)}
|
|
332
|
+
style={{
|
|
333
|
+
width: CHECKBOX,
|
|
334
|
+
height: CHECKBOX,
|
|
335
|
+
margin: `${(UNIT - CHECKBOX) / 2}px 0`,
|
|
336
|
+
accentColor: WIDGET_ITEM,
|
|
337
|
+
cursor: readonly ? 'default' : 'pointer',
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function Numeric({
|
|
344
|
+
row,
|
|
345
|
+
value,
|
|
346
|
+
index,
|
|
347
|
+
onWrite,
|
|
348
|
+
}: {
|
|
349
|
+
readonly row: BlenderRnaRow;
|
|
350
|
+
readonly value: number;
|
|
351
|
+
readonly index?: number;
|
|
352
|
+
readonly onWrite: (next: number, index?: number) => void;
|
|
353
|
+
}) {
|
|
354
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
355
|
+
const decimals = row.type === 'FLOAT' ? (row.precision ?? 3) : 0;
|
|
356
|
+
const shown = draft ?? (row.type === 'FLOAT' ? value.toFixed(decimals) : String(value));
|
|
357
|
+
if (row.readonly) return <StaticValue>{shown}</StaticValue>;
|
|
358
|
+
return (
|
|
359
|
+
<input
|
|
360
|
+
type="number"
|
|
361
|
+
// The FULL name on every component — the drawn label says `Location X`
|
|
362
|
+
// on the first row and a bare `Y` on the next (Blender's own economy),
|
|
363
|
+
// so the control carries the whole one. See {@link Checkbox}'s `label`.
|
|
364
|
+
aria-label={fieldName(row, index)}
|
|
365
|
+
value={shown}
|
|
366
|
+
// The SOFT range is what Blender's own field drags between; the hard
|
|
367
|
+
// range is the clamp the engine applies, and it refuses out of it.
|
|
368
|
+
{...(row.softMin === undefined ? {} : { min: row.softMin })}
|
|
369
|
+
{...(row.softMax === undefined ? {} : { max: row.softMax })}
|
|
370
|
+
{...(row.step === undefined ? {} : { step: row.type === 'FLOAT' ? row.step / 100 : 1 })}
|
|
371
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
372
|
+
onBlur={() => {
|
|
373
|
+
const next = Number(draft);
|
|
374
|
+
setDraft(null);
|
|
375
|
+
if (draft !== null && Number.isFinite(next) && next !== value) onWrite(next, index);
|
|
376
|
+
}}
|
|
377
|
+
onKeyDown={(event) => {
|
|
378
|
+
if (event.key === 'Enter') (event.target as HTMLInputElement).blur();
|
|
379
|
+
if (event.key === 'Escape') setDraft(null);
|
|
380
|
+
}}
|
|
381
|
+
style={fieldStyle}
|
|
382
|
+
/>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** X/Y/Z/W — `RNA_property_array_item_char`'s own letters for a vector or
|
|
387
|
+
* colour; anything longer is numbered, as Blender numbers it. */
|
|
388
|
+
const ARRAY_LETTERS = ['X', 'Y', 'Z', 'W'];
|
|
389
|
+
|
|
390
|
+
/** A field's WHOLE name, for the control's `aria-label` — the property's UI
|
|
391
|
+
* name (its RNA identifier when it has none), plus the array component's
|
|
392
|
+
* letter. Never the drawn label, which omits the property name on every
|
|
393
|
+
* component after the first. */
|
|
394
|
+
function fieldName(row: BlenderRnaRow, index?: number): string {
|
|
395
|
+
const name = row.name || row.identifier;
|
|
396
|
+
return index === undefined ? name : `${name} ${ARRAY_LETTERS[index] ?? index}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function colourOf(values: readonly number[]): string {
|
|
400
|
+
const byte = (channel: number) =>
|
|
401
|
+
Math.max(0, Math.min(255, Math.round((values[channel] ?? 0) ** (1 / 2.2) * 255)));
|
|
402
|
+
return `rgb(${byte(0)}, ${byte(1)}, ${byte(2)})`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** A property's widget, by RNA TYPE — the dispatcher is one `switch` and each
|
|
406
|
+
* arm is its own component, because "which widget does this type get" is the
|
|
407
|
+
* whole mapping this view is and it should read as a list. */
|
|
408
|
+
interface WidgetProps {
|
|
409
|
+
readonly row: BlenderRnaRow;
|
|
410
|
+
readonly path: string;
|
|
411
|
+
readonly write: (next: unknown, index?: number) => void;
|
|
412
|
+
readonly onOpen: (path: string) => void;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function PointerWidget({ row, onOpen }: WidgetProps) {
|
|
416
|
+
const value = row.value as BlenderRnaPointer | null;
|
|
417
|
+
if (value === null || value.path === null)
|
|
418
|
+
return <StaticValue>{value === null ? 'None' : (value.name ?? value.type)}</StaticValue>;
|
|
419
|
+
const target = value.path;
|
|
420
|
+
return (
|
|
421
|
+
<button
|
|
422
|
+
type="button"
|
|
423
|
+
aria-label={fieldName(row)}
|
|
424
|
+
onClick={() => onOpen(target)}
|
|
425
|
+
style={{ ...fieldStyle, cursor: 'pointer', textAlign: 'left' }}
|
|
426
|
+
title={target}
|
|
427
|
+
>
|
|
428
|
+
{value.name ?? value.type}
|
|
429
|
+
</button>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function EnumWidget({ row, write }: WidgetProps) {
|
|
434
|
+
const items = row.items ?? [];
|
|
435
|
+
if (row.isFlag) {
|
|
436
|
+
const held = (row.value as readonly string[] | null) ?? [];
|
|
437
|
+
return <StaticValue>{held.length === 0 ? 'None' : held.join(', ')}</StaticValue>;
|
|
438
|
+
}
|
|
439
|
+
const value = String(row.value ?? '');
|
|
440
|
+
if (row.readonly)
|
|
441
|
+
return <StaticValue>{items.find((i) => i.identifier === value)?.name ?? value}</StaticValue>;
|
|
442
|
+
return (
|
|
443
|
+
<select
|
|
444
|
+
aria-label={fieldName(row)}
|
|
445
|
+
value={value}
|
|
446
|
+
onChange={(event) => write(event.target.value)}
|
|
447
|
+
style={{ ...fieldStyle, textAlign: 'left' }}
|
|
448
|
+
>
|
|
449
|
+
{items.map((item) => (
|
|
450
|
+
<option key={item.identifier} value={item.identifier}>
|
|
451
|
+
{item.name}
|
|
452
|
+
</option>
|
|
453
|
+
))}
|
|
454
|
+
</select>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function StringWidget({ row, write }: WidgetProps) {
|
|
459
|
+
const value = String(row.value ?? '');
|
|
460
|
+
return row.readonly ? (
|
|
461
|
+
<StaticValue>{value}</StaticValue>
|
|
462
|
+
) : (
|
|
463
|
+
<TextField value={value} label={fieldName(row)} onWrite={(next) => write(next)} />
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A COLOR subtype is a swatch, not three numbers — `PropertyRNA.subtype` is
|
|
468
|
+
* exactly what says so, and it is why the door carries `subtype` at all. */
|
|
469
|
+
function ColourWidget({ row }: WidgetProps) {
|
|
470
|
+
const values = (row.value as readonly number[] | null) ?? [];
|
|
471
|
+
return (
|
|
472
|
+
<span
|
|
473
|
+
style={{ ...fieldStyle, background: colourOf(values), display: 'block' }}
|
|
474
|
+
title={values.map((value) => value.toFixed(3)).join(', ')}
|
|
475
|
+
/>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Blender stacks an array's components as their own rows, labelled by
|
|
480
|
+
* `RNA_property_array_item_char` (X/Y/Z/W) and numbered past four.
|
|
481
|
+
*
|
|
482
|
+
* A BOOLEAN ARRAY IS AN ARRAY, and that is why this function dispatches on
|
|
483
|
+
* the element type rather than being numeric-only. `Object.lock_location` is
|
|
484
|
+
* three booleans, and Blender draws all three: `properties_object.py:49`
|
|
485
|
+
* (`OBJECT_PT_transform`) is `row.prop(ob, "lock_location", text="",
|
|
486
|
+
* emboss=False, icon='DECORATE_UNLOCKED')` — a whole-array `prop()` beside
|
|
487
|
+
* the location vector, which Blender's layout engine expands into one lock
|
|
488
|
+
* toggle per component, stacked alongside the three number fields (the same
|
|
489
|
+
* shape at `:59`/`:74` for `lock_rotation` and `:83` for `lock_scale`).
|
|
490
|
+
* Measured 2026-09-21 in the rail before this: `lock_location` fell to the
|
|
491
|
+
* single-checkbox arm of {@link widgetFor}, so ONE unchecked box stood for
|
|
492
|
+
* three values — and its write was `setattr(ob, "lock_location", true)`, a
|
|
493
|
+
* scalar into a three-element array, which is a refusal rather than the lock
|
|
494
|
+
* the person asked for. Each toggle here writes its own index (the door's
|
|
495
|
+
* `index` argument, `session.py::rna_set`'s `getattr(target, id)[index]`). */
|
|
496
|
+
function ArrayRows({ row, label, title, write }: WidgetProps & { label: string; title: string }) {
|
|
497
|
+
const values = (row.value as readonly (number | boolean)[] | null) ?? [];
|
|
498
|
+
return (
|
|
499
|
+
<>
|
|
500
|
+
{values.map((value, index) => (
|
|
501
|
+
<Row
|
|
502
|
+
key={`${row.identifier}-${ARRAY_LETTERS[index] ?? index}`}
|
|
503
|
+
label={componentLabel(label, index, values.length)}
|
|
504
|
+
title={title}
|
|
505
|
+
readonly={row.readonly}
|
|
506
|
+
identifier={`${row.identifier}[${index}]`}
|
|
507
|
+
>
|
|
508
|
+
{row.type === 'BOOLEAN' ? (
|
|
509
|
+
<Checkbox
|
|
510
|
+
value={value === true}
|
|
511
|
+
readonly={row.readonly}
|
|
512
|
+
label={fieldName(row, index)}
|
|
513
|
+
onChange={(next) => write(next, index)}
|
|
514
|
+
/>
|
|
515
|
+
) : (
|
|
516
|
+
<Numeric
|
|
517
|
+
row={row}
|
|
518
|
+
value={Number(value)}
|
|
519
|
+
index={index}
|
|
520
|
+
onWrite={(next, at) => write(next, at)}
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
</Row>
|
|
524
|
+
))}
|
|
525
|
+
</>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function componentLabel(label: string, index: number, length: number): string {
|
|
530
|
+
if (length > ARRAY_LETTERS.length) return index === 0 ? label : `${label} ${index}`;
|
|
531
|
+
return `${index === 0 ? `${label} ` : ''}${ARRAY_LETTERS[index]}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function widgetFor(props: WidgetProps): React.ReactNode {
|
|
535
|
+
const { row, write } = props;
|
|
536
|
+
switch (row.type) {
|
|
537
|
+
case 'POINTER':
|
|
538
|
+
return <PointerWidget {...props} />;
|
|
539
|
+
case 'ENUM':
|
|
540
|
+
return <EnumWidget {...props} />;
|
|
541
|
+
case 'STRING':
|
|
542
|
+
return <StringWidget {...props} />;
|
|
543
|
+
case 'BOOLEAN':
|
|
544
|
+
return (
|
|
545
|
+
<Checkbox
|
|
546
|
+
value={row.value === true}
|
|
547
|
+
readonly={row.readonly}
|
|
548
|
+
label={fieldName(row)}
|
|
549
|
+
onChange={write}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
default:
|
|
553
|
+
return <Numeric row={row} value={Number(row.value ?? 0)} onWrite={(next) => write(next)} />;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function PropertyRow({
|
|
558
|
+
row,
|
|
559
|
+
path,
|
|
560
|
+
onOpen,
|
|
561
|
+
}: {
|
|
562
|
+
readonly row: BlenderRnaRow;
|
|
563
|
+
readonly path: string;
|
|
564
|
+
readonly onOpen: (path: string) => void;
|
|
565
|
+
}) {
|
|
566
|
+
const write = (next: unknown, index?: number) =>
|
|
567
|
+
void writeBlenderRnaProperty(path, row.identifier, next, index);
|
|
568
|
+
const label = row.name || row.identifier;
|
|
569
|
+
const title = `${row.identifier} — ${row.type}${row.subtype === 'NONE' ? '' : `/${row.subtype}`}${
|
|
570
|
+
row.readonly ? ' (read-only)' : ''
|
|
571
|
+
}${row.description ? `\n${row.description}` : ''}`;
|
|
572
|
+
const props: WidgetProps = { row, path, write, onOpen };
|
|
573
|
+
const unreadable = row.valueOmitted ?? row.valueError;
|
|
574
|
+
|
|
575
|
+
if (unreadable !== undefined)
|
|
576
|
+
return (
|
|
577
|
+
<Row label={label} title={title} readonly identifier={row.identifier}>
|
|
578
|
+
<StaticValue>{unreadable}</StaticValue>
|
|
579
|
+
</Row>
|
|
580
|
+
);
|
|
581
|
+
if (row.type === 'COLLECTION')
|
|
582
|
+
return (
|
|
583
|
+
<CollectionRow
|
|
584
|
+
label={label}
|
|
585
|
+
title={title}
|
|
586
|
+
value={(row.value ?? { count: 0 }) as BlenderRnaCollectionValue}
|
|
587
|
+
identifier={row.identifier}
|
|
588
|
+
onOpen={() => onOpen(`${path}.${row.identifier}`)}
|
|
589
|
+
/>
|
|
590
|
+
);
|
|
591
|
+
const numericArray = row.type === 'INT' || row.type === 'FLOAT';
|
|
592
|
+
if (row.arrayLength > 0 && (numericArray || row.type === 'BOOLEAN')) {
|
|
593
|
+
if (numericArray && (row.subtype === 'COLOR' || row.subtype === 'COLOR_GAMMA'))
|
|
594
|
+
return (
|
|
595
|
+
<Row label={label} title={title} readonly={row.readonly} identifier={row.identifier}>
|
|
596
|
+
<ColourWidget {...props} />
|
|
597
|
+
</Row>
|
|
598
|
+
);
|
|
599
|
+
return <ArrayRows {...props} label={label} title={title} />;
|
|
600
|
+
}
|
|
601
|
+
return (
|
|
602
|
+
<Row label={label} title={title} readonly={row.readonly} identifier={row.identifier}>
|
|
603
|
+
{widgetFor(props)}
|
|
604
|
+
</Row>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function TextField({
|
|
609
|
+
value,
|
|
610
|
+
label,
|
|
611
|
+
onWrite,
|
|
612
|
+
}: {
|
|
613
|
+
readonly value: string;
|
|
614
|
+
/** The row's own label, on the control — see {@link Checkbox}'s `label`. */
|
|
615
|
+
readonly label?: string;
|
|
616
|
+
readonly onWrite: (next: string) => void;
|
|
617
|
+
}) {
|
|
618
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
619
|
+
return (
|
|
620
|
+
<input
|
|
621
|
+
type="text"
|
|
622
|
+
{...(label === undefined ? {} : { 'aria-label': label })}
|
|
623
|
+
value={draft ?? value}
|
|
624
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
625
|
+
onBlur={() => {
|
|
626
|
+
if (draft !== null && draft !== value) onWrite(draft);
|
|
627
|
+
setDraft(null);
|
|
628
|
+
}}
|
|
629
|
+
onKeyDown={(event) => {
|
|
630
|
+
if (event.key === 'Enter') (event.target as HTMLInputElement).blur();
|
|
631
|
+
if (event.key === 'Escape') setDraft(null);
|
|
632
|
+
}}
|
|
633
|
+
style={{ ...fieldStyle, textAlign: 'left' }}
|
|
634
|
+
/>
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function CollectionRow({
|
|
639
|
+
label,
|
|
640
|
+
title,
|
|
641
|
+
value,
|
|
642
|
+
identifier,
|
|
643
|
+
onOpen,
|
|
644
|
+
}: {
|
|
645
|
+
readonly label: string;
|
|
646
|
+
readonly title: string;
|
|
647
|
+
readonly value: BlenderRnaCollectionValue;
|
|
648
|
+
readonly identifier: string;
|
|
649
|
+
readonly onOpen: () => void;
|
|
650
|
+
}) {
|
|
651
|
+
const [open, setOpen] = useState(false);
|
|
652
|
+
const names = value.names ?? [];
|
|
653
|
+
return (
|
|
654
|
+
<>
|
|
655
|
+
<Row label={label} title={title} readonly={false} identifier={identifier}>
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
aria-label={label}
|
|
659
|
+
onClick={() => (names.length ? setOpen(!open) : onOpen())}
|
|
660
|
+
style={{ ...fieldStyle, cursor: 'pointer', textAlign: 'left' }}
|
|
661
|
+
>
|
|
662
|
+
{names.length ? `${open ? '▼' : '▶'} ` : ''}
|
|
663
|
+
{value.count} {value.count === 1 ? 'item' : 'items'}
|
|
664
|
+
</button>
|
|
665
|
+
</Row>
|
|
666
|
+
{open
|
|
667
|
+
? names.map((name) => (
|
|
668
|
+
<Row key={name} label="" readonly>
|
|
669
|
+
<StaticValue>{name}</StaticValue>
|
|
670
|
+
</Row>
|
|
671
|
+
))
|
|
672
|
+
: null}
|
|
673
|
+
</>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---- the curated body ------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* ONE OF BLENDER'S OWN PANELS, as data.
|
|
681
|
+
*
|
|
682
|
+
* A tab module declares a list of these and the view draws them ABOVE the
|
|
683
|
+
* generic RNA view, which stays beneath under "All properties". The list is a
|
|
684
|
+
* transcription of the matching `scripts/startup/bl_ui/properties_*.py`:
|
|
685
|
+
* `bl_label` is the title, each `layout.prop(ob, "x")` is an entry of
|
|
686
|
+
* {@link properties} IN BLENDER'S ORDER, and a `bl_parent_id` panel is a
|
|
687
|
+
* {@link sub}. Blender's UI layer is never run, ported or recorded
|
|
688
|
+
* (ARCHITECTURE-CORE §Blender north star) — the Python file is read as the
|
|
689
|
+
* SPECIFICATION of which properties this tab shows and in what order, and
|
|
690
|
+
* what draws them is this file's own widgets over the RNA door's rows.
|
|
691
|
+
*
|
|
692
|
+
* WHAT IS DELIBERATELY NOT TRANSCRIBED: the `poll`/`if` conditions Blender's
|
|
693
|
+
* draw functions carry. A `properties_object.py` panel hides
|
|
694
|
+
* `show_all_edges` for a non-mesh by branching in Python; here the property is
|
|
695
|
+
* simply NOT ON the datablock RNA answered with, so it does not render and
|
|
696
|
+
* the panel header says how many of Blender's it found. That is the same
|
|
697
|
+
* outcome by a mechanism that cannot go stale — a transcribed condition is a
|
|
698
|
+
* copy of Blender's logic, which is the one thing the ruling forbids.
|
|
699
|
+
*/
|
|
700
|
+
/**
|
|
701
|
+
* ONE ENTRY OF A PANEL'S PROPERTY LIST.
|
|
702
|
+
*
|
|
703
|
+
* A bare identifier reads the panel's own datablock, which is the ordinary
|
|
704
|
+
* case. The qualified form is for the panels where BLENDER'S OWN draw
|
|
705
|
+
* function reads two structs into one list — `RENDER_PT_eevee_raytracing`
|
|
706
|
+
* (`properties_render.py:387`) draws `scene.eevee`'s `use_raytracing` and
|
|
707
|
+
* `ray_tracing_method` beside `scene.eevee.ray_tracing_options`'s
|
|
708
|
+
* `resolution_scale`, in that order, in one panel. Keeping the row in
|
|
709
|
+
* Blender's position while naming the datablock it actually comes from is
|
|
710
|
+
* what this is for; the alternative — a nested block per struct — would put
|
|
711
|
+
* the rows in an order Blender does not use.
|
|
712
|
+
*/
|
|
713
|
+
export type BlenderCuratedProperty = string | { readonly from: string; readonly property: string };
|
|
714
|
+
|
|
715
|
+
const identifierOf = (entry: BlenderCuratedProperty): string =>
|
|
716
|
+
typeof entry === 'string' ? entry : entry.property;
|
|
717
|
+
|
|
718
|
+
/** ONE TEST A BLENDER `poll` MAKES ON A VALUE ({@link BlenderCuratedPanel.when}). */
|
|
719
|
+
export interface BlenderPanelCondition {
|
|
720
|
+
/** Which of the tab's datablocks holds it, by the label `rna_context` gave
|
|
721
|
+
* it. The panel's own `from` when omitted. */
|
|
722
|
+
readonly from?: string;
|
|
723
|
+
readonly property: string;
|
|
724
|
+
/** The values Blender's `poll` accepts. */
|
|
725
|
+
readonly is?: readonly (string | number | boolean)[];
|
|
726
|
+
/** …or the ones it refuses (`part.child_type != 'NONE'`). */
|
|
727
|
+
readonly isNot?: readonly (string | number | boolean)[];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export interface BlenderCuratedPanel {
|
|
731
|
+
readonly title: string;
|
|
732
|
+
/** Which of the TAB's own datablocks this panel reads, by the label
|
|
733
|
+
* `session.py::rna_context` gave it ("Object", "Object Data", "Vertex
|
|
734
|
+
* Groups"…). The tab's first path when omitted. */
|
|
735
|
+
readonly from?: string;
|
|
736
|
+
/** RNA identifiers, in the order Blender's own draw function lists them. */
|
|
737
|
+
readonly properties: readonly BlenderCuratedProperty[];
|
|
738
|
+
/** Blender's `bl_options = {'DEFAULT_CLOSED'}`. */
|
|
739
|
+
readonly closed?: boolean;
|
|
740
|
+
/** A `bl_parent_id` child panel. */
|
|
741
|
+
readonly sub?: readonly BlenderCuratedPanel[];
|
|
742
|
+
/**
|
|
743
|
+
* THE DATABLOCK IS A COLLECTION, and the panel is Blender's `template_list`
|
|
744
|
+
* over it — the Vertex Groups, Shape Keys, UV Maps and Material Slots
|
|
745
|
+
* panels are all this shape. Each member becomes its own sub-panel of
|
|
746
|
+
* {@link properties}, and the member the context calls ACTIVE is marked, the
|
|
747
|
+
* way Blender's list highlights the active row.
|
|
748
|
+
*/
|
|
749
|
+
readonly collection?: boolean;
|
|
750
|
+
/**
|
|
751
|
+
* AND THEN EVERYTHING THE MEMBER'S OWN STRUCT DECLARES, after the named
|
|
752
|
+
* properties, in RNA's order.
|
|
753
|
+
*
|
|
754
|
+
* It exists for the modifier stack and its siblings, where Blender draws a
|
|
755
|
+
* per-TYPE panel: `properties_data_modifier.py` delegates to one draw
|
|
756
|
+
* function per modifier type, sixty-odd of them, each listing that type's
|
|
757
|
+
* own properties. Transcribing sixty lists is not a reading of the source,
|
|
758
|
+
* it is a copy of it — and RNA already says which struct declares each
|
|
759
|
+
* property (`BlenderRnaRow.group`), so "the type's own settings" is exactly
|
|
760
|
+
* the rows whose declaring struct is the member's own
|
|
761
|
+
* (`SubsurfModifier`, `ArmatureModifier`), with the shared `Modifier` base
|
|
762
|
+
* named above them. Derived, and it cannot go stale.
|
|
763
|
+
*/
|
|
764
|
+
readonly andDeclared?: boolean;
|
|
765
|
+
/**
|
|
766
|
+
* BLENDER'S `COMPAT_ENGINES`, the one `poll` that IS transcribed.
|
|
767
|
+
*
|
|
768
|
+
* Every other `poll` is dropped because RNA already answers it: a property
|
|
769
|
+
* a type does not carry is simply not in the door's rows, so the panel does
|
|
770
|
+
* not draw (see the note above). The engine polls are the exception that
|
|
771
|
+
* proves the rule — `scene.eevee` and `scene.display` exist under EVERY
|
|
772
|
+
* engine, so nothing about the datablock says whether Blender would draw
|
|
773
|
+
* the panel. The condition is `context.engine in cls.COMPAT_ENGINES`
|
|
774
|
+
* (`properties_render.py`, once per panel class), and what is read here is
|
|
775
|
+
* the ENGINE's own answer to it: `rna_context`'s `engine`, which is
|
|
776
|
+
* `scene.render.engine`. Not a copy of Blender's logic — a copy of
|
|
777
|
+
* Blender's LIST, checked against the engine's live value.
|
|
778
|
+
*/
|
|
779
|
+
readonly engine?: readonly string[];
|
|
780
|
+
/**
|
|
781
|
+
* A `poll` THAT TESTS A VALUE, for the same reason: `part.physics_type ==
|
|
782
|
+
* 'BOIDS'`, `tex.type == 'CLOUDS'`, `rbo.type == 'ACTIVE'`. These panels
|
|
783
|
+
* are about a MODE the datablock is in, and every mode's properties live on
|
|
784
|
+
* the same struct, so RNA cannot answer it either. Each condition names a
|
|
785
|
+
* property of one of the tab's datablocks and the values Blender's own
|
|
786
|
+
* `poll` accepts; all of them must hold. A condition whose datablock has
|
|
787
|
+
* not been read yet holds the panel back rather than guessing.
|
|
788
|
+
*/
|
|
789
|
+
readonly when?: readonly BlenderPanelCondition[];
|
|
790
|
+
/** Which of the context's active handles marks a row of this collection —
|
|
791
|
+
* `activeVertexGroup`, `activeMaterial`, `activeModifier`,
|
|
792
|
+
* `activeShapeKey`, `activeConstraint`, `activeParticleSystem`,
|
|
793
|
+
* `activeBone`. Read only with {@link collection}. */
|
|
794
|
+
readonly active?: keyof BlenderRnaContext;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* WHICH MEMBER OF THIS COLLECTION THE CONTEXT CALLS ACTIVE — by address when
|
|
799
|
+
* RNA gives the member one, and by INDEX when it does not.
|
|
800
|
+
*
|
|
801
|
+
* The index half is not a convenience, it is the only answer for two of the
|
|
802
|
+
* three lists that need one, and the live walk is what said so (2026-09-19):
|
|
803
|
+
* a `VertexGroup` has no `path_from_id()` at all — I1 recorded that — so
|
|
804
|
+
* `activeVertexGroup` carries only its index, and `activeMaterial` carries the
|
|
805
|
+
* MATERIAL's address while the list being drawn is of material SLOTS, so its
|
|
806
|
+
* path never matches a row. Both marked nothing. `rna_context` reports
|
|
807
|
+
* `index` for exactly those two (`session.py::rna_context`), which is what
|
|
808
|
+
* Blender's own `template_list` highlights by.
|
|
809
|
+
*/
|
|
810
|
+
function activeMember(panel: BlenderCuratedPanel): { path: string | null; index: number | null } {
|
|
811
|
+
if (panel.active === undefined) return { path: null, index: null };
|
|
812
|
+
const handle = blenderPropertiesState().context?.[panel.active];
|
|
813
|
+
if (typeof handle !== 'object' || handle === null) return { path: null, index: null };
|
|
814
|
+
const record = handle as { path?: string | null; index?: number };
|
|
815
|
+
return {
|
|
816
|
+
path: record.path ?? null,
|
|
817
|
+
index: typeof record.index === 'number' ? record.index : null,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function CuratedCollectionPanel({
|
|
822
|
+
panel,
|
|
823
|
+
path,
|
|
824
|
+
onOpen,
|
|
825
|
+
}: {
|
|
826
|
+
readonly panel: BlenderCuratedPanel;
|
|
827
|
+
readonly path: string;
|
|
828
|
+
readonly onOpen: (path: string) => void;
|
|
829
|
+
}) {
|
|
830
|
+
useBlenderProperties();
|
|
831
|
+
const view = blenderRnaViewFor(path);
|
|
832
|
+
const active = activeMember(panel);
|
|
833
|
+
if (view === undefined)
|
|
834
|
+
return (
|
|
835
|
+
<Panel title={panel.title} closed={panel.closed === true}>
|
|
836
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>Reading {path}…</div>
|
|
837
|
+
</Panel>
|
|
838
|
+
);
|
|
839
|
+
if (view.kind !== 'collection')
|
|
840
|
+
return (
|
|
841
|
+
<Panel title={panel.title} closed={panel.closed === true} note={path}>
|
|
842
|
+
<div style={{ color: ERROR_INK, fontSize: FONT }}>
|
|
843
|
+
{path} is not a collection — this panel is declared `collection: true`.
|
|
844
|
+
</div>
|
|
845
|
+
</Panel>
|
|
846
|
+
);
|
|
847
|
+
return (
|
|
848
|
+
<Panel title={panel.title} closed={panel.closed === true} note={path} count={view.count}>
|
|
849
|
+
{view.items.length === 0 ? (
|
|
850
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>None</div>
|
|
851
|
+
) : null}
|
|
852
|
+
{view.items.map((item, index) => (
|
|
853
|
+
<Panel
|
|
854
|
+
key={item.path ?? item.name ?? item.type}
|
|
855
|
+
title={`${
|
|
856
|
+
(item.path !== null && item.path === active.path) || index === active.index ? '● ' : ''
|
|
857
|
+
}${item.name ?? item.type}`}
|
|
858
|
+
sub
|
|
859
|
+
>
|
|
860
|
+
{item.path === null ? (
|
|
861
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
|
|
862
|
+
{item.type} — RNA gives this member no address.
|
|
863
|
+
</div>
|
|
864
|
+
) : (
|
|
865
|
+
<CuratedRows
|
|
866
|
+
path={item.path}
|
|
867
|
+
properties={panel.properties}
|
|
868
|
+
andDeclared={panel.andDeclared === true}
|
|
869
|
+
onOpen={onOpen}
|
|
870
|
+
/>
|
|
871
|
+
)}
|
|
872
|
+
</Panel>
|
|
873
|
+
))}
|
|
874
|
+
</Panel>
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** A named property list over one addressed struct — the body both curated
|
|
879
|
+
* panel shapes draw. */
|
|
880
|
+
function CuratedRows({
|
|
881
|
+
path,
|
|
882
|
+
properties,
|
|
883
|
+
andDeclared = false,
|
|
884
|
+
onOpen,
|
|
885
|
+
}: {
|
|
886
|
+
readonly path: string;
|
|
887
|
+
readonly properties: readonly BlenderCuratedProperty[];
|
|
888
|
+
readonly andDeclared?: boolean;
|
|
889
|
+
readonly onOpen: (path: string) => void;
|
|
890
|
+
}) {
|
|
891
|
+
useBlenderProperties();
|
|
892
|
+
const view = blenderRnaViewFor(path);
|
|
893
|
+
if (view === undefined)
|
|
894
|
+
return (
|
|
895
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>Reading {path}…</div>
|
|
896
|
+
);
|
|
897
|
+
const rows = view.kind === 'struct' ? view.groups.flatMap((group) => group.rows) : [];
|
|
898
|
+
const byIdentifier = new Map(rows.map((row) => [row.identifier, row]));
|
|
899
|
+
// A collection member is addressed by its own path, so a qualified entry is
|
|
900
|
+
// read here as the identifier alone — the datablock it names belongs to the
|
|
901
|
+
// TAB, and a member of a list is not one of those.
|
|
902
|
+
const named = properties
|
|
903
|
+
.map((entry) => byIdentifier.get(identifierOf(entry)))
|
|
904
|
+
.filter((row): row is BlenderRnaRow => row !== undefined);
|
|
905
|
+
// The member's OWN struct's rows — a `SubsurfModifier`'s, not the shared
|
|
906
|
+
// `Modifier` base's, which the named list above carries.
|
|
907
|
+
const declared =
|
|
908
|
+
andDeclared && view.kind === 'struct'
|
|
909
|
+
? shown(rows).filter(
|
|
910
|
+
(row) =>
|
|
911
|
+
row.group === view.type &&
|
|
912
|
+
!properties.some((entry) => identifierOf(entry) === row.identifier),
|
|
913
|
+
)
|
|
914
|
+
: [];
|
|
915
|
+
return (
|
|
916
|
+
<>
|
|
917
|
+
{[...named, ...declared].map((row) => (
|
|
918
|
+
<PropertyRow key={row.identifier} row={row} path={path} onOpen={onOpen} />
|
|
919
|
+
))}
|
|
920
|
+
</>
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* DOES BLENDER'S `poll` STAND? Three answers, because "not yet read" is not
|
|
926
|
+
* "no": `true` draw, `false` do not, `null` the engine has not answered yet.
|
|
927
|
+
*/
|
|
928
|
+
function panelStands(
|
|
929
|
+
panel: BlenderCuratedPanel,
|
|
930
|
+
paths: readonly { readonly label: string; readonly path: string }[],
|
|
931
|
+
): boolean | null {
|
|
932
|
+
if (panel.engine !== undefined) {
|
|
933
|
+
const engine = blenderPropertiesState().context?.engine;
|
|
934
|
+
if (engine === undefined) return null;
|
|
935
|
+
if (!panel.engine.includes(engine)) return false;
|
|
936
|
+
}
|
|
937
|
+
for (const condition of panel.when ?? []) {
|
|
938
|
+
const label = condition.from ?? panel.from;
|
|
939
|
+
const entry = label ? paths.find((item) => item.label === label) : paths[0];
|
|
940
|
+
if (entry === undefined) return false;
|
|
941
|
+
const view = blenderRnaViewFor(entry.path);
|
|
942
|
+
if (view === undefined) return null;
|
|
943
|
+
if (view.kind !== 'struct') return false;
|
|
944
|
+
const row = view.groups
|
|
945
|
+
.flatMap((group) => group.rows)
|
|
946
|
+
.find((item) => item.identifier === condition.property);
|
|
947
|
+
if (row === undefined) return false;
|
|
948
|
+
const value = row.value;
|
|
949
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
950
|
+
return false;
|
|
951
|
+
if (condition.is !== undefined && !condition.is.includes(value)) return false;
|
|
952
|
+
if (condition.isNot !== undefined && condition.isNot.includes(value)) return false;
|
|
953
|
+
}
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function CuratedPanel({
|
|
958
|
+
panel,
|
|
959
|
+
paths,
|
|
960
|
+
onOpen,
|
|
961
|
+
}: {
|
|
962
|
+
readonly panel: BlenderCuratedPanel;
|
|
963
|
+
readonly paths: readonly { readonly label: string; readonly path: string }[];
|
|
964
|
+
readonly onOpen: (path: string) => void;
|
|
965
|
+
}) {
|
|
966
|
+
useBlenderProperties();
|
|
967
|
+
if (panelStands(panel, paths) !== true) return null;
|
|
968
|
+
const entry = panel.from ? paths.find((item) => item.label === panel.from) : paths[0];
|
|
969
|
+
if (entry !== undefined && panel.collection === true)
|
|
970
|
+
return <CuratedCollectionPanel panel={panel} path={entry.path} onOpen={onOpen} />;
|
|
971
|
+
// A panel whose datablock this context does not carry is not drawn at all —
|
|
972
|
+
// the same answer Blender's `poll` gives, from the door rather than from a
|
|
973
|
+
// copy of its conditions.
|
|
974
|
+
if (entry === undefined) return null;
|
|
975
|
+
const view = blenderRnaViewFor(entry.path);
|
|
976
|
+
const sub = panel.sub ?? [];
|
|
977
|
+
// EACH ENTRY IS READ FROM THE DATABLOCK BLENDER READS IT FROM: the panel's
|
|
978
|
+
// own, or the one a qualified entry names ({@link BlenderCuratedProperty}).
|
|
979
|
+
// Order is Blender's list, whichever struct a row comes from.
|
|
980
|
+
const found: { identifier: string; row: BlenderRnaRow; path: string }[] = [];
|
|
981
|
+
const missing: string[] = [];
|
|
982
|
+
for (const item of panel.properties) {
|
|
983
|
+
const identifier = identifierOf(item);
|
|
984
|
+
const where = typeof item === 'string' ? entry : paths.find((path) => path.label === item.from);
|
|
985
|
+
const source = where === undefined ? undefined : blenderRnaViewFor(where.path);
|
|
986
|
+
const row =
|
|
987
|
+
source?.kind === 'struct'
|
|
988
|
+
? source.groups.flatMap((group) => group.rows).find((it) => it.identifier === identifier)
|
|
989
|
+
: undefined;
|
|
990
|
+
if (row === undefined || where === undefined) missing.push(identifier);
|
|
991
|
+
else found.push({ identifier, row, path: where.path });
|
|
992
|
+
}
|
|
993
|
+
if (view === undefined)
|
|
994
|
+
return (
|
|
995
|
+
<Panel title={panel.title} closed={panel.closed === true}>
|
|
996
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
|
|
997
|
+
Reading {entry.path}…
|
|
998
|
+
</div>
|
|
999
|
+
</Panel>
|
|
1000
|
+
);
|
|
1001
|
+
if (found.length === 0 && sub.length === 0) return null;
|
|
1002
|
+
return (
|
|
1003
|
+
<Panel
|
|
1004
|
+
title={panel.title}
|
|
1005
|
+
closed={panel.closed === true}
|
|
1006
|
+
note={
|
|
1007
|
+
missing.length === 0
|
|
1008
|
+
? `${entry.path}`
|
|
1009
|
+
: `${entry.path}\nNot on this datablock: ${missing.join(', ')}`
|
|
1010
|
+
}
|
|
1011
|
+
>
|
|
1012
|
+
{found.map((item) => (
|
|
1013
|
+
<PropertyRow key={item.identifier} row={item.row} path={item.path} onOpen={onOpen} />
|
|
1014
|
+
))}
|
|
1015
|
+
{/* KEYED BY POSITION, NOT BY TITLE: Blender ships two panels with the
|
|
1016
|
+
same `bl_label` under different engines and expects the poll to pick
|
|
1017
|
+
one — Render's "Sampling" (EEVEE `:675`, Workbench `:1004`) and
|
|
1018
|
+
"Film" (`:757`, `:1025`), View Layer's "Data" (`:111`, `:137`). A
|
|
1019
|
+
title key made those React duplicates, which the live walk found by
|
|
1020
|
+
name ("Encountered two children with the same key"). */}
|
|
1021
|
+
{sub.map((child, index) => (
|
|
1022
|
+
<CuratedPanel key={`${index}:${child.title}`} panel={child} paths={paths} onOpen={onOpen} />
|
|
1023
|
+
))}
|
|
1024
|
+
</Panel>
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ---- one addressed datablock ----------------------------------------------
|
|
1029
|
+
|
|
1030
|
+
function DatablockView({
|
|
1031
|
+
path,
|
|
1032
|
+
onOpen,
|
|
1033
|
+
}: {
|
|
1034
|
+
readonly path: string;
|
|
1035
|
+
readonly onOpen: (path: string) => void;
|
|
1036
|
+
}) {
|
|
1037
|
+
useBlenderProperties();
|
|
1038
|
+
const view: BlenderRnaView | undefined = blenderRnaViewFor(path);
|
|
1039
|
+
if (view === undefined)
|
|
1040
|
+
return (
|
|
1041
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT, padding: PANEL_PAD }}>
|
|
1042
|
+
Reading {path}…
|
|
1043
|
+
</div>
|
|
1044
|
+
);
|
|
1045
|
+
if (view.kind === 'collection')
|
|
1046
|
+
return (
|
|
1047
|
+
<>
|
|
1048
|
+
{view.items.length === 0 ? (
|
|
1049
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
|
|
1050
|
+
{view.count === 0 ? 'None' : `${view.count} items`}
|
|
1051
|
+
</div>
|
|
1052
|
+
) : null}
|
|
1053
|
+
{view.items.map((item) => (
|
|
1054
|
+
<Panel key={item.path ?? item.name ?? item.type} title={item.name ?? item.type} sub>
|
|
1055
|
+
{item.path === null ? (
|
|
1056
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT }}>
|
|
1057
|
+
{item.type} — RNA gives this member no address.
|
|
1058
|
+
</div>
|
|
1059
|
+
) : (
|
|
1060
|
+
<DatablockView path={item.path} onOpen={onOpen} />
|
|
1061
|
+
)}
|
|
1062
|
+
</Panel>
|
|
1063
|
+
))}
|
|
1064
|
+
</>
|
|
1065
|
+
);
|
|
1066
|
+
return (
|
|
1067
|
+
<>
|
|
1068
|
+
{view.groups.map((group) => {
|
|
1069
|
+
const rows = shown(group.rows);
|
|
1070
|
+
if (rows.length === 0) return null;
|
|
1071
|
+
return (
|
|
1072
|
+
<Panel key={group.id} title={group.label} sub>
|
|
1073
|
+
{rows.map((row) => (
|
|
1074
|
+
<PropertyRow key={row.identifier} row={row} path={view.path} onOpen={onOpen} />
|
|
1075
|
+
))}
|
|
1076
|
+
</Panel>
|
|
1077
|
+
);
|
|
1078
|
+
})}
|
|
1079
|
+
</>
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ---- the section body ------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* ONE PROPERTIES TAB'S BODY. `tabId` names the tab this section IS; the
|
|
1087
|
+
* context door says which datablocks that tab shows and in what order
|
|
1088
|
+
* (`session.py::rna_context`, mirrored from `buttons_context.cc`).
|
|
1089
|
+
*
|
|
1090
|
+
* A pointer row DRILLS: clicking it replaces the shown datablock with the
|
|
1091
|
+
* target, and the breadcrumb walks back. That is the "pointer → the target's
|
|
1092
|
+
* name as a link that changes the active datablock" half of I1, and it is what
|
|
1093
|
+
* makes `object.data.materials[0].node_tree` reachable with no per-tab code.
|
|
1094
|
+
*/
|
|
1095
|
+
export function BlenderPropertiesSection({
|
|
1096
|
+
tabId,
|
|
1097
|
+
subject,
|
|
1098
|
+
curated = [],
|
|
1099
|
+
}: {
|
|
1100
|
+
readonly tabId: string;
|
|
1101
|
+
readonly subject: BlenderSubject;
|
|
1102
|
+
/** Blender's own panels for this tab, in Blender's order
|
|
1103
|
+
* ({@link BlenderCuratedPanel}). Empty — the tab has not been curated yet —
|
|
1104
|
+
* and the generic view is the whole body, uncollapsed. */
|
|
1105
|
+
readonly curated?: readonly BlenderCuratedPanel[];
|
|
1106
|
+
}) {
|
|
1107
|
+
useBlenderProperties();
|
|
1108
|
+
useEffect(() => showBlenderSubject(subject), [subject]);
|
|
1109
|
+
const [drill, setDrill] = useState<string[]>([]);
|
|
1110
|
+
const state = blenderPropertiesState();
|
|
1111
|
+
const tab = state.context?.tabs.find((entry) => entry.id === tabId) ?? null;
|
|
1112
|
+
|
|
1113
|
+
// A drill belongs to the datablock it was opened from; a new subject or a
|
|
1114
|
+
// tab whose paths moved starts at the top again.
|
|
1115
|
+
const paths = tab?.paths ?? [];
|
|
1116
|
+
const anchor = paths.map((entry) => entry.path).join('|');
|
|
1117
|
+
useEffect(() => setDrill([]), [anchor]);
|
|
1118
|
+
|
|
1119
|
+
if (state.error !== null)
|
|
1120
|
+
return (
|
|
1121
|
+
<div style={{ color: ERROR_INK, fontSize: FONT, padding: PANEL_PAD }}>{state.error}</div>
|
|
1122
|
+
);
|
|
1123
|
+
if (tab === null)
|
|
1124
|
+
return (
|
|
1125
|
+
<div style={{ color: INK, opacity: READONLY_ALPHA, fontSize: FONT, padding: PANEL_PAD }}>
|
|
1126
|
+
{state.loading ? 'Reading the engine…' : 'Blender shows no properties here.'}
|
|
1127
|
+
</div>
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
const open = (path: string) => setDrill((held) => [...held, path]);
|
|
1131
|
+
const drilled = drill[drill.length - 1];
|
|
1132
|
+
|
|
1133
|
+
return (
|
|
1134
|
+
<div style={{ padding: `${PANEL_PAD}px 0` }}>
|
|
1135
|
+
{drill.length > 0 ? (
|
|
1136
|
+
<button
|
|
1137
|
+
type="button"
|
|
1138
|
+
onClick={() => setDrill((held) => held.slice(0, -1))}
|
|
1139
|
+
style={{
|
|
1140
|
+
...fieldStyle,
|
|
1141
|
+
width: 'auto',
|
|
1142
|
+
marginBottom: PANEL_PAD,
|
|
1143
|
+
cursor: 'pointer',
|
|
1144
|
+
textAlign: 'left',
|
|
1145
|
+
}}
|
|
1146
|
+
title={drilled}
|
|
1147
|
+
>
|
|
1148
|
+
{'← '}
|
|
1149
|
+
{tab.label}
|
|
1150
|
+
</button>
|
|
1151
|
+
) : null}
|
|
1152
|
+
{drilled !== undefined ? (
|
|
1153
|
+
<Panel title={drilled.slice(drilled.lastIndexOf('.') + 1)}>
|
|
1154
|
+
<DatablockView path={drilled} onOpen={open} />
|
|
1155
|
+
</Panel>
|
|
1156
|
+
) : (
|
|
1157
|
+
<>
|
|
1158
|
+
{curated.map((panel, index) => (
|
|
1159
|
+
// Position, not title — see the note on the sub-panel map above.
|
|
1160
|
+
<CuratedPanel
|
|
1161
|
+
key={`${index}:${panel.title}`}
|
|
1162
|
+
panel={panel}
|
|
1163
|
+
paths={paths}
|
|
1164
|
+
onOpen={open}
|
|
1165
|
+
/>
|
|
1166
|
+
))}
|
|
1167
|
+
{/* THE GENERIC VIEW STAYS BENEATH. A curated body is Blender's own
|
|
1168
|
+
panels, which are a SELECTION of the datablock's RNA — the rest
|
|
1169
|
+
of it is still the same data and still worth reading, so it sits
|
|
1170
|
+
here collapsed rather than being hidden. On an uncurated tab
|
|
1171
|
+
there is nothing above and this is simply the body, open. */}
|
|
1172
|
+
{curated.length > 0 ? (
|
|
1173
|
+
<Panel title="All properties" closed>
|
|
1174
|
+
{paths.map((entry) => (
|
|
1175
|
+
<Panel key={entry.path} title={entry.label} sub>
|
|
1176
|
+
<DatablockView path={entry.path} onOpen={open} />
|
|
1177
|
+
</Panel>
|
|
1178
|
+
))}
|
|
1179
|
+
</Panel>
|
|
1180
|
+
) : (
|
|
1181
|
+
paths.map((entry) => (
|
|
1182
|
+
<Panel key={entry.path} title={entry.label}>
|
|
1183
|
+
<DatablockView path={entry.path} onOpen={open} />
|
|
1184
|
+
</Panel>
|
|
1185
|
+
))
|
|
1186
|
+
)}
|
|
1187
|
+
</>
|
|
1188
|
+
)}
|
|
1189
|
+
</div>
|
|
1190
|
+
);
|
|
1191
|
+
}
|