@volter/blender-engine 0.1.148 → 0.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +22 -5
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +66 -129
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +37 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +3 -0
  192. package/browser/protocol.ts +130 -43
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +271 -106
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1389 -1446
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +492 -306
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +195 -180
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -1,96 +1,9 @@
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
- """
1
+ """The tab’s long-lived Blender session. A directory protocol carries requests and replies while one bpy.data remains alive on Blender’s pthread. The page owns in/ and reply/; the guest owns out/ and ask/. Each writer cleans its own files after acknowledgement and removes .done last. Explicit saves write Blender’s native format and let worker.ts return changed output to the project."""
2
+
3
+ import sys
4
+
5
+ # The session module begins loading (a start step: see `main`).
6
+ print("@@VOLTER-PHASE module", file=sys.stderr, flush=True)
94
7
 
95
8
  import array
96
9
  import base64
@@ -100,7 +13,6 @@ import json
100
13
  import math
101
14
  import os
102
15
  import re
103
- import sys
104
16
  import time
105
17
  import traceback
106
18
  from contextlib import redirect_stdout
@@ -114,7 +26,12 @@ import mathutils
114
26
  # THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
115
27
  # reads the scene out as typed columns in a side arena the worker reads
116
28
  # through `BlenderEngine.readArena` -- see EXPORT_BUFFER_PATH below.
117
- import _blender_web
29
+ try:
30
+ import _blender_web
31
+ except ModuleNotFoundError as error:
32
+ if error.name != "_blender_web":
33
+ raise
34
+ _blender_web = None
118
35
 
119
36
  # The presenter's refusals, spelled in
120
37
  # `packages/blender-engine/browser/three/blender-runtime-frame.ts`. This is the only
@@ -127,30 +44,12 @@ IN = os.path.join(ROOT, "in")
127
44
  OUT = os.path.join(ROOT, "out")
128
45
  ASK = os.path.join(ROOT, "ask")
129
46
  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.
47
+ # Use the caller-supplied EXPORT_BUFFER_PATH when its host reads the arena through the filesystem.
48
+ # Hosts with access to linear memory leave it empty to avoid a second copy. Both doors expose the
49
+ # same native export bytes.
145
50
  EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
146
51
 
147
- # WHERE THE ENGINE'S HEAP WENT, stage by stage: the heap only grows, so its size after a stage says
148
- # nothing about the stage before it; this records `_blender_web.memory()` at each, in MB
149
- # ([label, heap, mallocInUse, mallocFree, guarded, guardedPeak]), and a script reads `MEMORY_MARKS`
150
- # (the last 64) to say which stage raised the high-water mark. MEASURED 2026-09-29 on the
151
- # Stoneguard file: the first export's door call took the heap from 1,442 to 3,644 MB while in-use
152
- # rose 28 MB and the guarded peak did not move -- OpenSubdiv's limit-surface evaluator for one
153
- # head (quality 3), which native Blender pays too (+1.2 GB peak RSS there).
52
+ # Retain the last 64 heap marks as [label, heap, mallocInUse, mallocFree, guarded, guardedPeak] in MB so callers can attribute growth to a stage.
154
53
  MEMORY_MARKS = []
155
54
 
156
55
 
@@ -202,11 +101,17 @@ def _prepare_directories():
202
101
 
203
102
  # ---------------------------------------------------------------- file load
204
103
 
104
+ # Each file load, counted: a request during which one came replaced the file, never removed its objects.
105
+ _LOADS = {"count": 0}
106
+
107
+
205
108
  @bpy.app.handlers.persistent
206
109
  def _load_post(_arg):
110
+ _LOADS["count"] += 1
207
111
  # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
208
112
  # into the Main that just went away); this is the session's half.
209
113
  SESSION.forget()
114
+ SESSION.renew()
210
115
  SESSION.loaded()
211
116
  # Factory reset reloads add-ons. Preserve native Cycles and re-register
212
117
  # only the headless GPU presenters we own.
@@ -275,7 +180,7 @@ def _surface_backgrounds(node):
275
180
  """World Surface as `(what the camera sees, what lights the scene)`; a
276
181
  world that is one Background is both and the second is None. A Mix Shader
277
182
  by Light Path `Is Camera Ray` is how a scene gets a bright backdrop and a
278
- restrained fill from one sky (the tram stop): input 2 is the camera's,
183
+ restrained fill from one sky: input 2 is the camera's,
279
184
  input 1 lights the scene."""
280
185
  kind = node.bl_idname
281
186
  if kind == "ShaderNodeBackground":
@@ -381,13 +286,12 @@ def _describe_world_socket(root_socket, camera_ray):
381
286
  if kind == "ShaderNodeMapRange" and socket == "Result":
382
287
  if node.data_type != "FLOAT":
383
288
  raise NotImplementedError("World Map Range data type %s" % node.data_type)
384
- steps = node.inputs.get("Steps")
385
289
  return {"kind": "map_range", "interpolation": node.interpolation_type,
386
290
  "clamp": bool(node.clamp) and node.interpolation_type not in ("SMOOTHSTEP", "SMOOTHERSTEP"),
387
291
  "value": value(node.inputs["Value"]),
388
292
  "from_min": value(node.inputs["From Min"]), "from_max": value(node.inputs["From Max"]),
389
293
  "to_min": value(node.inputs["To Min"]), "to_max": value(node.inputs["To Max"]),
390
- "steps": value(steps) if steps is not None else 0.0}
294
+ "steps": value(node.inputs["Steps"])}
391
295
  if kind == "ShaderNodeSeparateXYZ" and socket in ("X", "Y", "Z"):
392
296
  return {"kind": "separate", "vector": value(node.inputs["Vector"]), "axis": ("X", "Y", "Z").index(socket)}
393
297
  if kind == "ShaderNodeValToRGB" and socket == "Color":
@@ -406,8 +310,8 @@ def _describe_world_socket(root_socket, camera_ray):
406
310
  if model not in ("SINGLE_SCATTERING", "MULTIPLE_SCATTERING"):
407
311
  raise NotImplementedError(
408
312
  "World Sky Texture: sky_type %r is not implemented (SINGLE_SCATTERING and MULTIPLE_SCATTERING are)" % model)
409
- vector = node.inputs.get("Vector")
410
- if vector is not None and vector.links:
313
+ vector = node.inputs["Vector"]
314
+ if vector.links:
411
315
  raise NotImplementedError("Linked World Sky Texture Vector")
412
316
  return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
413
317
  "sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
@@ -500,10 +404,10 @@ def _describe_world(world):
500
404
  # Only a wholly constant background can drop the expression: linked
501
405
  # Strength may carry spatial radiance even when Color itself is constant.
502
406
  if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
503
- described.pop("shader", None)
407
+ del described["shader"]
504
408
  if "lighting" in described and not any(
505
409
  (lighting or background).inputs[name].is_linked for name in ("Color", "Strength")):
506
- described["lighting"].pop("shader", None)
410
+ del described["lighting"]["shader"]
507
411
  return described
508
412
 
509
413
 
@@ -549,36 +453,7 @@ def draw_camera(obj):
549
453
  }
550
454
 
551
455
 
552
- # ------------------------------------------------------- the material graphs
553
-
554
- # THE MATERIAL GRAPH, for a surface whose inputs are more than constants.
555
- #
556
- # The export door reduces each material to what a three.js standard material
557
- # holds -- a constant or one image per input. A LINKED input of a Principled
558
- # BSDF or an Emission is instead shipped as the node graph that drives it, and
559
- # the presenter compiles that graph with Blender's own node GLSL
560
- # (`three/blender-node-graph.ts`, `three/blender-node-glsl.generated.ts`). The
561
- # door is told which materials those are (`graph_materials`), so it stays quiet
562
- # about reducing them, which images their graphs sample (`graph_images`), so it
563
- # ships those pictures with the same revisions as every other, and which read
564
- # Generated coordinates (`graph_generated`), so a deformed mesh wearing one
565
- # ships Blender's orco -- or the door names why it cannot.
566
- #
567
- # The graph is flattened here: reroutes and muted nodes are followed the way
568
- # Blender's own node tree evaluation follows them, and group nodes are inlined
569
- # (their nodes keyed by the group path), so the presenter sees one acyclic
570
- # list of Blender nodes. What reaches the presenter is Blender's own data:
571
- # every input socket in declaration order with its GPU type and value, every
572
- # output socket's GPU type, and the node's own RNA properties.
573
-
574
- # The node types the presenter compiles. A graph reaching any other type is
575
- # not shipped; the door's reduction stands and a warning names the node.
576
-
577
- # EVERY LINK INTO A SOCKET, from one pass over its tree. `NodeSocket.links`
578
- # is Python in Blender (`_bpy_types.py`): each read walks ALL of the tree's
579
- # links, and the graph pass reads it per socket per present -- measured on a
580
- # 28-material scene, 875 reads and 9 of the pass's 15 profiled seconds. The
581
- # map is built once per tree per present (`material_graphs` clears it).
456
+ # Ship linked Principled BSDF and Emission inputs as node graphs for blender-node-graph.ts to compile. Build socket-link indexes once per node tree per present.
582
457
  _TREE_LINKS = {}
583
458
 
584
459
 
@@ -632,7 +507,7 @@ def _gpu_type(socket):
632
507
  if kind in ("VALUE", "INT", "BOOLEAN"):
633
508
  return "float"
634
509
  if kind == "VECTOR":
635
- return "vec%d" % len(socket.default_value) if hasattr(socket, "default_value") else "vec3"
510
+ return "vec%d" % len(socket.default_value)
636
511
  if kind in ("RGBA", "ROTATION"):
637
512
  return "vec4"
638
513
  if kind == "SHADER":
@@ -642,8 +517,6 @@ def _gpu_type(socket):
642
517
 
643
518
  def _socket_value(socket, gpu):
644
519
  """An unlinked socket's value in its GPU type (`nodestack_get_vec`)."""
645
- if not hasattr(socket, "default_value"):
646
- return 0.0 if gpu == "float" else [0.0] * int(gpu[3:])
647
520
  value = socket.default_value
648
521
  if gpu == "float":
649
522
  return float(value)
@@ -785,7 +658,10 @@ def _saved_view():
785
658
  Modeling), else the first 3D View any screen holds. `RegionView3D`'s pivot, rotation (view to
786
659
  world, `(w, x, y, z)`), distance and projection (`PERSP`, `ORTHO`, or `CAMERA` for a view
787
660
  through the scene camera), the `View3D`'s lens, and its SHADING TYPE, which Blender reopens
788
- with the view: a file saved in Material Preview opens in Material Preview."""
661
+ with the view: a file saved in Material Preview opens in Material Preview. Beside them, the
662
+ Solid options the tab draws as Blender does: the object outline (`show_object_outline`, in
663
+ `object_outline_color`), the specular term (`show_specular_highlight`) and what Solid and
664
+ Wireframe draw behind the model (`background_type`, with `background_color` for VIEWPORT)."""
789
665
  screens = [window.screen for wm in bpy.data.window_managers for window in wm.windows
790
666
  if window.screen is not None]
791
667
  workspace = bpy.data.workspaces.get("Modeling")
@@ -806,6 +682,11 @@ def _saved_view():
806
682
  "shading": space.shading.type,
807
683
  "scene_world": bool(space.shading.use_scene_world),
808
684
  "scene_lights": bool(space.shading.use_scene_lights),
685
+ "show_object_outline": bool(space.shading.show_object_outline),
686
+ "object_outline_color": [float(v) for v in space.shading.object_outline_color],
687
+ "show_specular_highlight": bool(space.shading.show_specular_highlight),
688
+ "background_type": space.shading.background_type,
689
+ "background_color": [float(v) for v in space.shading.background_color],
809
690
  }
810
691
  return None
811
692
 
@@ -862,7 +743,7 @@ def _subject_line(scene, view_layer):
862
743
  bone = rig.data.bones.active
863
744
  if bone is not None and _bone_shown(bone):
864
745
  parts.append(" : " + bone.name)
865
- key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
746
+ key = ob.active_shape_key
866
747
  if key is not None:
867
748
  parts.append(" : " + key.name)
868
749
  if ob.show_only_shape_key:
@@ -876,7 +757,7 @@ def _subject_line(scene, view_layer):
876
757
 
877
758
  def _bone_shown(bone):
878
759
  """`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
879
- collections = getattr(bone, "collections", None)
760
+ collections = bone.collections
880
761
  return not collections or any(c.is_visible for c in collections)
881
762
 
882
763
 
@@ -1009,7 +890,7 @@ def _follow(socket, stack):
1009
890
  return stack, node
1010
891
 
1011
892
 
1012
- def _light_path_side(socket, stack):
893
+ def _light_path_side(socket):
1013
894
  """A Mix Shader factor that separates the camera's surface (a Light Path's
1014
895
  Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
1015
896
  as the input index the camera sees; None for any other factor. The door
@@ -1027,7 +908,7 @@ def _light_path_side(socket, stack):
1027
908
  def _collapse(stack, node):
1028
909
  """Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
1029
910
  while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
1030
- side = _light_path_side(node.inputs[0], stack)
911
+ side = _light_path_side(node.inputs[0])
1031
912
  if side is None:
1032
913
  break
1033
914
  stack, node = _follow(node.inputs[side], stack)
@@ -1140,23 +1021,17 @@ def _mixed_graph(material, stack, surface):
1140
1021
  }
1141
1022
 
1142
1023
 
1143
- def _object_info_random(name):
1144
- """Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
1145
- Jenkins hash_uint2(name_hash, 0), converted to float32 * 2**-32.
1146
- Instances must use depsgraph.random_id instead; their export is separate.
1147
- See draw_handle.hh::random and cycles/blender/object.cpp.
1148
- """
1024
+ def _hash_uint2_float(value):
1025
+ """Jenkins hash_uint2(value, 0), converted to float32 * 2**-32."""
1149
1026
  import struct
1150
1027
 
1151
1028
  mask = 0xffffffff
1152
- h = 0
1153
- # Blender's platform flags specify -funsigned-char (native and WASM).
1154
- for byte in name.encode("utf8"):
1155
- h = (h * 37 + byte) & mask
1156
- a = (0xdeadbeef + 21 + h) & mask
1029
+ a = (0xdeadbeef + 21 + value) & mask
1157
1030
  b = c = (0xdeadbeef + 21) & mask
1031
+
1158
1032
  def rotate(x, bits):
1159
1033
  return ((x << bits) | (x >> (32 - bits))) & mask
1034
+
1160
1035
  c = ((c ^ b) - rotate(b, 14)) & mask
1161
1036
  a = ((a ^ c) - rotate(c, 11)) & mask
1162
1037
  b = ((b ^ a) - rotate(a, 25)) & mask
@@ -1167,6 +1042,18 @@ def _object_info_random(name):
1167
1042
  return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1168
1043
 
1169
1044
 
1045
+ def _object_info_random(name):
1046
+ """Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
1047
+ hash_uint2(name_hash, 0). Instances use depsgraph.random_id instead.
1048
+ See draw_handle.hh::random and cycles/blender/object.cpp.
1049
+ """
1050
+ h = 0
1051
+ # Blender's platform flags specify -funsigned-char (native and WASM).
1052
+ for byte in name.encode("utf8"):
1053
+ h = (h * 37 + byte) & 0xffffffff
1054
+ return _hash_uint2_float(h)
1055
+
1056
+
1170
1057
  def _particle_info_random(index=0):
1171
1058
  """Cycles Particle Info Random: hash_uint2(particle.index, 0).
1172
1059
 
@@ -1174,20 +1061,7 @@ def _particle_info_random(index=0):
1174
1061
  without a particle record use the zero-index dummy KernelParticle.
1175
1062
  This is distinct from Object Info's placement random_id.
1176
1063
  """
1177
- import struct
1178
- mask = 0xffffffff
1179
- a = (0xdeadbeef + 21 + index) & mask
1180
- b = c = (0xdeadbeef + 21) & mask
1181
- def rotate(x, bits):
1182
- return ((x << bits) | (x >> (32 - bits))) & mask
1183
- c = ((c ^ b) - rotate(b, 14)) & mask
1184
- a = ((a ^ c) - rotate(c, 11)) & mask
1185
- b = ((b ^ a) - rotate(a, 25)) & mask
1186
- c = ((c ^ b) - rotate(b, 16)) & mask
1187
- a = ((a ^ c) - rotate(c, 4)) & mask
1188
- b = ((b ^ a) - rotate(a, 14)) & mask
1189
- c = ((c ^ b) - rotate(b, 24)) & mask
1190
- return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1064
+ return _hash_uint2_float(index)
1191
1065
 
1192
1066
 
1193
1067
  def _depsgraph_placements(frame, depsgraph):
@@ -1204,7 +1078,7 @@ def _depsgraph_placements(frame, depsgraph):
1204
1078
  row["shadow_visible"] = bool(source.visible_shadow)
1205
1079
  # Frame-local native addresses are comparison tokens, not authoring IDs.
1206
1080
  # Consume them here so neither the presenter nor saved projects retain them.
1207
- geometry = frame.pop("instance_geometry", {})
1081
+ geometry = frame.pop("instance_geometry")
1208
1082
  placements = []
1209
1083
  for instance in depsgraph.object_instances:
1210
1084
  source = instance.object.original
@@ -1217,9 +1091,8 @@ def _depsgraph_placements(frame, depsgraph):
1217
1091
  continue
1218
1092
  if not instance.show_self:
1219
1093
  continue
1220
- # AN UNSUPPORTED PLACEMENT IS LEFT OUT, NAMED ONCE (`warn`): raising here
1221
- # lost the whole frame, so one odd instance kept the entire scene from
1222
- # opening. The native exporter skips the same duplis.
1094
+ # Omit unsupported placements and name each refusal through `warn`.
1095
+ # The rest of the frame remains drawable.
1223
1096
  if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
1224
1097
  (instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
1225
1098
  warn("Depsgraph instances of %r are not drawn: their geometry differs from the exported source" % source.name)
@@ -1263,7 +1136,7 @@ def material_graphs(scene):
1263
1136
  _TREE_LINKS.clear()
1264
1137
  graphs = {}
1265
1138
  for obj in scene.objects:
1266
- for slot in getattr(obj, "material_slots", ()):
1139
+ for slot in obj.material_slots:
1267
1140
  material = slot.material
1268
1141
  if material is not None and material.name not in graphs:
1269
1142
  graphs[material.name] = material_graph(material)
@@ -1272,9 +1145,7 @@ def material_graphs(scene):
1272
1145
 
1273
1146
  # ------------------------------------------------------------- the overlays
1274
1147
  #
1275
- # INSPECTION OVERLAYS, READ OFF THE ENGINE (ARCHITECTURE-CORE §Blender north
1276
- # star, "Inspection parity, not editing parity"; WORK.md §Blender in the tab
1277
- # is Blender, "Inspection parity", I4). Blender's overlay ENGINE
1148
+ # INSPECTION OVERLAYS, READ OFF THE ENGINE. Blender's overlay engine
1278
1149
  # (`source/blender/draw/engines/overlay/`) is never run, ported or recorded
1279
1150
  # here: what these answer is the DATA its draw functions read -- a bone's
1280
1151
  # display matrix, a vertex's weight -- and a three.js presenter draws it
@@ -1304,7 +1175,7 @@ def _armature_bones(obj, locked_groups):
1304
1175
  [...].matrix` in the RNA door reads back identical -- and the presenter
1305
1176
  scales the unit shape itself."""
1306
1177
  armature = obj.data
1307
- active = getattr(armature.bones, "active", None)
1178
+ active = armature.bones.active
1308
1179
  active_name = active.name if active is not None else None
1309
1180
  bones = []
1310
1181
  for pchan in obj.pose.bones:
@@ -1319,19 +1190,8 @@ def _armature_bones(obj, locked_groups):
1319
1190
  "hide": bool(bone.hide),
1320
1191
  "length": float(bone.length),
1321
1192
  "matrix": [[float(v) for v in row] for row in pchan.matrix],
1322
- # BONE_SELECTED / BONE_DRAW_ACTIVE, the two flags
1323
- # `get_pchan_color_wire` branches on (`:760-795`).
1324
- #
1325
- # SELECTION IS THE POSE CHANNEL'S, and that is a MEASUREMENT rather
1326
- # than a preference: at this pin `Bone` carries `hide` and
1327
- # `hide_select` and NO `select` — `rna_def_bone_common`
1328
- # (`rna_armature.cc:1305`) declares none and `:1913` puts one on
1329
- # `EditBone` alone, so `bone.select` is an AttributeError. Asked of
1330
- # the engine itself, a `Bone`'s sel/hide properties are
1331
- # `['hide', 'hide_select']` and a `PoseBone`'s are
1332
- # `['hide', 'select']`. It is the same BIT either way — both write
1333
- # `Bone.flag`'s `BONE_SELECTED`, which is what the overlay's
1334
- # `bone.flag() & BONE_SELECTED` reads.
1193
+ # PoseBone.select supplies BONE_SELECTED; Bone has no select RNA property.
1194
+ # Selection and the active bone determine get_pchan_color_wire's colours.
1335
1195
  "select": bool(pchan.select),
1336
1196
  "active": bone.name == active_name,
1337
1197
  # BONE_DRAW_LOCKED_WEIGHT, set in weight-paint mode for every bone
@@ -1374,7 +1234,7 @@ def _bone_inherit(bone):
1374
1234
  flags.append("inherit_scale=%s" % bone.inherit_scale)
1375
1235
  if not bone.use_local_location:
1376
1236
  flags.append("use_local_location=False")
1377
- if getattr(bone, "use_relative_parent", False):
1237
+ if bone.use_relative_parent:
1378
1238
  flags.append("use_relative_parent=True")
1379
1239
  return {"inherit": flags} if flags else {}
1380
1240
 
@@ -1458,10 +1318,25 @@ def _locked_weight_groups(view_layer):
1458
1318
  obj = view_layer.objects.active
1459
1319
  if obj is None or "WEIGHT_PAINT" not in obj.mode:
1460
1320
  return frozenset()
1461
- groups = getattr(obj, "vertex_groups", None) or ()
1321
+ groups = obj.vertex_groups
1462
1322
  return frozenset(group.name for group in groups if group.lock_weight)
1463
1323
 
1464
1324
 
1325
+ def _warn_after_armature(view_layer):
1326
+ """Warn once per object and stack when a modifier after Armature depends on the pose: exported vertices include its bind-time result, while the presenter subsequently moves only the skin."""
1327
+ for obj in view_layer.objects:
1328
+ if obj.type != "MESH":
1329
+ continue
1330
+ stack = [m for m in obj.modifiers if m.show_viewport]
1331
+ at = next((i for i, m in enumerate(stack) if m.type == "ARMATURE" and m.object is not None), None)
1332
+ if at is None or at == len(stack) - 1:
1333
+ continue
1334
+ after = ", ".join("%s (%s)" % (m.name, m.type) for m in stack[at + 1:])
1335
+ warn("%s: the modifiers after its Armature (%s) are evaluated once at the pose it was sent in; as its bones "
1336
+ "move in Play they are carried by the skin, not run again. Apply them, or move them "
1337
+ "before the Armature, where the pose should change their result." % (obj.name, after))
1338
+
1339
+
1465
1340
  def _armatures(view_layer):
1466
1341
  """Every armature the view layer shows, with its display type and pose.
1467
1342
 
@@ -1496,39 +1371,12 @@ def _armatures(view_layer):
1496
1371
  return armatures
1497
1372
 
1498
1373
 
1499
- def _weights(scene, view_layer, frame, known):
1500
- """PER-VERTEX WEIGHT for the active object's active vertex group.
1501
-
1502
- Blender's own evaluation, `evaluate_vertex_weight`
1503
- (`draw/intern/mesh_extractors/extract_mesh_vbo_weights.cc:20-67`), in the
1504
- default state -- no Multi-Paint, no Lock-Relative -- so the weight is
1505
- `BKE_defvert_find_weight(dvert, active)` clamped to [0,1], and a vertex the
1506
- group does not weight is the ALERT value the fragment shader paints
1507
- `TH_VERTEX_UNREFERENCED` over. WHICH vertices alert is
1508
- `scene.tool_settings.vertex_group_user` (`rna_scene.cc:3428-3434`, default
1509
- ACTIVE): ACTIVE alerts a vertex with no weight in the ACTIVE group, ALL only
1510
- one with no weight in ANY group, NONE never.
1511
-
1512
- THE ARRAY IS A REFERENCE WHEN NOTHING MOVED, the same contract the meshes
1513
- have (`blender-runtime-frame.ts`): it is the size of a vertex column and
1514
- every mutation presents, so it ships only when its CONTENT has changed since
1515
- the frame this session last sent.
1516
-
1517
- ITS OWN DIGEST, NOT THE MESH'S REVISION, and that is a measurement rather
1518
- than a preference. The first shape of this function keyed the reference on
1519
- the mesh's export revision -- the number the geometry itself ships under --
1520
- and MEASURED live 2026-09-19: writing 640 deform weights through
1521
- `VertexGroup.add()` does not move it, because the door's revision follows
1522
- the depsgraph's GEOMETRY update record and a deform layer is not geometry.
1523
- So the array shipped once, all zeros, and every later present said
1524
- "unchanged" while the engine held a full ramp. The walk is unavoidable
1525
- either way -- an honest answer has to read every vertex -- so what the
1526
- reference saves is the TRANSFER, and a digest of the bytes is the only key
1527
- that cannot lie about them."""
1374
+ def _weights(scene, view_layer, known):
1375
+ """Return the active vertex group’s weights and zero-weight alert flags using Blender’s default evaluation. Transfer typed arrays as base64 and identify retained arrays by their byte digest."""
1528
1376
  obj = view_layer.objects.active
1529
1377
  if obj is None or obj.type != "MESH" or obj.data is None:
1530
1378
  return None
1531
- group = getattr(getattr(obj, "vertex_groups", None), "active", None)
1379
+ group = obj.vertex_groups.active
1532
1380
  if group is None:
1533
1381
  return None
1534
1382
  mesh = obj.data
@@ -1578,10 +1426,10 @@ class Session:
1578
1426
  self._known = {}
1579
1427
  # The last frame's accounting (`_present`), read by `dispatch`.
1580
1428
  self.last_shipped = None
1581
- # THE SESSION'S DOCUMENT: the `.blend` it opens at start and saves back
1429
+ # THE SESSION'S DOCUMENT: the `.blend` it last opened and saves back
1582
1430
  # into. Absolute (the module filesystem mirrors the project at its own
1583
1431
  # host path) plus the project-relative spelling the tab needs to name
1584
- # the destination. None until `blender-start` states one.
1432
+ # the destination. None until `open-document` names one.
1585
1433
  self.document = None
1586
1434
  self.document_relative = None
1587
1435
  # Set by a present that left the document behind the model; cleared by
@@ -1607,18 +1455,48 @@ class Session:
1607
1455
 
1608
1456
  def forget(self):
1609
1457
  self._known.clear()
1458
+ self._action_revisions = {}
1459
+
1460
+ def renew(self):
1461
+ """A NEW PRESENTATION SESSION FOR EACH FILE LOAD: the editor's open of a document, a
1462
+ script's `open_mainfile` or `read_homefile` alike. A load restarts the revisions the export
1463
+ door numbers its datablocks with, and the view keeps a mesh whose session and revision it
1464
+ already holds (`blender-runtime-view.ts`), so another file's "Cube" at the same revision
1465
+ would be drawn as the last one's. A new id makes the view clear what it held and refuse the
1466
+ last file's late frames."""
1467
+ self.session = "blender-%d" % time.time_ns()
1468
+
1469
+ def _kept_action_revisions(self, door):
1470
+ """Keep action revisions stable across evaluation-only updates. An edit advances
1471
+ _ACTION_EDITS and permits the native revision to replace the retained revision. All
1472
+ writing requests, scripts, undo and redo count as edits; a file read resets the table."""
1473
+ held = getattr(self, "_action_revisions", None)
1474
+ if held is None:
1475
+ held = self._action_revisions = {}
1476
+ edits = _ACTION_EDITS["count"]
1477
+ shown = {}
1478
+ for name, revision in door.items():
1479
+ known = held.get(name)
1480
+ if known is None or (known[0] != revision and known[2] != edits):
1481
+ held[name] = (revision, revision, edits)
1482
+ else:
1483
+ held[name] = (revision, known[1], edits)
1484
+ shown[name] = held[name][1]
1485
+ for name in [name for name in held if name not in door]:
1486
+ del held[name]
1487
+ return shown
1610
1488
 
1611
1489
  # -- the export
1612
1490
 
