@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,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
|
+
}
|