@volter/blender-engine 0.1.148 → 0.1.149

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 +16 -0
  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 +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -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 +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  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 +505 -283
  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 +156 -151
  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,4 @@
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."""
94
2
 
95
3
  import array
96
4
  import base64
@@ -114,7 +22,12 @@ import mathutils
114
22
  # THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
115
23
  # reads the scene out as typed columns in a side arena the worker reads
116
24
  # through `BlenderEngine.readArena` -- see EXPORT_BUFFER_PATH below.
117
- import _blender_web
25
+ try:
26
+ import _blender_web
27
+ except ModuleNotFoundError as error:
28
+ if error.name != "_blender_web":
29
+ raise
30
+ _blender_web = None
118
31
 
119
32
  # The presenter's refusals, spelled in
120
33
  # `packages/blender-engine/browser/three/blender-runtime-frame.ts`. This is the only
@@ -127,30 +40,12 @@ IN = os.path.join(ROOT, "in")
127
40
  OUT = os.path.join(ROOT, "out")
128
41
  ASK = os.path.join(ROOT, "ask")
129
42
  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.
43
+ # Use the caller-supplied EXPORT_BUFFER_PATH when its host reads the arena through the filesystem.
44
+ # Hosts with access to linear memory leave it empty to avoid a second copy. Both doors expose the
45
+ # same native export bytes.
145
46
  EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
146
47
 
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).
48
+ # Retain the last 64 heap marks as [label, heap, mallocInUse, mallocFree, guarded, guardedPeak] in MB so callers can attribute growth to a stage.
154
49
  MEMORY_MARKS = []
155
50
 
156
51
 
@@ -202,8 +97,13 @@ def _prepare_directories():
202
97
 
203
98
  # ---------------------------------------------------------------- file load
204
99
 
100
+ # Each file load, counted: a request during which one came replaced the file, never removed its objects.
101
+ _LOADS = {"count": 0}
102
+
103
+
205
104
  @bpy.app.handlers.persistent
206
105
  def _load_post(_arg):
106
+ _LOADS["count"] += 1
207
107
  # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
208
108
  # into the Main that just went away); this is the session's half.
209
109
  SESSION.forget()
@@ -275,7 +175,7 @@ def _surface_backgrounds(node):
275
175
  """World Surface as `(what the camera sees, what lights the scene)`; a
276
176
  world that is one Background is both and the second is None. A Mix Shader
277
177
  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,
178
+ restrained fill from one sky: input 2 is the camera's,
279
179
  input 1 lights the scene."""
280
180
  kind = node.bl_idname
281
181
  if kind == "ShaderNodeBackground":
@@ -381,13 +281,12 @@ def _describe_world_socket(root_socket, camera_ray):
381
281
  if kind == "ShaderNodeMapRange" and socket == "Result":
382
282
  if node.data_type != "FLOAT":
383
283
  raise NotImplementedError("World Map Range data type %s" % node.data_type)
384
- steps = node.inputs.get("Steps")
385
284
  return {"kind": "map_range", "interpolation": node.interpolation_type,
386
285
  "clamp": bool(node.clamp) and node.interpolation_type not in ("SMOOTHSTEP", "SMOOTHERSTEP"),
387
286
  "value": value(node.inputs["Value"]),
388
287
  "from_min": value(node.inputs["From Min"]), "from_max": value(node.inputs["From Max"]),
389
288
  "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}
289
+ "steps": value(node.inputs["Steps"])}
391
290
  if kind == "ShaderNodeSeparateXYZ" and socket in ("X", "Y", "Z"):
392
291
  return {"kind": "separate", "vector": value(node.inputs["Vector"]), "axis": ("X", "Y", "Z").index(socket)}
393
292
  if kind == "ShaderNodeValToRGB" and socket == "Color":
@@ -406,8 +305,8 @@ def _describe_world_socket(root_socket, camera_ray):
406
305
  if model not in ("SINGLE_SCATTERING", "MULTIPLE_SCATTERING"):
407
306
  raise NotImplementedError(
408
307
  "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:
308
+ vector = node.inputs["Vector"]
309
+ if vector.links:
411
310
  raise NotImplementedError("Linked World Sky Texture Vector")
412
311
  return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
413
312
  "sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
@@ -500,10 +399,10 @@ def _describe_world(world):
500
399
  # Only a wholly constant background can drop the expression: linked
501
400
  # Strength may carry spatial radiance even when Color itself is constant.
502
401
  if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
503
- described.pop("shader", None)
402
+ del described["shader"]
504
403
  if "lighting" in described and not any(
505
404
  (lighting or background).inputs[name].is_linked for name in ("Color", "Strength")):
506
- described["lighting"].pop("shader", None)
405
+ del described["lighting"]["shader"]
507
406
  return described
508
407
 
509
408
 
@@ -549,36 +448,7 @@ def draw_camera(obj):
549
448
  }
550
449
 
551
450
 
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).
451
+ # 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
452
  _TREE_LINKS = {}
583
453
 
584
454
 
@@ -632,7 +502,7 @@ def _gpu_type(socket):
632
502
  if kind in ("VALUE", "INT", "BOOLEAN"):
633
503
  return "float"
634
504
  if kind == "VECTOR":
635
- return "vec%d" % len(socket.default_value) if hasattr(socket, "default_value") else "vec3"
505
+ return "vec%d" % len(socket.default_value)
636
506
  if kind in ("RGBA", "ROTATION"):
637
507
  return "vec4"
638
508
  if kind == "SHADER":
@@ -642,8 +512,6 @@ def _gpu_type(socket):
642
512
 
643
513
  def _socket_value(socket, gpu):
644
514
  """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
515
  value = socket.default_value
648
516
  if gpu == "float":
649
517
  return float(value)
@@ -785,7 +653,10 @@ def _saved_view():
785
653
  Modeling), else the first 3D View any screen holds. `RegionView3D`'s pivot, rotation (view to
786
654
  world, `(w, x, y, z)`), distance and projection (`PERSP`, `ORTHO`, or `CAMERA` for a view
787
655
  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."""
656
+ with the view: a file saved in Material Preview opens in Material Preview. Beside them, the
657
+ Solid options the tab draws as Blender does: the object outline (`show_object_outline`, in
658
+ `object_outline_color`), the specular term (`show_specular_highlight`) and what Solid and
659
+ Wireframe draw behind the model (`background_type`, with `background_color` for VIEWPORT)."""
789
660
  screens = [window.screen for wm in bpy.data.window_managers for window in wm.windows
790
661
  if window.screen is not None]
791
662
  workspace = bpy.data.workspaces.get("Modeling")
@@ -806,6 +677,11 @@ def _saved_view():
806
677
  "shading": space.shading.type,
807
678
  "scene_world": bool(space.shading.use_scene_world),
808
679
  "scene_lights": bool(space.shading.use_scene_lights),
680
+ "show_object_outline": bool(space.shading.show_object_outline),
681
+ "object_outline_color": [float(v) for v in space.shading.object_outline_color],
682
+ "show_specular_highlight": bool(space.shading.show_specular_highlight),
683
+ "background_type": space.shading.background_type,
684
+ "background_color": [float(v) for v in space.shading.background_color],
809
685
  }
810
686
  return None
811
687
 
@@ -862,7 +738,7 @@ def _subject_line(scene, view_layer):
862
738
  bone = rig.data.bones.active
863
739
  if bone is not None and _bone_shown(bone):
864
740
  parts.append(" : " + bone.name)
865
- key = ob.active_shape_key if getattr(ob.data, "shape_keys", None) is not None else None
741
+ key = ob.active_shape_key
866
742
  if key is not None:
867
743
  parts.append(" : " + key.name)
868
744
  if ob.show_only_shape_key:
@@ -876,7 +752,7 @@ def _subject_line(scene, view_layer):
876
752
 
877
753
  def _bone_shown(bone):
878
754
  """`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
879
- collections = getattr(bone, "collections", None)
755
+ collections = bone.collections
880
756
  return not collections or any(c.is_visible for c in collections)
881
757
 
882
758
 
@@ -1009,7 +885,7 @@ def _follow(socket, stack):
1009
885
  return stack, node
1010
886
 
1011
887
 
1012
- def _light_path_side(socket, stack):
888
+ def _light_path_side(socket):
1013
889
  """A Mix Shader factor that separates the camera's surface (a Light Path's
1014
890
  Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
1015
891
  as the input index the camera sees; None for any other factor. The door
@@ -1027,7 +903,7 @@ def _light_path_side(socket, stack):
1027
903
  def _collapse(stack, node):
1028
904
  """Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
1029
905
  while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
1030
- side = _light_path_side(node.inputs[0], stack)
906
+ side = _light_path_side(node.inputs[0])
1031
907
  if side is None:
1032
908
  break
1033
909
  stack, node = _follow(node.inputs[side], stack)
@@ -1140,23 +1016,17 @@ def _mixed_graph(material, stack, surface):
1140
1016
  }
1141
1017
 
1142
1018
 
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
- """
1019
+ def _hash_uint2_float(value):
1020
+ """Jenkins hash_uint2(value, 0), converted to float32 * 2**-32."""
1149
1021
  import struct
1150
1022
 
1151
1023
  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
1024
+ a = (0xdeadbeef + 21 + value) & mask
1157
1025
  b = c = (0xdeadbeef + 21) & mask
1026
+
1158
1027
  def rotate(x, bits):
1159
1028
  return ((x << bits) | (x >> (32 - bits))) & mask
1029
+
1160
1030
  c = ((c ^ b) - rotate(b, 14)) & mask
1161
1031
  a = ((a ^ c) - rotate(c, 11)) & mask
1162
1032
  b = ((b ^ a) - rotate(a, 25)) & mask
@@ -1167,6 +1037,18 @@ def _object_info_random(name):
1167
1037
  return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
1168
1038
 
1169
1039
 
1040
+ def _object_info_random(name):
1041
+ """Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
1042
+ hash_uint2(name_hash, 0). Instances use depsgraph.random_id instead.
1043
+ See draw_handle.hh::random and cycles/blender/object.cpp.
1044
+ """
1045
+ h = 0
1046
+ # Blender's platform flags specify -funsigned-char (native and WASM).
1047
+ for byte in name.encode("utf8"):
1048
+ h = (h * 37 + byte) & 0xffffffff
1049
+ return _hash_uint2_float(h)
1050
+
1051
+
1170
1052
  def _particle_info_random(index=0):
1171
1053
  """Cycles Particle Info Random: hash_uint2(particle.index, 0).
1172
1054
 
@@ -1174,20 +1056,7 @@ def _particle_info_random(index=0):
1174
1056
  without a particle record use the zero-index dummy KernelParticle.
1175
1057
  This is distinct from Object Info's placement random_id.
1176
1058
  """
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]
1059
+ return _hash_uint2_float(index)
1191
1060
 
1192
1061
 
1193
1062
  def _depsgraph_placements(frame, depsgraph):
@@ -1204,7 +1073,7 @@ def _depsgraph_placements(frame, depsgraph):
1204
1073
  row["shadow_visible"] = bool(source.visible_shadow)
1205
1074
  # Frame-local native addresses are comparison tokens, not authoring IDs.
1206
1075
  # Consume them here so neither the presenter nor saved projects retain them.
1207
- geometry = frame.pop("instance_geometry", {})
1076
+ geometry = frame.pop("instance_geometry")
1208
1077
  placements = []
1209
1078
  for instance in depsgraph.object_instances:
1210
1079
  source = instance.object.original
@@ -1217,9 +1086,8 @@ def _depsgraph_placements(frame, depsgraph):
1217
1086
  continue
1218
1087
  if not instance.show_self:
1219
1088
  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.
1089
+ # Omit unsupported placements and name each refusal through `warn`.
1090
+ # The rest of the frame remains drawable.
1223
1091
  if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
1224
1092
  (instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
1225
1093
  warn("Depsgraph instances of %r are not drawn: their geometry differs from the exported source" % source.name)
@@ -1263,7 +1131,7 @@ def material_graphs(scene):
1263
1131
  _TREE_LINKS.clear()
1264
1132
  graphs = {}
1265
1133
  for obj in scene.objects:
1266
- for slot in getattr(obj, "material_slots", ()):
1134
+ for slot in obj.material_slots:
1267
1135
  material = slot.material
1268
1136
  if material is not None and material.name not in graphs:
1269
1137
  graphs[material.name] = material_graph(material)
@@ -1272,9 +1140,7 @@ def material_graphs(scene):
1272
1140
 
1273
1141
  # ------------------------------------------------------------- the overlays
1274
1142
  #
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
1143
+ # INSPECTION OVERLAYS, READ OFF THE ENGINE. Blender's overlay engine
1278
1144
  # (`source/blender/draw/engines/overlay/`) is never run, ported or recorded
1279
1145
  # here: what these answer is the DATA its draw functions read -- a bone's
1280
1146
  # display matrix, a vertex's weight -- and a three.js presenter draws it
@@ -1304,7 +1170,7 @@ def _armature_bones(obj, locked_groups):
1304
1170
  [...].matrix` in the RNA door reads back identical -- and the presenter
1305
1171
  scales the unit shape itself."""
1306
1172
  armature = obj.data
1307
- active = getattr(armature.bones, "active", None)
1173
+ active = armature.bones.active
1308
1174
  active_name = active.name if active is not None else None
1309
1175
  bones = []
1310
1176
  for pchan in obj.pose.bones:
@@ -1319,19 +1185,8 @@ def _armature_bones(obj, locked_groups):
1319
1185
  "hide": bool(bone.hide),
1320
1186
  "length": float(bone.length),
1321
1187
  "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.
1188
+ # PoseBone.select supplies BONE_SELECTED; Bone has no select RNA property.
1189
+ # Selection and the active bone determine get_pchan_color_wire's colours.
1335
1190
  "select": bool(pchan.select),
1336
1191
  "active": bone.name == active_name,
1337
1192
  # BONE_DRAW_LOCKED_WEIGHT, set in weight-paint mode for every bone
@@ -1374,7 +1229,7 @@ def _bone_inherit(bone):
1374
1229
  flags.append("inherit_scale=%s" % bone.inherit_scale)
1375
1230
  if not bone.use_local_location:
1376
1231
  flags.append("use_local_location=False")
1377
- if getattr(bone, "use_relative_parent", False):
1232
+ if bone.use_relative_parent:
1378
1233
  flags.append("use_relative_parent=True")
1379
1234
  return {"inherit": flags} if flags else {}
1380
1235
 
@@ -1458,10 +1313,25 @@ def _locked_weight_groups(view_layer):
1458
1313
  obj = view_layer.objects.active
1459
1314
  if obj is None or "WEIGHT_PAINT" not in obj.mode:
1460
1315
  return frozenset()
1461
- groups = getattr(obj, "vertex_groups", None) or ()
1316
+ groups = obj.vertex_groups
1462
1317
  return frozenset(group.name for group in groups if group.lock_weight)
1463
1318
 
1464
1319
 
1320
+ def _warn_after_armature(view_layer):
1321
+ """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."""
1322
+ for obj in view_layer.objects:
1323
+ if obj.type != "MESH":
1324
+ continue
1325
+ stack = [m for m in obj.modifiers if m.show_viewport]
1326
+ at = next((i for i, m in enumerate(stack) if m.type == "ARMATURE" and m.object is not None), None)
1327
+ if at is None or at == len(stack) - 1:
1328
+ continue
1329
+ after = ", ".join("%s (%s)" % (m.name, m.type) for m in stack[at + 1:])
1330
+ warn("%s: the modifiers after its Armature (%s) are evaluated once at the pose it was sent in; as its bones "
1331
+ "move in Play they are carried by the skin, not run again. Apply them, or move them "
1332
+ "before the Armature, where the pose should change their result." % (obj.name, after))
1333
+
1334
+
1465
1335
  def _armatures(view_layer):
1466
1336
  """Every armature the view layer shows, with its display type and pose.
1467
1337
 
@@ -1496,39 +1366,12 @@ def _armatures(view_layer):
1496
1366
  return armatures
1497
1367
 
1498
1368
 
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."""
1369
+ def _weights(scene, view_layer, known):
1370
+ """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
1371
  obj = view_layer.objects.active
1529
1372
  if obj is None or obj.type != "MESH" or obj.data is None:
1530
1373
  return None
1531
- group = getattr(getattr(obj, "vertex_groups", None), "active", None)
1374
+ group = obj.vertex_groups.active
1532
1375
  if group is None:
1533
1376
  return None
1534
1377
  mesh = obj.data
@@ -1607,18 +1450,39 @@ class Session:
1607
1450
 
1608
1451
  def forget(self):
1609
1452
  self._known.clear()
1453
+ self._action_revisions = {}
1454
+
1455
+ def _kept_action_revisions(self, door):
1456
+ """Keep action revisions stable across evaluation-only updates. An edit advances
1457
+ _ACTION_EDITS and permits the native revision to replace the retained revision. All
1458
+ writing requests, scripts, undo and redo count as edits; a file read resets the table."""
1459
+ held = getattr(self, "_action_revisions", None)
1460
+ if held is None:
1461
+ held = self._action_revisions = {}
1462
+ edits = _ACTION_EDITS["count"]
1463
+ shown = {}
1464
+ for name, revision in door.items():
1465
+ known = held.get(name)
1466
+ if known is None or (known[0] != revision and known[2] != edits):
1467
+ held[name] = (revision, revision, edits)
1468
+ else:
1469
+ held[name] = (revision, known[1], edits)
1470
+ shown[name] = held[name][1]
1471
+ for name in [name for name in held if name not in door]:
1472
+ del held[name]
1473
+ return shown
1610
1474
 
1611
1475
  # -- the export
1612
1476
 
1613
- def _export(self, depsgraph=None):
1477
+ def _export(self, depsgraph):
1614
1478
  """THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
1615
1479
 
1616
1480
  One call evaluates the scene, walks the depsgraph's own update record
1617
1481
  for the revisions, reads Blender's arrays straight into a side arena
1618
1482
  and answers a small JSON frame that names each column by
1619
1483
  `{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.
1484
+ from EXPORT_BUFFER_PATH (`session-frame.mts`). Large arrays cross
1485
+ through that binary arena rather than JSON.
1622
1486
 
1623
1487
  WHAT THE DOOR DOES NOT DESCRIBE is reduced here and merged in: the
1624
1488
  WORLD, whose sky/gradient graph becomes the presenter's world
@@ -1645,18 +1509,11 @@ class Session:
1645
1509
  # that can see the ask can read the file.
1646
1510
  if EXPORT_BUFFER_PATH:
1647
1511
  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))