1613
- def _export(self, depsgraph=None):
1491
+ def _export(self, depsgraph):
1614
1492
  """THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
1615
1493
 
1616
1494
  One call evaluates the scene, walks the depsgraph's own update record
1617
1495
  for the revisions, reads Blender's arrays straight into a side arena
1618
1496
  and answers a small JSON frame that names each column by
1619
1497
  `{offset, length, dtype, count, stride}`; the worker copies those bytes
1620
- off the module heap (`session-frame.mts`). Nothing large crosses as
1621
- JSON and nothing is staged through the filesystem.
1498
+ from EXPORT_BUFFER_PATH (`session-frame.mts`). Large arrays cross
1499
+ through that binary arena rather than JSON.
1622
1500
 
1623
1501
  WHAT THE DOOR DOES NOT DESCRIBE is reduced here and merged in: the
1624
1502
  WORLD, whose sky/gradient graph becomes the presenter's world
@@ -1645,18 +1523,11 @@ class Session:
1645
1523
  # that can see the ask can read the file.
1646
1524
  if EXPORT_BUFFER_PATH:
1647
1525
  options["buffer_path"] = EXPORT_BUFFER_PATH
1648
- # NOTICE, THEN PULL (`_present`): a changed mesh is named here and its columns are written
1649
- # one mesh at a time after, so the engine never holds every changed mesh's columns at once.
1650
- # An engine without the pull door ships them all in this call, as before.
1651
- if hasattr(_blender_web, "export_mesh"):
1652
- options["defer"] = True
1653
- if hasattr(_blender_web, "memory_reset_peak"):
1654
- _blender_web.memory_reset_peak()
1655
- if hasattr(_blender_web, "export_frame_chunked"):
1656
- exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
1657
- lambda: _asked({"checkpoint": "native-export"}))
1658
- else:
1659
- exported = _blender_web.export_frame(json.dumps(options, ensure_ascii=False))
1526
+ # Pull changed resource columns individually after the frame describes them.
1527
+ options["defer"] = True
1528
+ _blender_web.memory_reset_peak()
1529
+ exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
1530
+ lambda: _asked({"checkpoint": "native-export"}))
1660
1531
  _mark("export:door")
1661
1532
  frame = json.loads(exported)
1662
1533
  del exported
@@ -1664,22 +1535,14 @@ class Session:
1664
1535
  error = frame.get("error")
1665
1536
  if error:
1666
1537
  raise RuntimeError("Blender export door: %s" % error)
1667
- # THE SESSION OWNS BOTH HALVES OF ITS IDENTITY. The presenter reads
1668
- # `(session, revision)` as a PAIR and refuses outright a frame whose
1669
- # revision went backwards under a name it already holds ("The runtime
1670
- # frame is older than the displayed model"). The door's own counter is
1671
- # per-`Main` -- it starts over on every file read, which is what its
1672
- # per-datablock revisions must do -- while the session name does not, so
1673
- # leaving the two in different hands splits the pair the moment a script
1674
- # opens a .blend. MEASURED 2026-09-18 through the editor tab:
1675
- # `read_factory_settings` took the door's counter from 40 back to 1 and
1676
- # nine of 17-workshop-interior's 63 calls died on that refusal.
1538
+ # Keep session and revision under one owner. The native per-Main counter restarts on file reads, while the session’s frame revision must remain monotonic.
1677
1539
  self.revision += 1
1678
1540
  frame["revision"] = self.revision
1541
+ frame["actions"] = self._kept_action_revisions(frame["actions"])
1679
1542
  # THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
1680
1543
  # named at the revision the door holds it at (`graph_images`, which is
1681
1544
  # the session's to read and not part of the presenter's frame).
1682
- revisions = frame.pop("graph_images", {})
1545
+ revisions = frame.pop("graph_images")
1683
1546
  for name, graph in graphs.items():
1684
1547
  if name not in frame["materials"]:
1685
1548
  continue
@@ -1694,9 +1557,7 @@ class Session:
1694
1557
  # automatic one is the evaluated bounds the presenter already holds.
1695
1558
  if graphs:
1696
1559
  depsgraph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
1697
- # BY NAME THROUGH ONE MAP: `scene.objects.get` searches the collection linearly, so a
1698
- # lookup per row was quadratic -- measured on the Stoneguard file (11,702 rows): 11,343
1699
- # calls, 14.4 s of every present.
1560
+ # Index scene objects by name once for the export-row lookup.
1700
1561
  by_name = {obj.name: obj for obj in scene.objects}
1701
1562
  for row in frame["objects"]:
1702
1563
  if not any(m in graphs for m in row["materials"] if m is not None):
@@ -1725,13 +1586,21 @@ class Session:
1725
1586
  # by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
1726
1587
  # `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
1727
1588
  for entry in frame["materials"].values():
1728
- material = bpy.data.materials.get(entry.get("name", ""))
1589
+ material = bpy.data.materials.get(entry["name"])
1729
1590
  if material is not None:
1730
1591
  entry["viewport"] = {
1731
1592
  "color": [float(v) for v in material.diffuse_color],
1732
1593
  "roughness": float(material.roughness),
1733
1594
  "metallic": float(material.metallic),
1734
1595
  }
1596
+ # Settings > Backface Culling > Shadow: a shadow from the faces turned to the light
1597
+ # only. Off (the default), EEVEE casts from both faces of every surface.
1598
+ entry["backface_culling_shadow"] = bool(material.use_backface_culling_shadow)
1599
+ # Settings > Backface Culling > Camera: the surface's back faces are not drawn (the
1600
+ # glTF importer sets it on every single-sided material). Transparency Overlap draws
1601
+ # all faces of a blended surface in one pass (on, the default), or only the nearest (off).
1602
+ entry["backface_culling"] = bool(material.use_backface_culling)
1603
+ entry["transparency_overlap"] = bool(material.use_transparency_overlap)
1735
1604
  graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
1736
1605
  # ONE NAME, TWO OBJECTS: Blender tells a linked object from a local one (or from another
1737
1606
  # file's) by its library as well as its name; the presenter knows them by name alone and
@@ -1763,8 +1632,8 @@ class Session:
1763
1632
  # (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
1764
1633
  # turn with the cursor's rotation in whichever `rotation_mode` it has.
1765
1634
  frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
1766
- # THE OVERLAYS, after the door's frame stands: `_weights` reads the
1767
- # mesh's own revision out of it (see there), so it cannot run before.
1635
+ # Inspection overlays are derived from the evaluated scene and view layer.
1636
+ # Weight-array reuse is keyed by their own byte digest.
1768
1637
  view_layer = graph.view_layer
1769
1638
  # THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
1770
1639
  # both from the scene, so they come from here (`_subject_line`).
@@ -1778,36 +1647,52 @@ class Session:
1778
1647
  # WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
1779
1648
  frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
1780
1649
  frame["view"] = _saved_view()
1650
+ # A WORLD background is the World's Viewport Display colour (`World.color`).
1651
+ if frame["view"] is not None:
1652
+ frame["view"]["world_color"] = [float(v) for v in scene.world.color] if scene.world else None
1653
+ # THE SCENE'S COLOUR MANAGEMENT, with the frame it colours: Rendered and Material Preview
1654
+ # are drawn through it (`blender-display-transform.ts`), so it travels with every present
1655
+ # and the tab never draws a frame through settings it has not been sent yet.
1656
+ # HOW THE VIEWPORT ANTI-ALIASES: Solid by Workbench's accumulation (the scene's own
1657
+ # `display.viewport_aa` when Workbench renders it, the Preferences' otherwise) and
1658
+ # Rendered by EEVEE's film (`taa_samples` over `filter_size`).
1659
+ frame["sampling"] = {
1660
+ "engine": scene.render.engine,
1661
+ "viewport_aa": scene.display.viewport_aa,
1662
+ "taa_samples": int(scene.eevee.taa_samples),
1663
+ "filter_size": float(scene.render.filter_size),
1664
+ }
1665
+ view_settings = scene.view_settings
1666
+ frame["display"] = {
1667
+ "view_transform": view_settings.view_transform,
1668
+ "look": view_settings.look or "None",
1669
+ "exposure": float(view_settings.exposure),
1670
+ "gamma": float(view_settings.gamma),
1671
+ "display_device": scene.display_settings.display_device,
1672
+ }
1781
1673
  frame["units"] = {
1782
1674
  "system": scene.unit_settings.system,
1783
1675
  "scale_length": float(scene.unit_settings.scale_length),
1784
1676
  }
1785
1677
  frame["armatures"] = _armatures(view_layer)
1678
+ _warn_after_armature(view_layer)
1786
1679
  # THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
1787
1680
  frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
1788
1681
  "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
1789
1682
  # ACTIONS NOTHING KEEPS: no user and no fake user, so the next save drops them (Blender's own
1790
1683
  # rule). A game that plays one is told before it vanishes.
1791
1684
  frame["unkeptActions"] = sorted(a.name for a in bpy.data.actions if a.users == 0 and not a.use_fake_user)
1792
- frame["weights"] = _weights(scene, view_layer, frame, self._known)
1793
- warnings = list(frame.get("warnings", ()))
1685
+ frame["weights"] = _weights(scene, view_layer, self._known)
1686
+ warnings = list(frame["warnings"])
1794
1687
  warnings.extend(_WARNINGS)
1795
1688
  del _WARNINGS[:]
1796
1689
  frame["warnings"] = warnings
1797
1690
  return frame
1798
1691
 
1799
1692
  def present(self, capture=None):
1800
- """Hand the tab a frame, and give back whatever it answered with.
1801
-
1802
- A render's photograph comes back through this same door, which is why
1803
- it blocks: `bpy.ops.render.render()` is waiting on the pixels.
1804
-
1805
- THE PRESENTER IS THE AUTHORITY ON WHAT IT HOLDS, and it says so by
1806
- name: a page that reloaded under a still-running session holds none of
1807
- this session's geometry while `_known` says it was sent. The record is
1808
- dropped and the frame goes out again IN FULL -- the same answer
1809
- `runtime_session.present` gives, for the same reason.
1810
- """
1693
+ """Present a frame and return the tab’s answer. Rendering blocks on this same channel for its
1694
+ pixels. Reconcile resource identity with the presenter’s held frame; when a document
1695
+ reload has discarded it, resend the required resources in full."""
1811
1696
  try:
1812
1697
  return self._present(capture)
1813
1698
  except RuntimeError as error:
@@ -1816,11 +1701,12 @@ class Session:
1816
1701
  self._known.clear()
1817
1702
  return self._present(capture)
1818
1703
 
1819
- def _present(self, capture=None, depsgraph=None):
1704
+ def _present(self, capture, depsgraph=None):
1820
1705
  known = dict(self._known)
1821
1706
  _mark("present:before")
1822
1707
  frame = self._export(depsgraph)
1823
1708
  _mark("present:exported")
1709
+ self._carry_browser_decoded_images(frame)
1824
1710
  pulled = self._pull(frame, render=depsgraph is not None)
1825
1711
  _mark("present:pulled")
1826
1712
  self._drop_unreachable_textures(frame)
@@ -1840,22 +1726,14 @@ class Session:
1840
1726
  # its value also depends on native undo initialization/checkpoints.
1841
1727
  "dirty": bool(bpy.data.is_dirty),
1842
1728
  }
1843
- # A PRESENT DOES NOT MAKE THE DOCUMENT STALE; a request that can change
1844
- # the model does (`mark_changed`, from `dispatch`). The predicate was the
1845
- # present itself, and a present also runs with no change behind it --
1846
- # opening the document, rebinding its view. MEASURED 2026-09-24 on
1847
- # `arena`: opening a Model document and doing nothing rewrote its
1848
- # `.blend` with 556 bytes changed, so a file a person only looked at read
1849
- # as modified. `columns`/`images` above stay the wrong question for the
1850
- # same reason as before: a MOVE ships zero columns and a DELETION ships
1851
- # nothing, and both come from a request `mark_changed` sees.
1729
+ # Only a request that can mutate the model marks the document changed. A present or view rebind alone must not trigger a save.
1852
1730
  for key, mesh in frame["meshes"].items():
1853
1731
  self._known["mesh:" + key] = mesh["revision"]
1854
1732
  for name, image in frame["images"].items():
1855
1733
  self._known["image:" + name] = image["revision"]
1856
1734
  # The weight array follows the meshes' own rule: recorded as sent, so
1857
1735
  # the next present ships a reference instead of the bytes.
1858
- weights = frame.get("weights")
1736
+ weights = frame["weights"]
1859
1737
  if weights is not None:
1860
1738
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
1861
1739
  weights["digest"])
@@ -1880,18 +1758,11 @@ class Session:
1880
1758
  self._reconcile_with_presenter(answer, frame)
1881
1759
  return answer
1882
1760
 
1883
- def _pull(self, frame, render=False):
1884
- """THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
1885
- during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
1886
- the changed meshes and pictures as deferred; the worker first copies the frame's own
1887
- columns out of the heap (`hold`), then each mesh's columns and each picture's pixels in
1888
- turn, copying each out before the next is written (every pull frees the one before). The
1889
- engine's peak is the scene plus one datablock's columns, not plus every changed one's.
1890
-
1891
- The pieces come back into the frame as the door would have written them: a picture with no
1892
- readable pixels leaves `images` with its warning, as it did when the frame carried it
1893
- whole, so `_drop_unreachable_textures` reads the same frame either way. Answers whether
1894
- anything was pulled; the worker then presents the frame with its pieces."""
1761
+ def _pull(self, frame, render):
1762
+ """Pull deferred mesh and image columns one datablock at a time after the worker copies the
1763
+ frame header. Copy each resource before the next pull releases its columns. Remove
1764
+ unreadable images with their warnings before texture reconciliation. Return whether any
1765
+ resource was pulled."""
1895
1766
  meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
1896
1767
  images = [name for name, image in frame["images"].items() if image.get("deferred")]
1897
1768
  if not meshes and not images and not render:
@@ -1909,7 +1780,7 @@ class Session:
1909
1780
  piece = json.loads(door(json.dumps(options, ensure_ascii=False)))
1910
1781
  if piece.get("error"):
1911
1782
  raise RuntimeError("Blender export door: %s" % piece["error"])
1912
- frame["warnings"].extend(piece.get("warnings", []))
1783
+ frame["warnings"].extend(piece["warnings"])
1913
1784
  if piece[kind] is None:
1914
1785
  del frame["images"][key]
1915
1786
  continue
@@ -1920,8 +1791,6 @@ class Session:
1920
1791
  """Render evaluation owns different geometry and visibility than a viewport.
1921
1792
  Keep its native revisions, deferred pulls and presenter separate for the
1922
1793
  entire blocking capture, and restore the authoring export in finally."""
1923
- if not hasattr(_blender_web, "begin_render_export"):
1924
- raise RuntimeError("This Blender artifact has no render-evaluated export; update the artifact and its corresponding source")
1925
1794
  _blender_web.begin_render_export(depsgraph)
1926
1795
  try:
1927
1796
  rendering = Session()
@@ -1930,20 +1799,33 @@ class Session:
1930
1799
  finally:
1931
1800
  _blender_web.end_render_export()
1932
1801
 
1802
+ def _carry_browser_decoded_images(self, frame):
1803
+ """Send WebP images as their encoded file bytes when Blender has no decoder and the browser can decode them. Revision them by the bytes and include them as deferred images."""
1804
+ carried = frame["images"]
1805
+ names = set()
1806
+ for material in frame["materials"].values():
1807
+ for slot in ("texture", "roughness_texture", "normal_texture"):
1808
+ reference = material.get(slot)
1809
+ if reference is not None:
1810
+ names.add(reference["image"]["name"])
1811
+ graph = material.get("graph")
1812
+ for node in graph["nodes"].values() if graph is not None else ():
1813
+ image = node["props"].get("image")
1814
+ if image is not None:
1815
+ names.add(image["name"])
1816
+ for name in names:
1817
+ if name in carried:
1818
+ continue
1819
+ data = _browser_decoded_bytes(bpy.data.images.get(name))
1820
+ if data is None:
1821
+ continue
1822
+ revision = int.from_bytes(hashlib.sha256(data).digest()[:4], "big") & 0x7fffffff
1823
+ if self._known.get("image:" + name) == revision:
1824
+ continue
1825
+ carried[name] = {"deferred": True, "revision": revision}
1826
+
1933
1827
  def _send_encoded_image(self, key, deferred):
1934
- """A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
1935
-
1936
- A modelling program keeps a picture compressed as its file and lets the GPU hold the
1937
- decoded texture; only this side's raster door decoded every picture into RGBA and shipped
1938
- that. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material pictures are 171 MB as
1939
- their packed files and 1,343 MB as RGBA, and the hosted tab's renderer passed 10 GB while
1940
- it opened. So a picture whose texels ARE its file -- a plain file or packed PNG or JPEG,
1941
- not edited in this session -- is written to the engine's filesystem as those bytes and
1942
- crosses as they are (`worker.ts`), decoded by the browser. The rest keep the raster door:
1943
- a key with `@` (a roughness remap bakes new texels), a picture painted here (`is_dirty`),
1944
- a UDIM or generated one, or bytes neither decoder reads.
1945
-
1946
- Answers whether it was sent."""
1828
+ """Send unchanged file-backed encoded images directly when their bytes represent the frame’s texels. Painted, generated and UDIM images retain the raw-pixel path. Return whether the image was sent."""
1947
1829
  if "@" in key:
1948
1830
  return False
1949
1831
  image = bpy.data.images.get(key)
@@ -1957,11 +1839,8 @@ class Session:
1957
1839
  data = fh.read()
1958
1840
  except (OSError, TypeError, ValueError):
1959
1841
  return False
1960
- if data[:8] == b"\x89PNG\r\n\x1a\n":
1961
- mime = "image/png"
1962
- elif data[:3] == b"\xff\xd8\xff":
1963
- mime = "image/jpeg"
1964
- else:
1842
+ mime = _encoded_mime(data)
1843
+ if mime is None:
1965
1844
  return False
1966
1845
  path = os.path.join(ROOT, "encoded-image.bin")
1967
1846
  with open(path, "wb") as fh:
@@ -1970,7 +1849,9 @@ class Session:
1970
1849
  _asked({"image": key, "piece": {
1971
1850
  "revision": deferred["revision"],
1972
1851
  "mime": mime,
1973
- "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
1852
+ # (a picture this Blender could not load has no colour space name; an 8-bit file's default is sRGB)
1853
+ "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" or (
1854
+ image.colorspace_settings.name == "" and not image.colorspace_settings.is_data) else "data",
1974
1855
  "encodedPath": path,
1975
1856
  }})
1976
1857
  finally:
@@ -1978,137 +1859,43 @@ class Session:
1978
1859
  return True
1979
1860
 
1980
1861
  def _drop_unreachable_textures(self, frame):
1981
- """A picture the export could not read is a WARNING, not a refusal.
1982
-
1983
- The door writes an image by reading its ibuf; an image it cannot read
1984
- is named by the door (`image <name> has no readable pixels`) and left
1985
- out of the frame -- while the MATERIAL still carries the texture
1986
- reference. The presenter then refuses BY NAME
1987
- (`RUNTIME_FRAME_UNKNOWN_IMAGE`), and that refusal cannot be satisfied:
1988
- re-sending the frame in full re-exports the same unreadable image, so
1989
- the second present dies the same way and the whole call fails.
1990
-
1991
- WHY an image is unreadable is the EXPORT DOOR's question and is not
1992
- answered here, so this says only what it observed. Three mechanisms
1993
- that would explain it were measured in the tab on 2026-09-18 and all
1994
- three SHIP correctly, so none of them is it: a `images.new` image
1995
- filled through `pixels.foreach_set`; a PNG written with `image.save()`
1996
- and read back with `images.load`; and the same PNG written and read
1997
- through a `replay_fs`-style symlinked run directory.
1998
-
1999
- MEASURED 2026-09-18: the five-model battery's `17-workshop-interior`,
2000
- `18-riverside-bridge`, `19-tram-stop` and `20-rigged-courier` each got
2001
- ZERO calls, refused inside `blender-start` -- `bind_document` opened
2002
- the document the previous model wrote, whose textures live OUTSIDE the
2003
- project (another folder of the machine that wrote it) and
2004
- are in no fresh worker's filesystem.
2005
-
2006
- THE PRESENTER'S REFUSAL IS RIGHT AND STAYS. It answers "the record says
2007
- I hold this and I do not", which is the desync `_reconcile_with_presenter`
2008
- exists for. What is wrong is asking it about a picture the session
2009
- NEVER SENT AND CANNOT SEND. So a texture reference naming an image that
2010
- is in neither this frame nor `_known` is dropped here, and the
2011
- mechanism is named once (`warn`) -- the material falls back to its own
2012
- socket colour, which is this file's stated rule for a capability the
2013
- export cannot reach.
2014
-
2015
- A name `_known` still holds is left alone: that IS the desync case, and
2016
- the refusal it raises is the signal that clears the tables and re-ships.
2017
- """
2018
- carried = frame.get("images") or {}
2019
- for material in (frame.get("materials") or {}).values():
1862
+ """Warn for unreadable images and mark their material references as missing so the presenter can draw Blender’s missing-image colour. Unknown previously sent images still trigger desynchronization recovery."""
1863
+ carried = frame["images"]
1864
+ for material in frame["materials"].values():
2020
1865
  for slot in ("texture", "roughness_texture", "normal_texture"):
2021
1866
  reference = material.get(slot)
2022
- if not isinstance(reference, dict):
1867
+ if reference is None:
2023
1868
  continue
2024
- name = (reference.get("image") or {}).get("name")
2025
- if name is None or name in carried or ("image:" + name) in self._known:
1869
+ name = reference["image"]["name"]
1870
+ if name in carried or ("image:" + name) in self._known:
2026
1871
  continue
2027
1872
  del material[slot]
2028
- # THE TWO CAUSES THIS WARNING HAS HAD, and the one line that
2029
- # tells them apart -- because the text names `write_image` and
2030
- # a reader reasonably concludes the EXPORT is broken, which
2031
- # twice it was not. `write_image` says "no readable pixels"
2032
- # when `BKE_image_acquire_ibuf` hands back nothing, and the
2033
- # image is usually the thing at fault, not the export:
2034
- #
2035
- # 1. THE IMAGE NEVER LOADED. `source == 'FILE'` and the
2036
- # filepath does not resolve inside the program -- a
2037
- # relative path saved against a directory the guest
2038
- # filesystem does not carry is the worked case. Measured
2039
- # 2026-09-19 (WS-W): a probe's `models/model.blend`
2040
- # raised 18 of these, and every one of its images read
2041
- # `has_data False, size (0, 0), len(pixels) 0`.
2042
- # 2. THE PROGRAM WAS OUT OF MEMORY. Measured 2026-09-19 on
2043
- # the WALI skew: `16-market-courtyard` raised eight of
2044
- # these while the module was linked
2045
- # `-Wl,--max-memory=1073741824`, and raising that link to
2046
- # 4 GiB made all eight vanish -- same recording, same
2047
- # staged textures, same door, 95/95 calls and a silent
2048
- # console.
2049
- #
2050
- # So before suspecting the export, ask the images:
2051
- # for im in bpy.data.images:
2052
- # print(im.name, im.source, tuple(im.size), im.has_data,
2053
- # im.filepath)
2054
- # (0, 0) with has_data False is cause 1 or 2, never the export.
1873
+ # Warn when neither this frame nor the retained record can supply the image.
2055
1874
  warn("material %s: the export could not read the picture %s, so this "
2056
1875
  "frame draws the material without it. The image is in neither "
2057
1876
  "this frame nor the presenter's record, so nothing can be "
2058
1877
  "re-sent to satisfy it; the export door is what knows why "
2059
1878
  "(`bpy_web_export.cc::write_image`)."
2060
- % (material.get("name", "?"), name))
2061
-
2062
- # A MATERIAL'S NODE GRAPH samples its pictures through Image Texture nodes, which
2063
- # the slots above do not cover: a graph naming a picture that was never sent had
2064
- # the presenter refuse the whole frame, so a file whose textures are missing did
2065
- # not open at all (measured on `16-market-courtyard` saved with its textures
2066
- # outside the project). Blender opens such a file and draws each missing picture
2067
- # magenta; the node is kept, marked, and the presenter draws it so.
1879
+ % (material["name"], name))
1880
+
1881
+ # Mark unreadable node-graph images as missing, retaining the nodes so the presenter draws them magenta.
2068
1882
  graph = material.get("graph")
2069
- for node in ((graph or {}).get("nodes") or {}).values():
2070
- props = node.get("props") or {}
1883
+ for node in graph["nodes"].values() if graph is not None else ():
1884
+ props = node["props"]
2071
1885
  image = props.get("image")
2072
- name = image.get("name") if isinstance(image, dict) else None
2073
- if name is None or name in carried or ("image:" + name) in self._known:
1886
+ if image is None:
1887
+ continue
1888
+ name = image["name"]
1889
+ if name in carried or ("image:" + name) in self._known:
2074
1890
  continue
2075
1891
  props["image"] = None
2076
1892
  props["image_missing"] = True
2077
1893
  warn("material %s: the picture %s is not readable in this program (%s), so it "
2078
1894
  "draws magenta, as Blender draws a missing image."
2079
- % (material.get("name", "?"), name, "a path outside the project, or no pixels"))
1895
+ % (material["name"], name, "a path outside the project, or no pixels"))
2080
1896
 
2081
1897
  def _reconcile_with_presenter(self, answer, frame):
2082
- """THE PRESENTER'S REPORT OUTRANKS THE SESSION'S RECORD.
2083
-
2084
- `_known` is what this session BELIEVES the tab holds, accumulated one
2085
- present at a time. The presenter is what actually holds it, and the two
2086
- come apart with neither side failing: the worker and this Python
2087
- session belong to the TAB (`blender-runtime-host.ts` keeps one
2088
- `BlenderRuntime`), while the presenter belongs to the Model DOCUMENT --
2089
- so rebuilding that document (closing and reopening it, a play stall
2090
- that remounts it) leaves a view holding nothing under a session whose
2091
- tables say everything crossed. Every later frame then ships references
2092
- to bytes that are gone.
2093
-
2094
- MEASURED 2026-09-18, the battery's `19-tram-stop` and `20-rigged-courier`:
2095
- both got zero calls, refused at `blender-start` with
2096
- `RUNTIME_FRAME_UNKNOWN_IMAGE: the presenter does not hold runtime image
2097
- wood_planks_roughness.png@roughness(...) (revision 1)`.
2098
-
2099
- So every present's answer carries `held` -- what the presenter held
2100
- BEFORE this frame (`protocol.ts`) -- and a holding that is not this
2101
- session's means the accumulated record describes a presenter that is
2102
- gone. What survives is exactly what THIS frame carried: a mesh whose
2103
- columns crossed and every image in it (there is no unchanged-image
2104
- form). A mesh that went as a reference did not cross and is dropped.
2105
-
2106
- An ABSENT `held` is not a report and is judged as nothing: an older
2107
- presenter must not be read as "holds nothing" on every frame.
2108
-
2109
- `_known` is only ever narrowed here, never widened, so the correction
2110
- costs one re-ship and can never suppress one.
2111
- """
1898
+ """Reconcile the session’s known resources with the presenter’s held frame. A reopened document can discard data while the worker survives; narrow known resources so missing data is sent again."""
2112
1899
  if not isinstance(answer, dict) or "held" not in answer:
2113
1900
  self.presenter_held = "unreported"
2114
1901
  return
@@ -2122,7 +1909,7 @@ class Session:
2122
1909
  self._known["mesh:" + key] = mesh["revision"]
2123
1910
  for name, image in frame["images"].items():
2124
1911
  self._known["image:" + name] = image["revision"]
2125
- weights = frame.get("weights")
1912
+ weights = frame["weights"]
2126
1913
  if weights is not None and not weights.get("unchanged"):
2127
1914
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
2128
1915
  weights["digest"])
@@ -2132,28 +1919,36 @@ class Session:
2132
1919
  def bind_document(self, relative_path):
2133
1920
  """Name the session's document and OPEN it when the project has one.
2134
1921
 
2135
- One session holds one document. The path is stated at start and moves
2136
- only where Blender itself moved: a script's own `save_as_mainfile` (or
2137
- `open_mainfile`) to another `.blend` in the project retargets
2138
- `bpy.data.filepath`, and `follow_open_file` moves the document with it
2139
- after the call.
1922
+ The session holds one document at a time: the one the editor last opened
1923
+ here (`open-document`), and it moves otherwise only where Blender itself
1924
+ moved: a script's own `save_as_mainfile` (or `open_mainfile`) to another
1925
+ `.blend` in the project retargets `bpy.data.filepath`, and
1926
+ `follow_open_file` moves the document with it after the call.
2140
1927
 
