@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.
- package/LICENSE +724 -0
- package/README.md +48 -0
- package/browser/blender-emscripten-engine.mts +289 -0
- package/browser/blender-engine.mts +412 -0
- package/browser/blender-wali-engine.mts +362 -0
- package/browser/index.ts +7 -0
- package/browser/protocol.ts +202 -0
- package/browser/rna.ts +697 -0
- package/browser/runtime.ts +511 -0
- package/browser/session-frame.mts +169 -0
- package/browser/session.py +4166 -0
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/attach-presenter.ts +140 -0
- package/browser/three/blender-agx.ts +235 -0
- package/browser/three/blender-base64.ts +42 -0
- package/browser/three/blender-corner-normals.ts +432 -0
- package/browser/three/blender-display-lut.ts +145 -0
- package/browser/three/blender-filmic.ts +49 -0
- package/browser/three/blender-frame-columns.ts +100 -0
- package/browser/three/blender-gradient-texture.ts +57 -0
- package/browser/three/blender-runtime-armature.ts +528 -0
- package/browser/three/blender-runtime-frame.ts +39 -0
- package/browser/three/blender-runtime-geometry.ts +342 -0
- package/browser/three/blender-runtime-lighting.ts +829 -0
- package/browser/three/blender-runtime-shadows.ts +107 -0
- package/browser/three/blender-runtime-view.ts +1481 -0
- package/browser/three/blender-runtime-volume.ts +128 -0
- package/browser/three/blender-runtime-weights.ts +306 -0
- package/browser/three/blender-sky.ts +461 -0
- package/browser/three/blender-standard.ts +68 -0
- package/browser/three/blender-triangulate.ts +181 -0
- package/browser/three/filmic-srgb.lut +0 -0
- package/browser/three/presenter.ts +265 -0
- package/browser/three/release.ts +27 -0
- package/browser/three/sky-precompute-worker.ts +45 -0
- package/browser/three/sky-worker.ts +79 -0
- package/browser/three/world-field-sampler.ts +358 -0
- package/browser/three/world-math.ts +59 -0
- package/browser/vgai_three.py +554 -0
- package/browser/worker.ts +648 -0
- package/package.json +48 -0
- package/wasm/BUNDLE.json +65 -0
- package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +2 -0
- 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
|