1512
+ # Pull changed resource columns individually after the frame describes them.
1513
+ options["defer"] = True
1514
+ _blender_web.memory_reset_peak()
1515
+ exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
1516
+ lambda: _asked({"checkpoint": "native-export"}))
1660
1517
  _mark("export:door")
1661
1518
  frame = json.loads(exported)
1662
1519
  del exported
@@ -1664,22 +1521,14 @@ class Session:
1664
1521
  error = frame.get("error")
1665
1522
  if error:
1666
1523
  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.
1524
+ # 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
1525
  self.revision += 1
1678
1526
  frame["revision"] = self.revision
1527
+ frame["actions"] = self._kept_action_revisions(frame["actions"])
1679
1528
  # THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
1680
1529
  # named at the revision the door holds it at (`graph_images`, which is
1681
1530
  # the session's to read and not part of the presenter's frame).
1682
- revisions = frame.pop("graph_images", {})
1531
+ revisions = frame.pop("graph_images")
1683
1532
  for name, graph in graphs.items():
1684
1533
  if name not in frame["materials"]:
1685
1534
  continue
@@ -1694,9 +1543,7 @@ class Session:
1694
1543
  # automatic one is the evaluated bounds the presenter already holds.
1695
1544
  if graphs:
1696
1545
  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.
1546
+ # Index scene objects by name once for the export-row lookup.
1700
1547
  by_name = {obj.name: obj for obj in scene.objects}
1701
1548
  for row in frame["objects"]:
1702
1549
  if not any(m in graphs for m in row["materials"] if m is not None):
@@ -1725,13 +1572,21 @@ class Session:
1725
1572
  # by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
1726
1573
  # `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
1727
1574
  for entry in frame["materials"].values():
1728
- material = bpy.data.materials.get(entry.get("name", ""))
1575
+ material = bpy.data.materials.get(entry["name"])
1729
1576
  if material is not None:
1730
1577
  entry["viewport"] = {
1731
1578
  "color": [float(v) for v in material.diffuse_color],
1732
1579
  "roughness": float(material.roughness),
1733
1580
  "metallic": float(material.metallic),
1734
1581
  }
1582
+ # Settings > Backface Culling > Shadow: a shadow from the faces turned to the light
1583
+ # only. Off (the default), EEVEE casts from both faces of every surface.
1584
+ entry["backface_culling_shadow"] = bool(material.use_backface_culling_shadow)
1585
+ # Settings > Backface Culling > Camera: the surface's back faces are not drawn (the
1586
+ # glTF importer sets it on every single-sided material). Transparency Overlap draws
1587
+ # all faces of a blended surface in one pass (on, the default), or only the nearest (off).
1588
+ entry["backface_culling"] = bool(material.use_backface_culling)
1589
+ entry["transparency_overlap"] = bool(material.use_transparency_overlap)
1735
1590
  graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
1736
1591
  # ONE NAME, TWO OBJECTS: Blender tells a linked object from a local one (or from another
1737
1592
  # file's) by its library as well as its name; the presenter knows them by name alone and
@@ -1763,8 +1618,8 @@ class Session:
1763
1618
  # (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
1764
1619
  # turn with the cursor's rotation in whichever `rotation_mode` it has.
1765
1620
  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.
1621
+ # Inspection overlays are derived from the evaluated scene and view layer.
1622
+ # Weight-array reuse is keyed by their own byte digest.
1768
1623
  view_layer = graph.view_layer
1769
1624
  # THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
1770
1625
  # both from the scene, so they come from here (`_subject_line`).
@@ -1778,36 +1633,52 @@ class Session:
1778
1633
  # WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
1779
1634
  frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
1780
1635
  frame["view"] = _saved_view()
1636
+ # A WORLD background is the World's Viewport Display colour (`World.color`).
1637
+ if frame["view"] is not None:
1638
+ frame["view"]["world_color"] = [float(v) for v in scene.world.color] if scene.world else None
1639
+ # THE SCENE'S COLOUR MANAGEMENT, with the frame it colours: Rendered and Material Preview
1640
+ # are drawn through it (`blender-display-transform.ts`), so it travels with every present
1641
+ # and the tab never draws a frame through settings it has not been sent yet.
1642
+ # HOW THE VIEWPORT ANTI-ALIASES: Solid by Workbench's accumulation (the scene's own
1643
+ # `display.viewport_aa` when Workbench renders it, the Preferences' otherwise) and
1644
+ # Rendered by EEVEE's film (`taa_samples` over `filter_size`).
1645
+ frame["sampling"] = {
1646
+ "engine": scene.render.engine,
1647
+ "viewport_aa": scene.display.viewport_aa,
1648
+ "taa_samples": int(scene.eevee.taa_samples),
1649
+ "filter_size": float(scene.render.filter_size),
1650
+ }
1651
+ view_settings = scene.view_settings
1652
+ frame["display"] = {
1653
+ "view_transform": view_settings.view_transform,
1654
+ "look": view_settings.look or "None",
1655
+ "exposure": float(view_settings.exposure),
1656
+ "gamma": float(view_settings.gamma),
1657
+ "display_device": scene.display_settings.display_device,
1658
+ }
1781
1659
  frame["units"] = {
1782
1660
  "system": scene.unit_settings.system,
1783
1661
  "scale_length": float(scene.unit_settings.scale_length),
1784
1662
  }
1785
1663
  frame["armatures"] = _armatures(view_layer)
1664
+ _warn_after_armature(view_layer)
1786
1665
  # THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
1787
1666
  frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
1788
1667
  "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
1789
1668
  # ACTIONS NOTHING KEEPS: no user and no fake user, so the next save drops them (Blender's own
1790
1669
  # rule). A game that plays one is told before it vanishes.
1791
1670
  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", ()))
1671
+ frame["weights"] = _weights(scene, view_layer, self._known)
1672
+ warnings = list(frame["warnings"])
1794
1673
  warnings.extend(_WARNINGS)
1795
1674
  del _WARNINGS[:]
1796
1675
  frame["warnings"] = warnings
1797
1676
  return frame
1798
1677
 
1799
1678
  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
- """
1679
+ """Present a frame and return the tab’s answer. Rendering blocks on this same channel for its
1680
+ pixels. Reconcile resource identity with the presenter’s held frame; when a document
1681
+ reload has discarded it, resend the required resources in full."""
1811
1682
  try:
1812
1683
  return self._present(capture)
1813
1684
  except RuntimeError as error:
@@ -1816,11 +1687,12 @@ class Session:
1816
1687
  self._known.clear()
1817
1688
  return self._present(capture)
1818
1689
 
1819
- def _present(self, capture=None, depsgraph=None):
1690
+ def _present(self, capture, depsgraph=None):
1820
1691
  known = dict(self._known)
1821
1692
  _mark("present:before")
1822
1693
  frame = self._export(depsgraph)
1823
1694
  _mark("present:exported")
1695
+ self._carry_browser_decoded_images(frame)
1824
1696
  pulled = self._pull(frame, render=depsgraph is not None)
1825
1697
  _mark("present:pulled")
1826
1698
  self._drop_unreachable_textures(frame)
@@ -1840,22 +1712,14 @@ class Session:
1840
1712
  # its value also depends on native undo initialization/checkpoints.
1841
1713
  "dirty": bool(bpy.data.is_dirty),
1842
1714
  }
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.
1715
+ # Only a request that can mutate the model marks the document changed. A present or view rebind alone must not trigger a save.
1852
1716
  for key, mesh in frame["meshes"].items():
1853
1717
  self._known["mesh:" + key] = mesh["revision"]
1854
1718
  for name, image in frame["images"].items():
1855
1719
  self._known["image:" + name] = image["revision"]
1856
1720
  # The weight array follows the meshes' own rule: recorded as sent, so
1857
1721
  # the next present ships a reference instead of the bytes.
1858
- weights = frame.get("weights")
1722
+ weights = frame["weights"]
1859
1723
  if weights is not None:
1860
1724
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
1861
1725
  weights["digest"])
@@ -1880,18 +1744,11 @@ class Session:
1880
1744
  self._reconcile_with_presenter(answer, frame)
1881
1745
  return answer
1882
1746
 
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."""
1747
+ def _pull(self, frame, render):
1748
+ """Pull deferred mesh and image columns one datablock at a time after the worker copies the
1749
+ frame header. Copy each resource before the next pull releases its columns. Remove
1750
+ unreadable images with their warnings before texture reconciliation. Return whether any
1751
+ resource was pulled."""
1895
1752
  meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
1896
1753
  images = [name for name, image in frame["images"].items() if image.get("deferred")]
1897
1754
  if not meshes and not images and not render:
@@ -1909,7 +1766,7 @@ class Session:
1909
1766
  piece = json.loads(door(json.dumps(options, ensure_ascii=False)))
1910
1767
  if piece.get("error"):
1911
1768
  raise RuntimeError("Blender export door: %s" % piece["error"])
1912
- frame["warnings"].extend(piece.get("warnings", []))
1769
+ frame["warnings"].extend(piece["warnings"])
1913
1770
  if piece[kind] is None:
1914
1771
  del frame["images"][key]
1915
1772
  continue
@@ -1920,8 +1777,6 @@ class Session:
1920
1777
  """Render evaluation owns different geometry and visibility than a viewport.
1921
1778
  Keep its native revisions, deferred pulls and presenter separate for the
1922
1779
  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
1780
  _blender_web.begin_render_export(depsgraph)
1926
1781
  try:
1927
1782
  rendering = Session()
@@ -1930,20 +1785,33 @@ class Session:
1930
1785
  finally:
1931
1786
  _blender_web.end_render_export()
1932
1787
 
1788
+ def _carry_browser_decoded_images(self, frame):
1789
+ """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."""
1790
+ carried = frame["images"]
1791
+ names = set()
1792
+ for material in frame["materials"].values():
1793
+ for slot in ("texture", "roughness_texture", "normal_texture"):
1794
+ reference = material.get(slot)
1795
+ if reference is not None:
1796
+ names.add(reference["image"]["name"])
1797
+ graph = material.get("graph")
1798
+ for node in graph["nodes"].values() if graph is not None else ():
1799
+ image = node["props"].get("image")
1800
+ if image is not None:
1801
+ names.add(image["name"])
1802
+ for name in names:
1803
+ if name in carried:
1804
+ continue
1805
+ data = _browser_decoded_bytes(bpy.data.images.get(name))
1806
+ if data is None:
1807
+ continue
1808
+ revision = int.from_bytes(hashlib.sha256(data).digest()[:4], "big") & 0x7fffffff
1809
+ if self._known.get("image:" + name) == revision:
1810
+ continue
1811
+ carried[name] = {"deferred": True, "revision": revision}
1812
+
1933
1813
  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."""
1814
+ """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
1815
  if "@" in key:
1948
1816
  return False
1949
1817
  image = bpy.data.images.get(key)
@@ -1957,11 +1825,8 @@ class Session:
1957
1825
  data = fh.read()
1958
1826
  except (OSError, TypeError, ValueError):
1959
1827
  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:
1828
+ mime = _encoded_mime(data)
1829
+ if mime is None:
1965
1830
  return False
1966
1831
  path = os.path.join(ROOT, "encoded-image.bin")
1967
1832
  with open(path, "wb") as fh:
@@ -1970,7 +1835,9 @@ class Session:
1970
1835
  _asked({"image": key, "piece": {
1971
1836
  "revision": deferred["revision"],
1972
1837
  "mime": mime,
1973
- "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
1838
+ # (a picture this Blender could not load has no colour space name; an 8-bit file's default is sRGB)
1839
+ "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" or (
1840
+ image.colorspace_settings.name == "" and not image.colorspace_settings.is_data) else "data",
1974
1841
  "encodedPath": path,
1975
1842
  }})
1976
1843
  finally:
@@ -1978,137 +1845,43 @@ class Session:
1978
1845
  return True
1979
1846
 
1980
1847
  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():
1848
+ """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."""
1849
+ carried = frame["images"]
1850
+ for material in frame["materials"].values():
2020
1851
  for slot in ("texture", "roughness_texture", "normal_texture"):
2021
1852
  reference = material.get(slot)
2022
- if not isinstance(reference, dict):
1853
+ if reference is None:
2023
1854
  continue
2024
- name = (reference.get("image") or {}).get("name")
2025
- if name is None or name in carried or ("image:" + name) in self._known:
1855
+ name = reference["image"]["name"]
1856
+ if name in carried or ("image:" + name) in self._known:
2026
1857
  continue
2027
1858
  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.
1859
+ # Warn when neither this frame nor the retained record can supply the image.
2055
1860
  warn("material %s: the export could not read the picture %s, so this "
2056
1861
  "frame draws the material without it. The image is in neither "
2057
1862
  "this frame nor the presenter's record, so nothing can be "
2058
1863
  "re-sent to satisfy it; the export door is what knows why "
2059
1864
  "(`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.
1865
+ % (material["name"], name))
1866
+
1867
+ # Mark unreadable node-graph images as missing, retaining the nodes so the presenter draws them magenta.
2068
1868
  graph = material.get("graph")
2069
- for node in ((graph or {}).get("nodes") or {}).values():
2070
- props = node.get("props") or {}
1869
+ for node in graph["nodes"].values() if graph is not None else ():
1870
+ props = node["props"]
2071
1871
  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:
1872
+ if image is None:
1873
+ continue
1874
+ name = image["name"]
1875
+ if name in carried or ("image:" + name) in self._known:
2074
1876
  continue
2075
1877
  props["image"] = None
2076
1878
  props["image_missing"] = True
2077
1879
  warn("material %s: the picture %s is not readable in this program (%s), so it "
2078
1880
  "draws magenta, as Blender draws a missing image."
2079
- % (material.get("name", "?"), name, "a path outside the project, or no pixels"))
1881
+ % (material["name"], name, "a path outside the project, or no pixels"))
2080
1882
 
2081
1883
  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
- """
1884
+ """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
1885
  if not isinstance(answer, dict) or "held" not in answer:
2113
1886
  self.presenter_held = "unreported"
2114
1887
  return
@@ -2122,7 +1895,7 @@ class Session:
2122
1895
  self._known["mesh:" + key] = mesh["revision"]
2123
1896
  for name, image in frame["images"].items():
2124
1897
  self._known["image:" + name] = image["revision"]
2125
- weights = frame.get("weights")
1898
+ weights = frame["weights"]
2126
1899
  if weights is not None and not weights.get("unchanged"):
2127
1900
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
2128
1901
  weights["digest"])
@@ -2151,9 +1924,7 @@ class Session:
2151
1924
  return {"document": relative_path, "opened": False}
2152
1925
  _asked({"checkpoint": "document-open"})
2153
1926
  _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"}))
1927
+ _blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
2157
1928
  self._binding = True
2158
1929
  try:
2159
1930
  bpy.ops.wm.open_mainfile(filepath=self.document)
@@ -2163,32 +1934,39 @@ class Session:
2163
1934
  raise
2164
1935
  finally:
2165
1936
  self._binding = False
2166
- if checkpointed_read:
2167
- _blender_web.set_read_checkpoint(None)
1937
+ _blender_web.set_read_checkpoint(None)
2168
1938
  _mark("open:after")
2169
1939
  _asked({"checkpoint": "document-opened"})
2170
1940
  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.
1941
+ # Remove the staged .blend after Blender loads it to release its WasmFS bytes before
1942
+ # presentation. The worker keeps its host stamp, and the next save rewrites the file.
2177
1943
  os.remove(self.document)
2178
- self.present()
1944
+ # A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
1945
+ # failed the open would leave a saved document unopenable and every MCP call refused.
1946
+ # The model is in Blender, so the session keeps it and says loudly that the view is behind;
1947
+ # the next edit presents again, and can remove whatever the presenter could not take.
1948
+ presented = True
1949
+ try:
1950
+ self.present()
1951
+ except Exception as thrown: # noqa: BLE001
1952
+ presented = False
1953
+ if _another_document_shown(thrown):
1954
+ # A document switched away during opening may refuse this frame. It presents again when it becomes active.
1955
+ _say("%s is open in Blender; the editor is showing another document, so it is presented when "
1956
+ "it is shown again" % relative_path)
1957
+ else:
1958
+ _say("@@VOLTER-ERROR %s is open in Blender, but its first present failed, so the Model "
1959
+ "document does not show it yet: %r" % (relative_path, thrown))
2179
1960
  # The load did not dirty anything: what is in memory IS the file.
2180
1961
  self.save_due = False
2181
1962
  return {"document": relative_path, "opened": True,
2182
1963
  "objects": len(bpy.data.objects),
2183
- "size": size}
1964
+ "size": size, "presented": presented}
2184
1965
 
2185
1966
  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."""
1967
+ """Track file loads that replace the session’s document model. The session’s own document
1968
+ open retains ownership; other loads suspend saving until the model belongs to the document
1969
+ again."""
2192
1970
  if self._binding:
2193
1971
  return
2194
1972
  self._foreign = True
@@ -2196,38 +1974,16 @@ class Session:
2196
1974
 
2197
1975
  def mark_changed(self):
2198
1976
  """A request that can change the model ran: the next present carries
2199
- `saveDue`, and the tab saves after an idle second."""
1977
+ `saveDue`, and the document is dirty until the workbench saves it. (Its actions too: `_kept_action_revisions`.)"""
1978
+ _ACTION_EDITS["count"] += 1
2200
1979
  if self.document is not None:
2201
1980
  self.save_due = True
2202
1981
 
2203
1982
  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
- """
1983
+ """Follow Blender’s open file after execute. Save As and open_mainfile can move the bound
1984
+ document to another project .blend; saving a copy preserves the current binding. Return
1985
+ None when unchanged, a moved answer for a project path, or an outside-project refusal once
1986
+ per file. Never save another file’s in-memory model over the bound document."""
2231
1987
  if self.document is None:
2232
1988
  return None
2233
1989
  if not bpy.data.filepath:
@@ -2271,94 +2027,7 @@ class Session:
2271
2027
  self.document_relative)}
2272
2028
 
2273
2029
  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
- """
2030
+ """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
2031
  self.save_due = False
2363
2032
  if self.document is None:
2364
2033
  return {"saved": False, "reason": "no-document"}
@@ -2366,19 +2035,9 @@ class Session:
2366
2035
  # Not the document's model (`loaded`): writing it would replace the file with another.
2367
2036
  return {"saved": False, "reason": "not-the-document", "document": self.document_relative,
2368
2037
  "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.
2038
+ # Save requests follow mark_changed, which records potentially mutating
2039
+ # operations. bpy.data.is_dirty is diagnostic: native undo checkpoints
2040
+ # do not reliably track this background session's mutations.
2382
2041
  dirty = bool(bpy.data.is_dirty)
2383
2042
  existed = os.path.exists(self.document)
2384
2043
  directory = os.path.dirname(self.document)
@@ -2397,12 +2056,38 @@ class Session:
2397
2056
  "document": self.document_relative,
2398
2057
  "size": os.path.getsize(self.document),
2399
2058
  "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.
2059
+ # Report the dirty state and whether the document already existed before this
2060
+ # write.
2403
2061
  "dirty": dirty, "existed": existed}
2404
2062
 
2405
2063
 
2064
+ def _encoded_mime(data):
2065
+ """The MIME type of a picture file the browser decodes and the tab takes as it is, else None."""
2066
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
2067
+ return "image/png"
2068
+ if data[:3] == b"\xff\xd8\xff":
2069
+ return "image/jpeg"
2070
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
2071
+ return "image/webp"
2072
+ return None
2073
+
2074
+
2075
+ def _browser_decoded_bytes(image):
2076
+ """A file image's bytes when this Blender holds no pixels for it and the file is one the browser decodes
2077
+ (WebP: the editor's Blender is built without it), else None."""
2078
+ if image is None or image.source != "FILE" or image.is_dirty or image.has_data:
2079
+ return None
2080
+ try:
2081
+ if image.packed_file is not None:
2082
+ data = bytes(image.packed_file.data)
2083
+ else:
2084
+ with open(bpy.path.abspath(image.filepath), "rb") as fh:
2085
+ data = fh.read()
2086
+ except (OSError, TypeError, ValueError):
2087
+ return None
2088
+ return data if _encoded_mime(data) == "image/webp" else None
2089
+
2090
+
2406
2091
  SESSION = Session()
2407
2092
 
2408
2093
 
@@ -2421,12 +2106,7 @@ def _drop(path):
2421
2106
  _say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
2422
2107
 
2423
2108
 
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.
2109
+ # Poll briskly for 50 ms after activity, then sleep 2 ms between idle directory reads.
2430
2110
  _BRISK_SECONDS = 0.05
2431
2111
 
2432
2112
 
@@ -2434,10 +2114,15 @@ def _pause(since):
2434
2114
  time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
2435
2115
 
2436
2116
 
2117
+ def _another_document_shown(thrown):
2118
+ """Whether a refusal is the tab's for a document it does not show (`blender-runtime-host.ts`'s
2119
+ `modelDocumentConflict`, "Blender is editing <held>, but the editor is showing <other>"): the editor moved on to
2120
+ another document while this one was opening, which is no fault of the document's."""
2121
+ return "but the editor is showing" in str(thrown)
2122
+
2123
+
2437
2124
  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")."""
2125
+ """Raise the tab's structured error before a refused piece can enter a frame."""
2441
2126
  answer = ask(payload)
2442
2127
  if isinstance(answer, dict) and answer.get("error"):