2141
1928
  `open_mainfile` runs the session's `_load_post` like any other load
2142
1929
  (the export door's revision table is reset, the render engine ids are
2143
1930
  retaken), and the one present afterwards is what puts the reopened
2144
- model on the tab's screen.
1931
+ model on the tab's screen, as a new presentation session (`renew`).
2145
1932
  """
2146
1933
  self.document_relative = relative_path
2147
1934
  self.document = os.path.join(self.project, relative_path)
2148
1935
  self._foreign = False
2149
1936
  self._foreign_said = False
1937
+ self._outside_file = None
2150
1938
  if not os.path.exists(self.document):
2151
- return {"document": relative_path, "opened": False}
1939
+ # A DOCUMENT NOT YET WRITTEN OPENS EMPTY: Blender's empty file
1940
+ # (`read_homefile(use_empty=True)`: its scenes, windows and workspaces alone), loaded as the session's
1941
+ # own open, so its first save writes the new model, never the factory startup's cube, light and camera
1942
+ # a fresh worker holds, or the file it held before.
1943
+ self._binding = True
1944
+ try:
1945
+ bpy.ops.wm.read_homefile(use_empty=True)
1946
+ finally:
1947
+ self._binding = False
1948
+ return {"document": relative_path, "opened": False, "presented": self._present_opened(relative_path)}
2152
1949
  _asked({"checkpoint": "document-open"})
2153
1950
  _mark("open:before")
2154
- checkpointed_read = hasattr(_blender_web, "set_read_checkpoint")
2155
- if checkpointed_read:
2156
- _blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
1951
+ _blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
2157
1952
  self._binding = True
2158
1953
  try:
2159
1954
  bpy.ops.wm.open_mainfile(filepath=self.document)
@@ -2163,32 +1958,46 @@ class Session:
2163
1958
  raise
2164
1959
  finally:
2165
1960
  self._binding = False
2166
- if checkpointed_read:
2167
- _blender_web.set_read_checkpoint(None)
1961
+ _blender_web.set_read_checkpoint(None)
2168
1962
  _mark("open:after")
2169
1963
  _asked({"checkpoint": "document-opened"})
2170
1964
  size = os.path.getsize(self.document)
2171
- # THE STAGED COPY GOES ONCE BLENDER HAS READ IT. It sits in the engine's heap (WasmFS keeps
2172
- # file data in linear memory) and nothing reads it again: the load took everything into
2173
- # Blender's own memory, packed pictures included, and the next save writes the path anew.
2174
- # Freed before the first present, its bytes serve that present's evaluation instead of
2175
- # raising the heap's high-water mark by the file's size (307 MB for the Stoneguard file).
2176
- # The worker still holds the host's stamp for it, so it is not staged in again.
1965
+ # Remove the staged .blend after Blender loads it to release its WasmFS bytes before
1966
+ # presentation. The worker keeps its host stamp, and the next save rewrites the file.
2177
1967
  os.remove(self.document)
2178
- self.present()
2179
- # The load did not dirty anything: what is in memory IS the file.
2180
- self.save_due = False
1968
+ presented = self._present_opened(relative_path)
2181
1969
  return {"document": relative_path, "opened": True,
2182
1970
  "objects": len(bpy.data.objects),
2183
- "size": size}
1971
+ "size": size, "presented": presented}
1972
+
1973
+ def _present_opened(self, relative_path):
1974
+ """The first present of a document just opened, and whether it was taken.
1975
+
1976
+ A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
1977
+ failed the open would leave a saved document unopenable and every MCP call refused.
1978
+ The model is in Blender, so the session keeps it and says loudly that the view is behind;
1979
+ the next edit presents again, and can remove whatever the presenter could not take.
1980
+ """
1981
+ presented = True
1982
+ try:
1983
+ self.present()
1984
+ except Exception as thrown: # noqa: BLE001
1985
+ presented = False
1986
+ if _another_document_shown(thrown):
1987
+ # A document switched away during opening may refuse this frame. It presents again when it becomes active.
1988
+ _say("%s is open in Blender; the editor is showing another document, so it is presented when "
1989
+ "it is shown again" % relative_path)
1990
+ else:
1991
+ _say("@@VOLTER-ERROR %s is open in Blender, but its first present failed, so the Model "
1992
+ "document does not show it yet: %r" % (relative_path, thrown))
1993
+ # The load did not dirty anything: what is in memory IS the file.
1994
+ self.save_due = False
1995
+ return presented
2184
1996
 
2185
1997
  def loaded(self):
2186
- """A FILE WAS LOADED (`_load_post`). The session's own open of its document is the one
2187
- load that leaves the document's model in memory; any other replaces it with another
2188
- file's (`read_homefile` is Blender's File > New), and the session used to save that over
2189
- the document: a script that ran `read_homefile` on its own, meaning to Save As a new
2190
- piece next, emptied the open level on disk. MEASURED 2026-10-08 in the editor's Blender:
2191
- a document with four objects was saved with none."""
1998
+ """Track file loads that replace the session’s document model. The session’s own document
1999
+ open retains ownership; other loads suspend saving until the model belongs to the document
2000
+ again."""
2192
2001
  if self._binding:
2193
2002
  return
2194
2003
  self._foreign = True
@@ -2196,38 +2005,16 @@ class Session:
2196
2005
 
2197
2006
  def mark_changed(self):
2198
2007
  """A request that can change the model ran: the next present carries
2199
- `saveDue`, and the tab saves after an idle second."""
2008
+ `saveDue`, and the document is dirty until the workbench saves it. (Its actions too: `_kept_action_revisions`.)"""
2009
+ _ACTION_EDITS["count"] += 1
2200
2010
  if self.document is not None:
2201
2011
  self.save_due = True
2202
2012
 
2203
2013
  def follow_open_file(self):
2204
- """FOLLOW BLENDER'S SAVE AS: the document is the file Blender has open.
2205
-
2206
- Read after every `execute`, because a script is the one request that can
2207
- change which file Blender holds. `save_as_mainfile(filepath=X)` writes X
2208
- and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
2209
- Save goes there. The session used to keep saving to the file it was
2210
- started on, so the model a script had just saved as X was written over
2211
- the OLD file on the next idle save, and the editor refused X's own tab
2212
- because the worker still claimed the old one. SEEN 2026-10-07: an
2213
- agent built an obby through the Blender MCP, saved it as
2214
- `src/models/obby.blend`, and its level landed in `src/models/cube.blend`
2215
- instead -- so Play looked for `cube.play.ts`.
2216
-
2217
- What does NOT move, by Blender's own rules rather than ours:
2218
- `save_mainfile()` and a Save As to the same path leave the path where it
2219
- is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
2220
- original open (which is also how `save_document` writes, so the
2221
- session's own save never moves anything). `open_mainfile` does move it:
2222
- Blender then holds that file, and saving the old path would write one
2223
- file's model over another's.
2224
-
2225
- Answers None when nothing moved, `{"moved": True, "from", "document"}`
2226
- when the document followed Blender to another project `.blend`, and
2227
- `{"moved": False, "from", "outside"}` -- once per file -- when Blender
2228
- holds a file outside the project: the editor has no document there, so
2229
- the session keeps its own and keeps saving to it.
2230
- """
2014
+ """Follow Blender’s open file after execute. Save As and open_mainfile can move the bound
2015
+ document to another project .blend; saving a copy preserves the current binding. Return
2016
+ None when unchanged, a moved answer for a project path, or an outside-project refusal once
2017
+ per file. Never save another file’s in-memory model over the bound document."""
2231
2018
  if self.document is None:
2232
2019
  return None
2233
2020
  if not bpy.data.filepath:
@@ -2271,94 +2058,7 @@ class Session:
2271
2058
  self.document_relative)}
2272
2059
 
2273
2060
  def save_document(self):
2274
- """Write the document -- Blender's own format, by Blender's own operator.
2275
-
2276
- ONE WRITE, AND IT DOES NOT TOUCH THE SCRIPT'S DATABLOCKS. The save used
2277
- to run `bpy.ops.file.make_paths_relative()` and then write a second
2278
- time, so that a document would reopen on another machine. That rewrote
2279
- the filepath of every image in `bpy.data` -- datablocks the SCRIPT
2280
- owns and did not ask to have moved. MEASURED 2026-09-18,
2281
- `16-market-courtyard` seq 10: the model loads its textures from an
2282
- absolute host path the harness maps into the sandbox, the first idle
2283
- save at seq ~8 turned those filepaths into `//../../..` relative to
2284
- `/project/models/`, and the next texture write resolved against the
2285
- document's directory instead and died in Emscripten's FS with
2286
- `ErrnoError`. Nine of the courtyard's 95 calls ran. The document the
2287
- run left behind then named images that no longer resolved, so the three
2288
- models that opened it afterwards got zero calls apiece.
2289
-
2290
- `relative_remap` IS FALSE, and that is a measurement rather than a
2291
- reading of the operator's documentation. `save_as_mainfile`'s
2292
- `relative_remap` is described as remapping RELATIVE paths so they stay
2293
- valid from a new location; on this build it also rewrites ABSOLUTE
2294
- ones. MEASURED 2026-09-18 in the tab, four cells, one image loaded from
2295
- an absolute path outside the project, `/outside/wsh-outside/textures/wsh_abs.png`:
2296
-
2297
- relative_remap use_relative_paths img.filepath after the save
2298
- False True /outside/.../wsh_abs.png
2299
- False False /outside/.../wsh_abs.png
2300
- True False //../../../wsh-outside/.../wsh_abs.png
2301
- True True //../../../wsh-outside/.../wsh_abs.png
2302
-
2303
- `relative_remap` alone decides it and the preference does not enter.
2304
- So True is the same rewrite `make_paths_relative` was, arriving through
2305
- a different door, and False is the only value that leaves a script's
2306
- paths as the script wrote them. "Reopens on another machine" is served
2307
- by a script loading its project files through project-relative paths in
2308
- the first place, which is the script's choice to make.
2309
-
2310
- `copy` IS SET, and it is what makes "the session mutates nothing" true
2311
- of the save as a whole rather than only of the path flag above. Without
2312
- it `save_as_mainfile` MOVES the file: `bpy.data.filepath` becomes the
2313
- document, so a script that saved to its own path has been retargeted,
2314
- and every RELATIVE path it wrote now resolves from the document's
2315
- directory instead of the one the script chose.
2316
-
2317
- MEASURED 2026-09-18 on `18-riverside-bridge`, which is an ordinary
2318
- thing for a script to do: seq 26 is
2319
- `save_as_mainfile(filepath=os.path.join(ITEM, 'model.blend'))`, which
2320
- left its nine textures as `//inputs/<name>.png` relative to ITEM, and
2321
- seq 27-34 call `save_mainfile()` again. With the session's save moving
2322
- `bpy.data.filepath` to `models/model.blend` in between, those paths
2323
- resolved to `models/inputs/` — nothing — and each later save rebased
2324
- the already-relative path again, compounding to
2325
- `//../../probes/probes/ws-h-probe/models/inputs/bark_color.png`. Every
2326
- one of the bridge's pictures was unreadable from there, so the export
2327
- could not write them and the model presented untextured.
2328
-
2329
- The two flags are one rule, and neither alone is it: `relative_remap`
2330
- False is what leaves an ABSOLUTE path absolute, and `copy` is what
2331
- leaves a RELATIVE one resolving from the base the script gave it.
2332
-
2333
- What this costs, stated: a script's own `bpy.ops.wm.save_mainfile()`
2334
- writes where the SCRIPT pointed it, not the session's document. That is
2335
- the script's choice to make, which is the whole of the rule.
2336
-
2337
- `compress` IS stated, and that is the whole reason it appears here.
2338
- MEASURED 2026-09-18 in the tab: the operator's own RNA default is
2339
- False, but `save_as_mainfile` takes its value from
2340
- `preferences.filepaths.use_file_compression`, which is True in this
2341
- build -- so the first documents this lane wrote came out Zstandard
2342
- (`file` says "Zstandard compressed data", `head -c 12` is not
2343
- `BLENDER`). Blender reads either back, so nothing was broken; what was
2344
- wrong is that the BYTES of a file the project commits depended on a
2345
- per-machine preference. A document states its own format.
2346
-
2347
- THE FORMAT IT STATES IS COMPRESSED (Zstandard, Blender's own). Measured
2348
- 2026-10-08 on a character with a 43-clip library (1.3 million keys): the
2349
- uncompressed write was 101.9 MB, compressed 12.8 MB, and the save itself
2350
- took the same time (0.07-0.17 s against 0.10-0.14 s). A game scene with
2351
- that character was 120 MB uncompressed, past GitHub's 100 MB file limit,
2352
- and 17 MB compressed. Blender reads both.
2353
-
2354
- THE SAVE'S CHUNKED TRANSPORT READS THE BYTES (`document-chunks.mts`): it
2355
- sends the server only the chunks it lacks, and that reuse was measured
2356
- on uncompressed files. Measured again with compression, same character,
2357
- one small edit per save after the first: uncompressed sent 4.19 MB (one
2358
- chunk) in about 1.4 s end to end; compressed sent 2.31 MB in about
2359
- 0.26 s. Zstandard compresses in independent blocks, so an edit still
2360
- changes part of the file, not all of it.
2361
- """
2061
+ """Save Blender’s native document without rewriting script-owned datablock paths. Use relative_remap=False and compression; the worker returns the saved bytes to the bound project file."""
2362
2062
  self.save_due = False
2363
2063
  if self.document is None:
2364
2064
  return {"saved": False, "reason": "no-document"}
@@ -2366,19 +2066,9 @@ class Session:
2366
2066
  # Not the document's model (`loaded`): writing it would replace the file with another.
2367
2067
  return {"saved": False, "reason": "not-the-document", "document": self.document_relative,
2368
2068
  "holds": bpy.data.filepath or "a new, untitled file"}
2369
- # `bpy.data.is_dirty` IS NOT A PREDICATE HERE, and this is the measurement
2370
- # rather than a preference. Before native undo initialization it stayed
2371
- # False even after a script built fourteen objects; with explicit undo
2372
- # checkpoints it can stay True across read-only operations. It is not
2373
- # a reliable change detector for this request-driven background session.
2374
- #
2375
- # It was briefly used to skip a redundant write, and it froze the
2376
- # document after its first save -- a reopened session could model all
2377
- # day and never write again. The predicate is `mark_changed`'s instead:
2378
- # a request that can change the model ran. Coarser than a diff (a
2379
- # read-only script rewrites the file once), bounded by the tab's
2380
- # idle-second debounce, and it cannot miss a change. The reading still
2381
- # rides in the answer, as an instrument.
2069
+ # Save requests follow mark_changed, which records potentially mutating
2070
+ # operations. bpy.data.is_dirty is diagnostic: native undo checkpoints
2071
+ # do not reliably track this background session's mutations.
2382
2072
  dirty = bool(bpy.data.is_dirty)
2383
2073
  existed = os.path.exists(self.document)
2384
2074
  directory = os.path.dirname(self.document)
@@ -2397,12 +2087,38 @@ class Session:
2397
2087
  "document": self.document_relative,
2398
2088
  "size": os.path.getsize(self.document),
2399
2089
  "revision": self.revision,
2400
- # The instruments a reader needs to judge the save: what
2401
- # Blender thought of the file's state, and whether this was the
2402
- # first write.
2090
+ # Report the dirty state and whether the document already existed before this
2091
+ # write.
2403
2092
  "dirty": dirty, "existed": existed}
2404
2093
 
2405
2094
 
2095
+ def _encoded_mime(data):
2096
+ """The MIME type of a picture file the browser decodes and the tab takes as it is, else None."""
2097
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
2098
+ return "image/png"
2099
+ if data[:3] == b"\xff\xd8\xff":
2100
+ return "image/jpeg"
2101
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
2102
+ return "image/webp"
2103
+ return None
2104
+
2105
+
2106
+ def _browser_decoded_bytes(image):
2107
+ """A file image's bytes when this Blender holds no pixels for it and the file is one the browser decodes
2108
+ (WebP: the editor's Blender is built without it), else None."""
2109
+ if image is None or image.source != "FILE" or image.is_dirty or image.has_data:
2110
+ return None
2111
+ try:
2112
+ if image.packed_file is not None:
2113
+ data = bytes(image.packed_file.data)
2114
+ else:
2115
+ with open(bpy.path.abspath(image.filepath), "rb") as fh:
2116
+ data = fh.read()
2117
+ except (OSError, TypeError, ValueError):
2118
+ return None
2119
+ return data if _encoded_mime(data) == "image/webp" else None
2120
+
2121
+
2406
2122
  SESSION = Session()
2407
2123
 
2408
2124
 
@@ -2421,12 +2137,7 @@ def _drop(path):
2421
2137
  _say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
2422
2138
 
2423
2139
 
2424
- # HOW OFTEN TO LOOK, by how recently something happened. A present's answer
2425
- # and the next call of a gesture are usually a few milliseconds away, and a
2426
- # fixed 2 ms sleep put a whole sleep into each of them: a no-op execute spent
2427
- # 6-7 ms of its 8-10 ms coming back, most of it the present's `ask` waiting out
2428
- # sleeps on both skews (measured 2026-09-28). Brisk for 50 ms after activity,
2429
- # then the old 2 ms, so an idle session lists its directory no more than before.
2140
+ # Poll briskly for 50 ms after activity, then sleep 2 ms between idle directory reads.
2430
2141
  _BRISK_SECONDS = 0.05
2431
2142
 
2432
2143
 
@@ -2434,10 +2145,15 @@ def _pause(since):
2434
2145
  time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
2435
2146
 
2436
2147
 
2148
+ def _another_document_shown(thrown):
2149
+ """Whether a refusal is the tab's for a document it does not show (`blender-runtime-host.ts`'s
2150
+ `modelDocumentConflict`, "Blender is editing <held>, but the editor is showing <other>"): the editor moved on to
2151
+ another document while this one was opening, which is no fault of the document's."""
2152
+ return "but the editor is showing" in str(thrown)
2153
+
2154
+
2437
2155
  def _asked(payload):
2438
- """`ask`, failing as the tab failed: a piece the worker could not take comes back as
2439
- `{"error": ...}`, and a pull that read past it presented a frame missing that piece, refused
2440
- later under another name ("deferred and never pulled")."""
2156
+ """Raise the tab's structured error before a refused piece can enter a frame."""
2441
2157
  answer = ask(payload)
2442
2158
  if isinstance(answer, dict) and answer.get("error"):
