@volter/blender-engine 0.1.129 → 0.1.131

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.
@@ -140,17 +140,45 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
140
140
  }
141
141
 
142
142
  /**
143
- * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
144
- * `.data` package (53 MB) and the Essentials payload are immutable for a build,
145
- * and the page keeps them in Cache Storage under the digest the artifact door
146
- * reports, so a later open reads them at the browser's own speed instead of
147
- * from the editor's server. Measured in the browser substrate, where that
148
- * server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
149
- * alone), about half of it decoding their brotli. A file the door gives no
150
- * digest for is fetched as it always was.
143
+ * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD -- WHERE THE SERVER RUNS IN THE TAB. The wasm
144
+ * (86 MB decoded), the `.data` package (53 MB) and the Essentials payload are immutable for a
145
+ * build, and a page whose editor server runs in its own tab (browser-substrate; the door says so
146
+ * as `status.inTab`) keeps them in Cache Storage under the digest the door reports, so a later
147
+ * open reads them at the browser's own speed instead of from that server. Measured there: the
148
+ * three took 7.2 s per open (4.2 s for the wasm alone), about half of it decoding their brotli
149
+ * in the tab. A file the door gives no digest for is fetched as it always was.
150
+ *
151
+ * NOT WHERE THE SERVER IS A PROCESS ON THIS MACHINE. There the door answers from local disk over
152
+ * loopback with `no-cache` and an ETag, brotli as stored to a client that accepts it
153
+ * (`editor-blender/serving/blender-routes.ts`), so the browser's HTTP cache keeps the bytes as
154
+ * sent (~38 MB) and a repeat open is a 304 and the browser's own decode. Cache Storage there
155
+ * held a second, decoded copy (~144 MiB) per origin, and an editor origin is per project folder
156
+ * (`editor-<id>.localhost:<port>`, before 0.5.190 `127.0.0.1:<port>`): measured 2026-10-07 in
157
+ * one Chrome profile, 113 origins held ~16.3 GB. The HTTP cache is size-bounded and evicted by the browser; Cache Storage is
158
+ * kept until the origin deletes it. So such a page fetches through the HTTP cache and releases
159
+ * any copy an earlier build left in its origin ({@link releaseArtifactCache}).
160
+ *
161
+ * The cache was never made persistent (nothing here calls `navigator.storage.persist`), so it is
162
+ * best-effort storage Chrome may evict under storage pressure; it stays that way.
151
163
  */
152
164
  const ARTIFACT_CACHE = 'volter-blender-artifacts';
153
165
 
166
+ /**
167
+ * RELEASE THIS ORIGIN'S COPY, on every boot of a page that does not keep one. A page can only
168
+ * delete its own origin's storage: an origin nobody opens again is reclaimed by Chrome's own
169
+ * eviction, not here. Best-effort and off the boot's path: a Cache Storage that cannot answer
170
+ * (see {@link cachedArtifact}) costs one line of page output, never the open.
171
+ */
172
+ function releaseArtifactCache(note: (text: string) => void): void {
173
+ if (typeof caches === 'undefined') return;
174
+ void caches.delete(ARTIFACT_CACHE).then(
175
+ (released) => {
176
+ if (released) note(`Released Blender's artifact cache in this origin: this editor's server runs on this machine, so the browser's HTTP cache keeps the engine instead.`);
177
+ },
178
+ (error: unknown) => note(`Blender's artifact cache in this origin could not be released (${error instanceof Error ? error.message : String(error)}); Chrome may still evict it.`),
179
+ );
180
+ }
181
+
154
182
  /**
155
183
  * THE CACHE SPEEDS AN OPEN UP; IT NEVER DECIDES WHETHER BLENDER OPENS. A Cache Storage that cannot
156
184
  * open or answer (measured 2026-10-07, headless Chrome on Windows: `caches.open` threw "Unexpected
@@ -202,10 +230,14 @@ export async function startEmscriptenBlenderEngine(
202
230
  'every response). This worker reports crossOriginIsolated=false.',
203
231
  );
204
232
  const glueUrl = artifactUrl('blender_browser.js');
205
- const digests = status.digests ?? {};
206
- const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
207
233
  // Page output, not an editor-console condition: nothing in the project can resolve the browser's cache.
208
234
  const noteCache = (text: string) => options.log('log', text);
235
+ // Digests are what `cachedArtifact` keeps a file under; with none, every file goes through the
236
+ // HTTP cache. Only a server in this tab is worth a Cache Storage copy (see ARTIFACT_CACHE).
237
+ const keepsArtifacts = status.inTab === true;
238
+ if (!keepsArtifacts) releaseArtifactCache(noteCache);
239
+ const digests = keepsArtifacts ? (status.digests ?? {}) : {};
240
+ const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
209
241
  const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`), noteCache);
210
242
  // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
211
243
  // so it is read before the module starts; the wasm streams in beside it.
@@ -237,6 +237,9 @@ export interface BlenderArtifactStatus {
237
237
  missing: string[];
238
238
  /** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
239
239
  digests?: Record<string, string>;
240
+ /** Emscripten: the server answering runs in this tab (browser-substrate). Only then are the
241
+ * digests used to keep the files in Cache Storage; absent (an older server) means false. */
242
+ inTab?: boolean;
240
243
  /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
