@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,389 @@
1
+ /**
2
+ * WHAT THE OUTLINER IS LOOKING AT — the page-side half of the TREE door
3
+ * (WORK.md §Blender in the tab is Blender, "Inspection parity", I3).
4
+ *
5
+ * The same shape as `blender-properties-model.ts`, and deliberately: a plain
6
+ * React-free store with `subscribe` / `version` / `state`, one read driven by
7
+ * the thing that displays it, re-read on every PRESENT because every mutation
8
+ * presents (`session.py::dispatch`) and a frame is the tab's one signal that
9
+ * what it is showing is stale.
10
+ *
11
+ * WHAT IT IS NOT. It holds no tree of its own making: `rows` is the engine's
12
+ * answer, kept verbatim, and the only thing this module derives is the two
13
+ * indexes a hierarchy provider needs — a row by id, and a row's parent — so
14
+ * `HierarchyProvider.node(id)` is a lookup rather than a walk.
15
+ */
16
+
17
+ import type { BlenderOutlinerRow, BlenderOutlinerTree } from '@volter/blender-engine/browser/rna';
18
+ import { editorHost } from '@volter/editor-sdk/host';
19
+ import type * as THREE from 'three';
20
+ import {
21
+ blenderOutliner,
22
+ blenderOutlinerSet,
23
+ blenderPresentationDocumentId,
24
+ blenderSessionStarted,
25
+ } from '../host/blender-runtime-host';
26
+
27
+ /** The presented view, as much of it as this module reads — structurally
28
+ * typed for the same reason `blender-properties-model.ts` narrows it: the
29
+ * document that published it and the reader are two modules meeting over one
30
+ * object, and `@volter/editor-blender` holds no host import. */
31
+ interface PresentedView {
32
+ readonly root: THREE.Object3D;
33
+ subscribeFrames(listener: () => void): () => void;
34
+ objectForBlenderName(name: string): THREE.Object3D | null;
35
+ /** The other direction, and the one a WRITE needs: which Blender object
36
+ * this three object IS, by identity in the frame's own table — never
37
+ * `object.name`. The transform gizmo addresses `bpy.data.objects[…]` with
38
+ * it (`blender-outliner-authoring.ts`). */
39
+ blenderObjectName(object: THREE.Object3D): string | null;
40
+ /** BLENDER'S SELECTION, off the frame — see
41
+ * `BlenderRuntimeView.blenderSelection`. The engine is the truth and this
42
+ * is the read; {@link blenderEngineSelection} is the door. */
43
+ blenderSelection(): { readonly selected: readonly string[]; readonly active: string | null };
44
+ /** Where `vgai.stage.mode` comes from — see `BlenderRuntimeView.stageMode`. */
45
+ setStageMode(mode: string | null): void;
46
+ }
47
+
48
+ const isPresentedView = (value: unknown): value is PresentedView =>
49
+ typeof value === 'object' &&
50
+ value !== null &&
51
+ typeof (value as PresentedView).subscribeFrames === 'function' &&
52
+ typeof (value as PresentedView).objectForBlenderName === 'function' &&
53
+ typeof (value as PresentedView).blenderObjectName === 'function' &&
54
+ typeof (value as PresentedView).blenderSelection === 'function' &&
55
+ typeof (value as PresentedView).setStageMode === 'function';
56
+
57
+ export function blenderPresentedView(): PresentedView | null {
58
+ const published = editorHost().documents.context(blenderPresentationDocumentId());
59
+ return isPresentedView(published) ? published : null;
60
+ }
61
+
62
+ /**
63
+ * WHAT BLENDER HAS SELECTED, off the last frame — the one read behind the
64
+ * Model document's selection, and the reason this module has no selection
65
+ * state of its own.
66
+ *
67
+ * A frame arrives after EVERY mutation (`session.py::dispatch` presents after
68
+ * `execute`, `rna-set` and `outliner-set`), so this answer moves the moment
69
+ * the engine's does: a `bpy.ops.mesh.primitive_torus_add()` typed by an agent
70
+ * leaves the torus selected and active in Blender, the present ships that,
71
+ * and the outline in this editor follows without anyone telling it to.
72
+ * Before the first frame it is empty, which is the honest answer for a
73
+ * document whose engine has not presented yet.
74
+ */
75
+ export function blenderEngineSelection(): {
76
+ readonly selected: readonly string[];
77
+ readonly active: string | null;
78
+ } {
79
+ return blenderPresentedView()?.blenderSelection() ?? { selected: [], active: null };
80
+ }
81
+
82
+ export interface BlenderOutlinerState {
83
+ readonly tree: BlenderOutlinerTree | null;
84
+ /** Every row by `id`, and every row's parent id — the two indexes a
85
+ * hierarchy provider needs, rebuilt whenever the tree is replaced. */
86
+ readonly byId: ReadonlyMap<string, BlenderOutlinerRow>;
87
+ readonly parentOf: ReadonlyMap<string, string | null>;
88
+ readonly error: string | null;
89
+ readonly loading: boolean;
90
+ }
91
+
92
+ const EMPTY: BlenderOutlinerState = {
93
+ tree: null,
94
+ byId: new Map(),
95
+ parentOf: new Map(),
96
+ error: null,
97
+ loading: false,
98
+ };
99
+
100
+ let state: BlenderOutlinerState = EMPTY;
101
+ let version = 0;
102
+ const listeners = new Set<() => void>();
103
+ let frameSubscription: (() => void) | null = null;
104
+ /** The selection the last read was made for, so a re-render does not refetch. */
105
+ let readFor: string | null = null;
106
+ /** THE SELECTION THE TREE IS OF, which outlives {@link readFor} (see
107
+ * {@link onFrame}). Empty until the first read; a frame before then re-reads
108
+ * the whole tree with no selection, which is the right answer for it. */
109
+ let lastSelectionKey = '';
110
+ let scheduled: string | null = null;
111
+
112
+ function index(tree: BlenderOutlinerTree | null): Pick<BlenderOutlinerState, 'byId' | 'parentOf'> {
113
+ const byId = new Map<string, BlenderOutlinerRow>();
114
+ const parentOf = new Map<string, string | null>();
115
+ const walk = (row: BlenderOutlinerRow, parent: string | null): void => {
116
+ byId.set(row.id, row);
117
+ parentOf.set(row.id, parent);
118
+ for (const child of row.children) walk(child, row.id);
119
+ };
120
+ for (const row of tree?.rows ?? []) walk(row, null);
121
+ return { byId, parentOf };
122
+ }
123
+
124
+ function publish(next: Partial<BlenderOutlinerState>): void {
125
+ state = { ...state, ...next };
126
+ version += 1;
127
+ for (const listener of [...listeners]) listener();
128
+ }
129
+
130
+ export function subscribeBlenderOutliner(listener: () => void): () => void {
131
+ listeners.add(listener);
132
+ return () => {
133
+ listeners.delete(listener);
134
+ };
135
+ }
136
+
137
+ export function blenderOutlinerVersion(): number {
138
+ return version;
139
+ }
140
+
141
+ export function blenderOutlinerState(): BlenderOutlinerState {
142
+ return state;
143
+ }
144
+
145
+ /**
146
+ * THE READ NEVER STARTS INSIDE A RENDER, and that is I1's correction applied
147
+ * here rather than rediscovered: the hierarchy asks for the tree from inside
148
+ * React's render pass, and the read's first act is to publish `loading`, which
149
+ * is a `setState` in every subscriber ("Cannot update a component … while
150
+ * rendering a different component"). The WANT is recorded synchronously so a
151
+ * second ask in the same pass queues nothing; the read runs on the next turn.
152
+ */
153
+ function scheduleRead(key: string): void {
154
+ if (scheduled === key) return;
155
+ scheduled = key;
156
+ setTimeout(() => {
157
+ if (scheduled !== key) return;
158
+ scheduled = null;
159
+ void startRead(key === '' ? [] : key.split('\u0000'));
160
+ }, 0);
161
+ }
162
+
163
+ /**
164
+ * WHICH ANSWER WINS — a SEQUENCE, not the selection key.
165
+ *
166
+ * Two reads can be in flight at once: a frame arrives and schedules one
167
+ * (`onFrame`), and a structural verb asks for one of its own
168
+ * ({@link refreshBlenderOutliner}). Staleness used to be judged by comparing
169
+ * `readFor` against the read's own key, which answers a different question —
170
+ * "is this still the selection anyone wants?" — and gets the ordering wrong
171
+ * the moment the two reads carry DIFFERENT keys: whichever started last owns
172
+ * `readFor`, so the other one's answer is thrown away even when it is newer.
173
+ *
174
+ * MEASURED 2026-09-21: Add ▸ Mesh ▸ UV Sphere created the sphere, the verb's
175
+ * own read was discarded by that comparison, and the verb then looked for the
176
+ * new row in the tree from BEFORE the add and found none — so nothing was
177
+ * selected. The frame's read landed a moment later and the row appeared, which
178
+ * is why the defect looked like "selection only".
179
+ *
180
+ * The sequence says exactly what the guard means: the LAST READ STARTED is the
181
+ * one whose answer the panel shows. {@link readFor} keeps its other job, which
182
+ * is idempotence for {@link showBlenderOutliner}.
183
+ */
184
+ let readSeq = 0;
185
+ /** The read most recently started, so {@link refreshBlenderOutliner} can wait
186
+ * for whichever one WINS rather than only for its own. */
187
+ let latestRead: Promise<void> = Promise.resolve();
188
+
189
+ function startRead(selected: readonly string[]): Promise<void> {
190
+ const done = read(selected);
191
+ latestRead = done;
192
+ return done;
193
+ }
194
+
195
+ async function read(selected: readonly string[]): Promise<void> {
196
+ const key = selected.join('\u0000');
197
+ const seq = ++readSeq;
198
+ readFor = key;
199
+ lastSelectionKey = key;
200
+ publish({ loading: true });
201
+ try {
202
+ const tree = await blenderOutliner(selected);
203
+ if (seq !== readSeq) return;
204
+ // THE MODE RIDES WITH THE TREE, because the tree is what needs it (pose
205
+ // rows exist only in pose mode) and because the read happens anyway.
206
+ // `vgai.stage.mode` is then the document's own published context — see
207
+ // `BlenderRuntimeView.stageMode`.
208
+ blenderPresentedView()?.setStageMode(tree?.mode ?? null);
209
+ publish({ tree, ...index(tree), loading: false, error: null });
210
+ } catch (error) {
211
+ if (seq !== readSeq) return;
212
+ publish({ loading: false, error: describe(error) });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * A frame arrived: the tree the engine holds may be a different tree (an object
218
+ * added, a modifier stacked, a collection excluded). Re-read it.
219
+ *
220
+ * IT RE-READS THE LAST SELECTION, NOT `readFor`, and that distinction is the
221
+ * second half of the defect above: `readFor` is the IDEMPOTENCE marker and a
222
+ * successful column write clears it deliberately (the value it holds is now
223
+ * stale), so a frame arriving after one found nothing to re-read and the eye
224
+ * it had just written never moved. The selection the tree was last read for is
225
+ * its own fact and outlives both.
226
+ */
227
+ function onFrame(): void {
228
+ readFor = null;
229
+ scheduleRead(lastSelectionKey);
230
+ for (const listener of [...frameListeners]) listener();
231
+ }
232
+
233
+ /**
234
+ * A FRAME ARRIVED — for a reader that needs the frame itself rather than the
235
+ * tree the frame makes stale.
236
+ *
237
+ * {@link blenderEngineSelection} is the one such reader: a present that
238
+ * changes nothing structural still carries a new `selected`/`active`, and the
239
+ * tree read above would answer the same rows. It rides THIS module's frame
240
+ * subscription rather than taking a second one, because `watchFrames` already
241
+ * owns the "the document may not have published its view yet" retry and a
242
+ * second copy of that is how two subscriptions drift.
243
+ */
244
+ const frameListeners = new Set<() => void>();
245
+
246
+ export function onBlenderFrame(listener: () => void): () => void {
247
+ watchFrames();
248
+ frameListeners.add(listener);
249
+ return () => {
250
+ frameListeners.delete(listener);
251
+ };
252
+ }
253
+
254
+ /**
255
+ * TAKE THE FRAME SUBSCRIPTION, AND WAIT FOR THE DOCUMENT IF IT IS NOT THERE
256
+ * YET — which is the shape of the defect the I3 walk found: the hierarchy asks
257
+ * for the tree the moment the panel first renders, and the Model document
258
+ * publishes its context on its own mount, so the FIRST ask can legitimately
259
+ * find no view. Retrying "next time someone asks" is not enough, because
260
+ * {@link showBlenderOutliner} is idempotent by selection: nothing asks again
261
+ * until the selection moves, so a scene mutated by a script updated the
262
+ * viewport and left the Outliner showing the old tree for good (measured
263
+ * 2026-09-19 — `outliner_set` hid an object and its row's eye never moved).
264
+ * `documents.waitForContext` is the door for exactly this.
265
+ */
266
+ let awaitingView = false;
267
+ /** WHICH view the live {@link frameSubscription} is against. A document that
268
+ * remounts, or an engine restarted with `blender-start --fresh`, PUBLISHES A
269
+ * NEW `BlenderRuntimeView`; the old one's `subscribeFrames` set goes with it,
270
+ * so a subscription held against it is a subscription to nothing and every
271
+ * later present reaches no reader — the tree, and now the selection, freeze
272
+ * on what they last read while the viewport goes on drawing the new frames.
273
+ * Holding the view this subscription belongs to is what makes the identity
274
+ * check below possible; `frameSubscription !== null` alone cannot see it. */
275
+ let subscribedView: unknown = null;
276
+
277
+ function watchFrames(): void {
278
+ const current = blenderPresentedView();
279
+ if (frameSubscription !== null && subscribedView === current) return;
280
+ if (frameSubscription !== null) {
281
+ frameSubscription();
282
+ frameSubscription = null;
283
+ subscribedView = null;
284
+ }
285
+ const view = current;
286
+ if (view !== null) {
287
+ frameSubscription = view.subscribeFrames(onFrame);
288
+ subscribedView = view;
289
+ return;
290
+ }
291
+ if (awaitingView) return;
292
+ awaitingView = true;
293
+ void editorHost()
294
+ .documents.waitForContext(blenderPresentationDocumentId())
295
+ .then(() => {
296
+ awaitingView = false;
297
+ watchFrames();
298
+ })
299
+ .catch(() => {
300
+ awaitingView = false;
301
+ });
302
+ }
303
+
304
+ /**
305
+ * WHAT THE OUTLINER IS SHOWING — called by the hierarchy provider on every
306
+ * read. Idempotent: a repeat of a selection already read (or queued) does
307
+ * nothing, and the read it does start runs outside the render pass.
308
+ */
309
+ export function showBlenderOutliner(selected: readonly string[]): void {
310
+ if (!blenderSessionStarted()) return;
311
+ watchFrames();
312
+ const key = [...selected].sort().join('\u0000');
313
+ if (readFor === key) return;
314
+ scheduleRead(key);
315
+ }
316
+
317
+ /**
318
+ * RE-READ THE TREE NOW, AND ANSWER WHEN IT HAS LANDED — the awaited half of
319
+ * {@link showBlenderOutliner}, for a caller that has just CHANGED the tree and
320
+ * must address a row that did not exist a moment ago.
321
+ *
322
+ * The structural verbs are the callers (`blender-outliner-authoring.ts`'s
323
+ * `StructureProvider`): `bpy.ops.mesh.primitive_uv_sphere_add()` returns, the
324
+ * present that follows ships a frame, {@link onFrame} schedules a read on the
325
+ * next turn — and the verb's contract is to hand its caller the new row's id
326
+ * and select it, which it cannot do against the tree from before the add. So
327
+ * it asks for the read itself and awaits it, instead of polling for a row to
328
+ * appear.
329
+ *
330
+ * It is the SAME read the panel drives, not a second one: `readFor` and
331
+ * `lastSelectionKey` move with it, so the frame's own scheduled read finds the
332
+ * selection already current and does nothing.
333
+ */
334
+ export async function refreshBlenderOutliner(selected: readonly string[]): Promise<void> {
335
+ if (!blenderSessionStarted()) return;
336
+ // Any read already QUEUED is superseded by this one before it starts.
337
+ scheduled = null;
338
+ let awaited = startRead([...selected].sort());
339
+ // AND IT WAITS FOR WHICHEVER READ WINS, not merely for its own: a frame's
340
+ // read can start WHILE this one is in flight and take the sequence, in which
341
+ // case this one's answer is dropped and the caller must still not look at
342
+ // the tree until the winner has published (see {@link readSeq}).
343
+ //
344
+ // THE CHECK IS AFTER THE AWAIT, not before it, and that is the whole
345
+ // mechanism — a pre-test loop finds nothing newer at entry (the frame's read
346
+ // is scheduled on a later turn), exits at once, and returns having published
347
+ // nothing. Measured 2026-09-21: the first cut of this loop was pre-test, and
348
+ // Add ▸ Mesh ▸ Monkey still resolved its new row against the tree from
349
+ // before the add. It ends because each turn follows a read that started
350
+ // strictly later than the last.
351
+ for (;;) {
352
+ await awaited;
353
+ if (latestRead === awaited) return;
354
+ awaited = latestRead;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * WRITE ONE RESTRICTION COLUMN. The engine refuses a column it draws on no row
360
+ * of that type BY NAME and this surfaces the refusal verbatim; a successful
361
+ * write presents, and the frame that comes back re-reads the whole tree
362
+ * through {@link onFrame} — so the eye shows the state the ENGINE holds rather
363
+ * than the one that was clicked.
364
+ */
365
+ export async function writeBlenderOutlinerColumn(
366
+ path: string,
367
+ column: string,
368
+ value: boolean,
369
+ ): Promise<boolean> {
370
+ try {
371
+ await blenderOutlinerSet(path, column, value);
372
+ // RE-READ THE TREE HERE, rather than waiting for the frame the write
373
+ // presented. A present is not a reliable signal for a COLUMN: `hide_render`
374
+ // changes nothing the presenter draws, so the frame is identical and the
375
+ // session ships nothing — measured 2026-09-19, the render camera toggled in
376
+ // the engine and the row never moved. The write's own outcome is what the
377
+ // row has to show, so the write asks for it.
378
+ readFor = null;
379
+ scheduleRead(lastSelectionKey);
380
+ return true;
381
+ } catch (error) {
382
+ publish({ error: describe(error) });
383
+ return false;
384
+ }
385
+ }
386
+
387
+ function describe(error: unknown): string {
388
+ return error instanceof Error ? error.message : String(error);
389
+ }