2443
2159
  raise RuntimeError("The tab refused %s: %s" % (
@@ -2449,11 +2165,9 @@ def ask(payload):
2449
2165
  """Block until the tab answers. The worker's own thread is free while this
2450
2166
  waits; only the Blender pthread is held, which is where the operator is.
2451
2167
 
2452
- THE REQUEST IS OURS AND THE REPLY IS THE PAGE'S -- see the header's
2453
- ownership table. We write `ask/<n>.json` + `ask/<n>.done`, read the page's
2454
- `reply/<n>.json` once `reply/<n>.done` appears, and then remove only our
2455
- own two files; their disappearance is what tells the page it may retire
2456
- the reply it wrote. `.done` goes last, both times."""
2168
+ The guest writes ask/<n>.json and ask/<n>.done, then reads reply/<n>.json
2169
+ after the page writes reply/<n>.done. It removes only its own ask files
2170
+ to acknowledge the reply. Each writer creates and removes .done last."""
2457
2171
  _ASK_SEQUENCE[0] += 1
2458
2172
  name = str(_ASK_SEQUENCE[0])
2459
2173
  with open(os.path.join(ASK, name + ".json"), "w") as fh:
@@ -2474,20 +2188,7 @@ def ask(payload):
2474
2188
  # ---------------------------------------------------------------- the render engine
2475
2189
 
2476
2190
  def _vertical_extent(cam, width, height):
2477
- """The photograph's vertical field of view in degrees (or, for an
2478
- orthographic camera, its vertical extent in Blender units), framed the way
2479
- Blender frames a render: `BKE_camera_params_compute_viewplane`.
2480
-
2481
- Blender's `sensor_fit` says which sensor dimension spans which image
2482
- dimension. HORIZONTAL: `sensor_width` spans the image width, and the
2483
- vertical extent follows from the aspect. VERTICAL: `sensor_height` spans the
2484
- height. AUTO: `sensor_width` spans the LARGER image dimension. The previous
2485
- line sent `camera.angle_y`, which Blender derives from `sensor_height`
2486
- alone, so under AUTO or HORIZONTAL fit a 36x24 sensor photographed 1.5x too
2487
- wide a view -- measured on the ten-model battery (2026-09-18): every hero
2488
- image differed across ~100% of pixels by exactly the 36/24 ratio, and
2489
- `sensor_height = 36` made rendered/analytic 0.9991. Pixel aspect is not
2490
- applied; the presenter renders square pixels."""
2191
+ """Compute vertical field of view or orthographic extent using Blender’s sensor-fit rules. HORIZONTAL uses sensor_width across image width; VERTICAL uses sensor_height across height; AUTO fits the larger image dimension. The presenter uses square pixels."""
2491
2192
  import math
2492
2193
  landscape = width >= height
2493
2194
  fit = cam.sensor_fit
@@ -2503,11 +2204,11 @@ def _vertical_extent(cam, width, height):
2503
2204
  return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
2504
2205
 
2505
2206
 
2506
- def _photograph(depsgraph, width, height, linear=False):
2207
+ def _photograph(depsgraph, width, height, linear=False, viewport=False):
2507
2208
  """three.js IS the renderer: the render is a photograph of the scene the
2508
2209
  engine holds, taken through the scene's own camera at `scene.render`'s
2509
2210
  exact resolution. `bpy.ops.render.render` semantics are the contract."""
2510
- scene = depsgraph.scene if hasattr(depsgraph, "scene") else bpy.context.scene
2211
+ scene = depsgraph.scene
2511
2212
  camera = scene.camera
2512
2213
  if camera is None:
2513
2214
  raise RuntimeError("The scene has no camera, so there is nothing to render")
@@ -2524,12 +2225,14 @@ def _photograph(depsgraph, width, height, linear=False):
2524
2225
  # matrix (`blender-runtime-host.ts`).
2525
2226
  up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
2526
2227
  view = scene.view_settings
2527
- look = getattr(view, "look", "None") or "None"
2228
+ look = view.look or "None"
2528
2229
  transform = {
2529
2230
  "Standard": "none",
2530
2231
  "AgX": "agx",
2531
2232
  "Filmic": "filmic",
2532
2233
  "Khronos PBR Neutral": "neutral",
2234
+ "ACES 1.3": "aces13",
2235
+ "ACES 2.0": "aces20",
2533
2236
  }.get(view.view_transform)
2534
2237
  if transform is None:
2535
2238
  raise RuntimeError(
@@ -2558,32 +2261,27 @@ def _photograph(depsgraph, width, height, linear=False):
2558
2261
  # result is what a render leaves, and it holds scene-linear pixels.
2559
2262
  if linear:
2560
2263
  render["linear"] = True
2561
- answer = SESSION.photograph(
2562
- depsgraph, {"position": position, "target": target, "up": up, "render": render}
2563
- )
2264
+ pose = {"position": position, "target": target, "up": up, "render": render}
2265
+ # Capture the already-presented scene through its camera using the ordinary incremental present path.
2266
+ answer = SESSION.present(pose) if viewport else SESSION.photograph(depsgraph, pose)
2564
2267
  if not isinstance(answer, dict) or "base64" not in answer:
2565
2268
  raise RuntimeError("The renderer did not answer with a photograph")
2566
2269
  _assert_photographed_from(answer.get("camera"), position, target, up)
2567
2270
  return base64.b64decode(answer["base64"]), render, answer
2568
2271
 
2569
2272
 
2273
+ def photograph_frame(width, height):
2274
+ """A FILM'S FRAME (`render-movie`): the scene at its current frame, as the viewport presents it,
2275
+ photographed through the scene camera (marker cuts included, as Blender switches the camera on a
2276
+ frame change) at `width` x `height`, with the scene's view transform. Answers the PNG's bytes."""
2277
+ png, _render, _answer = _photograph(bpy.context.evaluated_depsgraph_get(), width, height, viewport=True)
2278
+ return png
2279
+
2280
+
2570
2281
  def _assert_photographed_from(reported, position, target, up):
2571
- """The photograph answers with the pose it ACTUALLY used, back in Blender's
2572
- frame, and it must be the pose that was sent -- exactly, with no tolerance.
2573
-
2574
- This is an instrument, not a nicety. A render's image is allowed to differ
2575
- from Cycles (three.js is the renderer), so a camera placed in the wrong
2576
- frame produces a picture nobody can call wrong: the lane shipped for its
2577
- whole life photographing the model's underside from below the floor because
2578
- Blender's Z-up numbers were read as three.js world space, and every hero
2579
- image the battery graded came from that camera.
2580
-
2581
- The check is exact because the conversion is exact: the model root's world
2582
- matrix is a signed axis permutation (`blender-runtime-view.ts` sets the
2583
- matrix outright rather than a float quarter turn), so a vector through it
2584
- and its inverse is bit-identical. A mismatch is the conversion being wrong,
2585
- never the check being too strict -- do not widen it.
2586
- """
2282
+ """Require the photograph’s camera pose, transformed back into Blender’s frame, to equal the sent
2283
+ pose exactly. The model root uses a signed axis permutation, so the transform and its inverse
2284
+ preserve the values without a floating-point tolerance."""
2587
2285
  sent = {"position": position, "target": target, "up": up}
2588
2286
  if not isinstance(reported, dict):
2589
2287
  raise RuntimeError(
@@ -2650,20 +2348,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
2650
2348
 
2651
2349
 
2652
2350
  def _fill_result(engine, result, answer, png, width, height):
2653
- """THE RENDER RESULT HOLDS SCENE-LINEAR PIXELS, and Blender runs the scene's
2654
- view transform over them wherever it shows or saves one (`write_still`, the
2655
- Image Editor), as it does over Cycles'. The photograph's PNG is the display
2656
- image -- the transform already ran -- so loading it put every render
2657
- through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
2658
- render view: a background 53 against the viewport's 59, a shadow's blue 2
2659
- against 7. The same capture's linear frame goes in instead: half floats,
2660
- RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
2661
- 0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
2662
- path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
2663
- as the PNG path did.
2664
-
2665
- A renderer that sends no linear frame gets the old path, and says so."""
2666
- linear = answer.get("linearBase64") if isinstance(answer, dict) else None
2351
+ """Load scene-linear capture pixels into RenderResult so Blender applies the scene view transform exactly once. A presenter without linear pixels uses the declared PNG path."""
2352
+ linear = answer.get("linearBase64")
2667
2353
  if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
2668
2354
  import numpy
2669
2355
 
@@ -2696,8 +2382,7 @@ def _engine_class(identifier):
2696
2382
 
2697
2383
  A Python-registered engine is exposed on `bpy.types` under its CLASS name
2698
2384
  (`bpy.types.CyclesRender`), never under its id, so `bpy.types.CYCLES` is
2699
- always absent -- looking there found nothing and left the add-on's engine
2700
- in place. The subclass tree is the one true registry."""
2385
+ always absent. Search the RenderEngine subclass tree by `bl_idname`."""
2701
2386
  pending = list(bpy.types.RenderEngine.__subclasses__())
2702
2387
  while pending:
2703
2388
  cls = pending.pop()
@@ -2708,28 +2393,8 @@ def _engine_class(identifier):
2708
2393
 
2709
2394
 
2710
2395
  def _release_from_owning_addon(existing):
2711
- """Take the class out of its OWNING ADD-ON's registration list as well.
2712
-
2713
- `unregister_class(existing)` is only half the gesture. The add-on that
2714
- registered the class still holds it in its module-level `classes`, and
2715
- Blender tears every add-on down again on `read_factory_settings` and on
2716
- each `open_mainfile` -- so the add-on's own `unregister()` calls
2717
- `unregister_class` on a class that is already gone and raises
2718
- `RuntimeError: missing bl_rna attribute`. `addon_utils.disable` catches
2719
- that and prints a full traceback, and the remainder of that add-on's
2720
- `unregister()` never runs.
2721
-
2722
- MEASURED 2026-09-18 through the editor tab, running Blender's own
2723
- `tests/python` suites against the bundle: 25 of 43 suites printed
2724
- `Exception in module unregister(): '/bw/scripts/addons_core/cycles/
2725
- __init__.py'`, and the native oracle at the same pin (5.2.0 LTS,
2726
- fbe6228777e7) printed it in NONE of them -- the add-on is intact there
2727
- because nothing took its engine id. Dropping the class from `classes`
2728
- makes the add-on's next `register()`/`unregister()` pair agree with what
2729
- this session actually did, which is what keeps `scene.cycles` (the reason
2730
- the add-on stays enabled at all) reachable after a factory reset.
2731
- """
2732
- module = sys.modules.get(getattr(existing, "__module__", ""))
2396
+ """Remove a replaced engine class from its add-on’s registration list as well as Blender’s registry, so file loads and factory resets can unregister the remaining add-on classes."""
2397
+ module = sys.modules.get(existing.__module__)
2733
2398
  classes = getattr(module, "classes", None)
2734
2399
  if isinstance(classes, tuple):
2735
2400
  if existing in classes:
@@ -2778,14 +2443,7 @@ def _register_engine():
2778
2443
  bpy.utils.register_class(engine)
2779
2444
  made.append(identifier)
2780
2445
  except Exception: # noqa: BLE001
2781
- # Upstream Blender refuses to let a Python engine take a BUILT-IN
2782
- # id ("is built-in"); the shipped bundle carries a patch that sets
2783
- # the built-in type aside instead, since headless has no GPU for it
2784
- # to render with, so the two GPU ids are taken there (measured
2785
- # 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
2786
- # and `BLENDER_WORKBENCH`: that is a fact about the build, stated
2787
- # in the start reply and refused by name at the render, never a
2788
- # warning on every boot.
2446
+ # Report an engine id whose registration failed.
2789
2447
  unavailable.append(identifier)
2790
2448
  # `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
2791
2449
  # again after every file load (the ids have to be retaken), and Blender
@@ -2889,12 +2547,8 @@ class NativeHistory:
2889
2547
  finally:
2890
2548
  self.moving = False
2891
2549
  self.cursor += -1 if direction == "undo" else 1
2892
- # AN UNDO STEP IS AN ORDINARY UPDATE when Blender kept its datablocks and depsgraph:
2893
- # memfile undo re-reads only the changed IDs and tags exactly those, so the next frame
2894
- # re-ships what changed and the presenter keeps the rest (`session_undo`). A step that
2895
- # replaced the depsgraph starts both revision caches over, as does an engine without
2896
- # the door.
2897
- if not hasattr(_blender_web, "session_undo") or _blender_web.session_undo():
2550
+ # An undo that retains the depsgraph sends only changed IDs; a replaced depsgraph resets revision caches.
2551
+ if _blender_web.session_undo():
2898
2552
  _blender_web.session_reset()
2899
2553
  SESSION.forget()
2900
2554
  SESSION.present()
@@ -2966,12 +2620,8 @@ def get_object_info(name):
2966
2620
 
2967
2621
 
2968
2622
  def execute(code):
2969
- """Run one script in a namespace of its own, `{"bpy": bpy}`, which is what
2970
- the Blender MCP add-on gives every call. A helper one call defines reaches
2971
- a later call only through `bpy.app.driver_namespace`, and a name a later
2972
- call binds never reaches an earlier call's closure: with one dict shared
2973
- across calls, the courtyard's render helper (defined at seq 29) found its
2974
- `sc` rebound to a tuple by seq 35 and failed where the recording did not."""
2623
+ """Run one script in its own `{"bpy": bpy}` namespace. Calls can share helpers
2624
+ through `bpy.app.driver_namespace`; local names and closures stay isolated."""
2975
2625
  namespace = {"bpy": bpy}
2976
2626
  captured = io.StringIO()
2977
2627
  error = None
@@ -2984,14 +2634,7 @@ def execute(code):
2984
2634
  return {"executed": error is None, "result": captured.getvalue(), "error": error}
2985
2635
 
2986
2636
 
2987
- # CAPABILITIES COMPILED OUT OF THIS BUILD, refused BY NAME at the door. Blender
2988
- # built without them keeps their operators registered and reports success while
2989
- # doing nothing: measured on `06-cable-gripper` (2026-09-18),
2990
- # `bpy.ops.rigidbody.world_add()` returned {'FINISHED'} with `scene.rigidbody_world`
2991
- # still None, and the script died three lines later on the None. That is the
2992
- # silent degrade the rulings forbid. The build option is Blender's own fact
2993
- # (`bpy.app.build_options`); the keyword is the operator namespace a script
2994
- # reaches the capability through. Same shape as the render-engine refusal above.
2637
+ # Refuse script requests for capabilities disabled by bpy.app.build_options, naming the operator namespace and unavailable build feature.
2995
2638
  _ABSENT_CAPABILITIES = (
2996
2639
  ("bullet", "rigidbody", "rigid body physics (Bullet)"),
2997
2640
  ("fluid", "bpy.ops.fluid", "fluid simulation (Mantaflow)"),
@@ -3002,31 +2645,14 @@ _ABSENT_CAPABILITIES = (
3002
2645
  def _absent_capability(code):
3003
2646
  options = bpy.app.build_options
3004
2647
  for option, keyword, capability in _ABSENT_CAPABILITIES:
3005
- if keyword in code and not getattr(options, option, True):
2648
+ if keyword in code and not getattr(options, option):
3006
2649
  return ("CapabilityUnavailable: %s is not compiled into this Blender build, so "
3007
2650
  "`%s` operators are refused rather than reported as done with nothing "
3008
2651
  "created. (bpy.app.build_options.%s is False.)" % (capability, keyword, option))
3009
2652
  return _absent_essentials_assets(code)
3010
2653
 
3011
2654
 
3012
- # THE ESSENTIALS ASSET LIBRARY is not a build option, so it cannot be refused
3013
- # from `bpy.app.build_options` like the three above -- it is a PAYLOAD fact, and
3014
- # the only honest predicate is the directory itself.
3015
- #
3016
- # MEASURED 2026-09-18 in the tab: `bpy.utils.system_resource('DATAFILES')` is
3017
- # `/bw/datafiles` and holds exactly `['colormanagement', 'fonts']`, because the
3018
- # preload payload ships those two and nothing else from `release/datafiles`
3019
- # (recipe/blender_web_headless.cmake). So
3020
- # `system_resource('DATAFILES', path='assets')` answers `''`, and Blender
3021
- # reports the empty string back to the caller verbatim:
3022
- #
3023
- # bpy.ops.object.shade_auto_smooth(use_auto_smooth=True)
3024
- # RuntimeError: Error: No asset found at path ""
3025
- #
3026
- # Running Blender's own suites against the bundle, that one absence is every
3027
- # failing assertion in `object_edit` (1) and `sculpt_paint/brush_asset_test`
3028
- # (4) -- both clean on the native oracle at the same pin. A message naming the
3029
- # empty path is not something an author can act on; naming the library is.
2655
+ # Check the Essentials asset-library directory itself; availability is a payload property rather than a bpy.app.build_options flag.
3030
2656
  _ESSENTIALS_KEYWORDS = ("shade_auto_smooth", "asset_activate", "ESSENTIALS")
3031
2657
 
3032
2658
 
@@ -3049,9 +2675,7 @@ def _absent_essentials_assets(code):
3049
2675
 
3050
2676
  # ------------------------------------------------------------------ the RNA door
3051
2677
  #
3052
- # EVERYTHING BLENDER SHOWS ABOUT A DATABLOCK, READ THROUGH RNA (ARCHITECTURE-CORE
3053
- # §Blender north star, "Inspection parity, not editing parity"; WORK.md §Blender
3054
- # in the tab is Blender, "Inspection parity", I1). Blender's UI layer is never
2678
+ # DATABLOCK PROPERTIES READ THROUGH RNA. Blender's UI layer is never
3055
2679
  # run, ported or recorded here: what this answers is `bl_rna.properties` --
3056
2680
  # Blender's own introspection of its own data -- as typed rows, and what our
3057
2681
  # panels draw is those rows.
@@ -3076,20 +2700,7 @@ _RNA_STRING_LIMIT = 4096
3076
2700
  # struct carries it and it points at the struct's own type.
3077
2701
  _RNA_SKIP = ("rna_type",)
3078
2702
 
3079
- # BLENDER'S PYTHON LAYER ADDS PROPERTIES RNA DOES NOT KNOW. `bl_rna.properties`
3080
- # is RNA's own list, and `scripts/modules/bpy_types.py` puts ordinary Python
3081
- # `property` descriptors on top of the generated classes -- `Object.children`,
3082
- # `Object.users_collection`, `Object.users_scene`, `Bone.children`. They are
3083
- # part of the API Blender's OWN panels read: `properties_object.py:201` draws
3084
- # the Collections panel straight out of `obj.users_collection`, which is why
3085
- # that panel came up EMPTY here until the door answered them (measured
3086
- # 2026-09-19; `users_collection` appears nowhere under `source/blender/makesrna`
3087
- # at the pin, so nothing in the RNA iteration could ever have found it).
3088
- #
3089
- # SKIPPED BY A RULE, NOT A LIST: a name ending `_recursive` is Blender's own
3090
- # spelling for a walker that re-derives a whole subtree on every read
3091
- # (`children_recursive`, `parent_recursive`), and what it holds is one drill
3092
- # away through the plain property beside it.
2703
+ # Include Python property descriptors from bpy_types alongside RNA properties, excluding recursive variants when the immediate property already offers traversal.
3093
2704
  _PY_PROPERTY_SUFFIX_SKIP = ("_recursive",)
3094
2705
 
3095
2706
  _ID_COLLECTIONS = None
@@ -3105,7 +2716,7 @@ def _id_collections():
3105
2716
  for prop in bpy.data.bl_rna.properties:
3106
2717
  if prop.type != "COLLECTION":
3107
2718
  continue
3108
- fixed = getattr(prop, "fixed_type", None)
2719
+ fixed = prop.fixed_type
3109
2720
  if fixed is not None:
3110
2721
  table.setdefault(fixed.identifier, prop.identifier)
3111
2722
  return table
@@ -3122,7 +2733,7 @@ def _id_collection_for(datablock):
3122
2733
  name = _ID_COLLECTIONS.get(rna.identifier)
3123
2734
  if name:
3124
2735
  return name
3125
- rna = getattr(rna, "base", None)
2736
+ rna = rna.base
3126
2737
  return None
3127
2738
 
3128
2739
 
@@ -3138,7 +2749,7 @@ def _rna_address(struct):
3138
2749
  if collection is None:
3139
2750
  return None
3140
2751
  return 'bpy.data.%s["%s"]' % (collection, bpy.utils.escape_identifier(struct.name))
3141
- owner = getattr(struct, "id_data", None)
2752
+ owner = struct.id_data
3142
2753
  if owner is None:
3143
2754
  return None
3144
2755
  base = _rna_address(owner)
@@ -3164,23 +2775,17 @@ def _rna_resolve(path):
3164
2775
 
3165
2776
 
3166
2777
  def _rna_group(struct_rna, identifier):
3167
- """WHICH RNA STRUCT DECLARES THIS PROPERTY -- the only grouping RNA itself
3168
- exposes, and a real one: `Object`'s own properties read apart from the `ID`
3169
- fields every datablock carries. It is NOT Blender's panel layout. Which
3170
- properties a Properties panel draws, and in what order, lives in
3171
- `scripts/startup/bl_ui/properties_*.py`, which the ruling forbids running,
3172
- porting or recording; our own curated panels are I2 and name their RNA
3173
- themselves. Cached per struct -- the base chain is walked once."""
2778
+ """Find the RNA struct declaring a property and cache the result per struct. This groups RNA inheritance independently of Blender’s panel layout."""
3174
2779
  cached = _RNA_GROUPS.get(struct_rna.identifier)
3175
2780
  if cached is None:
3176
2781
  cached = {}
3177
2782
  for prop in struct_rna.properties:
3178
2783
  declaring = struct_rna
3179
- base = getattr(struct_rna, "base", None)
2784
+ base = struct_rna.base
3180
2785
  while base is not None:
3181
2786
  if prop.identifier in base.properties:
3182
2787
  declaring = base
3183
- base = getattr(base, "base", None)
2788
+ base = base.base
3184
2789
  cached[prop.identifier] = (declaring.identifier, declaring.name)
3185
2790
  _RNA_GROUPS[struct_rna.identifier] = cached
3186
2791
  return cached.get(identifier, (struct_rna.identifier, struct_rna.name))
@@ -3220,7 +2825,7 @@ def _rna_value(target, prop):
3220
2825
  if kind == "COLLECTION":
3221
2826
  value = getattr(target, prop.identifier)
3222
2827
  row = {"count": len(value)}
3223
- fixed = getattr(prop, "fixed_type", None)
2828
+ fixed = prop.fixed_type
3224
2829
  # A mesh's `vertices` has no `name`, and asking 100k of them for one is
3225
2830
  # exactly the serialisation this door refuses -- so the question is
3226
2831
  # asked of the TYPE, once, off its own RNA.
@@ -3236,14 +2841,7 @@ def _rna_value(target, prop):
3236
2841
  if kind == "ENUM":
3237
2842
  return sorted(value) if prop.is_enum_flag else value
3238
2843
  if getattr(prop, "array_length", 0):
3239
- # A MATRIX IS AN ARRAY OF ROWS, not of numbers. `bpy_prop_array` is
3240
- # multi-dimensional wherever `PropertyRNA.array_dimension > 1` --
3241
- # `Bone.matrix` is 3x3, `matrix_local` and `Object.matrix_world` 4x4 --
3242
- # and iterating one yields `Vector`s, which `float()` refuses by name
3243
- # ("could not convert string to float: Vector((1.0, 0.0, 0.0))").
3244
- # Measured live 2026-09-19 on the Bone tab's Transform panel, which is
3245
- # where a matrix first got named. Flattened in ROW-MAJOR order, which
3246
- # is how `array_length` already counts it (9 and 16, not 3 and 4).
2844
+ # Flatten multidimensional bpy_prop_array values in row-major order; RNA array_length counts scalar values rather than rows.
3247
2845
  return [_rna_scalar(item, kind) for item in _rna_flatten(value)]
3248
2846
  if kind == "BOOLEAN":
3249
2847
  return bool(value)
@@ -3290,19 +2888,7 @@ def _rna_row(target, prop):
3290
2888
  # by the presentation, so the door stays the whole surface.
3291
2889
  "hidden": bool(prop.is_hidden),
3292
2890
  }
3293
- # UNDRAWN STATE -- the second standing rule, ruled on after I2 measured it
3294
- # (WORK.md §Blender in the tab is Blender, "Inspection parity", I2, "The two
3295
- # standing rules"). `rna_define.cc:1311-1312` gives every property
3296
- # `prop->name = identifier` and `prop->description = ""` at definition, and
3297
- # `RNA_def_property_ui_text` is what replaces them -- so a property whose
3298
- # NAME is still its identifier and whose description is still empty is one
3299
- # Blender never gave UI text to. That is what the modifier panel-open
3300
- # booleans are (`rna_def_modifier_panel_open_prop`, `:2694-2705`, which sets
3301
- # `PROP_NO_DEG_UPDATE` and an sdna bit and no flag at all, so the
3302
- # PROP_HIDDEN rule above does not catch them): state a panel's open/closed
3303
- # arrow writes, not data about the datablock. Reported here and FILTERED by
3304
- # the generic view, exactly as `hidden` is; a curated list names none of
3305
- # them because `bl_ui` draws none of them.
2891
+ # Mark properties without UI text as undrawn when their name equals the identifier and their description is empty.
3306
2892
  if prop.name == prop.identifier and not prop.description:
3307
2893
  row["undrawn"] = True
3308
2894
  if prop.type in ("INT", "FLOAT"):
@@ -3314,20 +2900,20 @@ def _rna_row(target, prop):
3314
2900
  if prop.type == "FLOAT":
3315
2901
  row["precision"] = int(prop.precision)
3316
2902
  if prop.type == "STRING":
3317
- row["lengthMax"] = int(getattr(prop, "length_max", 0) or 0)
2903
+ row["lengthMax"] = int(prop.length_max)
3318
2904
  if prop.type == "ENUM":
3319
2905
  row["isFlag"] = bool(prop.is_enum_flag)
3320
2906
  try:
3321
2907
  items = prop.enum_items
3322
2908
  except Exception: # noqa: BLE001
3323
- items = getattr(prop, "enum_items_static", ())
2909
+ items = prop.enum_items_static
3324
2910
  row["items"] = [
3325
2911
  {"identifier": item.identifier, "name": item.name,
3326
2912
  "description": item.description, "icon": item.icon}
3327
2913
  for item in items
3328
2914
  ]
3329
2915
  if prop.type in ("POINTER", "COLLECTION"):
3330
- fixed = getattr(prop, "fixed_type", None)
2916
+ fixed = prop.fixed_type
3331
2917
  if fixed is not None:
3332
2918
  row["itemType"] = fixed.identifier
3333
2919
  if length > _RNA_ARRAY_LIMIT:
@@ -3352,8 +2938,7 @@ def _py_properties(cls):
3352
2938
  return cached
3353
2939
  found = []
3354
2940
  seen = set()
3355
- rna = getattr(cls, "bl_rna", None)
3356
- declared = set(rna.properties.keys()) if rna is not None else set()
2941
+ declared = set(cls.bl_rna.properties.keys())
3357
2942
  for base in cls.__mro__:
3358
2943
  for name, member in vars(base).items():
3359
2944
  if name.startswith("_") or name in seen or name in declared:
@@ -3450,16 +3035,7 @@ def _py_row(target, name, descriptor, owner):
3450
3035
 
3451
3036
 
3452
3037
  def _rna_member(collection_path, item, index):
3453
- """One member of a collection, ADDRESSED.
3454
-
3455
- `path_from_id()` is the first answer and the right one -- but it is not
3456
- always an answer: a `VertexGroup` has no RNA path back to its object (the
3457
- three groups of a skinned mesh came out with `path: null` on the first
3458
- live walk, 2026-09-19), while `bpy.data.path_resolve` resolves
3459
- `objects["X"].vertex_groups["chest"]` perfectly well. So where the struct
3460
- cannot say where it lives, the COLLECTION says it: this path plus the
3461
- member's own key. Index for a member with no name -- a mesh vertex -- which
3462
- is also how `path_from_id` would spell it."""
3038
+ """Address collection members with path_from_id when available, otherwise append their name or index to the collection’s own path."""
3463
3039
  address = _rna_address(item)
3464
3040
  name = getattr(item, "name", None)
3465
3041
  if address is None:
@@ -3576,11 +3152,11 @@ def _rna_active_object(name):
3576
3152
  return bpy.context.view_layer.objects.active
3577
3153
 
3578
3154
 
3579
- def _named(value, path=None):
3155
+ def _named(value):
3580
3156
  if value is None:
3581
3157
  return None
3582
- return {"name": getattr(value, "name", None),
3583
- "path": path if path is not None else _rna_address(value),
3158
+ return {"name": value.name,
3159
+ "path": _rna_address(value),
3584
3160
  "type": value.bl_rna.identifier}
3585
3161
 
3586
3162
 
@@ -3590,14 +3166,7 @@ def _tab(identifier, label, icon, paths):
3590
3166
 
3591
3167
 
3592
3168
  def _layer_collection_path(view_layer_path, root, target):
3593
- """A `LayerCollection` HAS NO `path_from_id()` -- measured live 2026-09-19,
3594
- the same shape as I1's `VertexGroup` finding, and it made
3595
- `COLLECTION_PT_viewlayer_flags`'s datablock unaddressable so the View Layer
3596
- sub-panel drew nothing. Its address is its POSITION in the view layer's
3597
- tree, which the tree itself can spell."""
3598
- if target is None:
3599
- return None
3600
-
3169
+ """Address a LayerCollection by its position in the view-layer tree because it has no path_from_id."""
3601
3170
  def walk(node, path):
3602
3171
  if node == target:
3603
3172
  return path
@@ -3613,12 +3182,14 @@ def _layer_collection_path(view_layer_path, root, target):
3613
3182
 
3614
3183
  def _texture_slot_users(users, owner, slots, label, name=None):
3615
3184
  """Blender's `buttons_texture_user_mtex_add`: one user per FILLED slot."""
3616
- for index, slot in enumerate(slots or ()):
3617
- texture = getattr(slot, "texture", None)
3185
+ for slot in slots:
3186
+ if slot is None:
3187
+ continue
3188
+ texture = slot.texture
3618
3189
  if texture is None:
3619
3190
  continue
3620
3191
  users.append({"label": label,
3621
- "name": name if name else getattr(owner, "name", None),
3192
+ "name": name if name else owner.name,
3622
3193
  "path": _rna_address(slot),
3623
3194
  "property": "texture",
3624
3195
  "texture": _named(texture)})
@@ -3629,11 +3200,11 @@ def _texture_node_users(users, tree, label):
3629
3200
  `texture` pointer is filled."""
3630
3201
  if tree is None:
3631
3202
  return
3632
- for node in getattr(tree, "nodes", ()):
3203
+ for node in tree.nodes:
3633
3204
  prop = node.bl_rna.properties.get("texture")
3634
3205
  if prop is None or prop.type != "POINTER":
3635
3206
  continue
3636
- texture = getattr(node, "texture", None)
3207
+ texture = node.texture
3637
3208
  if texture is None:
3638
3209
  continue
3639
3210
  users.append({"label": label, "name": node.name,
@@ -3642,58 +3213,41 @@ def _texture_node_users(users, tree, label):
3642
3213
 
3643
3214
 
3644
3215
  def _texture_users(scene, view_layer, obj):
3645
- """WHO USES A TEXTURE, mirrored from
3646
- `space_buttons/buttons_texture.cc::buttons_texture_users_from_context`
3647
- (`:244-366`) at the engine's pin -- in ITS order, because
3648
- `buttons_texture_context_compute` (`:369`) takes `ct->index` (0 unless a
3649
- person picks another) and the Texture tab then shows THAT user's texture.
3650
-
3651
- The C walks, in order: the scene's compositing node tree ("Compositor"),
3652
- the active line style's slots and node tree ("Line Style"), the object's
3653
- modifiers via `BKE_modifiers_foreach_tex_link` ("Modifiers"), the ACTIVE
3654
- particle system's `part.mtex[]` ("Particles"), the object's force field
3655
- when `forcefield == PFIELD_TEXTURE` ("Fields"), and the active paint
3656
- brush's own two slots ("Brush").
3657
-
3658
- NOTE, because it is easy to assume otherwise from older Blenders: a LIGHT
3659
- is not a texture user at this pin. `buttons_texture_users_from_context`
3660
- does not look at `ob->data` at all -- lamp textures went with 2.8's
3661
- renderer rewrite. Measured, not remembered."""
3216
+ """Report texture users in Blender buttons_texture_users_from_context order, with the active user index selecting the displayed texture."""
3662
3217
  users = []
3663
- _texture_node_users(users, getattr(scene, "compositing_node_group", None), "Compositor")
3218
+ _texture_node_users(users, scene.compositing_node_group, "Compositor")
3664
3219
 
3665
- freestyle = getattr(view_layer, "freestyle_settings", None)
3666
- linesets = getattr(freestyle, "linesets", None)
3667
- linestyle = getattr(getattr(linesets, "active", None), "linestyle", None)
3220
+ lineset = view_layer.freestyle_settings.linesets.active
3221
+ linestyle = lineset.linestyle if lineset is not None else None
3668
3222
  if linestyle is not None:
3669
- _texture_slot_users(users, linestyle, getattr(linestyle, "texture_slots", None),
3223
+ _texture_slot_users(users, linestyle, linestyle.texture_slots,
3670
3224
  "Line Style")
3671
- _texture_node_users(users, getattr(linestyle, "node_tree", None), "Line Style")
3225
+ _texture_node_users(users, linestyle.node_tree, "Line Style")
3672
3226
 
3673
3227
  if obj is not None:
3674
3228
  for modifier in obj.modifiers:
3675
3229
  for prop in modifier.bl_rna.properties:
3676
3230
  if prop.type != "POINTER":
3677
3231
  continue
3678
- fixed = getattr(prop, "fixed_type", None)
3232
+ fixed = prop.fixed_type
3679
3233
  if fixed is None or fixed.identifier != "Texture":
3680
3234
  continue
3681
- texture = getattr(modifier, prop.identifier, None)
3235
+ texture = getattr(modifier, prop.identifier)
3682
3236
  if texture is None:
3683
3237
  continue
3684
3238
  users.append({"label": "Modifiers", "name": modifier.name,
3685
3239
  "path": _rna_address(modifier),
3686
3240
  "property": prop.identifier,
3687
3241
  "texture": _named(texture)})
3688
- systems = getattr(obj, "particle_systems", None)
3689
- active_system = getattr(systems, "active", None) if systems is not None else None
3690
- settings = getattr(active_system, "settings", None)
3242
+ systems = obj.particle_systems
3243
+ active_system = systems.active
3244
+ settings = active_system.settings if active_system is not None else None
3691
3245
  if settings is not None:
3692
- _texture_slot_users(users, settings, getattr(settings, "texture_slots", None),
3246
+ _texture_slot_users(users, settings, settings.texture_slots,
3693
3247
  "Particles", name=active_system.name)
3694
- field = getattr(obj, "field", None)
3695
- if field is not None and getattr(field, "type", None) == "TEXTURE" \
3696
- and getattr(field, "texture", None) is not None:
3248
+ field = obj.field
3249
+ if field is not None and field.type == "TEXTURE" \
3250
+ and field.texture is not None:
3697
3251
  users.append({"label": "Fields", "name": "Texture Field",
3698
3252
  "path": _rna_address(field), "property": "texture",
3699
3253
  "texture": _named(field.texture)})
@@ -3701,17 +3255,17 @@ def _texture_users(scene, view_layer, obj):
3701
3255
  # `BKE_paint_brush(BKE_paint_get_active_from_context(C))`. Headless has no
3702
3256
  # paint mode, so this is whichever tool settings hold a brush at all --
3703
3257
  # asked of every `Paint` the tool settings carry rather than of a mode.
3704
- tools = getattr(bpy.context, "tool_settings", None)
3258
+ tools = bpy.context.tool_settings
3705
3259
  for member in ("image_paint", "sculpt", "vertex_paint", "weight_paint",
3706
3260
  "gpencil_paint", "curves_sculpt"):
3707
- paint = getattr(tools, member, None)
3708
- brush = getattr(paint, "brush", None)
3261
+ paint = getattr(tools, member) if tools is not None else None
3262
+ brush = paint.brush if paint is not None else None
3709
3263
  if brush is None:
3710
3264
  continue
3711
3265
  for slot, prop in (("texture_slot", "texture"), ("mask_texture_slot", "mask_texture")):
3712
- holder = getattr(brush, slot, None)
3713
- texture = getattr(brush, prop, None)
3714
- if holder is None or texture is None:
3266
+ holder = getattr(brush, slot)
3267
+ texture = getattr(brush, prop)
3268
+ if texture is None:
3715
3269
  continue
3716
3270
  users.append({"label": "Brush", "name": brush.name,
3717
3271
  "path": _rna_address(holder), "property": "texture",
@@ -3740,7 +3294,7 @@ def rna_context(object_name=None, collection_path=None):
3740
3294
  # then shows THAT collection. Activating it here would be a mutation the
3741
3295
  # document saves, so the row's own address arrives as a parameter and stands
3742
3296
  # in for `view_layer.active_layer_collection` for this read alone.
3743
- active_layer_collection = getattr(view_layer, "active_layer_collection", None)
3297
+ active_layer_collection = view_layer.active_layer_collection
3744
3298
  if collection_path:
3745
3299
  named = _rna_resolve(collection_path)
3746
3300
  if not isinstance(named, bpy.types.LayerCollection):
@@ -3749,7 +3303,7 @@ def rna_context(object_name=None, collection_path=None):
3749
3303
  "LayerCollection (`buttons_context_path_collection`)."
3750
3304
  % (collection_path, named.bl_rna.identifier))
3751
3305
  active_layer_collection = named
3752
- collection = getattr(active_layer_collection, "collection", None)
3306
+ collection = active_layer_collection.collection if active_layer_collection is not None else None
3753
3307
  if collection is not None and collection == scene.collection:
3754
3308
  collection = None
3755
3309
 
@@ -3774,7 +3328,7 @@ def rna_context(object_name=None, collection_path=None):
3774
3328
  # object whose data supports materials even when the slot is empty --
3775
3329
  # `OB_TYPE_SUPPORT_MATERIAL` is asked here as "does this data hold a
3776
3330
  # `materials` collection", which is the same set, read off RNA.
3777
- slot_index = max(int(getattr(obj, "active_material_index", 0)), 0)
3331
+ slot_index = max(int(obj.active_material_index), 0)
3778
3332
  slots = obj.material_slots
3779
3333
  slot = slots[slot_index] if slot_index < len(slots) else None
3780
3334
  held = slot.material if slot is not None else None
@@ -3790,16 +3344,15 @@ def rna_context(object_name=None, collection_path=None):
3790
3344
  particle_system = None
3791
3345
  if obj is not None:
3792
3346
  if obj.type in _MODIFIER_OBJECT_TYPES:
3793
- modifier = _named(getattr(obj.modifiers, "active", None))
3794
- groups = getattr(obj, "vertex_groups", None)
3795
- if groups is not None and getattr(groups, "active", None) is not None:
3347
+ modifier = _named(obj.modifiers.active)
3348
+ groups = obj.vertex_groups
3349
+ if groups.active is not None:
3796
3350
  vertex_group = {"index": int(groups.active_index)}
3797
3351
  vertex_group.update(_named(groups.active))
3798
- shape_key = _named(getattr(obj, "active_shape_key", None))
3799
- constraint = _named(getattr(obj.constraints, "active", None))
3800
- systems = getattr(obj, "particle_systems", None)
3801
- if systems is not None:
3802
- particle_system = _named(getattr(systems, "active", None))
3352
+ shape_key = _named(obj.active_shape_key)
3353
+ constraint = _named(obj.constraints.active)
3354
+ systems = obj.particle_systems
3355
+ particle_system = _named(systems.active)
3803
3356
 
3804
3357
  # ED_buttons_tabs_list (`space_buttons.cc:218-252`), in its own order. The
3805
3358
  # TOOL tab is Blender's active-tool settings and has no reader here; SHADERFX
@@ -3873,18 +3426,14 @@ def rna_context(object_name=None, collection_path=None):
3873
3426
  # it is what nearly every panel on this tab draws -- `part` in
3874
3427
  # every one of their draw functions. The system itself carries
3875
3428
  # only a handful (`seed`, `parent`, the vertex-group names).
3876
- systems_active = getattr(systems, "active", None)
3877
- settings = getattr(systems_active, "settings", None)
3878
- if settings is not None:
3879
- paths.append(("Settings", _rna_address(settings)))
3880
- cloth = getattr(systems_active, "cloth", None)
3881
- if cloth is not None:
3882
- # PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
3883
- # and `.collision_settings` (`:390-404`, `:463-470`).
3884
- paths.append(("Hair Dynamics",
3885
- _rna_address(getattr(cloth, "settings", None))))
3886
- paths.append(("Hair Collisions",
3887
- _rna_address(getattr(cloth, "collision_settings", None))))
3429
+ systems_active = systems.active
3430
+ paths.append(("Settings", _rna_address(systems_active.settings)))
3431
+ cloth = systems_active.cloth
3432
+ if cloth is not None:
3433
+ # PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
3434
+ # and `.collision_settings` (`:390-404`, `:463-470`).
3435
+ paths.append(("Hair Dynamics", _rna_address(cloth.settings)))
3436
+ paths.append(("Hair Collisions", _rna_address(cloth.collision_settings)))
3888
3437
  tabs.append(_tab("particle", "Particles", "properties-particles", paths))
3889
3438
  # `buttons_context_path_object` answers Physics and Constraints both.
3890
3439
  #
@@ -3903,34 +3452,28 @@ def rna_context(object_name=None, collection_path=None):
3903
3452
  ("Soft Body", "soft_body"),
3904
3453
  ("Collision", "collision"),
3905
3454
  ("Force Field", "field"))
3906
- if getattr(obj, field, None) is not None]
3907
- for label, kind, members in (("Cloth", "CLOTH",
3908
- # The MODIFIER as well as its settings:
3909
- # `point_cache_ui(self, md.point_cache, …)`
3910
- # is the cache panel's datablock
3911
- # (`properties_physics_cloth.py:269`), and
3912
- # that lives on the modifier, not on
3913
- # `md.settings`. Measured live 2026-09-19:
3914
- # the Cache panel drew nothing without it.
3455
+ if getattr(obj, field) is not None]
3456
+ for kind, members in (("CLOTH",
3457
+ # Include the modifier itself beside its settings because the point-cache panel addresses md.point_cache.
3915
3458
  (("Cloth Modifier", None),
3916
3459
  ("Cloth", "settings"),
3917
3460
  ("Cloth Collisions", "collision_settings"))),
3918
- ("Soft Body", "SOFT_BODY",
3461
+ ("SOFT_BODY",
3919
3462
  (("Soft Body Modifier", None),)),
3920
- ("Fluid", "FLUID",
3463
+ ("FLUID",
3921
3464
  (("Fluid", None),
3922
3465
  ("Fluid Domain", "domain_settings"),
3923
3466
  ("Fluid Flow", "flow_settings"),
3924
3467
  ("Fluid Effector", "effector_settings"))),
3925
- ("Dynamic Paint", "DYNAMIC_PAINT",
3468
+ ("DYNAMIC_PAINT",
3926
3469
  (("Dynamic Paint", None),
3927
3470
  ("Dynamic Paint Canvas", "canvas_settings"),
3928
3471
  ("Dynamic Paint Brush", "brush_settings")))):
3929
- modifier = next((item for item in obj.modifiers if item.type == kind), None)
3930
- if modifier is None:
3472
+ physics_modifier = next((item for item in obj.modifiers if item.type == kind), None)
3473
+ if physics_modifier is None:
3931
3474
  continue
3932
3475
  for name, member in members:
3933
- target = modifier if member is None else getattr(modifier, member, None)
3476
+ target = physics_modifier if member is None else getattr(physics_modifier, member)
3934
3477
  physics.append((name, _rna_address(target)))
3935
3478
  tabs.append(_tab("physics", "Physics", "properties-physics",
3936
3479
  physics or [("Object", object_path)]))
@@ -3945,7 +3488,7 @@ def rna_context(object_name=None, collection_path=None):
3945
3488
  paths = [("Object Data", data_path)]
3946
3489
  if obj.type == "ARMATURE":
3947
3490
  paths.append(("Bones", "%s.bones" % data_path))
3948
- if getattr(obj, "vertex_groups", None) is not None and len(obj.vertex_groups):
3491
+ if len(obj.vertex_groups):
3949
3492
  paths.append(("Vertex Groups", "%s.vertex_groups" % object_path))
3950
3493
  keys = getattr(obj.data, "shape_keys", None)
3951
3494
  if keys is not None:
@@ -3962,11 +3505,11 @@ def rna_context(object_name=None, collection_path=None):
3962
3505
  [("Constraints", "%s.constraints" % pose_bone["path"])]))
3963
3506
  if material is not None:
3964
3507
  paths = [("Material Slots", "%s.material_slots" % object_path)]
3965
- if material.get("path"):
3508
+ if material["path"]:
3966
3509
  paths.insert(0, ("Material", material["path"]))
3967
3510
  tabs.append(_tab("material", "Material", "properties-material", paths))
3968
3511
  # Blender's Texture tab is `buttons_texture_context_compute`'s search for a
3969
- # texture USER, now mirrored in `_texture_users` -- in the C's own order,
3512
+ # texture USER, mirrored in `_texture_users` -- in the C's own order,
3970
3513
  # with user 0 the active one (`ct->index`). The tab stands when that search
3971
3514
  # finds a user, or (as `buttons_context_path_texture` does through the
3972
3515
  # pinned-ID branch) when the file holds textures at all.
@@ -3975,7 +3518,7 @@ def rna_context(object_name=None, collection_path=None):
3975
3518
  if texture_users or len(bpy.data.textures):
3976
3519
  paths = []
3977
3520
  if active_texture_user is not None:
3978
- paths.append(("Texture", (active_texture_user["texture"] or {}).get("path")))
3521
+ paths.append(("Texture", active_texture_user["texture"]["path"]))
3979
3522
  paths.append(("User", active_texture_user["path"]))
3980
3523
  paths.append(("Textures", "bpy.data.textures"))
3981
3524
  tabs.append(_tab("texture", "Texture", "properties-texture", paths))
@@ -4014,47 +3557,11 @@ def rna_context(object_name=None, collection_path=None):
4014
3557
 
4015
3558
 
4016
3559
 
4017
- # ---- the outliner tree ------------------------------------------------------
4018
- #
4019
- # MIRRORED FROM `space_outliner/tree/tree_display_view_layer.cc` -- Blender's
4020
- # VIEW LAYER display mode, the one its Outliner opens on -- and from the
4021
- # per-datablock expansions beside it: `tree_element_id_object.cc` (the order an
4022
- # object expands in), `tree_element_id_mesh.cc`, `tree_element_id_armature.cc`,
4023
- # `tree_element_pose.cc`, `tree_element_modifier.cc`, `tree_element_defgroup.cc`
4024
- # and `tree_element_particle_system.cc`, all read at the engine's pin (Blender
4025
- # 5.2.0, `fbe6228777e7`). Blender's UI layer is never run, ported or recorded:
4026
- # what this answers is the TREE those functions build, as rows, and OUR
4027
- # hierarchy panel draws it (ARCHITECTURE-CORE §Blender north star, "Inspection
4028
- # parity, not editing parity"; WORK.md §Blender in the tab is Blender,
4029
- # "Inspection parity", I3).
4030
- #
4031
- # THE ADDRESS IS THE ENGINE'S OWN, exactly as `rna_view`'s is: a row's `path` is
4032
- # `_rna_address` (or a collection's address plus the member's key, I1's
4033
- # answer for a struct that cannot say where it lives), so a row opens in the
4034
- # RNA door with no translation. `id` is the row's IDENTITY in the tree, and it
4035
- # IS the path in every ordinary case -- the two differ only where Blender
4036
- # itself draws one datablock twice (an object linked into two collections, and
4037
- # the `TE_CHILD_NOT_IN_COLLECTION` duplicate
4038
- # `make_object_parent_hierarchy_collections` adds), where an ordinal keeps the
4039
- # rows distinct while `path` stays the engine's own address for both.
4040
- #
4041
- # WHAT IS OPEN AT REST. A fresh tree-store element is CLOSED
4042
- # (`outliner_tree.cc:139`, `tselem->flag = TSE_CLOSED`); the Scene Collection
4043
- # (`tree_display_view_layer.cc:130`) and every editable layer collection
4044
- # (`:167`) clear that flag and NOTHING else does -- so a collection is open and
4045
- # an OBJECT is closed, its data, modifiers and groups with it.
4046
- #
4047
- # BIG LISTS ARE PAGED, the way `rna_view`'s collections are counted: a child
4048
- # list longer than _OUTLINER_PAGE answers its first page and says how many more
4049
- # there are, so a scene of 20,000 objects cannot turn one read into a
4050
- # 20,000-row payload.
4051
- #
4052
- # AND THE READ COSTS WHAT IT SHOWS, not what the scene holds: an object's row
4053
- # and its expansion are built only once the page has kept its place
4054
- # (`rna_outliner`). The ids do not move for it. An id's ordinal is the number of
4055
- # rows that claimed the same address before it in the WHOLE tree's order, kept
4056
- # rows or not, and `_OutlinerScope` answers that count for a kept object's rows
4057
- # from what every earlier object would have claimed.
3560
+ # Build the outliner’s View Layer tree from Blender’s tree_display_view_layer and per-datablock
3561
+ # expansion rules. Rows use native RNA addresses; duplicate appearances get distinct ordinal ids
3562
+ # while preserving their shared path. Collections begin expanded and object rows collapsed. Page
3563
+ # long child lists and build expansions only for retained objects. _OutlinerScope counts preceding
3564
+ # claims across the full tree so row identities remain stable between pages.
4058
3565
 
4059
3566
  _OUTLINER_PAGE = 64
4060
3567
 
@@ -4120,11 +3627,9 @@ def _enum_icon(target, identifier):
4120
3627
  (`outliner_draw.cc:2773-2785`); `rna_enum_constraint_type_items` carries
4121
3628
  that function's own constraint switch. Reading the engine's enum is reading
4122
3629
  the table the Outliner draws from, rather than transcribing it."""
4123
- prop = target.bl_rna.properties.get(identifier)
4124
- if prop is None:
4125
- return None
4126
- item = prop.enum_items.get(getattr(target, identifier, None))
4127
- return (getattr(item, "icon", None) or None) if item is not None else None
3630
+ prop = target.bl_rna.properties[identifier]
3631
+ item = prop.enum_items.get(getattr(target, identifier))
3632
+ return (item.icon or None) if item is not None else None
4128
3633
 
4129
3634
 
4130
3635
  def _outliner_object_icon(obj):
@@ -4134,30 +3639,26 @@ def _outliner_object_icon(obj):
4134
3639
  if obj.type == "EMPTY":
4135
3640
  if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
4136
3641
  return "OUTLINER_OB_GROUP_INSTANCE"
4137
- if getattr(obj, "empty_display_type", None) == "IMAGE":
3642
+ if obj.empty_display_type == "IMAGE":
4138
3643
  return "OUTLINER_OB_IMAGE"
4139
- field = getattr(obj, "field", None)
4140
- if field is not None and getattr(field, "type", "NONE") != "NONE":
3644
+ field = obj.field
3645
+ if field is not None and field.type != "NONE":
4141
3646
  return "OUTLINER_OB_FORCE_FIELD"
4142
3647
  return _enum_icon(obj, "type") or "OBJECT_DATA"
4143
3648
 
4144
3649
 
4145
3650
  def _outliner_id_icon(datablock):
4146
3651
  """`tree_element_get_icon_from_id` for anything that is not an object."""
4147
- if datablock is None:
4148
- return "DOT"
4149
3652
  if isinstance(datablock, bpy.types.Light):
4150
- return _OUTLINER_LIGHT_ICONS.get(getattr(datablock, "type", None),
4151
- "OUTLINER_DATA_LIGHT")
3653
+ return _OUTLINER_LIGHT_ICONS[datablock.type]
4152
3654
  if isinstance(datablock, bpy.types.LightProbe):
4153
- return _OUTLINER_PROBE_ICONS.get(getattr(datablock, "type", None),
4154
- "LIGHTPROBE_SPHERE")
3655
+ return _OUTLINER_PROBE_ICONS[datablock.type]
4155
3656
  rna = datablock.bl_rna
4156
3657
  while rna is not None:
4157
3658
  icon = _OUTLINER_ID_ICONS.get(rna.identifier)
4158
3659
  if icon:
4159
3660
  return icon
4160
- rna = getattr(rna, "base", None)
3661
+ rna = rna.base
4161
3662
  return "DOT"
4162
3663
 
4163
3664
 
@@ -4250,7 +3751,7 @@ def _outliner_modifier(md, obj, object_path, seen):
4250
3751
  linked = getattr(md, member, None)
4251
3752
  if linked is None:
4252
3753
  continue
4253
- linked_path = _rna_address(linked) or "%s.%s" % (path, member)
3754
+ linked_path = _rna_address(linked)
4254
3755
  children.append(_outliner_row(linked_path, linked.name, kind, icon, seen))
4255
3756
  break
4256
3757
  system = getattr(md, "particle_system", None)
@@ -4291,8 +3792,8 @@ def _outliner_data(obj, seen):
4291
3792
  children.extend(_outliner_bone(bone, path, seen) for bone in page)
4292
3793
  if more:
4293
3794
  row["more"] = more
4294
- collections = getattr(data, "collections", None)
4295
- if collections is not None and len(collections):
3795
+ collections = data.collections
3796
+ if collections:
4296
3797
  base = _outliner_row("%s.collections" % path, "Bone Collections",
4297
3798
  "TSE_BONE_COLLECTION_BASE", "GROUP_BONE", seen)
4298
3799
  page, more = _outliner_page(collections)
@@ -4369,10 +3870,9 @@ def _outliner_object(obj, view_layer, seen, chosen, active):
4369
3870
  "MODIFIER_DATA", seen, object=obj.name)
