@volter/blender-engine 0.1.8 → 0.1.10

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.
@@ -193,9 +193,9 @@ export async function startEmscriptenBlenderEngine(
193
193
  // editor's console door is for conditions somebody must resolve, so only the
194
194
  // session's OWN named conditions go there.
195
195
  const say = (_level: 'log' | 'error', text: string) => {
196
- if (text.startsWith('@@VGAI-READY ')) readyLine = text.slice('@@VGAI-READY '.length);
196
+ if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
197
197
  options.log(
198
- text.startsWith('@@VGAI-WARN') || text.startsWith('@@VGAI-ERROR') ? 'error' : 'log',
198
+ text.startsWith('@@VOLTER-WARN') || text.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
199
199
  text,
200
200
  );
201
201
  };
@@ -224,11 +224,11 @@ export async function startEmscriptenBlenderEngine(
224
224
  BLENDER_SYSTEM_PYTHON: '/bw/python',
225
225
  BLENDER_SYSTEM_SCRIPTS: '/bw/scripts',
226
226
  BLENDER_SYSTEM_DATAFILES: '/bw/datafiles',
227
- VGAI_SESSION_ROOT: SESSION_ROOT,
227
+ VOLTER_SESSION_ROOT: SESSION_ROOT,
228
228
  HOME: '/root',
229
229
  TMPDIR: '/tmp',
230
230
  });
231
- // VGAI_EXPORT_BUFFER_PATH IS DELIBERATELY UNSET. This engine reads the
231
+ // VOLTER_EXPORT_BUFFER_PATH IS DELIBERATELY UNSET. This engine reads the
232
232
  // arena off `HEAPU8`; asking the door to also write it to a file would
233
233
  // cost this skew a megabyte-scale write per present for bytes it
234
234
  // already has (`session.py`, EXPORT_BUFFER_PATH).
@@ -29,7 +29,7 @@
29
29
  * and that IS the answer. There is no manifest field, no setting and no flag,
30
30
  * because nothing a user does should depend on the skew -- if it did, the
31
31
  * skews would be answering differently, which the ruling forbids.
32
- * `VGAI_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
32
+ * `VOLTER_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
33
33
  * different build and is how the substrate skew is reached in development.
34
34
  *
35
35
  * WHAT DIFFERS, and it is four things, all below this line:
@@ -43,7 +43,7 @@
43
43
  * implementation over {@link BlenderFiles}.
44
44
  */
45
45
 
46
- export const SESSION_ROOT = '/work/.vgai-session';
46
+ export const SESSION_ROOT = '/work/.volter-session';
47
47
  export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
48
48
 
49
49
  /**
@@ -137,7 +137,7 @@ export interface BlenderEngine {
137
137
  readonly bootMs: number;
138
138
  /** The engine's memory in bytes, now. Linear memory on the standalone skew;
139
139
  * null where the skew cannot see the module's memory from the host, which
140
- * is the WALI case and is why `vgai status` prints "unreported" rather than
140
+ * is the WALI case and is why `volter-model-editor status` prints "unreported" rather than
141
141
  * a zero. */
142
142
  memoryBytes(): number | null;
