@volter/editor-blender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -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
+ }