2443
2128
  raise RuntimeError("The tab refused %s: %s" % (
@@ -2449,11 +2134,9 @@ def ask(payload):
2449
2134
  """Block until the tab answers. The worker's own thread is free while this
2450
2135
  waits; only the Blender pthread is held, which is where the operator is.
2451
2136
 
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."""
2137
+ The guest writes ask/<n>.json and ask/<n>.done, then reads reply/<n>.json
2138
+ after the page writes reply/<n>.done. It removes only its own ask files
2139
+ to acknowledge the reply. Each writer creates and removes .done last."""
2457
2140
  _ASK_SEQUENCE[0] += 1
2458
2141
  name = str(_ASK_SEQUENCE[0])
2459
2142
  with open(os.path.join(ASK, name + ".json"), "w") as fh:
@@ -2474,20 +2157,7 @@ def ask(payload):
2474
2157
  # ---------------------------------------------------------------- the render engine
2475
2158
 
2476
2159
  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."""
2160
+ """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
2161
  import math
2492
2162
  landscape = width >= height
2493
2163
  fit = cam.sensor_fit
@@ -2503,11 +2173,11 @@ def _vertical_extent(cam, width, height):
2503
2173
  return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
2504
2174
 
2505
2175
 
2506
- def _photograph(depsgraph, width, height, linear=False):
2176
+ def _photograph(depsgraph, width, height, linear=False, viewport=False):
2507
2177
  """three.js IS the renderer: the render is a photograph of the scene the
2508
2178
  engine holds, taken through the scene's own camera at `scene.render`'s
2509
2179
  exact resolution. `bpy.ops.render.render` semantics are the contract."""
2510
- scene = depsgraph.scene if hasattr(depsgraph, "scene") else bpy.context.scene
2180
+ scene = depsgraph.scene
2511
2181
  camera = scene.camera
2512
2182
  if camera is None:
2513
2183
  raise RuntimeError("The scene has no camera, so there is nothing to render")
@@ -2524,12 +2194,14 @@ def _photograph(depsgraph, width, height, linear=False):
2524
2194
  # matrix (`blender-runtime-host.ts`).
2525
2195
  up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
2526
2196
  view = scene.view_settings
2527
- look = getattr(view, "look", "None") or "None"
2197
+ look = view.look or "None"
2528
2198
  transform = {
2529
2199
  "Standard": "none",
2530
2200
  "AgX": "agx",
2531
2201
  "Filmic": "filmic",
2532
2202
  "Khronos PBR Neutral": "neutral",
2203
+ "ACES 1.3": "aces13",
2204
+ "ACES 2.0": "aces20",
2533
2205
  }.get(view.view_transform)
2534
2206
  if transform is None:
2535
2207
  raise RuntimeError(
@@ -2558,32 +2230,27 @@ def _photograph(depsgraph, width, height, linear=False):
2558
2230
  # result is what a render leaves, and it holds scene-linear pixels.
2559
2231
  if linear:
2560
2232
  render["linear"] = True
2561
- answer = SESSION.photograph(
2562
- depsgraph, {"position": position, "target": target, "up": up, "render": render}
2563
- )
2233
+ pose = {"position": position, "target": target, "up": up, "render": render}
2234
+ # Capture the already-presented scene through its camera using the ordinary incremental present path.
2235
+ answer = SESSION.present(pose) if viewport else SESSION.photograph(depsgraph, pose)
2564
2236
  if not isinstance(answer, dict) or "base64" not in answer:
2565
2237
  raise RuntimeError("The renderer did not answer with a photograph")
2566
2238
  _assert_photographed_from(answer.get("camera"), position, target, up)
2567
2239
  return base64.b64decode(answer["base64"]), render, answer
2568
2240
 
2569
2241
 
2242
+ def photograph_frame(width, height):
2243
+ """A FILM'S FRAME (`render-movie`): the scene at its current frame, as the viewport presents it,
2244
+ photographed through the scene camera (marker cuts included, as Blender switches the camera on a
2245
+ frame change) at `width` x `height`, with the scene's view transform. Answers the PNG's bytes."""
2246
+ png, _render, _answer = _photograph(bpy.context.evaluated_depsgraph_get(), width, height, viewport=True)
2247
+ return png
2248
+
2249
+
2570
2250
  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
- """
2251
+ """Require the photograph’s camera pose, transformed back into Blender’s frame, to equal the sent
2252
+ pose exactly. The model root uses a signed axis permutation, so the transform and its inverse
2253
+ preserve the values without a floating-point tolerance."""
2587
2254
  sent = {"position": position, "target": target, "up": up}
2588
2255
  if not isinstance(reported, dict):
2589
2256
  raise RuntimeError(
@@ -2650,20 +2317,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
2650
2317
 
2651
2318
 
2652
2319
  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
2320
+ """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."""
2321
+ linear = answer.get("linearBase64")
2667
2322
  if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
2668
2323
  import numpy
2669
2324
 
@@ -2696,8 +2351,7 @@ def _engine_class(identifier):
2696
2351
 
2697
2352
  A Python-registered engine is exposed on `bpy.types` under its CLASS name
2698
2353
  (`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."""
2354
+ always absent. Search the RenderEngine subclass tree by `bl_idname`."""
2701
2355
  pending = list(bpy.types.RenderEngine.__subclasses__())
2702
2356
  while pending:
2703
2357
  cls = pending.pop()
@@ -2708,28 +2362,8 @@ def _engine_class(identifier):
2708
2362
 
2709
2363
 
2710
2364
  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__", ""))
2365
+ """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."""
2366
+ module = sys.modules.get(existing.__module__)
2733
2367
  classes = getattr(module, "classes", None)
2734
2368
  if isinstance(classes, tuple):
2735
2369
  if existing in classes:
@@ -2778,14 +2412,7 @@ def _register_engine():
2778
2412
  bpy.utils.register_class(engine)
2779
2413
  made.append(identifier)
2780
2414
  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.
2415
+ # Report an engine id whose registration failed.
2789
2416
  unavailable.append(identifier)
2790
2417
  # `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
2791
2418
  # again after every file load (the ids have to be retaken), and Blender
@@ -2889,12 +2516,8 @@ class NativeHistory:
2889
2516
  finally:
2890
2517
  self.moving = False
2891
2518
  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():
2519
+ # An undo that retains the depsgraph sends only changed IDs; a replaced depsgraph resets revision caches.
2520
+ if _blender_web.session_undo():
2898
2521
  _blender_web.session_reset()
2899
2522
  SESSION.forget()
2900
2523
  SESSION.present()
@@ -2966,12 +2589,8 @@ def get_object_info(name):
2966
2589
 
2967
2590
 
2968
2591
  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."""
2592
+ """Run one script in its own `{"bpy": bpy}` namespace. Calls can share helpers
2593
+ through `bpy.app.driver_namespace`; local names and closures stay isolated."""
2975
2594
  namespace = {"bpy": bpy}
2976
2595
  captured = io.StringIO()
2977
2596
  error = None
@@ -2984,14 +2603,7 @@ def execute(code):
2984
2603
  return {"executed": error is None, "result": captured.getvalue(), "error": error}
2985
2604
 
2986
2605
 
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.
2606
+ # Refuse script requests for capabilities disabled by bpy.app.build_options, naming the operator namespace and unavailable build feature.
2995
2607
  _ABSENT_CAPABILITIES = (
2996
2608
  ("bullet", "rigidbody", "rigid body physics (Bullet)"),
2997
2609
  ("fluid", "bpy.ops.fluid", "fluid simulation (Mantaflow)"),
@@ -3002,31 +2614,14 @@ _ABSENT_CAPABILITIES = (
3002
2614
  def _absent_capability(code):
3003
2615
  options = bpy.app.build_options
3004
2616
  for option, keyword, capability in _ABSENT_CAPABILITIES:
3005
- if keyword in code and not getattr(options, option, True):
2617
+ if keyword in code and not getattr(options, option):
3006
2618
  return ("CapabilityUnavailable: %s is not compiled into this Blender build, so "
3007
2619
  "`%s` operators are refused rather than reported as done with nothing "
3008
2620
  "created. (bpy.app.build_options.%s is False.)" % (capability, keyword, option))
3009
2621
  return _absent_essentials_assets(code)
3010
2622
 
3011
2623
 
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.
2624
+ # Check the Essentials asset-library directory itself; availability is a payload property rather than a bpy.app.build_options flag.
3030
2625
  _ESSENTIALS_KEYWORDS = ("shade_auto_smooth", "asset_activate", "ESSENTIALS")
3031
2626
 
3032
2627
 
@@ -3049,9 +2644,7 @@ def _absent_essentials_assets(code):
3049
2644
 
3050
2645
  # ------------------------------------------------------------------ the RNA door
3051
2646
  #
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
2647
+ # DATABLOCK PROPERTIES READ THROUGH RNA. Blender's UI layer is never
3055
2648
  # run, ported or recorded here: what this answers is `bl_rna.properties` --
3056
2649
  # Blender's own introspection of its own data -- as typed rows, and what our
3057
2650
  # panels draw is those rows.
@@ -3076,20 +2669,7 @@ _RNA_STRING_LIMIT = 4096
3076
2669
  # struct carries it and it points at the struct's own type.
3077
2670
  _RNA_SKIP = ("rna_type",)
3078
2671
 
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.
2672
+ # Include Python property descriptors from bpy_types alongside RNA properties, excluding recursive variants when the immediate property already offers traversal.
3093
2673
  _PY_PROPERTY_SUFFIX_SKIP = ("_recursive",)
3094
2674
 
3095
2675
  _ID_COLLECTIONS = None
@@ -3105,7 +2685,7 @@ def _id_collections():
3105
2685
  for prop in bpy.data.bl_rna.properties:
3106
2686
  if prop.type != "COLLECTION":
3107
2687
  continue
3108
- fixed = getattr(prop, "fixed_type", None)
2688
+ fixed = prop.fixed_type
3109
2689
  if fixed is not None:
3110
2690
  table.setdefault(fixed.identifier, prop.identifier)
3111
2691
  return table
@@ -3122,7 +2702,7 @@ def _id_collection_for(datablock):
3122
2702
  name = _ID_COLLECTIONS.get(rna.identifier)
3123
2703
  if name:
3124
2704
  return name
3125
- rna = getattr(rna, "base", None)
2705
+ rna = rna.base
3126
2706
  return None
3127
2707
 
3128
2708
 
@@ -3138,7 +2718,7 @@ def _rna_address(struct):
3138
2718
  if collection is None:
3139
2719
  return None
3140
2720
  return 'bpy.data.%s["%s"]' % (collection, bpy.utils.escape_identifier(struct.name))
3141
- owner = getattr(struct, "id_data", None)
2721
+ owner = struct.id_data
3142
2722
  if owner is None:
3143
2723
  return None
3144
2724
  base = _rna_address(owner)
@@ -3164,23 +2744,17 @@ def _rna_resolve(path):
3164
2744
 
3165
2745
 
3166
2746
  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."""
2747
+ """Find the RNA struct declaring a property and cache the result per struct. This groups RNA inheritance independently of Blender’s panel layout."""
3174
2748
  cached = _RNA_GROUPS.get(struct_rna.identifier)
3175
2749
  if cached is None:
3176
2750
  cached = {}
3177
2751
  for prop in struct_rna.properties:
3178
2752
  declaring = struct_rna
3179
- base = getattr(struct_rna, "base", None)
2753
+ base = struct_rna.base
3180
2754
  while base is not None:
3181
2755
  if prop.identifier in base.properties:
3182
2756
  declaring = base
3183
- base = getattr(base, "base", None)
2757
+ base = base.base
3184
2758
  cached[prop.identifier] = (declaring.identifier, declaring.name)
3185
2759
  _RNA_GROUPS[struct_rna.identifier] = cached
3186
2760
  return cached.get(identifier, (struct_rna.identifier, struct_rna.name))
@@ -3220,7 +2794,7 @@ def _rna_value(target, prop):
3220
2794
  if kind == "COLLECTION":
3221
2795
  value = getattr(target, prop.identifier)
3222
2796
  row = {"count": len(value)}
3223
- fixed = getattr(prop, "fixed_type", None)
2797
+ fixed = prop.fixed_type
3224
2798
  # A mesh's `vertices` has no `name`, and asking 100k of them for one is
3225
2799
  # exactly the serialisation this door refuses -- so the question is
3226
2800
  # asked of the TYPE, once, off its own RNA.
@@ -3236,14 +2810,7 @@ def _rna_value(target, prop):
3236
2810
  if kind == "ENUM":
3237
2811
  return sorted(value) if prop.is_enum_flag else value
3238
2812
  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).
2813
+ # Flatten multidimensional bpy_prop_array values in row-major order; RNA array_length counts scalar values rather than rows.
3247
2814
  return [_rna_scalar(item, kind) for item in _rna_flatten(value)]
3248
2815
  if kind == "BOOLEAN":
3249
2816
  return bool(value)
@@ -3290,19 +2857,7 @@ def _rna_row(target, prop):
3290
2857
  # by the presentation, so the door stays the whole surface.
3291
2858
  "hidden": bool(prop.is_hidden),
3292
2859
  }
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.
2860
+ # Mark properties without UI text as undrawn when their name equals the identifier and their description is empty.
3306
2861
  if prop.name == prop.identifier and not prop.description:
3307
2862
  row["undrawn"] = True
3308
2863
  if prop.type in ("INT", "FLOAT"):
@@ -3314,20 +2869,20 @@ def _rna_row(target, prop):
3314
2869
  if prop.type == "FLOAT":
3315
2870
  row["precision"] = int(prop.precision)
3316
2871
  if prop.type == "STRING":
3317
- row["lengthMax"] = int(getattr(prop, "length_max", 0) or 0)
2872
+ row["lengthMax"] = int(prop.length_max)
3318
2873
  if prop.type == "ENUM":
3319
2874
  row["isFlag"] = bool(prop.is_enum_flag)
3320
2875
  try:
3321
2876
  items = prop.enum_items
3322
2877
  except Exception: # noqa: BLE001
3323
- items = getattr(prop, "enum_items_static", ())
2878
+ items = prop.enum_items_static
3324
2879
  row["items"] = [
3325
2880
  {"identifier": item.identifier, "name": item.name,
3326
2881
  "description": item.description, "icon": item.icon}
3327
2882
  for item in items
3328
2883
  ]
3329
2884
  if prop.type in ("POINTER", "COLLECTION"):
3330
- fixed = getattr(prop, "fixed_type", None)
2885
+ fixed = prop.fixed_type
3331
2886
  if fixed is not None:
3332
2887
  row["itemType"] = fixed.identifier
3333
2888
  if length > _RNA_ARRAY_LIMIT:
@@ -3352,8 +2907,7 @@ def _py_properties(cls):
3352
2907
  return cached
3353
2908
  found = []
3354
2909
  seen = set()
3355
- rna = getattr(cls, "bl_rna", None)
3356
- declared = set(rna.properties.keys()) if rna is not None else set()
2910
+ declared = set(cls.bl_rna.properties.keys())
3357
2911
  for base in cls.__mro__:
3358
2912
  for name, member in vars(base).items():
3359
2913
  if name.startswith("_") or name in seen or name in declared:
@@ -3450,16 +3004,7 @@ def _py_row(target, name, descriptor, owner):
3450
3004
 
3451
3005
 
3452
3006
  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."""
3007
+ """Address collection members with path_from_id when available, otherwise append their name or index to the collection’s own path."""
3463
3008
  address = _rna_address(item)
3464
3009
  name = getattr(item, "name", None)
3465
3010
  if address is None:
@@ -3576,11 +3121,11 @@ def _rna_active_object(name):
3576
3121
  return bpy.context.view_layer.objects.active
3577
3122
 
3578
3123
 
3579
- def _named(value, path=None):
3124
+ def _named(value):
3580
3125
  if value is None:
3581
3126
  return None
3582
- return {"name": getattr(value, "name", None),
3583
- "path": path if path is not None else _rna_address(value),
3127
+ return {"name": value.name,
3128
+ "path": _rna_address(value),
3584
3129
  "type": value.bl_rna.identifier}
3585
3130
 
3586
3131
 
@@ -3590,14 +3135,7 @@ def _tab(identifier, label, icon, paths):
3590
3135
 
3591
3136
 
3592
3137
  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
-
3138
+ """Address a LayerCollection by its position in the view-layer tree because it has no path_from_id."""
3601
3139
  def walk(node, path):
3602
3140
  if node == target:
3603
3141
  return path
@@ -3613,12 +3151,14 @@ def _layer_collection_path(view_layer_path, root, target):
3613
3151
 
3614
3152
  def _texture_slot_users(users, owner, slots, label, name=None):
3615
3153
  """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)
3154
+ for slot in slots:
3155
+ if slot is None:
3156
+ continue
3157
+ texture = slot.texture
3618
3158
  if texture is None:
3619
3159
  continue
3620
3160
  users.append({"label": label,
3621
- "name": name if name else getattr(owner, "name", None),
3161
+ "name": name if name else owner.name,
3622
3162
  "path": _rna_address(slot),
3623
3163
  "property": "texture",
3624
3164
  "texture": _named(texture)})
@@ -3629,11 +3169,11 @@ def _texture_node_users(users, tree, label):
3629
3169
  `texture` pointer is filled."""
3630
3170
  if tree is None:
3631
3171
  return
3632
- for node in getattr(tree, "nodes", ()):
3172
+ for node in tree.nodes:
3633
3173
  prop = node.bl_rna.properties.get("texture")
3634
3174
  if prop is None or prop.type != "POINTER":
3635
3175
  continue
3636
- texture = getattr(node, "texture", None)
3176
+ texture = node.texture
3637
3177
  if texture is None:
3638
3178
  continue
3639
3179
  users.append({"label": label, "name": node.name,
@@ -3642,58 +3182,41 @@ def _texture_node_users(users, tree, label):
3642
3182
 
3643
3183
 
3644
3184
  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."""
3185
+ """Report texture users in Blender buttons_texture_users_from_context order, with the active user index selecting the displayed texture."""
3662
3186
  users = []
3663
- _texture_node_users(users, getattr(scene, "compositing_node_group", None), "Compositor")
3187
+ _texture_node_users(users, scene.compositing_node_group, "Compositor")
3664
3188
 
3665
- freestyle = getattr(view_layer, "freestyle_settings", None)
3666
- linesets = getattr(freestyle, "linesets", None)
3667
- linestyle = getattr(getattr(linesets, "active", None), "linestyle", None)
3189
+ lineset = view_layer.freestyle_settings.linesets.active
3190
+ linestyle = lineset.linestyle if lineset is not None else None
3668
3191
  if linestyle is not None:
3669
- _texture_slot_users(users, linestyle, getattr(linestyle, "texture_slots", None),
3192
+ _texture_slot_users(users, linestyle, linestyle.texture_slots,
3670
3193
  "Line Style")
3671
- _texture_node_users(users, getattr(linestyle, "node_tree", None), "Line Style")
3194
+ _texture_node_users(users, linestyle.node_tree, "Line Style")
3672
3195
 
3673
3196
  if obj is not None:
3674
3197
  for modifier in obj.modifiers:
3675
3198
  for prop in modifier.bl_rna.properties:
3676
3199
  if prop.type != "POINTER":
3677
3200
  continue
3678
- fixed = getattr(prop, "fixed_type", None)
3201
+ fixed = prop.fixed_type
3679
3202
  if fixed is None or fixed.identifier != "Texture":
3680
3203
  continue
3681
- texture = getattr(modifier, prop.identifier, None)
3204
+ texture = getattr(modifier, prop.identifier)
3682
3205
  if texture is None:
3683
3206
  continue
3684
3207
  users.append({"label": "Modifiers", "name": modifier.name,
3685
3208
  "path": _rna_address(modifier),
3686
3209
  "property": prop.identifier,
3687
3210
  "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)
3211
+ systems = obj.particle_systems
3212
+ active_system = systems.active
3213
+ settings = active_system.settings if active_system is not None else None
3691
3214
  if settings is not None:
3692
- _texture_slot_users(users, settings, getattr(settings, "texture_slots", None),
3215
+ _texture_slot_users(users, settings, settings.texture_slots,
3693
3216
  "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:
3217
+ field = obj.field
3218
+ if field is not None and field.type == "TEXTURE" \
3219
+ and field.texture is not None:
3697
3220
  users.append({"label": "Fields", "name": "Texture Field",
3698
3221
  "path": _rna_address(field), "property": "texture",
3699
3222
  "texture": _named(field.texture)})
@@ -3701,17 +3224,17 @@ def _texture_users(scene, view_layer, obj):
3701
3224
  # `BKE_paint_brush(BKE_paint_get_active_from_context(C))`. Headless has no
3702
3225
  # paint mode, so this is whichever tool settings hold a brush at all --
3703
3226
  # asked of every `Paint` the tool settings carry rather than of a mode.
3704
- tools = getattr(bpy.context, "tool_settings", None)
3227
+ tools = bpy.context.tool_settings
3705
3228
  for member in ("image_paint", "sculpt", "vertex_paint", "weight_paint",
3706
3229
  "gpencil_paint", "curves_sculpt"):
3707
- paint = getattr(tools, member, None)
3708
- brush = getattr(paint, "brush", None)
3230
+ paint = getattr(tools, member) if tools is not None else None
3231
+ brush = paint.brush if paint is not None else None
3709
3232
  if brush is None:
3710
3233
  continue
3711
3234
  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:
3235
+ holder = getattr(brush, slot)
3236
+ texture = getattr(brush, prop)
3237
+ if texture is None:
3715
3238
  continue
3716
3239
  users.append({"label": "Brush", "name": brush.name,
3717
3240
  "path": _rna_address(holder), "property": "texture",
@@ -3740,7 +3263,7 @@ def rna_context(object_name=None, collection_path=None):
3740
3263
  # then shows THAT collection. Activating it here would be a mutation the
3741
3264
  # document saves, so the row's own address arrives as a parameter and stands
3742
3265
  # in for `view_layer.active_layer_collection` for this read alone.
3743
- active_layer_collection = getattr(view_layer, "active_layer_collection", None)
3266
+ active_layer_collection = view_layer.active_layer_collection
3744
3267
  if collection_path:
3745
3268
  named = _rna_resolve(collection_path)
3746
3269
  if not isinstance(named, bpy.types.LayerCollection):
@@ -3749,7 +3272,7 @@ def rna_context(object_name=None, collection_path=None):
3749
3272
  "LayerCollection (`buttons_context_path_collection`)."
3750
3273
  % (collection_path, named.bl_rna.identifier))
3751
3274
  active_layer_collection = named
3752
- collection = getattr(active_layer_collection, "collection", None)
3275
+ collection = active_layer_collection.collection if active_layer_collection is not None else None
3753
3276
  if collection is not None and collection == scene.collection:
3754
3277
  collection = None
3755
3278
 
@@ -3774,7 +3297,7 @@ def rna_context(object_name=None, collection_path=None):
3774
3297
  # object whose data supports materials even when the slot is empty --
3775
3298
  # `OB_TYPE_SUPPORT_MATERIAL` is asked here as "does this data hold a
3776
3299
  # `materials` collection", which is the same set, read off RNA.
3777
- slot_index = max(int(getattr(obj, "active_material_index", 0)), 0)
3300
+ slot_index = max(int(obj.active_material_index), 0)
3778
3301
  slots = obj.material_slots
3779
3302
  slot = slots[slot_index] if slot_index < len(slots) else None
3780
3303
  held = slot.material if slot is not None else None
@@ -3790,16 +3313,15 @@ def rna_context(object_name=None, collection_path=None):
3790
3313
  particle_system = None
3791
3314
  if obj is not None:
3792
3315
  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:
3316
+ modifier = _named(obj.modifiers.active)
3317
+ groups = obj.vertex_groups
3318
+ if groups.active is not None:
3796
3319
  vertex_group = {"index": int(groups.active_index)}
3797
3320
  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))
3321
+ shape_key = _named(obj.active_shape_key)
3322
+ constraint = _named(obj.constraints.active)
3323
+ systems = obj.particle_systems
3324
+ particle_system = _named(systems.active)
3803
3325
 
3804
3326
  # ED_buttons_tabs_list (`space_buttons.cc:218-252`), in its own order. The
3805
3327
  # TOOL tab is Blender's active-tool settings and has no reader here; SHADERFX
@@ -3873,18 +3395,14 @@ def rna_context(object_name=None, collection_path=None):
3873
3395
  # it is what nearly every panel on this tab draws -- `part` in
3874
3396
  # every one of their draw functions. The system itself carries
3875
3397
  # 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))))
3398
+ systems_active = systems.active
3399
+ paths.append(("Settings", _rna_address(systems_active.settings)))
3400
+ cloth = systems_active.cloth
3401
+ if cloth is not None:
3402
+ # PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
3403
+ # and `.collision_settings` (`:390-404`, `:463-470`).
3404
+ paths.append(("Hair Dynamics", _rna_address(cloth.settings)))
3405
+ paths.append(("Hair Collisions", _rna_address(cloth.collision_settings)))
3888
3406
  tabs.append(_tab("particle", "Particles", "properties-particles", paths))
3889
3407
  # `buttons_context_path_object` answers Physics and Constraints both.
3890
3408
  #
@@ -3903,34 +3421,28 @@ def rna_context(object_name=None, collection_path=None):
3903
3421
  ("Soft Body", "soft_body"),
3904
3422
  ("Collision", "collision"),
3905
3423
  ("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.
3424
+ if getattr(obj, field) is not None]
3425
+ for kind, members in (("CLOTH",
3426
+ # Include the modifier itself beside its settings because the point-cache panel addresses md.point_cache.
3915
3427
  (("Cloth Modifier", None),
3916
3428
  ("Cloth", "settings"),
3917
3429
  ("Cloth Collisions", "collision_settings"))),
3918
- ("Soft Body", "SOFT_BODY",
3430
+ ("SOFT_BODY",
3919
3431
  (("Soft Body Modifier", None),)),
3920
- ("Fluid", "FLUID",
3432
+ ("FLUID",
3921
3433
  (("Fluid", None),
3922
3434
  ("Fluid Domain", "domain_settings"),
3923
3435
  ("Fluid Flow", "flow_settings"),
3924
3436
  ("Fluid Effector", "effector_settings"))),
3925
- ("Dynamic Paint", "DYNAMIC_PAINT",
3437
+ ("DYNAMIC_PAINT",
3926
3438
  (("Dynamic Paint", None),
3927
3439
  ("Dynamic Paint Canvas", "canvas_settings"),
3928
3440
  ("Dynamic Paint Brush", "brush_settings")))):
3929
- modifier = next((item for item in obj.modifiers if item.type == kind), None)
3930
- if modifier is None:
3441
+ physics_modifier = next((item for item in obj.modifiers if item.type == kind), None)
3442
+ if physics_modifier is None:
3931
3443
  continue
3932
3444
  for name, member in members:
3933
- target = modifier if member is None else getattr(modifier, member, None)
3445
+ target = physics_modifier if member is None else getattr(physics_modifier, member)
3934
3446
  physics.append((name, _rna_address(target)))
3935
3447
  tabs.append(_tab("physics", "Physics", "properties-physics",
3936
3448
  physics or [("Object", object_path)]))
@@ -3945,7 +3457,7 @@ def rna_context(object_name=None, collection_path=None):
3945
3457
  paths = [("Object Data", data_path)]
3946
3458
  if obj.type == "ARMATURE":
3947
3459
  paths.append(("Bones", "%s.bones" % data_path))
3948
- if getattr(obj, "vertex_groups", None) is not None and len(obj.vertex_groups):
3460
+ if len(obj.vertex_groups):
3949
3461
  paths.append(("Vertex Groups", "%s.vertex_groups" % object_path))
3950
3462
  keys = getattr(obj.data, "shape_keys", None)
3951
3463
  if keys is not None:
@@ -3962,11 +3474,11 @@ def rna_context(object_name=None, collection_path=None):
3962
3474
  [("Constraints", "%s.constraints" % pose_bone["path"])]))
3963
3475
  if material is not None:
3964
3476
  paths = [("Material Slots", "%s.material_slots" % object_path)]
3965
- if material.get("path"):
3477
+ if material["path"]:
3966
3478
  paths.insert(0, ("Material", material["path"]))
3967
3479
  tabs.append(_tab("material", "Material", "properties-material", paths))
3968
3480
  # 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,
3481
+ # texture USER, mirrored in `_texture_users` -- in the C's own order,
3970
3482
  # with user 0 the active one (`ct->index`). The tab stands when that search
3971
3483
  # finds a user, or (as `buttons_context_path_texture` does through the
3972
3484
  # pinned-ID branch) when the file holds textures at all.
@@ -3975,7 +3487,7 @@ def rna_context(object_name=None, collection_path=None):
3975
3487
  if texture_users or len(bpy.data.textures):
3976
3488
  paths = []
3977
3489
  if active_texture_user is not None:
3978
- paths.append(("Texture", (active_texture_user["texture"] or {}).get("path")))
3490
+ paths.append(("Texture", active_texture_user["texture"]["path"]))
3979
3491
  paths.append(("User", active_texture_user["path"]))
3980
3492
  paths.append(("Textures", "bpy.data.textures"))
3981
3493
  tabs.append(_tab("texture", "Texture", "properties-texture", paths))
@@ -4014,47 +3526,11 @@ def rna_context(object_name=None, collection_path=None):
4014
3526
 
4015
3527
 
4016
3528
 
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.
3529
+ # Build the outliner’s View Layer tree from Blender’s tree_display_view_layer and per-datablock
3530
+ # expansion rules. Rows use native RNA addresses; duplicate appearances get distinct ordinal ids
3531
+ # while preserving their shared path. Collections begin expanded and object rows collapsed. Page
3532
+ # long child lists and build expansions only for retained objects. _OutlinerScope counts preceding
3533
+ # claims across the full tree so row identities remain stable between pages.
4058
3534
 
4059
3535
  _OUTLINER_PAGE = 64
4060
3536
 
@@ -4120,11 +3596,9 @@ def _enum_icon(target, identifier):
4120
3596
  (`outliner_draw.cc:2773-2785`); `rna_enum_constraint_type_items` carries
4121
3597
  that function's own constraint switch. Reading the engine's enum is reading
4122
3598
  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
3599
+ prop = target.bl_rna.properties[identifier]
3600
+ item = prop.enum_items.get(getattr(target, identifier))
3601
+ return (item.icon or None) if item is not None else None
4128
3602
 
4129
3603
 
4130
3604
  def _outliner_object_icon(obj):
@@ -4134,30 +3608,26 @@ def _outliner_object_icon(obj):
4134
3608
  if obj.type == "EMPTY":
4135
3609
  if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
4136
3610
  return "OUTLINER_OB_GROUP_INSTANCE"
4137
- if getattr(obj, "empty_display_type", None) == "IMAGE":
3611
+ if obj.empty_display_type == "IMAGE":
4138
3612
  return "OUTLINER_OB_IMAGE"
4139
- field = getattr(obj, "field", None)
4140
- if field is not None and getattr(field, "type", "NONE") != "NONE":
3613
+ field = obj.field
3614
+ if field is not None and field.type != "NONE":
4141
3615
  return "OUTLINER_OB_FORCE_FIELD"
4142
3616
  return _enum_icon(obj, "type") or "OBJECT_DATA"
4143
3617
 
4144
3618
 
4145
3619
  def _outliner_id_icon(datablock):
4146
3620
  """`tree_element_get_icon_from_id` for anything that is not an object."""
4147
- if datablock is None:
4148
- return "DOT"
4149
3621
  if isinstance(datablock, bpy.types.Light):
4150
- return _OUTLINER_LIGHT_ICONS.get(getattr(datablock, "type", None),
4151
- "OUTLINER_DATA_LIGHT")
3622
+ return _OUTLINER_LIGHT_ICONS[datablock.type]
4152
3623
  if isinstance(datablock, bpy.types.LightProbe):
4153
- return _OUTLINER_PROBE_ICONS.get(getattr(datablock, "type", None),
4154
- "LIGHTPROBE_SPHERE")
3624
+ return _OUTLINER_PROBE_ICONS[datablock.type]
4155
3625
  rna = datablock.bl_rna
4156
3626
  while rna is not None:
4157
3627
  icon = _OUTLINER_ID_ICONS.get(rna.identifier)
4158
3628
  if icon:
4159
3629
  return icon
4160
- rna = getattr(rna, "base", None)
3630
+ rna = rna.base
4161
3631
  return "DOT"
4162
3632
 
4163
3633
 
@@ -4250,7 +3720,7 @@ def _outliner_modifier(md, obj, object_path, seen):
4250
3720
  linked = getattr(md, member, None)
4251
3721
  if linked is None:
4252
3722
  continue
4253
- linked_path = _rna_address(linked) or "%s.%s" % (path, member)
3723
+ linked_path = _rna_address(linked)
4254
3724
  children.append(_outliner_row(linked_path, linked.name, kind, icon, seen))
4255
3725
  break
4256
3726
  system = getattr(md, "particle_system", None)
@@ -4291,8 +3761,8 @@ def _outliner_data(obj, seen):
4291
3761
  children.extend(_outliner_bone(bone, path, seen) for bone in page)
4292
3762
  if more:
4293
3763
  row["more"] = more
4294
- collections = getattr(data, "collections", None)
4295
- if collections is not None and len(collections):
3764
+ collections = data.collections
3765
+ if collections:
4296
3766
  base = _outliner_row("%s.collections" % path, "Bone Collections",
4297
3767
  "TSE_BONE_COLLECTION_BASE", "GROUP_BONE", seen)
4298
3768
  page, more = _outliner_page(collections)
@@ -4369,10 +3839,9 @@ def _outliner_object(obj, view_layer, seen, chosen, active):
4369
3839
  "MODIFIER_DATA", seen, object=obj.name)
4370
3840
  base["children"] = [_outliner_modifier(md, obj, path, seen) for md in obj.modifiers]
4371
3841
  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".
3842
+ groups = obj.vertex_groups
3843
+ if groups:
3844
+ # `expand_vertex_groups`: objects with vertex groups expose their collection here.
4376
3845
  base_path = "%s.vertex_groups" % path
4377
3846
  base = _outliner_row(base_path, "Vertex Groups", "TSE_DEFGROUP_BASE", "GROUP_VERTEX",
4378
3847
  seen, object=obj.name)
@@ -4429,13 +3898,12 @@ class _OutlinerScope:
4429
3898
  self.local = {}
4430
3899
 
4431
3900
  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
3901
+ for prefix, address, before in self.scopes:
3902
+ if path == address or path.startswith(prefix):
3903
+ return before
4436
3904
  return None
4437
3905
 
4438
- def get(self, path, default=0):
3906
+ def get(self, path, default):
4439
3907
  if path in self.local:
4440
3908
  return self.local[path]
4441
3909
  before = self._before(path)
@@ -4449,30 +3917,16 @@ class _OutlinerScope:
4449
3917
 
4450
3918
  # ---- the node editor's tree ------------------------------------------------
4451
3919
  #
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.
3920
+ # Read a whole node tree in one response. Fields are bl_rna properties of
3921
+ # Node, NodeSocket and NodeLink, matching rna_nodetree.cc.
3922
+ # The node view inspects and navigates; it refuses editing gestures in its
3923
+ # status line. RNA property writes remain on rna_set.
4469
3924
  _NODE_SOCKET_VALUE_MAX = 4
4470
3925
 
4471
3926
 
4472
3927
  def _node_socket_value(socket):
4473
3928
  """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`)."""
3929
+ array property; None when the socket has no default or its value is unsupported."""
4476
3930
  if not hasattr(socket, "default_value"):
4477
3931
  return None
4478
3932
  value = socket.default_value
@@ -4509,18 +3963,17 @@ def _node_socket(socket, index):
4509
3963
  "shape": socket.display_shape,
4510
3964
  "enabled": bool(socket.enabled),
4511
3965
  "hide": bool(socket.hide),
4512
- "hideValue": bool(getattr(socket, "hide_value", False)),
3966
+ "hideValue": bool(socket.hide_value),
4513
3967
  "linked": bool(socket.is_linked),
4514
- "multiInput": bool(getattr(socket, "is_multi_input", False)),
3968
+ "multiInput": bool(socket.is_multi_input),
4515
3969
  "index": index,
4516
3970
  "value": _node_socket_value(socket),
4517
3971
  # THE SLIDER'S RANGE. A scalar socket whose `default_value` property
4518
3972
  # carries a BOUNDED soft range is drawn by Blender as a NUMBER SLIDER
4519
3973
  # -- `ui_but_is_slider`'s `UI_BTYPE_NUM_SLIDER`, whose back is filled to
4520
3974
  # 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.
3975
+ # `interface_widgets.cc`). The soft range determines the filled
3976
+ # proportion; an unbounded field has no slider fill.
4524
3977
  #
4525
3978
  # `soft_min`/`soft_max` are the UI range and `min`/`max` the hard one;
4526
3979
  # Blender sliders against the SOFT pair. An unbounded property (a
@@ -4533,11 +3986,7 @@ def _node_socket(socket, index):
4533
3986
  def _node_socket_range(socket):
4534
3987
  """The soft range of a scalar socket drawn as a SLIDER, when it is one.
4535
3988
 
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
3989
+ The socket subtype determines whether Blender draws a slider: `PROP_FACTOR` is what
4541
3990
  `uiItemR`/`node_socket_button_default` pass `UI_ITEM_R_SLIDER` for, so a
4542
3991
  factor draws as `UI_BTYPE_NUM_SLIDER` with a filled back and every other
4543
3992
  number draws as a plain `UI_BTYPE_NUM`.
@@ -4550,61 +3999,28 @@ def _node_socket_range(socket):
4550
3999
  prop = socket.bl_rna.properties["default_value"]
4551
4000
  except (AttributeError, KeyError):
4552
4001
  return {}
4553
- if getattr(prop, "type", None) not in ("FLOAT", "INT"):
4002
+ if prop.type not in ("FLOAT", "INT"):
4554
4003
  return {}
4555
- if getattr(prop, "array_length", 0):
4556
- return {}
4557
- if getattr(prop, "subtype", None) != "FACTOR":
4004
+ if prop.array_length:
4558
4005
  return {}
4559
- low = getattr(prop, "soft_min", None)
4560
- high = getattr(prop, "soft_max", None)
4561
- if low is None or high is None:
4006
+ if prop.subtype != "FACTOR":
4562
4007
  return {}
4008
+ low = prop.soft_min
4009
+ high = prop.soft_max
4563
4010
  if not (math.isfinite(low) and math.isfinite(high)) or high <= low:
4564
4011
  return {}
4565
4012
  return {"softMin": float(low), "softMax": float(high)}
4566
4013
 
4567
4014
 
4568
4015
  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."""
4016
+ """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
4017
  return {
4602
4018
  "name": node.name,
4603
4019
  "idname": node.bl_idname,
4604
4020
  "type": node.type,
4605
4021
  "typeLabel": node.bl_label,
4606
4022
  "label": node.label or None,
4607
- "colorTag": getattr(node, "color_tag", "NONE"),
4023
+ "colorTag": node.color_tag,
4608
4024
  # `Node.location` is the tree-space corner Blender lays out from
4609
4025
  # (`node_update_basis`: `dy = loc.y`, `node_draw.cc:1297`); `width` is
4610
4026
  # the value `NODE_WIDTH(node)` scales (`node_intern.hh:337`).
@@ -4620,11 +4036,11 @@ def _node_row(node):
4620
4036
  # gives an OUTPUT-class node the output colour only when it is the
4621
4037
  # active output (`node_draw.cc:1395-1401`).
4622
4038
  "activeOutput": bool(getattr(node, "is_active_output", False)),
4623
- "panelCount": len(getattr(node, "panel_states", ())),
4039
+ "panelCount": len(node.panel_states),
4624
4040
  "panels": [{"identifier": int(state.identifier),
4625
4041
  "collapsed": bool(state.is_collapsed)}
4626
- for state in getattr(node, "panel_states", ())],
4627
- "showOptions": bool(getattr(node, "show_options", True)),
4042
+ for state in node.panel_states],
4043
+ "showOptions": bool(node.show_options),
4628
4044
  "inputs": [_node_socket(socket, index) for index, socket in enumerate(node.inputs)],
4629
4045
  "outputs": [_node_socket(socket, index) for index, socket in enumerate(node.outputs)],
4630
4046
  }
@@ -4644,7 +4060,7 @@ def _node_tree_of(path, material):
4644
4060
  raise ValueError("The engine holds no material named %r" % (material,))
4645
4061
  else:
4646
4062
  obj = bpy.context.view_layer.objects.active
4647
- mat = getattr(obj, "active_material", None) if obj is not None else None
4063
+ mat = obj.active_material if obj is not None else None
4648
4064
  if mat is None:
4649
4065
  return None, None, None
4650
4066
  if not mat.use_nodes:
@@ -4707,32 +4123,7 @@ def _uv_object_of(object_name):
4707
4123
 
4708
4124
 
4709
4125
  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
- """
4126
+ """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
4127
  obj = _uv_object_of(object_name)
4737
4128
  if obj is None:
4738
4129
  return {"object": None, "mesh": None, "layers": [], "active": None,
@@ -4811,7 +4202,7 @@ def _uv_backdrop_image(obj):
4811
4202
  (`ED_space_image_auto_set`, `space_image.cc:60-101`). There is no
4812
4203
  `SpaceImage` here, so the second half is the whole rule: the active
4813
4204
  material's node tree's active `TEX_IMAGE`."""
4814
- mat = getattr(obj, "active_material", None)
4205
+ mat = obj.active_material
4815
4206
  if mat is None or not mat.use_nodes or mat.node_tree is None:
4816
4207
  return None
4817
4208
  nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image is not None]
@@ -4824,25 +4215,7 @@ def _uv_backdrop_image(obj):
4824
4215
  "source": image.source, "hasData": bool(image.has_data)}
4825
4216
 
4826
4217
 
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.
4218
+ # 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
4219
 
4847
4220
 
4848
4221
  def _rig_armature_of(obj):
@@ -4854,170 +4227,18 @@ def _rig_armature_of(obj):
4854
4227
  return obj.find_armature()
4855
4228
 
4856
4229
 
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
4230
  def _matrix_rows(matrix):
4882
4231
  return [[float(v) for v in row] for row in matrix]
4883
4232
 
4884
4233
 
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
4234
  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"
4235
+ """Return the first matching channelbag's F-curves, or the first bag's when no slot is supplied; no match yields an empty list."""
4236
+ for layer in action.layers:
4237
+ for strip in layer.strips:
4238
+ for bag in strip.channelbags:
4239
+ if slot_handle is None or int(bag.slot_handle) == int(slot_handle):
4240
+ return list(bag.fcurves)
4241
+ return []
5021
4242
 
5022
4243
 
5023
4244
  _BONE_PATH = re.compile(r'^pose\.bones\["(.+)"\]\.(location|rotation_quaternion|'
@@ -5040,7 +4261,7 @@ def _key_columns(curves):
5040
4261
  # EXTREME wins over KEYFRAME the way Blender's own merge does:
5041
4262
  # `nupdate_ak_bezt` keeps the "most significant" type
5042
4263
  # (`keyframes_keylist.cc`, `KEYFRAME_STATE` ordering).
5043
- if _KEY_TYPE_RANK.get(key.type, 0) > _KEY_TYPE_RANK.get(record["type"], 0):
4264
+ if _KEY_TYPE_RANK[key.type] > _KEY_TYPE_RANK[record["type"]]:
5044
4265
  record["type"] = key.type
5045
4266
  return columns
5046
4267
 
@@ -5065,12 +4286,12 @@ def _summary_objects():
5065
4286
  """
5066
4287
  out = []
5067
4288
  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
4289
+ adt = obj.animation_data
4290
+ action = adt.action if adt is not None else None
5070
4291
  if action is None:
5071
4292
  continue
5072
- slot = getattr(adt, "action_slot", None)
5073
- curves, _shape = _action_channelbag(action, getattr(slot, "handle", None))
4293
+ slot = adt.action_slot
4294
+ curves = _action_channelbag(action, slot.handle if slot is not None else None)
5074
4295
  columns = _key_columns(curves)
5075
4296
  out.append({
5076
4297
  "object": obj.name,
@@ -5082,31 +4303,7 @@ def _summary_objects():
5082
4303
 
5083
4304
 
5084
4305
  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
- """
4306
+ """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
4307
  if object_name:
5111
4308
  obj = bpy.data.objects.get(object_name)
5112
4309
  if obj is None:
@@ -5143,14 +4340,7 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5143
4340
  "slot": None,
5144
4341
  "tracks": [],
5145
4342
  "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.
4343
+ # 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
4344
  "summary": _summary_objects() if summary else [],
5155
4345
  }
5156
4346
  adt = arm_obj.animation_data if arm_obj is not None else None
@@ -5164,11 +4354,11 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5164
4354
  "and an empty summary row, which is Blender's own empty state.")
5165
4355
  return header
5166
4356
  action = assigned
5167
- slot = getattr(adt, "action_slot", None) if adt is not None else None
4357
+ slot = adt.action_slot if adt is not None else None
5168
4358
  if action_name and (assigned is None or action_name != assigned.name):
5169
4359
  # 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).
4360
+ # baked against this armature WITHOUT assigning it, so the file is untouched. The
4361
+ # owner's identifier selects its slot, otherwise the first object slot is used.
5172
4362
  action = bpy.data.actions.get(action_name)
5173
4363
  if action is None:
5174
4364
  raise ValueError("The engine holds no action named %r" % (action_name,))
@@ -5176,23 +4366,20 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5176
4366
  # identifier names this object (`OB<name>`), else the only object slot. A layered action
5177
4367
  # with several object slots and none for this armature has no right answer; its first
5178
4368
  # 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)
4369
+ slots = [one for one in action.slots if one.target_id_type == "OBJECT"]
4370
+ slot = next((one for one in slots if one.identifier == "OB" + arm_obj.name), None)
5181
4371
  if slot is None and slots:
5182
4372
  slot = slots[0]
5183
4373
  if len(slots) > 1:
5184
4374
  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"])
4375
+ % (action.name, len(slots), arm_obj.name, slot.identifier))
4376
+ curves = _action_channelbag(action, slot.handle if slot is not None else None)
4377
+ # Report object-slot count so consumers share a clip across matching armatures only when its channel set is independent of the owner.
4378
+ header["objectSlots"] = len([one for one in action.slots
4379
+ if one.target_id_type == "OBJECT"])
5192
4380
  header.update({
5193
4381
  "action": action.name,
5194
- "slot": getattr(slot, "name_display", None),
5195
- "channels": shape,
4382
+ "slot": slot.name_display if slot is not None else None,
5196
4383
  "fcurves": len(curves),
5197
4384
  "keys": sum(len(fc.keyframe_points) for fc in curves),
5198
4385
  })
@@ -5208,32 +4395,32 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5208
4395
  # THE ACTION'S OWN RANGE (`Action.frame_range`: its keys, or its manual range) with its keys:
5209
4396
  # what a strip or the action line plays, sampled through `FCurve.evaluate`, which applies the
5210
4397
  # 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])))
4398
+ span = action.frame_range
4399
+ first = int(math.floor(min(frames[0], span[0])))
4400
+ last = int(math.ceil(max(frames[-1], span[1])))
5214
4401
  if last <= first:
5215
4402
  last = first + 1
5216
4403
  header["clipStart"] = first
5217
4404
  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.
4405
+ # Report the active action range including Cycles modifier extensions; null bounds are unbounded. The manual frame range does not enter it.
5221
4406
  header["keysStart"], header["keysEnd"] = _action_key_range(action, first, last)
5222
4407
  channels = {}
5223
4408
  others = set()
5224
4409
  for fcurve in curves:
5225
4410
  match = _BONE_PATH.match(fcurve.data_path)
5226
4411
  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)
4412
+ # NOT A BONE'S CHANNEL: a custom property, a bone channel this door does not read.
4413
+ # Named, so it is never dropped quietly. The armature object's OWN transform is not
4414
+ # this door's either, but it plays: the scene's movie (`rna_scene_movie`) carries it.
4415
+ if _OBJECT_PATH.match(fcurve.data_path) is None:
4416
+ others.add(fcurve.data_path)
5230
4417
  continue
5231
4418
  bone, prop = match.group(1), match.group(2)
5232
4419
  channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
5233
4420
  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,))
4421
+ header["reason"] = ("%r has no pose-bone transform channels. The scene's movie "
4422
+ "(`rna_scene_movie`) carries supported object, material and shape-key "
4423
+ "animation; the Timeline still draws this action's keys." % (action.name,))
5237
4424
  return header
5238
4425
  tracks = []
5239
4426
  unplayed = []
@@ -5247,12 +4434,621 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
5247
4434
  header["unsupported"] = ["curves on %s" % ", ".join(sorted(others))]
5248
4435
  header.update({
5249
4436
  "tracks": tracks,
4437
+ "bones": _three_bones(arm_obj, channels, first, last),
5250
4438
  "duration": (last - first) / fps,
5251
4439
  "unplayedBones": unplayed,
5252
4440
  })
5253
4441
  return header
5254
4442
 
5255
4443
 
4444
+ def _three_bones(arm_obj, channels, first, last):
4445
+ """THE ACTION AS THREE.JS PLAYS IT: per bone it keys, the bone's LOCAL transform (relative to
4446
+ its parent bone, as a `THREE.Bone` carries it) at every integer frame from `first` to `last`.
4447
+
4448
+ Blender evaluates every channel (`FCurve.evaluate`: its keys, handles, interpolation, easing,
4449
+ extrapolation and modifiers); a component the action does not key keeps the bone's own value.
4450
+ The basis is composed by the bone's rotation mode and placed by its rest relative to its
4451
+ parent's rest, `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so a clip moves
4452
+ a bone exactly as Blender's pose does at every frame, and three.js interpolates between them.
4453
+
4454
+ Each column crosses as base64 Float32: `position` three per frame, `quaternion` four (x, y, z,
4455
+ w, as three reads them, kept on one hemisphere so a slerp never turns the long way), `scale`
4456
+ three. A column whose every sample equals its first crosses as that one sample."""
4457
+ frames = list(range(first, last + 1))
4458
+ by_bone = {}
4459
+ for (bone, prop), components in channels.items():
4460
+ by_bone.setdefault(bone, {})[prop] = components
4461
+ out = []
4462
+ for name in sorted(by_bone):
4463
+ pbone = arm_obj.pose.bones.get(name)
4464
+ if pbone is None:
4465
+ continue
4466
+ rest = pbone.bone.matrix_local
4467
+ parent = pbone.bone.parent
4468
+ rest_relative = (parent.matrix_local.inverted() @ rest) if parent is not None else rest.copy()
4469
+ props = by_bone[name]
4470
+ mode = pbone.rotation_mode
4471
+ own = {
4472
+ "location": list(pbone.location),
4473
+ "rotation_quaternion": list(pbone.rotation_quaternion),
4474
+ "rotation_euler": list(pbone.rotation_euler),
4475
+ "rotation_axis_angle": list(pbone.rotation_axis_angle),
4476
+ "scale": list(pbone.scale),
4477
+ }
4478
+ position = array.array("f")
4479
+ quaternion = array.array("f")
4480
+ scale = array.array("f")
4481
+ last_q = None
4482
+ keys = set()
4483
+ for components in props.values():
4484
+ for fcurve in components.values():
4485
+ for point in fcurve.keyframe_points:
4486
+ keys.add(round(float(point.co[0]), 3))
4487
+ for frame in frames:
4488
+ values = {prop: list(value) for prop, value in own.items()}
4489
+ for prop, components in props.items():
4490
+ for index, fcurve in components.items():
4491
+ if index < len(values[prop]):
4492
+ values[prop][index] = fcurve.evaluate(frame)
4493
+ if mode == "QUATERNION":
4494
+ rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
4495
+ elif mode == "AXIS_ANGLE":
4496
+ angle, x, y, z = values["rotation_axis_angle"]
4497
+ axis = mathutils.Vector((x, y, z))
4498
+ rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 1.0, 0.0), angle)
4499
+ else:
4500
+ rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
4501
+ basis = mathutils.Matrix.LocRotScale(mathutils.Vector(values["location"]), rotation,
4502
+ mathutils.Vector(values["scale"]))
4503
+ loc, rot, size = (rest_relative @ basis).decompose()
4504
+ if last_q is not None and last_q.dot(rot) < 0:
4505
+ rot.negate()
4506
+ last_q = rot
4507
+ position.extend((loc.x, loc.y, loc.z))
4508
+ quaternion.extend((rot.x, rot.y, rot.z, rot.w))
4509
+ scale.extend((size.x, size.y, size.z))
4510
+
4511
+ out.append({"bone": name, "keys": sorted(keys), "position": _column(position, 3),
4512
+ "quaternion": _column(quaternion, 4), "scale": _column(scale, 3)})
4513
+ return out
4514
+
4515
+
4516
+ # ------------------------------------------------------------- the scene's movie
4517
+
4518
+ # AN OBJECT'S OWN TRANSFORM CHANNELS, as an action keys them on the object (not on a bone).
4519
+ _OBJECT_PATH = re.compile(r'^(location|rotation_quaternion|rotation_euler|rotation_axis_angle|scale)$')
4520
+ # A CAMERA'S KEYABLE PROJECTION, as an action keys it on the camera's data.
4521
+ _CAMERA_PROPS = ("lens", "sensor_width", "sensor_height", "ortho_scale", "shift_x", "shift_y",
4522
+ "clip_start", "clip_end")
4523
+
4524
+
4525
+ def _slot_handle(action, owner, prefix, slot):
4526
+ """Use the assigned slot, then the owner identifier, then the first slot of the owner’s ID type."""
4527
+ if slot is not None:
4528
+ return slot.handle
4529
+ slots = list(action.slots)
4530
+ if not slots:
4531
+ return None
4532
+ kind = {"OB": "OBJECT", "CA": "CAMERA", "NT": "NODETREE", "KE": "KEY", "SC": "SCENE"}[prefix]
4533
+ named = next((one for one in slots if one.identifier == prefix + owner.name), None)
4534
+ if named is not None:
4535
+ return named.handle
4536
+ typed = [one for one in slots if one.target_id_type == kind]
4537
+ return typed[0].handle if typed else None
4538
+
4539
+
4540
+ def _object_constraints(obj):
4541
+ """An object's constraint stack, in order: Damped Track and Track To carry what the presenter
4542
+ evaluates them from; every other type is named and played only in Blender."""
4543
+ stack = []
4544
+ for con in obj.constraints:
4545
+ target = getattr(con, "target", None)
4546
+ entry = {"name": con.name, "type": con.type, "enabled": bool(con.enabled),
4547
+ "influence": float(con.influence)}
4548
+ if target is not None:
4549
+ entry["target"] = target.name
4550
+ if con.type in ("DAMPED_TRACK", "TRACK_TO"):
4551
+ entry["trackAxis"] = con.track_axis
4552
+ if con.type == "TRACK_TO":
4553
+ entry["upAxis"] = con.up_axis
4554
+ entry["useTargetZ"] = bool(con.use_target_z)
4555
+ stack.append(entry)
4556
+ return stack
4557
+
4558
+
4559
+ def _column(samples, stride):
4560
+ """A sampled column as base64 Float32: its first sample alone when it never changes."""
4561
+ if len(samples) > stride and all(abs(samples[i] - samples[i % stride]) < 1e-7 for i in range(len(samples))):
4562
+ samples = samples[:stride]
4563
+ return {"count": len(samples) // stride, "base64": base64.b64encode(samples.tobytes()).decode("ascii")}
4564
+
4565
+
4566
+ def _owner_action_at(adt, frame):
4567
+ """Use the active action at the scene frame, otherwise the last unmuted strip containing it,
4568
+ or the first unmuted strip when none contains it. Clamp to that strip's range and apply its
4569
+ scale and repeat. Return no action when no unmuted action strip exists."""
4570
+ if adt.action is not None:
4571
+ return adt.action, frame
4572
+ best = None
4573
+ for track in adt.nla_tracks:
4574
+ if track.mute:
4575
+ continue
4576
+ for strip in track.strips:
4577
+ if strip.mute or strip.action is None:
4578
+ continue
4579
+ if strip.frame_start <= frame <= strip.frame_end or best is None:
4580
+ best = strip
4581
+ if best is None:
4582
+ return None, frame
4583
+ length = (best.action_frame_end - best.action_frame_start) or 1.0
4584
+ scale = abs(best.scale) or 1.0
4585
+ at = min(max(frame, best.frame_start), best.frame_end)
4586
+ into = ((at - best.frame_start) / scale) % length if at < best.frame_end else length
4587
+ return best.action, best.action_frame_start + into
4588
+
4589
+
4590
+ def _sample_object(obj, pre, delta, first, last, unsupported):
4591
+ """AN OBJECT'S MOVIE AS THREE.JS PLAYS IT: its local transform (`pre @ basis`, the basis with its
4592
+ delta transform, `BKE_object_to_mat4`) at every scene frame from `first` to `last`, each channel
4593
+ evaluated by Blender (`FCurve.evaluate`) from the action it shows there. Columns as the clip
4594
+ door's: position, quaternion (x, y, z, w, one hemisphere), scale; and `hidden` (one per frame,
4595
+ 1 hidden) when its render visibility is keyed."""
4596
+ adt = obj.animation_data
4597
+ 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)):
4598
+ unsupported.append("its NLA layering (the active action, else the top strip, is played)")
4599
+ position = array.array("f")
4600
+ quaternion = array.array("f")
4601
+ scale = array.array("f")
4602
+ hidden = array.array("f")
4603
+ keyed_hidden = False
4604
+ last_q = None
4605
+ mode = obj.rotation_mode
4606
+ own = {"location": list(obj.location), "rotation_quaternion": list(obj.rotation_quaternion),
4607
+ "rotation_euler": list(obj.rotation_euler), "rotation_axis_angle": list(obj.rotation_axis_angle),
4608
+ "scale": list(obj.scale), "hide_render": [float(obj.hide_render)]}
4609
+ curves_of = {}
4610
+ for frame in range(first, last + 1):
4611
+ action, at = _owner_action_at(adt, frame)
4612
+ values = {prop: list(value) for prop, value in own.items()}
4613
+ if action is not None:
4614
+ curves = curves_of.get(action.name)
4615
+ if curves is None:
4616
+ handle = _slot_handle(action, obj, "OB", adt.action_slot if action == adt.action else None)
4617
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4618
+ for fcurve in curves:
4619
+ if fcurve.data_path in values and fcurve.array_index < len(values[fcurve.data_path]):
4620
+ values[fcurve.data_path][fcurve.array_index] = fcurve.evaluate(at)
4621
+ if fcurve.data_path == "hide_render":
4622
+ keyed_hidden = True
4623
+ if mode == "QUATERNION":
4624
+ rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
4625
+ elif mode == "AXIS_ANGLE":
4626
+ angle, x, y, z = values["rotation_axis_angle"]
4627
+ axis = mathutils.Vector((x, y, z))
4628
+ rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 0.0, 1.0), angle)
4629
+ else:
4630
+ rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
4631
+ rotation = delta @ rotation
4632
+ location = mathutils.Vector(values["location"]) + obj.delta_location
4633
+ size = mathutils.Vector([a * b for a, b in zip(values["scale"], obj.delta_scale)])
4634
+ loc, rot, sca = (pre @ mathutils.Matrix.LocRotScale(location, rotation, size)).decompose()
4635
+ if last_q is not None and last_q.dot(rot) < 0:
4636
+ rot.negate()
4637
+ last_q = rot
4638
+ position.extend((loc.x, loc.y, loc.z))
4639
+ quaternion.extend((rot.x, rot.y, rot.z, rot.w))
4640
+ scale.extend((sca.x, sca.y, sca.z))
4641
+ hidden.append(1.0 if values["hide_render"][0] >= 0.5 else 0.0)
4642
+ out = {"start": first, "end": last, "position": _column(position, 3), "quaternion": _column(quaternion, 4),
4643
+ "scale": _column(scale, 3)}
4644
+ if keyed_hidden:
4645
+ out["hidden"] = _column(hidden, 1)
4646
+ return out
4647
+
4648
+
4649
+ def _sample_camera(data, first, last):
4650
+ """A CAMERA'S PROJECTION AS THE MOVIE HAS IT: each of `_CAMERA_PROPS` at every scene frame, by
4651
+ Blender's evaluation of the action its data shows there."""
4652
+ adt = data.animation_data
4653
+ columns = {prop: array.array("f") for prop in _CAMERA_PROPS}
4654
+ curves_of = {}
4655
+ for frame in range(first, last + 1):
4656
+ action, at = _owner_action_at(adt, frame)
4657
+ values = {prop: float(getattr(data, prop)) for prop in _CAMERA_PROPS}
4658
+ if action is not None:
4659
+ curves = curves_of.get(action.name)
4660
+ if curves is None:
4661
+ handle = _slot_handle(action, data, "CA", adt.action_slot if action == adt.action else None)
4662
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4663
+ for fcurve in curves:
4664
+ if fcurve.data_path in values:
4665
+ values[fcurve.data_path] = fcurve.evaluate(at)
4666
+ for prop in _CAMERA_PROPS:
4667
+ columns[prop].append(values[prop])
4668
+ return {"start": first, "end": last, **{prop: _column(column, 1) for prop, column in columns.items()}}
4669
+
4670
+
4671
+ _MATERIAL_INPUTS = {"Base Color": 4, "Emission Color": 4, "Emission Strength": 1, "Alpha": 1}
4672
+
4673
+
4674
+ def _sample_material(material, first, last):
4675
+ """Sample a material’s keyed Principled Base Color, Emission and Alpha through the action its
4676
+ node tree shows at each scene frame. Return only keyed inputs, or None when none are keyed."""
4677
+ tree = material.node_tree
4678
+ adt = tree.animation_data if tree is not None else None
4679
+ if adt is None:
4680
+ return None
4681
+ bsdf = next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None)
4682
+ if bsdf is None:
4683
+ return None
4684
+ index = {}
4685
+ for i, socket in enumerate(bsdf.inputs):
4686
+ index.setdefault(socket.name, i)
4687
+ paths = {}
4688
+ own = {}
4689
+ for name, size in _MATERIAL_INPUTS.items():
4690
+ paths['nodes["%s"].inputs[%d].default_value' % (bsdf.name, index[name])] = name
4691
+ value = bsdf.inputs[index[name]].default_value
4692
+ own[name] = [float(v) for v in value] if size > 1 else [float(value)]
4693
+ keyed = set()
4694
+ color, emissive, opacity = array.array("f"), array.array("f"), array.array("f")
4695
+ curves_of = {}
4696
+ for frame in range(first, last + 1):
4697
+ action, at = _owner_action_at(adt, frame)
4698
+ values = {name: list(value) for name, value in own.items()}
4699
+ if action is not None:
4700
+ curves = curves_of.get(action.name)
4701
+ if curves is None:
4702
+ handle = _slot_handle(action, tree, "NT", adt.action_slot if action == adt.action else None)
4703
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4704
+ for fcurve in curves:
4705
+ name = paths.get(fcurve.data_path)
4706
+ if name is not None and fcurve.array_index < len(values[name]):
4707
+ values[name][fcurve.array_index] = fcurve.evaluate(at)
4708
+ keyed.add(name)
4709
+ base = values["Base Color"]
4710
+ color.extend(base[:3])
4711
+ glow = values["Emission Color"]
4712
+ strength = values["Emission Strength"][0]
4713
+ emissive.extend((glow[0] * strength, glow[1] * strength, glow[2] * strength))
4714
+ opacity.append(values["Alpha"][0])
4715
+ if not keyed:
4716
+ return None
4717
+ out = {"start": first, "end": last}
4718
+ if "Base Color" in keyed:
4719
+ out["color"] = _column(color, 3)
4720
+ if keyed & {"Emission Color", "Emission Strength"}:
4721
+ out["emissive"] = _column(emissive, 3)
4722
+ if "Alpha" in keyed:
4723
+ out["opacity"] = _column(opacity, 1)
4724
+ return out
4725
+
4726
+
4727
+ def _sample_shapes(obj, first, last, depsgraph):
4728
+ """Sample each keyed relative shape key’s vertex offsets and values at every scene frame,
4729
+ including the presented geometry’s current value. mapped is False when modifiers change vertex
4730
+ correspondence. Return None when no key is keyed."""
4731
+ mesh = obj.data
4732
+ keys = mesh.shape_keys if mesh is not None else None
4733
+ if keys is None or not keys.use_relative or len(keys.key_blocks) < 2:
4734
+ return None
4735
+ adt = keys.animation_data
4736
+ if adt is None:
4737
+ return None
4738
+ blocks = list(keys.key_blocks)[1:]
4739
+ path_of = {'key_blocks["%s"].value' % block.name: block.name for block in blocks}
4740
+ values = {block.name: array.array("f") for block in blocks}
4741
+ keyed = set()
4742
+ curves_of = {}
4743
+ for frame in range(first, last + 1):
4744
+ action, at = _owner_action_at(adt, frame)
4745
+ now = {block.name: float(block.value) for block in blocks}
4746
+ if action is not None:
4747
+ curves = curves_of.get(action.name)
4748
+ if curves is None:
4749
+ handle = _slot_handle(action, keys, "KE", adt.action_slot if action == adt.action else None)
4750
+ curves = curves_of[action.name] = _action_channelbag(action, handle)
4751
+ for fcurve in curves:
4752
+ name = path_of.get(fcurve.data_path)
4753
+ if name is not None:
4754
+ now[name] = fcurve.evaluate(at)
4755
+ keyed.add(name)
4756
+ for name, value in now.items():
4757
+ values[name].append(value)
4758
+ if not keyed:
4759
+ return None
4760
+ count = len(obj.data.vertices)
4761
+ try:
4762
+ mapped = len(obj.evaluated_get(depsgraph).data.vertices) == count
4763
+ except Exception:
4764
+ mapped = False
4765
+ out = []
4766
+ for block in blocks:
4767
+ if block.name not in keyed:
4768
+ continue
4769
+ own = array.array("f", [0.0]) * (3 * count)
4770
+ base = array.array("f", [0.0]) * (3 * count)
4771
+ block.data.foreach_get("co", own)
4772
+ (block.relative_key or keys.reference_key).data.foreach_get("co", base)
4773
+ delta = array.array("f", (a - b for a, b in zip(own, base)))
4774
+ out.append({"name": block.name, "delta": {"count": count, "base64": base64.b64encode(delta.tobytes()).decode("ascii")},
4775
+ "values": _column(values[block.name], 1), "current": float(block.value)})
4776
+ return {"object": obj.name, "start": first, "end": last, "vertices": count, "mapped": mapped, "keys": out}
4777
+
4778
+
4779
+ def rna_scene_movie():
4780
+ """Scene animation for Timeline preview and export: range, rate, camera, bound
4781
+ markers and sampled object, camera, material and shape-key columns. Object
4782
+ records retain parent-space transforms and constraints. Cameras named by
4783
+ the scene or a marker are included whether animated or not. Sampling leaves
4784
+ the scene frame unchanged."""
4785
+ scene = bpy.context.scene
4786
+ view_layer = bpy.context.view_layer
4787
+ fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
4788
+ markers = []
4789
+ for marker in scene.timeline_markers:
4790
+ markers.append({"name": marker.name, "frame": int(marker.frame),
4791
+ "camera": marker.camera.name if marker.camera is not None else None})
4792
+ wanted = {marker["camera"] for marker in markers if marker["camera"]}
4793
+ if scene.camera is not None:
4794
+ wanted.add(scene.camera.name)
4795
+
4796
+ def transform(path):
4797
+ return path if _OBJECT_PATH.match(path) or path == "hide_render" else None
4798
+
4799
+ def projection(path):
4800
+ return path if path in _CAMERA_PROPS else None
4801
+
4802
+ objects = []
4803
+ animated = {}
4804
+ for obj in view_layer.objects:
4805
+ own = _owner_animated(obj, transform, animated)
4806
+ constraints = _object_constraints(obj)
4807
+ camera = None
4808
+ if obj.type == "CAMERA" and obj.data is not None:
4809
+ data = obj.data
4810
+ camera = {
4811
+ "type": data.type, "lens": float(data.lens), "sensor_width": float(data.sensor_width),
4812
+ "sensor_height": float(data.sensor_height), "sensor_fit": data.sensor_fit,
4813
+ "ortho_scale": float(data.ortho_scale), "clip_start": float(data.clip_start),
4814
+ "clip_end": float(data.clip_end), "shift_x": float(data.shift_x), "shift_y": float(data.shift_y),
4815
+ "renderHidden": bool(obj.hide_render),
4816
+ **({"keyed": True} if _owner_animated(data, projection, animated) else {}),
4817
+ }
4818
+ if not own and (camera is None or (obj.name not in wanted and "keyed" not in camera)):
4819
+ continue
4820
+ unsupported = []
4821
+ parent = obj.parent
4822
+ if parent is None:
4823
+ pre = mathutils.Matrix.Identity(4)
4824
+ elif obj.parent_type == "OBJECT":
4825
+ pre = obj.matrix_parent_inverse
4826
+ else:
4827
+ # Parented to a bone, a vertex or a curve: the offset it has now is held.
4828
+ pre = (parent.matrix_world.inverted_safe() @ obj.matrix_world) @ obj.matrix_basis.inverted_safe()
4829
+ unsupported.append("its %s parenting (held where it is now)" % obj.parent_type.lower())
4830
+ mode = obj.rotation_mode
4831
+ if mode == "QUATERNION":
4832
+ delta = obj.delta_rotation_quaternion.normalized()
4833
+ elif mode == "AXIS_ANGLE":
4834
+ delta = mathutils.Quaternion((1.0, 0.0, 0.0, 0.0))
4835
+ else:
4836
+ delta = mathutils.Euler(obj.delta_rotation_euler, mode).to_quaternion()
4837
+ adt = obj.animation_data
4838
+ if adt is not None:
4839
+ for path in sorted({fc.data_path for fc in adt.drivers if _OBJECT_PATH.match(fc.data_path)}):
4840
+ unsupported.append("the driver on its %s" % path)
4841
+ for con in constraints:
4842
+ if con["enabled"] and con["type"] not in ("DAMPED_TRACK", "TRACK_TO"):
4843
+ unsupported.append("its %s constraint %r" % (con["type"].replace("_", " ").title(), con["name"]))
4844
+ sampled = _sample_object(obj, pre, delta, int(scene.frame_start), int(scene.frame_end),
4845
+ unsupported) if own else None
4846
+ if camera is not None and camera.pop("keyed", False):
4847
+ camera["sampled"] = _sample_camera(obj.data, int(scene.frame_start), int(scene.frame_end))
4848
+ objects.append({
4849
+ "name": obj.name,
4850
+ "type": obj.type,
4851
+ "parent": parent.name if parent is not None else None,
4852
+ **({"sampled": sampled} if sampled is not None else {}),
4853
+ "rotationMode": mode,
4854
+ "channels": {
4855
+ "location": [float(v) for v in obj.location],
4856
+ "rotation_quaternion": [float(v) for v in obj.rotation_quaternion],
4857
+ "rotation_euler": [float(v) for v in obj.rotation_euler],
4858
+ "rotation_axis_angle": [float(v) for v in obj.rotation_axis_angle],
4859
+ "scale": [float(v) for v in obj.scale],
4860
+ },
4861
+ "delta": {"location": [float(v) for v in obj.delta_location], "rotation": [float(v) for v in delta],
4862
+ "scale": [float(v) for v in obj.delta_scale]},
4863
+ "pre": _matrix_rows(pre),
4864
+ "constraints": constraints,
4865
+ **({"camera": camera} if camera is not None else {}),
4866
+ **({"unsupported": unsupported} if unsupported else {}),
4867
+ })
4868
+ # Sample keyed material inputs and shape keys.
4869
+ first, last = int(scene.frame_start), int(scene.frame_end)
4870
+ materials = []
4871
+ seen = set()
4872
+ for obj in view_layer.objects:
4873
+ for slot in obj.material_slots:
4874
+ material = slot.material
4875
+ if material is None or material.name in seen:
4876
+ continue
4877
+ seen.add(material.name)
4878
+ sampled = _sample_material(material, first, last)
4879
+ if sampled is not None:
4880
+ materials.append({"name": material.name, **sampled})
4881
+ depsgraph = bpy.context.evaluated_depsgraph_get()
4882
+ shapes = [one for one in (_sample_shapes(obj, first, last, depsgraph) for obj in view_layer.objects if obj.type == "MESH") if one]
4883
+ return {
4884
+ "materials": materials,
4885
+ "shapes": shapes,
4886
+ "scene": {"name": scene.name, "start": int(scene.frame_start), "end": int(scene.frame_end), "fps": fps,
4887
+ "current": int(scene.frame_current),
4888
+ "camera": scene.camera.name if scene.camera is not None else None},
4889
+ "markers": sorted(markers, key=lambda marker: marker["frame"]),
4890
+ "objects": objects,
4891
+ }
4892
+
4893
+
4894
+ def rna_sound_scene(first, last):
4895
+ """The scene's sound for the page to mix (docs/adr/movie-sound-and-react-frames.md,
4896
+ decision 2). Native audio mixing is unavailable in this Blender. Describe the movie's frames
4897
+ `first` to `last` for `@volter/editor-blender` to render in an OfflineAudioContext:
4898
+
4899
+ - the sequencer's sound strips: each one's file (or, packed, a copy written beside the
4900
+ session), where its sound starts and where the strip is cut, its speed, whether it is muted
4901
+ or mono, and its volume and pan at every frame, read through the scene's F-Curves (a fade
4902
+ is keys on volume);
4903
+ - the speakers: each one's file, the frames its NLA sound strips start on, its volume and
4904
+ distance settings, and its place and facing at every frame, with the scene camera's (the
4905
+ listener). Those are evaluated, so the frame moves and is restored.
4906
+
4907
+ The scene's own volume and distance model come too. READ ONLY: no history, save or present."""
4908
+ scene = bpy.context.scene
4909
+ fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
4910
+ count = last - first + 1
4911
+
4912
+ def sound_file(sound):
4913
+ if sound is None:
4914
+ return None
4915
+ if sound.packed_file is not None:
4916
+ path = os.path.join(ROOT, "sound-%s" % hashlib.sha256(sound.name.encode("utf-8")).hexdigest()[:16])
4917
+ with open(path, "wb") as out:
4918
+ out.write(sound.packed_file.data)
4919
+ return {"packed": path}
4920
+ # normalized: a path saved relative to the .blend ("//../../sounds/x.wav") keeps its ".." segments otherwise
4921
+ return {"path": os.path.normpath(bpy.path.abspath(sound.filepath, library=sound.library))}
4922
+
4923
+ # A strip's keyed volume and pan are the scene's F-Curves on the strip's own path.
4924
+ adt = scene.animation_data
4925
+ curves = {}
4926
+ if adt is not None and adt.action is not None:
4927
+ handle = _slot_handle(adt.action, scene, "SC", adt.action_slot)
4928
+ for fcurve in _action_channelbag(adt.action, handle):
4929
+ curves[(fcurve.data_path, fcurve.array_index)] = fcurve
4930
+
4931
+ def sampled(strip, prop):
4932
+ constant = float(getattr(strip, prop))
4933
+ fcurve = next((curves[(path, 0)] for path in (
4934
+ 'sequence_editor.strips_all["%s"].%s' % (strip.name, prop),
4935
+ 'sequence_editor.sequences_all["%s"].%s' % (strip.name, prop)) if (path, 0) in curves), None)
4936
+ values = array.array("f", (fcurve.evaluate(frame) if fcurve is not None else constant
4937
+ for frame in range(first, last + 1)))
4938
+ return _column(values, 1)
4939
+
4940
+ editor = scene.sequence_editor
4941
+ strips = []
4942
+ if editor is not None:
4943
+ channels = editor.channels
4944
+ for strip in editor.strips_all:
4945
+ if strip.type != "SOUND":
4946
+ continue
4947
+ channel = channels[strip.channel] if 0 <= strip.channel < len(channels) else None
4948
+ strips.append({
4949
+ "name": strip.name,
4950
+ "file": sound_file(strip.sound),
4951
+ "mono": bool(strip.sound is not None and strip.sound.use_mono),
4952
+ "muted": bool(strip.mute or (channel is not None and channel.mute)),
4953
+ "start": float(strip.frame_start),
4954
+ "finalStart": float(strip.frame_final_start),
4955
+ "finalEnd": float(strip.frame_final_end),
4956
+ "speed": float(getattr(strip, "speed_factor", 1.0)),
4957
+ "volume": sampled(strip, "volume"),
4958
+ "pan": sampled(strip, "pan"),
4959
+ })
4960
+
4961
+ speakers = [obj for obj in scene.objects if obj.type == "SPEAKER" and obj.data is not None]
4962
+ described = []
4963
+ listener = None
4964
+ if speakers:
4965
+ tracks = {obj.name: [] for obj in speakers}
4966
+ for obj in speakers:
4967
+ for track in (obj.animation_data.nla_tracks if obj.animation_data is not None else ()):
4968
+ if track.mute:
4969
+ continue
4970
+ for strip in track.strips:
4971
+ if strip.type == "SOUND" and not strip.mute:
4972
+ tracks[obj.name].append(float(strip.frame_start))
4973
+ places = {obj.name: array.array("f") for obj in speakers}
4974
+ facings = {obj.name: array.array("f") for obj in speakers}
4975
+ volumes = {obj.name: array.array("f") for obj in speakers}
4976
+ heard = {"position": array.array("f"), "forward": array.array("f"), "up": array.array("f")}
4977
+ kept = scene.frame_current
4978
+ try:
4979
+ for frame in range(first, last + 1):
4980
+ scene.frame_set(frame)
4981
+ for obj in speakers:
4982
+ matrix = obj.matrix_world
4983
+ places[obj.name].extend(matrix.translation)
4984
+ facings[obj.name].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
4985
+ volumes[obj.name].append(float(obj.data.volume))
4986
+ camera = scene.camera
4987
+ matrix = camera.matrix_world if camera is not None else mathutils.Matrix.Identity(4)
4988
+ heard["position"].extend(matrix.translation)
4989
+ heard["forward"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
4990
+ heard["up"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 1.0, 0.0))).normalized())
4991
+ finally:
4992
+ scene.frame_set(kept)
4993
+ for obj in speakers:
4994
+ data = obj.data
4995
+ described.append({
4996
+ "name": obj.name,
4997
+ "file": sound_file(data.sound),
4998
+ "muted": bool(data.muted),
4999
+ "starts": tracks[obj.name],
5000
+ "volume": _column(volumes[obj.name], 1),
5001
+ "volumeMin": float(data.volume_min),
5002
+ "volumeMax": float(data.volume_max),
5003
+ "attenuation": float(data.attenuation),
5004
+ "distanceReference": float(data.distance_reference),
5005
+ "distanceMax": float(data.distance_max),
5006
+ "coneInner": float(data.cone_angle_inner),
5007
+ "coneOuter": float(data.cone_angle_outer),
5008
+ "coneOuterVolume": float(data.cone_volume_outer),
5009
+ "position": _column(places[obj.name], 3),
5010
+ "facing": _column(facings[obj.name], 3),
5011
+ })
5012
+ listener = {key: _column(column, 3) for key, column in heard.items()}
5013
+
5014
+ return {
5015
+ "fps": fps, "first": first, "last": last, "count": count,
5016
+ "volume": float(scene.audio_volume),
5017
+ # `use_audio` is the negation of `AUDIO_MUTE`: true, the default, plays the sequencer's sound
5018
+ "muted": not bool(scene.use_audio),
5019
+ "distanceModel": scene.audio_distance_model,
5020
+ "strips": strips,
5021
+ "speakers": described,
5022
+ "listener": listener,
5023
+ }
5024
+
5025
+
5026
+ def _owner_animated(owner, accept, cache):
5027
+ """Whether an owner's own properties (`accept` names them) are keyed by an action it shows: its
5028
+ active action or an unmuted NLA strip's. One look per action and owner kind, kept in `cache`:
5029
+ a level's armatures share their few actions, and their curves are bones', not the object's."""
5030
+ adt = owner.animation_data
5031
+ if adt is None:
5032
+ return False
5033
+ actions = [adt.action] if adt.action is not None else []
5034
+ for track in adt.nla_tracks:
5035
+ if not track.mute:
5036
+ actions.extend(strip.action for strip in track.strips if strip.action is not None and not strip.mute)
5037
+ for action in actions:
5038
+ key = (action.name, accept)
5039
+ if key not in cache:
5040
+ # EVERY SLOT'S CURVES: a camera's object and its lens share one action through two slots.
5041
+ curves = []
5042
+ for layer in action.layers:
5043
+ for strip in layer.strips:
5044
+ for bag in strip.channelbags:
5045
+ curves.extend(bag.fcurves)
5046
+ cache[key] = any(accept(fcurve.data_path) for fcurve in curves)
5047
+ if cache[key]:
5048
+ return True
5049
+ return False
5050
+
5051
+
5256
5052
  # `Keyframe.type`'s significance order, mirrored from the `KEYFRAME_STATE`