143
143
  /** Bytes of packed `.data` payload handed back after boot, or null when the
@@ -303,7 +303,7 @@ export function openSessionChannel(
303
303
  *
304
304
  * The loop below waits for `out/<id>.done` and nothing else. When the
305
305
  * program behind the directory DIES, that file is never written, so the
306
- * call waits forever: `vgai status` reports it IN FLIGHT for as long as
306
+ * call waits forever: `volter-model-editor status` reports it IN FLIGHT for as long as
307
307
  * anyone looks, `blender-start` keeps answering from the host's cached
308
308
  * banner, and the harness has no answer to time out against. MEASURED
309
309
  * 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
@@ -404,7 +404,7 @@ export function openSessionChannel(
404
404
  `Blender is gone, so this call will never be answered: ${ended}. ` +
405
405
  'The session keeps its directory and its cached banner, so every later call would ' +
406
406
  'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
407
- '(`VGAI_BLENDER_FRESH_SESSION=1` for the battery harness).',
407
+ '(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
408
408
  );
409
409
  await sleep(pollDelay(performance.now() - began));
410
410
  }
@@ -245,9 +245,9 @@ export async function startWaliBlenderEngine(
245
245
  // `--python-use-system-env` below is what makes Blender read the
246
246
  // variable at all.
247
247
  PYTHONPATH: `${RESOURCES}/python/lib/numpy313.zip:${RESOURCES}/python/lib/wheels313.zip`,
248
- VGAI_SESSION_ROOT: SESSION_ROOT,
248
+ VOLTER_SESSION_ROOT: SESSION_ROOT,
249
249
  // THE ARENA'S DOOR ON THIS SKEW. See `readArena`.
250
- VGAI_EXPORT_BUFFER_PATH: ARENA_PATH,
250
+ VOLTER_EXPORT_BUFFER_PATH: ARENA_PATH,
251
251
  },
252
252
  threadPoolSize: workers.pool,
253
253
  // Blender talks to nothing. The editor's own routes are this worker's, not
@@ -260,13 +260,13 @@ export async function startWaliBlenderEngine(
260
260
  const say = (text: string) => {
261
261
  for (const line of text.split('\n')) {
262
262
  if (line === '') continue;
263
- if (line.startsWith('@@VGAI-READY ')) readyLine = line.slice('@@VGAI-READY '.length);
263
+ if (line.startsWith('@@VOLTER-READY ')) readyLine = line.slice('@@VOLTER-READY '.length);
264
264
  // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS
265
265
  // -- the same rule as the standalone engine, and for the same measured
266
266
  // reason: only the session's own named conditions belong in the set an
267
267
  // agent must drive to zero.
268
268
  options.log(
269
- line.startsWith('@@VGAI-WARN') || line.startsWith('@@VGAI-ERROR') ? 'error' : 'log',
269
+ line.startsWith('@@VOLTER-WARN') || line.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
270
270
  line,
271
271
  );
272
272
  }
@@ -304,7 +304,7 @@ export async function startWaliBlenderEngine(
304
304
  // only defensible because the CALLER can degrade VISIBLY; without this
305
305
  // the degrade is silent, and before it existed the notice reached a
306
306
  // nested worker's devtools console and nothing else.
307
- onDiagnostic: (message: string) => options.log('error', `@@VGAI-ERROR ${message}`),
307
+ onDiagnostic: (message: string) => options.log('error', `@@VOLTER-ERROR ${message}`),
308
308
  },
309
309
  )
310
310
  .then(
@@ -316,7 +316,7 @@ export async function startWaliBlenderEngine(
316
316
  },
317
317
  )
318
318
  .finally(() => {
319
- if (ended !== null) options.log('error', `@@VGAI-ERROR the Blender program ended: ${ended}`);
319
+ if (ended !== null) options.log('error', `@@VOLTER-ERROR the Blender program ended: ${ended}`);
320
320
  });
321
321
 
322
322
  while (readyLine === null) {
@@ -352,7 +352,7 @@ export async function startWaliBlenderEngine(
352
352
  readArena: () => filesystem.readFile(ARENA_PATH),
353
353
  bootMs,
354
354
  // The module's memory is the program worker's and no door reports it.
355
- // Null rather than zero: `vgai status` prints "unreported", which is true,
355
+ // Null rather than zero: `volter-model-editor status` prints "unreported", which is true,
356
356
  // where a zero would be a measurement that was never taken.
357
357
  memoryBytes: () => null,
358
358
  // There is no packed payload on this skew: Blender's runtime tree is
package/browser/rna.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * (`bl_rna.properties`) — the ruling's "everything Blender SHOWS about a
9
9
  * datablock, we show, in OUR panels, read from the engine through bpy and RNA"
10
10
  * (ARCHITECTURE-CORE §Blender north star, "Inspection parity, not editing
11
- * parity"). Nothing in this file is a vgai invention over RNA: every field is
11
+ * parity"). Nothing in this file is a volter invention over RNA: every field is
12
12
  * a `PropertyRNA` member, a `bpy.types` name, or an address `path_from_id()`
13
13
  * produced.
14
14
  */