241
244
  * requires for any non-blob URL. */
242
245
  integrity?: string;
package/browser/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { CaptureRequest, FileEntry, RuntimeStart } from './protocol';
1
+ export type { CaptureRequest, FileEntry, RuntimeStart, NativePreviewRequest, NativePreviewResult, NativeDocumentInfo, FileListSnapshot } from './protocol';
2
2
  export {
3
3
  BlenderRuntime,
4
4
  type BlenderRuntimeOptions,
@@ -12,6 +12,10 @@ export type WorkerRequest =
12
12
  * empty and is created by the first save. */
13
13
  | { id: number; op: 'start'; project: string; document?: string }
14
14
  | { id: number; op: 'execute'; code: string; history?: boolean; label?: string }
15
+ /** A native Cycles photograph, isolated from authoring/history and the
16
+ * Three frame exporter. This is a preview request, not a modeling edit. */
17
+ | { id: number; op: 'native-preview'; width: number; height: number; samples: number; camera?: string }
18
+ | { id: number; op: 'list-files-snapshot'; path: string }
15
19
  | { id: number; op: 'scene-info' }
16
20
  | { id: number; op: 'object-info'; name: string }
17
21
  | { id: number; op: 'screenshot-view'; maxSize: number }
@@ -101,6 +105,11 @@ export type WorkerRequest =
101
105
  export type WorkerReply =
102
106
  | { op: 'frame-stream'; id: number; chunk: FrameChunk }
103
107
  | { op: 'document-dirty'; dirty: boolean }
108
+ /** THE DOCUMENT FOLLOWED BLENDER: a script's `save_as_mainfile` (or `open_mainfile`) left
109
+ * another project `.blend` open, and the session's saves now go there
110
+ * (`session.py::follow_open_file`). Both paths are project-relative. Posted before the
111
+ * call's own answer, so the tab's handle already names the new file when the call returns. */
112
+ | { op: 'document-moved'; from: string; document: string }
104
113
  | { op: 'history'; entries: NativeHistoryEntry[] }
105
114
  | { id: number; result: unknown }
106
115
  | { id: number; error: string }
@@ -139,6 +148,36 @@ export type NativeHistoryEntry = { reset: true } | {
139
148
  resource: string | null;
140
149
  };
141
150
 
151
+ export interface NativePreviewRequest {
152
+ width: number;
153
+ height: number;
154
+ samples: number;
155
+ camera?: string;
156
+ }
157
+
158
+ export interface NativePreviewResult {
159
+ path: string;
160
+ renderer: 'CYCLES';
161
+ session: string;
162
+ revision: number;
163
+ camera: string;
164
+ width: number;
165
+ height: number;
166
+ samples: number;
167
+ seconds: number;
168
+ }
169
+
170
+ export interface NativeDocumentInfo {
171
+ filepath: string;
172
+ session: string;
173
+ revision: number;
174
+ }
175
+
176
+ export interface FileListSnapshot {
177
+ entries: FileEntry[];
178
+ document: NativeDocumentInfo;
179
+ }
180
+
142
181
  export interface CaptureRequest {
143
182
  /** A viewport screenshot's bound, the longer side of a square frame.
144
183
  * Absent on a render, which states its exact pixel dimensions instead. */
@@ -173,6 +212,12 @@ export interface RenderRequest {
173
212
  height: number;
174
213
  /** The camera's VERTICAL field of view, in degrees. */
175
214
  fov: number;
215
+ /** Authored camera clipping and sensor shift; not viewport navigation. */
216
+ clipStart?: number;
217
+ clipEnd?: number;
218
+ shiftX?: number;
219
+ shiftY?: number;
220
+ horizontalFit?: boolean;
176
221
  /** The scene's own view transform, as the three.js tone mapping of the same
177
222
  * NAME (`bpy/_render_three.py`). A transform with no curve here never
178
223
  * reaches this point: Python refuses it. */
@@ -12,6 +12,9 @@ import type {
12
12
  WorkerReply,
13
13
  WorkerRequest,
14
14
  NativeHistoryEntry,
15
+ NativePreviewRequest,
16
+ NativePreviewResult,
17
+ FileListSnapshot,
15
18
  } from './protocol';
16
19
  import type {
17
20
  BlenderActionClip,
@@ -49,6 +52,11 @@ export interface BlenderRuntimeOptions {
49
52
  capture?: CaptureRequest,
50
53
  ): Promise<PresentAnswer> | PresentAnswer;
51
54
  log?(level: 'log' | 'warn' | 'error', text: string): void;
55
+ /** The session's document followed Blender to another project `.blend` (a script's Save As,
56
+ * `protocol.ts`'s `document-moved`). Called after {@link BlenderRuntime.document} already
57
+ * names the new file and before the call that moved it answers. An observer: a throw here
58
+ * changes nothing. */
59
+ documentMoved?(moved: { readonly from: string; readonly document: string }): void;
52
60
  }
53
61
 
54
62
  /**
@@ -234,7 +242,9 @@ export class BlenderRuntime {
234
242
  return this.#project;
235
243
  }
236
244
 
237
- /** The startup document owned by this worker, including while boot is pending. */
245
+ /** The document owned by this worker, including while boot is pending: the startup one, until
246
+ * a script's Save As leaves Blender holding another project `.blend` and the session follows
247
+ * it there (`session.py::follow_open_file`). */
238
248
  get document(): string | null {
239
249
  return this.#document;
240
250
  }
@@ -285,6 +295,13 @@ export class BlenderRuntime {
285
295
  return (await this.#request({ op: 'execute', code, history, label })) as string;
286
296
  }
287
297
 
298
+ /** Render in native Cycles without changing or re-exporting the document.
299
+ * The returned PNG is read through readFile; no pixels cross the JSON relay. */
300
+ async nativePreview(request: NativePreviewRequest): Promise<NativePreviewResult> {
301
+ await this.#ready();
302
+ return await this.#request({ op: 'native-preview', ...request }) as NativePreviewResult;
303
+ }
304
+
288
305
  async historyGesture(op: 'history-begin' | 'history-end'): Promise<void> {
289
306
  await this.#ready();
290
307
  await this.#request({ op });
@@ -466,6 +483,13 @@ export class BlenderRuntime {
466
483
  return (await this.#request({ op: 'list-files', path })) as FileEntry[];
467
484
  }
468
485
 
486
+ /** Files and their native document identity, without executing Python or
487
+ * exporting/saving the model. The worker serializes this with authoring. */
488
+ async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
489
+ await this.#ready();
490
+ return await this.#request({ op: 'list-files-snapshot', path }) as FileListSnapshot;
491
+ }
492
+
469
493
  /** The session and revision of the last frame this runtime forwarded; null
470
494
  * before the first present. See {@link PresentedState}. */
471
495
  get presented(): PresentedState | null {
@@ -657,6 +681,14 @@ export class BlenderRuntime {
657
681
  this.#dirty = reply.dirty;
658
682
  return;
659
683
  }
684
+ if (reply.op === 'document-moved') {
685
+ // The worker already saves to the new file; this handle must claim it too, or the
686
+ // editor refuses the new file's own Model tab as a resource conflict.
687
+ this.#document = reply.document;
688
+ try { this.#options.documentMoved?.({ from: reply.from, document: reply.document }); }
689
+ catch { /* an observer cannot undo the move Blender made */ }
690
+ return;
691
+ }
660
692
  if (reply.op === 'history') {
661
693
  this.#options.history?.(reply.entries);
662
694
  return;
@@ -207,13 +207,8 @@ def _load_post(_arg):
207
207
  # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
208
208
  # into the Main that just went away); this is the session's half.
209
209
  SESSION.forget()
210
- # A factory reset reloads every add-on (`bpy.utils.load_scripts(reload_scripts=True)`
211
- # runs inside `read_factory_settings`), and the Cycles add-on's own engine
212
- # class comes back under `CYCLES` while ours is gone -- measured in the tab:
213
- # after the battery's opening reset, `scene.render.engine = 'CYCLES'`
214
- # resolved to `cycles.CyclesRender`, every render was a real path trace
215
- # in wasm (35 s at 1280x720, 48 samples) and `_photograph` never ran. The
216
- # engine ids are taken again on every load.
210
+ # Factory reset reloads add-ons. Preserve native Cycles and re-register
211
+ # only the headless GPU presenters we own.
217
212
  ENGINES[:], UNAVAILABLE_ENGINES[:] = _register_engine()
218
213
  HISTORY.reset()
219
214
 
@@ -413,7 +408,9 @@ def _describe_world_socket(root_socket, camera_ray):
413
408
  vector = node.inputs.get("Vector")
414
409
  if vector is not None and vector.links:
415
410
  raise NotImplementedError("Linked World Sky Texture Vector")
416
- return {"kind": "sky", "sky_model": model, "sun_elevation": float(node.sun_elevation),
411
+ return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
412
+ "sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
413
+ "sun_elevation": float(node.sun_elevation),
417
414
  "sun_rotation": float(node.sun_rotation), "altitude": float(node.altitude),
418
415
  "air_density": float(node.air_density), "aerosol_density": float(node.aerosol_density),
419
416
  "ozone_density": float(node.ozone_density)}
@@ -608,7 +605,7 @@ _GRAPH_NODES = frozenset((
608
605
  "ShaderNodeGamma", "ShaderNodeRGBToBW", "ShaderNodeSeparateColor", "ShaderNodeCombineColor",
609
606
  "ShaderNodeVectorRotate", "ShaderNodeFresnel", "ShaderNodeLayerWeight", "ShaderNodeBump",
610
607
  "ShaderNodeNormalMap", "ShaderNodeNewGeometry", "ShaderNodeVertexColor", "ShaderNodeAttribute",
611
- "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve", "ShaderNodeObjectInfo",
608
+ "ShaderNodeRGBCurve", "ShaderNodeVectorCurve", "ShaderNodeFloatCurve", "ShaderNodeObjectInfo", "ShaderNodeParticleInfo",
612
609
  ))
613
610
  # The surfaces whose inputs map onto the presenter's standard material, and
614
611
  # the inputs of each the presenter reads from a graph.
@@ -937,6 +934,8 @@ class _MaterialGraph:
937
934
  return self.source(stack[:-1], outer_socket)
938
935
  if kind not in _GRAPH_NODES:
939
936
  raise _GraphRefusal("%s (%s) is not compiled by the presenter" % (node.name, kind))
937
+ if kind == "ShaderNodeParticleInfo" and socket.name != "Random":
938
+ raise _GraphRefusal("Particle Info output %s is not compiled by the presenter" % socket.name)
940
939
  refusal = _refusal(node)
941
940
  if refusal is not None:
942
941
  raise _GraphRefusal(refusal)
@@ -1164,6 +1163,91 @@ def _object_info_random(name):
1164
1163
  return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1165
1164
 
1166
1165
 
1166
+ def _particle_info_random(index=0):
1167
+ """Cycles Particle Info Random: hash_uint2(particle.index, 0).
1168
+
1169
+ Native sync_dupli_particle excludes child particles; they and objects
1170
+ without a particle record use the zero-index dummy KernelParticle.
1171
+ This is distinct from Object Info's placement random_id.
1172
+ """
1173
+ import struct
1174
+ mask = 0xffffffff
1175
+ a = (0xdeadbeef + 21 + index) & mask
1176
+ b = c = (0xdeadbeef + 21) & mask
1177
+ def rotate(x, bits):
1178
+ return ((x << bits) | (x >> (32 - bits))) & mask
1179
+ c = ((c ^ b) - rotate(b, 14)) & mask
1180
+ a = ((a ^ c) - rotate(c, 11)) & mask
1181
+ b = ((b ^ a) - rotate(a, 25)) & mask
1182
+ c = ((c ^ b) - rotate(b, 16)) & mask
1183
+ a = ((a ^ c) - rotate(c, 4)) & mask
1184
+ b = ((b ^ a) - rotate(a, 14)) & mask
1185
+ c = ((c ^ b) - rotate(b, 24)) & mask
1186
+ return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1187
+
1188
+
1189
+ def _depsgraph_placements(frame, depsgraph):
1190
+ """Placement metadata only; every vertex stays in the native C++ arena.
1191
+
1192
+ Keep this table separate from authoring objects. No realization, datablock
1193
+ edits, vertex arrays or generated scene objects.
1194
+ """
1195
+ import struct
1196
+ by_name = {row["name"]: row for row in frame["objects"]}
1197
+ for row in frame["objects"]:
1198
+ source = bpy.data.objects.get(row["name"])
1199
+ if source is not None:
1200
+ row["shadow_visible"] = bool(source.visible_shadow)
1201
+ # Frame-local native addresses are comparison tokens, not authoring IDs.
1202
+ # Consume them here so neither the presenter nor saved projects retain them.
1203
+ geometry = frame.pop("instance_geometry", {})
1204
+ placements = []
1205
+ for instance in depsgraph.object_instances:
1206
+ source = instance.object.original
1207
+ row = by_name.get(source.name)
1208
+ if not instance.is_instance:
1209
+ if row is not None:
1210
+ row["render_visible"] = row["render_visible"] and not source.hide_render
1211
+ row["viewport_show_self"] = bool(instance.show_self)
1212
+ row["render_show_self"] = not source.is_instancer or source.show_instancer_for_render
1213
+ continue
1214
+ if not instance.show_self:
1215
+ continue
1216
+ if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
1217
+ (instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
1218
+ raise NotImplementedError("Depsgraph instance geometry differs from its native source: %s" % source.name)
1219
+ if row is None or (source.type == "MESH" and row["mesh"] is None):
1220
+ raise NotImplementedError("Depsgraph instance source has no native exported geometry: %s" % source.name)
1221
+ if row["mesh"] is None:
1222
+ if source.type not in ("EMPTY", "CAMERA"):
1223
+ warn("Depsgraph instance %r of type %s is not drawn" % (source.name, source.type))
1224
+ continue
1225
+ owner = instance.parent.original if instance.parent is not None else None
1226
+ owner_row = by_name.get(owner.name) if owner is not None else None
1227
+ if owner is not None and owner_row is None:
1228
+ raise NotImplementedError("Depsgraph instance owner is not exported: %s" % owner.name)
1229
+ persistent = list(instance.persistent_id)
1230
+ particle_index = persistent[0]
1231
+ psys = instance.particle_system
1232
+ if psys is None or not 0 <= particle_index < len(psys.particles):
1233
+ particle_index = 0
1234
+ random = struct.unpack("f", struct.pack("f", (int(instance.random_id) & 0xffffffff) / 4294967296.0))[0]
1235
+ placements.append({
1236
+ "id": "@instance:" + json.dumps([owner.name if owner else None, source.name, persistent], separators=(",", ":"), ensure_ascii=False),
1237
+ "source": row["id"], "owner": owner_row["id"] if owner_row is not None else None,
1238
+ "matrix": [[float(v) for v in r] for r in instance.matrix_world],
1239
+ "visible": True,
1240
+ "render_visible": not source.hide_render and (owner is None or not owner.hide_render) and
1241
+ (owner_row is None or bool(owner_row["render_visible"])),
1242
+ # Cycles intersects source and instancer ray visibility.
1243
+ "shadow_visible": bool(source.visible_shadow) and (owner is None or bool(owner.visible_shadow)),
1244
+ "random": random,
1245
+ "particle_random": _particle_info_random(particle_index),
1246
+ "color": [float(v) for v in instance.object.color],
1247
+ })
1248
+ return placements
1249
+
1250
+
1167
1251
  def material_graphs(scene):
1168
1252
  """Every graph the scene's materials need, by material name."""
1169
1253
  _TREE_LINKS.clear()
@@ -1380,6 +1464,9 @@ class Session:
1380
1464
  # Set by a present that left the document behind the model; cleared by
1381
1465
  # the save. The tab reads it as `saveDue` beside the frame.
1382
1466
  self.save_due = False
1467
+ # The OUTSIDE-THE-PROJECT file Blender was last found holding, once said
1468
+ # (`follow_open_file`): the refusal is told once per file, not per call.
1469
+ self._outside_file = None
1383
1470
  # WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
1384
1471
  # `(session, revision)` it held BEFORE the last frame, None when it
1385
1472
  # held nothing, and the string "unreported" for a presenter that does
@@ -1417,7 +1504,7 @@ class Session:
1417
1504
  # (`overlay_empty.hh` `image_sync`).
1418
1505
  empty_images = {obj.data.name for obj in scene.objects
1419
1506
  if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
1420
- options = {"session": self.session, "evaluate": True, "known": self._known,
1507
+ options = {"session": self.session, "evaluate": True, "known": self._known, "instance_sources": True,
1421
1508
  "graph_materials": sorted(graphs),
1422
1509
  "graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
1423
1510
  "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
@@ -1490,6 +1577,7 @@ class Session:
1490
1577
  "color": [float(v) for v in obj.color],
1491
1578
  "index": float(obj.pass_index),
1492
1579
  "random": _object_info_random(obj.name),
1580
+ "particle_random": _particle_info_random(),
1493
1581
  }
1494
1582
  data = obj.evaluated_get(depsgraph).data if obj is not None else None
1495
1583
  if getattr(data, "use_auto_texspace", True) is False:
@@ -1510,6 +1598,7 @@ class Session:
1510
1598
  "roughness": float(material.roughness),
1511
1599
  "metallic": float(material.metallic),
1512
1600
  }
1601
+ frame["instances"] = _depsgraph_placements(frame, bpy.context.evaluated_depsgraph_get())
1513
1602
  frame["world"] = draw_world(scene)
1514
1603
  frame["cameras"] = {
1515
1604
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
@@ -1878,10 +1967,11 @@ class Session:
1878
1967
  def bind_document(self, relative_path):
1879
1968
  """Name the session's document and OPEN it when the project has one.
1880
1969
 
1881
- One session holds one document. The path is stated at start and never
1882
- moves afterwards: a script's own `save_as_mainfile` to somewhere else
1883
- is an ordinary thing for a script to do and is left alone -- it retargets
1884
- Blender's `bpy.data.filepath`, not this.
1970
+ One session holds one document. The path is stated at start and moves
1971
+ only where Blender itself moved: a script's own `save_as_mainfile` (or
1972
+ `open_mainfile`) to another `.blend` in the project retargets
1973
+ `bpy.data.filepath`, and `follow_open_file` moves the document with it
1974
+ after the call.
1885
1975
 
1886
1976
  `open_mainfile` runs the session's `_load_post` like any other load
1887
1977
  (the export door's revision table is reset, the render engine ids are
@@ -1925,6 +2015,58 @@ class Session:
1925
2015
  if self.document is not None:
1926
2016
  self.save_due = True
1927
2017
 
2018
+ def follow_open_file(self):
2019
+ """FOLLOW BLENDER'S SAVE AS: the document is the file Blender has open.
2020
+
2021
+ Read after every `execute`, because a script is the one request that can
2022
+ change which file Blender holds. `save_as_mainfile(filepath=X)` writes X
2023
+ and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
2024
+ Save goes there. The session used to keep saving to the file it was
2025
+ started on, so the model a script had just saved as X was written over
2026
+ the OLD file on the next idle save, and the editor refused X's own tab
2027
+ because the worker still claimed the old one. SEEN 2026-10-07: an
2028
+ agent built an obby through the Blender MCP, saved it as
2029
+ `src/models/obby.blend`, and its level landed in `src/models/cube.blend`
2030
+ instead -- so Play looked for `cube.play.ts`.
2031
+
2032
+ What does NOT move, by Blender's own rules rather than ours:
2033
+ `save_mainfile()` and a Save As to the same path leave the path where it
2034
+ is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
2035
+ original open (which is also how `save_document` writes, so the
2036
+ session's own save never moves anything). `open_mainfile` does move it:
2037
+ Blender then holds that file, and saving the old path would write one
2038
+ file's model over another's.
2039
+
2040
+ Answers None when nothing moved, `{"moved": True, "from", "document"}`
2041
+ when the document followed Blender to another project `.blend`, and
2042
+ `{"moved": False, "from", "outside"}` -- once per file -- when Blender
2043
+ holds a file outside the project: the editor has no document there, so
2044
+ the session keeps its own and keeps saving to it.
2045
+ """
2046
+ if self.document is None or not bpy.data.filepath:
2047
+ return None
2048
+ current = os.path.normpath(os.path.abspath(bpy.data.filepath))
2049
+ if current == os.path.normpath(self.document):
2050
+ self._outside_file = None
2051
+ return None
2052
+ root = os.path.normpath(self.project)
2053
+ relative = current[len(root) + 1:] if current.startswith(root + "/") else None
2054
+ # The same shape the worker and the server check (`worker.ts::isDocumentPath`).
2055
+ if (relative is not None and relative.endswith(".blend") and "\\" not in relative
2056
+ and all(part not in ("", ".", "..") for part in relative.split("/"))):
2057
+ previous = self.document_relative
2058
+ self.document = current
2059
+ self.document_relative = relative
2060
+ self._outside_file = None
2061
+ # The file Blender wrote sits only in the engine's filesystem until the
2062
+ # session's own save carries it to the project.
2063
+ self.save_due = True
2064
+ return {"moved": True, "from": previous, "document": relative}
2065
+ if current == self._outside_file:
2066
+ return None
2067
+ self._outside_file = current
2068
+ return {"moved": False, "from": self.document_relative, "outside": current}
2069
+
1928
2070
  def save_document(self):
1929
2071
  """Write the document -- Blender's own format, by Blender's own operator.
1930
2072
 
@@ -2175,6 +2317,12 @@ def _photograph(depsgraph, width, height, linear=False):
2175
2317
  "width": int(width),
2176
2318
  "height": int(height),
2177
2319
  "fov": _vertical_extent(camera.data, int(width), int(height)),
2320
+ "clipStart": float(camera.data.clip_start),
2321
+ "clipEnd": float(camera.data.clip_end),
2322
+ "shiftX": float(camera.data.shift_x),
2323
+ "shiftY": float(camera.data.shift_y),
2324
+ "horizontalFit": camera.data.sensor_fit == "HORIZONTAL" or
2325
+ (camera.data.sensor_fit == "AUTO" and width >= height),
2178
2326
  "toneMapping": transform,
2179
2327
  "exposure": float(2.0 ** view.exposure),
2180
2328
  "gamma": float(view.gamma),
@@ -2239,10 +2387,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
2239
2387
  """The scene's renderer, so `write_still`, `save_render` and Render Result
2240
2388
  behave as Blender's own.
2241
2389
 
2242
- Registered under the three engine ids a script names. The port has real
2243
- Cycles compiled in, so the Cycles add-on's own engine is unregistered
2244
- first -- otherwise `scene.render.engine = 'CYCLES'` resolves to it and
2245
- starts a path trace nobody asked for inside the tab.
2390
+ Registered as VOLTER_THREE and for headless GPU-engine substitutes.
2391
+ CYCLES remains Blender's compiled CPU path tracer.
2246
2392
  """
2247
2393
 
2248
2394
  bl_idname = "VOLTER_THREE"
@@ -2372,22 +2518,21 @@ def _release_from_owning_addon(existing):
2372
2518
 
2373
2519
 
2374
2520
  def _register_engine():
2375
- """Take the three engine ids a script can name.
2376
-
2377
- Blender refuses two classes with one `bl_idname`, so the add-on's engine
2378
- class is unregistered first and the bundle's patched registration sets a
2379
- built-in type aside; `bpy.utils.register_class` on a subclass whose
2380
- `bl_idname` is `CYCLES` (or `BLENDER_EEVEE`, the factory default) then
2381
- makes `scene.render.engine` resolve here. `BLENDER_EEVEE` is the factory engine's id in 5.x (measured on the
2382
- oracle: the enum holds exactly that name; `BLENDER_EEVEE_NEXT` was 4.2's). Only the Cycles ENGINE CLASS goes: the add-on stays enabled, because
2383
- `scene.cycles` is the add-on's property group and a script sets `samples`,
2384
- `use_denoising` and the rest on it (disabling the add-on removed it, and
2385
- every workshop render died on `'Scene' object has no attribute 'cycles'`
2386
- before it reached the photograph).
2521
+ """Keep native Cycles and install the headless GPU presenters.
2522
+
2523
+ Native Cycles needs no browser GPU. Replacing its class with a Combined-only
2524
+ Three photograph drops evaluated instances, GI, shader closures and other
2525
+ compositor pass inputs. VOLTER_THREE is the explicit raster photograph;
2526
+ the live viewport and Play use Three independently of this engine choice.
2387
2527
  """
2388
2528
  made, unavailable = [], []
2529
+ cycles = _engine_class("CYCLES")
2530
+ if cycles is not None and not issubclass(cycles, VolterRenderEngine):
2531
+ made.append("CYCLES")
2532
+ else:
2533
+ # Do not report a raster substitute as an available native path tracer.
2534
+ unavailable.append("CYCLES")
2389
2535
  for identifier, label in (
2390
- ("CYCLES", "Cycles"),
2391
2536
  ("BLENDER_EEVEE", "EEVEE"),
2392
2537
  ("BLENDER_WORKBENCH", "Workbench"),
2393
2538
  ):
@@ -2414,7 +2559,7 @@ def _register_engine():
2414
2559
  # Upstream Blender refuses to let a Python engine take a BUILT-IN
2415
2560
  # id ("is built-in"); the shipped bundle carries a patch that sets
2416
2561
  # the built-in type aside instead, since headless has no GPU for it
2417
- # to render with, so all three ids are taken there (measured
2562
+ # to render with, so the two GPU ids are taken there (measured
2418
2563
  # 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
2419
2564
  # and `BLENDER_WORKBENCH`: that is a fact about the build, stated
2420
2565
  # in the start reply and refused by name at the render, never a
@@ -5167,8 +5312,63 @@ def _dispatch_request(request):
5167
5312
  }[op])
5168
5313
 
5169
5314
 
5315
+ def _native_preview(request):
5316
+ """Native Cycles in an owned scene copy, never in the authoring scene.
5317
+
5318
+ Preview settings, Render Result and temporary files are derived render
5319
+ resources. No history checkpoint, save, frame export or revision bump is
5320
+ owed by this operation. Remove the owned scene before another operation
5321
+ can serialize a project or enumerate its scene datablocks.
5322
+ """
5323
+ for key, maximum in (("width", 1024), ("height", 1024), ("samples", 512)):
5324
+ value = request.get(key)
5325
+ if type(value) is not int or not 1 <= value <= maximum:
5326
+ raise ValueError("Native preview %s must be an integer from 1 to %d" % (key, maximum))
5327
+ engine = _engine_class("CYCLES")
5328
+ if engine is None or issubclass(engine, VolterRenderEngine):
5329
+ raise RuntimeError("Native Cycles is unavailable; a Three photograph cannot answer this request")
5330
+ original = bpy.context.scene
5331
+ camera = original.objects.get(request["camera"]) if request.get("camera") else original.camera
5332
+ if camera is None or camera.type != "CAMERA":
5333
+ raise ValueError("Native preview needs a camera belonging to this scene")
5334
+ preview = original.copy()
5335
+ path = os.path.join(ROOT, "native-preview.png")
5336
+ try:
5337
+ preview.camera = camera
5338
+ preview.render.engine = "CYCLES"
5339
+ preview.render.resolution_x = request["width"]
5340
+ preview.render.resolution_y = request["height"]
5341
+ preview.render.resolution_percentage = 100
5342
+ # Rendered viewport shading precedes the compositor and uses its own
5343
+ # sampling budget; the authoring scene's settings remain untouched.
5344
+ preview.render.use_compositing = False
5345
+ preview.render.use_persistent_data = False
5346
+ preview.cycles.samples = request["samples"]
5347
+ preview.cycles.use_denoising = False
5348
+ preview.render.image_settings.file_format = "PNG"
5349
+ preview.render.image_settings.color_mode = "RGBA"
5350
+ preview.render.image_settings.color_depth = "8"
5351
+ preview.render.filepath = path
5352
+ if os.path.exists(path):
5353
+ os.unlink(path)
5354
+ start = time.perf_counter()
5355
+ result = bpy.ops.render.render(write_still=True, scene=preview.name, layer=bpy.context.view_layer.name)
5356
+ if "FINISHED" not in result or not os.path.isfile(path):
5357
+ raise RuntimeError("Native Cycles preview did not produce its PNG")
5358
+ return {"path": path, "renderer": "CYCLES", "session": SESSION.session,
5359
+ "revision": SESSION.revision, "camera": camera.name,
5360
+ "width": request["width"], "height": request["height"],
5361
+ "samples": request["samples"], "seconds": time.perf_counter() - start}
5362
+ finally:
5363
+ bpy.data.scenes.remove(preview, do_unlink=True)
5364
+
5365
+
5170
5366
  def _dispatch(request):
5171
5367
  op = request.get("op")
5368
+ if op == "native-preview":
5369
+ return _native_preview(request)
5370
+ if op == "document-info":
5371
+ return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
5172
5372
  if op == "execute":
5173
5373
  engine = bpy.context.scene.render.engine
5174
5374
  if engine in UNAVAILABLE_ENGINES and "render.render" in request["code"]:
@@ -5185,6 +5385,11 @@ def _dispatch(request):
5185
5385
  HISTORY.changed()
5186
5386
  SESSION.mark_changed()
5187
5387
  answer = execute(request["code"])
5388
+ # Read before the present, so the save that present asks for goes to the
5389
+ # file Blender now holds (`Session.follow_open_file`).
5390
+ followed = SESSION.follow_open_file()
5391
+ if followed is not None:
5392
+ answer["document"] = followed
5188
5393
  # Every mutation is presented, the rule: the Model
5189
5394
  # document is what the agent is looking at.
5190
5395
  try:
@@ -0,0 +1,27 @@
1
+ /** Disposable draw records only. The native frame's authoring objects remain
2
+ * unchanged; followers receive these records without expanding them twice. */
3
+ type Placement = {
4
+ id:string;source:string;owner:string|null;matrix:number[][];
5
+ visible:boolean;render_visible:boolean;shadow_visible?:boolean | undefined;random:number;color:number[];particle_random?:number | undefined;
6
+ };
7
+ type DrawSource = {
8
+ id:string;name:string;parent:string|null;selected:boolean;
9
+ object_info?:{random:number;particle_random?:number | undefined} | undefined;
10
+ };
11
+ export function expandDrawPlacements<T extends {objects:DrawSource[];instances?:Placement[] | undefined}>(frame: T): T {
12
+ if (!frame.instances?.length) return frame;
13
+ const sources = new Map(frame.objects.map(row => [row.id,row]));
14
+ const draws = frame.instances.map(instance => {
15
+ const source = sources.get(instance.source), owner = instance.owner === null ? null : sources.get(instance.owner);
16
+ if (!source) throw new Error(`Missing native instance source ${instance.source}`);
17
+ if (instance.owner !== null && !owner) throw new Error(`Missing native instance owner ${instance.owner}`);
18
+ return {...source,id:instance.id,name:owner?.name ?? source.name,parent:instance.owner,
19
+ instance_owner:instance.owner,matrix:instance.matrix,visible:instance.visible,
20
+ viewport_show_self:true,render_show_self:true,
21
+ render_visible:instance.render_visible,selected:owner?.selected ?? false,
22
+ shadow_visible:instance.shadow_visible ?? true,
23
+ ...(source.object_info ? {object_info:{...source.object_info,random:instance.random,color:instance.color,particle_random:instance.particle_random}} : {})};
24
+ });
25
+ const {instances: _placements,...rest} = frame;
26
+ return {...rest,objects:[...frame.objects,...draws]} as T;
27
+ }