@volter/blender-engine 0.1.0 → 0.1.2
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/blender-emscripten-engine.mts +31 -0
- package/browser/protocol.ts +13 -1
- package/browser/runtime.ts +59 -3
- package/browser/session.py +180 -22
- package/browser/three/blender-runtime-lighting.ts +2 -0
- package/browser/three/world-field-sampler.ts +14 -6
- package/browser/worker.ts +70 -62
- package/package.json +1 -1
- package/wasm/essentials.bin.br +0 -0
- package/wasm/essentials.json +112 -0
|
@@ -267,6 +267,8 @@ export async function startEmscriptenBlenderEngine(
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
const files = moduleFiles(module);
|
|
270
|
+
FS.chmod('/bw/datafiles', 0o755);
|
|
271
|
+
await mountEssentials(files);
|
|
270
272
|
const { request } = openSessionChannel(files, options);
|
|
271
273
|
|
|
272
274
|
return {
|
|
@@ -287,3 +289,32 @@ export async function startEmscriptenBlenderEngine(
|
|
|
287
289
|
releasedPayloadBytes,
|
|
288
290
|
};
|
|
289
291
|
}
|
|
292
|
+
|
|
293
|
+
/** Assets are data, not a second engine. Ship them separately so a data update
|
|
294
|
+
* does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
|
|
295
|
+
* checked before anything enters Blender's filesystem. */
|
|
296
|
+
async function mountEssentials(files: BlenderFiles): Promise<void> {
|
|
297
|
+
const [indexResponse, payloadResponse] = await Promise.all([
|
|
298
|
+
fetch(artifactUrl('essentials.json')), fetch(artifactUrl('essentials.bin')),
|
|
299
|
+
]);
|
|
300
|
+
if (!indexResponse.ok || !payloadResponse.ok)
|
|
301
|
+
throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
|
|
302
|
+
const index = await indexResponse.json() as {
|
|
303
|
+
bytes: number; sha256: string;
|
|
304
|
+
files: { path: string; offset: number; bytes: number; sha256: string }[];
|
|
305
|
+
};
|
|
306
|
+
const payload = await payloadResponse.arrayBuffer();
|
|
307
|
+
const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', payload)),
|
|
308
|
+
(byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
309
|
+
if (payload.byteLength !== index.bytes || hash !== index.sha256)
|
|
310
|
+
throw new Error('Blender Essentials payload does not match its source manifest');
|
|
311
|
+
for (const file of index.files) {
|
|
312
|
+
if (!file.path || file.path.includes('\\') || file.path.split('/').some(p => !p || p === '.' || p === '..') ||
|
|
313
|
+
!Number.isInteger(file.offset) || !Number.isInteger(file.bytes) ||
|
|
314
|
+
file.offset < 0 || file.bytes < 0 || file.offset + file.bytes > payload.byteLength)
|
|
315
|
+
throw new Error(`Invalid Blender Essentials file: ${file.path}`);
|
|
316
|
+
const path = `/bw/datafiles/assets/${file.path}`;
|
|
317
|
+
await files.mkdirTree(path.slice(0, path.lastIndexOf('/')));
|
|
318
|
+
await files.writeFile(path, new Uint8Array(payload, file.offset, file.bytes));
|
|
319
|
+
}
|
|
320
|
+
}
|
package/browser/protocol.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
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' }
|
|
6
|
+
| { id: number; op: 'history-begin' | 'history-end' }
|
|
7
|
+
| { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
|
|
5
8
|
/** `document` is the session's `.blend`, PROJECT-RELATIVE (`models/model.blend`
|
|
6
9
|
* by default). An existing one is opened at start; an absent one starts
|
|
7
10
|
* empty and is created by the first save. */
|
|
8
11
|
| { id: number; op: 'start'; project: string; document?: string }
|
|
9
|
-
| { id: number; op: 'execute'; code: string }
|
|
12
|
+
| { id: number; op: 'execute'; code: string; history?: boolean; label?: string }
|
|
10
13
|
| { id: number; op: 'scene-info' }
|
|
11
14
|
| { id: number; op: 'object-info'; name: string }
|
|
12
15
|
| { id: number; op: 'screenshot-view'; maxSize: number }
|
|
@@ -32,6 +35,7 @@ export type WorkerRequest =
|
|
|
32
35
|
| {
|
|
33
36
|
id: number;
|
|
34
37
|
op: 'rna-set';
|
|
38
|
+
history?: boolean;
|
|
35
39
|
path: string;
|
|
36
40
|
property: string;
|
|
37
41
|
value: unknown;
|
|
@@ -90,6 +94,8 @@ export type WorkerRequest =
|
|
|
90
94
|
};
|
|
91
95
|
|
|
92
96
|
export type WorkerReply =
|
|
97
|
+
| { op: 'document-dirty'; dirty: boolean }
|
|
98
|
+
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
93
99
|
| { id: number; result: unknown }
|
|
94
100
|
| { id: number; error: string }
|
|
95
101
|
/** The worker asks the tab to display a frame (and to remember the view a
|
|
@@ -121,6 +127,12 @@ export type WorkerReply =
|
|
|
121
127
|
*/
|
|
122
128
|
| { op: 'memory'; bytes: number };
|
|
123
129
|
|
|
130
|
+
export type NativeHistoryEntry = { reset: true } | {
|
|
131
|
+
id: string;
|
|
132
|
+
label: string;
|
|
133
|
+
resource: string | null;
|
|
134
|
+
};
|
|
135
|
+
|
|
124
136
|
export interface CaptureRequest {
|
|
125
137
|
/** A viewport screenshot's bound, the longer side of a square frame.
|
|
126
138
|
* Absent on a render, which states its exact pixel dimensions instead. */
|
package/browser/runtime.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
RuntimeStart,
|
|
10
10
|
WorkerReply,
|
|
11
11
|
WorkerRequest,
|
|
12
|
+
NativeHistoryEntry,
|
|
12
13
|
} from './protocol';
|
|
13
14
|
import type {
|
|
14
15
|
BlenderActionClip,
|
|
@@ -26,6 +27,7 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
|
|
|
26
27
|
type Request = DistributiveOmit<WorkerRequest, 'id'>;
|
|
27
28
|
|
|
28
29
|
export interface BlenderRuntimeOptions {
|
|
30
|
+
history?(entries: readonly NativeHistoryEntry[]): void;
|
|
29
31
|
/** Display a frame. A screenshot `capture` has its view REMEMBERED so the
|
|
30
32
|
* next document capture photographs what the agent asked for; a render
|
|
31
33
|
* capture (`capture.render`) is photographed here and now, and its answer
|
|
@@ -137,6 +139,14 @@ export class BlenderRuntime {
|
|
|
137
139
|
>();
|
|
138
140
|
#nextId = 0;
|
|
139
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
|
+
};
|
|
140
150
|
/** `performance.now()` at the `postMessage` of every outstanding call. */
|
|
141
151
|
readonly #callStarts = new Map<number, number>();
|
|
142
152
|
#lastCallMs: number | null = null;
|
|
@@ -155,6 +165,7 @@ export class BlenderRuntime {
|
|
|
155
165
|
type: 'module',
|
|
156
166
|
name: 'blender',
|
|
157
167
|
});
|
|
168
|
+
globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
|
|
158
169
|
this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
|
|
159
170
|
this.#worker.onerror = (event) => {
|
|
160
171
|
// A module worker that fails to LOAD reports an ErrorEvent with an empty
|
|
@@ -222,9 +233,20 @@ export class BlenderRuntime {
|
|
|
222
233
|
return this.start(this.#project);
|
|
223
234
|
}
|
|
224
235
|
|
|
225
|
-
async execute(code: string): Promise<string> {
|
|
236
|
+
async execute(code: string, history = true, label = 'Blender Python'): Promise<string> {
|
|
226
237
|
await this.#ready();
|
|
227
|
-
return (await this.#request({ op: 'execute', code })) as string;
|
|
238
|
+
return (await this.#request({ op: 'execute', code, history, label })) as string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async historyGesture(op: 'history-begin' | 'history-end'): Promise<void> {
|
|
242
|
+
await this.#ready();
|
|
243
|
+
await this.#request({ op });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async historyStep(token: string, direction: 'undo' | 'redo'): Promise<boolean> {
|
|
247
|
+
await this.#ready();
|
|
248
|
+
const result = await this.#request({ op: 'history-step', token, direction }) as { moved: boolean };
|
|
249
|
+
return result.moved;
|
|
228
250
|
}
|
|
229
251
|
|
|
230
252
|
async sceneInfo(): Promise<string> {
|
|
@@ -267,10 +289,12 @@ export class BlenderRuntime {
|
|
|
267
289
|
property: string,
|
|
268
290
|
value: unknown,
|
|
269
291
|
index?: number,
|
|
292
|
+
history = true,
|
|
270
293
|
): Promise<BlenderRnaWrite> {
|
|
271
294
|
await this.#ready();
|
|
272
295
|
return (await this.#request({
|
|
273
296
|
op: 'rna-set',
|
|
297
|
+
history,
|
|
274
298
|
path,
|
|
275
299
|
property,
|
|
276
300
|
value,
|
|
@@ -395,7 +419,29 @@ export class BlenderRuntime {
|
|
|
395
419
|
return this.#presented;
|
|
396
420
|
}
|
|
397
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(). */
|
|
398
441
|
terminate(): void {
|
|
442
|
+
if (this.#terminated) return;
|
|
443
|
+
this.#terminated = true;
|
|
444
|
+
globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
|
|
399
445
|
this.#worker.terminate();
|
|
400
446
|
const error = new Error('The Blender session was terminated');
|
|
401
447
|
for (const id of [...this.#pending.keys()]) this.#settled(id);
|
|
@@ -455,7 +501,9 @@ export class BlenderRuntime {
|
|
|
455
501
|
this.#lastCallWindow = { start: started, end: started + elapsed };
|
|
456
502
|
}
|
|
457
503
|
|
|
458
|
-
#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'));
|
|
459
507
|
const id = ++this.#nextId;
|
|
460
508
|
return new Promise((resolve, reject) => {
|
|
461
509
|
this.#pending.set(id, { resolve, reject });
|
|
@@ -468,6 +516,14 @@ export class BlenderRuntime {
|
|
|
468
516
|
|
|
469
517
|
async #receive(reply: WorkerReply): Promise<void> {
|
|
470
518
|
if ('op' in reply) {
|
|
519
|
+
if (reply.op === 'document-dirty') {
|
|
520
|
+
this.#dirty = reply.dirty;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (reply.op === 'history') {
|
|
524
|
+
this.#options.history?.(reply.entries);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
471
527
|
if (reply.op === 'log') {
|
|
472
528
|
this.#options.log?.(reply.level, reply.text);
|
|
473
529
|
return;
|
package/browser/session.py
CHANGED
|
@@ -194,6 +194,7 @@ def _load_post(_arg):
|
|
|
194
194
|
# in wasm (35 s at 1280x720, 48 samples) and `_photograph` never ran. The
|
|
195
195
|
# engine ids are taken again on every load.
|
|
196
196
|
ENGINES[:], UNAVAILABLE_ENGINES[:] = _register_engine()
|
|
197
|
+
HISTORY.reset()
|
|
197
198
|
|
|
198
199
|
|
|
199
200
|
# ---------------------------------------------------------------- warnings
|
|
@@ -352,12 +353,11 @@ def _describe_background(background, camera_ray):
|
|
|
352
353
|
"inputs": [{"kind": "math", "operation": "ADD", "clamp": False,
|
|
353
354
|
"inputs": products[:2]}, products[2]]}
|
|
354
355
|
if kind == "ShaderNodeMapping" and socket == "Vector":
|
|
355
|
-
if node.vector_type != "POINT":
|
|
356
|
-
raise NotImplementedError("World Mapping vector type %s" % node.vector_type)
|
|
357
356
|
for name in ("Location", "Rotation", "Scale"):
|
|
358
357
|
if node.inputs[name].links:
|
|
359
358
|
raise NotImplementedError("Linked World Mapping " + name)
|
|
360
|
-
return {"kind": "mapping", "
|
|
359
|
+
return {"kind": "mapping", "vector_type": node.vector_type,
|
|
360
|
+
"vector": value(node.inputs["Vector"]),
|
|
361
361
|
"location": [float(c) for c in node.inputs["Location"].default_value],
|
|
362
362
|
"rotation": [float(c) for c in node.inputs["Rotation"].default_value],
|
|
363
363
|
"scale": [float(c) for c in node.inputs["Scale"].default_value]}
|
|
@@ -402,9 +402,17 @@ def _describe_background(background, camera_ray):
|
|
|
402
402
|
finally:
|
|
403
403
|
visiting.remove(key)
|
|
404
404
|
|
|
405
|
+
color = value(background.inputs["Color"])
|
|
406
|
+
strength = value(background.inputs["Strength"])
|
|
407
|
+
# The field evaluator already implements an unclamped color mix. Mixing
|
|
408
|
+
# black with radiance at factor strength is exact multiplication, including
|
|
409
|
+
# spatially varying strength, with no second shader implementation.
|
|
410
|
+
if not isinstance(strength, (int, float)):
|
|
411
|
+
color = {"kind": "mix_color", "factor": strength, "a": [0.0, 0.0, 0.0],
|
|
412
|
+
"b": color, "clamp_factor": False, "clamp_result": False}
|
|
413
|
+
strength = 1.0
|
|
405
414
|
return {"color": [float(c) for c in list(background.inputs["Color"].default_value)[:3]],
|
|
406
|
-
"strength": float(
|
|
407
|
-
"shader": value(background.inputs["Color"])}
|
|
415
|
+
"strength": float(strength), "shader": color}
|
|
408
416
|
|
|
409
417
|
|
|
410
418
|
def _describe_world(world):
|
|
@@ -417,18 +425,17 @@ def _describe_world(world):
|
|
|
417
425
|
if outputs[0].inputs["Volume"].links:
|
|
418
426
|
raise NotImplementedError("World volume rendering is not implemented")
|
|
419
427
|
background, lighting = _surface_backgrounds(surface[0].from_node)
|
|
420
|
-
for node in (background, lighting):
|
|
421
|
-
if node is not None and node.inputs["Strength"].links:
|
|
422
|
-
raise NotImplementedError("Linked World Background strength is not implemented")
|
|
423
428
|
described = _describe_background(background, camera_ray=True)
|
|
424
429
|
lighting_data = _describe_background(lighting or background, camera_ray=False)
|
|
425
430
|
if lighting_data != described:
|
|
426
431
|
described["lighting"] = lighting_data
|
|
427
|
-
#
|
|
428
|
-
|
|
432
|
+
# Only a wholly constant background can drop the expression: linked
|
|
433
|
+
# Strength may carry spatial radiance even when Color itself is constant.
|
|
434
|
+
if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
|
|
429
435
|
described.pop("shader", None)
|
|
430
|
-
|
|
431
|
-
|
|
436
|
+
if "lighting" in described and not any(
|
|
437
|
+
(lighting or background).inputs[name].is_linked for name in ("Color", "Strength")):
|
|
438
|
+
described["lighting"].pop("shader", None)
|
|
432
439
|
return described
|
|
433
440
|
|
|
434
441
|
|
|
@@ -780,9 +787,8 @@ class Session:
|
|
|
780
787
|
# How many COLUMN BYTES this frame actually shipped: a move ships
|
|
781
788
|
# zero, a vertex edit one mesh's worth.
|
|
782
789
|
"bytes": int(_blender_web.buffer_size()),
|
|
783
|
-
# Blender's `is_dirty
|
|
784
|
-
#
|
|
785
|
-
# why `save_document` does not read it. See the note there.
|
|
790
|
+
# Blender's `is_dirty` is instrumentation, not our save predicate:
|
|
791
|
+
# its value also depends on native undo initialization/checkpoints.
|
|
786
792
|
"dirty": bool(bpy.data.is_dirty),
|
|
787
793
|
}
|
|
788
794
|
# A PRESENT IS WHAT LEAVES THE DOCUMENT STALE, and the predicate is the
|
|
@@ -1060,13 +1066,10 @@ class Session:
|
|
|
1060
1066
|
if self.document is None:
|
|
1061
1067
|
return {"saved": False, "reason": "no-document"}
|
|
1062
1068
|
# `bpy.data.is_dirty` IS NOT A PREDICATE HERE, and this is the measurement
|
|
1063
|
-
# rather than a preference.
|
|
1064
|
-
#
|
|
1065
|
-
#
|
|
1066
|
-
#
|
|
1067
|
-
# False. It is False before a save, False after one, and False across a
|
|
1068
|
-
# script that models an entire scene; the only state it ever named here
|
|
1069
|
-
# was "saved".
|
|
1069
|
+
# rather than a preference. Before native undo initialization it stayed
|
|
1070
|
+
# False even after a script built fourteen objects; with explicit undo
|
|
1071
|
+
# checkpoints it can stay True across read-only operations. It is not
|
|
1072
|
+
# a reliable change detector for this request-driven background session.
|
|
1070
1073
|
#
|
|
1071
1074
|
# It was briefly used to skip a redundant write, and it froze the
|
|
1072
1075
|
# document after its first save -- a reopened session could model all
|
|
@@ -1419,6 +1422,109 @@ def _register_engine():
|
|
|
1419
1422
|
return made, unavailable
|
|
1420
1423
|
|
|
1421
1424
|
|
|
1425
|
+
# ---------------------------------------------------------------- native document history
|
|
1426
|
+
|
|
1427
|
+
class NativeHistory:
|
|
1428
|
+
"""Blender owns the snapshots. These ids only address its steps from Code-OSS.
|
|
1429
|
+
|
|
1430
|
+
Background mode supports explicit undo_push (ed_undo_push_exec). Initialize
|
|
1431
|
+
after opening the document, then checkpoint at the request boundary, including
|
|
1432
|
+
scripts that mutate and subsequently fail. Never replay a script to redo it.
|
|
1433
|
+
"""
|
|
1434
|
+
def __init__(self):
|
|
1435
|
+
self.epoch = 0
|
|
1436
|
+
self.serial = 0
|
|
1437
|
+
self.steps = []
|
|
1438
|
+
self.cursor = 0
|
|
1439
|
+
self.events = []
|
|
1440
|
+
self.initialized = False
|
|
1441
|
+
self.group_depth = 0
|
|
1442
|
+
self.group_label = None
|
|
1443
|
+
self.mutation_serial = 0
|
|
1444
|
+
self.moving = False
|
|
1445
|
+
|
|
1446
|
+
def changed(self):
|
|
1447
|
+
self.mutation_serial += 1
|
|
1448
|
+
|
|
1449
|
+
def native_moved(self):
|
|
1450
|
+
# A script can invoke Blender's undo directly. It must not leave our
|
|
1451
|
+
# tokens addressing a different native cursor. Host-owned moves retain
|
|
1452
|
+
# their ledger; external moves invalidate it and start a fresh baseline.
|
|
1453
|
+
if not self.moving:
|
|
1454
|
+
self.reset()
|
|
1455
|
+
|
|
1456
|
+
def reset(self):
|
|
1457
|
+
self.epoch += 1
|
|
1458
|
+
self.steps = []
|
|
1459
|
+
self.cursor = 0
|
|
1460
|
+
self.initialized = False
|
|
1461
|
+
self.group_depth = 0
|
|
1462
|
+
self.group_label = None
|
|
1463
|
+
self.events = [{"reset": True}]
|
|
1464
|
+
|
|
1465
|
+
def begin(self):
|
|
1466
|
+
if self.initialized:
|
|
1467
|
+
return
|
|
1468
|
+
preferences = bpy.context.preferences.edit
|
|
1469
|
+
preferences.use_global_undo = True
|
|
1470
|
+
# Native eviction may exhaust history before our address ledger. poll()
|
|
1471
|
+
# below then refuses instead of claiming a restoration. Bound memory in
|
|
1472
|
+
# the browser worker; retaining hundreds of full scenes can exhaust wasm.
|
|
1473
|
+
preferences.undo_steps = 32
|
|
1474
|
+
preferences.undo_memory_limit = 256
|
|
1475
|
+
if "FINISHED" not in bpy.ops.ed.undo_push(message="Open document"):
|
|
1476
|
+
raise RuntimeError("Blender could not initialize native undo; the edit was not started")
|
|
1477
|
+
self.initialized = True
|
|
1478
|
+
|
|
1479
|
+
def commit(self, label):
|
|
1480
|
+
self.begin()
|
|
1481
|
+
if self.group_depth:
|
|
1482
|
+
self.group_label = self.group_label or label
|
|
1483
|
+
return
|
|
1484
|
+
bpy.context.view_layer.update()
|
|
1485
|
+
if "FINISHED" not in bpy.ops.ed.undo_push(message=label[:63]):
|
|
1486
|
+
raise RuntimeError("Blender could not checkpoint this edit in native undo")
|
|
1487
|
+
self.serial += 1
|
|
1488
|
+
token = "%s:%s:%s" % (SESSION.session, self.epoch, self.serial)
|
|
1489
|
+
self.steps[self.cursor:] = [token]
|
|
1490
|
+
# Blender keeps at most undo_steps native states, including the baseline.
|
|
1491
|
+
if len(self.steps) > 31:
|
|
1492
|
+
del self.steps[:-31]
|
|
1493
|
+
self.cursor = len(self.steps)
|
|
1494
|
+
self.events.append({"id": token, "label": label,
|
|
1495
|
+
"resource": SESSION.document_relative})
|
|
1496
|
+
|
|
1497
|
+
def move(self, token, direction):
|
|
1498
|
+
if self.group_depth:
|
|
1499
|
+
raise RuntimeError("Finish the current Blender gesture before undo or redo")
|
|
1500
|
+
index = self.cursor - 1 if direction == "undo" else self.cursor
|
|
1501
|
+
if index < 0 or index >= len(self.steps) or self.steps[index] != token:
|
|
1502
|
+
raise RuntimeError("Blender history expired or changed outside this edit; no other step was restored")
|
|
1503
|
+
operator = bpy.ops.ed.undo if direction == "undo" else bpy.ops.ed.redo
|
|
1504
|
+
if not operator.poll():
|
|
1505
|
+
raise RuntimeError("Blender cannot %s this native step in the current context" % direction)
|
|
1506
|
+
self.moving = True
|
|
1507
|
+
try:
|
|
1508
|
+
if "FINISHED" not in operator():
|
|
1509
|
+
raise RuntimeError("Blender did not finish %s" % direction)
|
|
1510
|
+
finally:
|
|
1511
|
+
self.moving = False
|
|
1512
|
+
self.cursor += -1 if direction == "undo" else 1
|
|
1513
|
+
# Native undo replaces datablocks. Both revision caches must forget their
|
|
1514
|
+
# old pointers before the restored scene is exported to the presenter.
|
|
1515
|
+
_blender_web.session_reset()
|
|
1516
|
+
SESSION.forget()
|
|
1517
|
+
SESSION.present()
|
|
1518
|
+
return {"moved": True}
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
HISTORY = NativeHistory()
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
@bpy.app.handlers.persistent
|
|
1525
|
+
def _history_post(_arg):
|
|
1526
|
+
HISTORY.native_moved()
|
|
1527
|
+
|
|
1422
1528
|
# ---------------------------------------------------------------- the MCP tools
|
|
1423
1529
|
|
|
1424
1530
|
def get_scene_info():
|
|
@@ -2049,6 +2155,7 @@ def rna_set(path, identifier, value, index=None):
|
|
|
2049
2155
|
setattr(target, identifier, value)
|
|
2050
2156
|
else:
|
|
2051
2157
|
getattr(target, identifier)[index] = value
|
|
2158
|
+
HISTORY.changed()
|
|
2052
2159
|
return {"path": path, "property": identifier, "value": _rna_value(target, prop)}
|
|
2053
2160
|
|
|
2054
2161
|
|
|
@@ -3925,28 +4032,35 @@ def outliner_set(path, column, value):
|
|
|
3925
4032
|
if isinstance(target, bpy.types.Object):
|
|
3926
4033
|
if column == "hide":
|
|
3927
4034
|
target.hide_set(value, view_layer=bpy.context.view_layer)
|
|
4035
|
+
HISTORY.changed()
|
|
3928
4036
|
return {"path": path, "column": column, "value": bool(target.hide_get())}
|
|
3929
4037
|
if column == "render":
|
|
3930
4038
|
target.hide_render = value
|
|
4039
|
+
HISTORY.changed()
|
|
3931
4040
|
return {"path": path, "column": column, "value": bool(target.hide_render)}
|
|
3932
4041
|
if column == "viewport":
|
|
3933
4042
|
target.hide_viewport = value
|
|
4043
|
+
HISTORY.changed()
|
|
3934
4044
|
return {"path": path, "column": column, "value": bool(target.hide_viewport)}
|
|
3935
4045
|
if isinstance(target, bpy.types.LayerCollection):
|
|
3936
4046
|
if column in ("exclude", "hide"):
|
|
3937
4047
|
member = "exclude" if column == "exclude" else "hide_viewport"
|
|
3938
4048
|
setattr(target, member, value)
|
|
4049
|
+
HISTORY.changed()
|
|
3939
4050
|
return {"path": path, "column": column, "value": bool(getattr(target, member))}
|
|
3940
4051
|
if column == "render":
|
|
3941
4052
|
target.collection.hide_render = value
|
|
4053
|
+
HISTORY.changed()
|
|
3942
4054
|
return {"path": path, "column": column, "value": bool(target.collection.hide_render)}
|
|
3943
4055
|
if isinstance(target, bpy.types.Modifier):
|
|
3944
4056
|
member = {"render": "show_render", "viewport": "show_viewport"}.get(column)
|
|
3945
4057
|
if member is not None:
|
|
3946
4058
|
setattr(target, member, not value)
|
|
4059
|
+
HISTORY.changed()
|
|
3947
4060
|
return {"path": path, "column": column, "value": not getattr(target, member)}
|
|
3948
4061
|
if isinstance(target, bpy.types.Constraint) and column == "hide":
|
|
3949
4062
|
target.enabled = not value
|
|
4063
|
+
HISTORY.changed()
|
|
3950
4064
|
return {"path": path, "column": column, "value": not target.enabled}
|
|
3951
4065
|
raise ValueError(
|
|
3952
4066
|
"Blender's Outliner draws no %r column on a %s, so nothing was written -- the columns "
|
|
@@ -3955,6 +4069,45 @@ def outliner_set(path, column, value):
|
|
|
3955
4069
|
|
|
3956
4070
|
|
|
3957
4071
|
def dispatch(request):
|
|
4072
|
+
op = request.get("op")
|
|
4073
|
+
if op == "history-begin":
|
|
4074
|
+
HISTORY.begin()
|
|
4075
|
+
HISTORY.group_depth += 1
|
|
4076
|
+
return None
|
|
4077
|
+
if op == "history-end":
|
|
4078
|
+
if HISTORY.group_depth == 0:
|
|
4079
|
+
raise RuntimeError("No Blender gesture is open")
|
|
4080
|
+
HISTORY.group_depth -= 1
|
|
4081
|
+
if HISTORY.group_depth == 0 and HISTORY.group_label:
|
|
4082
|
+
label, HISTORY.group_label = HISTORY.group_label, None
|
|
4083
|
+
HISTORY.commit(label)
|
|
4084
|
+
return None
|
|
4085
|
+
if op == "history-events":
|
|
4086
|
+
events, HISTORY.events = HISTORY.events, []
|
|
4087
|
+
return events
|
|
4088
|
+
if op == "history-step":
|
|
4089
|
+
if request["direction"] not in ("undo", "redo"):
|
|
4090
|
+
raise ValueError("Unknown history direction")
|
|
4091
|
+
return HISTORY.move(request["token"], request["direction"])
|
|
4092
|
+
mutation = op in ("execute", "rna-set", "outliner-set") and request.get("history", True)
|
|
4093
|
+
if not mutation:
|
|
4094
|
+
return _dispatch(request)
|
|
4095
|
+
HISTORY.begin()
|
|
4096
|
+
before = HISTORY.mutation_serial
|
|
4097
|
+
epoch = HISTORY.epoch
|
|
4098
|
+
try:
|
|
4099
|
+
return _dispatch(request)
|
|
4100
|
+
finally:
|
|
4101
|
+
# Loading a file resets Blender's native stack. Its final state cannot
|
|
4102
|
+
# serve as both the before and after of an invented undo checkpoint.
|
|
4103
|
+
if HISTORY.epoch == epoch and HISTORY.mutation_serial != before:
|
|
4104
|
+
HISTORY.commit(request.get("label") or {
|
|
4105
|
+
"execute": "Blender Python", "rna-set": "Set " + request.get("property", "property"),
|
|
4106
|
+
"outliner-set": "Set " + request.get("column", "visibility"),
|
|
4107
|
+
}[op])
|
|
4108
|
+
|
|
4109
|
+
|
|
4110
|
+
def _dispatch(request):
|
|
3958
4111
|
op = request.get("op")
|
|
3959
4112
|
if op == "execute":
|
|
3960
4113
|
engine = bpy.context.scene.render.engine
|
|
@@ -3967,6 +4120,9 @@ def dispatch(request):
|
|
|
3967
4120
|
absent = _absent_capability(request["code"])
|
|
3968
4121
|
if absent is not None:
|
|
3969
4122
|
return {"executed": False, "result": "", "error": absent}
|
|
4123
|
+
# Arbitrary Python can partially mutate before throwing. Once execution
|
|
4124
|
+
# starts it needs a checkpoint; capability refusals above do not.
|
|
4125
|
+
HISTORY.changed()
|
|
3970
4126
|
answer = execute(request["code"])
|
|
3971
4127
|
# Every mutation is presented, the rule: the Model
|
|
3972
4128
|
# document is what the agent is looking at.
|
|
@@ -4076,6 +4232,8 @@ def dispatch(request):
|
|
|
4076
4232
|
|
|
4077
4233
|
_prepare_directories()
|
|
4078
4234
|
bpy.app.handlers.load_post.append(_load_post)
|
|
4235
|
+
bpy.app.handlers.undo_post.append(_history_post)
|
|
4236
|
+
bpy.app.handlers.redo_post.append(_history_post)
|
|
4079
4237
|
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
4080
4238
|
_say("@@VGAI-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
4081
4239
|
"unavailableEngines": UNAVAILABLE_ENGINES}))
|
|
@@ -397,6 +397,7 @@ export type WorldExpression =
|
|
|
397
397
|
}
|
|
398
398
|
| {
|
|
399
399
|
kind: 'mapping';
|
|
400
|
+
vector_type?: 'POINT' | 'TEXTURE' | 'VECTOR' | 'NORMAL' | undefined;
|
|
400
401
|
vector: WorldExpression;
|
|
401
402
|
location: WorldVector;
|
|
402
403
|
rotation: WorldVector;
|
|
@@ -475,6 +476,7 @@ const worldExpression: z.ZodType<WorldExpression> = z.lazy(() =>
|
|
|
475
476
|
z
|
|
476
477
|
.object({
|
|
477
478
|
kind: z.literal('mapping'),
|
|
479
|
+
vector_type: z.enum(['POINT', 'TEXTURE', 'VECTOR', 'NORMAL']).optional(),
|
|
478
480
|
vector: worldExpression,
|
|
479
481
|
location: worldVector,
|
|
480
482
|
rotation: worldVector,
|
|
@@ -111,24 +111,32 @@ export function worldField(
|
|
|
111
111
|
}
|
|
112
112
|
if (expression.kind === 'mapping') {
|
|
113
113
|
const source = worldField(expression.vector, windowCoordinates);
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
const location = new THREE.Vector3(...expression.location);
|
|
115
|
+
const scale = new THREE.Vector3(...expression.scale);
|
|
116
|
+
const rotation = new THREE.Quaternion().setFromEuler(
|
|
117
117
|
new THREE.Euler(
|
|
118
118
|
expression.rotation[0],
|
|
119
119
|
expression.rotation[1],
|
|
120
120
|
expression.rotation[2],
|
|
121
121
|
'ZYX',
|
|
122
122
|
),
|
|
123
|
-
),
|
|
124
|
-
new THREE.Vector3(...expression.scale),
|
|
125
123
|
);
|
|
124
|
+
const inverseRotation = rotation.clone().invert();
|
|
125
|
+
const inverseScale = new THREE.Vector3(...expression.scale.map(v => v === 0 ? 0 : 1 / v));
|
|
126
126
|
const value = new THREE.Vector3();
|
|
127
127
|
return (direction) => {
|
|
128
128
|
const input = source(direction);
|
|
129
129
|
if (typeof input === 'number') value.setScalar(input);
|
|
130
130
|
else value.copy(input);
|
|
131
|
-
|
|
131
|
+
// Blender's gpu_shader_material_mapping.glsl: texture uses inverse
|
|
132
|
+
// rotation then safe division, normals inverse scale then normalization.
|
|
133
|
+
// A generic inverse matrix is incorrect when a scale component is zero.
|
|
134
|
+
switch (expression.vector_type ?? 'POINT') {
|
|
135
|
+
case 'TEXTURE': return value.sub(location).applyQuaternion(inverseRotation).multiply(inverseScale);
|
|
136
|
+
case 'VECTOR': return value.multiply(scale).applyQuaternion(rotation);
|
|
137
|
+
case 'NORMAL': return value.multiply(inverseScale).applyQuaternion(rotation).normalize();
|
|
138
|
+
case 'POINT': return value.multiply(scale).applyQuaternion(rotation).add(location);
|
|
139
|
+
}
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
if (expression.kind === 'map_range') {
|
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,10 +457,24 @@ 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 };
|
|
463
|
+
case 'history-begin':
|
|
464
|
+
case 'history-end':
|
|
465
|
+
return ask({ op: request.op });
|
|
466
|
+
case 'history-step':
|
|
467
|
+
return ask({ op: 'history-step', token: request.token, direction: request.direction });
|
|
483
468
|
case 'execute':
|
|
484
469
|
// Code about to run may open a file the host wrote since the last call.
|
|
485
470
|
await stageProjectFiles(files, projectRoot);
|
|
486
|
-
|
|
471
|
+
{
|
|
472
|
+
const answer = await ask({ op: 'execute', code: request.code, history: request.history ?? true,
|
|
473
|
+
label: request.label ?? 'Blender Python' }) as {
|
|
474
|
+
error?: string; result: string;
|
|
475
|
+
};
|
|
476
|
+
return answer.error ? `Error executing code: ${answer.error}` : `Code executed successfully: ${answer.result}`;
|
|
477
|
+
}
|
|
487
478
|
case 'present':
|
|
488
479
|
// Straight through to `session.py`'s own `present` op — the worker adds
|
|
489
480
|
// nothing, and a capture-less present answers `{ presented, revision }`.
|
|
@@ -517,6 +508,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
517
508
|
case 'rna-set':
|
|
518
509
|
return ask({
|
|
519
510
|
op: 'rna-set',
|
|
511
|
+
history: request.history !== false,
|
|
520
512
|
path: request.path,
|
|
521
513
|
property: request.property,
|
|
522
514
|
value: request.value,
|
|
@@ -626,23 +618,39 @@ function reportMemory(): void {
|
|
|
626
618
|
if (bytes !== null) post({ op: 'memory', bytes });
|
|
627
619
|
}
|
|
628
620
|
|
|
629
|
-
self.onmessage =
|
|
621
|
+
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
630
622
|
const request = event.data;
|
|
631
623
|
if (request.op === 'present-result') {
|
|
632
|
-
|
|
624
|
+
void handle(request);
|
|
633
625
|
return;
|
|
634
626
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
627
|
+
void enqueue(() => answerRequest(request));
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
638
631
|
try {
|
|
639
|
-
|
|
632
|
+
const result = await handle(request);
|
|
633
|
+
if (documentDirty) await saveDocument();
|
|
634
|
+
await reportHistory();
|
|
635
|
+
post({ id: request.id, result });
|
|
640
636
|
} catch (error) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
637
|
+
// A failed history drain must never strand the request's promise. Preserve
|
|
638
|
+
// both failures: the edit may have changed Blender before either failed.
|
|
639
|
+
let message = describeThrown(error);
|
|
640
|
+
try {
|
|
641
|
+
await reportHistory();
|
|
642
|
+
} catch (historyError) {
|
|
643
|
+
message += `\nUnable to deliver Blender history: ${describeThrown(historyError)}`;
|
|
644
|
+
}
|
|
645
|
+
post({ id: request.id, error: message });
|
|
644
646
|
}
|
|
645
647
|
// AFTER the answer, never before it: the reading is a passenger and must not
|
|
646
648
|
// sit between a finished call and the reply the caller is waiting on.
|
|
647
649
|
reportMemory();
|
|
648
|
-
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function reportHistory(): Promise<void> {
|
|
653
|
+
if (!engine || !session) return;
|
|
654
|
+
const entries = await engine.request({ op: 'history-events' }) as import('./protocol').NativeHistoryEntry[];
|
|
655
|
+
if (entries.length) post({ op: 'history', entries });
|
|
656
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceRepository": "https://github.com/volter-ai/blender",
|
|
3
|
+
"source": "94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c",
|
|
4
|
+
"license": "CC0-1.0",
|
|
5
|
+
"licenseFile": "LICENSE",
|
|
6
|
+
"bytes": 11821256,
|
|
7
|
+
"sha256": "4f95016f965b1fd3542a4ae58160c1cb3254027b567111d2a4d23e447f27b7e2",
|
|
8
|
+
"files": [
|
|
9
|
+
{
|
|
10
|
+
"path": "LICENSE",
|
|
11
|
+
"offset": 0,
|
|
12
|
+
"bytes": 7048,
|
|
13
|
+
"sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "blender_assets.cats.txt",
|
|
17
|
+
"offset": 7048,
|
|
18
|
+
"bytes": 5933,
|
|
19
|
+
"sha256": "fe62add99a5201173665682ceeeb606aef646eb81b6cadecfb7120ebb6e1def8"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "brushes/essentials_brushes-curve_sculpt.blend",
|
|
23
|
+
"offset": 12981,
|
|
24
|
+
"bytes": 827070,
|
|
25
|
+
"sha256": "de873a20947e1fb7f19675b0f0c79dcdb9d58a75e5193aa2606f8ce0cc0b15f1"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "brushes/essentials_brushes-gp_draw.blend",
|
|
29
|
+
"offset": 840051,
|
|
30
|
+
"bytes": 497193,
|
|
31
|
+
"sha256": "0596dce9286b824c31af67197b96e83a0c4a42ce2dbb36f40dfc8ecab3fe9f06"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"path": "brushes/essentials_brushes-gp_sculpt.blend",
|
|
35
|
+
"offset": 1337244,
|
|
36
|
+
"bytes": 357873,
|
|
37
|
+
"sha256": "235631a858c91b5cdaf70240ce0ed15bff11ef4474767e983d169a1bbf4723f6"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"path": "brushes/essentials_brushes-gp_vertex.blend",
|
|
41
|
+
"offset": 1695117,
|
|
42
|
+
"bytes": 245965,
|
|
43
|
+
"sha256": "ee1e9eee2cb898c3e95dbe2c059a51d4a5ec863a0d9bbd8ea1beb7d6a20bed06"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "brushes/essentials_brushes-gp_weight.blend",
|
|
47
|
+
"offset": 1941082,
|
|
48
|
+
"bytes": 196914,
|
|
49
|
+
"sha256": "ec19a0cbca6a8413c45a25994a3db0ce1c7c8bd002d1968069dc1b558ce8500e"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "brushes/essentials_brushes-mesh_sculpt.blend",
|
|
53
|
+
"offset": 2137996,
|
|
54
|
+
"bytes": 2715184,
|
|
55
|
+
"sha256": "f8b15126658f5a4ac455b0826a7eeae026576ed7c2ac45562d8880383fbf03bf"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "brushes/essentials_brushes-mesh_texture.blend",
|
|
59
|
+
"offset": 4853180,
|
|
60
|
+
"bytes": 972168,
|
|
61
|
+
"sha256": "6c57caeaaa1f16e175783a2fbcbabf8772e108414edca92cad52bcc8019110f8"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "brushes/essentials_brushes-mesh_vertex.blend",
|
|
65
|
+
"offset": 5825348,
|
|
66
|
+
"bytes": 600678,
|
|
67
|
+
"sha256": "7b4b5d973f1bc4a06f7553afc96c7cb0bd9a0c17921cd65903b40395eccd3300"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "brushes/essentials_brushes-mesh_weight.blend",
|
|
71
|
+
"offset": 6426026,
|
|
72
|
+
"bytes": 404988,
|
|
73
|
+
"sha256": "67b141b9f15b0709f954fcd5e1d846deb95f5da8615ae70f0adcfacb3025d87f"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"path": "nodes/compositing_nodes_essentials.blend",
|
|
77
|
+
"offset": 6831014,
|
|
78
|
+
"bytes": 1329516,
|
|
79
|
+
"sha256": "8f40be4b9a811f2f4845c117eafb78c0b61dac7d943fb81fec2c7a450c6ac4b4"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"path": "nodes/geometry_nodes_dynamics_assets.blend",
|
|
83
|
+
"offset": 8160530,
|
|
84
|
+
"bytes": 307332,
|
|
85
|
+
"sha256": "a2e6b4013abfc3b9355927e33b2ae5af2182dc079e820041124652a75ce7b100"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "nodes/geometry_nodes_essentials.blend",
|
|
89
|
+
"offset": 8467862,
|
|
90
|
+
"bytes": 696907,
|
|
91
|
+
"sha256": "4592ec2a51b1adbd87467812979ddebb09f6606a49aa75549fdd2b40b7ffac86"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"path": "nodes/principal_components.blend",
|
|
95
|
+
"offset": 9164769,
|
|
96
|
+
"bytes": 84028,
|
|
97
|
+
"sha256": "91cb8cada033daab6c6fbb8aef4767782cf75be867a0c2f38ced742160f38a39"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"path": "nodes/procedural_hair_node_assets.blend",
|
|
101
|
+
"offset": 9248797,
|
|
102
|
+
"bytes": 2488396,
|
|
103
|
+
"sha256": "b69d8bd8975b9db7f89693f8f19dd7fcb241f5a3786d2edfb0a243e91c1109bb"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"path": "nodes/shading_nodes_essentials.blend",
|
|
107
|
+
"offset": 11737193,
|
|
108
|
+
"bytes": 84063,
|
|
109
|
+
"sha256": "ffdaf64a4777233f6e47799bad17825a6c1e07c500eb1d9d0c23997f745327ee"
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|