@@ -241,7 +241,7 @@ export interface BlenderRnaWrite {
241
241
  // session's scene, as rows (WORK.md §Blender in the tab is Blender,
242
242
  // "Inspection parity", I3). Every field below is Blender's own — a `TSE_*`
243
243
  // element type, an `ICON_*` name `tree_element_get_icon` picks, a restriction
244
- // column `outliner_draw_restrictbuts` draws — and nothing here is a vgai
244
+ // column `outliner_draw_restrictbuts` draws — and nothing here is a volter
245
245
  // invention over the Outliner.
246
246
 
247
247
  /**
@@ -69,7 +69,7 @@ export interface PresentAnswer {
69
69
  * Read by anything that has to say WHERE a byte came from rather than merely
70
70
  * display it — `blender-list-files` answers with it, so the CLI's write-back
71
71
  * door can record which session's model a mirrored file was exported from
72
- * (`packages/vgai-cli/src/blender-mcp.ts`, class Mirror). Null until the
72
+ * (`packages/volter-cli/src/blender-mcp.ts`, class Mirror). Null until the
73
73
  * session's first present.
74
74
  */
75
75
  export interface PresentedState {
@@ -186,7 +186,7 @@ export class BlenderRuntime {
186
186
  // The page console is the editor's ledger (`installEditorConsoleReporting`
187
187
  // captures it, source-blind), and this package may import nothing of the
188
188
  // editor to say it any other way. Without this line the failure reached
189
- // a toast and `vgai console` read 0/0 while no Model document could open
189
+ // a toast and `volter-model-editor console` read 0/0 while no Model document could open
190
190
  // (measured 2026-09-20 from a registry install).
191
191
  console.error(error.message);
192
192
  for (const id of [...this.#pending.keys()]) this.#settled(id);
@@ -122,7 +122,7 @@ import _blender_web
122
122
  _UNKNOWN_GEOMETRY = "RUNTIME_FRAME_UNKNOWN_GEOMETRY"
123
123
  _UNKNOWN_IMAGE = "RUNTIME_FRAME_UNKNOWN_IMAGE"
124
124
 
125
- ROOT = os.environ.get("VGAI_SESSION_ROOT", "/work/.vgai-session")
125
+ ROOT = os.environ.get("VOLTER_SESSION_ROOT", "/work/.volter-session")
126
126
  IN = os.path.join(ROOT, "in")
127
127
  OUT = os.path.join(ROOT, "out")
128
128
  ASK = os.path.join(ROOT, "ask")
@@ -142,7 +142,7 @@ REPLY = os.path.join(ROOT, "reply")
142
142
  # whose host cannot reach the module's memory at all
143
143
  # (`blender-wali-engine.mts`), where the file is not a fallback but the
144
144
  # native door onto the same bytes.
145
- EXPORT_BUFFER_PATH = os.environ.get("VGAI_EXPORT_BUFFER_PATH", "")
145
+ EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
146
146
  _real_stderr = sys.stderr
147
147
 
148
148
 
@@ -172,7 +172,7 @@ def _prepare_directories():
172
172
  except FileExistsError:
173
173
  pass
174
174
  except OSError as error:
175
- _say("@@VGAI-ERROR cannot create %s: %r" % (directory, error))
175
+ _say("@@VOLTER-ERROR cannot create %s: %r" % (directory, error))
176
176
  try:
177
177
  os.chmod(directory, 0o777)
178
178
  except OSError:
@@ -215,7 +215,7 @@ def warn(what):
215
215
  return
216
216
  _WARNED.add(what)
217
217
  _WARNINGS.append(what)
218
- _say("@@VGAI-WARN " + what)
218
+ _say("@@VOLTER-WARN " + what)
219
219
 
220
220
 
221
221
  # ---------------------------------------------------------------- the world
@@ -523,6 +523,10 @@ def draw_camera(obj):
523
523
  "shift_x": float(camera.shift_x),
524
524
  "shift_y": float(camera.shift_y),
525
525
  "matrix": [[float(v) for v in row] for row in obj.matrix_world],
526
+ # What the 3D View's camera view darkens outside the frame (`drawviewborder`).
527
+ "passepartout": float(camera.passepartout_alpha) if camera.show_passepartout else 0.0,
528
+ # The size the overlay draws the camera at (`Camera.display_size`, `cam->drawsize`).
529
+ "display_size": float(camera.display_size),
526
530
  }
527
531
 
528
532
 
@@ -732,6 +736,107 @@ def _refusal(node):
732
736
  return None
733
737
 
734
738
 
739
+
740
+ def _empty_display(obj):
741
+ """An empty's display (`overlay_empty.hh`): its type and size, and for an image empty the
742
+ picture, where it sits on the object (`empty_image_offset`), how it meets depth, which side
743
+ shows, in which projections, its opacity when it blends, the object colour the picture is
744
+ multiplied by (`ucolor`) and whether the image stores premultiplied alpha."""
745
+ display = {"display": obj.empty_display_type, "size": float(obj.empty_display_size)}
746
+ if obj.empty_display_type == "IMAGE":
747
+ display["image"] = {
748
+ "name": obj.data.name if obj.data is not None else None,
749
+ "offset": [float(v) for v in obj.empty_image_offset],
750
+ "depth": obj.empty_image_depth,
751
+ "side": obj.empty_image_side,
752
+ "perspective": bool(obj.show_empty_image_perspective),
753
+ "orthographic": bool(obj.show_empty_image_orthographic),
754
+ "axis_aligned": bool(obj.show_empty_image_only_axis_aligned),
755
+ "opacity": float(obj.color[3]) if obj.use_empty_image_alpha else None,
756
+ "tint": [float(v) for v in obj.color[:3]],
757
+ "premultiplied": obj.data is not None and obj.data.alpha_mode == "PREMUL",
758
+ }
759
+ return display
760
+
761
+
762
+ def _saved_view():
763
+ """The 3D View the file saved, which is where Blender opens it: the `Modeling` workspace's
764
+ (this editor's Model workspace is Blender's Modeling), else the first 3D View any screen
765
+ holds. `RegionView3D`'s pivot, rotation (view to world, `(w, x, y, z)`), distance and
766
+ projection (`PERSP`, `ORTHO`, or `CAMERA` for a view through the scene camera), and the
767
+ `View3D`'s lens."""
768
+ workspace = bpy.data.workspaces.get("Modeling")
769
+ screens = list(workspace.screens) if workspace is not None else []
770
+ screens.extend(bpy.data.screens)
771
+ for screen in screens:
772
+ for area in screen.areas:
773
+ if area.type != "VIEW_3D":
774
+ continue
775
+ space = area.spaces[0]
776
+ region = space.region_3d
777
+ return {
778
+ "lens": float(space.lens),
779
+ "location": [float(v) for v in region.view_location],
780
+ "rotation": [float(v) for v in region.view_rotation],
781
+ "distance": float(region.view_distance),
782
+ "perspective": region.view_perspective,
783
+ }
784
+ return None
785
+
786
+
787
+ def _subject_line(scene, view_layer):
788
+ """The 3D Viewport's second overlay line, as `draw_selected_name` (`view3d_draw.cc`) writes it,
789
+ less the two parts that follow the playhead: the tab draws `(frame)` and the marker on it
790
+ from the frame it is showing, which during playback is not `frame_current` (written once, on
791
+ pause). Here: in Object Mode (or with nothing active) the active collection and a bar, then
792
+ the active object; outside Object Mode its data's name; the active edit or pose bone, or the
793
+ active shape key (` (Soloed)` when pinned). The scene's markers go with it, in list order,
794
+ for `BKE_scene_find_marker_name`. Blender tints the line on a keyframe; that colour is not
795
+ carried, and the ` (Viewer)` suffix belongs to a viewer path this tab does not show."""
796
+ parts = []
797
+ ob = view_layer.objects.active
798
+ if ob is None or ob.mode == "OBJECT":
799
+ # `BKE_collection_ui_name_get`: the master collection reads "Scene Collection", which is
800
+ # also what its RNA name returns.
801
+ parts.append(" " + view_layer.active_layer_collection.collection.name)
802
+ if ob is not None:
803
+ parts.append(" |")
804
+ if ob is not None:
805
+ parts.append(" " + ob.name)
806
+ if ob.mode != "OBJECT" and ob.data is not None:
807
+ parts.append(" | " + ob.data.name)
808
+ if ob.type == "ARMATURE":
809
+ arm = ob.data
810
+ if ob.mode == "EDIT":
811
+ if arm.edit_bones.active is not None:
812
+ parts.append(" : " + arm.edit_bones.active.name)
813
+ elif ob.mode == "POSE" and arm.bones.active is not None and _bone_shown(arm.bones.active):
814
+ parts.append(" : " + arm.bones.active.name)
815
+ elif ob.type in ("MESH", "LATTICE", "CURVE"):
816
+ if ob.type == "MESH" and ob.mode == "WEIGHT_PAINT":
817
+ rig = ob.find_armature()
818
+ if rig is not None and rig.mode == "POSE":
819
+ bone = rig.data.bones.active
820
+ if bone is not None and _bone_shown(bone):
821
+ parts.append(" : " + bone.name)
822
+ key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
823
+ if key is not None:
824
+ parts.append(" : " + key.name)
825
+ if ob.show_only_shape_key:
826
+ parts.append(" (Soloed)")
827
+ return {
828
+ "frame": int(scene.frame_current),
829
+ "body": "".join(parts),
830
+ "markers": [[int(m.frame), m.name] for m in scene.timeline_markers],
831
+ }
832
+
833
+
834
+ def _bone_shown(bone):
835
+ """`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
836
+ collections = getattr(bone, "collections", None)
837
+ return not collections or any(c.is_visible for c in collections)
838
+
839
+
735
840
  class _GraphRefusal(Exception):
736
841
  pass
737
842
 
@@ -1231,9 +1336,13 @@ class Session:
1231
1336
  """
1232
1337
  scene = bpy.context.scene
1233
1338
  graphs = material_graphs(scene)
1339
+ # An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
1340
+ # (`overlay_empty.hh` `image_sync`).
1341
+ empty_images = {obj.data.name for obj in scene.objects
1342
+ if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
1234
1343
  options = {"session": self.session, "evaluate": True, "known": self._known,
1235
1344
  "graph_materials": sorted(graphs),
1236
- "graph_images": sorted({i for g in graphs.values() for i in g["images"]}),
1345
+ "graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
1237
1346
  "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
1238
1347
  # THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
1239
1348
  # an ordered stream of filesystem patches that is the whole
@@ -1288,14 +1397,55 @@ class Session:
1288
1397
  default_color = getattr(getattr(data, "color_attributes", None), "default_color_name", "")
1289
1398
  if default_color:
1290
1399
  row["default_color"] = default_color
1400
+ # THE VIEWPORT DISPLAY of each material -- what Blender's Solid shading colours a surface
1401
+ # by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
1402
+ # `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
1403
+ for entry in frame["materials"].values():
1404
+ material = bpy.data.materials.get(entry.get("name", ""))
1405
+ if material is not None:
1406
+ entry["viewport"] = {
1407
+ "color": [float(v) for v in material.diffuse_color],
1408
+ "roughness": float(material.roughness),
1409
+ "metallic": float(material.metallic),
1410
+ }
1291
1411
  frame["world"] = draw_world(scene)
1292
1412
  frame["cameras"] = {
1293
1413
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
1294
1414
  }
1295
1415
  frame["volumes"] = {}
1416
+ # THE CAMERA A CAMERA VIEW LOOKS THROUGH (`view3d.view_camera`: the scene's, else a
1417
+ # camera that is active, else the view layer's first) and the frame it draws, the
1418
+ # render's shape with its pixel aspect.
1419
+ render = scene.render
1420
+ frame["camera_view"] = {
1421
+ "scene_camera": scene.camera.name if scene.camera is not None else None,
1422
+ "view_layer_cameras": [o.name for o in bpy.context.view_layer.objects if o.type == "CAMERA"],
1423
+ "aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
1424
+ }
1425
+ # THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
1426
+ # operators place and snap, drawn by the tab as Blender's overlay does
1427
+ # (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
1428
+ # turn with the cursor's rotation in whichever `rotation_mode` it has.
1429
+ frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
1296
1430
  # THE OVERLAYS, after the door's frame stands: `_weights` reads the
1297
1431
  # mesh's own revision out of it (see there), so it cannot run before.
1298
1432
  view_layer = bpy.context.view_layer
1433
+ # THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
1434
+ # both from the scene, so they come from here (`_subject_line`).
1435
+ frame["subject"] = _subject_line(scene, view_layer)
1436
+ # A LIGHT'S DISTANCES, for the overlay's direction line (`overlay_light.hh`: from
1437
+ # `clipsta` to `att_dist` down the light's -Z, for a spot or an area light).
1438
+ frame["light_distances"] = {
1439
+ light.name: [float(light.shadow_buffer_clip_start), float(light.cutoff_distance)]
1440
+ for light in bpy.data.lights
1441
+ }
1442
+ # WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
1443
+ frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
1444
+ frame["view"] = _saved_view()
1445
+ frame["units"] = {
1446
+ "system": scene.unit_settings.system,
1447
+ "scale_length": float(scene.unit_settings.scale_length),
1448
+ }
1299
1449
  frame["armatures"] = _armatures(view_layer)
1300
1450
  frame["weights"] = _weights(scene, view_layer, frame, self._known)
1301
1451
  warnings = list(frame.get("warnings", ()))
@@ -1670,11 +1820,11 @@ _ASK_SEQUENCE = [0]
1670
1820
  def _drop(path):
1671
1821
  """Remove one of THIS side's own channel files. A failure here means the
1672
1822
  ownership rule was broken by the other side, so it is named rather than
1673
- swallowed -- and named where `vgai console` reads it, not into a log."""
1823
+ swallowed -- and named where `volter-model-editor console` reads it, not into a log."""
1674
1824
  try:
1675
1825
  os.unlink(path)
1676
1826
  except OSError as error:
1677
- _say("@@VGAI-WARN the session could not remove its own %s: %r" % (path, error))
1827
+ _say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
1678
1828
 
1679
1829
 
1680
1830
  def ask(payload):
@@ -1829,7 +1979,7 @@ def _assert_photographed_from(reported, position, target, up):
1829
1979
  )
1830
1980
 
1831
1981
 
1832
- class VgaiRenderEngine(bpy.types.RenderEngine):
1982
+ class VolterRenderEngine(bpy.types.RenderEngine):
1833
1983
  """The scene's renderer, so `write_still`, `save_render` and Render Result
1834
1984
  behave as Blender's own.
1835
1985
 
@@ -1839,7 +1989,7 @@ class VgaiRenderEngine(bpy.types.RenderEngine):
1839
1989
  starts a path trace nobody asked for inside the tab.
1840
1990
  """
1841
1991
 
1842
- bl_idname = "VGAI_THREE"
1992
+ bl_idname = "VOLTER_THREE"
1843
1993
  bl_label = "three.js"
1844
1994
  bl_use_preview = False
1845
1995
 
@@ -1937,7 +2087,7 @@ def _register_engine():
1937
2087
  ("BLENDER_WORKBENCH", "Workbench"),
1938
2088
  ):
1939
2089
  existing = _engine_class(identifier)
1940
- if existing is not None and issubclass(existing, VgaiRenderEngine):
2090
+ if existing is not None and issubclass(existing, VolterRenderEngine):
1941
2091
  made.append(identifier)
1942
2092
  continue
1943
2093
  if existing is not None:
@@ -1948,8 +2098,8 @@ def _register_engine():
1948
2098
  else:
1949
2099
  _release_from_owning_addon(existing)
1950
2100
  engine = type(
1951
- "Vgai" + identifier.title().replace("_", ""),
1952
- (VgaiRenderEngine,),
2101
+ "Volter" + identifier.title().replace("_", ""),
2102
+ (VolterRenderEngine,),
1953
2103
  {"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
1954
2104
  )
1955
2105
  try:
@@ -1965,17 +2115,17 @@ def _register_engine():
1965
2115
  # in the start reply and refused by name at the render, never a
1966
2116
  # warning on every boot.
1967
2117
  unavailable.append(identifier)
1968
- # `VGAI_THREE` is the engine under its own name, registered ONCE: this runs
2118
+ # `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
1969
2119
  # again after every file load (the ids have to be retaken), and Blender
1970
2120
  # refuses a class it already holds -- which is not a capability anyone
1971
2121
  # lost, so it is not a warning.
1972
- if _engine_class(VgaiRenderEngine.bl_idname) is None:
2122
+ if _engine_class(VolterRenderEngine.bl_idname) is None:
1973
2123
  try:
1974
- bpy.utils.register_class(VgaiRenderEngine)
2124
+ bpy.utils.register_class(VolterRenderEngine)
1975
2125
  except Exception as error: # noqa: BLE001
1976
2126
  warn("could not register the three.js engine: %s" % error)
1977
- if _engine_class(VgaiRenderEngine.bl_idname) is not None:
1978
- made.append(VgaiRenderEngine.bl_idname)
2127
+ if _engine_class(VolterRenderEngine.bl_idname) is not None:
2128
+ made.append(VolterRenderEngine.bl_idname)
1979
2129
  return made, unavailable
1980
2130
 
1981
2131
 
@@ -2548,7 +2698,7 @@ def _py_row(target, name, descriptor, owner):
2548
2698
  from what it answers -- an ID is a POINTER, a sequence of them a
2549
2699
  COLLECTION with its count and names, a scalar its scalar. A value no RNA
2550
2700
  type covers is not invented into one: the property is dropped, and
2551
- nothing here is a vgai field over Blender's data."""
2701
+ nothing here is a volter field over Blender's data."""
2552
2702
  row = {
2553
2703
  "identifier": name,
2554
2704
  "name": name,
@@ -3744,7 +3894,7 @@ def _node_row(node):
3744
3894
 
3745
3895
 
3746
3896
  def _node_tree_of(path, material):
3747
- """WHICH TREE. Either an explicit RNA address (so a `vgai eval` can open a
3897
+ """WHICH TREE. Either an explicit RNA address (so a `volter-model-editor eval` can open a
3748
3898
  world's or a group's tree with the engine's own spelling), or a material by
3749
3899
  name, or -- given neither -- the active object's active material, which is
3750
3900
  what Blender's own Shading header resolves (`space_node.py:89-93`,
@@ -4002,7 +4152,7 @@ def rna_rig(object_name=None):
4002
4152
  presented frame carries every object at once, and the presenter has to know
4003
4153
  which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
4004
4154
  per object would be one round trip per mesh; asking by NAME stays available
4005
- for a `vgai eval` that wants to read one.
4155
+ for a `volter-model-editor eval` that wants to read one.
4006
4156
  """
4007
4157
  scene_frame = int(bpy.context.scene.frame_current)
4008
4158
  if object_name:
@@ -4625,7 +4775,32 @@ def outliner_set(path, column, value):
4625
4775
  % (column, target.bl_rna.identifier, path))
4626
4776
 
4627
4777
 
4778
+ # REQUESTS THAT CAN REMOVE OBJECTS, watched: a removal is reported with its cause, because an
4779
+ # object that disappears without a named act is the loss this session must never hide. A scene
4780
+ # was once found emptied and saved over its file by a burst of three edits at boot, with nothing
4781
+ # recording which requests they were.
4782
+ _WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step")
4783
+
4784
+
4628
4785
  def dispatch(request):
4786
+ op = request.get("op")
4787
+ if op not in _WATCHED_OPS:
4788
+ return _dispatch_request(request)
4789
+ before = {o.name for o in bpy.data.objects}
4790
+ try:
4791
+ return _dispatch_request(request)
4792
+ finally:
4793
+ removed = before - {o.name for o in bpy.data.objects}
4794
+ if removed:
4795
+ cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
4796
+ if k in request}
4797
+ if op == "execute":
4798
+ cause["code"] = str(request.get("code", ""))[:160]
4799
+ _say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
4800
+ op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
4801
+
4802
+
4803
+ def _dispatch_request(request):
4629
4804
  op = request.get("op")
4630
4805
  if op == "history-begin":
4631
4806
  HISTORY.begin()
@@ -4690,14 +4865,14 @@ def _dispatch(request):
4690
4865
  except Exception as thrown: # noqa: BLE001
4691
4866
  # LOUD, because a present that fails leaves the Model document
4692
4867
  # showing the state BEFORE this call and nothing else says so.
4693
- # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
4868
+ # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
4694
4869
  # escalate (`blender-emscripten-engine.mts:159`,
4695
- # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
4870
+ # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
4696
4871
  # to the console and nothing else), so it logged at `log` level and
4697
4872
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
4698
4873
  # the overlay walk froze the viewport through a dozen successful
4699
- # `blender-execute` calls with `vgai console` silent throughout.
4700
- _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
4874
+ # `blender-execute` calls with `volter-model-editor console` silent throughout.
4875
+ _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
4701
4876
  "showing the state before it: " + repr(thrown))
4702
4877
  return answer
4703
4878
  if op == "scene-info":
@@ -4740,14 +4915,14 @@ def _dispatch(request):
4740
4915
  except Exception as thrown: # noqa: BLE001
4741
4916
  # LOUD, because a present that fails leaves the Model document
4742
4917
  # showing the state BEFORE this call and nothing else says so.
4743
- # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
4918
+ # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
4744
4919
  # escalate (`blender-emscripten-engine.mts:159`,
4745
- # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
4920
+ # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
4746
4921
  # to the console and nothing else), so it logged at `log` level and
4747
4922
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
4748
4923
  # the overlay walk froze the viewport through a dozen successful
4749
- # `blender-execute` calls with `vgai console` silent throughout.
4750
- _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
4924
+ # `blender-execute` calls with `volter-model-editor console` silent throughout.
4925
+ _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
4751
4926
  "showing the state before it: " + repr(thrown))
4752
4927
  return answer
4753
4928
  if op == "rna-set":
@@ -4761,14 +4936,14 @@ def _dispatch(request):
4761
4936
  except Exception as thrown: # noqa: BLE001
4762
4937
  # LOUD, because a present that fails leaves the Model document
4763
4938
  # showing the state BEFORE this call and nothing else says so.
4764
- # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
4939
+ # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
4765
4940
  # escalate (`blender-emscripten-engine.mts:159`,
4766
- # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
4941
+ # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
4767
4942
  # to the console and nothing else), so it logged at `log` level and
4768
4943
  # reached no counter -- measured 2026-09-19 (I4), when a throw in
4769
4944
  # the overlay walk froze the viewport through a dozen successful
4770
- # `blender-execute` calls with `vgai console` silent throughout.
4771
- _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
4945
+ # `blender-execute` calls with `volter-model-editor console` silent throughout.
4946
+ _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
4772
4947
  "showing the state before it: " + repr(thrown))
4773
4948
  return answer
4774
4949
  if op == "save-document":
@@ -4796,7 +4971,7 @@ bpy.app.handlers.load_post.append(_load_post)
4796
4971
  bpy.app.handlers.undo_post.append(_history_post)
4797
4972
  bpy.app.handlers.redo_post.append(_history_post)
4798
4973
  ENGINES, UNAVAILABLE_ENGINES = _register_engine()
4799
- _say("@@VGAI-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
4974
+ _say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
4800
4975
  "unavailableEngines": UNAVAILABLE_ENGINES}))
4801
4976
 
4802
4977
  # Markers already dispatched, and the answers whose `out/` files this loop
@@ -4879,7 +5054,7 @@ try:
4879
5054
  except BaseException as _thrown: # noqa: BLE001
4880
5055
  # The one place a channel death can still be named. Without this the
4881
5056
  # program ends 0 and the page reports only that Blender is gone.
4882
- _say("@@VGAI-ERROR the session's channel loop raised %s: %s -- the session is over and "
5057
+ _say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
4883
5058
  "every outstanding call will go unanswered. %s"
4884
5059
  % (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
4885
5060
  raise
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ONE PRESENTER, ATTACHED TO ONE BLENDER (WS-AC, cut 3).
3
3
  *
4
- * `vgai_three.py` ships inside our Blender and turns every
4
+ * `volter_three.py` ships inside our Blender and turns every
5
5
  * `bpy.ops.render.render` into an ASK on a directory it owns; this is the
6
6
  * other half, for a host that holds that directory as a filesystem in its own
7
7
  * realm. It is the whole of what a host must do: hand it the filesystem, the
@@ -823,15 +823,24 @@ class Compiler {
823
823
  if (extension === 'CLIP')
824
824
  body.push(`if (any(lessThan(${co}.xy, vec2(0.0))) || any(greaterThan(${co}.xy, vec2(1.0)))) { ${color} = vec4(0.0); ${alpha} = 0.0; }`);
825
825
  }
826
- // node_shader_gpu_tex_image's Color output, for three's straight-alpha
827
- // upload: cleared for ignored/packed/data alpha; premultiplied (and
828
- // cleared) when Alpha is used; straight otherwise.
826
+ // node_shader_gpu_tex_image's Color output over the file's own texels (straight, or
827
+ // premultiplied for a PREMUL image, as Blender's GPU texture holds them): alpha cleared for
828
+ // ignored, packed or data alpha; otherwise straight when Alpha is used, so a factor does
829
+ // not multiply it in twice, and premultiplied when it is not. Measured in Blender 5.2's
830
+ // EEVEE: a straight red texel at alpha 0.5 emits 0.502 with Alpha unused, 1.0 with it used.
831
+ // A PREMUL sRGB image differs: the sampler decodes the premultiplied bytes, `decode(r·a)`,
832
+ // where Blender's texture holds `linear(r)·a`, so its partly transparent texels read darker.
829
833
  const mode = prop<string>(node, 'image_alpha_mode');
830
834
  const alphaUsed = this.uses(key, 1);
831
- if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data') || !alphaUsed)
832
- body.push(`${outs([0])}.a = 1.0;`);
835
+ const c = outs([0]);
836
+ if (mode === 'NONE' || mode === 'CHANNEL_PACKED' || prop<boolean>(node, 'image_is_data'))
837
+ body.push(`${c}.a = 1.0;`);
838
+ else if (mode === 'PREMUL')
839
+ body.push(alphaUsed
840
+ ? `if (${c}.a != 0.0 && ${c}.a != 1.0) ${c}.rgb /= ${c}.a;`
841
+ : `${c}.a = 1.0;`);
833
842
  else
834
- body.push(`${outs([0])} = vec4(${outs([0])}.rgb * ${outs([0])}.a, 1.0);`);
843
+ body.push(alphaUsed ? `${c}.a = 1.0;` : `${c}.rgb *= ${c}.a;`);
835
844
  break;
836
845
  }
837
846
  }
@@ -240,7 +240,7 @@ const BONE_SOLID_VERTEX = /* glsl */ `
240
240
  * ONLY THE `_fragment` HALF: three PREPENDS `colorspace_pars_fragment` to every
241
241
  * `ShaderMaterial` already, so including it here declared `LinearTransferOETF`,
242
242
  * `sRGBTransferEOTF` and `sRGBTransferOETF` twice and the whole program refused
243
- * to compile ("function already has a body", measured live in `vgai console`).
243
+ * to compile ("function already has a body", measured live in `volter-model-editor console`).
244
244
  */
245
245
  const BONE_SOLID_FRAGMENT = /* glsl */ `
246
246
  varying vec3 vColor;