4370
3871
  base["children"] = [_outliner_modifier(md, obj, path, seen) for md in obj.modifiers]
4371
3872
  children.append(base)
4372
- groups = getattr(obj, "vertex_groups", None)
4373
- if groups is not None and len(groups):
4374
- # `expand_vertex_groups`: mesh, lattice and grease pencil only, which is
4375
- # the same set as "this object carries a `vertex_groups` collection".
3873
+ groups = obj.vertex_groups
3874
+ if groups:
3875
+ # `expand_vertex_groups`: objects with vertex groups expose their collection here.
4376
3876
  base_path = "%s.vertex_groups" % path
4377
3877
  base = _outliner_row(base_path, "Vertex Groups", "TSE_DEFGROUP_BASE", "GROUP_VERTEX",
4378
3878
  seen, object=obj.name)
@@ -4429,13 +3929,12 @@ class _OutlinerScope:
4429
3929
  self.local = {}
4430
3930
 
4431
3931
  def _before(self, path):
4432
- if path is not None:
4433
- for prefix, address, before in self.scopes:
4434
- if path == address or path.startswith(prefix):
4435
- return before
3932
+ for prefix, address, before in self.scopes:
3933
+ if path == address or path.startswith(prefix):
3934
+ return before
4436
3935
  return None
4437
3936
 
4438
- def get(self, path, default=0):
3937
+ def get(self, path, default):
4439
3938
  if path in self.local:
4440
3939
  return self.local[path]
4441
3940
  before = self._before(path)
@@ -4449,30 +3948,16 @@ class _OutlinerScope:
4449
3948
 
4450
3949
  # ---- the node editor's tree ------------------------------------------------
4451
3950
  #
4452
- # THE ONE READ A NODE VIEW NEEDS. The generic `rna_view` door answers every one
4453
- # of these facts -- `bpy.data.materials["M"].node_tree.nodes` is a collection it
4454
- # opens, and each member is a struct it describes -- but it answers them ONE
4455
- # STRUCT AT A TIME, and a node tree's drawing is a fact about the WHOLE tree:
4456
- # the default Principled material is 2 nodes and 37 sockets, so drawing it
4457
- # through the generic door is 40 round trips at the door's own ~100 ms median.
4458
- # This door answers the same facts in one, and answers NOTHING the generic view
4459
- # could not: every field below is a `bl_rna` property of `Node`, `NodeSocket` or
4460
- # `NodeLink`, named here so a reader can check it against `rna_nodetree.cc`.
4461
- #
4462
- # IT IS A READ AND ONLY A READ (ARCHITECTURE-CORE "Inspection parity, not
4463
- # editing parity"): there is no node-tree writer beside it, because a gesture
4464
- # that moved a node or retyped a value would be editing, and editing parity is
4465
- # not the program. `rna_set` remains the one writer, and it refuses a node's
4466
- # `location` the same way it refuses anything else RNA declares read-only --
4467
- # which `location` is not, so the VIEW is what refuses, by name, in its own
4468
- # status line.
3951
+ # Read a whole node tree in one response. Fields are bl_rna properties of
3952
+ # Node, NodeSocket and NodeLink, matching rna_nodetree.cc.
3953
+ # The node view inspects and navigates; it refuses editing gestures in its
3954
+ # status line. RNA property writes remain on rna_set.
4469
3955
  _NODE_SOCKET_VALUE_MAX = 4
4470
3956
 
4471
3957
 
4472
3958
  def _node_socket_value(socket):
4473
3959
  """A socket's `default_value`, flattened the way `_rna_flatten` flattens an
4474
- array property -- and OMITTED where the socket has none (a shader socket,
4475
- a geometry socket) or Blender itself would not draw it (`hide_value`)."""
3960
+ array property; None when the socket has no default or its value is unsupported."""
4476
3961
  if not hasattr(socket, "default_value"):
4477
3962
  return None
4478
3963
  value = socket.default_value
@@ -4509,18 +3994,17 @@ def _node_socket(socket, index):
4509
3994
  "shape": socket.display_shape,
4510
3995
  "enabled": bool(socket.enabled),
4511
3996
  "hide": bool(socket.hide),
4512
- "hideValue": bool(getattr(socket, "hide_value", False)),
3997
+ "hideValue": bool(socket.hide_value),
4513
3998
  "linked": bool(socket.is_linked),
4514
- "multiInput": bool(getattr(socket, "is_multi_input", False)),
3999
+ "multiInput": bool(socket.is_multi_input),
4515
4000
  "index": index,
4516
4001
  "value": _node_socket_value(socket),
4517
4002
  # THE SLIDER'S RANGE. A scalar socket whose `default_value` property
4518
4003
  # carries a BOUNDED soft range is drawn by Blender as a NUMBER SLIDER
4519
4004
  # -- `ui_but_is_slider`'s `UI_BTYPE_NUM_SLIDER`, whose back is filled to
4520
4005
  # the value's proportion of that range (`widget_numslider`,
4521
- # `interface_widgets.cc`). Without the range a view can only draw the
4522
- # flat NUM field, which is what ours did: Roughness at 0.5 read as a
4523
- # text box where Blender's reads half-filled at a glance.
4006
+ # `interface_widgets.cc`). The soft range determines the filled
4007
+ # proportion; an unbounded field has no slider fill.
4524
4008
  #
4525
4009
  # `soft_min`/`soft_max` are the UI range and `min`/`max` the hard one;
4526
4010
  # Blender sliders against the SOFT pair. An unbounded property (a
@@ -4533,11 +4017,7 @@ def _node_socket(socket, index):
4533
4017
  def _node_socket_range(socket):
4534
4018
  """The soft range of a scalar socket drawn as a SLIDER, when it is one.
4535
4019
 
4536
- BEING BOUNDED IS NOT THE TEST, and the reference frame is what says so.
4537
- A first pass here reported the range for every bounded scalar, and the view
4538
- drew IOR (soft range 1..3) half-filled at 1.500 -- while Blender's own
4539
- shader editor leaves IOR FLAT and fills only Roughness and Alpha. The
4540
- separating property is the SUBTYPE: `PROP_FACTOR` is what
4020
+ The socket subtype determines whether Blender draws a slider: `PROP_FACTOR` is what
4541
4021
  `uiItemR`/`node_socket_button_default` pass `UI_ITEM_R_SLIDER` for, so a
4542
4022
  factor draws as `UI_BTYPE_NUM_SLIDER` with a filled back and every other
4543
4023
  number draws as a plain `UI_BTYPE_NUM`.
@@ -4550,61 +4030,28 @@ def _node_socket_range(socket):
4550
4030
  prop = socket.bl_rna.properties["default_value"]
4551
4031
  except (AttributeError, KeyError):
4552
4032
  return {}
4553
- if getattr(prop, "type", None) not in ("FLOAT", "INT"):
4554
- return {}
4555
- if getattr(prop, "array_length", 0):
4033
+ if prop.type not in ("FLOAT", "INT"):
4556
4034
  return {}
4557
- if getattr(prop, "subtype", None) != "FACTOR":
4035
+ if prop.array_length:
4558
4036
  return {}
4559
- low = getattr(prop, "soft_min", None)
4560
- high = getattr(prop, "soft_max", None)
4561
- if low is None or high is None:
4037
+ if prop.subtype != "FACTOR":
4562
4038
  return {}
4039
+ low = prop.soft_min
4040
+ high = prop.soft_max
4563
4041
  if not (math.isfinite(low) and math.isfinite(high)) or high <= low:
4564
4042
  return {}
4565
4043
  return {"softMin": float(low), "softMax": float(high)}
4566
4044
 
4567
4045
 
4568
4046
  def _node_row(node):
4569
- """One node, as the node editor draws it.
4570
-
4571
- `colorTag` is `Node.color_tag` (`rna_nodetree.cc:9480-9484`), which RNA
4572
- reads from `bke::node_color_tag(*node)` -- the very value
4573
- `node_get_colorid` (`node_draw.cc:1388-1434`) switches on to pick a header
4574
- colour. So the header hue is a READ of the engine, not a table of ours.
4575
-
4576
- `panels` is `Node.panel_states` (`rna_nodetree.cc:9430-9434`): per panel,
4577
- the declaration's `persistent_uid` and `is_collapsed`, and NOTHING ELSE --
4578
- no label, and no way to ask which sockets belong to it. That membership
4579
- lives in the node's C++ declaration, which `node_update_basis_from_
4580
- declaration` (`node_draw.cc:1086-1218`) walks and bpy does not expose.
4581
- Measured 2026-09-19 on Blender 5.2.0 in the tab: `ShaderNodeBsdfPrincipled`
4582
- answers 8 panel states (uids 9, 11, 18, 25, 27, 33, 37, 40), all collapsed,
4583
- and not one of its 32 `NodeSocket`s carries a panel field.
4584
-
4585
- RULED 2026-09-19: the MEMBERSHIP is traced from Blender's own `declare()`
4586
- bodies instead, so what this door owes is the COLLAPSED STATE the trace
4587
- cannot know -- per node, live, and changed under the person's hand. The
4588
- states arrive IN ORDER, because Blender indexes `panel_states_array` by
4589
- the declaration's panel INDEX (`node_draw.cc:1037`) and RNA's collection
4590
- IS that array; the uid rides along so a disagreement is visible rather
4591
- than silently mis-joined.
4592
-
4593
- THE TRACE IS THE CONSUMER'S, NOT THIS PACKAGE'S, and the split is the
4594
- point: `@volter/editor-blender`'s `blender-node-panels.source.mjs` reads a Blender
4595
- CHECKOUT at the engine's pin and writes `blender.node-panels.json` beside
4596
- the view that draws with it. Nothing of it belongs here -- this package is
4597
- the engine and its wire, and it answers what the RUNNING engine knows.
4598
-
4599
- `showOptions` is `Node.show_options` -> `NODE_OPTIONS`, the flag
4600
- `add_flat_items_for_layout` (`node_draw.cc:739-746`) returns early on."""
4047
+ """Report node header colour tags, live panel states, sockets and show_options from RNA. Socket-panel labels and membership belong to the consumer’s Blender source declarations."""
4601
4048
  return {
4602
4049
  "name": node.name,
4603
4050
  "idname": node.bl_idname,
4604
4051
  "type": node.type,
4605
4052
  "typeLabel": node.bl_label,
4606
4053
  "label": node.label or None,
4607
- "colorTag": getattr(node, "color_tag", "NONE"),
4054
+ "colorTag": node.color_tag,
4608
4055
  # `Node.location` is the tree-space corner Blender lays out from
4609
4056
  # (`node_update_basis`: `dy = loc.y`, `node_draw.cc:1297`); `width` is
4610
4057
  # the value `NODE_WIDTH(node)` scales (`node_intern.hh:337`).
@@ -4620,11 +4067,11 @@ def _node_row(node):
4620
4067
  # gives an OUTPUT-class node the output colour only when it is the
4621
4068
  # active output (`node_draw.cc:1395-1401`).
4622
4069
  "activeOutput": bool(getattr(node, "is_active_output", False)),
4623
- "panelCount": len(getattr(node, "panel_states", ())),
4070
+ "panelCount": len(node.panel_states),
4624
4071
  "panels": [{"identifier": int(state.identifier),
4625
4072
  "collapsed": bool(state.is_collapsed)}
4626
- for state in getattr(node, "panel_states", ())],
4627
- "showOptions": bool(getattr(node, "show_options", True)),
4073
+ for state in node.panel_states],
4074
+ "showOptions": bool(node.show_options),
4628
4075
  "inputs": [_node_socket(socket, index) for index, socket in enumerate(node.inputs)],
4629
4076
  "outputs": [_node_socket(socket, index) for index, socket in enumerate(node.outputs)],
4630
4077
  }
@@ -4644,7 +4091,7 @@ def _node_tree_of(path, material):
4644
4091
  raise ValueError("The engine holds no material named %r" % (material,))
4645
4092
  else:
4646
4093
  obj = bpy.context.view_layer.objects.active
4647
- mat = getattr(obj, "active_material", None) if obj is not None else None
4094
+ mat = obj.active_material if obj is not None else None
4648
4095
  if mat is None:
4649
4096
  return None, None, None
4650
4097
  if not mat.use_nodes:
@@ -4707,32 +4154,7 @@ def _uv_object_of(object_name):
4707
4154
 
4708
4155
 
4709
4156
  def rna_uv_layout(object_name=None, uv_layer=None):
