@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,47 +1,4 @@
1
- """OUR BLENDER RENDERS WITH THREE.JS -- the ask, shipped INSIDE the Blender.
2
-
3
- THE RENDER OVERRIDE IS NOT THE EDITOR'S. `session.py` is the editor tab's
4
- long-lived session and carries this same override for the session it owns;
5
- this file is the other delivery of it -- a Blender STARTUP module that ships in
6
- the pack's resource tree (`scripts/startup/volter_three.py`), so a bare
7
- `blender -b ... -a` on ANY surface renders with three.js too. Blender imports
8
- every module in `scripts/startup` and calls its `register()`.
9
-
10
- THE ASK SHIPS WITH THE BLENDER; THE ANSWER DOES NOT. What renders is a
11
- PRESENTER the host attached to this process (`browser/three/attach-presenter.ts`
12
- around `browser/three/presenter.ts`): a canvas, the frame translated to
13
- three.js, a camera, the display transform. This file only asks. The channel is
14
- the directory protocol `session.py`'s header states, with one writer per
15
- directory:
16
-
17
- <root>/ask/ THIS PROGRAM writes and unlinks; the host only reads.
18
- <root>/reply/ the HOST writes and unlinks; this program only reads.
19
- <root>/arena.bin THIS PROGRAM writes (the export door does); host reads.
20
-
21
- NO PRESENTER, NO OVERRIDE. `register()` asks `{"op": "hello"}` and waits two
22
- seconds. Answered, it takes the engine ids and every render is a photograph.
23
- Unanswered, it prints ONE line naming the root it waited at and registers
24
- nothing at all, so Blender renders with Cycles -- correct, slow, and never
25
- silent.
26
-
27
- THE ENGINE IS TAKEN ON `load_post`, NOT IN `register()`, and that is a
28
- measurement. Blender's `bpy.utils.load_scripts` registers every startup module
29
- BEFORE it enables the add-ons, so at `register()` time `cycles.CyclesRender`
30
- does not exist yet and taking the `CYCLES` id here would be overwritten by the
31
- add-on moments later. Measured on the native 5.2.0 LTS oracle
32
- (`blender -b --python-expr ...` with this module's probe in
33
- `BLENDER_USER_SCRIPTS`): at `register()` `CYCLES` is unregistered, and the
34
- FIRST `load_post` -- which Blender fires for the startup file itself, before
35
- any `--python-expr` runs -- sees `<class 'cycles.CyclesRender'>`. So the
36
- handler is where the ids are taken, and it runs again after every file load
37
- because a load re-enables the add-ons and hands `CYCLES` back.
38
-
39
- WHAT THIS FILE DOES NOT CARRY, deliberately: the editor session's incremental
40
- geometry record (every frame ships in full -- `known` is always empty, so no
41
- presenter can ever be asked about geometry it does not hold), its document,
42
- its overlays, and its world SHADER reducer. A world here is a CONSTANT
43
- background or nothing, named as a warning; see `_world`.
44
- """
1
+ """Standalone Blender startup render override. Bare background Blender processes use the presenter’s ask/reply directory protocol, while session.py owns the editor session’s equivalent engine. The package carries protocol and data conversion without importing editor state."""
45
2
 
46
3
  import base64
47
4
  import json
@@ -65,19 +22,7 @@ REPLY = os.path.join(ROOT, "reply")
65
22
  EXPORT_BUFFER_PATH = os.environ.get(
66
23
  "VOLTER_EXPORT_BUFFER_PATH", os.path.join(ROOT, "arena.bin")
67
24
  )
68
- # THE FRAME'S IDENTITY IS A PAIR, `(session, revision)`, and the presenter
69
- # refuses a revision that went backwards under a name it already holds. One
70
- # process is one session; the revision only ever counts up within it.
71
- #
72
- # THE PID IS NOT THE NAME, and that is measured rather than cautious. A
73
- # presenter outlives the Blenders it serves, so two processes' names must
74
- # differ -- and in the browser the guest's pids START OVER: the second
75
- # `blender -b ... -a` on one page came up as the same pid, its revision
76
- # restarted at 1 under a name the view already held at 24, and every render in
77
- # it died on "The runtime frame is older than the displayed model" (measured
78
- # 2026-09-20 in the substrate tab; the first run on a freshly loaded page
79
- # always worked, which is what hid it). The start time is what makes the name
80
- # this process's own.
25
+ # Identify frames by process session and monotonic revision. Include process start time because guest pids can repeat while a presenter remains alive.
81
26
  SESSION = "volter-three:%d-%d" % (os.getpid(), time.time_ns())
