@volter/editor-blender 0.1.6 → 0.1.7
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.
|
@@ -170,6 +170,9 @@ let readFor: string | null = null;
|
|
|
170
170
|
* A late answer for a deleted duplicate must not overwrite the new selection. */
|
|
171
171
|
let readGeneration = 0;
|
|
172
172
|
let contextRequest = 0;
|
|
173
|
+
/** Views read at the current generation. A held view outside this set is
|
|
174
|
+
* STALE: still shown, re-read on the next ask. */
|
|
175
|
+
const fresh = new Set<string>();
|
|
173
176
|
|
|
174
177
|
function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = false): void {
|
|
175
178
|
state = { ...state, ...next };
|
|
@@ -207,12 +210,17 @@ export function blenderPropertiesState(): BlenderPropertiesState {
|
|
|
207
210
|
* presenter draws ships no frame, and before this the rail simply kept the
|
|
208
211
|
* values it had. */
|
|
209
212
|
function onEngineMoved(): void {
|
|
213
|
+
// STALE, NOT EMPTY. The generation moves, so every answer already in flight
|
|
214
|
+
// is dropped when it lands (a deleted duplicate's late read cannot overwrite
|
|
215
|
+
// the new selection) -- but the panel keeps showing what it holds until the
|
|
216
|
+
// re-read answers. Clearing here blanked and repainted the whole Properties
|
|
217
|
+
// panel on every edit, because every edit presents a frame.
|
|
210
218
|
const subject = readFor;
|
|
211
219
|
readGeneration += 1;
|
|
212
220
|
inFlight.clear();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
fresh.clear();
|
|
222
|
+
if (subject !== null) scheduleContextRead(subject, true);
|
|
223
|
+
else publish({});
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
// THE FRAME IS ONE SIGNAL AND THE RNA DOOR IS THE OTHER. This subscription is
|
|
@@ -235,13 +243,13 @@ subscribeBlenderRna(() => {
|
|
|
235
243
|
*/
|
|
236
244
|
let scheduledKey: string | null = null;
|
|
237
245
|
|
|
238
|
-
function scheduleContextRead(key: string): void {
|
|
246
|
+
function scheduleContextRead(key: string, refresh = false): void {
|
|
239
247
|
if (scheduledKey === key) return;
|
|
240
248
|
scheduledKey = key;
|
|
241
249
|
setTimeout(() => {
|
|
242
250
|
if (scheduledKey !== key) return;
|
|
243
251
|
scheduledKey = null;
|
|
244
|
-
void readContext(key);
|
|
252
|
+
void readContext(key, refresh);
|
|
245
253
|
}, 0);
|
|
246
254
|
}
|
|
247
255
|
|
|
@@ -267,20 +275,30 @@ function watchFrames(): void {
|
|
|
267
275
|
frameSubscription = view.subscribeFrames(noteBlenderRnaChanged);
|
|
268
276
|
}
|
|
269
277
|
|
|
270
|
-
async function readContext(key: string): Promise<void> {
|
|
278
|
+
async function readContext(key: string, refresh = false): Promise<void> {
|
|
271
279
|
const collection = key.startsWith('collection:') ? key.slice('collection:'.length) : null;
|
|
272
280
|
const object = collection === null && key !== '' ? key : null;
|
|
281
|
+
// A REFRESH re-reads the subject already shown and keeps it on screen; a new
|
|
282
|
+
// subject starts empty, because nothing held belongs to it.
|
|
283
|
+
const sameSubject = refresh && readFor === key;
|
|
273
284
|
if (readFor !== key) {
|
|
274
285
|
readGeneration += 1;
|
|
275
286
|
inFlight.clear();
|
|
287
|
+
fresh.clear();
|
|
276
288
|
}
|
|
277
289
|
const generation = readGeneration;
|
|
278
290
|
const request = ++contextRequest;
|
|
279
291
|
readFor = key;
|
|
280
|
-
|
|
292
|
+
const previous = sameSubject ? state.context : null;
|
|
293
|
+
if (!sameSubject) publish({ loading: true, object, collection, context: null, error: null, views: new Map() });
|
|
281
294
|
try {
|
|
282
295
|
const context = await blenderRnaContext(object ?? undefined, collection ?? undefined);
|
|
283
296
|
if (generation !== readGeneration || request !== contextRequest) return;
|
|
297
|
+
if (sameSubject && JSON.stringify(context) === JSON.stringify(previous)) {
|
|
298
|
+
// Nothing the tab rail matches on moved: no rail recomposition.
|
|
299
|
+
publish({ loading: false, error: null });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
284
302
|
// ALWAYS a tab-rail notification, and that is a correction rather than a
|
|
285
303
|
// convenience: the gate a tab matches on is BOTH the subject this context
|
|
286
304
|
// was read for and the tab list, so comparing only the list missed the
|
|
@@ -314,14 +332,16 @@ export function showBlenderSubject(subject: BlenderSubject): void {
|
|
|
314
332
|
* "not read yet", which is what a section renders as its loading state. */
|
|
315
333
|
export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
|
|
316
334
|
const held = state.views.get(path);
|
|
317
|
-
if (held !== undefined) return held;
|
|
318
|
-
|
|
335
|
+
if (held !== undefined && fresh.has(path)) return held;
|
|
336
|
+
// A stale view is shown while its re-read is in flight.
|
|
337
|
+
if (inFlight.has(path) || !blenderSessionStarted()) return held;
|
|
319
338
|
inFlight.add(path);
|
|
320
339
|
const generation = readGeneration;
|
|
321
340
|
void blenderRna(path)
|
|
322
341
|
.then((view) => {
|
|
323
342
|
if (generation !== readGeneration) return;
|
|
324
343
|
inFlight.delete(path);
|
|
344
|
+
fresh.add(path);
|
|
325
345
|
if (view === null) return;
|
|
326
346
|
const views = new Map(state.views);
|
|
327
347
|
views.set(path, view);
|
|
@@ -332,7 +352,7 @@ export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
|
|
|
332
352
|
inFlight.delete(path);
|
|
333
353
|
publish({ error: describe(error) });
|
|
334
354
|
});
|
|
335
|
-
return
|
|
355
|
+
return held;
|
|
336
356
|
}
|
|
337
357
|
|
|
338
358
|
/**
|
|
@@ -351,9 +371,8 @@ export async function writeBlenderRnaProperty(
|
|
|
351
371
|
await blenderRnaSet(path, property, value, index);
|
|
352
372
|
// The write presented; a re-read of this one datablock closes the loop
|
|
353
373
|
// even if the frame listener has not been taken yet.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
publish({ views, error: null });
|
|
374
|
+
fresh.delete(path);
|
|
375
|
+
publish({ error: null });
|
|
357
376
|
return true;
|
|
358
377
|
} catch (error) {
|
|
359
378
|
publish({ error: describe(error) });
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
28
28
|
import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
|
|
29
29
|
import { editorHost } from '@volter/editor-sdk/host';
|
|
30
|
-
import { useCallback, useEffect } from 'react';
|
|
30
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
31
31
|
import * as THREE from 'three';
|
|
32
32
|
import { bindModelDocument, openModelDocumentBlend } from '../host/blender-runtime-host';
|
|
33
33
|
import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
@@ -79,17 +79,78 @@ export const inspectorBuiltins: readonly string[] = [];
|
|
|
79
79
|
// one set of presented objects (`blender-runtime-skin.ts`).
|
|
80
80
|
const view = blenderModelView;
|
|
81
81
|
|
|
82
|
-
export default function BlenderModelDocument({
|
|
82
|
+
export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
83
|
+
const { active, document, documentId, notify, publishContext } = props;
|
|
84
|
+
const blend = document?.source?.path;
|
|
85
|
+
const entryId = document?.id;
|
|
86
|
+
const key = JSON.stringify([documentId, entryId, blend]);
|
|
87
|
+
const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
|
|
88
|
+
const callbacks = useRef({ notify, publishContext });
|
|
89
|
+
callbacks.current = { notify, publishContext };
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (active === false || !documentId) return;
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
let unpublish: (() => void) | undefined;
|
|
95
|
+
const binding = blend === undefined ? null : { documentId, entryId: entryId!, blend };
|
|
96
|
+
const unbind = bindModelDocument(binding);
|
|
97
|
+
const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
|
|
98
|
+
setOpened(null);
|
|
99
|
+
void (async () => {
|
|
100
|
+
try {
|
|
101
|
+
if (binding) {
|
|
102
|
+
if (!await openModelDocumentBlend(binding, publish)) return;
|
|
103
|
+
} else {
|
|
104
|
+
// The standing Model document is the explicit blender-start target.
|
|
105
|
+
publish();
|
|
106
|
+
}
|
|
107
|
+
if (!cancelled) setOpened({ key, error: null });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
unpublish?.();
|
|
111
|
+
unpublish = undefined;
|
|
112
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
113
|
+
setOpened({ key, error: detail });
|
|
114
|
+
callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${blend ?? 'the model'}`, detail });
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true;
|
|
119
|
+
unpublish?.();
|
|
120
|
+
unbind();
|
|
121
|
+
};
|
|
122
|
+
}, [active, blend, documentId, entryId, key]);
|
|
123
|
+
|
|
124
|
+
if (active === false || !documentId) return null;
|
|
125
|
+
if (opened?.key !== key) return <div role="status">Opening model…</div>;
|
|
126
|
+
if (opened.error) return <div role="alert">{opened.error}</div>;
|
|
127
|
+
return <BlenderModelViewport {...props} />;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function BlenderModelViewport({
|
|
83
131
|
active,
|
|
84
132
|
document,
|
|
85
133
|
documentId,
|
|
86
134
|
surfaces,
|
|
87
|
-
notify,
|
|
88
|
-
publishContext,
|
|
89
135
|
}: ToolContributionProps) {
|
|
90
|
-
const build = useCallback(
|
|
136
|
+
const build = useCallback(
|
|
137
|
+
() => ({
|
|
138
|
+
root: view.root,
|
|
139
|
+
// What the view draws changes with each frame and the work after it,
|
|
140
|
+
// and with the skin's poses when the Timeline scrubs.
|
|
141
|
+
onChange(listener: () => void) {
|
|
142
|
+
const stopView = view.onChange(listener);
|
|
143
|
+
const stopSkin = blenderSkin.subscribe(listener);
|
|
144
|
+
return () => {
|
|
145
|
+
stopView();
|
|
146
|
+
stopSkin();
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
dispose() {},
|
|
150
|
+
}),
|
|
151
|
+
[],
|
|
152
|
+
);
|
|
91
153
|
const blend = document?.source?.path;
|
|
92
|
-
useEffect(() => publishContext?.(view), [publishContext]);
|
|
93
154
|
/**
|
|
94
155
|
* THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
|
|
95
156
|
* (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
|
|
@@ -149,29 +210,6 @@ export default function BlenderModelDocument({
|
|
|
149
210
|
detach?.();
|
|
150
211
|
};
|
|
151
212
|
}, [documentId]);
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
if (!documentId) return;
|
|
154
|
-
if (blend === undefined) {
|
|
155
|
-
// Opened by address: the session names its own document.
|
|
156
|
-
bindModelDocument(null);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// A FRESHLY OPENED MODEL DOCUMENT PRESENTS WITHOUT AN EXECUTE. Every
|
|
160
|
-
// mutation presents (`session.py::dispatch`), so the document only ever
|
|
161
|
-
// received a frame once something had RUN — open a `.blend` and the
|
|
162
|
-
// viewport stayed empty until a `vgai blender-exec`. The first present is
|
|
163
|
-
// the document's to ask for, because opening a document IS the request to
|
|
164
|
-
// see what is in it (I1 found this; WORK.md §Blender in the tab is
|
|
165
|
-
// Blender, "Inspection parity", I2 decision 6).
|
|
166
|
-
void openModelDocumentBlend(documentId, blend, document!.id).catch((error: unknown) => {
|
|
167
|
-
notify?.({
|
|
168
|
-
tone: 'error',
|
|
169
|
-
title: `Blender could not open ${blend}`,
|
|
170
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
return () => bindModelDocument(null);
|
|
174
|
-
}, [blend, document?.id, documentId, notify]);
|
|
175
213
|
if (!documentId) return null;
|
|
176
214
|
const Surface = surfaces.Object3DAuthoring;
|
|
177
215
|
return (
|
|
@@ -206,13 +206,40 @@ export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
|
|
|
206
206
|
* package: the document that binds it and the session that reads it ship
|
|
207
207
|
* together, so there is nothing for the SDK to carry.
|
|
208
208
|
*/
|
|
209
|
-
|
|
209
|
+
export interface ModelDocumentBinding {
|
|
210
|
+
readonly documentId: string;
|
|
211
|
+
readonly entryId: string;
|
|
212
|
+
readonly blend: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let boundModel: ModelDocumentBinding | null = null;
|
|
216
|
+
let modelBindingGeneration = 0;
|
|
210
217
|
|
|
211
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
|
|
214
|
-
|
|
218
|
+
/** Bind the mounted Model document; the returned cleanup releases only this
|
|
219
|
+
* binding. `null` names the standing Model without a file entry. */
|
|
220
|
+
export function bindModelDocument(bound: ModelDocumentBinding | null): () => void {
|
|
221
|
+
const generation = ++modelBindingGeneration;
|
|
215
222
|
boundModel = bound;
|
|
223
|
+
noteBlenderRnaChanged();
|
|
224
|
+
return () => {
|
|
225
|
+
// An old pane's asynchronous cleanup must not unbind its replacement.
|
|
226
|
+
if (modelBindingGeneration !== generation) return;
|
|
227
|
+
boundModel = null;
|
|
228
|
+
noteBlenderRnaChanged();
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function modelDocumentConflict(): string | null {
|
|
233
|
+
const held = runtime?.document;
|
|
234
|
+
// The finder owns model:<path>, wrapped by the host as document:<entry>.
|
|
235
|
+
// Read the active address too: activation precedes the contributed pane's
|
|
236
|
+
// effect, so an immediate command must not slip through that binding gap.
|
|
237
|
+
const activeId = editorHost().documents.activeId();
|
|
238
|
+
const requested = activeId?.startsWith('document:model:')
|
|
239
|
+
? activeId.slice('document:model:'.length)
|
|
240
|
+
: boundModel?.blend;
|
|
241
|
+
if (!requested || !held || requested === held) return null;
|
|
242
|
+
return `Blender is editing ${held}; ${requested} is not open. Return to ${held} before editing.`;
|
|
216
243
|
}
|
|
217
244
|
|
|
218
245
|
/** The document id a present must reach: the open Model document's, or the
|
|
@@ -233,25 +260,35 @@ export function blenderPresentationDocumentId(): string {
|
|
|
233
260
|
* path run backwards. `session.py` opens the named file at start and saves
|
|
234
261
|
* back to it, so naming it here IS opening it.
|
|
235
262
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
263
|
+
* The worker owns one file. Validate ownership before publishing a context;
|
|
264
|
+
* a refused open must never expose the previous model under a new tab's
|
|
265
|
+
* identity. Boot itself can present, so the context must exist before awaiting
|
|
266
|
+
* boot completion. Return false when the requesting pane has since unmounted.
|
|
240
267
|
*/
|
|
241
|
-
export async function openModelDocumentBlend(
|
|
242
|
-
|
|
268
|
+
export async function openModelDocumentBlend(
|
|
269
|
+
binding: ModelDocumentBinding,
|
|
270
|
+
publish: () => void,
|
|
271
|
+
): Promise<boolean> {
|
|
243
272
|
const host = editorHost();
|
|
244
|
-
if (!host.session.open())
|
|
273
|
+
if (!host.session.open()) throw new Error('Opening a model requires an open project session.');
|
|
245
274
|
const project = host.projectLocalState.projectRootPath();
|
|
246
|
-
if (project === null)
|
|
247
|
-
|
|
275
|
+
if (project === null) throw new Error('Opening a model requires a project path.');
|
|
276
|
+
if (boundModel !== binding || host.documents.activeId() !== binding.documentId) return false;
|
|
277
|
+
const session = blenderRuntime();
|
|
278
|
+
// start claims its resource synchronously; its first frame may arrive before
|
|
279
|
+
// the returned promise resolves. The getter above rejects a conflicting file.
|
|
280
|
+
const started = session.start(project, binding.blend);
|
|
281
|
+
publish();
|
|
282
|
+
await started;
|
|
283
|
+
if (boundModel !== binding) return false;
|
|
248
284
|
// AND SHOW WHAT IT OPENED. `start` binds the document and loads the file; it
|
|
249
285
|
// does not present, because presenting is what a MUTATION does
|
|
250
286
|
// (`session.py::dispatch`). So until this line a freshly opened Model
|
|
251
287
|
// document displayed nothing until an execute happened to run — I1 measured
|
|
252
288
|
// it, and "open a model, see the model" is the document's own request rather
|
|
253
289
|
// than a side effect to hope for.
|
|
254
|
-
await
|
|
290
|
+
await session.present();
|
|
291
|
+
return boundModel === binding;
|
|
255
292
|
}
|
|
256
293
|
|
|
257
294
|
/**
|
|
@@ -268,7 +305,7 @@ export async function openModelDocumentBlend(documentId: string, blend: string,
|
|
|
268
305
|
* the same reason.
|
|
269
306
|
*/
|
|
270
307
|
export function blenderSessionStarted(): boolean {
|
|
271
|
-
return runtime?.project != null;
|
|
308
|
+
return runtime?.project != null && modelDocumentConflict() === null;
|
|
272
309
|
}
|
|
273
310
|
|
|
274
311
|
/**
|
|
@@ -701,17 +738,32 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
|
701
738
|
|
|
702
739
|
async function runtimeView(): Promise<RuntimeView> {
|
|
703
740
|
const { documents } = editorHost();
|
|
704
|
-
|
|
705
|
-
|
|
741
|
+
// THE ID IS RE-READ WHILE WAITING. Which document a present reaches depends
|
|
742
|
+
// on the Model document's binding (`presentationDocumentId`), and at startup
|
|
743
|
+
// a present can begin before that pane has bound: reading the id once made
|
|
744
|
+
// it wait the whole window for `document:blender:runtime`, which a
|
|
745
|
+
// file-backed Model never publishes, while its own document published beside
|
|
746
|
+
// it -- the battery's preflight failed exactly so, 18 s after its first
|
|
747
|
+
// command (3 s of boot, then the full wait).
|
|
748
|
+
const deadline = Date.now() + 15_000;
|
|
749
|
+
let id = presentationDocumentId();
|
|
750
|
+
for (;;) {
|
|
751
|
+
id = presentationDocumentId();
|
|
752
|
+
const published = await documents.waitForContext(id, Math.max(0, Math.min(250, deadline - Date.now())));
|
|
753
|
+
if (isRuntimeView(published)) return published;
|
|
754
|
+
if (Date.now() >= deadline) break;
|
|
755
|
+
}
|
|
706
756
|
throw new Error(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
'
|
|
757
|
+
`The Blender Model document is not open, or the open one is not a Model this engine can present ` +
|
|
758
|
+
`to (it must answer applyFrame, captureSnapshot, recordPresentation and recordPhotograph): nothing ` +
|
|
759
|
+
`published a presentable view as ${id} within 15 s (bound model: ${boundModel?.documentId ?? 'none'}). ` +
|
|
760
|
+
'Open the Model document first (`vgai blender-mcp` opens it before its first call).',
|
|
711
761
|
);
|
|
712
762
|
}
|
|
713
763
|
|
|
714
764
|
export function blenderRuntime(): BlenderRuntime {
|
|
765
|
+
const conflict = modelDocumentConflict();
|
|
766
|
+
if (conflict) throw new Error(conflict);
|
|
715
767
|
if (runtime !== null) return runtime;
|
|
716
768
|
watchSessionEnd();
|
|
717
769
|
const lifetime = new AbortController();
|
|
@@ -747,6 +799,8 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
747
799
|
}
|
|
748
800
|
},
|
|
749
801
|
present: async (frame, description, capture) => {
|
|
802
|
+
const conflict = modelDocumentConflict();
|
|
803
|
+
if (conflict) throw new Error(conflict);
|
|
750
804
|
const documentId = presentationDocumentId();
|
|
751
805
|
const endWait = beginBlenderWork('waiting for Model presenter');
|
|
752
806
|
let view: RuntimeView;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-blender",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.7",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
]
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@volter/blender-engine": "0.1.
|
|
65
|
-
"@volter/editor-threejs": "0.5.
|
|
64
|
+
"@volter/blender-engine": "0.1.7",
|
|
65
|
+
"@volter/editor-threejs": "0.5.64",
|
|
66
66
|
"zod": "^4.3.6"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@volter/editor-sdk": "0.5.
|
|
70
|
-
"@volter/editor-project": "0.5.
|
|
69
|
+
"@volter/editor-sdk": "0.5.64",
|
|
70
|
+
"@volter/editor-project": "0.5.64",
|
|
71
71
|
"react": "^19.0.0",
|
|
72
72
|
"three": "^0.180.0"
|
|
73
73
|
},
|