@volter/blender-engine 0.1.0

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.
Files changed (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
@@ -0,0 +1,4166 @@
1
+ """The editor tab's Blender session, INSIDE real Blender.
2
+
3
+ THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
4
+ 2026-09-17). Blender 5.2 LTS is compiled to WebAssembly and started
5
+ `--background --factory-startup --python <this file>`; `main()` never returns
6
+ because this script does not return, so the module's Blender pthread stays
7
+ alive and holds ONE `bpy.data` for as long as the worker does. That is the
8
+ session: `execute(code) -> {stdout, error}`, plus the same `get_scene_info` /
9
+ `get_object_info` the MCP door has always carried, plus the EXPORT that feeds
10
+ the presenter.
11
+
12
+ THE CHANNEL IS A DIRECTORY, NOT STDIN, and that is a measurement rather than a
13
+ preference (2026-09-17, node, the v0.1.1 release): the build's
14
+ `FS_stdin_getChar` runs on the BLENDER PTHREAD -- `isMainThread=false` -- where
15
+ node's `process.stdin.fd` is `undefined` and a browser pthread has no
16
+ `window.prompt`, so stdin is not a channel the page can write into. WasmFS
17
+ keeps its file table in the shared wasm memory, so a FILE is: the worker's own
18
+ thread writes `in/<id>.json` then `in/<id>.done`, this loop sees it, and the
19
+ answer goes back as `out/<id>.json` + `out/<id>.done`. Measured round trip on
20
+ that same run: 5 ms idle, 30 ms for a cube, 436 ms for a subdivide-and-apply.
21
+
22
+ THE CHANNEL HAS ONE WRITER PER DIRECTORY, and on the WALI skew that rule is
23
+ what keeps a session alive for more than a few dozen calls:
24
+
25
+ in/ the PAGE writes and unlinks; this file only ever READS it.
26
+ out/ THIS FILE writes and unlinks; the page only reads.
27
+ ask/ THIS FILE writes and unlinks; the page only reads.
28
+ reply/ the PAGE writes and unlinks; this file only ever READS it.
29
+
30
+ Under WALI the page and this program are two snapshot-backed views of one
31
+ tree, reconciled by patches, and the host REFUSES a patch whose base no longer
32
+ matches what it holds -- then terminates the program. A path can only
33
+ disagree if someone other than the patch's author moved it, so disjoint
34
+ writer sets leave the assertion nothing to fire on. Measured 2026-09-19: with
35
+ the page unlinking this loop's `out/<id>.*` after every answer, a render loop
36
+ in one session died at call 37 and at call 14, with no guest exit path run.
37
+
38
+ THE ACK IS THE OTHER SIDE'S REQUEST FILE DISAPPEARING, which costs no file and
39
+ no extra write. The page unlinks `in/<id>.json` and then `in/<id>.done` only
40
+ after it has read `out/<id>.json`, so `in/<id>.done` gone means the answer was
41
+ taken and this loop may remove its own `out/<id>.*` -- read straight out of
42
+ the `listdir(IN)` the loop already does every pass. Symmetrically, `ask` waits
43
+ for `reply/<id>.done`, reads `reply/<id>.json`, and then removes its OWN
44
+ `ask/<id>.*`; the page watches those vanish and retires the reply it wrote.
45
+ `.done` is unlinked last on both sides, because `.done` is the token the other
46
+ side watches.
47
+
48
+ THE DIRECTORIES FOLLOW THEIR CONTENTS' OWNER: this file creates `out` and
49
+ `ask` and never `in` or `reply` (`blender-engine.mts`'s
50
+ `PAGE_OWNED_DIRECTORIES` makes those before the program starts). A `mkdir` or
51
+ `chmod` on a path the other side has already touched is the same crossing
52
+ edit at a smaller scale.
53
+
54
+ THE LOAD HANDLER IS `@persistent` because Blender clears every non-persistent
55
+ handler on file load, and `open_mainfile` is an ordinary thing for a script
56
+ here to do.
57
+
58
+ THE EXPORT IS THE C++ DOOR, not Python. `_blender_web.export_frame` evaluates
59
+ the scene, takes its revisions from the depsgraph's own update record, and
60
+ writes every column into a side ARENA in wasm memory; the JSON frame names each
61
+ one by `{offset, length, dtype, count, stride}`; the worker reads those bytes
62
+ through the one door its skew has (`BlenderEngine.readArena`: the module heap
63
+ where the host can reach it, the `buffer_path` file where it cannot). Nothing
64
+ large crosses as JSON. What this file still describes is what the door
65
+ does not: the WORLD's shader expression and the CAMERAS a photograph is framed
66
+ through.
67
+
68
+ THE REVISION SIGNAL IS THE DEPSGRAPH'S OWN UPDATE RECORD, accumulated by the
69
+ door's C callbacks on BOTH `depsgraph_update_post` and `frame_change_post` (a
70
+ `frame_set` fires only the second), with a render's own evaluation skipped.
71
+
72
+ THE SESSION HAS A DOCUMENT, and it is a `.blend` Blender itself writes.
73
+ `blender-start` names it (project-relative, `models/model.blend` by default);
74
+ an existing one is OPENED at start and an absent one starts empty. The session
75
+ saves it through `bpy.ops.wm.save_as_mainfile` -- never a codec of ours, and
76
+ never a mutation of the script's own datablocks. A SCRIPT'S DATABLOCKS ARE THE
77
+ SCRIPT'S: the session does not rewrite the paths a script loaded its images
78
+ from in order to save. Where a document reopens from is a question the script
79
+ answers when it loads its files, by loading them through project-relative
80
+ paths; `save_as_mainfile`'s own Save-As semantics are the whole of what the
81
+ session applies (see `save_document`).
82
+
83
+ WHO CARRIES THE DOCUMENT OUT, and why it is not this loop's job: the save
84
+ writes into the module filesystem, which mirrors the project at the SAME
85
+ absolute path but is not the disk. Python cannot push it out on its own --
86
+ `ask` is only served from inside a request's poll loop
87
+ (`blender-engine.mts::serveAsks`), so an `ask` raised from this idle loop
88
+ would never be answered and would wedge the Blender pthread forever. So the
89
+ DEBOUNCE LIVES IN THE TAB: a present that leaves the document stale says so
90
+ (`saveDue` beside the frame), the worker waits out an idle second, calls
91
+ `save-document` as an ordinary request, and carries the bytes to the project
92
+ (`worker.ts`). Every step is an explicit call; nothing polls.
93
+ """
94
+
95
+ import array
96
+ import base64
97
+ import hashlib
98
+ import io
99
+ import json
100
+ import math
101
+ import os
102
+ import re
103
+ import sys
104
+ import time
105
+ import traceback
106
+ from contextlib import redirect_stdout
107
+
108
+ import bpy
109
+ # Blender's own linear-algebra types. The rig/clip doors compose a pose
110
+ # channel's basis with its rest-relative matrix (`rna_action_clip`), which is a
111
+ # matrix product and a decompose — Blender's own, never a re-implementation.
112
+ import mathutils
113
+
114
+ # THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
115
+ # reads the scene out as typed columns in a side arena the worker reads
116
+ # through `BlenderEngine.readArena` -- see EXPORT_BUFFER_PATH below.
117
+ import _blender_web
118
+
119
+ # The presenter's refusals, spelled in
120
+ # `packages/mesh/contributions/blender-runtime-frame.ts`. This is the only
121
+ # place Python reads them, and `Session.present` is what answers them.
122
+ _UNKNOWN_GEOMETRY = "RUNTIME_FRAME_UNKNOWN_GEOMETRY"
123
+ _UNKNOWN_IMAGE = "RUNTIME_FRAME_UNKNOWN_IMAGE"
124
+
125
+ ROOT = os.environ.get("VGAI_SESSION_ROOT", "/work/.vgai-session")
126
+ IN = os.path.join(ROOT, "in")
127
+ OUT = os.path.join(ROOT, "out")
128
+ ASK = os.path.join(ROOT, "ask")
129
+ REPLY = os.path.join(ROOT, "reply")
130
+ # WHERE THE CALLER WANTS THE EXPORT ARENA, if it wants it anywhere.
131
+ #
132
+ # The export door offers TWO ways to read the arena it fills, and has since
133
+ # `bpy_web_export.cc` was written: the C exports that name it in linear
134
+ # memory, and a `buffer_path` option that also writes the same bytes to a file
135
+ # -- "natively the path is the caller's own string". Which door the caller
136
+ # uses is the caller's business, and this file does not know its toolchain: it
137
+ # states the path it was given, or states nothing.
138
+ #
139
+ # EMPTY UNLESS THE ENGINE SETS IT, and that is the point. The standalone skew
140
+ # reads the arena straight off the module heap and must not pay a
141
+ # megabyte-scale file write per present. The engine that sets it is the one
142
+ # whose host cannot reach the module's memory at all
143
+ # (`blender-wali-engine.mts`), where the file is not a fallback but the
144
+ # native door onto the same bytes.
145
+ EXPORT_BUFFER_PATH = os.environ.get("VGAI_EXPORT_BUFFER_PATH", "")
146
+ _real_stderr = sys.stderr
147
+
148
+
149
+ def _say(line):
150
+ print(line, file=_real_stderr, flush=True)
151
+
152
+
153
+ def _prepare_directories():
154
+ """Directories created through the module's JS door come out non-writable;
155
+ the session owns its own root from here, so it takes the mode it needs.
156
+
157
+ ONLY THE TWO DIRECTORIES THIS FILE WRITES INTO. `in` and `reply` belong to
158
+ the page and are made before this program starts; creating or chmodding
159
+ them here would put a guest edit on a page-owned path."""
160
+ for parent in ("/work", ROOT):
161
+ try:
162
+ os.makedirs(parent, exist_ok=True)
163
+ except OSError:
164
+ pass
165
+ try:
166
+ os.chmod(parent, 0o777)
167
+ except OSError:
168
+ pass
169
+ for directory in (OUT, ASK):
170
+ try:
171
+ os.mkdir(directory)
172
+ except FileExistsError:
173
+ pass
174
+ except OSError as error:
175
+ _say("@@VGAI-ERROR cannot create %s: %r" % (directory, error))
176
+ try:
177
+ os.chmod(directory, 0o777)
178
+ except OSError:
179
+ pass
180
+
181
+
182
+ # ---------------------------------------------------------------- file load
183
+
184
+ @bpy.app.handlers.persistent
185
+ def _load_post(_arg):
186
+ # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
187
+ # into the Main that just went away); this is the session's half.
188
+ SESSION.forget()
189
+ # A factory reset reloads every add-on (`bpy.utils.load_scripts(reload_scripts=True)`
190
+ # runs inside `read_factory_settings`), and the Cycles add-on's own engine
191
+ # class comes back under `CYCLES` while ours is gone -- measured in the tab:
192
+ # after the battery's opening reset, `scene.render.engine = 'CYCLES'`
193
+ # resolved to `cycles.CyclesRender`, every render was a real path trace
194
+ # in wasm (35 s at 1280x720, 48 samples) and `_photograph` never ran. The
195
+ # engine ids are taken again on every load.
196
+ ENGINES[:], UNAVAILABLE_ENGINES[:] = _register_engine()
197
+
198
+
199
+ # ---------------------------------------------------------------- warnings
200
+
201
+ _WARNED = set()
202
+ _WARNINGS = []
203
+
204
+
205
+ def warn(what):
206
+ """A capability this export cannot reach, named ONCE and never raised.
207
+
208
+ The rule: `_export` runs while a frame is being presented and
209
+ while a script's observations are being written, so raising here loses the
210
+ whole call's output. The warning names the mechanism; the material or mesh
211
+ falls back to what it would have had.
212
+ """
213
+ if what in _WARNED:
214
+ return
215
+ _WARNED.add(what)
216
+ _WARNINGS.append(what)
217
+ _say("@@VGAI-WARN " + what)
218
+
219
+
220
+ # ---------------------------------------------------------------- the world
221
+
222
+ _RAMP_SAMPLES = 257
223
+
224
+
225
+ def _ramp(ramp):
226
+ """One colour ramp, SAMPLED rather than described: 257 evaluations through
227
+ `color_ramp.evaluate`, so every value is the one Blender's own evaluator
228
+ returns; positions `i/256` are exact. CONSTANT is carried as a flag."""
229
+ samples = [ramp.evaluate(i / 256) for i in range(_RAMP_SAMPLES)]
230
+ return {
231
+ "ramp_color": [[float(sample[0]), float(sample[1]), float(sample[2])] for sample in samples],
232
+ "ramp_interpolate": ramp.interpolation != "CONSTANT",
233
+ }
234
+
235
+
236
+ _SCALAR_KINDS = frozenset(("separate", "map_range", "math", "to_float"))
237
+
238
+
239
+ def _is_scalar(expression):
240
+ if isinstance(expression, (int, float)):
241
+ return True
242
+ return isinstance(expression, dict) and (
243
+ expression["kind"] in _SCALAR_KINDS
244
+ or (expression["kind"] == "gradient" and expression["output"] == "Fac")
245
+ )
246
+
247
+
248
+ def _describe_expression(expression):
249
+ if isinstance(expression, dict):
250
+ return "a %s node, which is not a float" % expression["kind"]
251
+ if isinstance(expression, list):
252
+ return "a colour/vector of %d components" % len(expression)
253
+ return "%s, which is not a float" % type(expression).__name__
254
+
255
+
256
+ def _surface_backgrounds(node):
257
+ """World Surface as `(what the camera sees, what lights the scene)`; a
258
+ world that is one Background is both and the second is None. A Mix Shader
259
+ by Light Path `Is Camera Ray` is how a scene gets a bright backdrop and a
260
+ restrained fill from one sky (the tram stop): input 2 is the camera's,
261
+ input 1 lights the scene."""
262
+ kind = node.bl_idname
263
+ if kind == "ShaderNodeBackground":
264
+ return node, None
265
+ if kind != "ShaderNodeMixShader":
266
+ raise NotImplementedError("World Surface must connect to Background; it connects to %s" % kind)
267
+ factor = list(node.inputs[0].links)
268
+ if (len(factor) != 1 or factor[0].from_node.bl_idname != "ShaderNodeLightPath"
269
+ or factor[0].from_socket.name != "Is Camera Ray"):
270
+ source = ("%s.%s" % (factor[0].from_node.bl_idname, factor[0].from_socket.name)
271
+ if len(factor) == 1 else "a constant")
272
+ raise NotImplementedError(
273
+ "World Mix Shader is mixed by %s; only Light Path 'Is Camera Ray' separates "
274
+ "the camera's world from the lighting one" % source)
275
+ branches = []
276
+ for index in (2, 1):
277
+ links = list(node.inputs[index].links)
278
+ if len(links) != 1 or links[0].from_node.bl_idname != "ShaderNodeBackground":
279
+ raise NotImplementedError(
280
+ "World Mix Shader needs a Background on each side; input %d has %d" % (index, len(links)))
281
+ branches.append(links[0].from_node)
282
+ return branches[0], branches[1]
283
+
284
+
285
+ def _describe_background(background, camera_ray):
286
+ """The Background's Color as the presenter's world expression (its grammar
287
+ is `blender-runtime-lighting.ts`'s `worldExpression`); anything outside it
288
+ is refused BY NAME through NotImplementedError."""
289
+ visiting = set()
290
+
291
+ def value(socket):
292
+ links = list(socket.links)
293
+ if links:
294
+ source = links[0].from_socket
295
+ expression = output(links[0].from_node, source.name)
296
+ if socket.type == "VALUE" and source.type in ("RGBA", "VECTOR"):
297
+ return {"kind": "to_float", "source_type": source.type, "value": expression}
298
+ return expression
299
+ default = socket.default_value
300
+ return float(default) if isinstance(default, (float, int)) else [float(c) for c in list(default)[:3]]
301
+
302
+ def output(node, socket):
303
+ key = (id(node), socket)
304
+ if key in visiting:
305
+ raise NotImplementedError("World shader contains a cycle")
306
+ visiting.add(key)
307
+ try:
308
+ kind = node.bl_idname
309
+ if kind == "ShaderNodeLightPath" and socket == "Is Camera Ray":
310
+ return 1.0 if camera_ray else 0.0
311
+ if (kind == "ShaderNodeMixRGB" and socket == "Color") or (kind == "ShaderNodeMix" and socket == "Result"):
312
+ legacy = kind == "ShaderNodeMixRGB"
313
+ if not legacy and node.data_type != "RGBA":
314
+ raise NotImplementedError("World Mix data type %s" % node.data_type)
315
+ if node.blend_type != "MIX":
316
+ raise NotImplementedError("World color blend %s" % node.blend_type)
317
+ factor = value(node.inputs["Fac" if legacy else "Factor"])
318
+ clamp_factor = True if legacy else bool(node.clamp_factor)
319
+ clamp_result = bool(node.use_clamp if legacy else node.clamp_result)
320
+ a = node.inputs["Color1" if legacy else "A"]
321
+ b = node.inputs["Color2" if legacy else "B"]
322
+ if isinstance(factor, (int, float)) and clamp_factor:
323
+ factor = max(0.0, min(1.0, factor))
324
+ if isinstance(factor, (int, float)) and factor in (0.0, 1.0):
325
+ selected = value(a if factor == 0.0 else b)
326
+ if not clamp_result:
327
+ return selected
328
+ first = second = selected
329
+ else:
330
+ first, second = value(a), value(b)
331
+ return {"kind": "mix_color", "factor": factor, "a": first, "b": second,
332
+ "clamp_factor": clamp_factor, "clamp_result": clamp_result}
333
+ if kind == "ShaderNodeTexCoord" and socket == "Generated":
334
+ return {"kind": "direction"}
335
+ if kind == "ShaderNodeTexCoord" and socket == "Window":
336
+ return {"kind": "window"}
337
+ if kind == "ShaderNodeTexGradient" and socket in ("Fac", "Factor", "Color"):
338
+ vector = node.inputs["Vector"]
339
+ return {"kind": "gradient", "gradient_type": node.gradient_type,
340
+ "output": "Fac" if socket == "Factor" else socket,
341
+ "vector": value(vector) if vector.links else {"kind": "direction"}}
342
+ if kind == "ShaderNodeMath" and socket == "Value":
343
+ return {"kind": "math", "operation": node.operation, "clamp": bool(node.use_clamp),
344
+ "inputs": [value(s) for s in node.inputs]}
345
+ if kind == "ShaderNodeVectorMath" and socket == "Value" and node.operation == "DOT_PRODUCT":
346
+ vectors = [value(node.inputs[i]) for i in range(2)]
347
+ products = [
348
+ {"kind": "math", "operation": "MULTIPLY", "clamp": False,
349
+ "inputs": [{"kind": "separate", "vector": vector, "axis": axis} for vector in vectors]}
350
+ for axis in range(3)]
351
+ return {"kind": "math", "operation": "ADD", "clamp": False,
352
+ "inputs": [{"kind": "math", "operation": "ADD", "clamp": False,
353
+ "inputs": products[:2]}, products[2]]}
354
+ if kind == "ShaderNodeMapping" and socket == "Vector":
355
+ if node.vector_type != "POINT":
356
+ raise NotImplementedError("World Mapping vector type %s" % node.vector_type)
357
+ for name in ("Location", "Rotation", "Scale"):
358
+ if node.inputs[name].links:
359
+ raise NotImplementedError("Linked World Mapping " + name)
360
+ return {"kind": "mapping", "vector": value(node.inputs["Vector"]),
361
+ "location": [float(c) for c in node.inputs["Location"].default_value],
362
+ "rotation": [float(c) for c in node.inputs["Rotation"].default_value],
363
+ "scale": [float(c) for c in node.inputs["Scale"].default_value]}
364
+ if kind == "ShaderNodeMapRange" and socket == "Result":
365
+ if node.data_type != "FLOAT":
366
+ raise NotImplementedError("World Map Range data type %s" % node.data_type)
367
+ steps = node.inputs.get("Steps")
368
+ return {"kind": "map_range", "interpolation": node.interpolation_type,
369
+ "clamp": bool(node.clamp) and node.interpolation_type not in ("SMOOTHSTEP", "SMOOTHERSTEP"),
370
+ "value": value(node.inputs["Value"]),
371
+ "from_min": value(node.inputs["From Min"]), "from_max": value(node.inputs["From Max"]),
372
+ "to_min": value(node.inputs["To Min"]), "to_max": value(node.inputs["To Max"]),
373
+ "steps": value(steps) if steps is not None else 0.0}
374
+ if kind == "ShaderNodeSeparateXYZ" and socket in ("X", "Y", "Z"):
375
+ return {"kind": "separate", "vector": value(node.inputs["Vector"]), "axis": ("X", "Y", "Z").index(socket)}
376
+ if kind == "ShaderNodeValToRGB" and socket == "Color":
377
+ factor = value(node.inputs["Fac"])
378
+ if not _is_scalar(factor):
379
+ raise NotImplementedError(
380
+ "World ColorRamp needs a scalar factor; this one is fed by " + _describe_expression(factor))
381
+ ramp = _ramp(node.color_ramp)
382
+ return {"kind": "ramp", "factor": factor, "colors": ramp["ramp_color"],
383
+ "interpolate": ramp["ramp_interpolate"]}
384
+ if kind in ("ShaderNodeRGB", "ShaderNodeValue"):
385
+ default = node.outputs[socket].default_value
386
+ return float(default) if isinstance(default, (float, int)) else [float(c) for c in list(default)[:3]]
387
+ if kind == "ShaderNodeTexSky" and socket == "Color":
388
+ model = node.sky_type
389
+ if model != "MULTIPLE_SCATTERING":
390
+ raise NotImplementedError(
391
+ "World Sky Texture: sky_type %r is not implemented (MULTIPLE_SCATTERING is)" % model)
392
+ vector = node.inputs.get("Vector")
393
+ if vector is not None and vector.links:
394
+ raise NotImplementedError("Linked World Sky Texture Vector")
395
+ return {"kind": "sky", "sun_elevation": float(node.sun_elevation),
396
+ "sun_rotation": float(node.sun_rotation), "altitude": float(node.altitude),
397
+ "air_density": float(node.air_density), "aerosol_density": float(node.aerosol_density),
398
+ "ozone_density": float(node.ozone_density)}
399
+ if kind == "NodeReroute":
400
+ return value(node.inputs[0])
401
+ raise NotImplementedError("World shader %r: %s.%s" % (node.name, kind, socket))
402
+ finally:
403
+ visiting.remove(key)
404
+
405
+ return {"color": [float(c) for c in list(background.inputs["Color"].default_value)[:3]],
406
+ "strength": float(background.inputs["Strength"].default_value),
407
+ "shader": value(background.inputs["Color"])}
408
+
409
+
410
+ def _describe_world(world):
411
+ outputs = [n for n in world.node_tree.nodes if n.bl_idname == "ShaderNodeOutputWorld" and n.is_active_output]
412
+ if len(outputs) != 1:
413
+ raise NotImplementedError("World rendering needs one active World Output")
414
+ surface = list(outputs[0].inputs["Surface"].links)
415
+ if len(surface) != 1:
416
+ raise NotImplementedError("World Surface must connect to Background")
417
+ if outputs[0].inputs["Volume"].links:
418
+ raise NotImplementedError("World volume rendering is not implemented")
419
+ background, lighting = _surface_backgrounds(surface[0].from_node)
420
+ for node in (background, lighting):
421
+ if node is not None and node.inputs["Strength"].links:
422
+ raise NotImplementedError("Linked World Background strength is not implemented")
423
+ described = _describe_background(background, camera_ray=True)
424
+ lighting_data = _describe_background(lighting or background, camera_ray=False)
425
+ if lighting_data != described:
426
+ described["lighting"] = lighting_data
427
+ # A constant Color needs no expression; the presenter reads `color`.
428
+ if not background.inputs["Color"].is_linked:
429
+ described.pop("shader", None)
430
+ if "lighting" in described and not (lighting or background).inputs["Color"].is_linked:
431
+ described["lighting"].pop("shader", None)
432
+ return described
433
+
434
+
435
+ def draw_world(scene):
436
+ """The world as the presenter's radiance: a constant, or the node graph as
437
+ the expression grammar the presenter evaluates. A graph outside the grammar is a
438
+ standing warning naming the node, and no world -- the model stays visible
439
+ while it is being built."""
440
+ world = scene.world
441
+ if world is None:
442
+ return None
443
+ if world.node_tree is None:
444
+ return {"color": [float(c) for c in list(world.color)[:3]], "strength": 1.0}
445
+ try:
446
+ return _describe_world(world)
447
+ except NotImplementedError as refusal:
448
+ warn("world: %s" % refusal)
449
+ return None
450
+
451
+
452
+ def draw_camera(obj):
453
+ """What the presenter needs to frame a photograph through this camera."""
454
+ camera = obj.data
455
+ return {
456
+ "name": obj.name,
457
+ "type": camera.type,
458
+ "lens": float(camera.lens),
459
+ "sensor_width": float(camera.sensor_width),
460
+ "sensor_height": float(camera.sensor_height),
461
+ "sensor_fit": camera.sensor_fit,
462
+ "angle": float(camera.angle),
463
+ "angle_y": float(camera.angle_y),
464
+ "ortho_scale": float(camera.ortho_scale),
465
+ "clip_start": float(camera.clip_start),
466
+ "clip_end": float(camera.clip_end),
467
+ "shift_x": float(camera.shift_x),
468
+ "shift_y": float(camera.shift_y),
469
+ "matrix": [[float(v) for v in row] for row in obj.matrix_world],
470
+ }
471
+
472
+
473
+ # ------------------------------------------------------------- the overlays
474
+ #
475
+ # INSPECTION OVERLAYS, READ OFF THE ENGINE (ARCHITECTURE-CORE §Blender north
476
+ # star, "Inspection parity, not editing parity"; WORK.md §Blender in the tab
477
+ # is Blender, "Inspection parity", I4). Blender's overlay ENGINE
478
+ # (`source/blender/draw/engines/overlay/`) is never run, ported or recorded
479
+ # here: what these answer is the DATA its draw functions read -- a bone's
480
+ # display matrix, a vertex's weight -- and a three.js presenter draws it
481
+ # (`blender-runtime-armature.ts`, `blender-runtime-weights.ts`).
482
+ #
483
+ # THEY RIDE IN THE FRAME, beside the world and the cameras, for the reason
484
+ # those do (`Session._export`): the C++ export door describes geometry and
485
+ # materials and nothing else, both of these are per-scene, and a second door
486
+ # would be a second round trip per present with no way to stay in step with
487
+ # the frame it decorates.
488
+
489
+
490
+ def _armature_bones(obj, locked_groups):
491
+ """Every bone of one armature object, as `overlay_armature.cc` draws it.
492
+
493
+ THE MATRIX IS THE POSE MATRIX, always.
494
+ `draw_bone_update_disp_matrix_default` (`overlay_armature.cc:990-1020`)
495
+ takes `pchan->pose_mat` for a pose bone and rescales it uniformly by
496
+ `pchan_bone->length`; an armature in OBJECT mode is drawn through that same
497
+ pose path, because the rest pose IS a pose. `PoseBone.matrix` is that
498
+ `pose_mat`, in the armature object's own space, so the presenter parents
499
+ the drawing to the armature's presented object and needs no second
500
+ transform.
501
+
502
+ The LENGTH rides beside the matrix rather than being multiplied into it, so
503
+ the number sent is the engine's own -- `bpy.data.objects[...].pose.bones
504
+ [...].matrix` in the RNA door reads back identical -- and the presenter
505
+ scales the unit shape itself."""
506
+ armature = obj.data
507
+ active = getattr(armature.bones, "active", None)
508
+ active_name = active.name if active is not None else None
509
+ bones = []
510
+ for pchan in obj.pose.bones:
511
+ bone = pchan.bone
512
+ bones.append({
513
+ "name": bone.name,
514
+ "parent": bone.parent.name if bone.parent is not None else None,
515
+ # `draw_points` draws the ROOT sphere only for a bone that is not
516
+ # connected to its parent (`overlay_armature.cc:1338`), because a
517
+ # connected bone's head IS its parent's tail.
518
+ "connected": bool(bone.use_connect),
519
+ "hide": bool(bone.hide),
520
+ "length": float(bone.length),
521
+ "matrix": [[float(v) for v in row] for row in pchan.matrix],
522
+ # BONE_SELECTED / BONE_DRAW_ACTIVE, the two flags
523
+ # `get_pchan_color_wire` branches on (`:760-795`).
524
+ #
525
+ # SELECTION IS THE POSE CHANNEL'S, and that is a MEASUREMENT rather
526
+ # than a preference: at this pin `Bone` carries `hide` and
527
+ # `hide_select` and NO `select` — `rna_def_bone_common`
528
+ # (`rna_armature.cc:1305`) declares none and `:1913` puts one on
529
+ # `EditBone` alone, so `bone.select` is an AttributeError. Asked of
530
+ # the engine itself, a `Bone`'s sel/hide properties are
531
+ # `['hide', 'hide_select']` and a `PoseBone`'s are
532
+ # `['hide', 'select']`. It is the same BIT either way — both write
533
+ # `Bone.flag`'s `BONE_SELECTED`, which is what the overlay's
534
+ # `bone.flag() & BONE_SELECTED` reads.
535
+ "select": bool(pchan.select),
536
+ "active": bone.name == active_name,
537
+ # BONE_DRAW_LOCKED_WEIGHT, set in weight-paint mode for every bone
538
+ # whose SAME-NAMED vertex group on the painted object is locked
539
+ # (`overlay_armature.cc:2059-2084`); it shades both the solid and
540
+ # the wire toward `bone_locked_weight`.
541
+ "lockedWeight": bone.name in locked_groups,
542
+ })
543
+ return bones
544
+
545
+
546
+ def _locked_weight_groups(view_layer):
547
+ """The vertex groups `BONE_DRAW_LOCKED_WEIGHT` is read from.
548
+
549
+ `overlay_armature.cc:2059-2084`: only in weight paint, and only from the
550
+ ACTIVE object's own deform groups -- `dg->flag & DG_LOCK_WEIGHT`, which is
551
+ `VertexGroup.lock_weight`."""
552
+ obj = view_layer.objects.active
553
+ if obj is None or "WEIGHT_PAINT" not in obj.mode:
554
+ return frozenset()
555
+ groups = getattr(obj, "vertex_groups", None) or ()
556
+ return frozenset(group.name for group in groups if group.lock_weight)
557
+
558
+
559
+ def _armatures(view_layer):
560
+ """Every armature the view layer shows, with its display type and pose.
561
+
562
+ `display_type` is `bArmature.drawtype`, whose enum is `prop_drawtype_items`
563
+ (`rna_armature.cc:2146-2168`): OCTAHEDRAL, STICK, BBONE, ENVELOPE, WIRE.
564
+ `show_in_front` is `Object.dtx & OB_DRAW_IN_FRONT` (`rna_object.cc:
565
+ 3646-3648`), which is what puts an armature in the overlay's IN-FRONT
566
+ layer -- the one whose depth buffer is cleared before it draws
567
+ (`Instance::object_is_in_front`, `overlay_instance.cc:1110-1115`)."""
568
+ locked = _locked_weight_groups(view_layer)
569
+ armatures = {}
570
+ for obj in view_layer.objects:
571
+ if obj.type != "ARMATURE" or obj.data is None:
572
+ continue
573
+ armatures[obj.name] = {
574
+ "object": obj.name,
575
+ "displayType": obj.data.display_type,
576
+ "showInFront": bool(obj.show_in_front),
577
+ # `Object.mode`: POSE is what turns the wire colours from
578
+ # `theme.vertex` into the pose colours (`get_bone_wire_color`'s
579
+ # `ARM_DRAW_MODE_*` switch, `:906-936`).
580
+ "mode": obj.mode,
581
+ "bones": _armature_bones(obj, locked),
582
+ }
583
+ return armatures
584
+
585
+
586
+ def _weights(scene, view_layer, frame, known):
587
+ """PER-VERTEX WEIGHT for the active object's active vertex group.
588
+
589
+ Blender's own evaluation, `evaluate_vertex_weight`
590
+ (`draw/intern/mesh_extractors/extract_mesh_vbo_weights.cc:20-67`), in the
591
+ default state -- no Multi-Paint, no Lock-Relative -- so the weight is
592
+ `BKE_defvert_find_weight(dvert, active)` clamped to [0,1], and a vertex the
593
+ group does not weight is the ALERT value the fragment shader paints
594
+ `TH_VERTEX_UNREFERENCED` over. WHICH vertices alert is
595
+ `scene.tool_settings.vertex_group_user` (`rna_scene.cc:3428-3434`, default
596
+ ACTIVE): ACTIVE alerts a vertex with no weight in the ACTIVE group, ALL only
597
+ one with no weight in ANY group, NONE never.
598
+
599
+ THE ARRAY IS A REFERENCE WHEN NOTHING MOVED, the same contract the meshes
600
+ have (`blender-runtime-frame.ts`): it is the size of a vertex column and
601
+ every mutation presents, so it ships only when its CONTENT has changed since
602
+ the frame this session last sent.
603
+
604
+ ITS OWN DIGEST, NOT THE MESH'S REVISION, and that is a measurement rather
605
+ than a preference. The first shape of this function keyed the reference on
606
+ the mesh's export revision -- the number the geometry itself ships under --
607
+ and MEASURED live 2026-09-19: writing 640 deform weights through
608
+ `VertexGroup.add()` does not move it, because the door's revision follows
609
+ the depsgraph's GEOMETRY update record and a deform layer is not geometry.
610
+ So the array shipped once, all zeros, and every later present said
611
+ "unchanged" while the engine held a full ramp. The walk is unavoidable
612
+ either way -- an honest answer has to read every vertex -- so what the
613
+ reference saves is the TRANSFER, and a digest of the bytes is the only key
614
+ that cannot lie about them."""
615
+ obj = view_layer.objects.active
616
+ if obj is None or obj.type != "MESH" or obj.data is None:
617
+ return None
618
+ group = getattr(getattr(obj, "vertex_groups", None), "active", None)
619
+ if group is None:
620
+ return None
621
+ mesh = obj.data
622
+ index = int(group.index)
623
+ count = len(mesh.vertices)
624
+ alert_mode = scene.tool_settings.vertex_group_user
625
+ weights = bytearray(4 * count)
626
+ alerts = bytearray(count)
627
+ view = memoryview(weights).cast("f")
628
+ for i, vertex in enumerate(mesh.vertices):
629
+ value = 0.0
630
+ found = False
631
+ any_group = False
632
+ for element in vertex.groups:
633
+ any_group = True
634
+ if element.group == index:
635
+ value = float(element.weight)
636
+ found = True
637
+ if (not found) or value == 0.0:
638
+ if alert_mode == "ACTIVE":
639
+ alerts[i] = 1
640
+ elif alert_mode == "ALL" and not any_group:
641
+ alerts[i] = 1
642
+ view[i] = 0.0 if value < 0.0 else (1.0 if value > 1.0 else value)
643
+ digest = hashlib.sha1(bytes(weights) + bytes(alerts)).hexdigest()
644
+ header = {"object": obj.name, "group": group.name, "groupIndex": index,
645
+ "count": count, "digest": digest, "alertMode": alert_mode}
646
+ key = "weights:%s:%s" % (obj.name, group.name)
647
+ if known.get(key) == digest:
648
+ header["unchanged"] = True
649
+ return header
650
+ header["weightsBase64"] = base64.b64encode(bytes(weights)).decode("ascii")
651
+ header["alertBase64"] = base64.b64encode(bytes(alerts)).decode("ascii")
652
+ return header
653
+
654
+
655
+ # ---------------------------------------------------------------- the session
656
+
657
+ class Session:
658
+ def __init__(self):
659
+ self.session = "blender-%d" % int(time.time() * 1000)
660
+ self.revision = 0
661
+ self.project = "/project"
662
+ # What the presenter already holds, in the door's own key space
663
+ # (`mesh:<geometry key>` / `image:<name>`) at the revision it holds:
664
+ # a mesh it has ships as a reference, a picture it has does not ship.
665
+ self._known = {}
666
+ # The last frame's accounting (`_present`), read by `dispatch`.
667
+ self.last_shipped = None
668
+ # THE SESSION'S DOCUMENT: the `.blend` it opens at start and saves back
669
+ # into. Absolute (the module filesystem mirrors the project at its own
670
+ # host path) plus the project-relative spelling the tab needs to name
671
+ # the destination. None until `blender-start` states one.
672
+ self.document = None
673
+ self.document_relative = None
674
+ # Set by a present that left the document behind the model; cleared by
675
+ # the save. The tab reads it as `saveDue` beside the frame.
676
+ self.save_due = False
677
+ # WHAT THE PRESENTER LAST REPORTED HOLDING, as an instrument: the
678
+ # `(session, revision)` it held BEFORE the last frame, None when it
679
+ # held nothing, and the string "unreported" for a presenter that does
680
+ # not answer with one. `_reconcile_with_presenter` is what reads it as
681
+ # a fact; this field is what makes that fact visible from outside
682
+ # (`dispatch`'s `present` answers it).
683
+ self.presenter_held = "unreported"
684
+
685
+ def forget(self):
686
+ self._known.clear()
687
+
688
+ # -- the export
689
+
690
+ def _export(self):
691
+ """THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
692
+
693
+ One call evaluates the scene, walks the depsgraph's own update record
694
+ for the revisions, reads Blender's arrays straight into a side arena
695
+ and answers a small JSON frame that names each column by
696
+ `{offset, length, dtype, count, stride}`; the worker copies those bytes
697
+ off the module heap (`session-frame.mts`). Nothing large crosses as
698
+ JSON and nothing is staged through the filesystem.
699
+
700
+ WHAT THE DOOR DOES NOT DESCRIBE is reduced here and merged in: the
701
+ WORLD, whose sky/gradient graph becomes the presenter's world
702
+ expression (`draw_world`), the CAMERAS a photograph is framed
703
+ through (`draw_camera`), and the INSPECTION OVERLAYS -- the armatures'
704
+ bones and the active vertex group's weights (`_armatures`,
705
+ `_weights`). All per-scene, none of them geometry.
706
+ """
707
+ options = {"session": self.session, "evaluate": True, "known": self._known}
708
+ # THE ARENA IS WRITTEN BEFORE THE ASK, and on a skew whose channel is
709
+ # an ordered stream of filesystem patches that is the whole
710
+ # correctness argument: whatever carries `ask/<id>.done` out of this
711
+ # process carried the arena's bytes with it or before it, so a caller
712
+ # that can see the ask can read the file.
713
+ if EXPORT_BUFFER_PATH:
714
+ options["buffer_path"] = EXPORT_BUFFER_PATH
715
+ frame = json.loads(_blender_web.export_frame(json.dumps(options)))
716
+ error = frame.get("error")
717
+ if error:
718
+ raise RuntimeError("Blender export door: %s" % error)
719
+ # THE SESSION OWNS BOTH HALVES OF ITS IDENTITY. The presenter reads
720
+ # `(session, revision)` as a PAIR and refuses outright a frame whose
721
+ # revision went backwards under a name it already holds ("The runtime
722
+ # frame is older than the displayed model"). The door's own counter is
723
+ # per-`Main` -- it starts over on every file read, which is what its
724
+ # per-datablock revisions must do -- while the session name does not, so
725
+ # leaving the two in different hands splits the pair the moment a script
726
+ # opens a .blend. MEASURED 2026-09-18 through the editor tab:
727
+ # `read_factory_settings` took the door's counter from 40 back to 1 and
728
+ # nine of 17-workshop-interior's 63 calls died on that refusal.
729
+ self.revision += 1
730
+ frame["revision"] = self.revision
731
+ scene = bpy.context.scene
732
+ frame["world"] = draw_world(scene)
733
+ frame["cameras"] = {
734
+ obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
735
+ }
736
+ frame["volumes"] = {}
737
+ # THE OVERLAYS, after the door's frame stands: `_weights` reads the
738
+ # mesh's own revision out of it (see there), so it cannot run before.
739
+ view_layer = bpy.context.view_layer
740
+ frame["armatures"] = _armatures(view_layer)
741
+ frame["weights"] = _weights(scene, view_layer, frame, self._known)
742
+ warnings = list(frame.get("warnings", ()))
743
+ warnings.extend(_WARNINGS)
744
+ del _WARNINGS[:]
745
+ frame["warnings"] = warnings
746
+ return frame
747
+
748
+ def present(self, capture=None):
749
+ """Hand the tab a frame, and give back whatever it answered with.
750
+
751
+ A render's photograph comes back through this same door, which is why
752
+ it blocks: `bpy.ops.render.render()` is waiting on the pixels.
753
+
754
+ THE PRESENTER IS THE AUTHORITY ON WHAT IT HOLDS, and it says so by
755
+ name: a page that reloaded under a still-running session holds none of
756
+ this session's geometry while `_known` says it was sent. The record is
757
+ dropped and the frame goes out again IN FULL -- the same answer
758
+ `runtime_session.present` gives, for the same reason.
759
+ """
760
+ try:
761
+ return self._present(capture)
762
+ except RuntimeError as error:
763
+ if _UNKNOWN_GEOMETRY not in str(error) and _UNKNOWN_IMAGE not in str(error):
764
+ raise
765
+ self._known.clear()
766
+ return self._present(capture)
767
+
768
+ def _present(self, capture=None):
769
+ known = dict(self._known)
770
+ frame = self._export()
771
+ self._drop_unreachable_textures(frame)
772
+ # WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
773
+ # whose columns crossed, and the ids that went as a reference. The
774
+ # export's whole revision rule is visible here and nowhere else.
775
+ self.last_shipped = {
776
+ "revision": self.revision,
777
+ "columns": sorted(k for k, v in frame["meshes"].items() if "columns" in v),
778
+ "unchanged": sorted(k for k, v in frame["meshes"].items() if v.get("unchanged")),
779
+ "images": sorted(frame["images"].keys()),
780
+ # How many COLUMN BYTES this frame actually shipped: a move ships
781
+ # zero, a vertex edit one mesh's worth.
782
+ "bytes": int(_blender_web.buffer_size()),
783
+ # Blender's `is_dirty`, carried as an INSTRUMENT only: it is always
784
+ # False in this build (no undo stack in `--background`), which is
785
+ # why `save_document` does not read it. See the note there.
786
+ "dirty": bool(bpy.data.is_dirty),
787
+ }
788
+ # A PRESENT IS WHAT LEAVES THE DOCUMENT STALE, and the predicate is the
789
+ # present itself rather than what the frame shipped. `columns`/`images`
790
+ # above count BYTES THAT CROSSED, which is a different question: a MOVE
791
+ # ships zero columns (the comment above says so) and a DELETION ships
792
+ # nothing at all, yet both are changes a document that loses them is
793
+ # wrong. The cost this predicate could waste is bounded by the tab's
794
+ # one-save-per-idle-second debounce, and `save_document` still asks
795
+ # Blender whether anything actually changed before it writes.
796
+ if self.document is not None:
797
+ self.save_due = True
798
+ for key, mesh in frame["meshes"].items():
799
+ self._known["mesh:" + key] = mesh["revision"]
800
+ for name, image in frame["images"].items():
801
+ self._known["image:" + name] = image["revision"]
802
+ # The weight array follows the meshes' own rule: recorded as sent, so
803
+ # the next present ships a reference instead of the bytes.
804
+ weights = frame.get("weights")
805
+ if weights is not None:
806
+ self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
807
+ weights["digest"])
808
+ request = {"frame": frame}
809
+ if capture:
810
+ request["capture"] = capture
811
+ # BESIDE the frame, never inside it: the frame's schema is the
812
+ # presenter's and this is the session's own housekeeping.
813
+ if self.save_due:
814
+ request["saveDue"] = True
815
+ try:
816
+ answer = ask(request)
817
+ except BaseException:
818
+ self._known = known
819
+ raise
820
+ if isinstance(answer, dict) and answer.get("error"):
821
+ # Recorded only once the presenter has taken the frame.
822
+ self._known = known
823
+ raise RuntimeError(answer["error"])
824
+ self._reconcile_with_presenter(answer, frame)
825
+ return answer
826
+
827
+ def _drop_unreachable_textures(self, frame):
828
+ """A picture the export could not read is a WARNING, not a refusal.
829
+
830
+ The door writes an image by reading its ibuf; an image it cannot read
831
+ is named by the door (`image <name> has no readable pixels`) and left
832
+ out of the frame -- while the MATERIAL still carries the texture
833
+ reference. The presenter then refuses BY NAME
834
+ (`RUNTIME_FRAME_UNKNOWN_IMAGE`), and that refusal cannot be satisfied:
835
+ re-sending the frame in full re-exports the same unreadable image, so
836
+ the second present dies the same way and the whole call fails.
837
+
838
+ WHY an image is unreadable is the EXPORT DOOR's question and is not
839
+ answered here, so this says only what it observed. Three mechanisms
840
+ that would explain it were measured in the tab on 2026-09-18 and all
841
+ three SHIP correctly, so none of them is it: a `images.new` image
842
+ filled through `pixels.foreach_set`; a PNG written with `image.save()`
843
+ and read back with `images.load`; and the same PNG written and read
844
+ through a `replay_fs`-style symlinked run directory.
845
+
846
+ MEASURED 2026-09-18: the five-model battery's `17-workshop-interior`,
847
+ `18-riverside-bridge`, `19-tram-stop` and `20-rigged-courier` each got
848
+ ZERO calls, refused inside `blender-start` -- `bind_document` opened
849
+ the document the previous model wrote, whose textures live OUTSIDE the
850
+ project (`/Volumes/PeakSSD/volter-work/blender-scene-battery/...`) and
851
+ are in no fresh worker's filesystem.
852
+
853
+ THE PRESENTER'S REFUSAL IS RIGHT AND STAYS. It answers "the record says
854
+ I hold this and I do not", which is the desync `_reconcile_with_presenter`
855
+ exists for. What is wrong is asking it about a picture the session
856
+ NEVER SENT AND CANNOT SEND. So a texture reference naming an image that
857
+ is in neither this frame nor `_known` is dropped here, and the
858
+ mechanism is named once (`warn`) -- the material falls back to its own
859
+ socket colour, which is this file's stated rule for a capability the
860
+ export cannot reach.
861
+
862
+ A name `_known` still holds is left alone: that IS the desync case, and
863
+ the refusal it raises is the signal that clears the tables and re-ships.
864
+ """
865
+ carried = frame.get("images") or {}
866
+ for material in (frame.get("materials") or {}).values():
867
+ for slot in ("texture", "roughness_texture"):
868
+ reference = material.get(slot)
869
+ if not isinstance(reference, dict):
870
+ continue
871
+ name = (reference.get("image") or {}).get("name")
872
+ if name is None or name in carried or ("image:" + name) in self._known:
873
+ continue
874
+ del material[slot]
875
+ # THE TWO CAUSES THIS WARNING HAS HAD, and the one line that
876
+ # tells them apart -- because the text names `write_image` and
877
+ # a reader reasonably concludes the EXPORT is broken, which
878
+ # twice it was not. `write_image` says "no readable pixels"
879
+ # when `BKE_image_acquire_ibuf` hands back nothing, and the
880
+ # image is usually the thing at fault, not the export:
881
+ #
882
+ # 1. THE IMAGE NEVER LOADED. `source == 'FILE'` and the
883
+ # filepath does not resolve inside the program -- a
884
+ # relative path saved against a directory the guest
885
+ # filesystem does not carry is the worked case. Measured
886
+ # 2026-09-19 (WS-W): a probe's `models/model.blend`
887
+ # raised 18 of these, and every one of its images read
888
+ # `has_data False, size (0, 0), len(pixels) 0`.
889
+ # 2. THE PROGRAM WAS OUT OF MEMORY. Measured 2026-09-19 on
890
+ # the WALI skew: `16-market-courtyard` raised eight of
891
+ # these while the module was linked
892
+ # `-Wl,--max-memory=1073741824`, and raising that link to
893
+ # 4 GiB made all eight vanish -- same recording, same
894
+ # staged textures, same door, 95/95 calls and a silent
895
+ # console.
896
+ #
897
+ # So before suspecting the export, ask the images:
898
+ # for im in bpy.data.images:
899
+ # print(im.name, im.source, tuple(im.size), im.has_data,
900
+ # im.filepath)
901
+ # (0, 0) with has_data False is cause 1 or 2, never the export.
902
+ warn("material %s: the export could not read the picture %s, so this "
903
+ "frame draws the material without it. The image is in neither "
904
+ "this frame nor the presenter's record, so nothing can be "
905
+ "re-sent to satisfy it; the export door is what knows why "
906
+ "(`bpy_web_export.cc::write_image`)."
907
+ % (material.get("name", "?"), name))
908
+
909
+ def _reconcile_with_presenter(self, answer, frame):
910
+ """THE PRESENTER'S REPORT OUTRANKS THE SESSION'S RECORD.
911
+
912
+ `_known` is what this session BELIEVES the tab holds, accumulated one
913
+ present at a time. The presenter is what actually holds it, and the two
914
+ come apart with neither side failing: the worker and this Python
915
+ session belong to the TAB (`blender-runtime-host.ts` keeps one
916
+ `BlenderRuntime`), while the presenter belongs to the Model DOCUMENT --
917
+ so rebuilding that document (closing and reopening it, a play stall
918
+ that remounts it) leaves a view holding nothing under a session whose
919
+ tables say everything crossed. Every later frame then ships references
920
+ to bytes that are gone.
921
+
922
+ MEASURED 2026-09-18, the battery's `19-tram-stop` and `20-rigged-courier`:
923
+ both got zero calls, refused at `blender-start` with
924
+ `RUNTIME_FRAME_UNKNOWN_IMAGE: the presenter does not hold runtime image
925
+ wood_planks_roughness.png@roughness(...) (revision 1)`.
926
+
927
+ So every present's answer carries `held` -- what the presenter held
928
+ BEFORE this frame (`protocol.ts`) -- and a holding that is not this
929
+ session's means the accumulated record describes a presenter that is
930
+ gone. What survives is exactly what THIS frame carried: a mesh whose
931
+ columns crossed and every image in it (there is no unchanged-image
932
+ form). A mesh that went as a reference did not cross and is dropped.
933
+
934
+ An ABSENT `held` is not a report and is judged as nothing: an older
935
+ presenter must not be read as "holds nothing" on every frame.
936
+
937
+ `_known` is only ever narrowed here, never widened, so the correction
938
+ costs one re-ship and can never suppress one.
939
+ """
940
+ if not isinstance(answer, dict) or "held" not in answer:
941
+ self.presenter_held = "unreported"
942
+ return
943
+ held = answer["held"]
944
+ self.presenter_held = held
945
+ if isinstance(held, dict) and held.get("session") == self.session:
946
+ return
947
+ self._known = {}
948
+ for key, mesh in frame["meshes"].items():
949
+ if not mesh.get("unchanged"):
950
+ self._known["mesh:" + key] = mesh["revision"]
951
+ for name, image in frame["images"].items():
952
+ self._known["image:" + name] = image["revision"]
953
+ weights = frame.get("weights")
954
+ if weights is not None and not weights.get("unchanged"):
955
+ self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
956
+ weights["digest"])
957
+
958
+ # -- the document
959
+
960
+ def bind_document(self, relative_path):
961
+ """Name the session's document and OPEN it when the project has one.
962
+
963
+ One session holds one document. The path is stated at start and never
964
+ moves afterwards: a script's own `save_as_mainfile` to somewhere else
965
+ is an ordinary thing for a script to do and is left alone -- it retargets
966
+ Blender's `bpy.data.filepath`, not this.
967
+
968
+ `open_mainfile` runs the session's `_load_post` like any other load
969
+ (the export door's revision table is reset, the render engine ids are
970
+ retaken), and the one present afterwards is what puts the reopened
971
+ model on the tab's screen.
972
+ """
973
+ self.document_relative = relative_path
974
+ self.document = os.path.join(self.project, relative_path)
975
+ if not os.path.exists(self.document):
976
+ return {"document": relative_path, "opened": False}
977
+ bpy.ops.wm.open_mainfile(filepath=self.document)
978
+ self.present()
979
+ # The load did not dirty anything: what is in memory IS the file.
980
+ self.save_due = False
981
+ return {"document": relative_path, "opened": True,
982
+ "objects": len(bpy.data.objects),
983
+ "size": os.path.getsize(self.document)}
984
+
985
+ def save_document(self):
986
+ """Write the document -- Blender's own format, by Blender's own operator.
987
+
988
+ ONE WRITE, AND IT DOES NOT TOUCH THE SCRIPT'S DATABLOCKS. The save used
989
+ to run `bpy.ops.file.make_paths_relative()` and then write a second
990
+ time, so that a document would reopen on another machine. That rewrote
991
+ the filepath of every image in `bpy.data` -- datablocks the SCRIPT
992
+ owns and did not ask to have moved. MEASURED 2026-09-18,
993
+ `16-market-courtyard` seq 10: the model loads its textures from an
994
+ absolute host path the harness maps into the sandbox, the first idle
995
+ save at seq ~8 turned those filepaths into `//../../..` relative to
996
+ `/project/models/`, and the next texture write resolved against the
997
+ document's directory instead and died in Emscripten's FS with
998
+ `ErrnoError`. Nine of the courtyard's 95 calls ran. The document the
999
+ run left behind then named images that no longer resolved, so the three
1000
+ models that opened it afterwards got zero calls apiece.
1001
+
1002
+ `relative_remap` IS FALSE, and that is a measurement rather than a
1003
+ reading of the operator's documentation. `save_as_mainfile`'s
1004
+ `relative_remap` is described as remapping RELATIVE paths so they stay
1005
+ valid from a new location; on this build it also rewrites ABSOLUTE
1006
+ ones. MEASURED 2026-09-18 in the tab, four cells, one image loaded from
1007
+ `/Volumes/PeakSSD/volter-work/wsh-outside/textures/wsh_abs.png`:
1008
+
1009
+ relative_remap use_relative_paths img.filepath after the save
1010
+ False True /Volumes/.../wsh_abs.png
1011
+ False False /Volumes/.../wsh_abs.png
1012
+ True False //../../../wsh-outside/.../wsh_abs.png
1013
+ True True //../../../wsh-outside/.../wsh_abs.png
1014
+
1015
+ `relative_remap` alone decides it and the preference does not enter.
1016
+ So True is the same rewrite `make_paths_relative` was, arriving through
1017
+ a different door, and False is the only value that leaves a script's
1018
+ paths as the script wrote them. "Reopens on another machine" is served
1019
+ by a script loading its project files through project-relative paths in
1020
+ the first place, which is the script's choice to make.
1021
+
1022
+ `copy` IS SET, and it is what makes "the session mutates nothing" true
1023
+ of the save as a whole rather than only of the path flag above. Without
1024
+ it `save_as_mainfile` MOVES the file: `bpy.data.filepath` becomes the
1025
+ document, so a script that saved to its own path has been retargeted,
1026
+ and every RELATIVE path it wrote now resolves from the document's
1027
+ directory instead of the one the script chose.
1028
+
1029
+ MEASURED 2026-09-18 on `18-riverside-bridge`, which is an ordinary
1030
+ thing for a script to do: seq 26 is
1031
+ `save_as_mainfile(filepath=os.path.join(ITEM, 'model.blend'))`, which
1032
+ left its nine textures as `//inputs/<name>.png` relative to ITEM, and
1033
+ seq 27-34 call `save_mainfile()` again. With the session's save moving
1034
+ `bpy.data.filepath` to `models/model.blend` in between, those paths
1035
+ resolved to `models/inputs/` — nothing — and each later save rebased
1036
+ the already-relative path again, compounding to
1037
+ `//../../probes/probes/ws-h-probe/models/inputs/bark_color.png`. Every
1038
+ one of the bridge's pictures was unreadable from there, so the export
1039
+ could not write them and the model presented untextured.
1040
+
1041
+ The two flags are one rule, and neither alone is it: `relative_remap`
1042
+ False is what leaves an ABSOLUTE path absolute, and `copy` is what
1043
+ leaves a RELATIVE one resolving from the base the script gave it.
1044
+
1045
+ What this costs, stated: a script's own `bpy.ops.wm.save_mainfile()`
1046
+ writes where the SCRIPT pointed it, not the session's document. That is
1047
+ the script's choice to make, which is the whole of the rule.
1048
+
1049
+ `compress` IS stated, and that is the whole reason it appears here.
1050
+ MEASURED 2026-09-18 in the tab: the operator's own RNA default is
1051
+ False, but `save_as_mainfile` takes its value from
1052
+ `preferences.filepaths.use_file_compression`, which is True in this
1053
+ build -- so the first documents this lane wrote came out Zstandard
1054
+ (`file` says "Zstandard compressed data", `head -c 12` is not
1055
+ `BLENDER`). Blender reads either back, so nothing was broken; what was
1056
+ wrong is that the BYTES of a file the project commits depended on a
1057
+ per-machine preference. A document states its own format.
1058
+ """
1059
+ self.save_due = False
1060
+ if self.document is None:
1061
+ return {"saved": False, "reason": "no-document"}
1062
+ # `bpy.data.is_dirty` IS NOT A PREDICATE HERE, and this is the measurement
1063
+ # rather than a preference. It reports `is_memfile_undo_written`, which
1064
+ # only a GLOBAL UNDO PUSH clears -- and `--background` has no undo stack,
1065
+ # so nothing ever pushes one. MEASURED 2026-09-18 in the tab, straight
1066
+ # after `lantern.py` built fourteen objects: `bpy.data.is_dirty` is
1067
+ # False. It is False before a save, False after one, and False across a
1068
+ # script that models an entire scene; the only state it ever named here
1069
+ # was "saved".
1070
+ #
1071
+ # It was briefly used to skip a redundant write, and it froze the
1072
+ # document after its first save -- a reopened session could model all
1073
+ # day and never write again. The predicate is `_present`'s instead: a
1074
+ # present is what leaves the document stale. Coarser (a read-only
1075
+ # script rewrites the file once), bounded by the tab's idle-second
1076
+ # debounce, and it cannot miss a change. The reading still rides in the
1077
+ # answer, as an instrument.
1078
+ dirty = bool(bpy.data.is_dirty)
1079
+ existed = os.path.exists(self.document)
1080
+ directory = os.path.dirname(self.document)
1081
+ if directory:
1082
+ try:
1083
+ os.makedirs(directory, exist_ok=True)
1084
+ except OSError:
1085
+ pass
1086
+ try:
1087
+ os.chmod(directory, 0o777)
1088
+ except OSError:
1089
+ pass
1090
+ bpy.ops.wm.save_as_mainfile(
1091
+ filepath=self.document, compress=False, relative_remap=False, copy=True)
1092
+ return {"saved": True, "path": self.document,
1093
+ "document": self.document_relative,
1094
+ "size": os.path.getsize(self.document),
1095
+ "revision": self.revision,
1096
+ # The instruments a reader needs to judge the save: what
1097
+ # Blender thought of the file's state, and whether this was the
1098
+ # first write.
1099
+ "dirty": dirty, "existed": existed}
1100
+
1101
+
1102
+ SESSION = Session()
1103
+
1104
+
1105
+ # ---------------------------------------------------------------- asking the tab
1106
+
1107
+ _ASK_SEQUENCE = [0]
1108
+
1109
+
1110
+ def _drop(path):
1111
+ """Remove one of THIS side's own channel files. A failure here means the
1112
+ ownership rule was broken by the other side, so it is named rather than
1113
+ swallowed -- and named where `vgai console` reads it, not into a log."""
1114
+ try:
1115
+ os.unlink(path)
1116
+ except OSError as error:
1117
+ _say("@@VGAI-WARN the session could not remove its own %s: %r" % (path, error))
1118
+
1119
+
1120
+ def ask(payload):
1121
+ """Block until the tab answers. The worker's own thread is free while this
1122
+ waits; only the Blender pthread is held, which is where the operator is.
1123
+
1124
+ THE REQUEST IS OURS AND THE REPLY IS THE PAGE'S -- see the header's
1125
+ ownership table. We write `ask/<n>.json` + `ask/<n>.done`, read the page's
1126
+ `reply/<n>.json` once `reply/<n>.done` appears, and then remove only our
1127
+ own two files; their disappearance is what tells the page it may retire
1128
+ the reply it wrote. `.done` goes last, both times."""
1129
+ _ASK_SEQUENCE[0] += 1
1130
+ name = str(_ASK_SEQUENCE[0])
1131
+ with open(os.path.join(ASK, name + ".json"), "w") as fh:
1132
+ fh.write(json.dumps(payload))
1133
+ with open(os.path.join(ASK, name + ".done"), "w") as fh:
1134
+ fh.write("1")
1135
+ marker = os.path.join(REPLY, name + ".done")
1136
+ while not os.path.exists(marker):
1137
+ time.sleep(0.002)
1138
+ with open(os.path.join(REPLY, name + ".json")) as fh:
1139
+ body = fh.read()
1140
+ _drop(os.path.join(ASK, name + ".json"))
1141
+ _drop(os.path.join(ASK, name + ".done"))
1142
+ return json.loads(body) if body else None
1143
+
1144
+
1145
+ # ---------------------------------------------------------------- the render engine
1146
+
1147
+ def _vertical_extent(cam, width, height):
1148
+ """The photograph's vertical field of view in degrees (or, for an
1149
+ orthographic camera, its vertical extent in Blender units), framed the way
1150
+ Blender frames a render: `BKE_camera_params_compute_viewplane`.
1151
+
1152
+ Blender's `sensor_fit` says which sensor dimension spans which image
1153
+ dimension. HORIZONTAL: `sensor_width` spans the image width, and the
1154
+ vertical extent follows from the aspect. VERTICAL: `sensor_height` spans the
1155
+ height. AUTO: `sensor_width` spans the LARGER image dimension. The previous
1156
+ line sent `camera.angle_y`, which Blender derives from `sensor_height`
1157
+ alone, so under AUTO or HORIZONTAL fit a 36x24 sensor photographed 1.5x too
1158
+ wide a view -- measured on the ten-model battery (2026-09-18): every hero
1159
+ image differed across ~100% of pixels by exactly the 36/24 ratio, and
1160
+ `sensor_height = 36` made rendered/analytic 0.9991. Pixel aspect is not
1161
+ applied; the presenter renders square pixels."""
1162
+ import math
1163
+ landscape = width >= height
1164
+ fit = cam.sensor_fit
1165
+ if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
1166
+ extent = float(cam.sensor_height if fit == "VERTICAL" else cam.sensor_width)
1167
+ else:
1168
+ extent = float(cam.sensor_width) * height / max(width, 1)
1169
+ if cam.type == "ORTHO":
1170
+ # `ortho_scale` spans the same dimension `sensor_fit` names.
1171
+ if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
1172
+ return float(cam.ortho_scale)
1173
+ return float(cam.ortho_scale) * height / max(width, 1)
1174
+ return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
1175
+
1176
+
1177
+ def _photograph(depsgraph, width, height):
1178
+ """three.js IS the renderer: the render is a photograph of the scene the
1179
+ engine holds, taken through the scene's own camera at `scene.render`'s
1180
+ exact resolution. `bpy.ops.render.render` semantics are the contract."""
1181
+ scene = depsgraph.scene if hasattr(depsgraph, "scene") else bpy.context.scene
1182
+ camera = scene.camera
1183
+ if camera is None:
1184
+ raise RuntimeError("The scene has no camera, so there is nothing to render")
1185
+ matrix = camera.matrix_world
1186
+ rotation = matrix.to_quaternion()
1187
+ vector = __import__("mathutils").Vector
1188
+ position = [float(v) for v in matrix.translation]
1189
+ forward = rotation @ vector((0.0, 0.0, -1.0))
1190
+ target = [position[i] + float(forward[i]) for i in range(3)]
1191
+ # THE CAMERA'S ROLL, which position and target cannot express. A top-down
1192
+ # render is the everyday case: looking straight down, "up" is a free choice
1193
+ # and the photograph is wrong by an arbitrary rotation without it. All three
1194
+ # go out in BLENDER'S frame; the tab converts them through the model root's
1195
+ # matrix (`blender-runtime-host.ts`).
1196
+ up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
1197
+ view = scene.view_settings
1198
+ look = getattr(view, "look", "None") or "None"
1199
+ transform = {
1200
+ "Standard": "none",
1201
+ "AgX": "agx",
1202
+ "Filmic": "filmic",
1203
+ "Khronos PBR Neutral": "neutral",
1204
+ }.get(view.view_transform)
1205
+ if transform is None:
1206
+ raise RuntimeError(
1207
+ "Blender view transform %s has no curve in the renderer" % view.view_transform
1208
+ )
1209
+ render = {
1210
+ "width": int(width),
1211
+ "height": int(height),
1212
+ "fov": _vertical_extent(camera.data, int(width), int(height)),
1213
+ "toneMapping": transform,
1214
+ "exposure": float(2.0 ** view.exposure),
1215
+ "orthographic": camera.data.type == "ORTHO",
1216
+ "transparent": bool(scene.render.film_transparent),
1217
+ }
1218
+ # The renderer keys its baked look tables by the config's FULL name
1219
+ # (`AgX - Medium High Contrast`), and `AgX - Base Contrast` is the AgX base
1220
+ # view itself -- the same table as no look at all. Send the full name for a
1221
+ # non-identity look and nothing for the identity; a look with no table is
1222
+ # refused BY NAME on the other side (`blender-runtime-host.ts`).
1223
+ if look not in ("None", "AgX - Base Contrast"):
1224
+ render["look"] = look
1225
+ answer = SESSION.present(
1226
+ {"position": position, "target": target, "up": up, "render": render}
1227
+ )
1228
+ if not isinstance(answer, dict) or "base64" not in answer:
1229
+ raise RuntimeError("The renderer did not answer with a photograph")
1230
+ _assert_photographed_from(answer.get("camera"), position, target, up)
1231
+ return base64.b64decode(answer["base64"]), render
1232
+
1233
+
1234
+ def _assert_photographed_from(reported, position, target, up):
1235
+ """The photograph answers with the pose it ACTUALLY used, back in Blender's
1236
+ frame, and it must be the pose that was sent -- exactly, with no tolerance.
1237
+
1238
+ This is an instrument, not a nicety. A render's image is allowed to differ
1239
+ from Cycles (three.js is the renderer), so a camera placed in the wrong
1240
+ frame produces a picture nobody can call wrong: the lane shipped for its
1241
+ whole life photographing the model's underside from below the floor because
1242
+ Blender's Z-up numbers were read as three.js world space, and every hero
1243
+ image the battery graded came from that camera.
1244
+
1245
+ The check is exact because the conversion is exact: the model root's world
1246
+ matrix is a signed axis permutation (`blender-runtime-view.ts` sets the
1247
+ matrix outright rather than a float quarter turn), so a vector through it
1248
+ and its inverse is bit-identical. A mismatch is the conversion being wrong,
1249
+ never the check being too strict -- do not widen it.
1250
+ """
1251
+ sent = {"position": position, "target": target, "up": up}
1252
+ if not isinstance(reported, dict):
1253
+ raise RuntimeError(
1254
+ "The renderer did not say which camera it photographed from, so the "
1255
+ "render cannot be attributed to the scene camera %r" % (sent,)
1256
+ )
1257
+ for key in ("position", "target", "up"):
1258
+ got = reported.get(key)
1259
+ if not isinstance(got, (list, tuple)) or len(got) != 3:
1260
+ raise RuntimeError(
1261
+ "The renderer reported no %s for the camera it photographed from" % key
1262
+ )
1263
+ if [float(v) for v in got] != [float(v) for v in sent[key]]:
1264
+ raise RuntimeError(
1265
+ "The photograph was taken from a different camera than the scene's: "
1266
+ "%s was sent as %r and came back as %r. The Blender-to-document "
1267
+ "frame conversion in blender-runtime-host.ts is wrong."
1268
+ % (key, sent[key], list(got))
1269
+ )
1270
+
1271
+
1272
+ class VgaiRenderEngine(bpy.types.RenderEngine):
1273
+ """The scene's renderer, so `write_still`, `save_render` and Render Result
1274
+ behave as Blender's own.
1275
+
1276
+ Registered under the three engine ids a script names. The port has real
1277
+ Cycles compiled in, so the Cycles add-on's own engine is unregistered
1278
+ first -- otherwise `scene.render.engine = 'CYCLES'` resolves to it and
1279
+ starts a path trace nobody asked for inside the tab.
1280
+ """
1281
+
1282
+ bl_idname = "VGAI_THREE"
1283
+ bl_label = "three.js"
1284
+ bl_use_preview = False
1285
+
1286
+ def render(self, depsgraph):
1287
+ scene = depsgraph.scene
1288
+ scale = scene.render.resolution_percentage / 100.0
1289
+ width = max(1, int(scene.render.resolution_x * scale))
1290
+ height = max(1, int(scene.render.resolution_y * scale))
1291
+ try:
1292
+ png, _render = _photograph(depsgraph, width, height)
1293
+ except Exception as error: # noqa: BLE001 - reported to Blender as a render error
1294
+ self.report({"ERROR"}, str(error))
1295
+ return
1296
+ path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
1297
+ with open(path, "wb") as fh:
1298
+ fh.write(png)
1299
+ result = self.begin_result(0, 0, width, height)
1300
+ try:
1301
+ result.layers[0].load_from_file(path)
1302
+ except (RuntimeError, AttributeError) as error:
1303
+ self.report({"ERROR"}, "Render result could not take the photograph: %s" % error)
1304
+ self.end_result(result)
1305
+ try:
1306
+ os.unlink(path)
1307
+ except OSError:
1308
+ pass
1309
+
1310
+
1311
+ def _engine_class(identifier):
1312
+ """The registered RenderEngine class holding `identifier` as its `bl_idname`.
1313
+
1314
+ A Python-registered engine is exposed on `bpy.types` under its CLASS name
1315
+ (`bpy.types.CyclesRender`), never under its id, so `bpy.types.CYCLES` is
1316
+ always absent -- looking there found nothing and left the add-on's engine
1317
+ in place. The subclass tree is the one true registry."""
1318
+ pending = list(bpy.types.RenderEngine.__subclasses__())
1319
+ while pending:
1320
+ cls = pending.pop()
1321
+ if getattr(cls, "bl_idname", None) == identifier and getattr(cls, "is_registered", False):
1322
+ return cls
1323
+ pending.extend(cls.__subclasses__())
1324
+ return None
1325
+
1326
+
1327
+ def _release_from_owning_addon(existing):
1328
+ """Take the class out of its OWNING ADD-ON's registration list as well.
1329
+
1330
+ `unregister_class(existing)` is only half the gesture. The add-on that
1331
+ registered the class still holds it in its module-level `classes`, and
1332
+ Blender tears every add-on down again on `read_factory_settings` and on
1333
+ each `open_mainfile` -- so the add-on's own `unregister()` calls
1334
+ `unregister_class` on a class that is already gone and raises
1335
+ `RuntimeError: missing bl_rna attribute`. `addon_utils.disable` catches
1336
+ that and prints a full traceback, and the remainder of that add-on's
1337
+ `unregister()` never runs.
1338
+
1339
+ MEASURED 2026-09-18 through the editor tab, running Blender's own
1340
+ `tests/python` suites against the bundle: 25 of 43 suites printed
1341
+ `Exception in module unregister(): '/bw/scripts/addons_core/cycles/
1342
+ __init__.py'`, and the native oracle at the same pin (5.2.0 LTS,
1343
+ fbe6228777e7) printed it in NONE of them -- the add-on is intact there
1344
+ because nothing took its engine id. Dropping the class from `classes`
1345
+ makes the add-on's next `register()`/`unregister()` pair agree with what
1346
+ this session actually did, which is what keeps `scene.cycles` (the reason
1347
+ the add-on stays enabled at all) reachable after a factory reset.
1348
+ """
1349
+ module = sys.modules.get(getattr(existing, "__module__", ""))
1350
+ classes = getattr(module, "classes", None)
1351
+ if isinstance(classes, tuple):
1352
+ if existing in classes:
1353
+ module.classes = tuple(cls for cls in classes if cls is not existing)
1354
+ elif isinstance(classes, list):
1355
+ while existing in classes:
1356
+ classes.remove(existing)
1357
+
1358
+
1359
+ def _register_engine():
1360
+ """Take the three engine ids a script can name.
1361
+
1362
+ Blender refuses two classes with one `bl_idname`, so the add-on's engine
1363
+ class is unregistered first and the bundle's patched registration sets a
1364
+ built-in type aside; `bpy.utils.register_class` on a subclass whose
1365
+ `bl_idname` is `CYCLES` (or `BLENDER_EEVEE`, the factory default) then
1366
+ makes `scene.render.engine` resolve here. `BLENDER_EEVEE` is the factory engine's id in 5.x (measured on the
1367
+ 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
1368
+ `scene.cycles` is the add-on's property group and a script sets `samples`,
1369
+ `use_denoising` and the rest on it (disabling the add-on removed it, and
1370
+ every workshop render died on `'Scene' object has no attribute 'cycles'`
1371
+ before it reached the photograph).
1372
+ """
1373
+ made, unavailable = [], []
1374
+ for identifier, label in (
1375
+ ("CYCLES", "Cycles"),
1376
+ ("BLENDER_EEVEE", "EEVEE"),
1377
+ ("BLENDER_WORKBENCH", "Workbench"),
1378
+ ):
1379
+ existing = _engine_class(identifier)
1380
+ if existing is not None and issubclass(existing, VgaiRenderEngine):
1381
+ made.append(identifier)
1382
+ continue
1383
+ if existing is not None:
1384
+ try:
1385
+ bpy.utils.unregister_class(existing)
1386
+ except Exception: # noqa: BLE001
1387
+ pass
1388
+ else:
1389
+ _release_from_owning_addon(existing)
1390
+ engine = type(
1391
+ "Vgai" + identifier.title().replace("_", ""),
1392
+ (VgaiRenderEngine,),
1393
+ {"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
1394
+ )
1395
+ try:
1396
+ bpy.utils.register_class(engine)
1397
+ made.append(identifier)
1398
+ except Exception: # noqa: BLE001
1399
+ # Upstream Blender refuses to let a Python engine take a BUILT-IN
1400
+ # id ("is built-in"); the shipped bundle carries a patch that sets
1401
+ # the built-in type aside instead, since headless has no GPU for it
1402
+ # to render with, so all three ids are taken there (measured
1403
+ # 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
1404
+ # and `BLENDER_WORKBENCH`: that is a fact about the build, stated
1405
+ # in the start reply and refused by name at the render, never a
1406
+ # warning on every boot.
1407
+ unavailable.append(identifier)
1408
+ # `VGAI_THREE` is the engine under its own name, registered ONCE: this runs
1409
+ # again after every file load (the ids have to be retaken), and Blender
1410
+ # refuses a class it already holds -- which is not a capability anyone
1411
+ # lost, so it is not a warning.
1412
+ if _engine_class(VgaiRenderEngine.bl_idname) is None:
1413
+ try:
1414
+ bpy.utils.register_class(VgaiRenderEngine)
1415
+ except Exception as error: # noqa: BLE001
1416
+ warn("could not register the three.js engine: %s" % error)
1417
+ if _engine_class(VgaiRenderEngine.bl_idname) is not None:
1418
+ made.append(VgaiRenderEngine.bl_idname)
1419
+ return made, unavailable
1420
+
1421
+
1422
+ # ---------------------------------------------------------------- the MCP tools
1423
+
1424
+ def get_scene_info():
1425
+ scene = bpy.context.scene
1426
+ info = {
1427
+ "name": scene.name,
1428
+ "object_count": len(scene.objects),
1429
+ "objects": [],
1430
+ "materials_count": len(bpy.data.materials),
1431
+ }
1432
+ for i, obj in enumerate(scene.objects):
1433
+ if i >= 10:
1434
+ break
1435
+ info["objects"].append({
1436
+ "name": obj.name,
1437
+ "type": obj.type,
1438
+ "location": [round(float(obj.location.x), 2), round(float(obj.location.y), 2),
1439
+ round(float(obj.location.z), 2)],
1440
+ })
1441
+ return info
1442
+
1443
+
1444
+ def _aabb(obj):
1445
+ import mathutils
1446
+
1447
+ corners = [mathutils.Vector(corner) for corner in obj.bound_box]
1448
+ world = [obj.matrix_world @ corner for corner in corners]
1449
+ return [[*mathutils.Vector(map(min, zip(*world)))], [*mathutils.Vector(map(max, zip(*world)))]]
1450
+
1451
+
1452
+ def get_object_info(name):
1453
+ obj = bpy.data.objects.get(name)
1454
+ if not obj:
1455
+ raise ValueError("Object not found: %s" % name)
1456
+ info = {
1457
+ "name": obj.name,
1458
+ "type": obj.type,
1459
+ "location": [obj.location.x, obj.location.y, obj.location.z],
1460
+ "rotation": [obj.rotation_euler.x, obj.rotation_euler.y, obj.rotation_euler.z],
1461
+ "scale": [obj.scale.x, obj.scale.y, obj.scale.z],
1462
+ "visible": obj.visible_get(),
1463
+ "materials": [],
1464
+ }
1465
+ if obj.type == "MESH":
1466
+ info["world_bounding_box"] = _aabb(obj)
1467
+ for slot in obj.material_slots:
1468
+ if slot.material:
1469
+ info["materials"].append(slot.material.name)
1470
+ if obj.type == "MESH" and obj.data:
1471
+ info["mesh"] = {
1472
+ "vertices": len(obj.data.vertices),
1473
+ "edges": len(obj.data.edges),
1474
+ "polygons": len(obj.data.polygons),
1475
+ }
1476
+ return info
1477
+
1478
+
1479
+ def execute(code):
1480
+ """Run one script in a namespace of its own, `{"bpy": bpy}`, which is what
1481
+ the Blender MCP add-on gives every call. A helper one call defines reaches
1482
+ a later call only through `bpy.app.driver_namespace`, and a name a later
1483
+ call binds never reaches an earlier call's closure: with one dict shared
1484
+ across calls, the courtyard's render helper (defined at seq 29) found its
1485
+ `sc` rebound to a tuple by seq 35 and failed where the recording did not."""
1486
+ namespace = {"bpy": bpy}
1487
+ captured = io.StringIO()
1488
+ error = None
1489
+ try:
1490
+ with redirect_stdout(captured):
1491
+ exec(compile(code, "<blender-mcp>", "exec"), namespace)
1492
+ except BaseException as thrown: # noqa: BLE001 - the script's failure is the answer
1493
+ error = "%s: %s" % (type(thrown).__name__, thrown)
1494
+ traceback.print_exc(file=_real_stderr)
1495
+ return {"executed": error is None, "result": captured.getvalue(), "error": error}
1496
+
1497
+
1498
+ # CAPABILITIES COMPILED OUT OF THIS BUILD, refused BY NAME at the door. Blender
1499
+ # built without them keeps their operators registered and reports success while
1500
+ # doing nothing: measured on `06-cable-gripper` (2026-09-18),
1501
+ # `bpy.ops.rigidbody.world_add()` returned {'FINISHED'} with `scene.rigidbody_world`
1502
+ # still None, and the script died three lines later on the None. That is the
1503
+ # silent degrade the rulings forbid. The build option is Blender's own fact
1504
+ # (`bpy.app.build_options`); the keyword is the operator namespace a script
1505
+ # reaches the capability through. Same shape as the render-engine refusal above.
1506
+ _ABSENT_CAPABILITIES = (
1507
+ ("bullet", "rigidbody", "rigid body physics (Bullet)"),
1508
+ ("fluid", "bpy.ops.fluid", "fluid simulation (Mantaflow)"),
1509
+ ("alembic", "alembic", "Alembic import/export"),
1510
+ )
1511
+
1512
+
1513
+ def _absent_capability(code):
1514
+ options = bpy.app.build_options
1515
+ for option, keyword, capability in _ABSENT_CAPABILITIES:
1516
+ if keyword in code and not getattr(options, option, True):
1517
+ return ("CapabilityUnavailable: %s is not compiled into this Blender build, so "
1518
+ "`%s` operators are refused rather than reported as done with nothing "
1519
+ "created. (bpy.app.build_options.%s is False.)" % (capability, keyword, option))
1520
+ return _absent_essentials_assets(code)
1521
+
1522
+
1523
+ # THE ESSENTIALS ASSET LIBRARY is not a build option, so it cannot be refused
1524
+ # from `bpy.app.build_options` like the three above -- it is a PAYLOAD fact, and
1525
+ # the only honest predicate is the directory itself.
1526
+ #
1527
+ # MEASURED 2026-09-18 in the tab: `bpy.utils.system_resource('DATAFILES')` is
1528
+ # `/bw/datafiles` and holds exactly `['colormanagement', 'fonts']`, because the
1529
+ # preload payload ships those two and nothing else from `release/datafiles`
1530
+ # (recipe/blender_web_headless.cmake). So
1531
+ # `system_resource('DATAFILES', path='assets')` answers `''`, and Blender
1532
+ # reports the empty string back to the caller verbatim:
1533
+ #
1534
+ # bpy.ops.object.shade_auto_smooth(use_auto_smooth=True)
1535
+ # RuntimeError: Error: No asset found at path ""
1536
+ #
1537
+ # Running Blender's own suites against the bundle, that one absence is every
1538
+ # failing assertion in `object_edit` (1) and `sculpt_paint/brush_asset_test`
1539
+ # (4) -- both clean on the native oracle at the same pin. A message naming the
1540
+ # empty path is not something an author can act on; naming the library is.
1541
+ _ESSENTIALS_KEYWORDS = ("shade_auto_smooth", "asset_activate", "ESSENTIALS")
1542
+
1543
+
1544
+ def _absent_essentials_assets(code):
1545
+ if not any(keyword in code for keyword in _ESSENTIALS_KEYWORDS):
1546
+ return None
1547
+ try:
1548
+ present = bool(bpy.utils.system_resource("DATAFILES", path="assets"))
1549
+ except Exception: # noqa: BLE001
1550
+ present = False
1551
+ if present:
1552
+ return None
1553
+ return ("CapabilityUnavailable: Blender's ESSENTIALS asset library is not in this "
1554
+ "build's preload payload, so the bundled .blend assets behind Smooth by "
1555
+ "Angle and the essentials brushes cannot be linked. Operators that reach "
1556
+ "for them are refused here rather than failing on Blender's own "
1557
+ "`No asset found at path \"\"`, which names nothing an author can act on. "
1558
+ "(bpy.utils.system_resource('DATAFILES', path='assets') is empty.)")
1559
+
1560
+
1561
+ # ------------------------------------------------------------------ the RNA door
1562
+ #
1563
+ # EVERYTHING BLENDER SHOWS ABOUT A DATABLOCK, READ THROUGH RNA (ARCHITECTURE-CORE
1564
+ # §Blender north star, "Inspection parity, not editing parity"; WORK.md §Blender
1565
+ # in the tab is Blender, "Inspection parity", I1). Blender's UI layer is never
1566
+ # run, ported or recorded here: what this answers is `bl_rna.properties` --
1567
+ # Blender's own introspection of its own data -- as typed rows, and what our
1568
+ # panels draw is those rows.
1569
+ #
1570
+ # THE ADDRESS IS THE ENGINE'S OWN. A datablock is named the way Blender names it
1571
+ # to itself: `bpy.data.objects["Cube"]`,
1572
+ # `bpy.data.objects["Rig"].pose.bones["spine"]`, `bpy.data.materials["Mat"]`,
1573
+ # `bpy.data.objects["Cube"].modifiers["Subdivision"]`. Resolution is
1574
+ # `bpy.data.path_resolve` -- RNA's OWN resolver, never `eval` -- and every
1575
+ # address this door EMITS is built from `path_from_id()`, which is that same
1576
+ # resolver's round trip, so an address that came out of here goes back in.
1577
+ #
1578
+ # BIG DATABLOCKS ARE COUNTED, NEVER SERIALISED: a collection answers its length
1579
+ # and at most _RNA_COLLECTION_NAMES names (and no names at all when its item
1580
+ # type has no `name` property -- a mesh's 100k `vertices` is a count), and an
1581
+ # array longer than _RNA_ARRAY_LIMIT answers its length instead of its values.
1582
+
1583
+ _RNA_COLLECTION_NAMES = 16
1584
+ _RNA_ARRAY_LIMIT = 32
1585
+ _RNA_STRING_LIMIT = 4096
1586
+ # `rna_type` is RNA's metadata pointer, not data about the datablock: every
1587
+ # struct carries it and it points at the struct's own type.
1588
+ _RNA_SKIP = ("rna_type",)
1589
+
1590
+ # BLENDER'S PYTHON LAYER ADDS PROPERTIES RNA DOES NOT KNOW. `bl_rna.properties`
1591
+ # is RNA's own list, and `scripts/modules/bpy_types.py` puts ordinary Python
1592
+ # `property` descriptors on top of the generated classes -- `Object.children`,
1593
+ # `Object.users_collection`, `Object.users_scene`, `Bone.children`. They are
1594
+ # part of the API Blender's OWN panels read: `properties_object.py:201` draws
1595
+ # the Collections panel straight out of `obj.users_collection`, which is why
1596
+ # that panel came up EMPTY here until the door answered them (measured
1597
+ # 2026-09-19; `users_collection` appears nowhere under `source/blender/makesrna`
1598
+ # at the pin, so nothing in the RNA iteration could ever have found it).
1599
+ #
1600
+ # SKIPPED BY A RULE, NOT A LIST: a name ending `_recursive` is Blender's own
1601
+ # spelling for a walker that re-derives a whole subtree on every read
1602
+ # (`children_recursive`, `parent_recursive`), and what it holds is one drill
1603
+ # away through the plain property beside it.
1604
+ _PY_PROPERTY_SUFFIX_SKIP = ("_recursive",)
1605
+
1606
+ _ID_COLLECTIONS = None
1607
+ _RNA_GROUPS = {}
1608
+ _PY_PROPERTIES = {}
1609
+
1610
+
1611
+ def _id_collections():
1612
+ """`bpy.data`'s ID collections keyed by the RNA struct each one holds --
1613
+ `Object` -> `objects`, `Mesh` -> `meshes` -- read off `bpy.data`'s own RNA
1614
+ rather than written down, so a build with more ID types needs no edit."""
1615
+ table = {}
1616
+ for prop in bpy.data.bl_rna.properties:
1617
+ if prop.type != "COLLECTION":
1618
+ continue
1619
+ fixed = getattr(prop, "fixed_type", None)
1620
+ if fixed is not None:
1621
+ table.setdefault(fixed.identifier, prop.identifier)
1622
+ return table
1623
+
1624
+
1625
+ def _id_collection_for(datablock):
1626
+ global _ID_COLLECTIONS
1627
+ if _ID_COLLECTIONS is None:
1628
+ _ID_COLLECTIONS = _id_collections()
1629
+ rna = datablock.bl_rna
1630
+ # `bpy.data.lights` holds `Light` while a point lamp's own struct is
1631
+ # `PointLight`, so this is a walk up the RNA base chain, not a lookup.
1632
+ while rna is not None:
1633
+ name = _ID_COLLECTIONS.get(rna.identifier)
1634
+ if name:
1635
+ return name
1636
+ rna = getattr(rna, "base", None)
1637
+ return None
1638
+
1639
+
1640
+ def _rna_address(struct):
1641
+ """Blender's own address for `struct`, or None when it has none.
1642
+
1643
+ An ID is `bpy.data.<collection>["<name>"]`; anything else is its owning
1644
+ ID's address plus RNA's own `path_from_id()`."""
1645
+ if struct is None:
1646
+ return None
1647
+ if isinstance(struct, bpy.types.ID):
1648
+ collection = _id_collection_for(struct)
1649
+ if collection is None:
1650
+ return None
1651
+ return 'bpy.data.%s["%s"]' % (collection, bpy.utils.escape_identifier(struct.name))
1652
+ owner = getattr(struct, "id_data", None)
1653
+ if owner is None:
1654
+ return None
1655
+ base = _rna_address(owner)
1656
+ if base is None:
1657
+ return None
1658
+ try:
1659
+ inner = struct.path_from_id()
1660
+ except Exception: # noqa: BLE001
1661
+ return None
1662
+ return base if not inner else "%s.%s" % (base, inner)
1663
+
1664
+
1665
+ def _rna_resolve(path):
1666
+ """The datablock at an address. `bpy.data.path_resolve` is RNA's own
1667
+ resolver -- no `eval` -- and an unresolvable address raises with the
1668
+ address in the message."""
1669
+ if not isinstance(path, str) or not path.startswith("bpy.data."):
1670
+ raise ValueError(
1671
+ "An RNA path is the engine's own address and starts with `bpy.data.` -- "
1672
+ 'bpy.data.objects["Cube"], bpy.data.objects["Rig"].pose.bones["spine"], '
1673
+ 'bpy.data.objects["Cube"].modifiers["Subdivision"]. Got %r' % (path,))
1674
+ return bpy.data.path_resolve(path[len("bpy.data."):])
1675
+
1676
+
1677
+ def _rna_group(struct_rna, identifier):
1678
+ """WHICH RNA STRUCT DECLARES THIS PROPERTY -- the only grouping RNA itself
1679
+ exposes, and a real one: `Object`'s own properties read apart from the `ID`
1680
+ fields every datablock carries. It is NOT Blender's panel layout. Which
1681
+ properties a Properties panel draws, and in what order, lives in
1682
+ `scripts/startup/bl_ui/properties_*.py`, which the ruling forbids running,
1683
+ porting or recording; our own curated panels are I2 and name their RNA
1684
+ themselves. Cached per struct -- the base chain is walked once."""
1685
+ cached = _RNA_GROUPS.get(struct_rna.identifier)
1686
+ if cached is None:
1687
+ cached = {}
1688
+ for prop in struct_rna.properties:
1689
+ declaring = struct_rna
1690
+ base = getattr(struct_rna, "base", None)
1691
+ while base is not None:
1692
+ if prop.identifier in base.properties:
1693
+ declaring = base
1694
+ base = getattr(base, "base", None)
1695
+ cached[prop.identifier] = (declaring.identifier, declaring.name)
1696
+ _RNA_GROUPS[struct_rna.identifier] = cached
1697
+ return cached.get(identifier, (struct_rna.identifier, struct_rna.name))
1698
+
1699
+
1700
+ def _rna_pointer_row(value):
1701
+ return {
1702
+ "path": _rna_address(value),
1703
+ "name": getattr(value, "name", None),
1704
+ "type": value.bl_rna.identifier,
1705
+ }
1706
+
1707
+
1708
+ def _rna_flatten(value):
1709
+ """A `bpy_prop_array` of any dimension as one flat sequence, row-major --
1710
+ the order `PropertyRNA.array_length` already counts (a 4x4 matrix is 16)."""
1711
+ out = []
1712
+ for item in value:
1713
+ if hasattr(item, "__len__") and not isinstance(item, (str, bytes)):
1714
+ out.extend(_rna_flatten(item))
1715
+ else:
1716
+ out.append(item)
1717
+ return out
1718
+
1719
+
1720
+ def _rna_scalar(item, kind):
1721
+ if kind == "BOOLEAN":
1722
+ return bool(item)
1723
+ if kind == "INT":
1724
+ return int(item)
1725
+ return float(item)
1726
+
1727
+
1728
+ def _rna_value(target, prop):
1729
+ """One property's CURRENT value, in the shape its type earns."""
1730
+ kind = prop.type
1731
+ if kind == "COLLECTION":
1732
+ value = getattr(target, prop.identifier)
1733
+ row = {"count": len(value)}
1734
+ fixed = getattr(prop, "fixed_type", None)
1735
+ # A mesh's `vertices` has no `name`, and asking 100k of them for one is
1736
+ # exactly the serialisation this door refuses -- so the question is
1737
+ # asked of the TYPE, once, off its own RNA.
1738
+ if fixed is not None and "name" in fixed.properties:
1739
+ row["names"] = [item.name for item in value[:_RNA_COLLECTION_NAMES]]
1740
+ return row
1741
+ value = getattr(target, prop.identifier)
1742
+ if kind == "POINTER":
1743
+ return None if value is None else _rna_pointer_row(value)
1744
+ if kind == "STRING":
1745
+ text = str(value)
1746
+ return text if len(text) <= _RNA_STRING_LIMIT else text[:_RNA_STRING_LIMIT]
1747
+ if kind == "ENUM":
1748
+ return sorted(value) if prop.is_enum_flag else value
1749
+ if getattr(prop, "array_length", 0):
1750
+ # A MATRIX IS AN ARRAY OF ROWS, not of numbers. `bpy_prop_array` is
1751
+ # multi-dimensional wherever `PropertyRNA.array_dimension > 1` --
1752
+ # `Bone.matrix` is 3x3, `matrix_local` and `Object.matrix_world` 4x4 --
1753
+ # and iterating one yields `Vector`s, which `float()` refuses by name
1754
+ # ("could not convert string to float: Vector((1.0, 0.0, 0.0))").
1755
+ # Measured live 2026-09-19 on the Bone tab's Transform panel, which is
1756
+ # where a matrix first got named. Flattened in ROW-MAJOR order, which
1757
+ # is how `array_length` already counts it (9 and 16, not 3 and 4).
1758
+ return [_rna_scalar(item, kind) for item in _rna_flatten(value)]
1759
+ if kind == "BOOLEAN":
1760
+ return bool(value)
1761
+ if kind == "INT":
1762
+ return int(value)
1763
+ if kind == "FLOAT":
1764
+ return float(value)
1765
+ return value
1766
+
1767
+
1768
+ def _rna_readonly(target, prop):
1769
+ """BOTH HALVES OF "BLENDER SAYS SO": the property is declared read-only, or
1770
+ this particular datablock refuses the write (library-linked, override
1771
+ locked). `is_property_readonly` is the engine's own answer to the second."""
1772
+ if bool(prop.is_readonly):
1773
+ return True
1774
+ try:
1775
+ return bool(target.is_property_readonly(prop.identifier))
1776
+ except Exception: # noqa: BLE001
1777
+ return False
1778
+
1779
+
1780
+ def _rna_row(target, prop):
1781
+ """One `bl_rna` property as a typed row."""
1782
+ group, group_name = _rna_group(target.bl_rna, prop.identifier)
1783
+ length = int(getattr(prop, "array_length", 0) or 0)
1784
+ row = {
1785
+ "identifier": prop.identifier,
1786
+ "name": prop.name,
1787
+ "type": prop.type,
1788
+ "subtype": prop.subtype,
1789
+ "description": prop.description,
1790
+ "group": group,
1791
+ "groupName": group_name,
1792
+ "arrayLength": length,
1793
+ "readonly": _rna_readonly(target, prop),
1794
+ # `PropertyRNA.is_hidden` -- RNA's own PROP_HIDDEN flag
1795
+ # (`rna_rna.cc:795-799`, `prop->flag & PROP_HIDDEN`), which is how
1796
+ # Blender says "this exists but no UI draws it": `ID.original`
1797
+ # (`rna_ID.cc:2440-2448`), `ViewLayer.depsgraph`
1798
+ # (`rna_layer.cc:733`, through
1799
+ # `RNA_def_property_flag_hide_from_ui_workaround`), the NLA tweak
1800
+ # storage (`rna_animation.cc:1701,1713`). Reported here and FILTERED
1801
+ # by the presentation, so the door stays the whole surface.
1802
+ "hidden": bool(prop.is_hidden),
1803
+ }
1804
+ # UNDRAWN STATE -- the second standing rule, ruled on after I2 measured it
1805
+ # (WORK.md §Blender in the tab is Blender, "Inspection parity", I2, "The two
1806
+ # standing rules"). `rna_define.cc:1311-1312` gives every property
1807
+ # `prop->name = identifier` and `prop->description = ""` at definition, and
1808
+ # `RNA_def_property_ui_text` is what replaces them -- so a property whose
1809
+ # NAME is still its identifier and whose description is still empty is one
1810
+ # Blender never gave UI text to. That is what the modifier panel-open
1811
+ # booleans are (`rna_def_modifier_panel_open_prop`, `:2694-2705`, which sets
1812
+ # `PROP_NO_DEG_UPDATE` and an sdna bit and no flag at all, so the
1813
+ # PROP_HIDDEN rule above does not catch them): state a panel's open/closed
1814
+ # arrow writes, not data about the datablock. Reported here and FILTERED by
1815
+ # the generic view, exactly as `hidden` is; a curated list names none of
1816
+ # them because `bl_ui` draws none of them.
1817
+ if prop.name == prop.identifier and not prop.description:
1818
+ row["undrawn"] = True
1819
+ if prop.type in ("INT", "FLOAT"):
1820
+ row["softMin"] = float(prop.soft_min)
1821
+ row["softMax"] = float(prop.soft_max)
1822
+ row["hardMin"] = float(prop.hard_min)
1823
+ row["hardMax"] = float(prop.hard_max)
1824
+ row["step"] = float(prop.step)
1825
+ if prop.type == "FLOAT":
1826
+ row["precision"] = int(prop.precision)
1827
+ if prop.type == "STRING":
1828
+ row["lengthMax"] = int(getattr(prop, "length_max", 0) or 0)
1829
+ if prop.type == "ENUM":
1830
+ row["isFlag"] = bool(prop.is_enum_flag)
1831
+ try:
1832
+ items = prop.enum_items
1833
+ except Exception: # noqa: BLE001
1834
+ items = getattr(prop, "enum_items_static", ())
1835
+ row["items"] = [
1836
+ {"identifier": item.identifier, "name": item.name,
1837
+ "description": item.description, "icon": item.icon}
1838
+ for item in items
1839
+ ]
1840
+ if prop.type in ("POINTER", "COLLECTION"):
1841
+ fixed = getattr(prop, "fixed_type", None)
1842
+ if fixed is not None:
1843
+ row["itemType"] = fixed.identifier
1844
+ if length > _RNA_ARRAY_LIMIT:
1845
+ row["valueOmitted"] = "%d values" % length
1846
+ else:
1847
+ try:
1848
+ row["value"] = _rna_value(target, prop)
1849
+ except Exception as thrown: # noqa: BLE001
1850
+ row["valueError"] = "%s: %s" % (type(thrown).__name__, thrown)
1851
+ return row
1852
+
1853
+
1854
+ def _py_properties(cls):
1855
+ """Every Python `property` descriptor on this type that RNA does not
1856
+ declare, with the class that declares it -- walked once per type.
1857
+
1858
+ `bl_rna.properties` is RNA's list; `type(x).__mro__` is where
1859
+ `bpy_types.py`'s additions live. A name RNA already carries is left to
1860
+ RNA (the Python side would be the same value read a slower way)."""
1861
+ cached = _PY_PROPERTIES.get(cls.__name__)
1862
+ if cached is not None:
1863
+ return cached
1864
+ found = []
1865
+ seen = set()
1866
+ rna = getattr(cls, "bl_rna", None)
1867
+ declared = set(rna.properties.keys()) if rna is not None else set()
1868
+ for base in cls.__mro__:
1869
+ for name, member in vars(base).items():
1870
+ if name.startswith("_") or name in seen or name in declared:
1871
+ continue
1872
+ if not isinstance(member, property):
1873
+ continue
1874
+ if name.endswith(_PY_PROPERTY_SUFFIX_SKIP):
1875
+ continue
1876
+ seen.add(name)
1877
+ found.append((name, member, base.__name__))
1878
+ found.sort(key=lambda entry: entry[0])
1879
+ _PY_PROPERTIES[cls.__name__] = found
1880
+ return found
1881
+
1882
+
1883
+ def _py_row(target, name, descriptor, owner):
1884
+ """One Python-level property as a row of the SAME shape RNA's are, typed
1885
+ from what it answers -- an ID is a POINTER, a sequence of them a
1886
+ COLLECTION with its count and names, a scalar its scalar. A value no RNA
1887
+ type covers is not invented into one: the property is dropped, and
1888
+ nothing here is a vgai field over Blender's data."""
1889
+ row = {
1890
+ "identifier": name,
1891
+ "name": name,
1892
+ "subtype": "NONE",
1893
+ "description": (descriptor.__doc__ or "").strip(),
1894
+ "group": "py:%s" % owner,
1895
+ "groupName": "%s (Python API)" % owner,
1896
+ "arrayLength": 0,
1897
+ # A descriptor with no setter cannot be written, and `rna_set` refuses
1898
+ # it anyway (it is not in `bl_rna.properties`).
1899
+ "readonly": descriptor.fset is None,
1900
+ "hidden": False,
1901
+ }
1902
+ try:
1903
+ value = getattr(target, name)
1904
+ except Exception as thrown: # noqa: BLE001
1905
+ row["type"] = "STRING"
1906
+ row["valueError"] = "%s: %s" % (type(thrown).__name__, thrown)
1907
+ return row
1908
+ if value is None:
1909
+ row["type"] = "POINTER"
1910
+ row["value"] = None
1911
+ return row
1912
+ if isinstance(value, bpy.types.bpy_struct):
1913
+ row["type"] = "POINTER"
1914
+ row["value"] = _rna_pointer_row(value)
1915
+ row["itemType"] = value.bl_rna.identifier
1916
+ return row
1917
+ if isinstance(value, bool):
1918
+ row["type"] = "BOOLEAN"
1919
+ row["value"] = value
1920
+ return row
1921
+ if isinstance(value, int):
1922
+ row["type"] = "INT"
1923
+ row["value"] = int(value)
1924
+ return row
1925
+ if isinstance(value, float):
1926
+ row["type"] = "FLOAT"
1927
+ row["value"] = float(value)
1928
+ return row
1929
+ if isinstance(value, str):
1930
+ row["type"] = "STRING"
1931
+ row["value"] = value[:_RNA_STRING_LIMIT]
1932
+ return row
1933
+ if isinstance(value, (tuple, list, bpy.types.bpy_prop_collection)):
1934
+ members = list(value[:_RNA_COLLECTION_NAMES])
1935
+ row["type"] = "COLLECTION"
1936
+ held = {"count": len(value)}
1937
+ names = [getattr(item, "name", None) for item in members]
1938
+ if names and all(isinstance(item, str) for item in names):
1939
+ held["names"] = names
1940
+ row["value"] = held
1941
+ if members and isinstance(members[0], bpy.types.bpy_struct):
1942
+ row["itemType"] = members[0].bl_rna.identifier
1943
+ return row
1944
+ # A mathutils value (`Bone.center` is a `Vector`, `Bone.matrix` a
1945
+ # `Matrix`): a fixed-length run of numbers, flattened row-major the same
1946
+ # way `_rna_value` flattens a `bpy_prop_array`.
1947
+ try:
1948
+ flat = _rna_flatten(value)
1949
+ except Exception: # noqa: BLE001
1950
+ return None
1951
+ if flat and all(isinstance(item, (int, float)) and not isinstance(item, bool)
1952
+ for item in flat):
1953
+ row["type"] = "FLOAT"
1954
+ row["arrayLength"] = len(flat)
1955
+ if len(flat) > _RNA_ARRAY_LIMIT:
1956
+ row["valueOmitted"] = "%d values" % len(flat)
1957
+ else:
1958
+ row["value"] = [float(item) for item in flat]
1959
+ return row
1960
+ return None
1961
+
1962
+
1963
+ def _rna_member(collection_path, item, index):
1964
+ """One member of a collection, ADDRESSED.
1965
+
1966
+ `path_from_id()` is the first answer and the right one -- but it is not
1967
+ always an answer: a `VertexGroup` has no RNA path back to its object (the
1968
+ three groups of a skinned mesh came out with `path: null` on the first
1969
+ live walk, 2026-09-19), while `bpy.data.path_resolve` resolves
1970
+ `objects["X"].vertex_groups["chest"]` perfectly well. So where the struct
1971
+ cannot say where it lives, the COLLECTION says it: this path plus the
1972
+ member's own key. Index for a member with no name -- a mesh vertex -- which
1973
+ is also how `path_from_id` would spell it."""
1974
+ address = _rna_address(item)
1975
+ name = getattr(item, "name", None)
1976
+ if address is None:
1977
+ address = ('%s["%s"]' % (collection_path, bpy.utils.escape_identifier(name))
1978
+ if name else "%s[%d]" % (collection_path, index))
1979
+ return {"name": name, "path": address, "type": item.bl_rna.identifier}
1980
+
1981
+
1982
+ def rna_view(path, names=_RNA_COLLECTION_NAMES):
1983
+ """A datablock's whole RNA surface, grouped as RNA groups it -- or, when the
1984
+ address names a COLLECTION (`...modifiers`, `...vertex_groups`, `...bones`),
1985
+ its members with their own addresses, so the caller can open one."""
1986
+ target = _rna_resolve(path)
1987
+ if isinstance(target, bpy.types.bpy_prop_collection):
1988
+ return {
1989
+ "path": path,
1990
+ "kind": "collection",
1991
+ "count": len(target),
1992
+ "items": [_rna_member(path, item, index)
1993
+ for index, item in enumerate(target[:names])],
1994
+ }
1995
+ order = []
1996
+ grouped = {}
1997
+ count = 0
1998
+
1999
+ def add(row):
2000
+ if row["group"] not in grouped:
2001
+ grouped[row["group"]] = {"id": row["group"], "label": row["groupName"], "rows": []}
2002
+ order.append(row["group"])
2003
+ grouped[row["group"]]["rows"].append(row)
2004
+
2005
+ for prop in target.bl_rna.properties:
2006
+ if prop.identifier in _RNA_SKIP:
2007
+ continue
2008
+ add(_rna_row(target, prop))
2009
+ count += 1
2010
+ # AND THEN WHAT BLENDER'S PYTHON LAYER ADDS (see _PY_PROPERTY_SUFFIX_SKIP):
2011
+ # `Object.users_collection` and its siblings are real parts of the API
2012
+ # Blender's own panels read, and RNA has never heard of them. Last, in
2013
+ # their own group, so RNA's answer is never displaced by one.
2014
+ for name, descriptor, owner in _py_properties(type(target)):
2015
+ row = _py_row(target, name, descriptor, owner)
2016
+ if row is not None:
2017
+ add(row)
2018
+ count += 1
2019
+ return {
2020
+ "path": path,
2021
+ "kind": "struct",
2022
+ "type": target.bl_rna.identifier,
2023
+ "typeName": target.bl_rna.name,
2024
+ "name": getattr(target, "name", None),
2025
+ "count": count,
2026
+ # RNA's own order within a group and first-appearance order between
2027
+ # groups -- the engine's ordering, not a sort of ours.
2028
+ "groups": [grouped[group] for group in order],
2029
+ }
2030
+
2031
+
2032
+ def rna_set(path, identifier, value, index=None):
2033
+ """ONE property, written through bpy. A read-only property is refused BY
2034
+ NAME rather than written and lost."""
2035
+ target = _rna_resolve(path)
2036
+ prop = target.bl_rna.properties.get(identifier)
2037
+ if prop is None:
2038
+ raise ValueError("%s has no RNA property %r" % (path, identifier))
2039
+ if _rna_readonly(target, prop):
2040
+ raise ValueError(
2041
+ "%s.%s is read-only in Blender's own RNA, so nothing was written."
2042
+ % (path, identifier))
2043
+ if index is None:
2044
+ if prop.type == "ENUM" and prop.is_enum_flag:
2045
+ setattr(target, identifier, set(value))
2046
+ elif prop.type == "POINTER":
2047
+ setattr(target, identifier, None if value is None else _rna_resolve(value))
2048
+ else:
2049
+ setattr(target, identifier, value)
2050
+ else:
2051
+ getattr(target, identifier)[index] = value
2052
+ return {"path": path, "property": identifier, "value": _rna_value(target, prop)}
2053
+
2054
+
2055
+ # ---- the context the Properties tabs key on --------------------------------
2056
+ #
2057
+ # MIRRORED FROM `source/blender/editors/space_buttons/buttons_context.cc`, read
2058
+ # at the engine's own pin (Blender 5.2.0, `fbe6228777e7`) -- not guessed, and
2059
+ # not transcribed from a screenshot. A tab exists exactly when
2060
+ # `buttons_context_path(<tab>)` can build a path to it, and the rail's ORDER is
2061
+ # `ED_buttons_tabs_list` (`space_buttons.cc:201-256`). Each condition below
2062
+ # cites the function it mirrors, and every value read is the ENGINE's own
2063
+ # state -- the active bone is `armature.bones.active`, the active modifier is
2064
+ # `object.modifiers.active` -- never a list of ours.
2065
+ #
2066
+ # THE CALLER'S OBJECT IS THE ACTIVE ONE. `buttons_context_path_object` reads
2067
+ # `BKE_view_layer_active_object_get`; here the caller may NAME the object it is
2068
+ # looking at instead, because the person's selection happens in our viewport
2069
+ # and reading it must not WRITE the engine's active object -- that is a
2070
+ # mutation, and the document would save it. Given no name, the engine's own
2071
+ # active object answers.
2072
+
2073
+ # `buttons_context_path_modifier`: `ELEM(ob->type, OB_EMPTY, OB_MESH,
2074
+ # OB_CURVES_LEGACY, OB_FONT, OB_SURF, OB_LATTICE, OB_GREASE_PENCIL, OB_CURVES,
2075
+ # OB_POINTCLOUD, OB_VOLUME)`, in `Object.type`'s own enum spellings.
2076
+ _MODIFIER_OBJECT_TYPES = ("EMPTY", "MESH", "CURVE", "FONT", "SURFACE", "LATTICE",
2077
+ "GREASEPENCIL", "GPENCIL", "CURVES", "POINTCLOUD", "VOLUME")
2078
+
2079
+
2080
+ def _rna_active_object(name):
2081
+ if name:
2082
+ obj = bpy.data.objects.get(name)
2083
+ if obj is None:
2084
+ raise ValueError("The engine holds no object named %r" % (name,))
2085
+ return obj
2086
+ return bpy.context.view_layer.objects.active
2087
+
2088
+
2089
+ def _named(value, path=None):
2090
+ if value is None:
2091
+ return None
2092
+ return {"name": getattr(value, "name", None),
2093
+ "path": path if path is not None else _rna_address(value),
2094
+ "type": value.bl_rna.identifier}
2095
+
2096
+
2097
+ def _tab(identifier, label, icon, paths):
2098
+ return {"id": identifier, "label": label, "icon": icon,
2099
+ "paths": [{"label": name, "path": path} for name, path in paths if path]}
2100
+
2101
+
2102
+ def _layer_collection_path(view_layer_path, root, target):
2103
+ """A `LayerCollection` HAS NO `path_from_id()` -- measured live 2026-09-19,
2104
+ the same shape as I1's `VertexGroup` finding, and it made
2105
+ `COLLECTION_PT_viewlayer_flags`'s datablock unaddressable so the View Layer
2106
+ sub-panel drew nothing. Its address is its POSITION in the view layer's
2107
+ tree, which the tree itself can spell."""
2108
+ if target is None:
2109
+ return None
2110
+
2111
+ def walk(node, path):
2112
+ if node == target:
2113
+ return path
2114
+ for child in node.children:
2115
+ found = walk(child, '%s.children["%s"]'
2116
+ % (path, bpy.utils.escape_identifier(child.name)))
2117
+ if found is not None:
2118
+ return found
2119
+ return None
2120
+
2121
+ return walk(root, "%s.layer_collection" % view_layer_path)
2122
+
2123
+
2124
+ def _texture_slot_users(users, owner, slots, label, name=None):
2125
+ """Blender's `buttons_texture_user_mtex_add`: one user per FILLED slot."""
2126
+ for index, slot in enumerate(slots or ()):
2127
+ texture = getattr(slot, "texture", None)
2128
+ if texture is None:
2129
+ continue
2130
+ users.append({"label": label,
2131
+ "name": name if name else getattr(owner, "name", None),
2132
+ "path": _rna_address(slot),
2133
+ "property": "texture",
2134
+ "texture": _named(texture)})
2135
+
2136
+
2137
+ def _texture_node_users(users, tree, label):
2138
+ """`buttons_texture_users_find_nodetree`: every node in the tree whose
2139
+ `texture` pointer is filled."""
2140
+ if tree is None:
2141
+ return
2142
+ for node in getattr(tree, "nodes", ()):
2143
+ prop = node.bl_rna.properties.get("texture")
2144
+ if prop is None or prop.type != "POINTER":
2145
+ continue
2146
+ texture = getattr(node, "texture", None)
2147
+ if texture is None:
2148
+ continue
2149
+ users.append({"label": label, "name": node.name,
2150
+ "path": _rna_address(node), "property": "texture",
2151
+ "texture": _named(texture)})
2152
+
2153
+
2154
+ def _texture_users(scene, view_layer, obj):
2155
+ """WHO USES A TEXTURE, mirrored from
2156
+ `space_buttons/buttons_texture.cc::buttons_texture_users_from_context`
2157
+ (`:244-366`) at the engine's pin -- in ITS order, because
2158
+ `buttons_texture_context_compute` (`:369`) takes `ct->index` (0 unless a
2159
+ person picks another) and the Texture tab then shows THAT user's texture.
2160
+
2161
+ The C walks, in order: the scene's compositing node tree ("Compositor"),
2162
+ the active line style's slots and node tree ("Line Style"), the object's
2163
+ modifiers via `BKE_modifiers_foreach_tex_link` ("Modifiers"), the ACTIVE
2164
+ particle system's `part.mtex[]` ("Particles"), the object's force field
2165
+ when `forcefield == PFIELD_TEXTURE` ("Fields"), and the active paint
2166
+ brush's own two slots ("Brush").
2167
+
2168
+ NOTE, because it is easy to assume otherwise from older Blenders: a LIGHT
2169
+ is not a texture user at this pin. `buttons_texture_users_from_context`
2170
+ does not look at `ob->data` at all -- lamp textures went with 2.8's
2171
+ renderer rewrite. Measured, not remembered."""
2172
+ users = []
2173
+ _texture_node_users(users, getattr(scene, "compositing_node_group", None), "Compositor")
2174
+
2175
+ freestyle = getattr(view_layer, "freestyle_settings", None)
2176
+ linesets = getattr(freestyle, "linesets", None)
2177
+ linestyle = getattr(getattr(linesets, "active", None), "linestyle", None)
2178
+ if linestyle is not None:
2179
+ _texture_slot_users(users, linestyle, getattr(linestyle, "texture_slots", None),
2180
+ "Line Style")
2181
+ _texture_node_users(users, getattr(linestyle, "node_tree", None), "Line Style")
2182
+
2183
+ if obj is not None:
2184
+ for modifier in obj.modifiers:
2185
+ for prop in modifier.bl_rna.properties:
2186
+ if prop.type != "POINTER":
2187
+ continue
2188
+ fixed = getattr(prop, "fixed_type", None)
2189
+ if fixed is None or fixed.identifier != "Texture":
2190
+ continue
2191
+ texture = getattr(modifier, prop.identifier, None)
2192
+ if texture is None:
2193
+ continue
2194
+ users.append({"label": "Modifiers", "name": modifier.name,
2195
+ "path": _rna_address(modifier),
2196
+ "property": prop.identifier,
2197
+ "texture": _named(texture)})
2198
+ systems = getattr(obj, "particle_systems", None)
2199
+ active_system = getattr(systems, "active", None) if systems is not None else None
2200
+ settings = getattr(active_system, "settings", None)
2201
+ if settings is not None:
2202
+ _texture_slot_users(users, settings, getattr(settings, "texture_slots", None),
2203
+ "Particles", name=active_system.name)
2204
+ field = getattr(obj, "field", None)
2205
+ if field is not None and getattr(field, "type", None) == "TEXTURE" \
2206
+ and getattr(field, "texture", None) is not None:
2207
+ users.append({"label": "Fields", "name": "Texture Field",
2208
+ "path": _rna_address(field), "property": "texture",
2209
+ "texture": _named(field.texture)})
2210
+
2211
+ # `BKE_paint_brush(BKE_paint_get_active_from_context(C))`. Headless has no
2212
+ # paint mode, so this is whichever tool settings hold a brush at all --
2213
+ # asked of every `Paint` the tool settings carry rather than of a mode.
2214
+ tools = getattr(bpy.context, "tool_settings", None)
2215
+ for member in ("image_paint", "sculpt", "vertex_paint", "weight_paint",
2216
+ "gpencil_paint", "curves_sculpt"):
2217
+ paint = getattr(tools, member, None)
2218
+ brush = getattr(paint, "brush", None)
2219
+ if brush is None:
2220
+ continue
2221
+ for slot, prop in (("texture_slot", "texture"), ("mask_texture_slot", "mask_texture")):
2222
+ holder = getattr(brush, slot, None)
2223
+ texture = getattr(brush, prop, None)
2224
+ if holder is None or texture is None:
2225
+ continue
2226
+ users.append({"label": "Brush", "name": brush.name,
2227
+ "path": _rna_address(holder), "property": "texture",
2228
+ "texture": _named(texture)})
2229
+ return users
2230
+
2231
+
2232
+ def rna_context(object_name=None, collection_path=None):
2233
+ scene = bpy.context.scene
2234
+ view_layer = bpy.context.view_layer
2235
+ scene_path = _rna_address(scene)
2236
+ view_layer_path = '%s.view_layers["%s"]' % (
2237
+ scene_path, bpy.utils.escape_identifier(view_layer.name))
2238
+ obj = _rna_active_object(object_name)
2239
+ object_path = _rna_address(obj) if obj is not None else None
2240
+ data_path = _rna_address(obj.data) if obj is not None and obj.data is not None else None
2241
+
2242
+ # `buttons_context_path_collection`: the view layer's active collection, and
2243
+ # NOT the scene's master collection ("Do not show collection tab for master
2244
+ # collection").
2245
+ #
2246
+ # THE CALLER'S COLLECTION IS THE ACTIVE ONE, exactly as the caller's object
2247
+ # is (see the header). Clicking a collection row in Blender's Outliner calls
2248
+ # `BKE_layer_collection_activate` (`tree_element_layer_collection_activate`,
2249
+ # `outliner_select.cc:812-821`) and the Properties editor's Collection tab
2250
+ # then shows THAT collection. Activating it here would be a mutation the
2251
+ # document saves, so the row's own address arrives as a parameter and stands
2252
+ # in for `view_layer.active_layer_collection` for this read alone.
2253
+ active_layer_collection = getattr(view_layer, "active_layer_collection", None)
2254
+ if collection_path:
2255
+ named = _rna_resolve(collection_path)
2256
+ if not isinstance(named, bpy.types.LayerCollection):
2257
+ raise ValueError(
2258
+ "%r is a %s, and the Properties editor's Collection tab is built around a "
2259
+ "LayerCollection (`buttons_context_path_collection`)."
2260
+ % (collection_path, named.bl_rna.identifier))
2261
+ active_layer_collection = named
2262
+ collection = getattr(active_layer_collection, "collection", None)
2263
+ if collection is not None and collection == scene.collection:
2264
+ collection = None
2265
+
2266
+ bone = None
2267
+ pose_bone = None
2268
+ if obj is not None and obj.type == "ARMATURE" and obj.data is not None:
2269
+ # `buttons_context_path_bone`: the EDIT bone in edit mode, the
2270
+ # armature's active bone otherwise. `buttons_context_path_pose_bone`
2271
+ # refuses in edit mode and finds the pose channel of that same bone.
2272
+ if obj.mode == "EDIT":
2273
+ bone = _named(obj.data.edit_bones.active)
2274
+ else:
2275
+ active = obj.data.bones.active
2276
+ bone = _named(active)
2277
+ if active is not None:
2278
+ pose_bone = _named(obj.pose.bones.get(active.name))
2279
+
2280
+ material = None
2281
+ if obj is not None and obj.data is not None and hasattr(obj.data, "materials"):
2282
+ # `buttons_context_path_material`: the ACTIVE SLOT's material
2283
+ # (`BKE_object_material_get(ob, ob->actcol)`). The tab stands for an
2284
+ # object whose data supports materials even when the slot is empty --
2285
+ # `OB_TYPE_SUPPORT_MATERIAL` is asked here as "does this data hold a
2286
+ # `materials` collection", which is the same set, read off RNA.
2287
+ slot_index = max(int(getattr(obj, "active_material_index", 0)), 0)
2288
+ slots = obj.material_slots
2289
+ slot = slots[slot_index] if slot_index < len(slots) else None
2290
+ held = slot.material if slot is not None else None
2291
+ material = {"index": slot_index, "slots": len(slots),
2292
+ "name": None, "path": None, "type": None}
2293
+ if held is not None:
2294
+ material.update(_named(held))
2295
+
2296
+ modifier = None
2297
+ vertex_group = None
2298
+ shape_key = None
2299
+ constraint = None
2300
+ particle_system = None
2301
+ if obj is not None:
2302
+ if obj.type in _MODIFIER_OBJECT_TYPES:
2303
+ modifier = _named(getattr(obj.modifiers, "active", None))
2304
+ groups = getattr(obj, "vertex_groups", None)
2305
+ if groups is not None and getattr(groups, "active", None) is not None:
2306
+ vertex_group = {"index": int(groups.active_index)}
2307
+ vertex_group.update(_named(groups.active))
2308
+ shape_key = _named(getattr(obj, "active_shape_key", None))
2309
+ constraint = _named(getattr(obj.constraints, "active", None))
2310
+ systems = getattr(obj, "particle_systems", None)
2311
+ if systems is not None:
2312
+ particle_system = _named(getattr(systems, "active", None))
2313
+
2314
+ # ED_buttons_tabs_list (`space_buttons.cc:218-252`), in its own order. The
2315
+ # TOOL tab is Blender's active-tool settings and has no reader here; SHADERFX
2316
+ # (Effects), STRIP and STRIP_MODIFIER likewise.
2317
+ # A TAB'S PATHS ARE THE DATABLOCKS ITS PANELS READ. `properties_*.py` is
2318
+ # the statement of which: `RENDER_PT_eevee_*` draw off `scene.eevee`,
2319
+ # `RENDER_PT_color_management` off `scene.view_settings`, `SCENE_PT_unit`
2320
+ # off `scene.unit_settings`. So the sub-struct is named here beside the
2321
+ # datablock, the way the Output tab already named `image_settings`, and a
2322
+ # curated panel says which it reads with `from`.
2323
+ world_path = _rna_address(scene.world) if scene.world else None
2324
+ tabs = [
2325
+ _tab("render", "Render", "properties-render",
2326
+ [("Render", "%s.render" % scene_path), ("Scene", scene_path),
2327
+ ("EEVEE", "%s.eevee" % scene_path),
2328
+ ("Raytracing", "%s.eevee.ray_tracing_options" % scene_path),
2329
+ ("Workbench", "%s.display" % scene_path),
2330
+ ("Workbench Shading", "%s.display.shading" % scene_path),
2331
+ ("Grease Pencil", "%s.grease_pencil_settings" % scene_path),
2332
+ ("View Settings", "%s.view_settings" % scene_path),
2333
+ ("Display Device", "%s.display_settings" % scene_path)]),
2334
+ _tab("output", "Output", "properties-output",
2335
+ [("Output", "%s.render" % scene_path),
2336
+ ("Image", "%s.render.image_settings" % scene_path),
2337
+ ("Scene", scene_path),
2338
+ ("FFmpeg", "%s.render.ffmpeg" % scene_path)]),
2339
+ _tab("view_layer", "View Layer", "properties-view-layer",
2340
+ [("View Layer", view_layer_path),
2341
+ ("EEVEE", "%s.eevee" % view_layer_path),
2342
+ # VIEWLAYER_PT_layer draws `rd.use_single_layer` beside the
2343
+ # layer's own `use` (`properties_view_layer.py:96-97`).
2344
+ ("Render", "%s.render" % scene_path)]),
2345
+ _tab("scene", "Scene", "properties-scene",
2346
+ [("Scene", scene_path),
2347
+ ("Units", "%s.unit_settings" % scene_path),
2348
+ ("EEVEE", "%s.eevee" % scene_path),
2349
+ # `SCENE_PT_rigid_body_world`'s sub-panels are `RigidBodySubPanel`,
2350
+ # whose poll is `scene.rigidbody_world` -- so an absent world is an
2351
+ # absent path, and the panels do not draw.
2352
+ ("Rigid Body World", _rna_address(scene.rigidbody_world)
2353
+ if scene.rigidbody_world is not None else None)]),
2354
+ # `buttons_context_path_world` answers true from the scene alone, so the
2355
+ # tab stands even when the scene holds no world.
2356
+ _tab("world", "World", "properties-world",
2357
+ [("World", world_path),
2358
+ ("Mist", "%s.mist_settings" % world_path if world_path else None)]),
2359
+ ]
2360
+ if collection is not None:
2361
+ layer_collection_path = _layer_collection_path(
2362
+ view_layer_path, view_layer.layer_collection, active_layer_collection)
2363
+ tabs.append(_tab("collection", "Collection", "properties-collection",
2364
+ [("Collection", _rna_address(collection)),
2365
+ # COLLECTION_PT_viewlayer_flags reads
2366
+ # `view_layer.active_layer_collection`, not the
2367
+ # collection (`properties_collection.py:47-62`).
2368
+ ("View Layer Collection", layer_collection_path)]))
2369
+ if obj is not None:
2370
+ tabs.append(_tab("object", "Object", "properties-object", [("Object", object_path)]))
2371
+ if obj.type in _MODIFIER_OBJECT_TYPES:
2372
+ paths = [("Modifiers", "%s.modifiers" % object_path)]
2373
+ if modifier:
2374
+ paths.append(("Active", modifier["path"]))
2375
+ tabs.append(_tab("modifier", "Modifiers", "properties-modifiers", paths))
2376
+ # `buttons_context_path_particle`: `ob->type == OB_MESH`.
2377
+ if obj.type == "MESH":
2378
+ paths = [("Particle Systems", "%s.particle_systems" % object_path)]
2379
+ if particle_system:
2380
+ paths.append(("Active", particle_system["path"]))
2381
+ # `particle_get_settings(context)` is `psys.settings`
2382
+ # (`properties_particle.py:40-46`), a ParticleSettings ID, and
2383
+ # it is what nearly every panel on this tab draws -- `part` in
2384
+ # every one of their draw functions. The system itself carries
2385
+ # only a handful (`seed`, `parent`, the vertex-group names).
2386
+ systems_active = getattr(systems, "active", None)
2387
+ settings = getattr(systems_active, "settings", None)
2388
+ if settings is not None:
2389
+ paths.append(("Settings", _rna_address(settings)))
2390
+ cloth = getattr(systems_active, "cloth", None)
2391
+ if cloth is not None:
2392
+ # PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
2393
+ # and `.collision_settings` (`:390-404`, `:463-470`).
2394
+ paths.append(("Hair Dynamics",
2395
+ _rna_address(getattr(cloth, "settings", None))))
2396
+ paths.append(("Hair Collisions",
2397
+ _rna_address(getattr(cloth, "collision_settings", None))))
2398
+ tabs.append(_tab("particle", "Particles", "properties-particles", paths))
2399
+ # `buttons_context_path_object` answers Physics and Constraints both.
2400
+ #
2401
+ # WHICH SIMULATIONS THIS OBJECT HAS is exactly the question
2402
+ # `PHYSICS_PT_add` answers with its add/remove buttons
2403
+ # (`properties_physics_common.py:55-110`): a sim is present when its
2404
+ # DATA is (`obj.rigid_body`, `obj.field`) or when a modifier of its
2405
+ # type is in the stack (`physics_add(col, context.cloth, …)` -- Blender's
2406
+ # `context.cloth` IS the ClothModifier). The panels then read the
2407
+ # modifier's own settings structs, so those are what is named here; an
2408
+ # absent sim is an absent path and its panels do not draw, which is the
2409
+ # same answer the polls give.
2410
+ physics = [(label, "%s.%s" % (object_path, field))
2411
+ for label, field in (("Rigid Body", "rigid_body"),
2412
+ ("Rigid Body Constraint", "rigid_body_constraint"),
2413
+ ("Soft Body", "soft_body"),
2414
+ ("Collision", "collision"),
2415
+ ("Force Field", "field"))
2416
+ if getattr(obj, field, None) is not None]
2417
+ for label, kind, members in (("Cloth", "CLOTH",
2418
+ # The MODIFIER as well as its settings:
2419
+ # `point_cache_ui(self, md.point_cache, …)`
2420
+ # is the cache panel's datablock
2421
+ # (`properties_physics_cloth.py:269`), and
2422
+ # that lives on the modifier, not on
2423
+ # `md.settings`. Measured live 2026-09-19:
2424
+ # the Cache panel drew nothing without it.
2425
+ (("Cloth Modifier", None),
2426
+ ("Cloth", "settings"),
2427
+ ("Cloth Collisions", "collision_settings"))),
2428
+ ("Soft Body", "SOFT_BODY",
2429
+ (("Soft Body Modifier", None),)),
2430
+ ("Fluid", "FLUID",
2431
+ (("Fluid", None),
2432
+ ("Fluid Domain", "domain_settings"),
2433
+ ("Fluid Flow", "flow_settings"),
2434
+ ("Fluid Effector", "effector_settings"))),
2435
+ ("Dynamic Paint", "DYNAMIC_PAINT",
2436
+ (("Dynamic Paint", None),
2437
+ ("Dynamic Paint Canvas", "canvas_settings"),
2438
+ ("Dynamic Paint Brush", "brush_settings")))):
2439
+ modifier = next((item for item in obj.modifiers if item.type == kind), None)
2440
+ if modifier is None:
2441
+ continue
2442
+ for name, member in members:
2443
+ target = modifier if member is None else getattr(modifier, member, None)
2444
+ physics.append((name, _rna_address(target)))
2445
+ tabs.append(_tab("physics", "Physics", "properties-physics",
2446
+ physics or [("Object", object_path)]))
2447
+ paths = [("Constraints", "%s.constraints" % object_path)]
2448
+ if constraint:
2449
+ paths.append(("Active", constraint["path"]))
2450
+ tabs.append(_tab("constraint", "Object Constraints", "properties-constraints", paths))
2451
+ if data_path is not None:
2452
+ # `buttons_context_path_data`: the object's own data. Bones and
2453
+ # vertex groups ride with it because Blender's Object Data tab is
2454
+ # where an armature's bones and a mesh's vertex groups are drawn.
2455
+ paths = [("Object Data", data_path)]
2456
+ if obj.type == "ARMATURE":
2457
+ paths.append(("Bones", "%s.bones" % data_path))
2458
+ if getattr(obj, "vertex_groups", None) is not None and len(obj.vertex_groups):
2459
+ paths.append(("Vertex Groups", "%s.vertex_groups" % object_path))
2460
+ keys = getattr(obj.data, "shape_keys", None)
2461
+ if keys is not None:
2462
+ paths.append(("Shape Keys", _rna_address(keys)))
2463
+ tabs.append(_tab("data", "Object Data", "properties-data", paths))
2464
+ if bone is not None:
2465
+ paths = [("Bone", bone["path"])]
2466
+ if pose_bone is not None:
2467
+ paths.append(("Pose Bone", pose_bone["path"]))
2468
+ tabs.append(_tab("bone", "Bone", "properties-bone", paths))
2469
+ if pose_bone is not None:
2470
+ tabs.append(_tab("bone_constraint", "Bone Constraints",
2471
+ "properties-constraints",
2472
+ [("Constraints", "%s.constraints" % pose_bone["path"])]))
2473
+ if material is not None:
2474
+ paths = [("Material Slots", "%s.material_slots" % object_path)]
2475
+ if material.get("path"):
2476
+ paths.insert(0, ("Material", material["path"]))
2477
+ tabs.append(_tab("material", "Material", "properties-material", paths))
2478
+ # Blender's Texture tab is `buttons_texture_context_compute`'s search for a
2479
+ # texture USER, now mirrored in `_texture_users` -- in the C's own order,
2480
+ # with user 0 the active one (`ct->index`). The tab stands when that search
2481
+ # finds a user, or (as `buttons_context_path_texture` does through the
2482
+ # pinned-ID branch) when the file holds textures at all.
2483
+ texture_users = _texture_users(scene, view_layer, obj)
2484
+ active_texture_user = texture_users[0] if texture_users else None
2485
+ if texture_users or len(bpy.data.textures):
2486
+ paths = []
2487
+ if active_texture_user is not None:
2488
+ paths.append(("Texture", (active_texture_user["texture"] or {}).get("path")))
2489
+ paths.append(("User", active_texture_user["path"]))
2490
+ paths.append(("Textures", "bpy.data.textures"))
2491
+ tabs.append(_tab("texture", "Texture", "properties-texture", paths))
2492
+
2493
+ return {
2494
+ "scene": scene_path,
2495
+ "viewLayer": view_layer_path,
2496
+ "world": _rna_address(scene.world) if scene.world else None,
2497
+ "collection": _rna_address(collection) if collection is not None else None,
2498
+ # `context.engine`, which is what every `COMPAT_ENGINES` poll in
2499
+ # `bl_ui` tests (`RenderEngine`'s id; `properties_render.py:28-48`
2500
+ # draws it from `scene.render.engine`). The curated panels that
2501
+ # Blender shows only under one engine read THIS rather than a copy of
2502
+ # the condition -- see `BlenderCuratedPanel.engine`.
2503
+ "engine": scene.render.engine,
2504
+ "mode": obj.mode if obj is not None else None,
2505
+ "active": None if obj is None else {
2506
+ "name": obj.name,
2507
+ "type": obj.type,
2508
+ "path": object_path,
2509
+ "dataPath": data_path,
2510
+ "dataType": obj.data.bl_rna.identifier if obj.data is not None else None,
2511
+ },
2512
+ "activeBone": bone,
2513
+ "activePoseBone": pose_bone,
2514
+ "activeMaterial": material,
2515
+ "activeModifier": modifier,
2516
+ "activeVertexGroup": vertex_group,
2517
+ "activeShapeKey": shape_key,
2518
+ "activeConstraint": constraint,
2519
+ "activeParticleSystem": particle_system,
2520
+ "textureUsers": texture_users,
2521
+ "activeTextureUser": active_texture_user,
2522
+ "tabs": tabs,
2523
+ }
2524
+
2525
+
2526
+
2527
+ # ---- the outliner tree ------------------------------------------------------
2528
+ #
2529
+ # MIRRORED FROM `space_outliner/tree/tree_display_view_layer.cc` -- Blender's
2530
+ # VIEW LAYER display mode, the one its Outliner opens on -- and from the
2531
+ # per-datablock expansions beside it: `tree_element_id_object.cc` (the order an
2532
+ # object expands in), `tree_element_id_mesh.cc`, `tree_element_id_armature.cc`,
2533
+ # `tree_element_pose.cc`, `tree_element_modifier.cc`, `tree_element_defgroup.cc`
2534
+ # and `tree_element_particle_system.cc`, all read at the engine's pin (Blender
2535
+ # 5.2.0, `fbe6228777e7`). Blender's UI layer is never run, ported or recorded:
2536
+ # what this answers is the TREE those functions build, as rows, and OUR
2537
+ # hierarchy panel draws it (ARCHITECTURE-CORE §Blender north star, "Inspection
2538
+ # parity, not editing parity"; WORK.md §Blender in the tab is Blender,
2539
+ # "Inspection parity", I3).
2540
+ #
2541
+ # THE ADDRESS IS THE ENGINE'S OWN, exactly as `rna_view`'s is: a row's `path` is
2542
+ # `_rna_address` (or a collection's address plus the member's key, I1's
2543
+ # answer for a struct that cannot say where it lives), so a row opens in the
2544
+ # RNA door with no translation. `id` is the row's IDENTITY in the tree, and it
2545
+ # IS the path in every ordinary case -- the two differ only where Blender
2546
+ # itself draws one datablock twice (an object linked into two collections, and
2547
+ # the `TE_CHILD_NOT_IN_COLLECTION` duplicate
2548
+ # `make_object_parent_hierarchy_collections` adds), where an ordinal keeps the
2549
+ # rows distinct while `path` stays the engine's own address for both.
2550
+ #
2551
+ # WHAT IS OPEN AT REST. A fresh tree-store element is CLOSED
2552
+ # (`outliner_tree.cc:139`, `tselem->flag = TSE_CLOSED`); the Scene Collection
2553
+ # (`tree_display_view_layer.cc:130`) and every editable layer collection
2554
+ # (`:167`) clear that flag and NOTHING else does -- so a collection is open and
2555
+ # an OBJECT is closed, its data, modifiers and groups with it.
2556
+ #
2557
+ # BIG LISTS ARE PAGED, the way `rna_view`'s collections are counted: a child
2558
+ # list longer than _OUTLINER_PAGE answers its first page and says how many more
2559
+ # there are, so a scene of 20,000 objects cannot turn one read into a
2560
+ # 20,000-row payload.
2561
+
2562
+ _OUTLINER_PAGE = 64
2563
+
2564
+ # `tree_element_get_icon_from_id` (`outliner_draw.cc:2479-2610`) as a table off
2565
+ # the RNA struct the datablock IS -- the C's `switch (GS(id->name))`, whose
2566
+ # cases are ID codes and whose two sub-switches (a light's lamp type, a light
2567
+ # probe's) are values RNA answers directly. Names are Blender's own icon names
2568
+ # WITHOUT the `ICON_` prefix, which is how `EnumPropertyItem.icon` spells them
2569
+ # and therefore how every icon this door already emits is spelled.
2570
+ _OUTLINER_ID_ICONS = {
2571
+ "Scene": "SCENE_DATA",
2572
+ "Mesh": "OUTLINER_DATA_MESH",
2573
+ "SurfaceCurve": "OUTLINER_DATA_SURFACE",
2574
+ "TextCurve": "OUTLINER_DATA_FONT",
2575
+ "Curve": "OUTLINER_DATA_CURVE",
2576
+ "MetaBall": "OUTLINER_DATA_META",
2577
+ "Lattice": "OUTLINER_DATA_LATTICE",
2578
+ "Material": "MATERIAL_DATA",
2579
+ "Texture": "TEXTURE_DATA",
2580
+ "Image": "IMAGE_DATA",
2581
+ "Speaker": "OUTLINER_DATA_SPEAKER",
2582
+ "Sound": "OUTLINER_DATA_SPEAKER",
2583
+ "Armature": "OUTLINER_DATA_ARMATURE",
2584
+ "Camera": "OUTLINER_DATA_CAMERA",
2585
+ "Key": "SHAPEKEY_DATA",
2586
+ "World": "WORLD_DATA",
2587
+ "Action": "ACTION",
2588
+ "Collection": "OUTLINER_COLLECTION",
2589
+ "Curves": "OUTLINER_DATA_CURVES",
2590
+ "PointCloud": "OUTLINER_DATA_POINTCLOUD",
2591
+ "Volume": "OUTLINER_DATA_VOLUME",
2592
+ "GreasePencilv3": "OUTLINER_DATA_GREASEPENCIL",
2593
+ "GreasePencil": "OUTLINER_DATA_GREASEPENCIL",
2594
+ "FreestyleLineStyle": "LINE_DATA",
2595
+ "Brush": "BRUSH_DATA",
2596
+ "ParticleSettings": "PARTICLES",
2597
+ "ShaderNodeTree": "NODETREE",
2598
+ "NodeTree": "NODETREE",
2599
+ "Palette": "COLOR",
2600
+ "VectorFont": "FILE_FONT",
2601
+ "MovieClip": "SEQUENCE",
2602
+ "Mask": "MOD_MASK",
2603
+ "Text": "FILE_TEXT",
2604
+ "Library": "LIBRARY_DATA_DIRECT",
2605
+ "WorkSpace": "WORKSPACE",
2606
+ "Screen": "WORKSPACE",
2607
+ }
2608
+
2609
+ # The two the table cannot hold, because the C keys them on a VALUE rather than
2610
+ # on the struct (`outliner_draw.cc:2508-2521`, `:2578-2589`).
2611
+ _OUTLINER_LIGHT_ICONS = {"POINT": "LIGHT_POINT", "SUN": "LIGHT_SUN",
2612
+ "SPOT": "LIGHT_SPOT", "AREA": "LIGHT_AREA"}
2613
+ _OUTLINER_PROBE_ICONS = {"SPHERE": "LIGHTPROBE_SPHERE", "PLANE": "LIGHTPROBE_PLANE",
2614
+ "VOLUME": "LIGHTPROBE_VOLUME"}
2615
+
2616
+
2617
+ def _enum_icon(target, identifier):
2618
+ """The icon RNA'S OWN enum item carries for this property's current value.
2619
+
2620
+ `rna_enum_object_type_items` (`rna_object.cc:218-240`) is item for item
2621
+ `ui::icon_from_object_type`'s answer; `rna_enum_object_modifier_type_items`
2622
+ carries the `ModifierTypeInfo::icon` that `tree_element_get_icon` reads
2623
+ (`outliner_draw.cc:2773-2785`); `rna_enum_constraint_type_items` carries
2624
+ that function's own constraint switch. Reading the engine's enum is reading
2625
+ the table the Outliner draws from, rather than transcribing it."""
2626
+ prop = target.bl_rna.properties.get(identifier)
2627
+ if prop is None:
2628
+ return None
2629
+ item = prop.enum_items.get(getattr(target, identifier, None))
2630
+ return (getattr(item, "icon", None) or None) if item is not None else None
2631
+
2632
+
2633
+ def _outliner_object_icon(obj):
2634
+ """`ui::icon_from_object_type` (`interface_icons.cc:2188-2237`): the type's
2635
+ own enum icon, with the three EMPTY overrides the C spells out (`:2218-2229`
2636
+ -- a collection instance, an image empty, a force field)."""
2637
+ if obj.type == "EMPTY":
2638
+ if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
2639
+ return "OUTLINER_OB_GROUP_INSTANCE"
2640
+ if getattr(obj, "empty_display_type", None) == "IMAGE":
2641
+ return "OUTLINER_OB_IMAGE"
2642
+ field = getattr(obj, "field", None)
2643
+ if field is not None and getattr(field, "type", "NONE") != "NONE":
2644
+ return "OUTLINER_OB_FORCE_FIELD"
2645
+ return _enum_icon(obj, "type") or "OBJECT_DATA"
2646
+
2647
+
2648
+ def _outliner_id_icon(datablock):
2649
+ """`tree_element_get_icon_from_id` for anything that is not an object."""
2650
+ if datablock is None:
2651
+ return "DOT"
2652
+ if isinstance(datablock, bpy.types.Light):
2653
+ return _OUTLINER_LIGHT_ICONS.get(getattr(datablock, "type", None),
2654
+ "OUTLINER_DATA_LIGHT")
2655
+ if isinstance(datablock, bpy.types.LightProbe):
2656
+ return _OUTLINER_PROBE_ICONS.get(getattr(datablock, "type", None),
2657
+ "LIGHTPROBE_SPHERE")
2658
+ rna = datablock.bl_rna
2659
+ while rna is not None:
2660
+ icon = _OUTLINER_ID_ICONS.get(rna.identifier)
2661
+ if icon:
2662
+ return icon
2663
+ rna = getattr(rna, "base", None)
2664
+ return "DOT"
2665
+
2666
+
2667
+ def _outliner_unique(path, seen):
2668
+ """A row's IDENTITY -- the address, unless this tree already drew that
2669
+ datablock (see the header)."""
2670
+ count = seen.get(path, 0) + 1
2671
+ seen[path] = count
2672
+ return path if count == 1 else "%s@%d" % (path, count)
2673
+
2674
+
2675
+ def _outliner_row(path, name, kind, icon, seen, **extra):
2676
+ row = {"id": _outliner_unique(path, seen), "path": path, "name": name,
2677
+ "type": kind, "icon": icon, "expanded": False, "children": []}
2678
+ row.update(extra)
2679
+ return row
2680
+
2681
+
2682
+ def _outliner_page(members):
2683
+ """A child list, capped. The page, and how many were left behind."""
2684
+ members = list(members)
2685
+ if len(members) <= _OUTLINER_PAGE:
2686
+ return members, 0
2687
+ return members[:_OUTLINER_PAGE], len(members) - _OUTLINER_PAGE
2688
+
2689
+
2690
+ def _outliner_key(collection_path, name):
2691
+ return '%s["%s"]' % (collection_path, bpy.utils.escape_identifier(name))
2692
+
2693
+
2694
+ def _outliner_constraint(con, owner_path, seen, owner_object):
2695
+ path = _outliner_key("%s.constraints" % owner_path, con.name)
2696
+ return _outliner_row(
2697
+ path, con.name, "TSE_CONSTRAINT", _enum_icon(con, "type") or "DOT", seen,
2698
+ object=owner_object,
2699
+ # `outliner_draw_restrictbuts`: a constraint's one column is HIDE, and
2700
+ # what it holds is `Constraint.enabled` (`outliner_draw.cc:1387-1408`).
2701
+ restrict={"hide": not con.enabled})
2702
+
2703
+
2704
+ def _outliner_bone(bone, armature_path, seen):
2705
+ """One `TSE_BONE` and its children -- `outliner_add_bone`
2706
+ (`tree_element_id_armature.cc`), which nests the armature's bones by parent.
2707
+ The glyph is `ICON_BONE_DATA` for every bone (`outliner_draw.cc:2649-2651`)."""
2708
+ path = _outliner_key("%s.bones" % armature_path, bone.name)
2709
+ row = _outliner_row(path, bone.name, "TSE_BONE", "BONE_DATA", seen)
2710
+ page, more = _outliner_page(bone.children)
2711
+ row["children"] = [_outliner_bone(child, armature_path, seen) for child in page]
2712
+ if more:
2713
+ row["more"] = more
2714
+ return row
2715
+
2716
+
2717
+ def _outliner_pose_bone(pchan, object_path, seen, owner_object):
2718
+ """`TSE_POSE_CHANNEL`, its own constraints under it, and its child channels
2719
+ -- `TreeElementPoseBase::expand` (`tree_element_pose.cc`), which nests the
2720
+ channels by `pchan->parent` the way the bone tree nests bones."""
2721
+ path = _outliner_key("%s.pose.bones" % object_path, pchan.name)
2722
+ row = _outliner_row(path, pchan.name, "TSE_POSE_CHANNEL", "BONE_DATA", seen,
2723
+ object=owner_object,
2724
+ restrict={"viewport": bool(pchan.bone.hide)})
2725
+ children = []
2726
+ if len(pchan.constraints):
2727
+ base = _outliner_row("%s.constraints" % path, "Constraints", "TSE_CONSTRAINT_BASE",
2728
+ "CONSTRAINT", seen, object=owner_object)
2729
+ base["children"] = [_outliner_constraint(con, path, seen, owner_object)
2730
+ for con in pchan.constraints]
2731
+ children.append(base)
2732
+ children.extend(_outliner_pose_bone(child, object_path, seen, owner_object)
2733
+ for child in pchan.children)
2734
+ row["children"] = children
2735
+ return row
2736
+
2737
+
2738
+ def _outliner_modifier(md, obj, object_path, seen):
2739
+ """`TSE_MODIFIER`, whose icon is `ModifierTypeInfo::icon`
2740
+ (`outliner_draw.cc:2773-2785`) and whose columns are `show_viewport` /
2741
+ `show_render` (`:1409-1450`). `TreeElementModifier::expand` hangs the
2742
+ modifier's own pointer under it -- the armature/lattice/curve/hook OBJECT, a
2743
+ nodes modifier's node group, and a particle system modifier's SYSTEM, which
2744
+ is the only place the Outliner shows particles at all."""
2745
+ path = _outliner_key("%s.modifiers" % object_path, md.name)
2746
+ row = _outliner_row(path, md.name, "TSE_MODIFIER", _enum_icon(md, "type") or "DOT", seen,
2747
+ object=obj.name,
2748
+ restrict={"viewport": not md.show_viewport,
2749
+ "render": not md.show_render})
2750
+ children = []
2751
+ for member, kind, icon in (("object", "TSE_LINKED_OB", "OBJECT_DATA"),
2752
+ ("node_group", "TSE_LINKED_NODE_TREE", "NODETREE")):
2753
+ linked = getattr(md, member, None)
2754
+ if linked is None:
2755
+ continue
2756
+ linked_path = _rna_address(linked) or "%s.%s" % (path, member)
2757
+ children.append(_outliner_row(linked_path, linked.name, kind, icon, seen))
2758
+ break
2759
+ system = getattr(md, "particle_system", None)
2760
+ if system is not None:
2761
+ children.append(_outliner_row(
2762
+ _outliner_key("%s.particle_systems" % object_path, system.name),
2763
+ system.settings.name, "TSE_LINKED_PSYS", "PARTICLES", seen, object=obj.name))
2764
+ row["children"] = children
2765
+ return row
2766
+
2767
+
2768
+ def _outliner_data(obj, seen):
2769
+ """THE OBJECT'S DATA and what its own expansion adds: a mesh's shape keys
2770
+ and materials (`tree_element_id_mesh.cc`), an armature's BONES and bone
2771
+ collections (`tree_element_id_armature.cc` -- and not its bones while the
2772
+ object is in POSE mode, where the Pose row carries the channels instead)."""
2773
+ data = obj.data
2774
+ path = _rna_address(data)
2775
+ row = _outliner_row(path, data.name, "TSE_SOME_ID", _outliner_id_icon(data), seen,
2776
+ object=obj.name, struct=data.bl_rna.identifier)
2777
+ children = []
2778
+ keys = getattr(data, "shape_keys", None)
2779
+ if keys is not None:
2780
+ keys_path = _rna_address(keys)
2781
+ base = _outliner_row(keys_path, keys.name, "TSE_SHAPE_KEY_BASE", "SHAPEKEY_DATA", seen,
2782
+ object=obj.name)
2783
+ page, more = _outliner_page(keys.key_blocks)
2784
+ base["children"] = [
2785
+ _outliner_row(_outliner_key("%s.key_blocks" % keys_path, block.name), block.name,
2786
+ "TSE_SHAPE_KEY_BLOCK", "SHAPEKEY_DATA", seen, object=obj.name)
2787
+ for block in page]
2788
+ if more:
2789
+ base["more"] = more
2790
+ children.append(base)
2791
+ if isinstance(data, bpy.types.Armature):
2792
+ if obj.mode != "POSE":
2793
+ page, more = _outliner_page([bone for bone in data.bones if bone.parent is None])
2794
+ children.extend(_outliner_bone(bone, path, seen) for bone in page)
2795
+ if more:
2796
+ row["more"] = more
2797
+ collections = getattr(data, "collections", None)
2798
+ if collections is not None and len(collections):
2799
+ base = _outliner_row("%s.collections" % path, "Bone Collections",
2800
+ "TSE_BONE_COLLECTION_BASE", "GROUP_BONE", seen)
2801
+ page, more = _outliner_page(collections)
2802
+ base["children"] = [
2803
+ _outliner_row(_outliner_key("%s.collections" % path, item.name), item.name,
2804
+ "TSE_BONE_COLLECTION", "GROUP_BONE", seen)
2805
+ for item in page]
2806
+ if more:
2807
+ base["more"] = more
2808
+ children.append(base)
2809
+ for material in getattr(data, "materials", ()) or ():
2810
+ if material is None:
2811
+ continue
2812
+ children.append(_outliner_row(_rna_address(material), material.name, "TSE_SOME_ID",
2813
+ "MATERIAL_DATA", seen, object=obj.name, struct="Material"))
2814
+ row["children"] = children
2815
+ return row
2816
+
2817
+
2818
+ def _outliner_object(obj, view_layer, seen, chosen, active):
2819
+ """ONE OBJECT ROW and everything `TreeElementIDObject::expand`
2820
+ (`tree_element_id_object.cc:33-45`) hangs under it, IN ITS ORDER: animation
2821
+ data, pose, data, materials, constraints, modifiers, vertex groups and the
2822
+ instanced collection. Its child OBJECTS are added afterwards, by the parent
2823
+ walk `ObjectsChildrenBuilder` does."""
2824
+ path = _rna_address(obj)
2825
+ row = _outliner_row(
2826
+ path, obj.name, "TSE_SOME_ID", _outliner_object_icon(obj), seen,
2827
+ struct="Object", objectType=obj.type, object=obj.name,
2828
+ selected=obj.name in chosen, active=obj.name == active,
2829
+ # The two columns Blender's Outliner draws for an object by default
2830
+ # (`space_outliner.cc:399` sets `show_restrict_flags` to
2831
+ # ENABLE|HIDE|RENDER and ENABLE is collections-only): the EYE is the
2832
+ # view layer BASE's `hide_viewport` (`outliner_draw.cc:1291-1317`,
2833
+ # which is what `Object.hide_get()` reads), the camera is the object's
2834
+ # own `hide_render` (`:1363-1384`).
2835
+ restrict={"hide": bool(obj.hide_get(view_layer=view_layer)),
2836
+ "render": bool(obj.hide_render)})
2837
+ children = []
2838
+ if obj.animation_data is not None:
2839
+ children.append(_outliner_row("%s.animation_data" % path, "Animation", "TSE_ANIM_DATA",
2840
+ "ANIM_DATA", seen, object=obj.name))
2841
+ if obj.pose is not None:
2842
+ pose = _outliner_row("%s.pose" % path, "Pose", "TSE_POSE_BASE", "ARMATURE_DATA", seen,
2843
+ object=obj.name)
2844
+ # `TreeElementPoseBase::expand`: the channels exist only IN POSE MODE
2845
+ # ("channels undefined in editmode, but we want the 'tenla' pose icon
2846
+ # itself"), so outside it this row is the label alone -- which is
2847
+ # exactly what Blender draws.
2848
+ if obj.mode == "POSE":
2849
+ page, more = _outliner_page([pchan for pchan in obj.pose.bones
2850
+ if pchan.parent is None])
2851
+ pose["children"] = [_outliner_pose_bone(pchan, path, seen, obj.name)
2852
+ for pchan in page]
2853
+ if more:
2854
+ pose["more"] = more
2855
+ children.append(pose)
2856
+ if obj.data is not None:
2857
+ children.append(_outliner_data(obj, seen))
2858
+ for slot in obj.material_slots:
2859
+ if slot.material is None:
2860
+ continue
2861
+ children.append(_outliner_row(_rna_address(slot.material), slot.material.name,
2862
+ "TSE_SOME_ID", "MATERIAL_DATA", seen, object=obj.name,
2863
+ struct="Material"))
2864
+ if len(obj.constraints):
2865
+ base = _outliner_row("%s.constraints" % path, "Constraints", "TSE_CONSTRAINT_BASE",
2866
+ "CONSTRAINT", seen, object=obj.name)
2867
+ base["children"] = [_outliner_constraint(con, path, seen, obj.name)
2868
+ for con in obj.constraints]
2869
+ children.append(base)
2870
+ if len(obj.modifiers):
2871
+ base = _outliner_row("%s.modifiers" % path, "Modifiers", "TSE_MODIFIER_BASE",
2872
+ "MODIFIER_DATA", seen, object=obj.name)
2873
+ base["children"] = [_outliner_modifier(md, obj, path, seen) for md in obj.modifiers]
2874
+ children.append(base)
2875
+ groups = getattr(obj, "vertex_groups", None)
2876
+ if groups is not None and len(groups):
2877
+ # `expand_vertex_groups`: mesh, lattice and grease pencil only, which is
2878
+ # the same set as "this object carries a `vertex_groups` collection".
2879
+ base_path = "%s.vertex_groups" % path
2880
+ base = _outliner_row(base_path, "Vertex Groups", "TSE_DEFGROUP_BASE", "GROUP_VERTEX",
2881
+ seen, object=obj.name)
2882
+ page, more = _outliner_page(groups)
2883
+ base["children"] = [
2884
+ _outliner_row(_outliner_key(base_path, group.name), group.name, "TSE_DEFGROUP",
2885
+ "GROUP_VERTEX", seen, object=obj.name)
2886
+ for group in page]
2887
+ if more:
2888
+ base["more"] = more
2889
+ children.append(base)
2890
+ if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
2891
+ # `expand_duplicated_group`.
2892
+ children.append(_outliner_row(_rna_address(obj.instance_collection),
2893
+ obj.instance_collection.name, "TSE_SOME_ID",
2894
+ "OUTLINER_COLLECTION", seen, struct="Collection"))
2895
+ row["children"] = children
2896
+ return row
2897
+
2898
+
2899
+ # ---- the node editor's tree ------------------------------------------------
2900
+ #
2901
+ # THE ONE READ A NODE VIEW NEEDS. The generic `rna_view` door answers every one
2902
+ # of these facts -- `bpy.data.materials["M"].node_tree.nodes` is a collection it
2903
+ # opens, and each member is a struct it describes -- but it answers them ONE
2904
+ # STRUCT AT A TIME, and a node tree's drawing is a fact about the WHOLE tree:
2905
+ # the default Principled material is 2 nodes and 37 sockets, so drawing it
2906
+ # through the generic door is 40 round trips at the door's own ~100 ms median.
2907
+ # This door answers the same facts in one, and answers NOTHING the generic view
2908
+ # could not: every field below is a `bl_rna` property of `Node`, `NodeSocket` or
2909
+ # `NodeLink`, named here so a reader can check it against `rna_nodetree.cc`.
2910
+ #
2911
+ # IT IS A READ AND ONLY A READ (ARCHITECTURE-CORE "Inspection parity, not
2912
+ # editing parity"): there is no node-tree writer beside it, because a gesture
2913
+ # that moved a node or retyped a value would be editing, and editing parity is
2914
+ # not the program. `rna_set` remains the one writer, and it refuses a node's
2915
+ # `location` the same way it refuses anything else RNA declares read-only --
2916
+ # which `location` is not, so the VIEW is what refuses, by name, in its own
2917
+ # status line.
2918
+ _NODE_SOCKET_VALUE_MAX = 4
2919
+
2920
+
2921
+ def _node_socket_value(socket):
2922
+ """A socket's `default_value`, flattened the way `_rna_flatten` flattens an
2923
+ array property -- and OMITTED where the socket has none (a shader socket,
2924
+ a geometry socket) or Blender itself would not draw it (`hide_value`)."""
2925
+ if not hasattr(socket, "default_value"):
2926
+ return None
2927
+ value = socket.default_value
2928
+ if isinstance(value, str):
2929
+ return value
2930
+ if hasattr(value, "__len__"):
2931
+ items = list(value)
2932
+ if len(items) > _NODE_SOCKET_VALUE_MAX:
2933
+ return None
2934
+ return [float(item) for item in items]
2935
+ if isinstance(value, bool):
2936
+ return bool(value)
2937
+ if isinstance(value, (int, float)):
2938
+ return float(value)
2939
+ # A POINTER-valued socket (an Object, an Image, a Material): its name is
2940
+ # what the inline widget shows.
2941
+ return getattr(value, "name", None)
2942
+
2943
+
2944
+ def _node_socket(socket, index):
2945
+ """One socket, as the node editor draws it.
2946
+
2947
+ Every field is a `NodeSocket` RNA property (`rna_node_socket.cc`): `type`
2948
+ is the `SOCK_*` that picks the socket's COLOUR
2949
+ (`std_node_socket_colors[]`, `drawnode.cc:987-1013`), `display_shape` the
2950
+ `SOCK_DISPLAY_SHAPE_*` that picks its MARK (`rna_node_socket.cc:793-802`),
2951
+ and `enabled`/`hide` together are `node_draw.cc`'s own
2952
+ `is_socket_available` test -- an unavailable socket occupies no row."""
2953
+ return {
2954
+ "identifier": socket.identifier,
2955
+ "name": socket.name,
2956
+ "label": socket.label or None,
2957
+ "type": socket.type,
2958
+ "shape": socket.display_shape,
2959
+ "enabled": bool(socket.enabled),
2960
+ "hide": bool(socket.hide),
2961
+ "hideValue": bool(getattr(socket, "hide_value", False)),
2962
+ "linked": bool(socket.is_linked),
2963
+ "multiInput": bool(getattr(socket, "is_multi_input", False)),
2964
+ "index": index,
2965
+ "value": _node_socket_value(socket),
2966
+ # THE SLIDER'S RANGE. A scalar socket whose `default_value` property
2967
+ # carries a BOUNDED soft range is drawn by Blender as a NUMBER SLIDER
2968
+ # -- `ui_but_is_slider`'s `UI_BTYPE_NUM_SLIDER`, whose back is filled to
2969
+ # the value's proportion of that range (`widget_numslider`,
2970
+ # `interface_widgets.cc`). Without the range a view can only draw the
2971
+ # flat NUM field, which is what ours did: Roughness at 0.5 read as a
2972
+ # text box where Blender's reads half-filled at a glance.
2973
+ #
2974
+ # `soft_min`/`soft_max` are the UI range and `min`/`max` the hard one;
2975
+ # Blender sliders against the SOFT pair. An unbounded property (a
2976
+ # location, an IOR with an open top) reports `inf` here and the view
2977
+ # draws no fill, which is also what Blender does.
2978
+ **_node_socket_range(socket),
2979
+ }
2980
+
2981
+
2982
+ def _node_socket_range(socket):
2983
+ """The soft range of a scalar socket drawn as a SLIDER, when it is one.
2984
+
2985
+ BEING BOUNDED IS NOT THE TEST, and the reference frame is what says so.
2986
+ A first pass here reported the range for every bounded scalar, and the view
2987
+ drew IOR (soft range 1..3) half-filled at 1.500 -- while Blender's own
2988
+ shader editor leaves IOR FLAT and fills only Roughness and Alpha. The
2989
+ separating property is the SUBTYPE: `PROP_FACTOR` is what
2990
+ `uiItemR`/`node_socket_button_default` pass `UI_ITEM_R_SLIDER` for, so a
2991
+ factor draws as `UI_BTYPE_NUM_SLIDER` with a filled back and every other
2992
+ number draws as a plain `UI_BTYPE_NUM`.
2993
+
2994
+ Absent for everything else -- a vector, a colour, a string, a pointer, a
2995
+ boolean, a non-factor scalar, or a factor whose soft range is open. The
2996
+ view's rule stays exactly "a `softMin`/`softMax` pair means draw a
2997
+ slider"."""
2998
+ try:
2999
+ prop = socket.bl_rna.properties["default_value"]
3000
+ except (AttributeError, KeyError):
3001
+ return {}
3002
+ if getattr(prop, "type", None) not in ("FLOAT", "INT"):
3003
+ return {}
3004
+ if getattr(prop, "array_length", 0):
3005
+ return {}
3006
+ if getattr(prop, "subtype", None) != "FACTOR":
3007
+ return {}
3008
+ low = getattr(prop, "soft_min", None)
3009
+ high = getattr(prop, "soft_max", None)
3010
+ if low is None or high is None:
3011
+ return {}
3012
+ if not (math.isfinite(low) and math.isfinite(high)) or high <= low:
3013
+ return {}
3014
+ return {"softMin": float(low), "softMax": float(high)}
3015
+
3016
+
3017
+ def _node_row(node):
3018
+ """One node, as the node editor draws it.
3019
+
3020
+ `colorTag` is `Node.color_tag` (`rna_nodetree.cc:9480-9484`), which RNA
3021
+ reads from `bke::node_color_tag(*node)` -- the very value
3022
+ `node_get_colorid` (`node_draw.cc:1388-1434`) switches on to pick a header
3023
+ colour. So the header hue is a READ of the engine, not a table of ours.
3024
+
3025
+ `panels` is `Node.panel_states` (`rna_nodetree.cc:9430-9434`): per panel,
3026
+ the declaration's `persistent_uid` and `is_collapsed`, and NOTHING ELSE --
3027
+ no label, and no way to ask which sockets belong to it. That membership
3028
+ lives in the node's C++ declaration, which `node_update_basis_from_
3029
+ declaration` (`node_draw.cc:1086-1218`) walks and bpy does not expose.
3030
+ Measured 2026-09-19 on Blender 5.2.0 in the tab: `ShaderNodeBsdfPrincipled`
3031
+ answers 8 panel states (uids 9, 11, 18, 25, 27, 33, 37, 40), all collapsed,
3032
+ and not one of its 32 `NodeSocket`s carries a panel field.
3033
+
3034
+ RULED 2026-09-19: the MEMBERSHIP is traced from Blender's own `declare()`
3035
+ bodies instead, so what this door owes is the COLLAPSED STATE the trace
3036
+ cannot know -- per node, live, and changed under the person's hand. The
3037
+ states arrive IN ORDER, because Blender indexes `panel_states_array` by
3038
+ the declaration's panel INDEX (`node_draw.cc:1037`) and RNA's collection
3039
+ IS that array; the uid rides along so a disagreement is visible rather
3040
+ than silently mis-joined.
3041
+
3042
+ THE TRACE IS THE CONSUMER'S, NOT THIS PACKAGE'S, and the split is the
3043
+ point: `@volter/editor-blender`'s `blender-node-panels.source.mjs` reads a Blender
3044
+ CHECKOUT at the engine's pin and writes `blender.node-panels.json` beside
3045
+ the view that draws with it. Nothing of it belongs here -- this package is
3046
+ the engine and its wire, and it answers what the RUNNING engine knows.
3047
+
3048
+ `showOptions` is `Node.show_options` -> `NODE_OPTIONS`, the flag
3049
+ `add_flat_items_for_layout` (`node_draw.cc:739-746`) returns early on."""
3050
+ return {
3051
+ "name": node.name,
3052
+ "idname": node.bl_idname,
3053
+ "type": node.type,
3054
+ "typeLabel": node.bl_label,
3055
+ "label": node.label or None,
3056
+ "colorTag": getattr(node, "color_tag", "NONE"),
3057
+ # `Node.location` is the tree-space corner Blender lays out from
3058
+ # (`node_update_basis`: `dy = loc.y`, `node_draw.cc:1297`); `width` is
3059
+ # the value `NODE_WIDTH(node)` scales (`node_intern.hh:337`).
3060
+ "location": [float(node.location[0]), float(node.location[1])],
3061
+ "width": float(node.width),
3062
+ "collapsed": bool(node.hide),
3063
+ "muted": bool(node.mute),
3064
+ "selected": bool(node.select),
3065
+ "useCustomColor": bool(node.use_custom_color),
3066
+ "color": [float(channel) for channel in node.color],
3067
+ "parent": node.parent.name if node.parent is not None else None,
3068
+ # `NODE_DO_OUTPUT` through its one RNA spelling: `node_get_colorid`
3069
+ # gives an OUTPUT-class node the output colour only when it is the
3070
+ # active output (`node_draw.cc:1395-1401`).
3071
+ "activeOutput": bool(getattr(node, "is_active_output", False)),
3072
+ "panelCount": len(getattr(node, "panel_states", ())),
3073
+ "panels": [{"identifier": int(state.identifier),
3074
+ "collapsed": bool(state.is_collapsed)}
3075
+ for state in getattr(node, "panel_states", ())],
3076
+ "showOptions": bool(getattr(node, "show_options", True)),
3077
+ "inputs": [_node_socket(socket, index) for index, socket in enumerate(node.inputs)],
3078
+ "outputs": [_node_socket(socket, index) for index, socket in enumerate(node.outputs)],
3079
+ }
3080
+
3081
+
3082
+ def _node_tree_of(path, material):
3083
+ """WHICH TREE. Either an explicit RNA address (so a `vgai eval` can open a
3084
+ world's or a group's tree with the engine's own spelling), or a material by
3085
+ name, or -- given neither -- the active object's active material, which is
3086
+ what Blender's own Shading header resolves (`space_node.py:89-93`,
3087
+ `template_ID(ob, "active_material")`)."""
3088
+ if path:
3089
+ return _rna_resolve(path), path, None
3090
+ if material:
3091
+ mat = bpy.data.materials.get(material)
3092
+ if mat is None:
3093
+ raise ValueError("The engine holds no material named %r" % (material,))
3094
+ else:
3095
+ obj = bpy.context.view_layer.objects.active
3096
+ mat = getattr(obj, "active_material", None) if obj is not None else None
3097
+ if mat is None:
3098
+ return None, None, None
3099
+ if not mat.use_nodes:
3100
+ return None, None, mat
3101
+ return mat.node_tree, '%s.node_tree' % (_rna_address(mat),), mat
3102
+
3103
+
3104
+ def rna_node_tree(path=None, material=None):
3105
+ """ONE NODE TREE, as the node editor would draw it."""
3106
+ tree, tree_path, mat = _node_tree_of(path, material)
3107
+ if tree is None:
3108
+ # NOT AN ERROR. Blender's node editor with no tree draws its flat
3109
+ # `TH_BACK` clear and nothing else -- `node_draw_space`'s whole else
3110
+ # branch is one `draw_nodespace_back_pix` call with no text in it
3111
+ # (`node_draw.cc:4849-4853`) -- so "no tree" is a state, not a failure.
3112
+ return {
3113
+ "path": None,
3114
+ "material": mat.name if mat is not None else None,
3115
+ "useNodes": bool(mat.use_nodes) if mat is not None else None,
3116
+ "nodes": [],
3117
+ "links": [],
3118
+ }
3119
+ active = tree.nodes.active
3120
+ return {
3121
+ "path": tree_path,
3122
+ "type": tree.bl_idname,
3123
+ "typeLabel": tree.bl_label,
3124
+ "material": mat.name if mat is not None else None,
3125
+ "useNodes": True,
3126
+ "active": active.name if active is not None else None,
3127
+ "nodes": [_node_row(node) for node in tree.nodes],
3128
+ # A LINK NAMES ITS SOCKETS BY IDENTIFIER, not by index: a node's socket
3129
+ # list is stable under `enabled`, so the identifier is what survives a
3130
+ # node changing mode. `is_muted` is `NodeLink.is_muted`.
3131
+ "links": [{"fromNode": link.from_node.name,
3132
+ "fromSocket": link.from_socket.identifier,
3133
+ "toNode": link.to_node.name,
3134
+ "toSocket": link.to_socket.identifier,
3135
+ "muted": bool(link.is_muted),
3136
+ "valid": bool(link.is_valid)}
3137
+ for link in tree.links],
3138
+ }
3139
+
3140
+
3141
+ def _uv_object_of(object_name):
3142
+ """WHOSE UVs. The named object, or -- given none -- the view layer's ACTIVE
3143
+ object, which is what Blender's UV editor draws
3144
+ (`MeshUVs::begin_sync` takes `state.object_mode` and the edit-mode object
3145
+ set; outside edit mode the active object is the only subject there is).
3146
+ A non-mesh active object is not an error: it is the empty state."""
3147
+ if object_name:
3148
+ obj = bpy.data.objects.get(object_name)
3149
+ if obj is None:
3150
+ raise ValueError("The engine holds no object named %r" % (object_name,))
3151
+ else:
3152
+ obj = bpy.context.view_layer.objects.active
3153
+ if obj is None or obj.type != "MESH":
3154
+ return None
3155
+ return obj
3156
+
3157
+
3158
+ def rna_uv_layout(object_name=None, uv_layer=None):
3159
+ """ONE MESH'S UV LAYOUT, as Blender's UV editor would draw it.
3160
+
3161
+ WHY A DOOR AND NOT THE GENERIC VIEW. `rna_view` answers a struct at a time
3162
+ and reports a collection as a COUNT, and a UV layout is
3163
+ `len(mesh.loops)` two-float corners plus a triangulation -- 8,008 corners
3164
+ over 31 meshes in `arena-weapons.blend`, measured 2026-09-19. The
3165
+ per-corner arrays therefore cross as BASE64 typed-array bytes, exactly as
3166
+ I4's weights do, and every scalar beside them is a `bl_rna` property the
3167
+ generic door also answers.
3168
+
3169
+ WHAT IT IS NOT GATED ON. Blender draws UVs only when
3170
+ `space_mode_is_uv && object_mode_is_edit` (`overlay_mesh.hh:593`), and both
3171
+ halves of that are its UI layer's -- a space this engine has none of, and a
3172
+ mode an inspection surface does not enter (orchestrator ruling 1,
3173
+ 2026-09-19: "inspection is not mode-gated"). So this reads the DATA in
3174
+ whatever mode the engine is in and reports `mode` beside it, and the view
3175
+ names the Blender mode the same picture would need.
3176
+
3177
+ THE SELECTION FLAGS ARE REPORTED WHERE THE DATA CARRIES THEM, and at this
3178
+ pin an evaluated mesh usually carries none: `MeshUVLoopLayer`
3179
+ (`rna_mesh.cc:2380-2456`) declares `uv`, `pin`, `name`, `active`,
3180
+ `active_render` and `active_clone` -- and NO vertex or edge selection,
3181
+ because UV selection lives in the BMesh an edit-mode session holds. `pin`
3182
+ IS declared, so it is answered; everything else draws unselected, which is
3183
+ the second half of the same ruling.
3184
+ """
3185
+ obj = _uv_object_of(object_name)
3186
+ if obj is None:
3187
+ return {"object": None, "mesh": None, "layers": [], "active": None,
3188
+ "mode": bpy.context.mode, "loops": 0, "polygons": 0}
3189
+ mesh = obj.data
3190
+ layers = [layer.name for layer in mesh.uv_layers]
3191
+ if uv_layer:
3192
+ layer = mesh.uv_layers.get(uv_layer)
3193
+ if layer is None:
3194
+ raise ValueError("%r has no UV map named %r -- it has: %s"
3195
+ % (obj.name, uv_layer, ", ".join(layers) or "none"))
3196
+ else:
3197
+ layer = mesh.uv_layers.active
3198
+ header = {
3199
+ "object": obj.name,
3200
+ "mesh": mesh.name,
3201
+ "path": _rna_address(mesh),
3202
+ "layers": layers,
3203
+ "active": layer.name if layer is not None else None,
3204
+ "mode": bpy.context.mode,
3205
+ "loops": len(mesh.loops),
3206
+ "polygons": len(mesh.polygons),
3207
+ "materials": [m.name if m else None for m in mesh.materials],
3208
+ }
3209
+ if layer is None:
3210
+ return header
3211
+ corners = len(mesh.loops)
3212
+ uvs = array.array("f", bytes(corners * 8))
3213
+ layer.uv.foreach_get("vector", uvs)
3214
+ # THE TRIANGULATION IS BLENDER'S OWN. `calc_loop_triangles` is what the
3215
+ # draw extraction uses, and `loop_triangles[i].loops` indexes the same
3216
+ # corner array the UVs came from -- so a triangle is three UVs with no
3217
+ # fan of ours in the middle. An n-gon's fan is Blender's, not a guess.
3218
+ mesh.calc_loop_triangles()
3219
+ tris = array.array("I", bytes(len(mesh.loop_triangles) * 12))
3220
+ mesh.loop_triangles.foreach_get("loops", tris)
3221
+ # THE FACE LOOPS, so an EDGE can be drawn. A polygon's corners are
3222
+ # contiguous (`loop_start` .. `loop_start + loop_total`), which is what
3223
+ # makes a face's UV outline a walk rather than a lookup.
3224
+ starts = array.array("I", bytes(len(mesh.polygons) * 4))
3225
+ totals = array.array("I", bytes(len(mesh.polygons) * 4))
3226
+ mesh.polygons.foreach_get("loop_start", starts)
3227
+ mesh.polygons.foreach_get("loop_total", totals)
3228
+ pins = None
3229
+ try:
3230
+ raw = array.array("b", bytes(corners))
3231
+ layer.pin.foreach_get("value", raw)
3232
+ if any(raw):
3233
+ pins = base64.b64encode(raw.tobytes()).decode("ascii")
3234
+ except Exception: # noqa: BLE001
3235
+ pins = None
3236
+ us = uvs[0::2]
3237
+ vs = uvs[1::2]
3238
+ header.update({
3239
+ "uvBase64": base64.b64encode(uvs.tobytes()).decode("ascii"),
3240
+ "triangleBase64": base64.b64encode(tris.tobytes()).decode("ascii"),
3241
+ "loopStartBase64": base64.b64encode(starts.tobytes()).decode("ascii"),
3242
+ "loopTotalBase64": base64.b64encode(totals.tobytes()).decode("ascii"),
3243
+ "triangles": len(mesh.loop_triangles),
3244
+ "pinBase64": pins,
3245
+ # SELECTION, named rather than assumed: what the data carries.
3246
+ "selection": None,
3247
+ "bounds": [min(us), min(vs), max(us), max(vs)] if corners else [0.0, 0.0, 1.0, 1.0],
3248
+ # THE IMAGE BEHIND THE TILE is the active material's image texture, and
3249
+ # `buttons_texture.cc`'s own walk is the precedent for asking the node
3250
+ # tree rather than the material: `TEX_IMAGE` is where an image is.
3251
+ "image": _uv_backdrop_image(obj),
3252
+ })
3253
+ return header
3254
+
3255
+
3256
+ def _uv_backdrop_image(obj):
3257
+ """THE IMAGE THE UV EDITOR WOULD SHOW BEHIND THE TILE, if any. Blender's
3258
+ own header resolves it from the space (`SpaceImage.image`) and the UV
3259
+ editor's auto-set picks the active material's active image texture node
3260
+ (`ED_space_image_auto_set`, `space_image.cc:60-101`). There is no
3261
+ `SpaceImage` here, so the second half is the whole rule: the active
3262
+ material's node tree's active `TEX_IMAGE`."""
3263
+ mat = getattr(obj, "active_material", None)
3264
+ if mat is None or not mat.use_nodes or mat.node_tree is None:
3265
+ return None
3266
+ nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image is not None]
3267
+ if not nodes:
3268
+ return None
3269
+ node = mat.node_tree.nodes.active
3270
+ chosen = node if node in nodes else nodes[0]
3271
+ image = chosen.image
3272
+ return {"name": image.name, "width": image.size[0], "height": image.size[1],
3273
+ "source": image.source, "hasData": bool(image.has_data)}
3274
+
3275
+
3276
+ # ------------------------------------------------------- the rig and its clip
3277
+ #
3278
+ # WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20). Blender
3279
+ # holds the animation as DATA; three.js PLAYS it. A Timeline that asked Blender
3280
+ # to re-evaluate the depsgraph and re-export the columns once per frame would
3281
+ # be the wrong architecture -- so these two doors hand the tab everything it
3282
+ # needs to build a `THREE.SkinnedMesh` + `Skeleton` + `AnimationMixer` ONCE,
3283
+ # and the scrub, the playback and the frame counter after that are three.js's
3284
+ # with ZERO calls back into this process.
3285
+ #
3286
+ # WHAT THE BIND POSE IS, and why it is not the rest pose. The export door runs
3287
+ # with `evaluate: True`, so the mesh columns the presenter already holds are
3288
+ # the EVALUATED mesh -- the armature modifier included -- which means they are
3289
+ # the deformed mesh at whatever frame Blender is sitting on. Binding a skeleton
3290
+ # whose bones are in that SAME pose makes the skinning an identity there
3291
+ # (`skinMatrix = sum(w_i * B_i * B_i^-1) = I`), so the picture at the bind
3292
+ # frame is byte-for-byte the frame Blender presented, and every other frame is
3293
+ # three.js's own evaluation of the same skin. That is what lets Blender's
3294
+ # `frame_current` stand still for the whole of playback.
3295
+
3296
+
3297
+ def _rig_armature_of(obj):
3298
+ """The armature that deforms this mesh, through `Object.find_armature()` --
3299
+ which is Blender's own answer (`BKE_modifiers_is_deformed_by_armature`
3300
+ plus the ARMATURE-parent case), not a modifier walk of ours."""
3301
+ if obj is None or obj.type != "MESH" or obj.data is None:
3302
+ return None
3303
+ return obj.find_armature()
3304
+
3305
+
3306
+ def _pose_bones_parents_first(arm_obj):
3307
+ """Every pose bone, parents before children. `Object.pose.bones` is already
3308
+ in hierarchy order at this pin, but three.js's `Skeleton` is built by
3309
+ INDEX and a child whose parent has no index yet cannot be parented, so the
3310
+ order is made explicit rather than assumed."""
3311
+ order = []
3312
+ seen = set()
3313
+
3314
+ def walk(pchan):
3315
+ if pchan.name in seen:
3316
+ return
3317
+ parent = pchan.parent
3318
+ if parent is not None:
3319
+ walk(parent)
3320
+ if pchan.name in seen:
3321
+ return
3322
+ seen.add(pchan.name)
3323
+ order.append(pchan)
3324
+
3325
+ for pchan in arm_obj.pose.bones:
3326
+ walk(pchan)
3327
+ return order
3328
+
3329
+
3330
+ def _matrix_rows(matrix):
3331
+ return [[float(v) for v in row] for row in matrix]
3332
+
3333
+
3334
+ def rna_rig(object_name=None):
3335
+ """EVERY SKIN BINDING THE SCENE NEEDS -- or one named mesh's.
3336
+
3337
+ A LIST, not a singleton, and that is what the PRESENTER's question is: a
3338
+ presented frame carries every object at once, and the presenter has to know
3339
+ which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
3340
+ per object would be one round trip per mesh; asking by NAME stays available
3341
+ for a `vgai eval` that wants to read one.
3342
+ """
3343
+ scene_frame = int(bpy.context.scene.frame_current)
3344
+ if object_name:
3345
+ obj = bpy.data.objects.get(object_name)
3346
+ if obj is None:
3347
+ raise ValueError("The engine holds no object named %r" % (object_name,))
3348
+ subjects = [obj]
3349
+ else:
3350
+ subjects = [obj for obj in bpy.context.view_layer.objects
3351
+ if obj.type == "MESH" and obj.data is not None
3352
+ and obj.find_armature() is not None]
3353
+ return {"frame": scene_frame, "named": object_name,
3354
+ "rigs": [_rig_of(obj) for obj in subjects]}
3355
+
3356
+
3357
+ def _rig_of(obj):
3358
+ """ONE MESH'S SKIN BINDING: the armature's bones, and up to four weighted
3359
+ bone influences per Blender vertex.
3360
+
3361
+ WHY A DOOR AND NOT THE GENERIC VIEW, the same answer `rna_uv_layout` gives:
3362
+ `rna_view` reports a collection as a COUNT, and a skin binding is
3363
+ `len(mesh.vertices)` x 4 pairs -- 1,152 vertices is 4,608 of them on the
3364
+ smallest probe. They cross as base64 typed-array bytes, the shape I4's
3365
+ weights established: `skinIndexBase64` Uint16 (`vertices * 4`) and
3366
+ `skinWeightBase64` Float32 (`vertices * 4`), normalized so the four sum to
3367
+ one.
3368
+
3369
+ THE INDICES ARE BLENDER VERTEX INDICES, not drawn-vertex ones. The draw
3370
+ splits a Blender vertex into as many drawn vertices as its corners need,
3371
+ and `DrawArrays.sourceVertex` is the map -- so the presenter EXPANDS these
3372
+ arrays through it (`blender-runtime-skin.ts`), exactly as I4's weight
3373
+ overlay expands its ramp.
3374
+
3375
+ TWO MATRICES PER BONE, both in the armature OBJECT's space and row-major as
3376
+ four rows: `rest` is `Bone.matrix_local` (the armature's rest pose, what
3377
+ `pose.bones[...]` is measured against) and `pose` is `PoseBone.matrix` --
3378
+ `pchan->pose_mat`, the SAME matrix I4's bone overlay draws. `pose` is the
3379
+ BIND pose, because it is the pose the exported columns were evaluated at.
3380
+ """
3381
+ if obj is None or obj.type != "MESH" or obj.data is None:
3382
+ return {"object": obj.name if obj is not None else None, "armature": None,
3383
+ "bones": [], "vertexCount": 0,
3384
+ "reason": "Only a mesh object carries a skin binding."}
3385
+ mesh = obj.data
3386
+ arm_obj = _rig_armature_of(obj)
3387
+ header = {
3388
+ "object": obj.name,
3389
+ "mesh": mesh.name,
3390
+ "vertexCount": len(mesh.vertices),
3391
+ "frame": int(bpy.context.scene.frame_current),
3392
+ "mode": bpy.context.mode,
3393
+ }
3394
+ if arm_obj is None:
3395
+ header.update({"armature": None, "bones": [],
3396
+ "reason": "%r is deformed by no armature -- `Object.find_armature()` "
3397
+ "answers none, so it presents as an ordinary Mesh." % (obj.name,)})
3398
+ return header
3399
+ order = _pose_bones_parents_first(arm_obj)
3400
+ index_of = {pchan.name: i for i, pchan in enumerate(order)}
3401
+ header.update({
3402
+ "armature": arm_obj.name,
3403
+ "armatureData": arm_obj.data.name,
3404
+ # THE CONSTRAINT COUNT IS REPORTED because the CLIP is derived from the
3405
+ # F-Curves alone (`rna_action_clip`) while this bind pose is read live
3406
+ # off `PoseBone.matrix`. For a constraint-free rig the two agree by
3407
+ # construction; a constrained bone's pose is Blender's solver's and the
3408
+ # clip cannot reproduce it, so the view says so rather than drifting.
3409
+ "constrainedBones": sorted(p.name for p in order if len(p.constraints)),
3410
+ "bones": [{
3411
+ "name": pchan.name,
3412
+ "parent": pchan.parent.name if pchan.parent is not None else None,
3413
+ "rest": _matrix_rows(pchan.bone.matrix_local),
3414
+ "pose": _matrix_rows(pchan.matrix),
3415
+ "length": float(pchan.bone.length),
3416
+ "deform": bool(pchan.bone.use_deform),
3417
+ "connected": bool(pchan.bone.use_connect),
3418
+ } for pchan in order],
3419
+ })
3420
+ group_to_bone = {}
3421
+ unmapped = []
3422
+ for group in obj.vertex_groups:
3423
+ bone = index_of.get(group.name)
3424
+ if bone is None:
3425
+ unmapped.append(group.name)
3426
+ else:
3427
+ group_to_bone[int(group.index)] = bone
3428
+ count = len(mesh.vertices)
3429
+ # FOUR INFLUENCES, which is three.js's `skinIndex`/`skinWeight` shape and
3430
+ # also glTF's; a vertex with more is truncated to its four heaviest and
3431
+ # RENORMALIZED, and the count of such vertices is reported rather than
3432
+ # quietly dropped.
3433
+ indices = array.array("H", bytes(count * 8))
3434
+ weights = array.array("f", bytes(count * 16))
3435
+ truncated = 0
3436
+ unweighted = 0
3437
+ most = 0
3438
+ for i, vertex in enumerate(mesh.vertices):
3439
+ pairs = []
3440
+ for element in vertex.groups:
3441
+ bone = group_to_bone.get(int(element.group))
3442
+ if bone is None:
3443
+ continue
3444
+ value = float(element.weight)
3445
+ if value > 0.0:
3446
+ pairs.append((value, bone))
3447
+ most = max(most, len(pairs))
3448
+ if len(pairs) > 4:
3449
+ truncated += 1
3450
+ pairs.sort(reverse=True)
3451
+ pairs = pairs[:4]
3452
+ total = sum(value for value, _ in pairs)
3453
+ if total <= 0.0:
3454
+ unweighted += 1
3455
+ continue
3456
+ for slot, (value, bone) in enumerate(pairs):
3457
+ indices[i * 4 + slot] = bone
3458
+ weights[i * 4 + slot] = value / total
3459
+ header.update({
3460
+ "skinIndexBase64": base64.b64encode(indices.tobytes()).decode("ascii"),
3461
+ "skinWeightBase64": base64.b64encode(weights.tobytes()).decode("ascii"),
3462
+ "maxInfluences": most,
3463
+ "truncatedVertices": truncated,
3464
+ "unweightedVertices": unweighted,
3465
+ "unmappedGroups": unmapped,
3466
+ })
3467
+ return header
3468
+
3469
+
3470
+ def _action_channelbag(action, slot_handle):
3471
+ """THE LAYERED ACTION'S F-CURVES. At this pin an `Action` HAS NO
3472
+ `.fcurves` -- measured 2026-09-19 and again here: `hasattr(action,
3473
+ 'fcurves')` is False. They live at
3474
+ `action.layers[i].strips[j].channelbags[k].fcurves`, and WHICH channelbag
3475
+ is decided by the SLOT the animated object uses
3476
+ (`Object.animation_data.action_slot`). The legacy attribute is still read
3477
+ first for a build where it exists, which is the same two-branch shape the
3478
+ rest of this file uses for a moved API."""
3479
+ legacy = getattr(action, "fcurves", None)
3480
+ if legacy is not None:
3481
+ return list(legacy), "legacy"
3482
+ for layer in getattr(action, "layers", ()):
3483
+ for strip in getattr(layer, "strips", ()):
3484
+ for bag in (getattr(strip, "channelbags", None) or ()):
3485
+ if slot_handle is None or int(getattr(bag, "slot_handle", -1)) == int(slot_handle):
3486
+ return list(bag.fcurves), "layered"
3487
+ return [], "none"
3488
+
3489
+
3490
+ _BONE_PATH = re.compile(r'^pose\.bones\["(.+)"\]\.(location|rotation_quaternion|'
3491
+ r'rotation_euler|rotation_axis_angle|scale)$')
3492
+
3493
+
3494
+ def _key_columns(curves):
3495
+ """THE SUMMARY ROW'S COLUMNS for one set of F-Curves: the union of every
3496
+ curve's key frames, which is what `summary_to_keylist`
3497
+ (`keyframes_keylist.cc:1019`) merges. Each column carries its TYPE
3498
+ (`Keyframe.type`, the shape's size multiplier, `keyframes_draw.cc:62-85`)
3499
+ and whether any contributing key is selected (`ActKeyColumn.sel`) --
3500
+ Blender's two colour axes for a diamond."""
3501
+ columns = {}
3502
+ for fcurve in curves:
3503
+ for key in fcurve.keyframe_points:
3504
+ frame = round(float(key.co[0]), 4)
3505
+ record = columns.setdefault(frame, {"frame": frame, "type": key.type, "select": False})
3506
+ record["select"] = record["select"] or bool(key.select_control_point)
3507
+ # EXTREME wins over KEYFRAME the way Blender's own merge does:
3508
+ # `nupdate_ak_bezt` keeps the "most significant" type
3509
+ # (`keyframes_keylist.cc`, `KEYFRAME_STATE` ordering).
3510
+ if _KEY_TYPE_RANK.get(key.type, 0) > _KEY_TYPE_RANK.get(record["type"], 0):
3511
+ record["type"] = key.type
3512
+ return columns
3513
+
3514
+
3515
+ def _summary_objects():
3516
+ """EVERY ANIMATED OBJECT IN THE VIEW LAYER, with its own key columns and
3517
+ whether it is SELECTED -- which is the whole of what a Timeline's
3518
+ `show_keys_from_selected_only` filter decides between.
3519
+
3520
+ Blender's Timeline takes its only-selected flag from the SCENE
3521
+ (`ac->scene->flag & SCE_KEYS_NO_SELONLY`, `anim_filter.cc:254-270`) rather
3522
+ than from the dope sheet, and the test it then applies per object is
3523
+ `(ADS_FILTER_ONLYSEL) && !(base.flag & BASE_SELECTED) -> skip`
3524
+ (`anim_filter.cc:2307`). `Object.select_get()` is that base flag through
3525
+ RNA.
3526
+
3527
+ THE DOOR REPORTS, THE VIEW DECIDES. Which side of the filter a Timeline is
3528
+ on is VIEW state (a headless Blender has no `SpaceDopeSheet`, and no scene
3529
+ flag we would be entitled to write), so this ships BOTH halves -- every
3530
+ animated object's columns and its selection -- and the view draws the union
3531
+ it wants. One round trip either way.
3532
+ """
3533
+ out = []
3534
+ for obj in bpy.context.view_layer.objects:
3535
+ adt = getattr(obj, "animation_data", None)
3536
+ action = getattr(adt, "action", None) if adt is not None else None
3537
+ if action is None:
3538
+ continue
3539
+ slot = getattr(adt, "action_slot", None)
3540
+ curves, _shape = _action_channelbag(action, getattr(slot, "handle", None))
3541
+ columns = _key_columns(curves)
3542
+ out.append({
3543
+ "object": obj.name,
3544
+ "action": action.name,
3545
+ "selected": bool(obj.select_get()),
3546
+ "keyframes": [columns[frame] for frame in sorted(columns)],
3547
+ })
3548
+ return out
3549
+
3550
+
3551
+ def rna_action_clip(object_name=None, bake=True):
3552
+ """ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
3553
+ every integer frame of the action's own range.
3554
+
3555
+ WHAT CROSSES, and why it is a BAKE rather than the raw keys. Blender's
3556
+ channels are `PoseBone.location/rotation_*/scale` in the bone's own REST
3557
+ space (`pchan->chan_mat`), while three.js's `Bone` carries a LOCAL
3558
+ transform relative to its PARENT BONE. The relation is
3559
+ `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so no
3560
+ per-channel re-labelling can turn one into the other -- the matrices have
3561
+ to be composed. Composing them per KEY would then also have to reproduce
3562
+ Blender's Bezier handles between keys, so the honest, one-code-path answer
3563
+ is to SAMPLE at every integer frame and ship LINEAR tracks: exact at every
3564
+ frame a Timeline can scrub to, linear in between, and stated as the
3565
+ justified difference rather than implied.
3566
+
3567
+ THE FRAME IS NEVER MOVED to do it. `FCurve.evaluate(frame)` reads the curve
3568
+ without touching `scene.frame_current` or the depsgraph, which is what
3569
+ makes this door cheap and what keeps bpy's own frame where the person left
3570
+ it.
3571
+
3572
+ A CONSTANT TRACK IS TWO KEYS. A rig's channels are overwhelmingly still --
3573
+ an untouched bone contributes three tracks of identical samples -- so a
3574
+ track whose every sample equals its first is shipped as its first and last
3575
+ only. Measured on the probe below.
3576
+ """
3577
+ if object_name:
3578
+ obj = bpy.data.objects.get(object_name)
3579
+ if obj is None:
3580
+ raise ValueError("The engine holds no object named %r" % (object_name,))
3581
+ else:
3582
+ obj = bpy.context.view_layer.objects.active
3583
+ arm_obj = obj if (obj is not None and obj.type == "ARMATURE") else _rig_armature_of(obj)
3584
+ if arm_obj is None:
3585
+ # THE ACTIVE OBJECT NEED NOT BE THE ANIMATED ONE, and Blender's own
3586
+ # Timeline says so: its summary row is the DOPE SHEET's filtered set,
3587
+ # which is every selected object's animation. With nothing rigged
3588
+ # selected, the one armature in the view layer is the honest subject
3589
+ # and there is no guessing to do when there is exactly one.
3590
+ rigged = [one for one in bpy.context.view_layer.objects
3591
+ if one.type == "ARMATURE" and one.animation_data is not None
3592
+ and one.animation_data.action is not None]
3593
+ if len(rigged) == 1:
3594
+ arm_obj = rigged[0]
3595
+ scene = bpy.context.scene
3596
+ fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
3597
+ header = {
3598
+ "object": obj.name if obj is not None else None,
3599
+ "armature": arm_obj.name if arm_obj is not None else None,
3600
+ # THE SCENE'S NAME, because the ONE write the Timeline makes
3601
+ # (`scene.frame_current`, on pause and at scrub-end) goes through
3602
+ # `rna_set`, and an RNA path there is the engine's own address: it
3603
+ # starts at `bpy.data.` and `bpy.context.scene` is refused by name.
3604
+ "scene": scene.name,
3605
+ "frameCurrent": int(scene.frame_current),
3606
+ "frameStart": int(scene.frame_start),
3607
+ "frameEnd": int(scene.frame_end),
3608
+ "fps": fps,
3609
+ "action": None,
3610
+ "slot": None,
3611
+ "tracks": [],
3612
+ "keyframes": [],
3613
+ # EVERY ANIMATED OBJECT AND ITS SELECTION, unconditionally -- the
3614
+ # Timeline's summary row is filtered by SELECTION in Blender
3615
+ # (`show_keys_from_selected_only`) and the view is what decides which
3616
+ # side of that filter it is on, so the door answers both halves even
3617
+ # when the subject below turns out to be nothing at all.
3618
+ "summary": _summary_objects(),
3619
+ }
3620
+ if arm_obj is None or arm_obj.animation_data is None or arm_obj.animation_data.action is None:
3621
+ header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
3622
+ "and an empty summary row, which is Blender's own empty state.")
3623
+ return header
3624
+ adt = arm_obj.animation_data
3625
+ action = adt.action
3626
+ slot = getattr(adt, "action_slot", None)
3627
+ curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
3628
+ header.update({
3629
+ "action": action.name,
3630
+ "slot": getattr(slot, "name_display", None),
3631
+ "channels": shape,
3632
+ "fcurves": len(curves),
3633
+ "keys": sum(len(fc.keyframe_points) for fc in curves),
3634
+ })
3635
+ if not curves:
3636
+ header["reason"] = ("%r holds no F-Curves for this object's slot, so there is nothing to "
3637
+ "play." % (action.name,))
3638
+ return header
3639
+ columns = _key_columns(curves)
3640
+ header["keyframes"] = [columns[frame] for frame in sorted(columns)]
3641
+ if not bake:
3642
+ return header
3643
+ frames = sorted(columns)
3644
+ first = int(math.floor(frames[0]))
3645
+ last = int(math.ceil(frames[-1]))
3646
+ if last <= first:
3647
+ last = first + 1
3648
+ header["clipStart"] = first
3649
+ header["clipEnd"] = last
3650
+ channels = {}
3651
+ for fcurve in curves:
3652
+ match = _BONE_PATH.match(fcurve.data_path)
3653
+ if match is None:
3654
+ continue
3655
+ bone, prop = match.group(1), match.group(2)
3656
+ channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
3657
+ if not channels:
3658
+ header["reason"] = ("%r animates no pose bone -- every F-Curve is on a data path this "
3659
+ "door does not play (object transform, shape keys, a material). The "
3660
+ "Timeline still draws its keys." % (action.name,))
3661
+ return header
3662
+ rest_relative = {}
3663
+ for pchan in _pose_bones_parents_first(arm_obj):
3664
+ local = pchan.bone.matrix_local
3665
+ parent = pchan.parent
3666
+ rest_relative[pchan.name] = (parent.bone.matrix_local.inverted_safe() @ local
3667
+ if parent is not None else local.copy())
3668
+ times = [(frame - first) / fps for frame in range(first, last + 1)]
3669
+ tracks = []
3670
+ unplayed = []
3671
+ for bone in sorted({name for name, _ in channels}):
3672
+ pchan = arm_obj.pose.bones.get(bone)
3673
+ if pchan is None:
3674
+ unplayed.append(bone)
3675
+ continue
3676
+ rest = rest_relative[bone]
3677
+ rotation_mode = pchan.rotation_mode
3678
+ positions, quaternions, scales = [], [], []
3679
+ previous = None
3680
+ for frame in range(first, last + 1):
3681
+ location = _sample(channels.get((bone, "location")), pchan.location, frame)
3682
+ scale = _sample(channels.get((bone, "scale")), pchan.scale, frame, default=1.0)
3683
+ if rotation_mode == "QUATERNION":
3684
+ quat = mathutils.Quaternion(
3685
+ _sample(channels.get((bone, "rotation_quaternion")),
3686
+ pchan.rotation_quaternion, frame, size=4, default=None,
3687
+ identity=(1.0, 0.0, 0.0, 0.0)))
3688
+ elif rotation_mode == "AXIS_ANGLE":
3689
+ raw = _sample(channels.get((bone, "rotation_axis_angle")),
3690
+ pchan.rotation_axis_angle, frame, size=4, default=None,
3691
+ identity=(0.0, 0.0, 1.0, 0.0))
3692
+ quat = mathutils.Quaternion(raw[1:4], raw[0])
3693
+ else:
3694
+ quat = mathutils.Euler(
3695
+ _sample(channels.get((bone, "rotation_euler")), pchan.rotation_euler, frame),
3696
+ rotation_mode).to_quaternion()
3697
+ basis = mathutils.Matrix.LocRotScale(mathutils.Vector(location), quat,
3698
+ mathutils.Vector(scale))
3699
+ position, rotation, scaling = (rest @ basis).decompose()
3700
+ # QUATERNION CONTINUITY, and it is not cosmetic: three's
3701
+ # `QuaternionLinearInterpolant` takes the shorter arc between
3702
+ # NEIGHBOURING samples, so one sign flip in the middle of a bake
3703
+ # spins the bone the long way round for a frame.
3704
+ if previous is not None and rotation.dot(previous) < 0.0:
3705
+ rotation.negate()
3706
+ previous = rotation.copy()
3707
+ positions.extend((position.x, position.y, position.z))
3708
+ quaternions.extend((rotation.x, rotation.y, rotation.z, rotation.w))
3709
+ scales.extend((scaling.x, scaling.y, scaling.z))
3710
+ tracks.extend(_track(bone, "position", times, positions, 3))
3711
+ tracks.extend(_track(bone, "quaternion", times, quaternions, 4))
3712
+ tracks.extend(_track(bone, "scale", times, scales, 3))
3713
+ header.update({
3714
+ "tracks": tracks,
3715
+ "duration": (last - first) / fps,
3716
+ "sampled": len(times),
3717
+ "unplayedBones": unplayed,
3718
+ })
3719
+ return header
3720
+
3721
+
3722
+ # `Keyframe.type`'s significance order, mirrored from the `KEYFRAME_STATE`
3723
+ # merge in `keyframes_keylist.cc` -- a column drawn from several curves takes
3724
+ # the most significant type any of them carries.
3725
+ _KEY_TYPE_RANK = {"JITTER": 1, "GENERATED": 2, "MOVING_HOLD": 3, "BREAKDOWN": 4,
3726
+ "KEYFRAME": 5, "EXTREME": 6}
3727
+
3728
+
3729
+ def _sample(group, current, frame, size=3, default=0.0, identity=None):
3730
+ """One channel's value at `frame`: each component's own F-Curve where there
3731
+ is one, and the pose channel's CURRENT value where there is not -- which is
3732
+ what Blender does too (an unkeyed component simply keeps its value)."""
3733
+ fallback = list(identity) if identity is not None else [
3734
+ (current[i] if current is not None and i < len(current) else default) for i in range(size)]
3735
+ if identity is not None and current is not None and group is not None:
3736
+ fallback = [float(v) for v in current]
3737
+ if group is None:
3738
+ return fallback
3739
+ out = list(fallback)
3740
+ for index, fcurve in group.items():
3741
+ if 0 <= index < len(out):
3742
+ out[index] = float(fcurve.evaluate(frame))
3743
+ return out
3744
+
3745
+
3746
+ def _track(bone, prop, times, values, stride):
3747
+ """One three.js keyframe track, or two keys when nothing moves.
3748
+
3749
+ The arrays cross as base64 Float32 (`timeBase64`, `valueBase64`), the shape
3750
+ every large payload in this file takes."""
3751
+ count = len(times)
3752
+ constant = True
3753
+ for i in range(1, count):
3754
+ for c in range(stride):
3755
+ if abs(values[i * stride + c] - values[c]) > 1e-6:
3756
+ constant = False
3757
+ break
3758
+ if not constant:
3759
+ break
3760
+ if constant:
3761
+ if count == 0:
3762
+ return []
3763
+ times = [times[0], times[-1]]
3764
+ values = values[0:stride] * 2
3765
+ return [{
3766
+ "bone": bone,
3767
+ "property": prop,
3768
+ "stride": stride,
3769
+ "count": len(times),
3770
+ "constant": constant,
3771
+ "timeBase64": base64.b64encode(array.array("f", times).tobytes()).decode("ascii"),
3772
+ "valueBase64": base64.b64encode(array.array("f", values).tobytes()).decode("ascii"),
3773
+ }]
3774
+
3775
+
3776
+ def rna_outliner(selected=None):
3777
+ """BLENDER'S VIEW LAYER TREE for the session's scene.
3778
+
3779
+ `TreeDisplayViewLayer::build_tree` under the default filters: one Scene
3780
+ Collection row, the view layer's layer collections nested under it, each
3781
+ collection's objects, and then the object parent hierarchy folded in
3782
+ (`add_view_layer`, `add_layer_collections_recursive`,
3783
+ `add_layer_collection_objects`, `ObjectsChildrenBuilder`).
3784
+
3785
+ `selected` is the CALLER'S selection -- our viewport's, by object name --
3786
+ for the same reason `rna_context` takes the object it is looking at:
3787
+ reading a selection must never WRITE the engine's, which is a mutation the
3788
+ document would save."""
3789
+ scene = bpy.context.scene
3790
+ view_layer = bpy.context.view_layer
3791
+ scene_path = _rna_address(scene)
3792
+ view_layer_path = '%s.view_layers["%s"]' % (
3793
+ scene_path, bpy.utils.escape_identifier(view_layer.name))
3794
+ root = view_layer.layer_collection
3795
+ chosen = set(selected or ())
3796
+ engine_active = view_layer.objects.active
3797
+ active = engine_active.name if engine_active is not None else None
3798
+ seen = {}
3799
+ # Every object row, with the row it sits under and the COLLECTION row it
3800
+ # belongs to -- what `ObjectsChildrenBuilder`'s map holds, and what the
3801
+ # parent walk below moves.
3802
+ placements = {}
3803
+
3804
+ def place(obj, row, collection_row):
3805
+ placements.setdefault(obj.name, []).append([row, collection_row, collection_row])
3806
+
3807
+ def collection_row(lc, path):
3808
+ collection = lc.collection
3809
+ row = _outliner_row(
3810
+ path, collection.name, "TSE_LAYER_COLLECTION", "OUTLINER_COLLECTION", seen,
3811
+ data=_rna_address(collection),
3812
+ # Open at rest: `tree_display_view_layer.cc:167` clears TSE_CLOSED
3813
+ # for every editable layer collection.
3814
+ expanded=True,
3815
+ # `outliner_draw_restrictbuts`' collection block
3816
+ # (`outliner_draw.cc:1620-1700`): EXCLUDE and the eye come off the
3817
+ # LAYER collection, the render camera off the collection itself.
3818
+ restrict={"exclude": bool(lc.exclude), "hide": bool(lc.hide_viewport),
3819
+ "render": bool(collection.hide_render)})
3820
+ children = [collection_row(child,
3821
+ _outliner_key("%s.children" % path, child.collection.name))
3822
+ for child in lc.children]
3823
+ # `add_layer_collections_recursive`: an EXCLUDED collection's objects
3824
+ # are not added at all -- it is not in the view layer.
3825
+ if not lc.exclude:
3826
+ page, more = _outliner_page(collection.objects)
3827
+ for obj in page:
3828
+ object_row = _outliner_object(obj, view_layer, seen, chosen, active)
3829
+ place(obj, object_row, row)
3830
+ children.append(object_row)
3831
+ if more:
3832
+ row["more"] = more
3833
+ row["children"] = children
3834
+ return row
3835
+
3836
+ scene_collection = _outliner_row(
3837
+ "%s.collection" % scene_path, "Scene Collection", "TSE_VIEW_COLLECTION_BASE",
3838
+ "OUTLINER_COLLECTION", seen,
3839
+ # `tree_display_view_layer.cc:130` clears TSE_CLOSED unconditionally.
3840
+ expanded=True)
3841
+ children = [collection_row(child,
3842
+ _outliner_key("%s.layer_collection.children" % view_layer_path,
3843
+ child.collection.name))
3844
+ for child in root.children]
3845
+ page, more = _outliner_page(root.collection.objects)
3846
+ for obj in page:
3847
+ object_row = _outliner_object(obj, view_layer, seen, chosen, active)
3848
+ place(obj, object_row, scene_collection)
3849
+ children.append(object_row)
3850
+ if more:
3851
+ scene_collection["more"] = more
3852
+ scene_collection["children"] = children
3853
+
3854
+ # `ObjectsChildrenBuilder::make_object_parent_hierarchy_collections`, parents
3855
+ # before children: a child object is MOVED out of its collection's row and
3856
+ # under its parent's when both are in that same collection; when they are
3857
+ # not, Blender adds the child under the parent ANYWAY and leaves it
3858
+ # unexpanded (`TE_CHILD_NOT_IN_COLLECTION`, whose row also draws no restrict
3859
+ # columns at all -- `outliner_draw.cc:1282-1286`).
3860
+ ordered = []
3861
+ placed = set()
3862
+
3863
+ def order(obj):
3864
+ if obj.parent is not None:
3865
+ order(obj.parent)
3866
+ if obj.name not in placed:
3867
+ placed.add(obj.name)
3868
+ ordered.append(obj)
3869
+
3870
+ for name in list(placements):
3871
+ order(bpy.data.objects[name])
3872
+ for obj in ordered:
3873
+ if obj.parent is None:
3874
+ continue
3875
+ parents = placements.get(obj.parent.name)
3876
+ mine = placements.get(obj.name)
3877
+ if not parents or not mine:
3878
+ continue
3879
+ for parent_row, _parent_owner, parent_collection in parents:
3880
+ moved = False
3881
+ for entry in mine:
3882
+ row, owner, _collection = entry
3883
+ if owner is not parent_collection:
3884
+ continue
3885
+ owner["children"].remove(row)
3886
+ parent_row["children"].append(row)
3887
+ entry[1] = parent_row
3888
+ moved = True
3889
+ break
3890
+ if moved:
3891
+ continue
3892
+ duplicate = _outliner_row(
3893
+ _rna_address(obj), obj.name, "TSE_SOME_ID", _outliner_object_icon(obj), seen,
3894
+ struct="Object", objectType=obj.type, object=obj.name,
3895
+ selected=obj.name in chosen, active=obj.name == active, notInCollection=True)
3896
+ parent_row["children"].append(duplicate)
3897
+ mine.append([duplicate, parent_row, parent_collection])
3898
+
3899
+ return {
3900
+ "scene": scene_path,
3901
+ "viewLayer": view_layer_path,
3902
+ # `context.mode` is what a `poll` tests; `Object.mode` is what the
3903
+ # Outliner's own pose rows key on. Both, because the Outliner needs the
3904
+ # object's and the rest of the editor needs the context's.
3905
+ "mode": bpy.context.mode,
3906
+ "objectMode": engine_active.mode if engine_active is not None else None,
3907
+ "active": active,
3908
+ "rows": [scene_collection],
3909
+ }
3910
+
3911
+
3912
+ def outliner_set(path, column, value):
3913
+ """ONE RESTRICTION COLUMN, written.
3914
+
3915
+ Not `rna_set`, and for one reason: the EYE on an object row is the view
3916
+ layer BASE's `hide_viewport` (`outliner_draw.cc:1291-1317`), and a `Base` is
3917
+ not a datablock `bpy.data` can address -- Blender's own Python door onto
3918
+ exactly that flag is `Object.hide_get()/hide_set()`. Every other column IS
3919
+ an ordinary RNA property, and the table below is
3920
+ `outliner_draw_restrictbuts`' own `props` struct (`:1182-1206`) row for row.
3921
+ A column Blender does not draw on this row type is refused BY NAME rather
3922
+ than written somewhere near it."""
3923
+ target = _rna_resolve(path)
3924
+ value = bool(value)
3925
+ if isinstance(target, bpy.types.Object):
3926
+ if column == "hide":
3927
+ target.hide_set(value, view_layer=bpy.context.view_layer)
3928
+ return {"path": path, "column": column, "value": bool(target.hide_get())}
3929
+ if column == "render":
3930
+ target.hide_render = value
3931
+ return {"path": path, "column": column, "value": bool(target.hide_render)}
3932
+ if column == "viewport":
3933
+ target.hide_viewport = value
3934
+ return {"path": path, "column": column, "value": bool(target.hide_viewport)}
3935
+ if isinstance(target, bpy.types.LayerCollection):
3936
+ if column in ("exclude", "hide"):
3937
+ member = "exclude" if column == "exclude" else "hide_viewport"
3938
+ setattr(target, member, value)
3939
+ return {"path": path, "column": column, "value": bool(getattr(target, member))}
3940
+ if column == "render":
3941
+ target.collection.hide_render = value
3942
+ return {"path": path, "column": column, "value": bool(target.collection.hide_render)}
3943
+ if isinstance(target, bpy.types.Modifier):
3944
+ member = {"render": "show_render", "viewport": "show_viewport"}.get(column)
3945
+ if member is not None:
3946
+ setattr(target, member, not value)
3947
+ return {"path": path, "column": column, "value": not getattr(target, member)}
3948
+ if isinstance(target, bpy.types.Constraint) and column == "hide":
3949
+ target.enabled = not value
3950
+ return {"path": path, "column": column, "value": not target.enabled}
3951
+ raise ValueError(
3952
+ "Blender's Outliner draws no %r column on a %s, so nothing was written -- the columns "
3953
+ "it draws per row type are `outliner_draw_restrictbuts`' own (%s)."
3954
+ % (column, target.bl_rna.identifier, path))
3955
+
3956
+
3957
+ def dispatch(request):
3958
+ op = request.get("op")
3959
+ if op == "execute":
3960
+ engine = bpy.context.scene.render.engine
3961
+ if engine in UNAVAILABLE_ENGINES and "render.render" in request["code"]:
3962
+ return {"executed": False, "result": "",
3963
+ "error": "RenderEngineUnavailable: %s is compiled into this Blender build and "
3964
+ "cannot be replaced by the three.js engine, so it has no renderer "
3965
+ "here. Set scene.render.engine to one of %s."
3966
+ % (engine, ", ".join(ENGINES))}
3967
+ absent = _absent_capability(request["code"])
3968
+ if absent is not None:
3969
+ return {"executed": False, "result": "", "error": absent}
3970
+ answer = execute(request["code"])
3971
+ # Every mutation is presented, the rule: the Model
3972
+ # document is what the agent is looking at.
3973
+ try:
3974
+ SESSION.present()
3975
+ except Exception as thrown: # noqa: BLE001
3976
+ # LOUD, because a present that fails leaves the Model document
3977
+ # showing the state BEFORE this call and nothing else says so.
3978
+ # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
3979
+ # escalate (`blender-emscripten-engine.mts:159`,
3980
+ # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
3981
+ # to the console and nothing else), so it logged at `log` level and
3982
+ # reached no counter -- measured 2026-09-19 (I4), when a throw in
3983
+ # the overlay walk froze the viewport through a dozen successful
3984
+ # `blender-execute` calls with `vgai console` silent throughout.
3985
+ _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
3986
+ "showing the state before it: " + repr(thrown))
3987
+ return answer
3988
+ if op == "scene-info":
3989
+ return get_scene_info()
3990
+ if op == "object-info":
3991
+ return get_object_info(request["name"])
3992
+ if op == "present":
3993
+ SESSION.present(request.get("capture"))
3994
+ return {"presented": True, "revision": SESSION.revision,
3995
+ "shipped": SESSION.last_shipped,
3996
+ # What the presenter itself said it was holding before this
3997
+ # frame -- the other side of `shipped`, and the only reading
3998
+ # that distinguishes "the tab has it" from "we sent it once".
3999
+ "presenterHeld": SESSION.presenter_held}
4000
+ if op == "rna":
4001
+ return rna_view(request["path"], request.get("names", _RNA_COLLECTION_NAMES))
4002
+ if op == "rna-context":
4003
+ return rna_context(request.get("object"), request.get("collection"))
4004
+ if op == "node-tree":
4005
+ return rna_node_tree(request.get("path"), request.get("material"))
4006
+ if op == "uv-layout":
4007
+ return rna_uv_layout(request.get("object"), request.get("uvLayer"))
4008
+ # THE RIG AND CLIP DOORS (the Timeline): both READS, so neither presents
4009
+ # and neither owes a derivation. There is deliberately no writer beside
4010
+ # them -- keying, moving a key and setting a range are edits.
4011
+ if op == "rig":
4012
+ return rna_rig(request.get("object"))
4013
+ if op == "action-clip":
4014
+ return rna_action_clip(request.get("object"), request.get("bake", True))
4015
+ if op == "outliner":
4016
+ return rna_outliner(request.get("selected"))
4017
+ if op == "outliner-set":
4018
+ answer = outliner_set(request["path"], request["column"], request["value"])
4019
+ # A COLUMN IS A WRITE, and a write presents -- the same rule `rna-set`
4020
+ # follows: hiding an object in the Outliner must change what the
4021
+ # viewport shows in the same gesture.
4022
+ try:
4023
+ SESSION.present()
4024
+ except Exception as thrown: # noqa: BLE001
4025
+ # LOUD, because a present that fails leaves the Model document
4026
+ # showing the state BEFORE this call and nothing else says so.
4027
+ # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
4028
+ # escalate (`blender-emscripten-engine.mts:159`,
4029
+ # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
4030
+ # to the console and nothing else), so it logged at `log` level and
4031
+ # reached no counter -- measured 2026-09-19 (I4), when a throw in
4032
+ # the overlay walk froze the viewport through a dozen successful
4033
+ # `blender-execute` calls with `vgai console` silent throughout.
4034
+ _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
4035
+ "showing the state before it: " + repr(thrown))
4036
+ return answer
4037
+ if op == "rna-set":
4038
+ answer = rna_set(request["path"], request["property"], request["value"],
4039
+ request.get("index"))
4040
+ # A WRITE IS PRESENTED, exactly as `execute` presents: the Model
4041
+ # document is what the person is looking at while they drag the field.
4042
+ try:
4043
+ SESSION.present()
4044
+ except Exception as thrown: # noqa: BLE001
4045
+ # LOUD, because a present that fails leaves the Model document
4046
+ # showing the state BEFORE this call and nothing else says so.
4047
+ # `@@VGAI-PRESENT-FAILED` was not one of the prefixes the engines
4048
+ # escalate (`blender-emscripten-engine.mts:159`,
4049
+ # `blender-wali-engine.mts:266` raise `@@VGAI-WARN`/`@@VGAI-ERROR`
4050
+ # to the console and nothing else), so it logged at `log` level and
4051
+ # reached no counter -- measured 2026-09-19 (I4), when a throw in
4052
+ # the overlay walk froze the viewport through a dozen successful
4053
+ # `blender-execute` calls with `vgai console` silent throughout.
4054
+ _say("@@VGAI-ERROR the present after this call failed, so the Model document is "
4055
+ "showing the state before it: " + repr(thrown))
4056
+ return answer
4057
+ if op == "save-document":
4058
+ return SESSION.save_document()
4059
+ if op == "start":
4060
+ SESSION.project = request.get("project", SESSION.project)
4061
+ try:
4062
+ os.chdir(SESSION.project)
4063
+ except OSError:
4064
+ pass
4065
+ answer = {"session": SESSION.session, "python": sys.version,
4066
+ "blender": bpy.app.version_string, "engines": ENGINES,
4067
+ "unavailableEngines": UNAVAILABLE_ENGINES}
4068
+ document = request.get("document")
4069
+ if document:
4070
+ answer.update(SESSION.bind_document(document))
4071
+ return answer
4072
+ raise ValueError("Unknown session op %r" % (op,))
4073
+
4074
+
4075
+ # ---------------------------------------------------------------- the loop
4076
+
4077
+ _prepare_directories()
4078
+ bpy.app.handlers.load_post.append(_load_post)
4079
+ ENGINES, UNAVAILABLE_ENGINES = _register_engine()
4080
+ _say("@@VGAI-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
4081
+ "unavailableEngines": UNAVAILABLE_ENGINES}))
4082
+
4083
+ # Markers already dispatched, and the answers whose `out/` files this loop
4084
+ # still owes a cleanup. Both are pruned as the page acks, so neither grows
4085
+ # with the session.
4086
+ def _channel_loop():
4087
+ """Serve the directory channel until the program ends.
4088
+
4089
+ A FUNCTION SO ITS DEATH HAS A NAME. Everything here below the per-request
4090
+ `try` is the CHANNEL itself, and an exception in it used to fall off the
4091
+ end of this script: Blender then finishes `--python` and exits 0 through
4092
+ its ordinary path, so the page is told "Blender exited 0" and nothing
4093
+ anywhere says why. MEASURED 2026-09-19 -- from outside, a channel death is
4094
+ indistinguishable from a clean shutdown, and the traceback goes to stderr,
4095
+ which is page output rather than a console condition. The caller below
4096
+ makes it one.
4097
+ """
4098
+ # Markers already dispatched, and the answers whose `out/` files this loop
4099
+ # still owes a cleanup. Both are pruned as the page acks, so neither grows
4100
+ # with the session.
4101
+ seen = set()
4102
+ unacked = []
4103
+ while True:
4104
+ # A FAILED LISTING IS NOT AN EMPTY ONE. This swallowed every OSError
4105
+ # into "no requests", so the one failure that matters -- the process
4106
+ # out of file descriptors -- turned the session DEAF: it kept looping
4107
+ # at full speed, answered nothing ever again, and the page waited on a
4108
+ # call with a live program and no error anywhere (measured
4109
+ # 2026-09-19, 1,008 s). The directory is the page's and is made before
4110
+ # the program starts, so its absence is the only benign reading;
4111
+ # anything else is named and ends the loop through the guard below.
4112
+ try:
4113
+ markers = set(n for n in os.listdir(IN)
4114
+ if n.endswith(".done") and n[: -len(".done")].isdigit())
4115
+ except FileNotFoundError:
4116
+ markers = set()
4117
+ # THE PAGE'S ACK IS ITS OWN REQUEST FILE BEING GONE: it unlinks
4118
+ # `in/<id>.done` only after reading `out/<id>.json`, so anything
4119
+ # missing from this listing has been taken and the answer is ours to
4120
+ # remove. This is the whole of the page's former four-unlink cleanup,
4121
+ # moved to the side that wrote the files (see the header's ownership
4122
+ # table).
4123
+ if unacked:
4124
+ still_owed = []
4125
+ for rid in unacked:
4126
+ if rid + ".done" in markers:
4127
+ still_owed.append(rid)
4128
+ continue
4129
+ _drop(os.path.join(OUT, rid + ".done"))
4130
+ _drop(os.path.join(OUT, rid + ".json"))
4131
+ seen.discard(rid + ".done")
4132
+ unacked = still_owed
4133
+ # Numerically, not lexicographically: "10.done" sorts before "2.done"
4134
+ # as a string, and a request answered out of order is a wrong answer.
4135
+ names = sorted((n for n in markers if n not in seen),
4136
+ key=lambda n: int(n[: -len(".done")]))
4137
+ if not names:
4138
+ time.sleep(0.002)
4139
+ continue
4140
+ for marker in names:
4141
+ seen.add(marker)
4142
+ rid = marker[: -len(".done")]
4143
+ body = {"id": rid}
4144
+ try:
4145
+ with open(os.path.join(IN, rid + ".json")) as fh:
4146
+ request = json.loads(fh.read())
4147
+ body["result"] = dispatch(request)
4148
+ except BaseException as thrown: # noqa: BLE001
4149
+ body["error"] = "%s: %s" % (type(thrown).__name__, thrown)
4150
+ traceback.print_exc(file=_real_stderr)
4151
+ with open(os.path.join(OUT, rid + ".json"), "w") as fh:
4152
+ fh.write(json.dumps(body))
4153
+ with open(os.path.join(OUT, rid + ".done"), "w") as fh:
4154
+ fh.write("1")
4155
+ unacked.append(rid)
4156
+
4157
+
4158
+ try:
4159
+ _channel_loop()
4160
+ except BaseException as _thrown: # noqa: BLE001
4161
+ # The one place a channel death can still be named. Without this the
4162
+ # program ends 0 and the page reports only that Blender is gone.
4163
+ _say("@@VGAI-ERROR the session's channel loop raised %s: %s -- the session is over and "
4164
+ "every outstanding call will go unanswered. %s"
4165
+ % (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
4166
+ raise