82
27
  HELLO_TIMEOUT_SECONDS = 2.0
83
28
  POLL_SECONDS = 0.002
@@ -87,25 +32,7 @@ _REVISION = [0]
87
32
  _WARNED = set()
88
33
  _WARNINGS = []
89
34
  def _say(line):
90
- """One line on Blender's own stderr, resolved at CALL TIME.
91
-
92
- WHERE THIS LINE COMES OUT, and it is not where a reader looks first.
93
- MEASURED 2026-09-20 in the substrate tab with the whole command redirected
94
- (`blender ... > log 2>&1`): Python's `sys.stderr` in this build is NOT the
95
- process's fd 2. `print(..., file=sys.stderr)` and
96
- `sys.stderr.buffer.write(...)` BOTH reached the terminal and NEITHER
97
- reached the file, while `print()` to stdout reached the file and not the
98
- terminal. So the one line this module ever prints -- the one that says
99
- Cycles is about to render -- is read on the TERMINAL; a reader who greps
100
- only a redirect will conclude the fallback is silent when it is not. That
101
- reading cost an hour and a wrong diagnosis; it is written here so it costs
102
- nobody else one.
103
-
104
- The stream is taken at call time rather than captured at import because a
105
- startup module is imported while Blender is still standing its streams up
106
- and nothing here needs the captured one. Both spellings were measured to
107
- work; this is the plainer.
108
- """
35
+ """Write to Blender’s current stderr at call time, after the host has attached its streams."""
109
36
  print(line, file=sys.stderr or sys.stdout, flush=True)
110
37
 
111
38
 
@@ -250,8 +177,6 @@ def _export(depsgraph):
250
177
 
251
178
 
252
179
  def _present(depsgraph, capture):
253
- if not hasattr(_blender_web, "begin_render_export"):
254
- raise RuntimeError("This Blender artifact has no render-evaluated export")
255
180
  _blender_web.begin_render_export(depsgraph)
256
181
  try:
257
182
  answer = ask({"frame": _export(depsgraph), "capture": {**capture, "evaluation": "render"}})
@@ -317,6 +242,8 @@ def _photograph(depsgraph, width, height, linear=False):
317
242
  "AgX": "agx",
318
243
  "Filmic": "filmic",
319
244
  "Khronos PBR Neutral": "neutral",
245
+ "ACES 1.3": "aces13",
246
+ "ACES 2.0": "aces20",
320
247
  }.get(view.view_transform)
321
248
  if transform is None:
322
249
  raise RuntimeError(
@@ -349,20 +276,9 @@ def _photograph(depsgraph, width, height, linear=False):
349
276
 
350
277
 
351
278
  def _assert_photographed_from(reported, position, target, up):
352
- """The photograph answers with the pose it ACTUALLY used, back in Blender's
353
- frame, and it must be the pose that was sent -- exactly, with no tolerance.
354
-
355
- This is an instrument, not a nicety. A render's image is allowed to differ
356
- from Cycles (three.js is the renderer), so a camera placed in the wrong
357
- frame produces a picture nobody can call wrong: the lane shipped for its
358
- whole life photographing the model's underside from below the floor because
359
- Blender's Z-up numbers were read as three.js world space.
360
-
361
- The check is exact because the conversion is exact: the model root's world
362
- matrix is a signed axis permutation, so a vector through it and its inverse
363
- is bit-identical. A mismatch is the conversion being wrong, never the check
364
- being too strict -- do not widen it.
365
- """
279
+ """Require the photograph’s camera pose transformed back into Blender’s frame to equal the sent
280
+ pose exactly. The model root uses a signed axis permutation, so its transform and inverse
281
+ preserve values without a floating-point tolerance."""
366
282
  sent = {"position": position, "target": target, "up": up}
367
283
  if not isinstance(reported, dict):
368
284
  raise RuntimeError(
@@ -417,19 +333,7 @@ class VolterRenderEngine(bpy.types.RenderEngine):
417
333
 
418
334
 
419
335
  def _fill_result(engine, result, answer, png, width, height):
420
- """THE RENDER RESULT HOLDS SCENE-LINEAR PIXELS, and Blender runs the scene's
421
- view transform over them wherever it shows or saves one (`write_still`, the
422
- Image Editor), as it does over Cycles'. The photograph's PNG is the display
423
- image -- the transform already ran -- so loading it put every render
424
- through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
425
- render view: a background 53 against the viewport's 59, a shadow's blue 2
426
- against 7. The same capture's linear frame goes in instead: half floats,
427
- RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
428
- 0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
429
- path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
430
- as the PNG path did.
431
-
432
- A renderer that sends no linear frame gets the old path, and says so."""
336
+ """Load scene-linear capture pixels into RenderResult so Blender applies the view transform once. A presenter without linear pixels uses the declared PNG path."""
433
337
  linear = answer.get("linearBase64") if isinstance(answer, dict) else None
434
338
  if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
435
339
  import numpy
package/browser/worker.ts CHANGED
@@ -2,47 +2,11 @@ import { PullJob, checkpointStream } from './pull-job.mts';
2
2
  import { sendFrameValue } from './frame-stream.mts';
3
3
  import { describeThrown } from './describe-thrown.mts';
4
4
  export { describeThrown } from './describe-thrown.mts';
5
- /**
6
- * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
7
- * 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
8
- * WebAssembly, running headless in this worker (`blender-engine.mts`), with
9
- * `session.py` as its one door and three.js as its renderer. The artifact is
10
- * served by the editor (`/__editor/blender-wasm/*`, `blender-wasm-artifact.ts`);
11
- * when it is not served, `start` refuses BY NAME. There is no other engine.
12
- *
13
- * NOTHING HERE KNOWS WHICH SKEW ANSWERED. Since the 2026-09-18 amendment there
14
- * are two builds -- the standalone Emscripten module and Blender as a WALI
15
- * program on browser-substrate -- and `blender-engine.mts` is where they meet.
16
- * Above that seam this file uses `engine.files`, `engine.readArena()` and
17
- * `engine.memoryBytes()` and could not tell them apart; ONE GATE, BOTH SKEWS
18
- * is what that buys.
19
- *
20
- * Nothing here touches a server beyond the editor's own routes. A frame
21
- * leaves through `present` to the Model document; a photograph comes back the
22
- * same way. The project's own files are staged into the engine's filesystem
23
- * before every call, and what the session writes is mirrored back out by the
24
- * transport (`list-files`/`read-file`).
25
- *
26
- * THE DOCUMENT'S SAVE BARRIER LIVES HERE. Python's loop cannot ask the tab for anything
27
- * while it is idle — `serveAsks` only runs inside a request's poll loop, so an
28
- * `ask` raised between calls is never answered and wedges the Blender pthread.
29
- * So the session marks a present `saveDue`, this file finishes the command,
30
- * calls `save-document` as an ordinary request (which Python's loop
31
- * picks up BETWEEN calls, never mid-call), and carries the bytes to the
32
- * project through `/__editor/blender-document` BEFORE acknowledging the edit —
33
- * as a delta: the chunks the server does not hold (`document-chunks.mts`).
34
- *
35
- * WHY THE CARRY IS NOT THE MIRROR'S JOB (`volter blender-mcp`, class Mirror):
36
- * the Mirror is pull-based and runs only after an `execute_blender_code`, so
37
- * a document saved after the LAST call of a modeling session
38
- * would never leave the worker — which is exactly the state this closes
39
- * ("closing the tab loses the model"). The Mirror still lists and mirrors the
40
- * same file in the MCP lane; it just is not what persistence depends on.
41
- */
5
+
42
6
  /// <reference types="vite/client" />
43
7
 
44
8
  import { startupOperation, type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
45
- import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
9
+ import type { CaptureRequest, FileEntry, ScriptRunAnswer, WorkerReply, WorkerRequest } from './protocol';
46
10
  import { documentChunks } from './document-chunks.mts';
47
11
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
48
12
 
@@ -50,8 +14,6 @@ const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(rep
50
14
  const log = (level: 'log' | 'warn' | 'error', text: string) => post({ op: 'log', level, text });
51
15
 
52
16
  interface Session {
53
- start(project: string): Promise<unknown>;
54
- execute(code: string): Promise<unknown>;
55
17
  sceneInfo(): Promise<unknown>;
56
18
  objectInfo(name: string): Promise<unknown>;
57
19
  screenshotView(maxSize: number): Promise<unknown>;
@@ -100,6 +62,8 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
100
62
  // no host path is ever on the wire.
101
63
  let documentPath: string | null = null;
102
64
  let documentDirty = false;
65
+ /** The revision the last reported change was. */
66
+ let dirtyRevision: number | null = null;
103
67
  // Commands and saves share one lane. In particular a flush cannot overtake
104
68
  // an accepted edit, and a later edit cannot race an upload of older bytes.
105
69
  let workTail: Promise<unknown> = Promise.resolve();
@@ -180,26 +144,14 @@ function setDocumentDirty(dirty: boolean): void {
180
144
  }
181
145
 
182
146
  /**
183
- * Save the document and land it in the project.
184
- *
185
- * Only called inside the command lane, after previous calls have finished.
186
- * Failure is a rejection: explicit shutdown must retain the live model.
147
+ * The document saved into the engine's own filesystem, and its bytes read back out: what a save
148
+ * carries to the project and a backup keeps. `null` when Blender holds another file's model, so
149
+ * the session wrote nothing over the document (said, where the person looks).
187
150
  */
188
- async function saveDocument(): Promise<void> {
189
- if (!engine || documentPath === null) return;
190
- const relative = documentPath;
191
- // Each phase's milliseconds, reported with the save: on a large document the save is the
192
- // edit's cost, and where it goes differs by host (a tab-served project runs the server here).
193
- const ms: Record<string, number> = {};
194
- let mark = performance.now();
195
- const lap = (phase: string) => {
196
- const now = performance.now();
197
- ms[phase] = Math.round(now - mark);
198
- mark = now;
199
- };
151
+ async function engineDocument(relative: string, lap: (phase: string) => void): Promise<{ readonly path: string; readonly bytes: Uint8Array } | null> {
200
152
  let answer: { saved?: boolean; path?: string; size?: number; reason?: string; holds?: string };
201
153
  try {
202
- answer = (await engine.request({ op: 'save-document' })) as typeof answer;
154
+ answer = (await engine!.request({ op: 'save-document' })) as typeof answer;
203
155
  lap('save');
204
156
  } catch (error) {
205
157
  // A document that cannot be written is the session's work at risk, so it
@@ -210,25 +162,53 @@ async function saveDocument(): Promise<void> {
210
162
  // the document. Said each time an edit goes unsaved, where the person looks: the document on
211
163
  // disk is the last one saved, and what they see now is not being kept.
212
164
  if (answer?.saved === false && answer.reason === 'not-the-document') {
213
- setDocumentDirty(false);
214
- log('warn', `@@VOLTER-WARN ${relative} is not being saved: Blender holds ${answer.holds ?? 'another file'}, ` +
215
- `not its model. Save As a .blend under src/models to keep this as a document of its own, or open ${relative} again to return to it.`);
216
- return;
165
+ const note = `${relative} is not being saved: Blender holds ${answer.holds ?? 'another file'}, ` +
166
+ `not its model. Save As a .blend under src/models to keep this as a document of its own, or reopen ${relative} to return to it.`;
167
+ post({ op: 'document-saving', document: relative, note });
168
+ log('warn', `@@VOLTER-WARN ${note}`);
169
+ return null;
217
170
  }
218
171
  if (!answer?.saved || typeof answer.path !== 'string')
219
172
  throw new Error(`Blender did not save the document ${relative}`);
220
- let bytes: Uint8Array;
221
173
  try {
222
- bytes = await engine.files.readFile(answer.path);
174
+ const bytes = await engine!.files.readFile(answer.path);
223
175
  lap('read');
176
+ return { path: answer.path, bytes };
224
177
  } catch (error) {
225
178
  throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
226
179
  }
180
+ }
181
+
182
+ /**
183
+ * Save the document and land it in the project: true once the project holds it, false when there was nothing to
184
+ * save to (no engine or document, or Blender holds another file's model).
185
+ *
186
+ * Only called inside the command lane, after previous calls have finished.
187
+ * Failure is a rejection: explicit shutdown must retain the live model.
188
+ */
189
+ async function saveDocument(): Promise<boolean> {
190
+ if (!engine || documentPath === null) return false;
191
+ const relative = documentPath;
192
+ // Each phase's milliseconds, reported with the save: on a large document the save is the
193
+ // edit's cost, and where it goes differs by host (a tab-served project runs the server here).
194
+ const ms: Record<string, number> = {};
195
+ let mark = performance.now();
196
+ const lap = (phase: string) => {
197
+ const now = performance.now();
198
+ ms[phase] = Math.round(now - mark);
199
+ mark = now;
200
+ };
201
+ const saved = await engineDocument(relative, lap);
202
+ if (saved === null) {
203
+ setDocumentDirty(false);
204
+ return false;
205
+ }
206
+ const { bytes } = saved;
227
207
  // The engine's copy is now the newer one, so the stager must stop treating
228
208
  // this path as the host's: an entry left in `staged` would make the next
229
209
  // call re-fetch the whole document over the session's own save, and would
230
- // hide it from `list-files` (which lists only what the SESSION owns).
231
- staged.delete(answer.path);
210
+ // hide it from `list-files-snapshot` (which lists only what the SESSION owns).
211
+ staged.delete(saved.path);
232
212
  let sent = 0;
233
213
  try {
234
214
  const chunks = await documentChunks(bytes);
@@ -270,7 +250,35 @@ async function saveDocument(): Promise<void> {
270
250
  throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
271
251
  }
272
252
  setDocumentDirty(false);
253
+ post({ op: 'document-saving', document: relative, note: null });
273
254
  log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent, ms })}`);
255
+ return true;
256
+ }
257
+
258
+ /**
259
+ * REOPEN THE DOCUMENT from the project's file, or as `backup` (its unsaved state at a restart).
260
+ * The project's copy is staged in as it is at start, so the stager keeps its host stamp and does
261
+ * not fetch the file again once Blender has read and removed it (`session.py::bind_document`); a
262
+ * backup is then written over it. Blender presents what it opened.
263
+ */
264
+ async function reopenDocument(backup?: Uint8Array): Promise<void> {
265
+ if (!engine || !projectRoot || documentPath === null) throw new Error('The Blender session holds no document to reopen');
266
+ const path = `${projectRoot}/${documentPath}`;
267
+ staged.delete(path);
268
+ try {
269
+ await engine.files.unlink(path);
270
+ } catch {
271
+ /* the open already removed it */
272
+ }
273
+ await stageProjectFiles(engine.files, projectRoot);
274
+ const host = staged.get(path)?.host;
275
+ if (host === undefined) throw new Error(`${documentPath} could not be read from the project`);
276
+ if (backup) {
277
+ await engine.files.writeFile(path, backup);
278
+ staged.set(path, { host, engine: await stampOf(engine.files, path) });
279
+ }
280
+ await engine.request({ op: 'reopen-document' });
281
+ setDocumentDirty(backup !== undefined);
274
282
  }
275
283
 
276
284
  async function startBlender(project: string, document?: string): Promise<unknown> {
@@ -283,8 +291,15 @@ async function startBlender(project: string, document?: string): Promise<unknown
283
291
  ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
284
292
  if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
285
293
  if (!holder.engine) throw new Error('The Blender session presented before it started');
286
- // Save once after the whole command, never during a partial frame.
287
- if (saveDue && documentPath !== null) setDocumentDirty(true);
294
+ // A CHANGE IS A NEW REVISION: every present of an unsaved document carries `saveDue`, and
295
+ // reporting each one would tell the workbench's backup the content changed when it did not.
296
+ if (saveDue && documentPath !== null) {
297
+ const revision = (frame as { revision?: unknown } | null)?.revision;
298
+ if (!documentDirty || (typeof revision === 'number' && revision !== dirtyRevision)) {
299
+ if (typeof revision === 'number') dirtyRevision = revision;
300
+ setDocumentDirty(true);
301
+ }
302
+ }
288
303
  // THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
289
304
  // typed arrays the tab draws from, and the record of what was sent
290
305
  // (`describeFrame`). After the post the buffers are detached and the
@@ -357,10 +372,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
357
372
  holder.engine = started;
358
373
  await loadCheckpoint?.("engine-ready");
359
374
  engine = started;
360
- // THE PROJECT'S FILES BEFORE THE SESSION'S FIRST ACT, because that act may
361
- // be `open_mainfile` on the document — which lives on the host's disk and is
362
- // not in the engine's filesystem until it is staged. Every other call stages
363
- // on its way in (`execute`); start had nothing to open before it did.
375
+ // Stage the project’s files before the session starts, including the .blend opened by its first
376
+ // operation. Later execute calls stage their inputs on entry.
364
377
  if (document) {
365
378
  // The document itself not reaching the engine is a refusal, not a footnote: the open below
366
379
  // would fail on a missing file (or open a stale copy) without saying why.
@@ -376,18 +389,6 @@ async function startBlender(project: string, document?: string): Promise<unknown
376
389
  })) as Record<string, unknown>;
377
390
  if (document) documentPath = document;
378
391
  session = {
379
- start: async () => banner,
380
- execute: async (code: string) => {
381
- const answer = (await started.request({ op: 'execute', code })) as {
382
- executed: boolean;
383
- result: string;
384
- error?: string;
385
- };
386
- // The MCP door's own shape: a script's failure is TEXT, not a rejection.
387
- return answer.error
388
- ? `Error executing code: ${answer.error}`
389
- : `Code executed successfully: ${answer.result}`;
390
- },
391
392
  sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
392
393
  objectInfo: async (name: string) =>
393
394
  JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
@@ -399,12 +400,10 @@ async function startBlender(project: string, document?: string): Promise<unknown
399
400
  return {
400
401
  ...banner,
401
402
  engine: 'blender-wasm',
402
- // WHICH BUILD ANSWERED. One gate, both skews: a board that cannot say
403
- // which one it measured cannot call a difference a defect in either.
403
+ // Report the engine build that answered.
404
404
  skew: started.skew,
405
405
  bootMs: Math.round(started.bootMs),
406
- // What the boot handed back (`blender-engine.mts`). Null says this bundle
407
- // has no release door and is carrying the payload twice.
406
+ // Released packed-payload size; WALI has no packed payload and reports null.
408
407
  releasedPayloadMB:
409
408
  started.releasedPayloadBytes === null
410
409
  ? null
@@ -505,26 +504,7 @@ async function projectIndex(project: string): Promise<ProjectFile[] | string> {
505
504
  return files;
506
505
  }
507
506
 
508
- /**
509
- * The project files this session has staged IN, and the two stamps that say
510
- * so: `host`, what the host's index reported (so a re-stage can skip a file
511
- * that has not moved on disk), and `engine`, what the file looked like in the
512
- * engine's own filesystem the instant after it was written there.
513
- *
514
- * THE SECOND ONE IS WHY THIS IS A PAIR. Ownership is not a property of the
515
- * PATH, it is a property of the BYTES: a staged file Python never touched is
516
- * the host's, and the same path after `export_scene.gltf` has written over it
517
- * is the session's output and has to reach the project. Keyed on the path
518
- * alone, a re-bake of an artifact that already exists — which is what every
519
- * bake after the first one is — was listed by nobody and silently went
520
- * nowhere, while a first bake of a NEW path worked, so nothing about the door
521
- * looked broken (measured 2026-09-19 re-baking cinematic-story's sky city).
522
- *
523
- * `engine` is `size:mtimeMs`, which is what both skews' `stat` answers. A
524
- * rewrite that lands in the same millisecond at exactly the same length would
525
- * read as untouched; a bake is seconds long and this is the strongest signal
526
- * the filesystem offers.
527
- */
507
+ /** For each staged project file, retain the host stamp and its size:mtimeMs stamp immediately after writing into the engine. A changed engine stamp identifies output that must return to the project even when its path was staged before. */
528
508
  const staged = new Map<string, { host: string; engine: string }>();
529
509
 
530
510
  async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
@@ -532,16 +512,7 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
532
512
  return info ? `${info.size}:${info.mtimeMs}` : '';
533
513
  }
534
514
 
535
- /**
536
- * Copy the project's changed files into the engine, and answer what could NOT be copied.
537
- *
538
- * THE ANSWER IS THE POINT (2026-10-06 audit: files that failed to copy were "reported to the
539
- * console only"). `say` writes the page console once per line, which nobody running a script
540
- * reads; a script that then opens or loads the missing file fails, or worse succeeds on a
541
- * stale copy, with no word of why. So every call returns its failures, and the callers put
542
- * them where the caller of THAT is looking: `execute` appends them to the script's answer, and
543
- * `start` refuses when the document itself is among them.
544
- */
515
+ /** Stage changed project files and return copy failures. execute appends them to its answer; start refuses when its document failed to stage. */
545
516
  async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
546
517
  const files = await projectIndex(project);
547
518
  if (typeof files === 'string') return [files];
@@ -590,7 +561,8 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
590
561
  /** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
591
562
  type FollowedDocument =
592
563
  | { moved: true; from: string; document: string }
593
- | { moved: false; from: string; outside: string };
564
+ | { moved: false; from: string; outside: string }
565
+ | { moved: false; from: string; notSaving: true; note: string };
594
566
 
595
567
  /**
596
568
  * THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
@@ -600,9 +572,16 @@ type FollowedDocument =
600
572
  * whose runtime handle holds the same path. A file outside the project is refused: the editor
601
573
  * has no document there, so the session keeps its own and keeps saving to it, and says so
602
574
  * naming both files, since the script believes it is now working in the other one.
575
+ * A notSaving answer is a different state: the engine protects the bound file and writes nothing to it.
603
576
  */
604
577
  function followDocument(followed: FollowedDocument | undefined): string {
605
578
  if (!followed) return '';
579
+ if ('notSaving' in followed) {
580
+ post({ op: 'document-saving', document: followed.from,
581
+ note: `${followed.from} is not being saved because Blender holds another scene. Save this scene as a new .blend inside the project to keep it, or reopen the saved document to return to it.` });
582
+ log('warn', followed.note);
583
+ return `\n\n${followed.note}`;
584
+ }
606
585
  if (!followed.moved) {
607
586
  const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
608
587
  `follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
@@ -614,6 +593,7 @@ function followDocument(followed: FollowedDocument | undefined): string {
614
593
  // The session checks the same shape, so this is a broken promise, not a refusal to explain.
615
594
  throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
616
595
  }
596
+ post({ op: 'document-saving', document: followed.from, note: null });
617
597
  documentPath = followed.document;
618
598
  // Even a present that failed must not leave the new file only in the engine's filesystem.
619
599
  setDocumentDirty(true);
@@ -639,9 +619,9 @@ async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<
639
619
  const root = projectMount(hostRoot);
640
620
  const out: FileEntry[] = [];
641
621
  // A root the engine has no directory at holds no output yet, and that is an honest empty
642
- // listing. A directory that exists and cannot be read is NOT: until 2026-10-06 every readdir
643
- // failure was answered as "no files", which is how a Windows root the engine never had (the
644
- // host's `C:\…` instead of `/C:/…`) reported success while nothing reached disk.
622
+ // listing. A directory that exists and cannot be read is NOT: answering "no files" would let a
623
+ // root the engine never had (a Windows host's `C:\…` instead of `/C:/…`) report success while
624
+ // nothing reached disk.
645
625
  if ((await files.stat(root)) === null) return out;
646
626
  const walk = async (dir: string): Promise<void> => {
647
627
  let names: string[];
@@ -712,8 +692,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
712
692
  // A flush lands what is pending; a document nothing changed stays the
713
693
  // file it was opened from.
714
694
  if (!documentDirty) return { saved: false };
715
- await saveDocument();
716
- return { saved: true };
695
+ return { saved: await saveDocument() };
696
+ case 'snapshot-document': {
697
+ if (documentPath === null) throw new Error('The Blender session holds no document');
698
+ const snapshot = await engineDocument(documentPath, () => {});
699
+ if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
700
+ return snapshot.bytes;
701
+ }
702
+ case 'revert-document':
703
+ await reopenDocument();
704
+ return undefined;
705
+ case 'restore-document':
706
+ await reopenDocument(request.bytes);
707
+ return undefined;
717
708
  case 'history-begin':
718
709
  case 'history-end':
719
710
  return ask({ op: request.op });
@@ -756,12 +747,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
756
747
  ...(request.names === undefined ? {} : { names: request.names }),
757
748
  });
758
749
  case 'rna-context':
759
- // EVERY FIELD IS NAMED HERE, and the omission is silent: this switch
760
- // REBUILDS the request rather than forwarding it, so a field added at
761
- // `runtime.ts` and read in `session.py` still arrives as `None` and the
762
- // door answers about something else entirely. Measured 2026-09-19 (I4
763
- // follow-up (a)): `collection` crossed both ends and never the middle,
764
- // and the Collection tab simply did not stand.
750
+ // Forward each request field explicitly to the Python session.
765
751
  return ask({
766
752
  op: 'rna-context',
767
753
  ...(request.object === undefined ? {} : { object: request.object }),
@@ -801,10 +787,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
801
787
  ...(request.object === undefined ? {} : { object: request.object }),
802
788
  ...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
803
789
  });
804
- // THE RIG AND CLIP DOORS, every field named for the same reason — and the
805
- // reason is a measured defect: I4's Collection tab simply did not stand
806
- // because `collection` crossed `runtime.ts` and `session.py` and never
807
- // this switch, with no error anywhere.
790
+ // Forward the clip request fields to the Python session.
808
791
  case 'action-clip':
809
792
  return ask({
810
793
  op: 'action-clip',
@@ -813,6 +796,30 @@ async function handle(request: WorkerRequest): Promise<unknown> {
813
796
  ...(request.action === undefined ? {} : { action: request.action }),
814
797
  ...(request.summary === undefined ? {} : { summary: request.summary }),
815
798
  });
799
+ case 'scene-movie':
800
+ return ask({ op: 'scene-movie' });
801
+ case 'sound-scene':
802
+ return ask({ op: 'sound-scene', first: request.first, last: request.last });
803
+ case 'gltf-export':
804
+ return ask({ op: 'gltf-export', kind: request.kind, force: request.force === true,
805
+ ...(request.previous ? { previous: request.previous } : {}),
806
+ ...(request.blendSha256 ? { blendSha256: request.blendSha256 } : {}), ...(request.luts ? { luts: request.luts } : {}) });
807
+ // A script reads the project's files as `execute`'s does, so they are copied in first; what could not be is
808
+ // answered beside its run.
809
+ // (the files a run wrote are read here and taken away, so its answer carries them and no later run can touch them)
810
+ case 'run-script': {
811
+ const unstaged = await stageProjectFiles(files, projectRoot);
812
+ const ran = await ask({ op: 'run-script', source: request.source, args: request.args, dir: request.dir }) as
813
+ { output: string; error: string | null; files: string[] };
814
+ const written: { path: string; bytes: Uint8Array }[] = [];
815
+ for (const path of ran.files) {
816
+ written.push({ path, bytes: (await files.readFile(`${request.dir}/${path}`)).slice() });
817
+ await files.unlink(`${request.dir}/${path}`);
818
+ }
819
+ return { output: ran.output, error: ran.error, files: written, unstaged } satisfies ScriptRunAnswer;
820
+ }
821
+ case 'asset-library':
822
+ return ask({ op: 'asset-library', library: request.library });
816
823
  case 'outliner-set':
817
824
  return ask({
818
825
  op: 'outliner-set',
@@ -831,33 +838,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
831
838
  staged.delete(request.path);
832
839
  return undefined;
833
840
  }
834
- case 'list-files':
835
- return listSessionFiles(files, request.path);
836
841
  }
837
842
  throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
838
843
  }
839
844
 
840
- /**
841
- * Tell the tab how big the engine's memory is now.
842
- *
843
- * O(1) on the standalone skew — a read of the `HEAPU8` view's length — and
844
- * posted after every call because that is when it can have changed. It is the
845
- * engine's memory as opposed to the page's: the census already carries
846
- * `heapUsedMB` (the PAGE's JS heap) and had no field at all for the wasm,
847
- * which is the larger half. Before the session exists there is nothing to read
848
- * and nothing is posted, and the substrate skew answers null — its module's
849
- * memory lives in a worker with no memory door, and a zero there would be a
850
- * measurement nobody took.
851
- */
845
+ /** Post the current engine memory size after calls. Before initialization there is no reading; WALI reports null because its memory lives in a separate worker. */
852
846
  function reportMemory(): void {
853
847
  if (!engine) return;
854
848
  const bytes = engine.memoryBytes();
855
849
  if (bytes !== null) post({ op: 'memory', bytes });
856
850
  }
857
851
 
858
- // A BLENDER THREAD THAT DIES IS WEIGHED FIRST: its error reaches this scope before the page's
859
- // `onerror`, so the heap it died at is posted ahead of the failure, and the page can say whether
860
- // the engine ran out of memory (`runtime.ts`) instead of "Uncaught [object Object]".
852
+ // Report heap size before the page handles a worker error so its diagnostic
853
+ // includes the engine's memory state.
861
854
  self.addEventListener('error', reportMemory);
862
855
 
863
856
  self.onmessage = (event: MessageEvent<WorkerRequest>) => {
@@ -869,10 +862,22 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
869
862
  void enqueue(() => answerRequest(request));
870
863
  };
871
864
 
865
+ /** How often the engine's memory is reported while a call runs (`reportMemory`). */
866
+ const MEMORY_REPORT_MS = 5_000;
867
+
872
868
  async function answerRequest(request: WorkerRequest): Promise<void> {
869
+ // Sample heap size while a long call runs; this worker remains responsive while Blender executes on its pthread.
870
+ const weighing = setInterval(reportMemory, MEMORY_REPORT_MS);
871
+ try {
872
+ await answerRequestNow(request);
873
+ } finally {
874
+ clearInterval(weighing);
875
+ }
876
+ }
877
+
878
+ async function answerRequestNow(request: WorkerRequest): Promise<void> {
873
879
  try {
874
880
  const result = await handle(request);
875
- if (!loadCheckpoint && documentDirty) await saveDocument();
876
881
  await reportHistory();
877
882
  post({ id: request.id, result });
878
883
  } catch (error) {