@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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SOURCE of `blender.palette.json` — Blender's own default theme, read
|
|
3
|
+
* from Blender's own theme TABLE rather than eyedropped from a screenshot
|
|
4
|
+
* (WORK.md §Blender in the tab is Blender, "Inspection parity", I2 decision 7;
|
|
5
|
+
* ARCHITECTURE-CORE §Blender north star, "The reference is Blender's SOURCE as
|
|
6
|
+
* well as its frames").
|
|
7
|
+
*
|
|
8
|
+
* Run `node packages/blender/contributions/blender-palette.source.mjs` to
|
|
9
|
+
* rewrite the JSON; `--check` reports what would change and exits non-zero if
|
|
10
|
+
* anything would. It reads
|
|
11
|
+
* `release/datafiles/userdef/userdef_default_theme.c` from a Blender checkout
|
|
12
|
+
* at the engine's pin (`$BLENDER_SRC`, default `~/volter/blender-src`; the
|
|
13
|
+
* checkout recipe is in WORK.md §Blender in the tab is Blender, "Inspection
|
|
14
|
+
* parity"). That file is itself generated — `tools/utils/blender_theme_as_c.py`
|
|
15
|
+
* writes it from the shipped theme — so it is the closest thing Blender has to
|
|
16
|
+
* a machine-readable palette.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this exists
|
|
19
|
+
*
|
|
20
|
+
* Every value in `blender.palette.json` used to be eyedropped off a reference
|
|
21
|
+
* frame, and I1 measured the drift that produces: `surface.panel` carried
|
|
22
|
+
* `#2f2f2f` where `.space_properties.back` is `#303030`, and `content.primary`
|
|
23
|
+
* `#e5e5e5` against `.text`'s `#e6e6e6`. One level each — invisible, and
|
|
24
|
+
* exactly the kind of error an eyedropper makes (a screenshot is a COMPOSITE:
|
|
25
|
+
* a panel fill sits under a hairline outline at 6.7% white, and a PNG's own
|
|
26
|
+
* colour management can move a level besides). The source has no such
|
|
27
|
+
* ambiguity.
|
|
28
|
+
*
|
|
29
|
+
* ## What is NOT derived, and why each one
|
|
30
|
+
*
|
|
31
|
+
* A key is derived only when its meaning is EXACTLY one theme member. The
|
|
32
|
+
* rest are stated as literals with their reason, because a false citation is
|
|
33
|
+
* worse than an honest measurement:
|
|
34
|
+
*
|
|
35
|
+
* - a COMPOSITE Blender draws by blending two members (the viewport's axis
|
|
36
|
+
* lines) — the source value is an input to what you see, not what you see;
|
|
37
|
+
* - a member Blender declares with an ALPHA it composites over a surface the
|
|
38
|
+
* palette does not carry (a region header, a popover back);
|
|
39
|
+
* - a semantic colour Blender's theme has no member for at all (our
|
|
40
|
+
* danger/warning/success set, which is the editor's own vocabulary).
|
|
41
|
+
*
|
|
42
|
+
* Nothing here invents a colour: every literal below names where it came from.
|
|
43
|
+
*/
|
|
44
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { homedir } from 'node:os';
|
|
46
|
+
|
|
47
|
+
// ------------------------------------------------- Blender's theme table
|
|
48
|
+
|
|
49
|
+
const CHECKOUT = process.env['BLENDER_SRC'] ?? `${homedir()}/volter/blender-src`;
|
|
50
|
+
const THEME = 'release/datafiles/userdef/userdef_default_theme.c';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `userdef_default_theme.c` as `{ 'tui.panel_header': { hex, alpha, line } }`.
|
|
54
|
+
*
|
|
55
|
+
* The file is machine-generated and utterly regular — one `.member = {` per
|
|
56
|
+
* nesting level, one `.member = RGBA(0x…)` per colour — so this is a brace
|
|
57
|
+
* walk rather than a C parser. A member the walk does not recognise is
|
|
58
|
+
* skipped; a member the TABLE below names and the walk did not find is a hard
|
|
59
|
+
* error, which is what keeps this honest when Blender renames one.
|
|
60
|
+
*/
|
|
61
|
+
function readTheme() {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
text = readFileSync(`${CHECKOUT}/${THEME}`, 'utf8');
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${THEME} is not in the Blender checkout at ${CHECKOUT}. Set BLENDER_SRC, or add the ` +
|
|
68
|
+
'directory to the sparse checkout (`git sparse-checkout add release/datafiles/userdef`) ' +
|
|
69
|
+
'and re-run the LFS-off checkout line in WORK.md §Blender in the tab is Blender.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const members = {};
|
|
73
|
+
const stack = [];
|
|
74
|
+
text.split('\n').forEach((line, index) => {
|
|
75
|
+
const open = /^\s*\.(\w+)\s*=\s*\{\s*$/.exec(line);
|
|
76
|
+
if (open !== null) {
|
|
77
|
+
stack.push(open[1]);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (/^\s*\},?\s*$/.test(line)) {
|
|
81
|
+
stack.pop();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const value = /^\s*\.(\w+)\s*=\s*RGBA?\(0x([0-9a-fA-F]+)\)/.exec(line);
|
|
85
|
+
if (value === null) return;
|
|
86
|
+
const digits = value[2].padStart(8, '0');
|
|
87
|
+
members[[...stack, value[1]].join('.')] = {
|
|
88
|
+
hex: `#${digits.slice(0, 6).toLowerCase()}`,
|
|
89
|
+
alpha: Number.parseInt(digits.slice(6, 8), 16) / 255,
|
|
90
|
+
line: index + 1,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
return members;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ------------------------------------------------------------ the mapping
|
|
97
|
+
|
|
98
|
+
/** A theme member, taken OPAQUE: Blender's `back` members carry an alpha byte
|
|
99
|
+
* that the region fill does not use (`.space_properties.back` is `0x30303000`
|
|
100
|
+
* and the properties editor is plainly not transparent), and a palette
|
|
101
|
+
* surface is one colour. */
|
|
102
|
+
const rgb = (member) => ({ member });
|
|
103
|
+
/** A theme member taken WITH its alpha — for the members whose alpha is the
|
|
104
|
+
* point (`.tui.widget_emboss`, `.tui.panel_outline`). */
|
|
105
|
+
const rgba = (member) => ({ member, alpha: true });
|
|
106
|
+
/** A value the theme has no single member for. `why` is mandatory. */
|
|
107
|
+
const held = (value, why) => ({ value, why });
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* PALETTE KEY → THEME MEMBER. Dotted on both sides; the left is a path into
|
|
111
|
+
* `theme.color`, the right a path into `U_theme_default`.
|
|
112
|
+
*/
|
|
113
|
+
const MAPPING = {
|
|
114
|
+
'surface.shell': rgb('tui.editor_border'),
|
|
115
|
+
'surface.panel': rgb('space_properties.back'),
|
|
116
|
+
'surface.chrome': held(
|
|
117
|
+
'#1c1c1c',
|
|
118
|
+
'a region HEADER is `.header` at alpha 0xb3 (`space_properties.header`) composited over the ' +
|
|
119
|
+
'window, and this palette carries ONE opaque colour for every header in the editor. ' +
|
|
120
|
+
'Measured on the reference frames.',
|
|
121
|
+
),
|
|
122
|
+
'surface.raised': rgb('tui.wcol_regular.inner'),
|
|
123
|
+
'surface.inset': rgb('tui.wcol_text.inner'),
|
|
124
|
+
'surface.overlay': held(
|
|
125
|
+
'rgba(48,48,48,0.94)',
|
|
126
|
+
'a popover in the source is `.tui.wcol_menu_back.inner` #181818 opaque; ours floats over the ' +
|
|
127
|
+
'editor and the frames measure it as the properties back at 94%. Our own compositing, not ' +
|
|
128
|
+
"Blender's.",
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
'boundary.default': rgb('tui.wcol_regular.outline'),
|
|
132
|
+
'boundary.strong': rgb('tui.wcol_regular.outline'),
|
|
133
|
+
'boundary.area': rgb('tui.editor_border'),
|
|
134
|
+
'boundary.indent': held(
|
|
135
|
+
'#656565',
|
|
136
|
+
"the Outliner's indent guides, which `outliner_draw.cc` draws as a shade of the row rather " +
|
|
137
|
+
'than from a member of its own. Measured on `outliner.png`.',
|
|
138
|
+
),
|
|
139
|
+
'boundary.divider': held(
|
|
140
|
+
'#2e2e2e',
|
|
141
|
+
'the rule inside one region, which Blender draws as a shade of the region back rather than ' +
|
|
142
|
+
'from a member of its own. Measured on the reference frames.',
|
|
143
|
+
),
|
|
144
|
+
|
|
145
|
+
'content.primary': rgb('tui.wcol_regular.text'),
|
|
146
|
+
'content.muted': rgb('space_outliner.text'),
|
|
147
|
+
'content.dim': held(
|
|
148
|
+
'#969696',
|
|
149
|
+
'Blender dims text by drawing the same `.text` at reduced alpha (`widget_alpha_factor`), not ' +
|
|
150
|
+
'from a second member. #969696 is `.tui.wcol_regular.text` #e6e6e6 at 0.5 over ' +
|
|
151
|
+
'`.space_properties.back` #303030.',
|
|
152
|
+
),
|
|
153
|
+
'content.onAccent': rgb('tui.wcol_regular.text_sel'),
|
|
154
|
+
'content.menu': rgb('tui.wcol_menu_item.text'),
|
|
155
|
+
'content.status': rgb('space_statusbar.header_text'),
|
|
156
|
+
'content.placeholder': held(
|
|
157
|
+
'#5e5e5e',
|
|
158
|
+
"Blender's fields draw no placeholder text at all; this is the editor's own affordance, set " +
|
|
159
|
+
'between `.tui.wcol_text.inner` and `.text`.',
|
|
160
|
+
),
|
|
161
|
+
'content.active': rgb('space_view3d.active'),
|
|
162
|
+
'content.selected': rgb('space_outliner.selected_object'),
|
|
163
|
+
'content.onBright.primary': held(
|
|
164
|
+
'#1d1d1d',
|
|
165
|
+
'`.tui.wcol_regular.item` #1d1d1d — the ink Blender puts ON a filled widget — read opaque.',
|
|
166
|
+
),
|
|
167
|
+
'content.onBright.muted': held('rgba(29,29,29,0.82)', 'the same ink, our own two dim steps.'),
|
|
168
|
+
'content.onBright.dim': held('rgba(29,29,29,0.7)', 'the same ink, our own two dim steps.'),
|
|
169
|
+
|
|
170
|
+
'accent.default': rgb('tui.wcol_regular.inner_sel'),
|
|
171
|
+
'accent.muted': rgb('tui.wcol_regular.inner_sel'),
|
|
172
|
+
|
|
173
|
+
'widget.regular': rgb('tui.wcol_regular.inner'),
|
|
174
|
+
'widget.menu': rgb('tui.wcol_menu.inner'),
|
|
175
|
+
'widget.field': rgb('tui.wcol_text.inner'),
|
|
176
|
+
'widget.emboss': rgba('tui.widget_emboss'),
|
|
177
|
+
|
|
178
|
+
'viewport.background': rgb('space_view3d.back'),
|
|
179
|
+
'viewport.grid': rgb('space_view3d.grid_major'),
|
|
180
|
+
'viewport.axisX': held(
|
|
181
|
+
'#cb293f',
|
|
182
|
+
"the grid's axis line is a BLEND of `.space_view3d.grid_major` and `.tui.xaxis` #ff3352 " +
|
|
183
|
+
'(the overlay engine blends them per line; `draw/engines/overlay` is outside the sparse ' +
|
|
184
|
+
'checkout). The source value is an input to what is drawn, not what is drawn — measured.',
|
|
185
|
+
),
|
|
186
|
+
'viewport.axisY': held(
|
|
187
|
+
'#69aa15',
|
|
188
|
+
'the same blend over `.tui.yaxis` #8bdc00 — measured, see axisX.',
|
|
189
|
+
),
|
|
190
|
+
// There is no `viewport.axisZ`: `EditorTheme.color.viewport` declares X and
|
|
191
|
+
// Y only (`packages/editor/src/theme.ts`), because the editor's grid plane
|
|
192
|
+
// draws two axis lines. `.tui.zaxis` #2890ff is the member a third would
|
|
193
|
+
// cite when one exists — a palette key no reader reads is a name with no
|
|
194
|
+
// caller.
|
|
195
|
+
'viewport.selection': rgb('space_view3d.select'),
|
|
196
|
+
'viewport.active': rgb('space_view3d.active'),
|
|
197
|
+
|
|
198
|
+
'region.outliner': rgb('space_outliner.back'),
|
|
199
|
+
'region.properties': rgb('space_properties.back'),
|
|
200
|
+
|
|
201
|
+
// The icon groups, `interface_icons.cc:126-132`'s `DEF_ICON_<GROUP>` macros
|
|
202
|
+
// through `interface/resources.cc:1059-1078` to these members. The two new
|
|
203
|
+
// names are I2's: `scene` for the Render/Output/View Layer/Scene tabs and
|
|
204
|
+
// `collection` for the Collection tab.
|
|
205
|
+
'category.object': rgb('tui.icon_object'),
|
|
206
|
+
'category.modifier': rgb('tui.icon_modifier'),
|
|
207
|
+
'category.material': rgb('tui.icon_shading'),
|
|
208
|
+
'category.data': rgb('tui.icon_object_data'),
|
|
209
|
+
'category.scene': rgb('tui.icon_scene'),
|
|
210
|
+
'category.collection': rgb('tui.icon_collection'),
|
|
211
|
+
'category.tool': held(
|
|
212
|
+
'#cbcbcb',
|
|
213
|
+
'Blender declares no icon group for the Tool tab — `ICON_TOOL_SETTINGS` is a plain ' +
|
|
214
|
+
'`DEF_ICON`, drawn in `.text`. Measured on the reference frames, and a level below ' +
|
|
215
|
+
'`.tui.icon_scene` #cccccc, which is what it sits beside.',
|
|
216
|
+
),
|
|
217
|
+
'category.operator': held(
|
|
218
|
+
'#95dab2',
|
|
219
|
+
"Blender's edit-mode tool column tints its creating operators green; no theme member carries " +
|
|
220
|
+
'it. Measured on `modeling-edit-none.png`.',
|
|
221
|
+
),
|
|
222
|
+
|
|
223
|
+
// The editor's OWN vocabulary. Blender's theme has no danger/warning/success
|
|
224
|
+
// group: a Blender error is a report banner, not a themed colour, so these
|
|
225
|
+
// are ours and are stated as ours.
|
|
226
|
+
'semantic.danger': held('#e0524a', "the editor's own; Blender's theme has no error colour."),
|
|
227
|
+
'semantic.dangerMuted': held('rgba(224,82,74,0.28)', 'the same, at our own two alphas.'),
|
|
228
|
+
'semantic.dangerFaint': held('rgba(224,82,74,0.12)', 'the same, at our own two alphas.'),
|
|
229
|
+
'semantic.warning': held('#e5b83c', "the editor's own."),
|
|
230
|
+
'semantic.warningMuted': held('rgba(229,184,60,0.28)', "the editor's own."),
|
|
231
|
+
'semantic.success': held('#6fbf5f', "the editor's own."),
|
|
232
|
+
'semantic.successMuted': held('rgba(111,191,95,0.28)', "the editor's own."),
|
|
233
|
+
'semantic.dynamic': held('#e5b83c', "the editor's own."),
|
|
234
|
+
'semantic.dynamicMuted': held('rgba(229,184,60,0.18)', "the editor's own."),
|
|
235
|
+
'semantic.instance': held(
|
|
236
|
+
'#ffa028',
|
|
237
|
+
"`.space_view3d.active` #ffa028 — the editor's instance badge reuses Blender's active ink.",
|
|
238
|
+
),
|
|
239
|
+
|
|
240
|
+
'neutralOverlay.hover': held(
|
|
241
|
+
'rgba(255,255,255,0.08)',
|
|
242
|
+
"Blender lightens a widget by SHADE (`widget_state`'s `+15` on the inner colour), not by " +
|
|
243
|
+
'an overlay; ours is an overlay because our widgets are CSS. The alpha matches that shade.',
|
|
244
|
+
),
|
|
245
|
+
'neutralOverlay.active': held('rgba(255,255,255,0.14)', 'the same shade rule, one step up.'),
|
|
246
|
+
scrim: held('rgba(0,0,0,0.6)', 'a modal scrim is ours — Blender dims nothing behind a popup.'),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Groups the emitted document keeps verbatim: not colours. */
|
|
250
|
+
const TYPOGRAPHY = {
|
|
251
|
+
sans: "'Inter', 'DejaVu Sans', system-ui, sans-serif",
|
|
252
|
+
mono: "'DejaVu Sans Mono', Menlo, monospace",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ------------------------------------------------------------------- emit
|
|
256
|
+
|
|
257
|
+
function set(target, path, value) {
|
|
258
|
+
const parts = path.split('.');
|
|
259
|
+
let node = target;
|
|
260
|
+
for (const part of parts.slice(0, -1)) node = node[part] ??= {};
|
|
261
|
+
node[parts.at(-1)] = value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function get(source, path) {
|
|
265
|
+
return path.split('.').reduce((node, part) => node?.[part], source);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cssOf(entry, withAlpha) {
|
|
269
|
+
if (!withAlpha || entry.alpha === 1) return entry.hex;
|
|
270
|
+
const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(entry.hex.slice(i, i + 2), 16));
|
|
271
|
+
return `rgba(${r},${g},${b},${Math.round(entry.alpha * 1000) / 1000})`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const theme = readTheme();
|
|
275
|
+
const color = {};
|
|
276
|
+
const citations = {};
|
|
277
|
+
for (const [key, rule] of Object.entries(MAPPING)) {
|
|
278
|
+
if (rule.member === undefined) {
|
|
279
|
+
set(color, key, rule.value);
|
|
280
|
+
citations[key] = `held — ${rule.why}`;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const entry = theme[rule.member];
|
|
284
|
+
if (entry === undefined)
|
|
285
|
+
throw new Error(
|
|
286
|
+
`${THEME} has no member \`${rule.member}\`, which \`${key}\` cites. Blender renamed or ` +
|
|
287
|
+
'removed it; re-read the theme and update the mapping rather than dropping the key.',
|
|
288
|
+
);
|
|
289
|
+
set(color, key, cssOf(entry, rule.alpha === true));
|
|
290
|
+
citations[key] = `${rule.member} (${THEME}:${entry.line})`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const target = new URL('./blender.palette.json', import.meta.url);
|
|
294
|
+
const before = JSON.parse(readFileSync(target, 'utf8'));
|
|
295
|
+
const document = {
|
|
296
|
+
schemaVersion: 3,
|
|
297
|
+
name: 'Blender',
|
|
298
|
+
theme: { id: 'blender', color, typography: TYPOGRAPHY },
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const changes = [];
|
|
302
|
+
for (const key of Object.keys(MAPPING)) {
|
|
303
|
+
const was = get(before.theme.color, key);
|
|
304
|
+
const now = get(color, key);
|
|
305
|
+
if (was !== now)
|
|
306
|
+
changes.push(` ${key.padEnd(28)} ${String(was)} -> ${now} [${citations[key]}]`);
|
|
307
|
+
}
|
|
308
|
+
for (const line of changes) console.log(line);
|
|
309
|
+
console.log(
|
|
310
|
+
`blender.palette.json — ${Object.keys(MAPPING).length} keys, ` +
|
|
311
|
+
`${Object.values(MAPPING).filter((rule) => rule.member !== undefined).length} derived from ` +
|
|
312
|
+
`${THEME}, ${changes.length} changed`,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (process.argv.includes('--check')) {
|
|
316
|
+
if (changes.length > 0) process.exit(1);
|
|
317
|
+
} else {
|
|
318
|
+
writeFileSync(target, `${JSON.stringify(document, null, 2)}\n`);
|
|
319
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE PROPERTIES SECTIONS ARE LOOKING AT — the page-side half of the RNA
|
|
3
|
+
* door (WORK.md §Blender in the tab is Blender, "Inspection parity", I1).
|
|
4
|
+
*
|
|
5
|
+
* React-free ON PURPOSE: this module is a plain store with `subscribe` /
|
|
6
|
+
* `version` / `state`, so the host's Blender side can import it without
|
|
7
|
+
* pulling React into its closure, and the sections read it through one
|
|
8
|
+
* `useSyncExternalStore` (`blender-properties-view.tsx`).
|
|
9
|
+
*
|
|
10
|
+
* THE SUBJECT IS OUR SELECTION, RESOLVED BY IDENTITY. The person clicks a node
|
|
11
|
+
* in our viewport; the adapter hands that node's `THREE.Object3D`; the
|
|
12
|
+
* presented view answers which Blender object that three object IS
|
|
13
|
+
* (`BlenderRuntimeView.blenderObjectName`, a lookup by object identity in the
|
|
14
|
+
* frame's own table — never a name match). That name goes to
|
|
15
|
+
* `rna_context(object=…)`, which builds the Properties context around it
|
|
16
|
+
* exactly as `buttons_context.cc` builds one around
|
|
17
|
+
* `BKE_view_layer_active_object_get`. Reading our selection never WRITES the
|
|
18
|
+
* engine's active object: that is a mutation, and the document would save it.
|
|
19
|
+
*
|
|
20
|
+
* WHEN IT RE-READS. Three times, and each is a signal rather than a poll:
|
|
21
|
+
* - a PRESENT (`BlenderRuntimeView.subscribeFrames`) — every mutation
|
|
22
|
+
* presents (`session.py::dispatch`), so a frame is "what you are showing is
|
|
23
|
+
* stale";
|
|
24
|
+
* - the RNA DOOR'S OWN VERSION (`subscribeBlenderRna`, ruling 3 of
|
|
25
|
+
* 2026-09-19) — because a write can change what RNA answers and nothing the
|
|
26
|
+
* presenter draws, so no frame ships: `Node.panel_states[n].is_collapsed`
|
|
27
|
+
* and `Object.hide_render` are the two measured cases;
|
|
28
|
+
* - the SELECTION moving to another datablock.
|
|
29
|
+
* A re-read that changes which TABS exist also calls the host door's
|
|
30
|
+
* `documents.contextChanged`, because the tab rail IS the section list and
|
|
31
|
+
* nothing in a host store moved.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { BlenderRnaContext, BlenderRnaView } from '@volter/blender-engine/browser/rna';
|
|
35
|
+
import { editorHost } from '@volter/editor-sdk/host';
|
|
36
|
+
import type * as THREE from 'three';
|
|
37
|
+
import {
|
|
38
|
+
blenderPresentationDocumentId,
|
|
39
|
+
blenderRna,
|
|
40
|
+
blenderRnaContext,
|
|
41
|
+
blenderRnaSet,
|
|
42
|
+
blenderSessionStarted,
|
|
43
|
+
noteBlenderRnaChanged,
|
|
44
|
+
subscribeBlenderRna,
|
|
45
|
+
} from '../host/blender-runtime-host';
|
|
46
|
+
import { blenderOutlinerState } from './blender-outliner-model';
|
|
47
|
+
|
|
48
|
+
/** The published Model-document context, as much of it as this module reads.
|
|
49
|
+
* Structurally typed rather than imported as the class, for the same reason
|
|
50
|
+
* `blender-runtime-host.ts` narrows its `RuntimeView`: the document that
|
|
51
|
+
* published it and the reader are two modules meeting over one object. */
|
|
52
|
+
interface PresentedView {
|
|
53
|
+
readonly root: THREE.Object3D;
|
|
54
|
+
subscribeFrames(listener: () => void): () => void;
|
|
55
|
+
blenderObjectName(object: THREE.Object3D): string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isPresentedView = (value: unknown): value is PresentedView =>
|
|
59
|
+
typeof value === 'object' &&
|
|
60
|
+
value !== null &&
|
|
61
|
+
typeof (value as PresentedView).subscribeFrames === 'function' &&
|
|
62
|
+
typeof (value as PresentedView).blenderObjectName === 'function';
|
|
63
|
+
|
|
64
|
+
/** The active authoring adapter, as much of it as this module reads. The
|
|
65
|
+
* adapter is deliberately `unknown` at the contribution point (adapter-native
|
|
66
|
+
* API, never a fabricated projection), and `@volter/editor-blender` holds no host
|
|
67
|
+
* import — so the shape it needs is stated here and checked at runtime. */
|
|
68
|
+
interface Object3DAuthoringLike {
|
|
69
|
+
readonly documentId: string;
|
|
70
|
+
readonly hierarchy: { object3D(id: string): THREE.Object3D | null };
|
|
71
|
+
readonly documentRootObject: THREE.Object3D | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isObject3DAuthoring = (value: unknown): value is Object3DAuthoringLike =>
|
|
75
|
+
typeof value === 'object' &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
typeof (value as Object3DAuthoringLike).documentId === 'string' &&
|
|
78
|
+
typeof (value as Object3DAuthoringLike).hierarchy?.object3D === 'function';
|
|
79
|
+
|
|
80
|
+
function presentedView(): PresentedView | null {
|
|
81
|
+
const published = editorHost().documents.context(blenderPresentationDocumentId());
|
|
82
|
+
return isPresentedView(published) ? published : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* WHAT THE SELECTED NODE IS, to Blender.
|
|
87
|
+
*
|
|
88
|
+
* - `{ kind: 'object', name }` — the node is one of the engine's objects;
|
|
89
|
+
* - `{ kind: 'collection', path }` — the node is a LAYER COLLECTION row of
|
|
90
|
+
* the Outliner. Clicking one in Blender calls `BKE_layer_collection_activate`
|
|
91
|
+
* (`tree_element_layer_collection_activate`, `outliner_select.cc:812-821`)
|
|
92
|
+
* and the Collection tab then shows THAT collection; activating it here
|
|
93
|
+
* would be a mutation the document saves, so the row's own address goes to
|
|
94
|
+
* the door as a parameter instead (`rna_context(collection_path=…)`), the
|
|
95
|
+
* same way the looked-at OBJECT does;
|
|
96
|
+
* - `{ kind: 'scene' }` — the node is the Scene Collection row, which is the
|
|
97
|
+
* whole file rather than an object in it, so only the tabs that need no
|
|
98
|
+
* object (Render, Output, View Layer, Scene, World, Collection) stand;
|
|
99
|
+
* - `null` — not a Blender subject at all, and no section of ours matches.
|
|
100
|
+
*
|
|
101
|
+
* Synchronous and cheap: `match()` runs on every inspector composition.
|
|
102
|
+
*/
|
|
103
|
+
export type BlenderSubject =
|
|
104
|
+
| { kind: 'object'; name: string }
|
|
105
|
+
| { kind: 'collection'; path: string }
|
|
106
|
+
| { kind: 'scene' };
|
|
107
|
+
|
|
108
|
+
export function resolveBlenderSubject(
|
|
109
|
+
node: { readonly id: string } | null,
|
|
110
|
+
adapter: unknown,
|
|
111
|
+
): BlenderSubject | null {
|
|
112
|
+
if (node === null || !isObject3DAuthoring(adapter)) return null;
|
|
113
|
+
const view = presentedView();
|
|
114
|
+
if (view === null) return null;
|
|
115
|
+
if (node.id === adapter.documentId)
|
|
116
|
+
return adapter.documentRootObject === view.root ? { kind: 'scene' } : null;
|
|
117
|
+
// A COLLECTION ROW HAS NO OBJECT, so it has to be resolved before the object
|
|
118
|
+
// lookup rather than after it — `object3D` answers null for one, which is the
|
|
119
|
+
// honest answer and used to end the resolution here (I3's standing OPEN: the
|
|
120
|
+
// rail simply stayed on the previous subject). The row table is this
|
|
121
|
+
// package's own (`blender-outliner-model.ts`), so this is a map lookup.
|
|
122
|
+
const row = blenderOutlinerState().byId.get(node.id);
|
|
123
|
+
if (row?.type === 'TSE_LAYER_COLLECTION') return { kind: 'collection', path: row.path };
|
|
124
|
+
const object = adapter.hierarchy.object3D(node.id);
|
|
125
|
+
if (object === null) return null;
|
|
126
|
+
// THE SCENE COLLECTION IS THE SCENE SUBJECT. Under I3 the hierarchy is
|
|
127
|
+
// Blender's Outliner, whose root row is the Scene Collection and NOT a
|
|
128
|
+
// document row of the host's — so the row that stands for the whole file is
|
|
129
|
+
// the one whose object is the presented ROOT, and it stands for exactly what
|
|
130
|
+
// the document row did: the tabs that need no object (Render, Output, View
|
|
131
|
+
// Layer, Scene, World, Collection).
|
|
132
|
+
if (object === view.root) return { kind: 'scene' };
|
|
133
|
+
const name = view.blenderObjectName(object);
|
|
134
|
+
return name === null ? null : { kind: 'object', name };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- the store ------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export interface BlenderPropertiesState {
|
|
140
|
+
/** The object the context was read around, or null for the scene subject. */
|
|
141
|
+
readonly object: string | null;
|
|
142
|
+
/** The layer collection the context was read around, when the subject is a
|
|
143
|
+
* collection row; null otherwise. */
|
|
144
|
+
readonly collection: string | null;
|
|
145
|
+
readonly context: BlenderRnaContext | null;
|
|
146
|
+
/** Fetched datablock views, keyed by RNA path. */
|
|
147
|
+
readonly views: ReadonlyMap<string, BlenderRnaView>;
|
|
148
|
+
/** The last refusal the door answered with, verbatim. */
|
|
149
|
+
readonly error: string | null;
|
|
150
|
+
readonly loading: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let state: BlenderPropertiesState = {
|
|
154
|
+
object: null,
|
|
155
|
+
collection: null,
|
|
156
|
+
context: null,
|
|
157
|
+
views: new Map(),
|
|
158
|
+
error: null,
|
|
159
|
+
loading: false,
|
|
160
|
+
};
|
|
161
|
+
let version = 0;
|
|
162
|
+
const listeners = new Set<() => void>();
|
|
163
|
+
/** The frame subscription, taken once the document has published its view. */
|
|
164
|
+
let frameSubscription: (() => void) | null = null;
|
|
165
|
+
/** Paths a fetch is already in flight for — a render must not queue a second. */
|
|
166
|
+
const inFlight = new Set<string>();
|
|
167
|
+
/** The subject the last context was read for, so a re-render does not refetch. */
|
|
168
|
+
let readFor: string | null = null;
|
|
169
|
+
|
|
170
|
+
function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = false): void {
|
|
171
|
+
state = { ...state, ...next };
|
|
172
|
+
version += 1;
|
|
173
|
+
for (const listener of [...listeners]) listener();
|
|
174
|
+
// THE TAB RAIL IS THE SECTION LIST, and section matching is the host's
|
|
175
|
+
// composition — which no store of ours can move. This is the one door that
|
|
176
|
+
// re-derives it (`@volter/editor-sdk/host`, `documents.contextChanged`).
|
|
177
|
+
if (tabsMayHaveChanged) editorHost().documents.contextChanged(blenderPresentationDocumentId());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function subscribeBlenderProperties(listener: () => void): () => void {
|
|
181
|
+
listeners.add(listener);
|
|
182
|
+
return () => {
|
|
183
|
+
listeners.delete(listener);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function blenderPropertiesVersion(): number {
|
|
188
|
+
return version;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function blenderPropertiesState(): BlenderPropertiesState {
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** THE ENGINE MOVED: everything read off it is stale. The CONTEXT is
|
|
196
|
+
* re-read immediately (the tabs may have changed — a modifier was added, a
|
|
197
|
+
* bone became active); the datablock views are dropped and re-read by
|
|
198
|
+
* whichever section is still showing them.
|
|
199
|
+
*
|
|
200
|
+
* Two things call this, and the second is why it is no longer named
|
|
201
|
+
* `onFrame`: a PRESENTED FRAME, and the RNA door's own version (ruling 3,
|
|
202
|
+
* 2026-09-19 — `blenderRnaVersion`). A write that changes nothing the
|
|
203
|
+
* presenter draws ships no frame, and before this the rail simply kept the
|
|
204
|
+
* values it had. */
|
|
205
|
+
function onEngineMoved(): void {
|
|
206
|
+
const subject = readFor;
|
|
207
|
+
readFor = null;
|
|
208
|
+
publish({ views: new Map() });
|
|
209
|
+
if (subject !== null) scheduleContextRead(subject);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// THE FRAME IS ONE SIGNAL AND THE RNA DOOR IS THE OTHER. This subscription is
|
|
213
|
+
// module-level rather than taken beside the frame one, because the RNA door
|
|
214
|
+
// exists from the first import while the document's view arrives later — and a
|
|
215
|
+
// write made before the first present is exactly the case that went unseen.
|
|
216
|
+
subscribeBlenderRna(() => {
|
|
217
|
+
onEngineMoved();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* THE READ NEVER STARTS INSIDE A RENDER. `showBlenderSubject` is called from a
|
|
222
|
+
* tab's `match()`, which the host runs while it is RENDERING the workspace —
|
|
223
|
+
* and the read's first act is to publish `loading`, which is a `setState` in
|
|
224
|
+
* every subscribed component. React said so by name ("Cannot update a
|
|
225
|
+
* component (BlenderPropertiesSection) while rendering a different
|
|
226
|
+
* component"), measured live 2026-09-19. The WANT is recorded
|
|
227
|
+
* synchronously so a second matcher in the same pass does not queue a second
|
|
228
|
+
* read; the read itself runs on the next turn of the loop, outside the render.
|
|
229
|
+
*/
|
|
230
|
+
let scheduledKey: string | null = null;
|
|
231
|
+
|
|
232
|
+
function scheduleContextRead(key: string): void {
|
|
233
|
+
if (scheduledKey === key) return;
|
|
234
|
+
scheduledKey = key;
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (scheduledKey !== key) return;
|
|
237
|
+
scheduledKey = null;
|
|
238
|
+
void readContext(key);
|
|
239
|
+
}, 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The read KEY is the subject, spelled so one string round-trips: `''` is the
|
|
243
|
+
* scene, `collection:<address>` a layer collection, anything else an object
|
|
244
|
+
* NAME. A collection's address is an RNA path and always contains a `.`, so
|
|
245
|
+
* the prefix cannot collide with a datablock name. */
|
|
246
|
+
function subjectKey(subject: BlenderSubject): string {
|
|
247
|
+
if (subject.kind === 'scene') return '';
|
|
248
|
+
return subject.kind === 'collection' ? `collection:${subject.path}` : subject.name;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function watchFrames(): void {
|
|
252
|
+
if (frameSubscription !== null) return;
|
|
253
|
+
const view = presentedView();
|
|
254
|
+
if (view === null) return;
|
|
255
|
+
// A FRAME IS REPORTED TO THE RNA DOOR'S VERSION, not consumed here. The
|
|
256
|
+
// subscription lives in this module because this module is what the document
|
|
257
|
+
// publishes its view to, but the signal belongs to every view over the
|
|
258
|
+
// engine — so it is minted in ONE place (ruling 3) and this module hears its
|
|
259
|
+
// own frame back through `subscribeBlenderRna` above, exactly as the node
|
|
260
|
+
// view and the UV view do.
|
|
261
|
+
frameSubscription = view.subscribeFrames(noteBlenderRnaChanged);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function readContext(key: string): Promise<void> {
|
|
265
|
+
const collection = key.startsWith('collection:') ? key.slice('collection:'.length) : null;
|
|
266
|
+
const object = collection === null && key !== '' ? key : null;
|
|
267
|
+
readFor = key;
|
|
268
|
+
publish({ loading: true, object, collection });
|
|
269
|
+
try {
|
|
270
|
+
const context = await blenderRnaContext(object ?? undefined, collection ?? undefined);
|
|
271
|
+
if (readFor !== key) return;
|
|
272
|
+
// ALWAYS a tab-rail notification, and that is a correction rather than a
|
|
273
|
+
// convenience: the gate a tab matches on is BOTH the subject this context
|
|
274
|
+
// was read for and the tab list, so comparing only the list missed the
|
|
275
|
+
// case that actually happens — selecting the Cube after the document row
|
|
276
|
+
// reads the SAME thirteen tabs for a different subject, the gated tabs'
|
|
277
|
+
// `state.object !== object` went false, and nothing ever said so. Measured
|
|
278
|
+
// live 2026-09-19: Collection/Modifiers/Particles/Object Data/Material
|
|
279
|
+
// disappeared on the first selection and only came back on a re-select.
|
|
280
|
+
publish({ context, loading: false, error: null }, true);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (readFor !== key) return;
|
|
283
|
+
publish({ context: null, loading: false, error: describe(error) }, true);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* WHAT THE PANEL IS LOOKING AT — called by every tab's `match()` on every
|
|
289
|
+
* composition, and by the section bodies. Idempotent: a repeat of the subject
|
|
290
|
+
* already read (or already queued) does nothing, and the read it does start
|
|
291
|
+
* runs outside the render pass (see {@link scheduleContextRead}).
|
|
292
|
+
*/
|
|
293
|
+
export function showBlenderSubject(subject: BlenderSubject): void {
|
|
294
|
+
if (!blenderSessionStarted()) return;
|
|
295
|
+
watchFrames();
|
|
296
|
+
const key = subjectKey(subject);
|
|
297
|
+
if (readFor === key) return;
|
|
298
|
+
scheduleContextRead(key);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The view of one datablock, fetching it on first ask. `undefined` means
|
|
302
|
+
* "not read yet", which is what a section renders as its loading state. */
|
|
303
|
+
export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
|
|
304
|
+
const held = state.views.get(path);
|
|
305
|
+
if (held !== undefined) return held;
|
|
306
|
+
if (inFlight.has(path) || !blenderSessionStarted()) return undefined;
|
|
307
|
+
inFlight.add(path);
|
|
308
|
+
void blenderRna(path)
|
|
309
|
+
.then((view) => {
|
|
310
|
+
inFlight.delete(path);
|
|
311
|
+
if (view === null) return;
|
|
312
|
+
const views = new Map(state.views);
|
|
313
|
+
views.set(path, view);
|
|
314
|
+
publish({ views, error: null });
|
|
315
|
+
})
|
|
316
|
+
.catch((error: unknown) => {
|
|
317
|
+
inFlight.delete(path);
|
|
318
|
+
publish({ error: describe(error) });
|
|
319
|
+
});
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* WRITE ONE PROPERTY. The engine refuses a read-only one by name and this
|
|
325
|
+
* surfaces that refusal verbatim; a successful write presents, and the frame
|
|
326
|
+
* that comes back re-reads everything through {@link onEngineMoved}, so the field
|
|
327
|
+
* shows the value the ENGINE holds rather than the one that was typed.
|
|
328
|
+
*/
|
|
329
|
+
export async function writeBlenderRnaProperty(
|
|
330
|
+
path: string,
|
|
331
|
+
property: string,
|
|
332
|
+
value: unknown,
|
|
333
|
+
index?: number,
|
|
334
|
+
): Promise<boolean> {
|
|
335
|
+
try {
|
|
336
|
+
await blenderRnaSet(path, property, value, index);
|
|
337
|
+
// The write presented; a re-read of this one datablock closes the loop
|
|
338
|
+
// even if the frame listener has not been taken yet.
|
|
339
|
+
const views = new Map(state.views);
|
|
340
|
+
views.delete(path);
|
|
341
|
+
publish({ views, error: null });
|
|
342
|
+
return true;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
publish({ error: describe(error) });
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function describe(error: unknown): string {
|
|
350
|
+
return error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|