@volter/blender-engine 0.1.1 → 0.1.3
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/browser/protocol.ts +2 -0
- package/browser/runtime.ts +38 -1
- package/browser/worker.ts +39 -59
- package/package.json +1 -1
package/browser/protocol.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* worker; the model lives in the worker's Python and nowhere else. */
|
|
3
3
|
|
|
4
4
|
export type WorkerRequest =
|
|
5
|
+
| { id: number; op: 'flush-document' }
|
|
5
6
|
| { id: number; op: 'history-begin' | 'history-end' }
|
|
6
7
|
| { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
|
|
7
8
|
/** `document` is the session's `.blend`, PROJECT-RELATIVE (`models/model.blend`
|
|
@@ -93,6 +94,7 @@ export type WorkerRequest =
|
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
export type WorkerReply =
|
|
97
|
+
| { op: 'document-dirty'; dirty: boolean }
|
|
96
98
|
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
97
99
|
| { id: number; result: unknown }
|
|
98
100
|
| { id: number; error: string }
|
package/browser/runtime.ts
CHANGED
|
@@ -139,6 +139,14 @@ export class BlenderRuntime {
|
|
|
139
139
|
>();
|
|
140
140
|
#nextId = 0;
|
|
141
141
|
#started: Promise<RuntimeStart> | null = null;
|
|
142
|
+
#stopping: Promise<void> | null = null;
|
|
143
|
+
#terminated = false;
|
|
144
|
+
#dirty = false;
|
|
145
|
+
readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
|
|
146
|
+
if (!this.#dirty && this.#pending.size === 0) return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
event.returnValue = '';
|
|
149
|
+
};
|
|
142
150
|
/** `performance.now()` at the `postMessage` of every outstanding call. */
|
|
143
151
|
readonly #callStarts = new Map<number, number>();
|
|
144
152
|
#lastCallMs: number | null = null;
|
|
@@ -157,6 +165,7 @@ export class BlenderRuntime {
|
|
|
157
165
|
type: 'module',
|
|
158
166
|
name: 'blender',
|
|
159
167
|
});
|
|
168
|
+
globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
|
|
160
169
|
this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
|
|
161
170
|
this.#worker.onerror = (event) => {
|
|
162
171
|
// A module worker that fails to LOAD reports an ErrorEvent with an empty
|
|
@@ -410,7 +419,29 @@ export class BlenderRuntime {
|
|
|
410
419
|
return this.#presented;
|
|
411
420
|
}
|
|
412
421
|
|
|
422
|
+
/** Drain accepted calls and persist the document before destroying its only
|
|
423
|
+
* copy. A failed save keeps the worker alive so the caller can retry. */
|
|
424
|
+
stop(): Promise<void> {
|
|
425
|
+
if (this.#terminated) return Promise.resolve();
|
|
426
|
+
if (this.#stopping) return this.#stopping;
|
|
427
|
+
this.#stopping = (async () => {
|
|
428
|
+
if (this.#started) {
|
|
429
|
+
await this.#started;
|
|
430
|
+
await this.#request({ op: 'flush-document' }, true);
|
|
431
|
+
}
|
|
432
|
+
this.terminate();
|
|
433
|
+
})().catch(error => {
|
|
434
|
+
this.#stopping = null;
|
|
435
|
+
throw error;
|
|
436
|
+
});
|
|
437
|
+
return this.#stopping;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Forced teardown for a lost session; explicit user stops must use stop(). */
|
|
413
441
|
terminate(): void {
|
|
442
|
+
if (this.#terminated) return;
|
|
443
|
+
this.#terminated = true;
|
|
444
|
+
globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
|
|
414
445
|
this.#worker.terminate();
|
|
415
446
|
const error = new Error('The Blender session was terminated');
|
|
416
447
|
for (const id of [...this.#pending.keys()]) this.#settled(id);
|
|
@@ -470,7 +501,9 @@ export class BlenderRuntime {
|
|
|
470
501
|
this.#lastCallWindow = { start: started, end: started + elapsed };
|
|
471
502
|
}
|
|
472
503
|
|
|
473
|
-
#request(request: Request): Promise<unknown> {
|
|
504
|
+
#request(request: Request, shutdown = false): Promise<unknown> {
|
|
505
|
+
if (this.#terminated || (this.#stopping && !shutdown))
|
|
506
|
+
return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
474
507
|
const id = ++this.#nextId;
|
|
475
508
|
return new Promise((resolve, reject) => {
|
|
476
509
|
this.#pending.set(id, { resolve, reject });
|
|
@@ -483,6 +516,10 @@ export class BlenderRuntime {
|
|
|
483
516
|
|
|
484
517
|
async #receive(reply: WorkerReply): Promise<void> {
|
|
485
518
|
if ('op' in reply) {
|
|
519
|
+
if (reply.op === 'document-dirty') {
|
|
520
|
+
this.#dirty = reply.dirty;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
486
523
|
if (reply.op === 'history') {
|
|
487
524
|
this.#options.history?.(reply.entries);
|
|
488
525
|
return;
|
package/browser/worker.ts
CHANGED
|
@@ -19,18 +19,17 @@
|
|
|
19
19
|
* before every call, and what the session writes is mirrored back out by the
|
|
20
20
|
* transport (`list-files`/`read-file`).
|
|
21
21
|
*
|
|
22
|
-
* THE DOCUMENT'S
|
|
23
|
-
* session that has a clock. Python's loop cannot ask the tab for anything
|
|
22
|
+
* THE DOCUMENT'S SAVE BARRIER LIVES HERE. Python's loop cannot ask the tab for anything
|
|
24
23
|
* while it is idle — `serveAsks` only runs inside a request's poll loop, so an
|
|
25
24
|
* `ask` raised between calls is never answered and wedges the Blender pthread.
|
|
26
|
-
* So the session marks a present `saveDue`, this file
|
|
27
|
-
*
|
|
25
|
+
* So the session marks a present `saveDue`, this file finishes the command,
|
|
26
|
+
* calls `save-document` as an ordinary request (which Python's loop
|
|
28
27
|
* picks up BETWEEN calls, never mid-call), and carries the bytes to the
|
|
29
|
-
* project through `/__editor/blender-document
|
|
28
|
+
* project through `/__editor/blender-document` BEFORE acknowledging the edit.
|
|
30
29
|
*
|
|
31
30
|
* WHY THE CARRY IS NOT THE MIRROR'S JOB (`vgai blender-mcp`, class Mirror):
|
|
32
31
|
* the Mirror is pull-based and runs only after an `execute_blender_code`, so
|
|
33
|
-
* a document saved
|
|
32
|
+
* a document saved after the LAST call of a modeling session
|
|
34
33
|
* would never leave the worker — which is exactly the state this closes
|
|
35
34
|
* ("closing the tab loses the model"). The Mirror still lists and mirrors the
|
|
36
35
|
* same file in the MCP lane; it just is not what persistence depends on.
|
|
@@ -114,36 +113,29 @@ let engine: BlenderEngine | null = null;
|
|
|
114
113
|
// the only one that crosses to the server, which joins it to its own root so
|
|
115
114
|
// no host path is ever on the wire.
|
|
116
115
|
let documentPath: string | null = null;
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
let documentDirty = false;
|
|
117
|
+
// Commands and saves share one lane. In particular a flush cannot overtake
|
|
118
|
+
// an accepted edit, and a later edit cannot race an upload of older bytes.
|
|
119
|
+
let workTail: Promise<unknown> = Promise.resolve();
|
|
120
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
121
|
+
const result = workTail.then(work);
|
|
122
|
+
workTail = result.catch(() => undefined);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
125
|
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
saveTimer = setTimeout(() => {
|
|
130
|
-
saveTimer = null;
|
|
131
|
-
void saveDocument();
|
|
132
|
-
}, DOCUMENT_SAVE_IDLE_MS);
|
|
126
|
+
function setDocumentDirty(dirty: boolean): void {
|
|
127
|
+
documentDirty = dirty;
|
|
128
|
+
post({ op: 'document-dirty', dirty });
|
|
133
129
|
}
|
|
134
130
|
|
|
135
131
|
/**
|
|
136
132
|
* Save the document and land it in the project.
|
|
137
133
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
134
|
+
* Only called inside the command lane, after previous calls have finished.
|
|
135
|
+
* Failure is a rejection: explicit shutdown must retain the live model.
|
|
140
136
|
*/
|
|
141
137
|
async function saveDocument(): Promise<void> {
|
|
142
138
|
if (!engine || documentPath === null) return;
|
|
143
|
-
if (callsInFlight > 0) {
|
|
144
|
-
armDocumentSave();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
139
|
const relative = documentPath;
|
|
148
140
|
let answer: { saved?: boolean; path?: string; size?: number };
|
|
149
141
|
try {
|
|
@@ -151,22 +143,15 @@ async function saveDocument(): Promise<void> {
|
|
|
151
143
|
} catch (error) {
|
|
152
144
|
// A document that cannot be written is the session's work at risk, so it
|
|
153
145
|
// is a named condition in the editor's console, not a debug line.
|
|
154
|
-
|
|
155
|
-
'error',
|
|
156
|
-
`@@VGAI-ERROR the Blender document ${relative} could not be saved: ${describeThrown(error)}`,
|
|
157
|
-
);
|
|
158
|
-
return;
|
|
146
|
+
throw new Error(`The Blender document ${relative} could not be saved: ${describeThrown(error)}`);
|
|
159
147
|
}
|
|
160
|
-
if (!answer?.saved || typeof answer.path !== 'string')
|
|
148
|
+
if (!answer?.saved || typeof answer.path !== 'string')
|
|
149
|
+
throw new Error(`Blender did not save the document ${relative}`);
|
|
161
150
|
let bytes: Uint8Array;
|
|
162
151
|
try {
|
|
163
152
|
bytes = await engine.files.readFile(answer.path);
|
|
164
153
|
} catch (error) {
|
|
165
|
-
|
|
166
|
-
'error',
|
|
167
|
-
`@@VGAI-ERROR the Blender document ${relative} was saved but could not be read back out of the engine: ${describeThrown(error)}`,
|
|
168
|
-
);
|
|
169
|
-
return;
|
|
154
|
+
throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
|
|
170
155
|
}
|
|
171
156
|
// The engine's copy is now the newer one, so the stager must stop treating
|
|
172
157
|
// this path as the host's: an entry left in `staged` would make the next
|
|
@@ -181,19 +166,12 @@ async function saveDocument(): Promise<void> {
|
|
|
181
166
|
});
|
|
182
167
|
if (!posted.ok) {
|
|
183
168
|
const said = await posted.text().catch(() => '');
|
|
184
|
-
|
|
185
|
-
'error',
|
|
186
|
-
`@@VGAI-ERROR the Blender document ${relative} was not written to the project: HTTP ${posted.status} ${said}`,
|
|
187
|
-
);
|
|
188
|
-
return;
|
|
169
|
+
throw new Error(`HTTP ${posted.status} ${said}`);
|
|
189
170
|
}
|
|
190
171
|
} catch (error) {
|
|
191
|
-
|
|
192
|
-
'error',
|
|
193
|
-
`@@VGAI-ERROR the Blender document ${relative} was not written to the project: ${describeThrown(error)}`,
|
|
194
|
-
);
|
|
195
|
-
return;
|
|
172
|
+
throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
|
|
196
173
|
}
|
|
174
|
+
setDocumentDirty(false);
|
|
197
175
|
log('log', `@@VGAI-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
|
|
198
176
|
}
|
|
199
177
|
|
|
@@ -206,9 +184,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
206
184
|
log,
|
|
207
185
|
ask: async ({ frame, capture, saveDue }) => {
|
|
208
186
|
if (!holder.engine) throw new Error('The Blender session presented before it started');
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
if (saveDue) armDocumentSave();
|
|
187
|
+
// Save once after the whole command, never during a partial frame.
|
|
188
|
+
if (saveDue && documentPath !== null) setDocumentDirty(true);
|
|
212
189
|
// THE ARENA IS READ ONCE, HERE, and both readers share those bytes: the
|
|
213
190
|
// typed arrays the tab draws from, and the record of what was sent
|
|
214
191
|
// (`describeFrame`). After the post the buffers are detached and the
|
|
@@ -480,6 +457,9 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
480
457
|
* shape the caller wants (the RNA door). */
|
|
481
458
|
const ask = engine.request.bind(engine);
|
|
482
459
|
switch (request.op) {
|
|
460
|
+
case 'flush-document':
|
|
461
|
+
await saveDocument();
|
|
462
|
+
return { saved: true };
|
|
483
463
|
case 'history-begin':
|
|
484
464
|
case 'history-end':
|
|
485
465
|
return ask({ op: request.op });
|
|
@@ -638,17 +618,19 @@ function reportMemory(): void {
|
|
|
638
618
|
if (bytes !== null) post({ op: 'memory', bytes });
|
|
639
619
|
}
|
|
640
620
|
|
|
641
|
-
self.onmessage =
|
|
621
|
+
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
642
622
|
const request = event.data;
|
|
643
623
|
if (request.op === 'present-result') {
|
|
644
|
-
|
|
624
|
+
void handle(request);
|
|
645
625
|
return;
|
|
646
626
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
627
|
+
void enqueue(() => answerRequest(request));
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
650
631
|
try {
|
|
651
632
|
const result = await handle(request);
|
|
633
|
+
if (documentDirty) await saveDocument();
|
|
652
634
|
await reportHistory();
|
|
653
635
|
post({ id: request.id, result });
|
|
654
636
|
} catch (error) {
|
|
@@ -661,13 +643,11 @@ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|
|
661
643
|
message += `\nUnable to deliver Blender history: ${describeThrown(historyError)}`;
|
|
662
644
|
}
|
|
663
645
|
post({ id: request.id, error: message });
|
|
664
|
-
} finally {
|
|
665
|
-
callsInFlight -= 1;
|
|
666
646
|
}
|
|
667
647
|
// AFTER the answer, never before it: the reading is a passenger and must not
|
|
668
648
|
// sit between a finished call and the reply the caller is waiting on.
|
|
669
649
|
reportMemory();
|
|
670
|
-
}
|
|
650
|
+
}
|
|
671
651
|
|
|
672
652
|
async function reportHistory(): Promise<void> {
|
|
673
653
|
if (!engine || !session) return;
|