4710
- """ONE MESH'S UV LAYOUT, as Blender's UV editor would draw it.
4711
-
4712
- WHY A DOOR AND NOT THE GENERIC VIEW. `rna_view` answers a struct at a time
4713
- and reports a collection as a COUNT, and a UV layout is
4714
- `len(mesh.loops)` two-float corners plus a triangulation -- 8,008 corners
4715
- over 31 meshes in `arena-weapons.blend`, measured 2026-09-19. The
4716
- per-corner arrays therefore cross as BASE64 typed-array bytes, exactly as
4717
- I4's weights do, and every scalar beside them is a `bl_rna` property the
4718
- generic door also answers.
4719
-
4720
- WHAT IT IS NOT GATED ON. Blender draws UVs only when
4721
- `space_mode_is_uv && object_mode_is_edit` (`overlay_mesh.hh:593`), and both
4722
- halves of that are its UI layer's -- a space this engine has none of, and a
4723
- mode an inspection surface does not enter (orchestrator ruling 1,
4724
- 2026-09-19: "inspection is not mode-gated"). So this reads the DATA in
4725
- whatever mode the engine is in and reports `mode` beside it, and the view
4726
- names the Blender mode the same picture would need.
4727
-
4728
- THE SELECTION FLAGS ARE REPORTED WHERE THE DATA CARRIES THEM, and at this
4729
- pin an evaluated mesh usually carries none: `MeshUVLoopLayer`
4730
- (`rna_mesh.cc:2380-2456`) declares `uv`, `pin`, `name`, `active`,
4731
- `active_render` and `active_clone` -- and NO vertex or edge selection,
4732
- because UV selection lives in the BMesh an edit-mode session holds. `pin`
4733
- IS declared, so it is answered; everything else draws unselected, which is
4734
- the second half of the same ruling.
4735
- """
4157
+ """Return per-corner UVs, triangulation, polygon ranges and pin flags as base64 typed arrays. Selection remains null outside edit-mode BMesh because MeshUVLoopLayer exposes no selection field."""
4736
4158
  obj = _uv_object_of(object_name)
4737
4159
  if obj is None:
4738
4160
  return {"object": None, "mesh": None, "layers": [], "active": None,
@@ -4811,7 +4233,7 @@ def _uv_backdrop_image(obj):
4811
4233
  (`ED_space_image_auto_set`, `space_image.cc:60-101`). There is no
4812
4234
  `SpaceImage` here, so the second half is the whole rule: the active
4813
4235
  material's node tree's active `TEX_IMAGE`."""
4814
- mat = getattr(obj, "active_material", None)
4236
+ mat = obj.active_material
4815
4237
  if mat is None or not mat.use_nodes or mat.node_tree is None:
4816
4238
  return None
4817
4239
  nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image is not None]
@@ -4824,25 +4246,7 @@ def _uv_backdrop_image(obj):
4824
4246
  "source": image.source, "hasData": bool(image.has_data)}
4825
4247
 
4826
4248
 
4827
- # ------------------------------------------------------- the rig and its clip
4828
- #
4829
- # WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20). Blender
4830
- # holds the animation as DATA; three.js PLAYS it. A Timeline that asked Blender
4831
- # to re-evaluate the depsgraph and re-export the columns once per frame would
4832
- # be the wrong architecture -- so these two doors hand the tab everything it
4833
- # needs to build a `THREE.SkinnedMesh` + `Skeleton` + `AnimationMixer` ONCE,
4834
- # and the scrub, the playback and the frame counter after that are three.js's
4835
- # with ZERO calls back into this process.
4836
- #
4837
- # WHAT THE BIND POSE IS, and why it is not the rest pose. The export door runs
4838
- # with `evaluate: True`, so the mesh columns the presenter already holds are
4839
- # the EVALUATED mesh -- the armature modifier included -- which means they are
4840
- # the deformed mesh at whatever frame Blender is sitting on. Binding a skeleton
4841
- # whose bones are in that SAME pose makes the skinning an identity there
4842
- # (`skinMatrix = sum(w_i * B_i * B_i^-1) = I`), so the picture at the bind
4843
- # frame is byte-for-byte the frame Blender presented, and every other frame is
4844
- # three.js's own evaluation of the same skin. That is what lets Blender's
4845
- # `frame_current` stand still for the whole of playback.
4249
+ # Export rig and clip data once for the presenter’s Skeleton and AnimationMixer. Bind to the evaluated pose so skinning is the identity at the exported frame; playback does not advance Blender’s frame_current.
4846
4250
 
4847
4251
 
4848
4252
  def _rig_armature_of(obj):
@@ -4854,170 +4258,18 @@ def _rig_armature_of(obj):
4854
4258
  return obj.find_armature()
4855
4259
 
4856
4260
 
4857
- def _pose_bones_parents_first(arm_obj):
4858
- """Every pose bone, parents before children. `Object.pose.bones` is already
4859
- in hierarchy order at this pin, but three.js's `Skeleton` is built by
4860
- INDEX and a child whose parent has no index yet cannot be parented, so the
4861
- order is made explicit rather than assumed."""
4862
- order = []
4863
- seen = set()
4864
-
4865
- def walk(pchan):
4866
- if pchan.name in seen:
4867
- return
4868
- parent = pchan.parent
4869
- if parent is not None:
4870
- walk(parent)
4871
- if pchan.name in seen:
4872
- return
4873
- seen.add(pchan.name)
4874
- order.append(pchan)
4875
-
4876
- for pchan in arm_obj.pose.bones:
4877
- walk(pchan)
4878
- return order
4879
-
4880
-
4881
4261
  def _matrix_rows(matrix):
4882
4262
  return [[float(v) for v in row] for row in matrix]
4883
4263
 
4884
4264
 
4885
-
4886
- def _rig_of(obj):
4887
- """ONE MESH'S SKIN BINDING: the armature's bones, and up to four weighted
4888
- bone influences per Blender vertex.
4889
-
4890
- WHY A DOOR AND NOT THE GENERIC VIEW, the same answer `rna_uv_layout` gives:
4891
- `rna_view` reports a collection as a COUNT, and a skin binding is
4892
- `len(mesh.vertices)` x 4 pairs -- 1,152 vertices is 4,608 of them on the
4893
- smallest probe. They cross as base64 typed-array bytes, the shape I4's
4894
- weights established: `skinIndexBase64` Uint16 (`vertices * 4`) and
4895
- `skinWeightBase64` Float32 (`vertices * 4`), normalized so the four sum to
4896
- one.
4897
-
4898
- THE INDICES ARE BLENDER VERTEX INDICES, not drawn-vertex ones. The draw
4899
- splits a Blender vertex into as many drawn vertices as its corners need,
4900
- and `DrawArrays.sourceVertex` is the map -- so the presenter EXPANDS these
4901
- arrays through it (`blender-runtime-skin.ts`), exactly as I4's weight
4902
- overlay expands its ramp.
4903
-
4904
- TWO MATRICES PER BONE, both in the armature OBJECT's space and row-major as
4905
- four rows: `rest` is `Bone.matrix_local` (the armature's rest pose, what
4906
- `pose.bones[...]` is measured against) and `pose` is `PoseBone.matrix` --
4907
- `pchan->pose_mat`, the SAME matrix I4's bone overlay draws. `pose` is the
4908
- BIND pose, because it is the pose the exported columns were evaluated at.
4909
- """
4910
- if obj is None or obj.type != "MESH" or obj.data is None:
4911
- return {"object": obj.name if obj is not None else None, "armature": None,
4912
- "bones": [], "vertexCount": 0,
4913
- "reason": "Only a mesh object carries a skin binding."}
4914
- mesh = obj.data
4915
- arm_obj = _rig_armature_of(obj)
4916
- header = {
4917
- "object": obj.name,
4918
- "mesh": mesh.name,
4919
- "vertexCount": len(mesh.vertices),
4920
- "frame": int(bpy.context.scene.frame_current),
4921
- "mode": bpy.context.mode,
4922
- }
4923
- if arm_obj is None:
4924
- header.update({"armature": None, "bones": [],
4925
- "reason": "%r is deformed by no armature -- `Object.find_armature()` "
4926
- "answers none, so it presents as an ordinary Mesh." % (obj.name,)})
4927
- return header
4928
- order = _pose_bones_parents_first(arm_obj)
4929
- index_of = {pchan.name: i for i, pchan in enumerate(order)}
4930
- header.update({
4931
- "armature": arm_obj.name,
4932
- "armatureData": arm_obj.data.name,
4933
- # THE CONSTRAINT COUNT IS REPORTED because the CLIP is derived from the
4934
- # F-Curves alone (`rna_action_clip`) while this bind pose is read live
4935
- # off `PoseBone.matrix`. For a constraint-free rig the two agree by
4936
- # construction; a constrained bone's pose is Blender's solver's and the
4937
- # clip cannot reproduce it, so the view says so rather than drifting.
4938
- "constrainedBones": sorted(p.name for p in order if len(p.constraints)),
4939
- "bones": [{
4940
- "name": pchan.name,
4941
- "parent": pchan.parent.name if pchan.parent is not None else None,
4942
- "rest": _matrix_rows(pchan.bone.matrix_local),
4943
- "pose": _matrix_rows(pchan.matrix),
4944
- "length": float(pchan.bone.length),
4945
- "deform": bool(pchan.bone.use_deform),
4946
- "connected": bool(pchan.bone.use_connect),
4947
- } for pchan in order],
4948
- })
4949
- group_to_bone = {}
4950
- unmapped = []
4951
- for group in obj.vertex_groups:
4952
- bone = index_of.get(group.name)
4953
- if bone is None:
4954
- unmapped.append(group.name)
4955
- else:
4956
- group_to_bone[int(group.index)] = bone
4957
- count = len(mesh.vertices)
4958
- # FOUR INFLUENCES, which is three.js's `skinIndex`/`skinWeight` shape and
4959
- # also glTF's; a vertex with more is truncated to its four heaviest and
4960
- # RENORMALIZED, and the count of such vertices is reported rather than
4961
- # quietly dropped.
4962
- indices = array.array("H", bytes(count * 8))
4963
- weights = array.array("f", bytes(count * 16))
4964
- truncated = 0
4965
- unweighted = 0
4966
- most = 0
4967
- checkpoint_at = time.monotonic()
4968
- for i, vertex in enumerate(mesh.vertices):
4969
- if i % 1024 == 0 and time.monotonic() - checkpoint_at >= 0.05:
4970
- _asked({"checkpoint": "rig-weights"})
4971
- checkpoint_at = time.monotonic()
4972
- pairs = []
4973
- for element in vertex.groups:
4974
- bone = group_to_bone.get(int(element.group))
4975
- if bone is None:
4976
- continue
4977
- value = float(element.weight)
4978
- if value > 0.0:
4979
- pairs.append((value, bone))
4980
- most = max(most, len(pairs))
4981
- if len(pairs) > 4:
4982
- truncated += 1
4983
- pairs.sort(reverse=True)
4984
- pairs = pairs[:4]
4985
- total = sum(value for value, _ in pairs)
4986
- if total <= 0.0:
4987
- unweighted += 1
4988
- continue
4989
- for slot, (value, bone) in enumerate(pairs):
4990
- indices[i * 4 + slot] = bone
4991
- weights[i * 4 + slot] = value / total
4992
- header.update({
4993
- "skinIndexBase64": base64.b64encode(indices.tobytes()).decode("ascii"),
4994
- "skinWeightBase64": base64.b64encode(weights.tobytes()).decode("ascii"),
4995
- "maxInfluences": most,
4996
- "truncatedVertices": truncated,
4997
- "unweightedVertices": unweighted,
4998
- "unmappedGroups": unmapped,
4999
- })
5000
- return header
5001
-
5002
-
5003
4265
  def _action_channelbag(action, slot_handle):
5004
- """THE LAYERED ACTION'S F-CURVES. At this pin an `Action` HAS NO
5005
- `.fcurves` -- measured 2026-09-19 and again here: `hasattr(action,
5006
- 'fcurves')` is False. They live at
5007
- `action.layers[i].strips[j].channelbags[k].fcurves`, and WHICH channelbag
5008
- is decided by the SLOT the animated object uses
5009
- (`Object.animation_data.action_slot`). The legacy attribute is still read
5010
- first for a build where it exists, which is the same two-branch shape the
5011
- rest of this file uses for a moved API."""
5012
- legacy = getattr(action, "fcurves", None)
5013
- if legacy is not None:
5014
- return list(legacy), "legacy"
5015
- for layer in getattr(action, "layers", ()):
5016
- for strip in getattr(layer, "strips", ()):
5017
- for bag in (getattr(strip, "channelbags", None) or ()):
5018
- if slot_handle is None or int(getattr(bag, "slot_handle", -1)) == int(slot_handle):
5019
- return list(bag.fcurves), "layered"
5020
- return [], "none"
4266
+ """Return the first matching channelbag's F-curves, or the first bag's when no slot is supplied; no match yields an empty list."""
4267
+ for layer in action.layers:
4268
+ for strip in layer.strips:
4269
+ for bag in strip.channelbags:
4270
+ if slot_handle is None or int(bag.slot_handle) == int(slot_handle):
4271
+ return list(bag.fcurves)
4272
+ return []
5021
4273
 
5022
4274
 
5023
4275
  _BONE_PATH = re.compile(r'^pose\.bones\["(.+)"\]\.(location|rotation_quaternion|'
@@ -5040,7 +4292,7 @@ def _key_columns(curves):
5040
4292
  # EXTREME wins over KEYFRAME the way Blender's own merge does:
5041
4293
  # `nupdate_ak_bezt` keeps the "most significant" type
5042
4294
  # (`keyframes_keylist.cc`, `KEYFRAME_STATE` ordering).
5043
- if _KEY_TYPE_RANK.get(key.type, 0) > _KEY_TYPE_RANK.get(record["type"], 0):
4295
+ if _KEY_TYPE_RANK[key.type] > _KEY_TYPE_RANK[record["type"]]:
5044
4296
  record["type"] = key.type
5045
4297
  return columns
5046
4298
 
@@ -5065,12 +4317,12 @@ def _summary_objects():
5065
4317
  """
5066
4318
  out = []
5067
4319
  for obj in bpy.context.view_layer.objects:
5068
- adt = getattr(obj, "animation_data", None)
5069
- action = getattr(adt, "action", None) if adt is not None else None
4320
+ adt = obj.animation_data
4321
+ action = adt.action if adt is not None else None
5070
4322
  if action is None:
5071
4323
  continue
5072
- slot = getattr(adt, "action_slot", None)
5073
- curves, _shape = _action_channelbag(action, getattr(slot, "handle", None))
4324
+ slot = adt.action_slot
4325
+ curves = _action_channelbag(action, slot.handle if slot is not None else None)
5074
4326
  columns = _key_columns(curves)
5075
4327
  out.append({
5076
4328
  "object": obj.name,
@@ -5082,31 +4334,7 @@ def _summary_objects():
5082
4334
 
5083
4335
 
5084
4336
  def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True):
5085
- """ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
5086
- every integer frame of the action's own range.
5087
-
5088
- WHAT CROSSES, and why it is a BAKE rather than the raw keys. Blender's
5089
- channels are `PoseBone.location/rotation_*/scale` in the bone's own REST
5090
- space (`pchan->chan_mat`), while three.js's `Bone` carries a LOCAL
5091
- transform relative to its PARENT BONE. The relation is
5092
- `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so no
5093
- per-channel re-labelling can turn one into the other -- the matrices have
5094
- to be composed. Composing them per KEY would then also have to reproduce
5095
- Blender's Bezier handles between keys, so the honest, one-code-path answer
5096
- is to SAMPLE at every integer frame and ship LINEAR tracks: exact at every
5097
- frame a Timeline can scrub to, linear in between, and stated as the
5098
- justified difference rather than implied.
5099
-
5100
- THE FRAME IS NEVER MOVED to do it. `FCurve.evaluate(frame)` reads the curve
5101
- without touching `scene.frame_current` or the depsgraph, which is what
5102
- makes this door cheap and what keeps bpy's own frame where the person left
5103
- it.
5104
-
5105
- A CONSTANT TRACK IS TWO KEYS. A rig's channels are overwhelmingly still --
5106
- an untouched bone contributes three tracks of identical samples -- so a
5107
- track whose every sample equals its first is shipped as its first and last
5108
- only. Measured on the probe below.
5109
- """
4337
+ """Bake each bone’s parent-relative transform at integer action frames for a three.js clip. Convert from Blender rest space through the parent rest matrix; compact constant tracks to one sample."""
5110
4338
  if object_name:
5111
4339
  obj = bpy.data.objects.get(object_name)
5112
4340
  if obj is None:
@@ -5143,14 +4371,7 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5143
4371
  "slot": None,
5144
4372
  "tracks": [],
5145
4373
  "keyframes": [],
5146
- # EVERY ANIMATED OBJECT AND ITS SELECTION, unconditionally -- the
5147
- # Timeline's summary row is filtered by SELECTION in Blender
5148
- # (`show_keys_from_selected_only`) and the view is what decides which
5149
- # side of that filter it is on, so the door answers both halves even
5150
- # when the subject below turns out to be nothing at all.
5151
- # `summary=False` for a reader that plays the clip (a game's clip library): the row walks
5152
- # every animated object in the view layer, which was ~690 of a ~700 ms read on a level of
5153
- # 23 armatures (measured 2026-10-08), and nothing that plays a clip draws it.
4374
+ # Include every animated object and its selection for Timeline summary filtering. A clip-only reader passes summary=False to skip the scene-wide summary walk.
5154
4375
  "summary": _summary_objects() if summary else [],
5155
4376
  }
5156
4377
  adt = arm_obj.animation_data if arm_obj is not None else None
@@ -5164,11 +4385,11 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5164
4385
  "and an empty summary row, which is Blender's own empty state.")
5165
4386
  return header
5166
4387
  action = assigned
5167
- slot = getattr(adt, "action_slot", None) if adt is not None else None
4388
+ slot = adt.action_slot if adt is not None else None
5168
4389
  if action_name and (assigned is None or action_name != assigned.name):
5169
4390
  # A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
5170
- # baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
5171
- # are its first channelbag's (no slot of this object chooses among them).
4391
+ # baked against this armature WITHOUT assigning it, so the file is untouched. The
4392
+ # owner's identifier selects its slot, otherwise the first object slot is used.
5172
4393
  action = bpy.data.actions.get(action_name)
5173
4394
  if action is None:
5174
4395
  raise ValueError("The engine holds no action named %r" % (action_name,))
@@ -5176,23 +4397,20 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5176
4397
  # identifier names this object (`OB<name>`), else the only object slot. A layered action
5177
4398
  # with several object slots and none for this armature has no right answer; its first
5178
4399
  # object slot is used and said in the reason.
5179
- slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
5180
- slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
4400
+ slots = [one for one in action.slots if one.target_id_type == "OBJECT"]
4401
+ slot = next((one for one in slots if one.identifier == "OB" + arm_obj.name), None)
5181
4402
  if slot is None and slots:
5182
4403
  slot = slots[0]
5183
4404
  if len(slots) > 1:
5184
4405
  header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
5185
- % (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
5186
- curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
5187
- # HOW MANY OBJECT SLOTS THE ACTION HAS: with one (or a legacy action, none) every armature
5188
- # reads the same curves, so a reader may share the clip across armatures with the same bones;
5189
- # with several, which one plays depends on the armature, so it may not.
5190
- header["objectSlots"] = len([one for one in getattr(action, "slots", ())
5191
- if getattr(one, "target_id_type", "OBJECT") == "OBJECT"])
4406
+ % (action.name, len(slots), arm_obj.name, slot.identifier))
4407
+ curves = _action_channelbag(action, slot.handle if slot is not None else None)
4408
+ # Report object-slot count so consumers share a clip across matching armatures only when its channel set is independent of the owner.
4409
+ header["objectSlots"] = len([one for one in action.slots
4410
+ if one.target_id_type == "OBJECT"])
5192
4411
  header.update({
5193
4412
  "action": action.name,
5194
- "slot": getattr(slot, "name_display", None),
5195
- "channels": shape,
4413
+ "slot": slot.name_display if slot is not None else None,
5196
4414
  "fcurves": len(curves),
5197
4415
  "keys": sum(len(fc.keyframe_points) for fc in curves),
5198
4416
  })
@@ -5208,32 +4426,32 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5208
4426
  # THE ACTION'S OWN RANGE (`Action.frame_range`: its keys, or its manual range) with its keys:
5209
4427
  # what a strip or the action line plays, sampled through `FCurve.evaluate`, which applies the
5210
4428
  # curves' extrapolation and modifiers (a Cycles modifier repeats them) as Blender does.
5211
- span = getattr(action, "frame_range", None)
5212
- first = int(math.floor(min(frames[0], span[0] if span is not None else frames[0])))
5213
- last = int(math.ceil(max(frames[-1], span[1] if span is not None else frames[-1])))
4429
+ span = action.frame_range
4430
+ first = int(math.floor(min(frames[0], span[0])))
4431
+ last = int(math.ceil(max(frames[-1], span[1])))
5214
4432
  if last <= first:
5215
4433
  last = first + 1
5216
4434
  header["clipStart"] = first
5217
4435
  header["clipEnd"] = last
5218
- # THE RANGE BLENDER PLACES THE ACTIVE ACTION OVER over NLA strips (`calc_action_range` with
5219
- # modifiers): its keys, opened to either side a Cycles modifier repeats into (null: unbounded).
5220
- # A manual frame range does not enter it. Measured: a cycling action is not held at its end.
4436
+ # Report the active action range including Cycles modifier extensions; null bounds are unbounded. The manual frame range does not enter it.
5221
4437
  header["keysStart"], header["keysEnd"] = _action_key_range(action, first, last)
5222
4438
  channels = {}
5223
4439
  others = set()
5224
4440
  for fcurve in curves:
5225
4441
  match = _BONE_PATH.match(fcurve.data_path)
5226
4442
  if match is None:
5227
- # NOT A BONE'S CHANNEL: the armature object's own transform (root motion), a custom
5228
- # property, a bone channel this door does not read. Named, so it is never dropped quietly.
5229
- others.add(fcurve.data_path)
4443
+ # NOT A BONE'S CHANNEL: a custom property, a bone channel this door does not read.
4444
+ # Named, so it is never dropped quietly. The armature object's OWN transform is not
4445
+ # this door's either, but it plays: the scene's movie (`rna_scene_movie`) carries it.
4446
+ if _OBJECT_PATH.match(fcurve.data_path) is None:
4447
+ others.add(fcurve.data_path)
5230
4448
  continue
5231
4449
  bone, prop = match.group(1), match.group(2)
5232
4450
  channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
5233
4451
  if not channels:
5234
- header["reason"] = ("%r animates no pose bone -- every F-Curve is on a data path this "
5235
- "door does not play (object transform, shape keys, a material). The "
5236
- "Timeline still draws its keys." % (action.name,))
4452
+ header["reason"] = ("%r has no pose-bone transform channels. The scene's movie "
4453
+ "(`rna_scene_movie`) carries supported object, material and shape-key "
4454
+ "animation; the Timeline still draws this action's keys." % (action.name,))
5237
4455
  return header
5238
4456
  tracks = []
5239
4457
  unplayed = []
@@ -5247,12 +4465,621 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5247
4465
  header["unsupported"] = ["curves on %s" % ", ".join(sorted(others))]
5248
4466
  header.update({
5249
4467
  "tracks": tracks,
4468
+ "bones": _three_bones(arm_obj, channels, first, last),
5250
4469
  "duration": (last - first) / fps,
5251
4470
  "unplayedBones": unplayed,
5252
4471
  })
5253
4472
  return header
5254
4473
 
5255
4474
 
