@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.
@@ -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
+ }
@@ -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. */
@@ -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;
@@ -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", "vector": value(node.inputs["Vector"]),
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(background.inputs["Strength"].default_value),
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
- # A constant Color needs no expression; the presenter reads `color`.
428
- if not background.inputs["Color"].is_linked:
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
- if "lighting" in described and not (lighting or background).inputs["Color"].is_linked:
431
- described["lighting"].pop("shader", None)
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`, carried as an INSTRUMENT only: it is always
784
- # False in this build (no undo stack in `--background`), which is
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. It reports `is_memfile_undo_written`, which
1064
- # only a GLOBAL UNDO PUSH clears -- and `--background` has no undo stack,
1065
- # so nothing ever pushes one. MEASURED 2026-09-18 in the tab, straight
1066
- # after `lantern.py` built fourteen objects: `bpy.data.is_dirty` is
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 matrix = new THREE.Matrix4().compose(
115
- new THREE.Vector3(...expression.location),
116
- new THREE.Quaternion().setFromEuler(
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
- return value.applyMatrix4(matrix);
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 DEBOUNCE LIVES HERE, because this is the only side of the
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 waits out one idle
27
- * second, calls `save-document` as an ordinary request (which Python's loop
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 one idle second after the LAST call of a modeling session
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 saveTimer: ReturnType<typeof setTimeout> | null = null;
118
- /** Calls the tab is waiting on. A save waits for zero. */
119
- let callsInFlight = 0;
120
-
121
- /** The idle second. One save per second of quiet, however many presents
122
- * arrived during it: the timer is RESET by each, so a script presenting in a
123
- * tight loop writes the document once, when it stops. */
124
- const DOCUMENT_SAVE_IDLE_MS = 1_000;
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 armDocumentSave(): void {
127
- if (documentPath === null) return;
128
- if (saveTimer !== null) clearTimeout(saveTimer);
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
- * NEVER MID-CALL: a call still outstanding means a script is running, and its
139
- * half-built model is not the document. The timer re-arms instead of writing.
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
- log(
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') return;
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
- log(
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
- log(
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
- log(
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
- // The session says this present left the document behind the model. The
210
- // clock is here; the write is one idle second away.
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
- return session.execute(request.code);
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 = async (event: MessageEvent<WorkerRequest>) => {
621
+ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
630
622
  const request = event.data;
631
623
  if (request.op === 'present-result') {
632
- await handle(request);
624
+ void handle(request);
633
625
  return;
634
626
  }
635
- // Held across the WHOLE call, so the document's save timer can tell "the
636
- // session is quiet" from "a script is still running".
637
- callsInFlight += 1;
627
+ void enqueue(() => answerRequest(request));
628
+ };
629
+
630
+ async function answerRequest(request: WorkerRequest): Promise<void> {
638
631
  try {
639
- post({ id: request.id, result: await handle(request) });
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
- post({ id: request.id, error: describeThrown(error) });
642
- } finally {
643
- callsInFlight -= 1;
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
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
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
+ }