5257
5053
  # merge in `keyframes_keylist.cc` -- a column drawn from several curves takes
5258
5054
  # the most significant type any of them carries.
@@ -5271,10 +5067,10 @@ def _action_key_range(action, first, last):
5271
5067
  repeats into, any other type opens both sides. Mute is not read. None is an open side."""
5272
5068
  low, high = None, None
5273
5069
  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 ()):
5070
+ curves = []
5071
+ for layer in action.layers:
5072
+ for strip in layer.strips:
5073
+ for bag in strip.channelbags:
5278
5074
  curves.extend(bag.fcurves)
5279
5075
  # A curve's keys and its last modifier count independently: a keyless curve carrying a
5280
5076
  # Generator or Noise still opens the range, as in Blender.
@@ -5334,8 +5130,8 @@ def _curve_body(fcurve):
5334
5130
  if modifier.mute:
5335
5131
  continue
5336
5132
  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)]
5133
+ cycles = [_CYCLE_MODE[modifier.mode_before], int(modifier.cycles_before),
5134
+ _CYCLE_MODE[modifier.mode_after], int(modifier.cycles_after)]
5339
5135
  else:
5340
5136
  unsupported.append("%s modifier" % modifier.type.lower())
5341
5137
  out = {
@@ -5449,9 +5245,7 @@ def rna_outliner(selected=None):
5449
5245
  placed.add(obj.name)
5450
5246
  ordered.append(obj)
5451
5247
 
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).
5248
+ # Reuse each placed object to avoid a linear ID-name lookup per row.
5455
5249
  for entries in list(placements.values()):
5456
5250
  order(entries[0][0]["place"])
5457
5251
  # A move takes the place out of its collection's list; the lists are
@@ -5486,16 +5280,9 @@ def rna_outliner(selected=None):
5486
5280
  for row in collections:
5487
5281
  row["children"] = [child for child in row["children"] if id(child) not in moved]
5488
5282
 
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.
5283
+ # Retain at most one page of active/selected rows and their ancestors.
5284
+ # Prioritize the active object, then the caller's selection, then the
5285
+ # engine's remaining selection so a clicked object keeps its address.
5499
5286
  wanted = [active] if active is not None else []
5500
5287
  wanted += sorted(chosen)
5501
5288
  wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
@@ -5508,7 +5295,7 @@ def rna_outliner(selected=None):
5508
5295
 
5509
5296
  def hold(row):
5510
5297
  inside = "place" in row and row["name"] in kept
5511
- for child in row.get("children", []):
5298
+ for child in row["children"]:
5512
5299
  inside = hold(child) or inside
5513
5300
  if inside:
5514
5301
  holding.add(id(row))
@@ -5542,7 +5329,7 @@ def rna_outliner(selected=None):
5542
5329
  # Cap the hierarchy's visible child lists, not the collection's flat input.
5543
5330
  # Every parent participates in folding even when its child list needs a page.
5544
5331
  def page_tree(row):
5545
- members = row.get("children", [])
5332
+ members = row["children"]
5546
5333
  children, more = _outliner_page(members)
5547
5334
  beyond = [child for child in members[len(children):] if id(child) in holding]
5548
5335
  children = [kept_row(child) for child in children + beyond]
@@ -5559,8 +5346,7 @@ def rna_outliner(selected=None):
5559
5346
  # page left behind adds only what it would have claimed that a later row
5560
5347
  # can share -- its own address, its materials, its modifiers' linked
5561
5348
  # 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.
5349
+ # and of its data.
5564
5350
  addresses = {}
5565
5351
 
5566
5352
  def address(datablock):
@@ -5580,33 +5366,24 @@ def rna_outliner(selected=None):
5580
5366
  object_key = obj.as_pointer()
5581
5367
  object_path = address(obj)
5582
5368
  data = obj.data
5583
- exact = object_path is None
5584
- scopes = [] if exact else [(object_path + ".", None, expanded.get(object_key, 0))]
5369
+ scopes = [(object_path + ".", None, expanded.get(object_key, 0))]
5585
5370
  data_key = bones = None
5586
5371
  if data is not None:
5587
5372
  data_key = data.as_pointer()
5588
5373
  data_path = address(data)
5589
5374
  keys = getattr(data, "shape_keys", None)
5590
5375
  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
5376
  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 = []
5377
+ before = data_expanded.get(data_key, 0)
5378
+ scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
5379
+ (data_path + ".", data_path, before)]
5380
+ if keys is not None:
5381
+ scopes.append((keys_path + ".", keys_path, before))
5601
5382
  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.
5383
+ if row is not None:
5604
5384
  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)
5385
+ for claimed in spot["claims"]:
5386
+ claimed["id"] = _outliner_unique(claimed["path"], scope)
5610
5387
  else:
5611
5388
  bump(object_path)
5612
5389
  if data is not None:
@@ -5621,9 +5398,7 @@ def rna_outliner(selected=None):
5621
5398
  linked = getattr(md, member, None)
5622
5399
  if linked is None:
5623
5400
  continue
5624
- linked_path = address(linked)
5625
- if linked_path:
5626
- bump(linked_path)
5401
+ bump(address(linked))
5627
5402
  break
5628
5403
  if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
5629
5404
  bump(address(obj.instance_collection))
@@ -5703,32 +5478,55 @@ def outliner_set(path, column, value):
5703
5478
  % (column, target.bl_rna.identifier, path))
5704
5479
 
5705
5480
 
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")
5481
+ # Record requests that can remove objects so every disappearance has a named cause.
5482
+ _WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step", "gltf-export", "run-script")
5483
+ # The requests that may change an action's keys (`Session._kept_action_revisions`): every edit, not an
5484
+ # export (which puts everything back) and not a read.
5485
+ _ACTION_EDIT_OPS = ("execute", "rna-set", "outliner-set", "history-step")
5486
+ _ACTION_EDITS = {"count": 0}
5711
5487
 
5712
5488
 
5713
5489
  def dispatch(request):
5714
5490
  op = request.get("op")
5491
+ if op in _ACTION_EDIT_OPS:
5492
+ _ACTION_EDITS["count"] += 1
5715
5493
  if op not in _WATCHED_OPS:
5716
5494
  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.
5495
+ # Compare stable session_uids so renames do not count as object deletions.
5719
5496
  before = {o.session_uid: o.name for o in bpy.data.objects}
5497
+ # A request that loads a file (`read_homefile`, `open_mainfile`, then perhaps `save_as_mainfile`, to the same path
5498
+ # or another) removes nothing from the document it replaced: its objects are that file's, as saved. What it can
5499
+ # lose is the document's unsaved changes, said where there were any.
5500
+ before_path = bpy.data.filepath
5501
+ # (the session's own record of changes not yet saved, `mark_changed`'s: Blender's `is_dirty` is instrumentation)
5502
+ before_dirty = SESSION.save_due
5503
+ before_loads = _LOADS["count"]
5720
5504
  try:
5721
5505
  return _dispatch_request(request)
5722
5506
  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)))
5507
+ cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
5508
+ if k in request}
5509
+ if op == "execute":
5510
+ cause["code"] = str(request.get("code", ""))[:160]
5511
+ if _LOADS["count"] != before_loads:
5512
+ same = bool(before_path) and os.path.normpath(bpy.data.filepath) == os.path.normpath(before_path)
5513
+ if before_dirty:
5514
+ _say("@@VOLTER-WARN %s now holds %s in place of %s, whose unsaved changes are lost (%s)" % (
5515
+ op, bpy.data.filepath or "a new, unsaved file", before_path or "an unsaved file",
5516
+ json.dumps(cause)))
5517
+ if same:
5518
+ # (the same file reloaded, rebuilt perhaps: its objects are new, so an object it no longer holds is
5519
+ # known by its name)
5520
+ gone = sorted(set(before.values()) - {o.name for o in bpy.data.objects})
5521
+ if gone:
5522
+ _say("@@VOLTER-WARN %s reloaded %s without objects %s (%s); %d remain" % (
5523
+ op, before_path, gone, json.dumps(cause), len(bpy.data.objects)))
5524
+ else:
5525
+ remaining = {o.session_uid for o in bpy.data.objects}
5526
+ removed = [name for uid, name in before.items() if uid not in remaining]
5527
+ if removed:
5528
+ _say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
5529
+ op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
5732
5530
 
5733
5531
 
5734
5532
  def _dispatch_request(request):
@@ -5822,10 +5620,119 @@ def _native_preview(request):
5822
5620
  bpy.data.scenes.remove(preview, do_unlink=True)
5823
5621
 
5824
5622
 
5623
+ # WHERE AN EXPORT IS WRITTEN in the module filesystem, before the tab carries it to the project:
5624
+ # one export's files at a time, outside the project's tree, so the MCP mirror never lists them.
5625
+ _GLTF_EXPORT_DIR = "/tmp/volter-gltf-export"
5626
+
5627
+
5628
+ def _gltf_export(request):
5629
+ """A COMPONENT OR A LEVEL BY BLENDER'S OWN glTF EXPORTER (`gltf_export.py`, RFC 0007).
5630
+
5631
+ The document as it is, written as `<name>.glb` and its manifest `<name>.json` into
5632
+ `_GLTF_EXPORT_DIR`, which the tab reads and writes into the project's
5633
+ `.volter/export/components/` or `.volter/export/levels/`. The export puts back everything it
5634
+ sets up (its temporary markers and collection, the active collection, an instancer's
5635
+ realization, the frame) before it answers, so, like `native-preview`, no history checkpoint,
5636
+ save, frame export or revision bump is owed. `previous` is the project's earlier manifest and
5637
+ its file's SHA-256: when the `.blend`, the exporter and the settings still match, nothing is
5638
+ exported and the answer is `current`. `blendSha256` is the document's SHA-256 as the tab reads
5639
+ it from the project: the module filesystem keeps no copy of a document it streamed in.
5640
+ """
5641
+ kind = request.get("kind")
5642
+ if kind not in ("component", "level"):
5643
+ raise ValueError("A glTF export is a component or a level")
5644
+ if SESSION.document is None or SESSION._foreign:
5645
+ raise RuntimeError("Blender holds no project document to export")
5646
+ if ROOT not in sys.path:
5647
+ sys.path.insert(0, ROOT)
5648
+ import gltf_export
5649
+ name = os.path.splitext(os.path.basename(SESSION.document))[0]
5650
+ os.makedirs(_GLTF_EXPORT_DIR, exist_ok=True)
5651
+ for stale in os.listdir(_GLTF_EXPORT_DIR):
5652
+ os.unlink(os.path.join(_GLTF_EXPORT_DIR, stale))
5653
+ out = os.path.join(_GLTF_EXPORT_DIR, name + ".glb")
5654
+ previous = request.get("previous")
5655
+ blend_sha = request.get("blendSha256")
5656
+ # The display LUTs the project holds intact (`.volter/export/display/<key>.cube`), each with its
5657
+ # recorded SHA-256: one is baked only when the scene's view settings have none.
5658
+ luts = request.get("luts")
5659
+ luts = ({str(k): str(v) for k, v in luts.items()} if isinstance(luts, dict)
5660
+ else [str(k) for k in luts] if isinstance(luts, list) else [])
5661
+ previous = previous if isinstance(previous, dict) else None
5662
+ blend_sha = blend_sha if isinstance(blend_sha, str) and blend_sha else None
5663
+ answer = None
5664
+ # Check whether the existing export is current before retaining document state. Unchanged
5665
+ # exports touch nothing; changed exports use gltf_export.export, which restores its temporary
5666
+ # changes.
5667
+ if not request.get("force") and previous is not None and blend_sha is not None:
5668
+ started = time.perf_counter()
5669
+ settings = dict(gltf_export.COMPONENT_SETTINGS if kind == "component" else gltf_export.LEVEL_SETTINGS)
5670
+ current = gltf_export._current(out, blend_sha, gltf_export.exporter_version(), settings, previous)
5671
+ if current is not None:
5672
+ answer = gltf_export._current_answer(out, current, started, luts)
5673
+ if answer is None:
5674
+ answer = gltf_export.export(kind, out, SESSION.project, force=bool(request.get("force")),
5675
+ previous=previous, blend_sha=blend_sha, luts=luts)
5676
+ # The file, what travels beside it (a newly baked display LUT), then its manifest, which names
5677
+ # them; a current export may still bring the LUT it names when the project lost it.
5678
+ sidecars = answer["sidecars"]
5679
+ files = list(sidecars) if answer["current"] else [out, *sidecars, os.path.splitext(out)[0] + ".json"]
5680
+ # What this Blender would write for either kind, so a caller can tell whether another file's
5681
+ # earlier export (a level's components) still stands without opening it.
5682
+ return {"kind": kind, "name": name, "document": SESSION.document_relative, "current": answer["current"],
5683
+ "ms": answer["ms"], "steps": answer.get("steps", {}), "manifest": answer["manifest"], "files": files,
5684
+ "exporter": gltf_export.exporter_version(),
5685
+ "settings": {"component": dict(gltf_export.COMPONENT_SETTINGS), "level": dict(gltf_export.LEVEL_SETTINGS)}}
5686
+
5687
+
5688
+ def _run_script(request):
5689
+ """A SCRIPT RUN AS `blender -b <document> -P <script> -- <args>` RUNS ONE, in the document Blender holds.
5690
+
5691
+ Its `sys.argv` ends in `--` and `args`, as Blender's own does, and its working directory is the run's own `dir`
5692
+ (new for each run, outside the project's tree): a file it writes by a relative path is named in the answer's
5693
+ `files` by that path, and the worker carries it out. It runs as `execute` runs a script (`output` its printing,
5694
+ `error` its raise, `SystemExit` included). This path adds no history step, save or present;
5695
+ scripts restore any temporary document changes themselves. The runner restores argv and cwd.
5696
+ """
5697
+ if SESSION.document is None or SESSION._foreign:
5698
+ raise RuntimeError("Blender holds no project document to run a script in")
5699
+ root = request["dir"]
5700
+ os.makedirs(root)
5701
+ argv, cwd = sys.argv, os.getcwd()
5702
+ sys.argv = [argv[0], "--", *[str(arg) for arg in request["args"]]]
5703
+ os.chdir(root)
5704
+ try:
5705
+ ran = execute(request["source"])
5706
+ finally:
5707
+ sys.argv = argv
5708
+ os.chdir(cwd)
5709
+ files = sorted(os.path.relpath(os.path.join(folder, name), root).replace(os.sep, "/")
5710
+ for folder, _, names in os.walk(root) for name in names)
5711
+ return {"output": ran["result"], "error": ran["error"], "files": files}
5712
+
5713
+
5714
+ def _asset_library(request):
5715
+ """AN ASSET LIBRARY IN BLENDER'S PREFERENCES (Preferences > File Paths > Asset Libraries): `name`, at the
5716
+ project's folder `directory`, importing its assets by `importMethod` (`LINK`, `APPEND`, ...). One of that name
5717
+ already there is set again. A preference, not a document edit: nothing is owed."""
5718
+ library = request["library"]
5719
+ path = os.path.join(SESSION.project, library["directory"])
5720
+ libraries = bpy.context.preferences.filepaths.asset_libraries
5721
+ held = libraries.get(library["name"]) or libraries.new(name=library["name"], directory=path)
5722
+ held.path = path
5723
+ held.import_method = library["importMethod"]
5724
+
5725
+
5825
5726
  def _dispatch(request):
5826
5727
  op = request.get("op")
5827
5728
  if op == "native-preview":
5828
5729
  return _native_preview(request)
5730
+ if op == "gltf-export":
5731
+ return _gltf_export(request)
5732
+ if op == "run-script":
5733
+ return _run_script(request)
5734
+ if op == "asset-library":
5735
+ return _asset_library(request)
5829
5736
  if op == "document-info":
5830
5737
  return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
5831
5738
  if op == "execute":
@@ -5854,15 +5761,7 @@ def _dispatch(request):
5854
5761
  try:
5855
5762
  SESSION.present()
5856
5763
  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.
5764
+ # Escalate present failures because the displayed document still holds its preceding state.
5866
5765
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5867
5766
  "showing the state before it: " + repr(thrown))
5868
5767
  return answer
@@ -5886,12 +5785,17 @@ def _dispatch(request):
5886
5785
  return rna_node_tree(request.get("path"), request.get("material"))
5887
5786
  if op == "uv-layout":
5888
5787
  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.
5788
+ # The Timeline clip read neither presents nor creates a derivation.
5789
+ # Keying, moving a key and setting a range remain edits.
5892
5790
  if op == "action-clip":
5893
5791
  return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"),
5894
5792
  request.get("summary", True))
5793
+ # The movie is a read.
5794
+ if op == "scene-movie":
5795
+ return rna_scene_movie()
5796
+ # The scene's sound for the page to mix: a read; speakers move the frame and restore it.
5797
+ if op == "sound-scene":
5798
+ return rna_sound_scene(int(request["first"]), int(request["last"]))
5895
5799
  if op == "outliner":
5896
5800
  return rna_outliner(request.get("selected"))
5897
5801
  if op == "outliner-set":
@@ -5903,15 +5807,7 @@ def _dispatch(request):
5903
5807
  try:
5904
5808
  SESSION.present()
5905
5809
  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.
5810
+ # Escalate present failures because the displayed document still holds its preceding state.
5915
5811
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5916
5812
  "showing the state before it: " + repr(thrown))
5917
5813
  return answer
@@ -5926,20 +5822,16 @@ def _dispatch(request):
5926
5822
  try:
5927
5823
  SESSION.present()
5928
5824
  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.
5825
+ # Escalate present failures because the displayed document still holds its preceding state.
5938
5826
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5939
5827
  "showing the state before it: " + repr(thrown))
5940
5828
  return answer
5941
5829
  if op == "save-document":
5942
5830
  return SESSION.save_document()
5831
+ if op == "reopen-document":
5832
+ # The document's file in the engine's filesystem was just rewritten (a revert, a
5833
+ # backup); open it again as the model.
5834
+ return SESSION.bind_document(SESSION.document_relative)
5943
5835
  if op == "start":
5944
5836
  SESSION.project = request.get("project", SESSION.project)
5945
5837
  try:
@@ -5958,25 +5850,14 @@ def _dispatch(request):
5958
5850
 
5959
5851
  # ---------------------------------------------------------------- the loop
5960
5852
 
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
5853
  def _channel_loop():
5973
5854
  """Serve the directory channel until the program ends.
5974
5855
 
5975
5856
  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
5857
+ `try` is the CHANNEL itself. An exception in it that fell off the end of
5858
+ this script would let Blender finish `--python` and exit 0 through its
5859
+ ordinary path, so the page would be told "Blender exited 0" and nothing
5860
+ anywhere would say why: from outside, a channel death is
5980
5861
  indistinguishable from a clean shutdown, and the traceback goes to stderr,
5981
5862
  which is page output rather than a console condition. The caller below
5982
5863
  makes it one.
@@ -5988,12 +5869,11 @@ def _channel_loop():
5988
5869
  unacked = []
5989
5870
  active = 0.0
5990
5871
  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
5872
+ # A FAILED LISTING IS NOT AN EMPTY ONE. Read as "no requests", the one
5873
+ # failure that matters -- the process out of file descriptors -- would
5874
+ # turn the session DEAF: looping at full speed, answering nothing ever
5875
+ # again, while the page waits on a call with a live program and no
5876
+ # error anywhere. The directory is the page's and is made before
5997
5877
  # the program starts, so its absence is the only benign reading;
5998
5878
  # anything else is named and ends the loop through the guard below.
5999
5879
  try:
@@ -6001,12 +5881,9 @@ def _channel_loop():
6001
5881
  if n.endswith(".done") and n[: -len(".done")].isdigit())