4475
+ def _three_bones(arm_obj, channels, first, last):
4476
+ """THE ACTION AS THREE.JS PLAYS IT: per bone it keys, the bone's LOCAL transform (relative to
4477
+ its parent bone, as a `THREE.Bone` carries it) at every integer frame from `first` to `last`.
4478
+
4479
+ Blender evaluates every channel (`FCurve.evaluate`: its keys, handles, interpolation, easing,
4480
+ extrapolation and modifiers); a component the action does not key keeps the bone's own value.
4481
+ The basis is composed by the bone's rotation mode and placed by its rest relative to its
4482
+ parent's rest, `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so a clip moves
4483
+ a bone exactly as Blender's pose does at every frame, and three.js interpolates between them.
4484
+
4485
+ Each column crosses as base64 Float32: `position` three per frame, `quaternion` four (x, y, z,
4486
+ w, as three reads them, kept on one hemisphere so a slerp never turns the long way), `scale`
4487
+ three. A column whose every sample equals its first crosses as that one sample."""
4488
+ frames = list(range(first, last + 1))
4489
+ by_bone = {}
4490
+ for (bone, prop), components in channels.items():
4491
+ by_bone.setdefault(bone, {})[prop] = components
4492
+ out = []
4493
+ for name in sorted(by_bone):
4494
+ pbone = arm_obj.pose.bones.get(name)
4495
+ if pbone is None:
4496
+ continue
4497
+ rest = pbone.bone.matrix_local
4498
+ parent = pbone.bone.parent
4499
+ rest_relative = (parent.matrix_local.inverted() @ rest) if parent is not None else rest.copy()
4500
+ props = by_bone[name]
4501
+ mode = pbone.rotation_mode
4502
+ own = {
4503
+ "location": list(pbone.location),
4504
+ "rotation_quaternion": list(pbone.rotation_quaternion),
4505
+ "rotation_euler": list(pbone.rotation_euler),
4506
+ "rotation_axis_angle": list(pbone.rotation_axis_angle),
4507
+ "scale": list(pbone.scale),
4508
+ }
4509
+ position = array.array("f")
4510
+ quaternion = array.array("f")
4511
+ scale = array.array("f")
4512
+ last_q = None
4513
+ keys = set()
4514
+ for components in props.values():
4515
+ for fcurve in components.values():
4516
+ for point in fcurve.keyframe_points:
4517
+ keys.add(round(float(point.co[0]), 3))
4518
+ for frame in frames:
4519
+ values = {prop: list(value) for prop, value in own.items()}
4520
+ for prop, components in props.items():
4521
+ for index, fcurve in components.items():
4522
+ if index < len(values[prop]):
4523
+ values[prop][index] = fcurve.evaluate(frame)
4524
+ if mode == "QUATERNION":
4525
+ rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
4526
+ elif mode == "AXIS_ANGLE":
4527
+ angle, x, y, z = values["rotation_axis_angle"]
4528
+ axis = mathutils.Vector((x, y, z))
4529
+ rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 1.0, 0.0), angle)
4530
+ else:
4531
+ rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
4532
+ basis = mathutils.Matrix.LocRotScale(mathutils.Vector(values["location"]), rotation,
4533
+ mathutils.Vector(values["scale"]))
4534
+ loc, rot, size = (rest_relative @ basis).decompose()
4535
+ if last_q is not None and last_q.dot(rot) < 0:
4536
+ rot.negate()
4537
+ last_q = rot
4538
+ position.extend((loc.x, loc.y, loc.z))
4539
+ quaternion.extend((rot.x, rot.y, rot.z, rot.w))
4540
+ scale.extend((size.x, size.y, size.z))
4541
+
4542
+ out.append({"bone": name, "keys": sorted(keys), "position": _column(position, 3),
4543
+ "quaternion": _column(quaternion, 4), "scale": _column(scale, 3)})
4544
+ return out
4545
+
4546
+
4547
+ # ------------------------------------------------------------- the scene's movie
4548
+
4549
+ # AN OBJECT'S OWN TRANSFORM CHANNELS, as an action keys them on the object (not on a bone).
4550
+ _OBJECT_PATH = re.compile(r'^(location|rotation_quaternion|rotation_euler|rotation_axis_angle|scale)$')
4551
+ # A CAMERA'S KEYABLE PROJECTION, as an action keys it on the camera's data.
4552
+ _CAMERA_PROPS = ("lens", "sensor_width", "sensor_height", "ortho_scale", "shift_x", "shift_y",
4553
+ "clip_start", "clip_end")
4554
+
4555
+
4556
+ def _slot_handle(action, owner, prefix, slot):
4557
+ """Use the assigned slot, then the owner identifier, then the first slot of the owner’s ID type."""
4558
+ if slot is not None:
4559
+ return slot.handle
4560
+ slots = list(action.slots)
4561
+ if not slots:
4562
+ return None
4563
+ kind = {"OB": "OBJECT", "CA": "CAMERA", "NT": "NODETREE", "KE": "KEY", "SC": "SCENE"}[prefix]
4564
+ named = next((one for one in slots if one.identifier == prefix + owner.name), None)
4565
+ if named is not None:
4566
+ return named.handle
4567
+ typed = [one for one in slots if one.target_id_type == kind]
4568
+ return typed[0].handle if typed else None
4569
+
4570
+
4571
+ def _object_constraints(obj):
4572
+ """An object's constraint stack, in order: Damped Track and Track To carry what the presenter
4573
+ evaluates them from; every other type is named and played only in Blender."""
4574
+ stack = []
4575
+ for con in obj.constraints:
4576
+ target = getattr(con, "target", None)
4577
+ entry = {"name": con.name, "type": con.type, "enabled": bool(con.enabled),
4578
+ "influence": float(con.influence)}
4579
+ if target is not None:
4580
+ entry["target"] = target.name
4581
+ if con.type in ("DAMPED_TRACK", "TRACK_TO"):
4582
+ entry["trackAxis"] = con.track_axis
4583
+ if con.type == "TRACK_TO":
4584
+ entry["upAxis"] = con.up_axis
4585
+ entry["useTargetZ"] = bool(con.use_target_z)
4586
+ stack.append(entry)
4587
+ return stack
4588
+
4589
+
4590
+ def _column(samples, stride):
4591
+ """A sampled column as base64 Float32: its first sample alone when it never changes."""
4592
+ if len(samples) > stride and all(abs(samples[i] - samples[i % stride]) < 1e-7 for i in range(len(samples))):
4593
+ samples = samples[:stride]
4594
+ return {"count": len(samples) // stride, "base64": base64.b64encode(samples.tobytes()).decode("ascii")}
4595
+
4596
+
4597
+ def _owner_action_at(adt, frame):
4598
+ """Use the active action at the scene frame, otherwise the last unmuted strip containing it,
4599
+ or the first unmuted strip when none contains it. Clamp to that strip's range and apply its
4600
+ scale and repeat. Return no action when no unmuted action strip exists."""
4601
+ if adt.action is not None:
4602
+ return adt.action, frame
4603
+ best = None
4604
+ for track in adt.nla_tracks:
4605
+ if track.mute:
4606
+ continue
4607
+ for strip in track.strips:
4608
+ if strip.mute or strip.action is None:
4609
+ continue
4610
+ if strip.frame_start <= frame <= strip.frame_end or best is None:
4611
+ best = strip
4612
+ if best is None:
4613
+ return None, frame
4614
+ length = (best.action_frame_end - best.action_frame_start) or 1.0
4615
+ scale = abs(best.scale) or 1.0
4616
+ at = min(max(frame, best.frame_start), best.frame_end)
4617
+ into = ((at - best.frame_start) / scale) % length if at < best.frame_end else length
4618
+ return best.action, best.action_frame_start + into
4619
+
4620
+
4621
+ def _sample_object(obj, pre, delta, first, last, unsupported):
4622
+ """AN OBJECT'S MOVIE AS THREE.JS PLAYS IT: its local transform (`pre @ basis`, the basis with its
4623
+ delta transform, `BKE_object_to_mat4`) at every scene frame from `first` to `last`, each channel
4624
+ evaluated by Blender (`FCurve.evaluate`) from the action it shows there. Columns as the clip
4625
+ door's: position, quaternion (x, y, z, w, one hemisphere), scale; and `hidden` (one per frame,
4626
+ 1 hidden) when its render visibility is keyed."""
4627
+ adt = obj.animation_data
4628
+ if len([track for track in adt.nla_tracks if not track.mute]) > 1 or (adt.action is not None and len(adt.nla_tracks)):
4629
+ unsupported.append("its NLA layering (the active action, else the top strip, is played)")
4630
+ position = array.array("f")
4631
+ quaternion = array.array("f")
4632
+ scale = array.array("f")
4633
+ hidden = array.array("f")
4634
+ keyed_hidden = False
4635
+ last_q = None
4636
+ mode = obj.rotation_mode
4637
+ own = {"location": list(obj.location), "rotation_quaternion": list(obj.rotation_quaternion),
4638
+ "rotation_euler": list(obj.rotation_euler), "rotation_axis_angle": list(obj.rotation_axis_angle),
4639
+ "scale": list(obj.scale), "hide_render": [float(obj.hide_render)]}
4640
+ curves_of = {}
4641
+ for frame in range(first, last + 1):
4642
+ action, at = _owner_action_at(adt, frame)
4643
+ values = {prop: list(value) for prop, value in own.items()}
4644
+ if action is not None:
4645
+ curves = curves_of.get(action.name)
4646
+ if curves is None:
4647
+ handle = _slot_handle(action, obj, "OB", adt.action_slot if action == adt.action else None)
4648
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4649
+ for fcurve in curves:
4650
+ if fcurve.data_path in values and fcurve.array_index < len(values[fcurve.data_path]):
4651
+ values[fcurve.data_path][fcurve.array_index] = fcurve.evaluate(at)
4652
+ if fcurve.data_path == "hide_render":
4653
+ keyed_hidden = True
4654
+ if mode == "QUATERNION":
4655
+ rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
4656
+ elif mode == "AXIS_ANGLE":
4657
+ angle, x, y, z = values["rotation_axis_angle"]
4658
+ axis = mathutils.Vector((x, y, z))
4659
+ rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 0.0, 1.0), angle)
4660
+ else:
4661
+ rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
4662
+ rotation = delta @ rotation
4663
+ location = mathutils.Vector(values["location"]) + obj.delta_location
4664
+ size = mathutils.Vector([a * b for a, b in zip(values["scale"], obj.delta_scale)])
4665
+ loc, rot, sca = (pre @ mathutils.Matrix.LocRotScale(location, rotation, size)).decompose()
4666
+ if last_q is not None and last_q.dot(rot) < 0:
4667
+ rot.negate()
4668
+ last_q = rot
4669
+ position.extend((loc.x, loc.y, loc.z))
4670
+ quaternion.extend((rot.x, rot.y, rot.z, rot.w))
4671
+ scale.extend((sca.x, sca.y, sca.z))
4672
+ hidden.append(1.0 if values["hide_render"][0] >= 0.5 else 0.0)
4673
+ out = {"start": first, "end": last, "position": _column(position, 3), "quaternion": _column(quaternion, 4),
4674
+ "scale": _column(scale, 3)}
4675
+ if keyed_hidden:
4676
+ out["hidden"] = _column(hidden, 1)
4677
+ return out
4678
+
4679
+
4680
+ def _sample_camera(data, first, last):
4681
+ """A CAMERA'S PROJECTION AS THE MOVIE HAS IT: each of `_CAMERA_PROPS` at every scene frame, by
4682
+ Blender's evaluation of the action its data shows there."""
4683
+ adt = data.animation_data
4684
+ columns = {prop: array.array("f") for prop in _CAMERA_PROPS}
4685
+ curves_of = {}
4686
+ for frame in range(first, last + 1):
4687
+ action, at = _owner_action_at(adt, frame)
4688
+ values = {prop: float(getattr(data, prop)) for prop in _CAMERA_PROPS}
4689
+ if action is not None:
4690
+ curves = curves_of.get(action.name)
4691
+ if curves is None:
4692
+ handle = _slot_handle(action, data, "CA", adt.action_slot if action == adt.action else None)
4693
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4694
+ for fcurve in curves:
4695
+ if fcurve.data_path in values:
4696
+ values[fcurve.data_path] = fcurve.evaluate(at)
4697
+ for prop in _CAMERA_PROPS:
4698
+ columns[prop].append(values[prop])
4699
+ return {"start": first, "end": last, **{prop: _column(column, 1) for prop, column in columns.items()}}
4700
+
4701
+
4702
+ _MATERIAL_INPUTS = {"Base Color": 4, "Emission Color": 4, "Emission Strength": 1, "Alpha": 1}
4703
+
4704
+
4705
+ def _sample_material(material, first, last):
4706
+ """Sample a material’s keyed Principled Base Color, Emission and Alpha through the action its
4707
+ node tree shows at each scene frame. Return only keyed inputs, or None when none are keyed."""
4708
+ tree = material.node_tree
4709
+ adt = tree.animation_data if tree is not None else None
4710
+ if adt is None:
4711
+ return None
4712
+ bsdf = next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None)
4713
+ if bsdf is None:
4714
+ return None
4715
+ index = {}
4716
+ for i, socket in enumerate(bsdf.inputs):
4717
+ index.setdefault(socket.name, i)
4718
+ paths = {}
4719
+ own = {}
4720
+ for name, size in _MATERIAL_INPUTS.items():
4721
+ paths['nodes["%s"].inputs[%d].default_value' % (bsdf.name, index[name])] = name
4722
+ value = bsdf.inputs[index[name]].default_value
4723
+ own[name] = [float(v) for v in value] if size > 1 else [float(value)]
4724
+ keyed = set()
4725
+ color, emissive, opacity = array.array("f"), array.array("f"), array.array("f")
4726
+ curves_of = {}
4727
+ for frame in range(first, last + 1):
4728
+ action, at = _owner_action_at(adt, frame)
4729
+ values = {name: list(value) for name, value in own.items()}
4730
+ if action is not None:
4731
+ curves = curves_of.get(action.name)
4732
+ if curves is None:
4733
+ handle = _slot_handle(action, tree, "NT", adt.action_slot if action == adt.action else None)
4734
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4735
+ for fcurve in curves:
4736
+ name = paths.get(fcurve.data_path)
4737
+ if name is not None and fcurve.array_index < len(values[name]):
4738
+ values[name][fcurve.array_index] = fcurve.evaluate(at)
4739
+ keyed.add(name)
4740
+ base = values["Base Color"]
4741
+ color.extend(base[:3])
4742
+ glow = values["Emission Color"]
4743
+ strength = values["Emission Strength"][0]
4744
+ emissive.extend((glow[0] * strength, glow[1] * strength, glow[2] * strength))
4745
+ opacity.append(values["Alpha"][0])
4746
+ if not keyed:
4747
+ return None
4748
+ out = {"start": first, "end": last}
4749
+ if "Base Color" in keyed:
4750
+ out["color"] = _column(color, 3)
4751
+ if keyed & {"Emission Color", "Emission Strength"}:
4752
+ out["emissive"] = _column(emissive, 3)
4753
+ if "Alpha" in keyed:
4754
+ out["opacity"] = _column(opacity, 1)
4755
+ return out
4756
+
4757
+
4758
+ def _sample_shapes(obj, first, last, depsgraph):
4759
+ """Sample each keyed relative shape key’s vertex offsets and values at every scene frame,
4760
+ including the presented geometry’s current value. mapped is False when modifiers change vertex
4761
+ correspondence. Return None when no key is keyed."""
4762
+ mesh = obj.data
4763
+ keys = mesh.shape_keys if mesh is not None else None
4764
+ if keys is None or not keys.use_relative or len(keys.key_blocks) < 2:
4765
+ return None
4766
+ adt = keys.animation_data
4767
+ if adt is None:
4768
+ return None
4769
+ blocks = list(keys.key_blocks)[1:]
4770
+ path_of = {'key_blocks["%s"].value' % block.name: block.name for block in blocks}
4771
+ values = {block.name: array.array("f") for block in blocks}
4772
+ keyed = set()
4773
+ curves_of = {}
4774
+ for frame in range(first, last + 1):
4775
+ action, at = _owner_action_at(adt, frame)
4776
+ now = {block.name: float(block.value) for block in blocks}
4777
+ if action is not None:
4778
+ curves = curves_of.get(action.name)
4779
+ if curves is None:
4780
+ handle = _slot_handle(action, keys, "KE", adt.action_slot if action == adt.action else None)
4781
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4782
+ for fcurve in curves:
4783
+ name = path_of.get(fcurve.data_path)
4784
+ if name is not None:
4785
+ now[name] = fcurve.evaluate(at)
4786
+ keyed.add(name)
4787
+ for name, value in now.items():
4788
+ values[name].append(value)
4789
+ if not keyed:
4790
+ return None
4791
+ count = len(obj.data.vertices)
4792
+ try:
4793
+ mapped = len(obj.evaluated_get(depsgraph).data.vertices) == count
4794
+ except Exception:
4795
+ mapped = False
4796
+ out = []
4797
+ for block in blocks:
4798
+ if block.name not in keyed:
4799
+ continue
4800
+ own = array.array("f", [0.0]) * (3 * count)
4801
+ base = array.array("f", [0.0]) * (3 * count)
4802
+ block.data.foreach_get("co", own)
4803
+ (block.relative_key or keys.reference_key).data.foreach_get("co", base)
4804
+ delta = array.array("f", (a - b for a, b in zip(own, base)))
4805
+ out.append({"name": block.name, "delta": {"count": count, "base64": base64.b64encode(delta.tobytes()).decode("ascii")},
4806
+ "values": _column(values[block.name], 1), "current": float(block.value)})
4807
+ return {"object": obj.name, "start": first, "end": last, "vertices": count, "mapped": mapped, "keys": out}
4808
+
4809
+
4810
+ def rna_scene_movie():
4811
+ """Scene animation for Timeline preview and export: range, rate, camera, bound
4812
+ markers and sampled object, camera, material and shape-key columns. Object
4813
+ records retain parent-space transforms and constraints. Cameras named by
4814
+ the scene or a marker are included whether animated or not. Sampling leaves
4815
+ the scene frame unchanged."""
4816
+ scene = bpy.context.scene
4817
+ view_layer = bpy.context.view_layer
4818
+ fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
4819
+ markers = []
4820
+ for marker in scene.timeline_markers:
4821
+ markers.append({"name": marker.name, "frame": int(marker.frame),
4822
+ "camera": marker.camera.name if marker.camera is not None else None})
4823
+ wanted = {marker["camera"] for marker in markers if marker["camera"]}
4824
+ if scene.camera is not None:
4825
+ wanted.add(scene.camera.name)
4826
+
4827
+ def transform(path):
4828
+ return path if _OBJECT_PATH.match(path) or path == "hide_render" else None
4829
+
4830
+ def projection(path):
4831
+ return path if path in _CAMERA_PROPS else None
4832
+
4833
+ objects = []
4834
+ animated = {}
4835
+ for obj in view_layer.objects:
4836
+ own = _owner_animated(obj, transform, animated)
4837
+ constraints = _object_constraints(obj)
4838
+ camera = None
4839
+ if obj.type == "CAMERA" and obj.data is not None:
4840
+ data = obj.data
4841
+ camera = {
4842
+ "type": data.type, "lens": float(data.lens), "sensor_width": float(data.sensor_width),
4843
+ "sensor_height": float(data.sensor_height), "sensor_fit": data.sensor_fit,
4844
+ "ortho_scale": float(data.ortho_scale), "clip_start": float(data.clip_start),
4845
+ "clip_end": float(data.clip_end), "shift_x": float(data.shift_x), "shift_y": float(data.shift_y),
4846
+ "renderHidden": bool(obj.hide_render),
4847
+ **({"keyed": True} if _owner_animated(data, projection, animated) else {}),
4848
+ }
4849
+ if not own and (camera is None or (obj.name not in wanted and "keyed" not in camera)):
4850
+ continue
4851
+ unsupported = []
4852
+ parent = obj.parent
4853
+ if parent is None:
4854
+ pre = mathutils.Matrix.Identity(4)
4855
+ elif obj.parent_type == "OBJECT":
4856
+ pre = obj.matrix_parent_inverse
4857
+ else:
4858
+ # Parented to a bone, a vertex or a curve: the offset it has now is held.
4859
+ pre = (parent.matrix_world.inverted_safe() @ obj.matrix_world) @ obj.matrix_basis.inverted_safe()
4860
+ unsupported.append("its %s parenting (held where it is now)" % obj.parent_type.lower())
4861
+ mode = obj.rotation_mode
4862
+ if mode == "QUATERNION":
4863
+ delta = obj.delta_rotation_quaternion.normalized()
4864
+ elif mode == "AXIS_ANGLE":
4865
+ delta = mathutils.Quaternion((1.0, 0.0, 0.0, 0.0))
4866
+ else:
4867
+ delta = mathutils.Euler(obj.delta_rotation_euler, mode).to_quaternion()
4868
+ adt = obj.animation_data
4869
+ if adt is not None:
4870
+ for path in sorted({fc.data_path for fc in adt.drivers if _OBJECT_PATH.match(fc.data_path)}):
4871
+ unsupported.append("the driver on its %s" % path)
4872
+ for con in constraints:
4873
+ if con["enabled"] and con["type"] not in ("DAMPED_TRACK", "TRACK_TO"):
4874
+ unsupported.append("its %s constraint %r" % (con["type"].replace("_", " ").title(), con["name"]))
4875
+ sampled = _sample_object(obj, pre, delta, int(scene.frame_start), int(scene.frame_end),
4876
+ unsupported) if own else None
4877
+ if camera is not None and camera.pop("keyed", False):
4878
+ camera["sampled"] = _sample_camera(obj.data, int(scene.frame_start), int(scene.frame_end))
4879
+ objects.append({
4880
+ "name": obj.name,
4881
+ "type": obj.type,
4882
+ "parent": parent.name if parent is not None else None,
4883
+ **({"sampled": sampled} if sampled is not None else {}),
4884
+ "rotationMode": mode,
4885
+ "channels": {
4886
+ "location": [float(v) for v in obj.location],
4887
+ "rotation_quaternion": [float(v) for v in obj.rotation_quaternion],
4888
+ "rotation_euler": [float(v) for v in obj.rotation_euler],
4889
+ "rotation_axis_angle": [float(v) for v in obj.rotation_axis_angle],
4890
+ "scale": [float(v) for v in obj.scale],
4891
+ },
4892
+ "delta": {"location": [float(v) for v in obj.delta_location], "rotation": [float(v) for v in delta],
4893
+ "scale": [float(v) for v in obj.delta_scale]},
4894
+ "pre": _matrix_rows(pre),
4895
+ "constraints": constraints,
4896
+ **({"camera": camera} if camera is not None else {}),
4897
+ **({"unsupported": unsupported} if unsupported else {}),
4898
+ })
4899
+ # Sample keyed material inputs and shape keys.
4900
+ first, last = int(scene.frame_start), int(scene.frame_end)
4901
+ materials = []
4902
+ seen = set()
4903
+ for obj in view_layer.objects:
4904
+ for slot in obj.material_slots:
4905
+ material = slot.material
4906
+ if material is None or material.name in seen:
4907
+ continue
4908
+ seen.add(material.name)
4909
+ sampled = _sample_material(material, first, last)
4910
+ if sampled is not None:
4911
+ materials.append({"name": material.name, **sampled})
4912
+ depsgraph = bpy.context.evaluated_depsgraph_get()
4913
+ shapes = [one for one in (_sample_shapes(obj, first, last, depsgraph) for obj in view_layer.objects if obj.type == "MESH") if one]
4914
+ return {
4915
+ "materials": materials,
4916
+ "shapes": shapes,
4917
+ "scene": {"name": scene.name, "start": int(scene.frame_start), "end": int(scene.frame_end), "fps": fps,
4918
+ "current": int(scene.frame_current),
4919
+ "camera": scene.camera.name if scene.camera is not None else None},
4920
+ "markers": sorted(markers, key=lambda marker: marker["frame"]),
4921
+ "objects": objects,
4922
+ }
4923
+
4924
+
4925
+ def rna_sound_scene(first, last):
4926
+ """The scene's sound for the page to mix (docs/adr/movie-sound-and-react-frames.md,
4927
+ decision 2). Native audio mixing is unavailable in this Blender. Describe the movie's frames
4928
+ `first` to `last` for `@volter/editor-blender` to render in an OfflineAudioContext:
4929
+
4930
+ - the sequencer's sound strips: each one's file (or, packed, a copy written beside the
4931
+ session), where its sound starts and where the strip is cut, its speed, whether it is muted
4932
+ or mono, and its volume and pan at every frame, read through the scene's F-Curves (a fade
4933
+ is keys on volume);
4934
+ - the speakers: each one's file, the frames its NLA sound strips start on, its volume and
4935
+ distance settings, and its place and facing at every frame, with the scene camera's (the
4936
+ listener). Those are evaluated, so the frame moves and is restored.
4937
+
4938
+ The scene's own volume and distance model come too. READ ONLY: no history, save or present."""
4939
+ scene = bpy.context.scene
4940
+ fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
4941
+ count = last - first + 1
4942
+
4943
+ def sound_file(sound):
4944
+ if sound is None:
4945
+ return None
4946
+ if sound.packed_file is not None:
4947
+ path = os.path.join(ROOT, "sound-%s" % hashlib.sha256(sound.name.encode("utf-8")).hexdigest()[:16])
4948
+ with open(path, "wb") as out:
4949
+ out.write(sound.packed_file.data)
4950
+ return {"packed": path}
4951
+ # normalized: a path saved relative to the .blend ("//../../sounds/x.wav") keeps its ".." segments otherwise
4952
+ return {"path": os.path.normpath(bpy.path.abspath(sound.filepath, library=sound.library))}
4953
+
4954
+ # A strip's keyed volume and pan are the scene's F-Curves on the strip's own path.
4955
+ adt = scene.animation_data
4956
+ curves = {}
4957
+ if adt is not None and adt.action is not None:
4958
+ handle = _slot_handle(adt.action, scene, "SC", adt.action_slot)
4959
+ for fcurve in _action_channelbag(adt.action, handle):
4960
+ curves[(fcurve.data_path, fcurve.array_index)] = fcurve
4961
+
4962
+ def sampled(strip, prop):
4963
+ constant = float(getattr(strip, prop))
4964
+ fcurve = next((curves[(path, 0)] for path in (
4965
+ 'sequence_editor.strips_all["%s"].%s' % (strip.name, prop),
4966
+ 'sequence_editor.sequences_all["%s"].%s' % (strip.name, prop)) if (path, 0) in curves), None)
4967
+ values = array.array("f", (fcurve.evaluate(frame) if fcurve is not None else constant
4968
+ for frame in range(first, last + 1)))
4969
+ return _column(values, 1)
4970
+
4971
+ editor = scene.sequence_editor
4972
+ strips = []
4973
+ if editor is not None:
4974
+ channels = editor.channels
4975
+ for strip in editor.strips_all:
4976
+ if strip.type != "SOUND":
4977
+ continue
4978
+ channel = channels[strip.channel] if 0 <= strip.channel < len(channels) else None
4979
+ strips.append({
4980
+ "name": strip.name,
4981
+ "file": sound_file(strip.sound),
4982
+ "mono": bool(strip.sound is not None and strip.sound.use_mono),
4983
+ "muted": bool(strip.mute or (channel is not None and channel.mute)),
4984
+ "start": float(strip.frame_start),
4985
+ "finalStart": float(strip.frame_final_start),
4986
+ "finalEnd": float(strip.frame_final_end),
4987
+ "speed": float(getattr(strip, "speed_factor", 1.0)),
4988
+ "volume": sampled(strip, "volume"),
4989
+ "pan": sampled(strip, "pan"),
4990
+ })
4991
+
4992
+ speakers = [obj for obj in scene.objects if obj.type == "SPEAKER" and obj.data is not None]
4993
+ described = []
4994
+ listener = None
4995
+ if speakers:
4996
+ tracks = {obj.name: [] for obj in speakers}
4997
+ for obj in speakers:
4998
+ for track in (obj.animation_data.nla_tracks if obj.animation_data is not None else ()):
4999
+ if track.mute:
5000
+ continue
5001
+ for strip in track.strips:
5002
+ if strip.type == "SOUND" and not strip.mute:
5003
+ tracks[obj.name].append(float(strip.frame_start))
5004
+ places = {obj.name: array.array("f") for obj in speakers}
5005
+ facings = {obj.name: array.array("f") for obj in speakers}
5006
+ volumes = {obj.name: array.array("f") for obj in speakers}
5007
+ heard = {"position": array.array("f"), "forward": array.array("f"), "up": array.array("f")}
5008
+ kept = scene.frame_current
5009
+ try:
5010
+ for frame in range(first, last + 1):
5011
+ scene.frame_set(frame)
5012
+ for obj in speakers:
5013
+ matrix = obj.matrix_world
5014
+ places[obj.name].extend(matrix.translation)
5015
+ facings[obj.name].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
5016
+ volumes[obj.name].append(float(obj.data.volume))
5017
+ camera = scene.camera
5018
+ matrix = camera.matrix_world if camera is not None else mathutils.Matrix.Identity(4)
5019
+ heard["position"].extend(matrix.translation)
5020
+ heard["forward"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
5021
+ heard["up"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 1.0, 0.0))).normalized())
5022
+ finally:
5023
+ scene.frame_set(kept)
5024
+ for obj in speakers:
5025
+ data = obj.data
5026
+ described.append({
5027
+ "name": obj.name,
5028
+ "file": sound_file(data.sound),
5029
+ "muted": bool(data.muted),
5030
+ "starts": tracks[obj.name],
5031
+ "volume": _column(volumes[obj.name], 1),
5032
+ "volumeMin": float(data.volume_min),
5033
+ "volumeMax": float(data.volume_max),
5034
+ "attenuation": float(data.attenuation),
5035
+ "distanceReference": float(data.distance_reference),
5036
+ "distanceMax": float(data.distance_max),
5037
+ "coneInner": float(data.cone_angle_inner),
5038
+ "coneOuter": float(data.cone_angle_outer),
5039
+ "coneOuterVolume": float(data.cone_volume_outer),
5040
+ "position": _column(places[obj.name], 3),
5041
+ "facing": _column(facings[obj.name], 3),
5042
+ })
5043
+ listener = {key: _column(column, 3) for key, column in heard.items()}
5044
+
5045
+ return {
5046
+ "fps": fps, "first": first, "last": last, "count": count,
5047
+ "volume": float(scene.audio_volume),
5048
+ # `use_audio` is the negation of `AUDIO_MUTE`: true, the default, plays the sequencer's sound
5049
+ "muted": not bool(scene.use_audio),
5050
+ "distanceModel": scene.audio_distance_model,
5051
+ "strips": strips,
5052
+ "speakers": described,
5053
+ "listener": listener,
5054
+ }
5055
+
5056
+
5057
+ def _owner_animated(owner, accept, cache):
5058
+ """Whether an owner's own properties (`accept` names them) are keyed by an action it shows: its
5059
+ active action or an unmuted NLA strip's. One look per action and owner kind, kept in `cache`:
5060
+ a level's armatures share their few actions, and their curves are bones', not the object's."""
5061
+ adt = owner.animation_data
5062
+ if adt is None:
5063
+ return False
5064
+ actions = [adt.action] if adt.action is not None else []
5065
+ for track in adt.nla_tracks:
5066
+ if not track.mute:
5067
+ actions.extend(strip.action for strip in track.strips if strip.action is not None and not strip.mute)
5068
+ for action in actions:
5069
+ key = (action.name, accept)
5070
+ if key not in cache:
5071
+ # EVERY SLOT'S CURVES: a camera's object and its lens share one action through two slots.
5072
+ curves = []
5073
+ for layer in action.layers:
5074
+ for strip in layer.strips:
5075
+ for bag in strip.channelbags:
5076
+ curves.extend(bag.fcurves)
5077
+ cache[key] = any(accept(fcurve.data_path) for fcurve in curves)
5078
+ if cache[key]:
5079
+ return True
5080
+ return False
5081
+
5082
+
5256
5083
  # `Keyframe.type`'s significance order, mirrored from the `KEYFRAME_STATE`
5257
5084
  # merge in `keyframes_keylist.cc` -- a column drawn from several curves takes
5258
5085
  # the most significant type any of them carries.
