@volter/editor-blender 0.1.5 → 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
- readFor = null;
214
- publish({ context: null, views: new Map(), error: null, loading: subject !== null });
215
- if (subject !== null) scheduleContextRead(subject);
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
- publish({ loading: true, object, collection, context: null, error: null, views: new Map() });
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
- if (inFlight.has(path) || !blenderSessionStarted()) return undefined;
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 undefined;
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
- const views = new Map(state.views);
355
- views.delete(path);
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(() => ({ root: view.root, dispose() {} }), []);
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
- let boundModel: { readonly documentId: string; readonly entryId: string; readonly blend: string } | null = null;
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
- /** Called by the Model document when it mounts (and with `null` when it
212
- * unmounts): the document id the host published its context under, and the
213
- * project-relative `.blend` the engine should have open. */
214
- export function bindModelDocument(bound: { documentId: string; entryId: string; blend: string } | null): void {
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
- * Idempotent and quiet: the runtime binds one project and one document for the
237
- * tab's lifetime, so a second Model document mounting over a live session
238
- * simply presents what that session holds. A project with no open session has
239
- * no engine to open anything in, and says nothing.
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(documentId: string, blend: string, entryId: string): Promise<void> {
242
- bindModelDocument({ documentId, entryId, blend });
268
+ export async function openModelDocumentBlend(
269
+ binding: ModelDocumentBinding,
270
+ publish: () => void,
271
+ ): Promise<boolean> {
243
272
  const host = editorHost();
244
- if (!host.session.open()) return;
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) return;
247
- await blenderRuntime().start(project, blend);
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 blenderRuntime().present();
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
  /**
@@ -650,13 +687,24 @@ let captureLifetime: AbortController | null = null;
650
687
  * terminate, and the host door only exists inside a running editor. */
651
688
  let watchingSessionEnd = false;
652
689
 
690
+ /** Diagnostics must not prevent teardown if the host has already detached. */
691
+ function beginBlenderWork(label: string): () => void {
692
+ try {
693
+ const end = editorHost().session.beginWork(`Blender: ${label}`);
694
+ return () => { try { end(); } catch { /* preserve the operation's result */ } };
695
+ } catch { return () => {}; }
696
+ }
697
+
653
698
  function terminateBlenderRuntime(): void {
654
- invalidateBlenderHistory();
655
- captureLifetime?.abort();
656
- captureLifetime = null;
657
- runtime?.terminate();
658
- runtime = null;
659
- lastCapture = null;
699
+ const end = beginBlenderWork('invalidating history and releasing runtime');
700
+ try {
701
+ invalidateBlenderHistory();
702
+ captureLifetime?.abort();
703
+ captureLifetime = null;
704
+ runtime?.terminate();
705
+ runtime = null;
706
+ lastCapture = null;
707
+ } finally { end(); }
660
708
  }
661
709
 
662
710
  // THE ENGINE DIES WITH THE SESSION, whether or not the tab can. A page told
@@ -690,23 +738,39 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
690
738
 
691
739
  async function runtimeView(): Promise<RuntimeView> {
692
740
  const { documents } = editorHost();
693
- const published = await documents.waitForContext(presentationDocumentId(), 15_000);
694
- if (isRuntimeView(published)) return published;
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
+ }
695
756
  throw new Error(
696
- 'The Blender Model document is not open, or the open one is not a Model this engine can present ' +
697
- 'to (it must answer applyFrame, captureSnapshot, recordPresentation and recordPhotograph): present ' +
698
- '{kind: "document", id: "blender:runtime"} ' +
699
- 'first (it ships with the editor; `vgai blender-mcp` presents it before its first call).',
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).',
700
761
  );
701
762
  }
702
763
 
703
764
  export function blenderRuntime(): BlenderRuntime {
765
+ const conflict = modelDocumentConflict();
766
+ if (conflict) throw new Error(conflict);
704
767
  if (runtime !== null) return runtime;
705
768
  watchSessionEnd();
706
769
  const lifetime = new AbortController();
707
770
  captureLifetime = lifetime;
708
771
  let photographing = false;
709
772
  runtime = new BlenderRuntime({
773
+ work: beginBlenderWork,
710
774
  history: (entries) => {
711
775
  const owner = runtime;
712
776
  if (!owner) throw new Error('Blender history arrived before its runtime');
@@ -735,8 +799,12 @@ export function blenderRuntime(): BlenderRuntime {
735
799
  }
736
800
  },
737
801
  present: async (frame, description, capture) => {
802
+ const conflict = modelDocumentConflict();
803
+ if (conflict) throw new Error(conflict);
738
804
  const documentId = presentationDocumentId();
739
- const view = await runtimeView();
805
+ const endWait = beginBlenderWork('waiting for Model presenter');
806
+ let view: RuntimeView;
807
+ try { view = await runtimeView(); } finally { endWait(); }
740
808
  lifetime.signal.throwIfAborted();
741
809
  if (presentationDocumentId() !== documentId)
742
810
  throw new Error('Blender document changed before its frame could be presented');
@@ -747,7 +815,11 @@ export function blenderRuntime(): BlenderRuntime {
747
815
  // still-running worker (`blender-runtime-view.ts::applyFrame` says how).
748
816
  // A document that answers `applyFrame` without one reports no `held` at
749
817
  // all, rather than a wrong `null`.
750
- const applied = view.applyFrame(frame) as { held?: unknown } | null | undefined;
818
+ const endApply = beginBlenderWork('applying frame to Model');
819
+ const applied = (() => {
820
+ try { return view.applyFrame(frame) as { held?: unknown } | null | undefined; }
821
+ finally { endApply(); }
822
+ })();
751
823
  const reports = typeof applied === 'object' && applied !== null && 'held' in applied;
752
824
  const held = reports ? (applied.held as { session: string; revision: number } | null) : null;
753
825
  const answer = (capture: unknown): PresentAnswer => ({
@@ -893,6 +965,7 @@ export function blenderRuntime(): BlenderRuntime {
893
965
  await snapshot.prepare(renderCamera);
894
966
  assertBinding();
895
967
  const captureOptions = {
968
+ effect: snapshot.effect,
896
969
  width: render.width,
897
970
  height: render.height,
898
971
  transparent: render.transparent === true,
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",
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.5",
65
- "@volter/editor-threejs": "0.5.62",
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": "*",
70
- "@volter/editor-project": "*",
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
  },