6002
5882
  except FileNotFoundError:
6003
5883
  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).
5884
+ # The page removes in/<id>.done after reading out/<id>.json.
5885
+ # An absent request marker acknowledges the guest-owned reply files,
5886
+ # which the guest can then remove.
6010
5887
  if unacked:
6011
5888
  still_owed = []
6012
5889
  for rid in unacked:
@@ -6044,12 +5921,46 @@ def _channel_loop():
6044
5921
  active = time.monotonic()
6045
5922
 
6046
5923
 
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
5924
+ # THE ADD-ONS THIS BLENDER SHIPS beside Blender's own: each a package the engine writes under `_ADDONS` before
5925
+ # Blender starts (`session-addons.mts`), enabled for the whole session, whatever file it opens, as Preferences >
5926
+ # Add-ons enables an installed one.
5927
+ _ADDONS = os.path.join(ROOT, "addons")
5928
+
5929
+
5930
+ def _enable_addons():
5931
+ import addon_utils
5932
+ sys.path.insert(0, _ADDONS)
5933
+ for name in sorted(os.listdir(_ADDONS)):
5934
+ failed = []
5935
+ addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
5936
+ if failed:
5937
+ _say("@@VOLTER-WARN the %s add-on did not enable: %s: %s" % (name, type(failed[0]).__name__, failed[0]))
5938
+
5939
+
5940
+ def main():
5941
+ """Start the worker channel; importing this module only exposes its RNA readers."""
5942
+ global ENGINES, UNAVAILABLE_ENGINES
5943
+ if _blender_web is None:
5944
+ raise RuntimeError("The session worker needs Blender with the _blender_web native door")
5945
+ _prepare_directories()
5946
+ bpy.app.handlers.load_post.append(_load_post)
5947
+ bpy.app.handlers.undo_post.append(_history_post)
5948
+ bpy.app.handlers.redo_post.append(_history_post)
5949
+ ENGINES, UNAVAILABLE_ENGINES = _register_engine()
5950
+ _enable_addons()
5951
+ _say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
5952
+ "unavailableEngines": UNAVAILABLE_ENGINES}))
5953
+
5954
+ try:
5955
+ _channel_loop()
5956
+ except BaseException as _thrown: # noqa: BLE001
5957
+ # The one place a channel death can still be named. Without this the
5958
+ # program ends 0 and the page reports only that Blender is gone.
5959
+ _say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
5960
+ "every outstanding call will go unanswered. %s"
5961
+ % (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
5962
+ raise
5963
+
5964
+
5965
+ if __name__ == "__main__":
5966
+ main()