@@ -5271,10 +5098,10 @@ def _action_key_range(action, first, last):
5271
5098
  repeats into, any other type opens both sides. Mute is not read. None is an open side."""
5272
5099
  low, high = None, None
5273
5100
  opened_low = opened_high = False
5274
- curves = list(getattr(action, "fcurves", None) or ())
5275
- for layer in getattr(action, "layers", ()):
5276
- for strip in getattr(layer, "strips", ()):
5277
- for bag in (getattr(strip, "channelbags", None) or ()):
5101
+ curves = []
5102
+ for layer in action.layers:
5103
+ for strip in layer.strips:
5104
+ for bag in strip.channelbags:
5278
5105
  curves.extend(bag.fcurves)
5279
5106
  # A curve's keys and its last modifier count independently: a keyless curve carrying a
5280
5107
  # Generator or Noise still opens the range, as in Blender.
@@ -5334,8 +5161,8 @@ def _curve_body(fcurve):
5334
5161
  if modifier.mute:
5335
5162
  continue
5336
5163
  if modifier.type == "CYCLES" and not modifier.use_restricted_range and not modifier.use_influence:
5337
- cycles = [_CYCLE_MODE.get(modifier.mode_before, 0), int(modifier.cycles_before),
5338
- _CYCLE_MODE.get(modifier.mode_after, 0), int(modifier.cycles_after)]
5164
+ cycles = [_CYCLE_MODE[modifier.mode_before], int(modifier.cycles_before),
5165
+ _CYCLE_MODE[modifier.mode_after], int(modifier.cycles_after)]
5339
5166
  else:
5340
5167
  unsupported.append("%s modifier" % modifier.type.lower())
5341
5168
  out = {
@@ -5449,9 +5276,7 @@ def rna_outliner(selected=None):
5449
5276
  placed.add(obj.name)
5450
5277
  ordered.append(obj)
5451
5278
 
5452
- # The placed object itself, not `bpy.data.objects[name]`: a lookup by name is
5453
- # a walk of the whole ID list, and one per object made this loop quadratic
5454
- # (43 ms of a 1,490-object read).
5279
+ # Reuse each placed object to avoid a linear ID-name lookup per row.
5455
5280
  for entries in list(placements.values()):
5456
5281
  order(entries[0][0]["place"])
5457
5282
  # A move takes the place out of its collection's list; the lists are
@@ -5486,16 +5311,9 @@ def rna_outliner(selected=None):
5486
5311
  for row in collections:
5487
5312
  row["children"] = [child for child in row["children"] if id(child) not in moved]
5488
5313
 
5489
- # THE SELECTION KEEPS ITS ROWS PAST THE PAGE. A row is how the editor
5490
- # addresses an object -- the viewport's outline and gizmo, the Properties
5491
- # rail -- so a selected or active object, the caller's or the engine's, keeps
5492
- # its row and every row above it however far down a long list it sits.
5493
- # BOUNDED LIKE THE PAGE ITSELF: the active object always, then at most one
5494
- # page of the selection, so selecting everything cannot turn one read back
5495
- # into the whole tree the page exists to keep out (Canyon Comet, 1,493
5496
- # objects selected: 1.77 MB a read uncapped, 142 KB capped).
5497
- # The caller's choice comes before the rest of the engine's selection: a click
5498
- # onto a selection already larger than a page keeps the object just clicked.
5314
+ # Retain at most one page of active/selected rows and their ancestors.
5315
+ # Prioritize the active object, then the caller's selection, then the
5316
+ # engine's remaining selection so a clicked object keeps its address.
5499
5317
  wanted = [active] if active is not None else []
5500
5318
  wanted += sorted(chosen)
5501
5319
  wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
@@ -5508,7 +5326,7 @@ def rna_outliner(selected=None):
5508
5326
 
5509
5327
  def hold(row):
5510
5328
  inside = "place" in row and row["name"] in kept
5511
- for child in row.get("children", []):
5329
+ for child in row["children"]:
5512
5330
  inside = hold(child) or inside
5513
5331
  if inside:
5514
5332
  holding.add(id(row))
@@ -5542,7 +5360,7 @@ def rna_outliner(selected=None):
5542
5360
  # Cap the hierarchy's visible child lists, not the collection's flat input.
5543
5361
  # Every parent participates in folding even when its child list needs a page.
5544
5362
  def page_tree(row):
5545
- members = row.get("children", [])
5363
+ members = row["children"]
5546
5364
  children, more = _outliner_page(members)
5547
5365
  beyond = [child for child in members[len(children):] if id(child) in holding]
5548
5366
  children = [kept_row(child) for child in children + beyond]
@@ -5559,8 +5377,7 @@ def rna_outliner(selected=None):
5559
5377
  # page left behind adds only what it would have claimed that a later row
5560
5378
  # can share -- its own address, its materials, its modifiers' linked
5561
5379
  # datablocks, its instanced collection -- and one more expansion of itself
5562
- # and of its data. Data with no address of its own claims its rows the
5563
- # long way, every one in the shared count.
5380
+ # and of its data.
5564
5381
  addresses = {}
5565
5382
 
5566
5383
  def address(datablock):
@@ -5580,33 +5397,24 @@ def rna_outliner(selected=None):
5580
5397
  object_key = obj.as_pointer()
5581
5398
  object_path = address(obj)
5582
5399
  data = obj.data
5583
- exact = object_path is None
5584
- scopes = [] if exact else [(object_path + ".", None, expanded.get(object_key, 0))]
5400
+ scopes = [(object_path + ".", None, expanded.get(object_key, 0))]
5585
5401
  data_key = bones = None
5586
5402
  if data is not None:
5587
5403
  data_key = data.as_pointer()
5588
5404
  data_path = address(data)
5589
5405
  keys = getattr(data, "shape_keys", None)
5590
5406
  keys_path = address(keys) if keys is not None else None
5591
- exact = exact or data_path is None or (keys is not None and keys_path is None)
5592
5407
  bones = isinstance(data, bpy.types.Armature) and obj.mode != "POSE"
5593
- if not exact:
5594
- before = data_expanded.get(data_key, 0)
5595
- scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
5596
- (data_path + ".", data_path, before)]
5597
- if keys_path is not None:
5598
- scopes.append((keys_path + ".", keys_path, before))
5599
- if exact:
5600
- scopes = []
5408
+ before = data_expanded.get(data_key, 0)
5409
+ scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
5410
+ (data_path + ".", data_path, before)]
5411
+ if keys is not None:
5412
+ scopes.append((keys_path + ".", keys_path, before))
5601
5413
  row = spot.get("row")
5602
- if row is not None or exact:
5603
- # Built rows, or the long way for data the scopes cannot name.
5414
+ if row is not None:
5604
5415
  scope = _OutlinerScope(seen, scopes)
5605
- if row is None:
5606
- _outliner_object(obj, view_layer, scope, chosen, active)
5607
- else:
5608
- for claimed in spot["claims"]:
5609
- claimed["id"] = _outliner_unique(claimed["path"], scope)
5416
+ for claimed in spot["claims"]:
5417
+ claimed["id"] = _outliner_unique(claimed["path"], scope)
5610
5418
  else:
5611
5419
  bump(object_path)
5612
5420
  if data is not None:
@@ -5621,9 +5429,7 @@ def rna_outliner(selected=None):
5621
5429
  linked = getattr(md, member, None)
5622
5430
  if linked is None:
5623
5431
  continue
5624
- linked_path = address(linked)
5625
- if linked_path:
5626
- bump(linked_path)
5432
+ bump(address(linked))
5627
5433
  break
5628
5434
  if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
5629
5435
  bump(address(obj.instance_collection))
@@ -5703,32 +5509,55 @@ def outliner_set(path, column, value):
5703
5509
  % (column, target.bl_rna.identifier, path))
5704
5510
 
5705
5511
 
5706
- # REQUESTS THAT CAN REMOVE OBJECTS, watched: a removal is reported with its cause, because an
5707
- # object that disappears without a named act is the loss this session must never hide. A scene
5708
- # was once found emptied and saved over its file by a burst of three edits at boot, with nothing
5709
- # recording which requests they were.
5710
- _WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step")
5512
+ # Record requests that can remove objects so every disappearance has a named cause.
5513
+ _WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step", "gltf-export", "run-script")
5514
+ # The requests that may change an action's keys (`Session._kept_action_revisions`): every edit, not an
5515
+ # export (which puts everything back) and not a read.
5516
+ _ACTION_EDIT_OPS = ("execute", "rna-set", "outliner-set", "history-step")
5517
+ _ACTION_EDITS = {"count": 0}
5711
5518
 
5712
5519
 
5713
5520
  def dispatch(request):
5714
5521
  op = request.get("op")
5522
+ if op in _ACTION_EDIT_OPS:
5523
+ _ACTION_EDITS["count"] += 1
5715
5524
  if op not in _WATCHED_OPS:
5716
5525
  return _dispatch_request(request)
5717
- # ID.session_uid survives renames, reallocations and file reloads.
5718
- # Names alone falsely reported an ordinary rename as a deleted object.
5526
+ # Compare stable session_uids so renames do not count as object deletions.
5719
5527
  before = {o.session_uid: o.name for o in bpy.data.objects}
5528
+ # A request that loads a file (`read_homefile`, `open_mainfile`, then perhaps `save_as_mainfile`, to the same path
5529
+ # or another) removes nothing from the document it replaced: its objects are that file's, as saved. What it can
5530
+ # lose is the document's unsaved changes, said where there were any.
5531
+ before_path = bpy.data.filepath
5532
+ # (the session's own record of changes not yet saved, `mark_changed`'s: Blender's `is_dirty` is instrumentation)
5533
+ before_dirty = SESSION.save_due
5534
+ before_loads = _LOADS["count"]
5720
5535
  try:
5721
5536
  return _dispatch_request(request)
5722
5537
  finally:
5723
- remaining = {o.session_uid for o in bpy.data.objects}
5724
- removed = [name for uid, name in before.items() if uid not in remaining]
5725
- if removed:
5726
- cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
5727
- if k in request}
5728
- if op == "execute":
5729
- cause["code"] = str(request.get("code", ""))[:160]
5730
- _say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
5731
- op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
5538
+ cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
5539
+ if k in request}
5540
+ if op == "execute":
5541
+ cause["code"] = str(request.get("code", ""))[:160]
5542
+ if _LOADS["count"] != before_loads:
5543
+ same = bool(before_path) and os.path.normpath(bpy.data.filepath) == os.path.normpath(before_path)
5544
+ if before_dirty:
5545
+ _say("@@VOLTER-WARN %s now holds %s in place of %s, whose unsaved changes are lost (%s)" % (
5546
+ op, bpy.data.filepath or "a new, unsaved file", before_path or "an unsaved file",
5547
+ json.dumps(cause)))
5548
+ if same:
5549
+ # (the same file reloaded, rebuilt perhaps: its objects are new, so an object it no longer holds is
5550
+ # known by its name)
5551
+ gone = sorted(set(before.values()) - {o.name for o in bpy.data.objects})
5552
+ if gone:
5553
+ _say("@@VOLTER-WARN %s reloaded %s without objects %s (%s); %d remain" % (
5554
+ op, before_path, gone, json.dumps(cause), len(bpy.data.objects)))
5555
+ else:
5556
+ remaining = {o.session_uid for o in bpy.data.objects}
5557
+ removed = [name for uid, name in before.items() if uid not in remaining]
5558
+ if removed:
5559
+ _say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
5560
+ op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
5732
5561
 
5733
5562
 
5734
5563
  def _dispatch_request(request):
@@ -5822,10 +5651,119 @@ def _native_preview(request):
5822
5651
  bpy.data.scenes.remove(preview, do_unlink=True)
5823
5652
 
5824
5653
 
5654
+ # WHERE AN EXPORT IS WRITTEN in the module filesystem, before the tab carries it to the project:
5655
+ # one export's files at a time, outside the project's tree, so the MCP mirror never lists them.
5656
+ _GLTF_EXPORT_DIR = "/tmp/volter-gltf-export"
5657
+
5658
+
5659
+ def _gltf_export(request):
5660
+ """A COMPONENT OR A LEVEL BY BLENDER'S OWN glTF EXPORTER (`gltf_export.py`, RFC 0007).
5661
+
5662
+ The document as it is, written as `<name>.glb` and its manifest `<name>.json` into
5663
+ `_GLTF_EXPORT_DIR`, which the tab reads and writes into the project's
5664
+ `.volter/export/components/` or `.volter/export/levels/`. The export puts back everything it
5665
+ sets up (its temporary markers and collection, the active collection, an instancer's
5666
+ realization, the frame) before it answers, so, like `native-preview`, no history checkpoint,
5667
+ save, frame export or revision bump is owed. `previous` is the project's earlier manifest and
5668
+ its file's SHA-256: when the `.blend`, the exporter and the settings still match, nothing is
5669
+ exported and the answer is `current`. `blendSha256` is the document's SHA-256 as the tab reads
5670
+ it from the project: the module filesystem keeps no copy of a document it streamed in.
5671
+ """
5672
+ kind = request.get("kind")
5673
+ if kind not in ("component", "level"):
5674
+ raise ValueError("A glTF export is a component or a level")
5675
+ if SESSION.document is None or SESSION._foreign:
5676
+ raise RuntimeError("Blender holds no project document to export")
5677
+ if ROOT not in sys.path:
5678
+ sys.path.insert(0, ROOT)
5679
+ import gltf_export
5680
+ name = os.path.splitext(os.path.basename(SESSION.document))[0]
5681
+ os.makedirs(_GLTF_EXPORT_DIR, exist_ok=True)
5682
+ for stale in os.listdir(_GLTF_EXPORT_DIR):
5683
+ os.unlink(os.path.join(_GLTF_EXPORT_DIR, stale))
5684
+ out = os.path.join(_GLTF_EXPORT_DIR, name + ".glb")
5685
+ previous = request.get("previous")
5686
+ blend_sha = request.get("blendSha256")
5687
+ # The display LUTs the project holds intact (`.volter/export/display/<key>.cube`), each with its
5688
+ # recorded SHA-256: one is baked only when the scene's view settings have none.
5689
+ luts = request.get("luts")
5690
+ luts = ({str(k): str(v) for k, v in luts.items()} if isinstance(luts, dict)
5691
+ else [str(k) for k in luts] if isinstance(luts, list) else [])
5692
+ previous = previous if isinstance(previous, dict) else None
5693
+ blend_sha = blend_sha if isinstance(blend_sha, str) and blend_sha else None
5694
+ answer = None
5695
+ # Check whether the existing export is current before retaining document state. Unchanged
5696
+ # exports touch nothing; changed exports use gltf_export.export, which restores its temporary
5697
+ # changes.
5698
+ if not request.get("force") and previous is not None and blend_sha is not None:
5699
+ started = time.perf_counter()
5700
+ settings = dict(gltf_export.COMPONENT_SETTINGS if kind == "component" else gltf_export.LEVEL_SETTINGS)
5701
+ current = gltf_export._current(out, blend_sha, gltf_export.exporter_version(), settings, previous)
5702
+ if current is not None:
5703
+ answer = gltf_export._current_answer(out, current, started, luts)
5704
+ if answer is None:
5705
+ answer = gltf_export.export(kind, out, SESSION.project, force=bool(request.get("force")),
5706
+ previous=previous, blend_sha=blend_sha, luts=luts)
5707
+ # The file, what travels beside it (a newly baked display LUT), then its manifest, which names
5708
+ # them; a current export may still bring the LUT it names when the project lost it.
5709
+ sidecars = answer["sidecars"]
5710
+ files = list(sidecars) if answer["current"] else [out, *sidecars, os.path.splitext(out)[0] + ".json"]
5711
+ # What this Blender would write for either kind, so a caller can tell whether another file's
5712
+ # earlier export (a level's components) still stands without opening it.
5713
+ return {"kind": kind, "name": name, "document": SESSION.document_relative, "current": answer["current"],
5714
+ "ms": answer["ms"], "steps": answer.get("steps", {}), "manifest": answer["manifest"], "files": files,
5715
+ "exporter": gltf_export.exporter_version(),
5716
+ "settings": {"component": dict(gltf_export.COMPONENT_SETTINGS), "level": dict(gltf_export.LEVEL_SETTINGS)}}
5717
+
5718
+
5719
+ def _run_script(request):
5720
+ """A SCRIPT RUN AS `blender -b <document> -P <script> -- <args>` RUNS ONE, in the document Blender holds.
5721
+
5722
+ Its `sys.argv` ends in `--` and `args`, as Blender's own does, and its working directory is the run's own `dir`
5723
+ (new for each run, outside the project's tree): a file it writes by a relative path is named in the answer's
5724
+ `files` by that path, and the worker carries it out. It runs as `execute` runs a script (`output` its printing,
5725
+ `error` its raise, `SystemExit` included). This path adds no history step, save or present;
5726
+ scripts restore any temporary document changes themselves. The runner restores argv and cwd.
5727
+ """
5728
+ if SESSION.document is None or SESSION._foreign:
5729
+ raise RuntimeError("Blender holds no project document to run a script in")
5730
+ root = request["dir"]
5731
+ os.makedirs(root)
5732
+ argv, cwd = sys.argv, os.getcwd()
5733
+ sys.argv = [argv[0], "--", *[str(arg) for arg in request["args"]]]
5734
+ os.chdir(root)
5735
+ try:
5736
+ ran = execute(request["source"])
5737
+ finally:
5738
+ sys.argv = argv
5739
+ os.chdir(cwd)
5740
+ files = sorted(os.path.relpath(os.path.join(folder, name), root).replace(os.sep, "/")
5741
+ for folder, _, names in os.walk(root) for name in names)
5742
+ return {"output": ran["result"], "error": ran["error"], "files": files}
5743
+
5744
+
5745
+ def _asset_library(request):
5746
+ """AN ASSET LIBRARY IN BLENDER'S PREFERENCES (Preferences > File Paths > Asset Libraries): `name`, at the
5747
+ project's folder `directory`, importing its assets by `importMethod` (`LINK`, `APPEND`, ...). One of that name
5748
+ already there is set again. A preference, not a document edit: nothing is owed."""
5749
+ library = request["library"]
5750
+ path = os.path.join(SESSION.project, library["directory"])
5751
+ libraries = bpy.context.preferences.filepaths.asset_libraries
5752
+ held = libraries.get(library["name"]) or libraries.new(name=library["name"], directory=path)
5753
+ held.path = path
5754
+ held.import_method = library["importMethod"]
5755
+
5756
+
5825
5757
  def _dispatch(request):
5826
5758
  op = request.get("op")
5827
5759
  if op == "native-preview":
5828
5760
  return _native_preview(request)
5761
+ if op == "gltf-export":
5762
+ return _gltf_export(request)
5763
+ if op == "run-script":
5764
+ return _run_script(request)
5765
+ if op == "asset-library":
5766
+ return _asset_library(request)
5829
5767
  if op == "document-info":
5830
5768
  return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
5831
5769
  if op == "execute":
@@ -5854,15 +5792,7 @@ def _dispatch(request):
5854
5792
  try:
5855
5793
  SESSION.present()
5856
5794
  except Exception as thrown: # noqa: BLE001
5857
- # LOUD, because a present that fails leaves the Model document
5858
- # showing the state BEFORE this call and nothing else says so.
5859
- # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
5860
- # escalate (`blender-emscripten-engine.mts:159`,
5861
- # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
5862
- # to the console and nothing else), so it logged at `log` level and
5863
- # reached no counter -- measured 2026-09-19 (I4), when a throw in
5864
- # the overlay walk froze the viewport through a dozen successful
5865
- # `blender-execute` calls with `cyclotron console` silent throughout.
5795
+ # Escalate present failures because the displayed document still holds its preceding state.
5866
5796
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5867
5797
  "showing the state before it: " + repr(thrown))
5868
5798
  return answer
@@ -5886,12 +5816,17 @@ def _dispatch(request):
5886
5816
  return rna_node_tree(request.get("path"), request.get("material"))
5887
5817
  if op == "uv-layout":
5888
5818
  return rna_uv_layout(request.get("object"), request.get("uvLayer"))
5889
- # THE RIG AND CLIP DOORS (the Timeline): both READS, so neither presents
5890
- # and neither owes a derivation. There is deliberately no writer beside
5891
- # them -- keying, moving a key and setting a range are edits.
5819
+ # The Timeline clip read neither presents nor creates a derivation.
5820
+ # Keying, moving a key and setting a range remain edits.
5892
5821
  if op == "action-clip":
5893
5822
  return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"),
5894
5823
  request.get("summary", True))
5824
+ # The movie is a read.
5825
+ if op == "scene-movie":
5826
+ return rna_scene_movie()
5827
+ # The scene's sound for the page to mix: a read; speakers move the frame and restore it.
5828
+ if op == "sound-scene":
5829
+ return rna_sound_scene(int(request["first"]), int(request["last"]))
5895
5830
  if op == "outliner":
5896
5831
  return rna_outliner(request.get("selected"))
5897
5832
  if op == "outliner-set":
@@ -5903,15 +5838,7 @@ def _dispatch(request):
5903
5838
  try:
5904
5839
  SESSION.present()
5905
5840
  except Exception as thrown: # noqa: BLE001
5906
- # LOUD, because a present that fails leaves the Model document
5907
- # showing the state BEFORE this call and nothing else says so.
5908
- # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
5909
- # escalate (`blender-emscripten-engine.mts:159`,
5910
- # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
5911
- # to the console and nothing else), so it logged at `log` level and
5912
- # reached no counter -- measured 2026-09-19 (I4), when a throw in
5913
- # the overlay walk froze the viewport through a dozen successful
5914
- # `blender-execute` calls with `cyclotron console` silent throughout.
5841
+ # Escalate present failures because the displayed document still holds its preceding state.
5915
5842
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5916
5843
  "showing the state before it: " + repr(thrown))
5917
5844
  return answer
@@ -5926,57 +5853,38 @@ def _dispatch(request):
5926
5853
  try:
5927
5854
  SESSION.present()
5928
5855
  except Exception as thrown: # noqa: BLE001
5929
- # LOUD, because a present that fails leaves the Model document
5930
- # showing the state BEFORE this call and nothing else says so.
5931
- # `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
5932
- # escalate (`blender-emscripten-engine.mts:159`,
5933
- # `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
5934
- # to the console and nothing else), so it logged at `log` level and
5935
- # reached no counter -- measured 2026-09-19 (I4), when a throw in
5936
- # the overlay walk froze the viewport through a dozen successful
5937
- # `blender-execute` calls with `cyclotron console` silent throughout.
5856
+ # Escalate present failures because the displayed document still holds its preceding state.
5938
5857
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5939
5858
  "showing the state before it: " + repr(thrown))
5940
5859
  return answer
5941
5860
  if op == "save-document":
5942
5861
  return SESSION.save_document()
5862
+ if op == "open-document":
5863
+ # The worker just staged the document's file into the engine's filesystem (another
5864
+ # `.blend`, or this one again for a revert or a backup); open it as the model.
5865
+ return SESSION.bind_document(request["document"])
5943
5866
  if op == "start":
5944
5867
  SESSION.project = request.get("project", SESSION.project)
5945
5868
  try:
5946
5869
  os.chdir(SESSION.project)
5947
5870
  except OSError:
5948
5871
  pass
5949
- answer = {"session": SESSION.session, "python": sys.version,
5950
- "blender": bpy.app.version_string, "engines": ENGINES,
5951
- "unavailableEngines": UNAVAILABLE_ENGINES}
5952
- document = request.get("document")
5953
- if document:
5954
- answer.update(SESSION.bind_document(document))
5955
- return answer
5872
+ return {"session": SESSION.session, "python": sys.version,
5873
+ "blender": bpy.app.version_string, "engines": ENGINES,
5874
+ "unavailableEngines": UNAVAILABLE_ENGINES}
5956
5875
  raise ValueError("Unknown session op %r" % (op,))
5957
5876
 
5958
5877
 
5959
5878
  # ---------------------------------------------------------------- the loop
5960
5879
 
5961
- _prepare_directories()
5962
- bpy.app.handlers.load_post.append(_load_post)
5963
- bpy.app.handlers.undo_post.append(_history_post)
5964
- bpy.app.handlers.redo_post.append(_history_post)
5965
- ENGINES, UNAVAILABLE_ENGINES = _register_engine()
5966
- _say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
5967
- "unavailableEngines": UNAVAILABLE_ENGINES}))
5968
-
5969
- # Markers already dispatched, and the answers whose `out/` files this loop
5970
- # still owes a cleanup. Both are pruned as the page acks, so neither grows
5971
- # with the session.
5972
5880
  def _channel_loop():
5973
5881
  """Serve the directory channel until the program ends.
5974
5882
 
5975
5883
  A FUNCTION SO ITS DEATH HAS A NAME. Everything here below the per-request
5976
- `try` is the CHANNEL itself, and an exception in it used to fall off the
5977
- end of this script: Blender then finishes `--python` and exits 0 through
5978
- its ordinary path, so the page is told "Blender exited 0" and nothing
5979
- anywhere says why. MEASURED 2026-09-19 -- from outside, a channel death is
5884
+ `try` is the CHANNEL itself. An exception in it that fell off the end of
5885
+ this script would let Blender finish `--python` and exit 0 through its
5886
+ ordinary path, so the page would be told "Blender exited 0" and nothing
5887
+ anywhere would say why: from outside, a channel death is
5980
5888
  indistinguishable from a clean shutdown, and the traceback goes to stderr,
5981
5889
  which is page output rather than a console condition. The caller below
5982
5890
  makes it one.
@@ -5988,12 +5896,11 @@ def _channel_loop():
5988
5896
  unacked = []
5989
5897
  active = 0.0
5990
5898
  while True:
5991
- # A FAILED LISTING IS NOT AN EMPTY ONE. This swallowed every OSError
5992
- # into "no requests", so the one failure that matters -- the process
5993
- # out of file descriptors -- turned the session DEAF: it kept looping
5994
- # at full speed, answered nothing ever again, and the page waited on a
5995
- # call with a live program and no error anywhere (measured
5996
- # 2026-09-19, 1,008 s). The directory is the page's and is made before
5899
+ # A FAILED LISTING IS NOT AN EMPTY ONE. Read as "no requests", the one
5900
+ # failure that matters -- the process out of file descriptors -- would
5901
+ # turn the session DEAF: looping at full speed, answering nothing ever
5902
+ # again, while the page waits on a call with a live program and no
5903
+ # error anywhere. The directory is the page's and is made before
5997
5904
  # the program starts, so its absence is the only benign reading;
5998
5905
  # anything else is named and ends the loop through the guard below.
5999
5906
  try:
@@ -6001,12 +5908,9 @@ def _channel_loop():
6001
5908
  if n.endswith(".done") and n[: -len(".done")].isdigit())
6002
5909
  except FileNotFoundError:
6003
5910
  markers = set()
6004
- # THE PAGE'S ACK IS ITS OWN REQUEST FILE BEING GONE: it unlinks
6005
- # `in/<id>.done` only after reading `out/<id>.json`, so anything
6006
- # missing from this listing has been taken and the answer is ours to
6007
- # remove. This is the whole of the page's former four-unlink cleanup,
6008
- # moved to the side that wrote the files (see the header's ownership
6009
- # table).
5911
+ # The page removes in/<id>.done after reading out/<id>.json.
5912
+ # An absent request marker acknowledges the guest-owned reply files,
5913
+ # which the guest can then remove.
6010
5914
  if unacked:
6011
5915
  still_owed = []
6012
5916
  for rid in unacked:
@@ -6044,12 +5948,51 @@ def _channel_loop():
6044
5948
  active = time.monotonic()
6045
5949
 
6046
5950
 
6047
- try:
6048
- _channel_loop()
6049
- except BaseException as _thrown: # noqa: BLE001
6050
- # The one place a channel death can still be named. Without this the
6051
- # program ends 0 and the page reports only that Blender is gone.
6052
- _say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
6053
- "every outstanding call will go unanswered. %s"
6054
- % (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
6055
- raise
5951
+ # THE ADD-ONS THIS BLENDER SHIPS beside Blender's own: each a package the engine writes under `_ADDONS` before
5952
+ # Blender starts (`session-addons.mts`), enabled for the whole session, whatever file it opens, as Preferences >
5953
+ # Add-ons enables an installed one.
5954
+ _ADDONS = os.path.join(ROOT, "addons")
5955
+
5956
+
5957
+ def _enable_addons():
5958
+ import addon_utils
5959
+ sys.path.insert(0, _ADDONS)
5960
+ for name in sorted(os.listdir(_ADDONS)):
5961
+ _say("@@VOLTER-PHASE addon/%s" % name)
5962
+ failed = []
5963
+ addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
5964
+ if failed:
5965
+ _say("@@VOLTER-WARN the %s add-on did not enable: %s: %s" % (name, type(failed[0]).__name__, failed[0]))
5966
+
5967
+
5968
+ def main():
5969
+ """Start the worker channel; importing this module only exposes its RNA readers."""
5970
+ global ENGINES, UNAVAILABLE_ENGINES
5971
+ if _blender_web is None:
5972
+ raise RuntimeError("The session worker needs Blender with the _blender_web native door")
5973
+ # Each step of the session's start says it began (`@@VOLTER-PHASE`): the page makes each a load boundary, so the
5974
+ # wait for the ready line is timed step by step, Blender's and Python's own start being the wait before the first.
5975
+ _say("@@VOLTER-PHASE session")
5976
+ _prepare_directories()
5977
+ bpy.app.handlers.load_post.append(_load_post)
5978
+ bpy.app.handlers.undo_post.append(_history_post)
5979
+ bpy.app.handlers.redo_post.append(_history_post)
5980
+ _say("@@VOLTER-PHASE engines")
5981
+ ENGINES, UNAVAILABLE_ENGINES = _register_engine()
5982
+ _enable_addons()
5983
+ _say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
5984
+ "unavailableEngines": UNAVAILABLE_ENGINES}))
5985
+
5986
+ try:
5987
+ _channel_loop()
5988
+ except BaseException as _thrown: # noqa: BLE001
5989
+ # The one place a channel death can still be named. Without this the
5990
+ # program ends 0 and the page reports only that Blender is gone.
5991
+ _say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
5992
+ "every outstanding call will go unanswered. %s"
5993
+ % (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
5994
+ raise
5995
+
5996
+
5997
+ if __name__ == "__main__":
5998
+ main()