@volter/blender-engine 0.1.7 → 0.1.9

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.
@@ -133,9 +133,37 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
133
133
  };
134
134
  }
135
135
 
136
+ /**
137
+ * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
138
+ * `.data` package (53 MB) and the Essentials payload are immutable for a build,
139
+ * and the page keeps them in Cache Storage under the digest the artifact door
140
+ * reports, so a later open reads them at the browser's own speed instead of
141
+ * from the editor's server. Measured in the browser substrate, where that
142
+ * server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
143
+ * alone), about half of it decoding their brotli. A file the door gives no
144
+ * digest for is fetched as it always was.
145
+ */
146
+ const ARTIFACT_CACHE = 'volter-blender-artifacts';
147
+
148
+ async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
149
+ const url = artifactUrl(file);
150
+ if (!digest || typeof caches === 'undefined') return fetch(url);
151
+ const key = `${url}?sha256=${digest}`;
152
+ const cache = await caches.open(ARTIFACT_CACHE);
153
+ const hit = await cache.match(key);
154
+ if (hit) return hit;
155
+ const response = await fetch(url);
156
+ if (!response.ok) return response;
157
+ // One build's bytes per file: an older build's are dropped as this one lands.
158
+ for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
159
+ const kept = new Response(await response.blob(), { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
160
+ await cache.put(key, kept.clone());
161
+ return kept;
162
+ }
163
+
136
164
  export async function startEmscriptenBlenderEngine(
137
165
  options: BlenderEngineOptions,
138
- _status: BlenderArtifactStatus,
166
+ status: BlenderArtifactStatus,
139
167
  ): Promise<BlenderEngine> {
140
168
  if (!self.crossOriginIsolated)
141
169
  throw new Error(
@@ -144,7 +172,17 @@ export async function startEmscriptenBlenderEngine(
144
172
  'every response). This worker reports crossOriginIsolated=false.',
145
173
  );
146
174
  const glueUrl = artifactUrl('blender_browser.js');
147
- const factory = await loadFactory(glueUrl);
175
+ const digests = status.digests ?? {};
176
+ // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
177
+ // so it is read before the module starts; the wasm streams in beside it.
178
+ const [factory, preloaded] = await Promise.all([
179
+ loadFactory(glueUrl),
180
+ cachedArtifact('blender_browser.data', digests['blender_browser.data']).then(async (response) => {
181
+ if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
182
+ return response.arrayBuffer();
183
+ }),
184
+ ]);
185
+ let bootError: unknown = null;
148
186
  const started = performance.now();
149
187
  let readyLine: string | null = null;
150
188
  // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
@@ -164,6 +202,16 @@ export async function startEmscriptenBlenderEngine(
164
202
  const module = await factory({
165
203
  arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
166
204
  locateFile: (file: string) => artifactUrl(file),
205
+ getPreloadedPackage: () => preloaded,
206
+ instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
207
+ void (async () => {
208
+ const response = await cachedArtifact('blender_browser.wasm', digests['blender_browser.wasm']);
209
+ if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
210
+ const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
211
+ receive(instance, compiled);
212
+ })().catch((error: unknown) => { bootError = error; });
213
+ return {};
214
+ },
167
215
  // The pthreads load the SAME patched glue this worker just evaluated.
168
216
  mainScriptUrlOrBlob: glueUrl,
169
217
  print: (text: string) => say('log', text),
@@ -209,7 +257,10 @@ export async function startEmscriptenBlenderEngine(
209
257
  });
210
258
  // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
211
259
  // long before the session exists. The ready line is what says it does.
212
- while (readyLine === null) await sleep(5);
260
+ while (readyLine === null) {
261
+ if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
262
+ await sleep(5);
263
+ }
213
264
  const bootMs = performance.now() - started;
214
265
  const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
215
266
 
@@ -268,7 +319,7 @@ export async function startEmscriptenBlenderEngine(
268
319
 
269
320
  const files = moduleFiles(module);
270
321
  FS.chmod('/bw/datafiles', 0o755);
271
- await mountEssentials(files);
322
+ await mountEssentials(files, digests['essentials.bin']);
272
323
  const { request } = openSessionChannel(files, options);
273
324
 
274
325
  return {
@@ -293,9 +344,9 @@ export async function startEmscriptenBlenderEngine(
293
344
  /** Assets are data, not a second engine. Ship them separately so a data update
294
345
  * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
295
346
  * checked before anything enters Blender's filesystem. */
296
- async function mountEssentials(files: BlenderFiles): Promise<void> {
347
+ async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
297
348
  const [indexResponse, payloadResponse] = await Promise.all([
298
- fetch(artifactUrl('essentials.json')), fetch(artifactUrl('essentials.bin')),
349
+ fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
299
350
  ]);
300
351
  if (!indexResponse.ok || !payloadResponse.ok)
301
352
  throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
@@ -179,6 +179,8 @@ export interface BlenderArtifactStatus {
179
179
  encoded: Record<string, 'br'>;
180
180
  /** Why it is unavailable, named. Empty when it is available. */
181
181
  missing: string[];
182
+ /** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
183
+ digests?: Record<string, string>;
182
184
  /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
183
185
  * requires for any non-blob URL. */
184
186
  integrity?: string;
@@ -434,10 +434,10 @@ export class BlenderRuntime {
434
434
  if (this.#terminated) return Promise.resolve();
435
435
  if (this.#stopping) return this.#stopping;
436
436
  this.#stopping = (async () => {
437
- if (this.#started) {
438
- await this.#started;
439
- await this.#request({ op: 'flush-document' }, true);
440
- }
437
+ // A start that failed opened no document, so there is nothing to flush: its stale
438
+ // rejection must not refuse every later close.
439
+ const started = this.#started ? await this.#started.then(() => true, () => false) : false;
440
+ if (started) await this.#request({ op: 'flush-document' }, true);
441
441
  this.terminate();
442
442
  })().catch(error => {
443
443
  this.#stopping = null;
@@ -470,7 +470,7 @@ export class BlenderRuntime {
470
470
  * A `present` is not counted separately — the worker only presents from
471
471
  * inside a call this meter is already holding open, and the page-side cost of
472
472
  * displaying the frame is main-thread time, which is the long-task observer's
473
- * subject (`packages/editor/src/blender-tab-metrics.ts`).
473
+ * subject (the host's `worker-call-metrics.ts`).
474
474
  */
475
475
  metrics(now: number = performance.now()): BlenderCallMetrics {
476
476
  let oldest: number | null = null;
@@ -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", ()))
@@ -1343,16 +1493,15 @@ class Session:
1343
1493
  # its value also depends on native undo initialization/checkpoints.
1344
1494
  "dirty": bool(bpy.data.is_dirty),
1345
1495
  }
1346
- # A PRESENT IS WHAT LEAVES THE DOCUMENT STALE, and the predicate is the
1347
- # present itself rather than what the frame shipped. `columns`/`images`
1348
- # above count BYTES THAT CROSSED, which is a different question: a MOVE
1349
- # ships zero columns (the comment above says so) and a DELETION ships
1350
- # nothing at all, yet both are changes a document that loses them is
1351
- # wrong. The cost this predicate could waste is bounded by the tab's
1352
- # one-save-per-idle-second debounce, and `save_document` still asks
1353
- # Blender whether anything actually changed before it writes.
1354
- if self.document is not None:
1355
- self.save_due = True
1496
+ # A PRESENT DOES NOT MAKE THE DOCUMENT STALE; a request that can change
1497
+ # the model does (`mark_changed`, from `dispatch`). The predicate was the
1498
+ # present itself, and a present also runs with no change behind it --
1499
+ # opening the document, rebinding its view. MEASURED 2026-09-24 on
1500
+ # `arena`: opening a Model document and doing nothing rewrote its
1501
+ # `.blend` with 556 bytes changed, so a file a person only looked at read
1502
+ # as modified. `columns`/`images` above stay the wrong question for the
1503
+ # same reason as before: a MOVE ships zero columns and a DELETION ships
1504
+ # nothing, and both come from a request `mark_changed` sees.
1356
1505
  for key, mesh in frame["meshes"].items():
1357
1506
  self._known["mesh:" + key] = mesh["revision"]
1358
1507
  for name, image in frame["images"].items():
@@ -1540,6 +1689,12 @@ class Session:
1540
1689
  "objects": len(bpy.data.objects),
1541
1690
  "size": os.path.getsize(self.document)}
1542
1691
 
1692
+ def mark_changed(self):
1693
+ """A request that can change the model ran: the next present carries
1694
+ `saveDue`, and the tab saves after an idle second."""
1695
+ if self.document is not None:
1696
+ self.save_due = True
1697
+
1543
1698
  def save_document(self):
1544
1699
  """Write the document -- Blender's own format, by Blender's own operator.
1545
1700
 
@@ -1625,11 +1780,11 @@ class Session:
1625
1780
  #
1626
1781
  # It was briefly used to skip a redundant write, and it froze the
1627
1782
  # document after its first save -- a reopened session could model all
1628
- # day and never write again. The predicate is `_present`'s instead: a
1629
- # present is what leaves the document stale. Coarser (a read-only
1630
- # script rewrites the file once), bounded by the tab's idle-second
1631
- # debounce, and it cannot miss a change. The reading still rides in the
1632
- # answer, as an instrument.
1783
+ # day and never write again. The predicate is `mark_changed`'s instead:
1784
+ # a request that can change the model ran. Coarser than a diff (a
1785
+ # read-only script rewrites the file once), bounded by the tab's
1786
+ # idle-second debounce, and it cannot miss a change. The reading still
1787
+ # rides in the answer, as an instrument.
1633
1788
  dirty = bool(bpy.data.is_dirty)
1634
1789
  existed = os.path.exists(self.document)
1635
1790
  directory = os.path.dirname(self.document)
@@ -4620,7 +4775,32 @@ def outliner_set(path, column, value):
4620
4775
  % (column, target.bl_rna.identifier, path))
4621
4776
 
4622
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
+
4623
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("@@VGAI-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):
4624
4804
  op = request.get("op")
4625
4805
  if op == "history-begin":
4626
4806
  HISTORY.begin()
@@ -4640,6 +4820,7 @@ def dispatch(request):
4640
4820
  if op == "history-step":
4641
4821
  if request["direction"] not in ("undo", "redo"):
4642
4822
  raise ValueError("Unknown history direction")
4823
+ SESSION.mark_changed()
4643
4824
  return HISTORY.move(request["token"], request["direction"])
4644
4825
  mutation = op in ("execute", "rna-set", "outliner-set") and request.get("history", True)
4645
4826
  if not mutation:
@@ -4675,6 +4856,7 @@ def _dispatch(request):
4675
4856
  # Arbitrary Python can partially mutate before throwing. Once execution
4676
4857
  # starts it needs a checkpoint; capability refusals above do not.
4677
4858
  HISTORY.changed()
4859
+ SESSION.mark_changed()
4678
4860
  answer = execute(request["code"])
4679
4861
  # Every mutation is presented, the rule: the Model
4680
4862
  # document is what the agent is looking at.
@@ -4723,6 +4905,7 @@ def _dispatch(request):
4723
4905
  if op == "outliner":
4724
4906
  return rna_outliner(request.get("selected"))
4725
4907
  if op == "outliner-set":
4908
+ SESSION.mark_changed()
4726
4909
  answer = outliner_set(request["path"], request["column"], request["value"])
4727
4910
  # A COLUMN IS A WRITE, and a write presents -- the same rule `rna-set`
4728
4911
  # follows: hiding an object in the Outliner must change what the
@@ -4743,6 +4926,7 @@ def _dispatch(request):
4743
4926
  "showing the state before it: " + repr(thrown))
4744
4927
  return answer
4745
4928
  if op == "rna-set":
4929
+ SESSION.mark_changed()
4746
4930
  answer = rna_set(request["path"], request["property"], request["value"],
4747
4931
  request.get("index"))
4748
4932
  # A WRITE IS PRESENTED, exactly as `execute` presents: the Model
@@ -143,6 +143,11 @@ export function graphProgramKey(material: THREE.MeshPhysicalMaterial): string {
143
143
  return `${binding.compiled.key}|${JSON.stringify(binding.channels)}`;
144
144
  }
145
145
 
146
+ /** The UV channels the material's graph reads on the current draw. */
147
+ export function graphUvChannels(material: THREE.MeshPhysicalMaterial): number[] {
148
+ return Object.values(bindings.get(material)?.channels ?? {});
149
+ }
150
+
146
151
  /** The channel each named UV layer is in on `geometry` (a layer the mesh
147
152
  * lacks reads zeros from channel 9, Blender's missing attribute). */
148
153
  function channelsFor(compiled: CompiledGraph, geometry: THREE.BufferGeometry): Record<string, number> {
@@ -305,7 +310,6 @@ export function applyGraphShader(material: THREE.MeshPhysicalMaterial, shader: T
305
310
  const {compiled, channels} = binding;
306
311
  Object.assign(shader.uniforms, binding.uniforms, {blenderViewport: {value: viewport}});
307
312
  const attribute = (channel: number) => (channel === 0 ? 'uv' : `uv${channel}`);
308
- const guards = [1, 2, 3].map(i => `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif`).join('\n');
309
313
  const varyings = `varying vec3 vBlenderObjectPosition;
310
314
  varying vec3 vBlenderObjectNormal;
311
315
  varying vec3 vBlenderWorldPosition;
@@ -314,7 +318,7 @@ varying vec3 vBlenderOrco;
314
318
  ${compiled.attributes.map(n => `varying vec4 ${attributeVarying(n)};`).join('\n')}
315
319
  ${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
316
320
  const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
317
- shader.vertexShader = `${guards}\nattribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
321
+ shader.vertexShader = `attribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
318
322
  '#include <project_vertex>',
319
323
  `vBlenderObjectPosition = transformed;
320
324
  vBlenderObjectNormal = objectNormal;
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
  * that MeshPhysicalMaterial does not expose as properties. */
5
5
  import * as THREE from 'three';
6
6
  import {z} from 'zod';
7
- import {applyGraphShader, bindGraphDraw, graphProgramKey, setGraphViewport} from './blender-graph-material';
7
+ import {applyGraphShader, bindGraphDraw, graphProgramKey, graphUvChannels, setGraphViewport} from './blender-graph-material';
8
8
  import {bindNamedUvChannels} from './blender-texture-samplers';
9
9
 
10
10
  const scalar = z.number().finite();
@@ -85,9 +85,16 @@ export function applyPhysicalMaterial(material: THREE.MeshPhysicalMaterial, inpu
85
85
  setGraphViewport(renderer);
86
86
  };
87
87
  material.onBeforeCompile = shader => {
88
- // Three declares channels 0..3. Blender has eight named UV maps in
89
- // addition to the default channel; only the used attributes survive GLSL.
90
- shader.vertexShader = [4, 5, 6, 7, 8, 9].map(i => `attribute vec2 uv${i};`).join('\n') + '\n' + shader.vertexShader;
88
+ // Three declares channels 1..3 only for its own maps, and none past 3.
89
+ // Blender has eight named UV maps beside the default channel (9 is the
90
+ // missing layer's zeros). Declare only the channels this draw reads: a
91
+ // driver may count every declared attribute against its sixteen, and
92
+ // six idle ones leave a graph with a few attributes no room.
93
+ const read = new Set([material.map, material.roughnessMap, material.normalMap]
94
+ .flatMap(texture => (texture ? [texture.channel] : [])).concat(graphUvChannels(material)));
95
+ shader.vertexShader = [...read].filter(i => i > 0).sort((a, b) => a - b)
96
+ .map(i => (i <= 3 ? `#ifndef USE_UV${i}\nattribute vec2 uv${i};\n#endif` : `attribute vec2 uv${i};`))
97
+ .join('\n') + '\n' + shader.vertexShader;
91
98
  Object.assign(shader.uniforms, held);
92
99
  shader.fragmentShader = `uniform float blenderCoatIor;
93
100
  uniform vec3 blenderWorldExtinction;