@volter/blender-engine 0.1.148 → 0.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +22 -5
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +66 -129
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +37 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +3 -0
  192. package/browser/protocol.ts +130 -43
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +271 -106
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1389 -1446
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +492 -306
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +195 -180
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
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>;
@@ -95,11 +57,13 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
95
57
 
96
58
  // ---- The session's document.
97
59
  //
98
- // One session, one `.blend`. `documentPath` is the PROJECT-RELATIVE spelling —
99
- // the only one that crosses to the server, which joins it to its own root so
100
- // no host path is ever on the wire.
60
+ // One `.blend` at a time, the one `open-document` last named (or a script's Save As moved
61
+ // to). `documentPath` is the PROJECT-RELATIVE spelling — the only one that crosses to the
62
+ // server, which joins it to its own root so 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,10 +250,70 @@ 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;
274
256
  }
275
257
 
276
- async function startBlender(project: string, document?: string): Promise<unknown> {
258
+ /**
259
+ * OPEN `document` IN THE RUNNING BLENDER from the project's file, or as `backup` (its unsaved state at a
260
+ * restart): another `.blend`, or the one it holds again (a revert, a restore). The project's copy is staged
261
+ * in fresh, so the stager keeps its host stamp and does not fetch the file again once Blender has read and
262
+ * removed it (`session.py::bind_document`); a backup is then written over it. Blender presents what it opened.
263
+ *
264
+ * A failure before Blender's own open (the path, the file's staging, a backup with no project file) leaves
265
+ * Blender as it was, holding the document it held, and answers `notOpened` with why; one inside Blender's
266
+ * open throws.
267
+ */
268
+ async function openDocument(document: string, backup?: Uint8Array): Promise<unknown> {
269
+ if (!engine || !projectRoot) throw new Error('The Blender session has not started');
270
+ try {
271
+ await stageDocument(engine, projectRoot, document, backup);
272
+ } catch (error) {
273
+ return { notOpened: describeThrown(error) };
274
+ }
275
+ documentPath = document;
276
+ const opened = await engine.request({ op: 'open-document', document });
277
+ setDocumentDirty(backup !== undefined);
278
+ return opened;
279
+ }
280
+
281
+ /** `document`'s file staged into the engine for its open, from the project or as `backup`. */
282
+ async function stageDocument(engine: BlenderEngine, projectRoot: string, document: string, backup?: Uint8Array): Promise<void> {
283
+ // The document is the project's own file and is named the project's own way. An absolute path here
284
+ // would be a host path on the wire and a destination the page chose, which this transport never carries.
285
+ if (!isDocumentPath(document))
286
+ throw new Error(
287
+ `The Blender document must be a project-relative .blend path with no traversal ` +
288
+ `(models/model.blend); got ${JSON.stringify(document)}`,
289
+ );
290
+ // The file given up and the one opened leave the engine and are staged again from the project: the
291
+ // one given up was saved there, and its copy here is the session's no longer (a script reads it, and a
292
+ // listing of the session's own files leaves it out, as it does every project file staged in).
293
+ for (const path of new Set([documentPath, document].flatMap((held) => held === null ? [] : [`${projectRoot}/${held}`]))) {
294
+ staged.delete(path);
295
+ try {
296
+ await engine.files.unlink(path);
297
+ } catch {
298
+ /* not in the engine: never staged, or the open already removed it */
299
+ }
300
+ }
301
+ const path = `${projectRoot}/${document}`;
302
+ // The document itself not reaching the engine is a refusal, not a footnote: the open would
303
+ // fail on a missing file (or open a stale copy) without saying why.
304
+ const failed = await stageProjectFiles(engine.files, projectRoot);
305
+ const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
306
+ if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
307
+ await loadCheckpoint?.('project-imported');
308
+ if (backup) {
309
+ const host = staged.get(path)?.host;
310
+ if (host === undefined) throw new Error(`${document} could not be read from the project`);
311
+ await engine.files.writeFile(path, backup);
312
+ staged.set(path, { host, engine: await stampOf(engine.files, path) });
313
+ }
314
+ }
315
+
316
+ async function startBlender(project: string): Promise<unknown> {
277
317
  // The engine is named through a holder rather than the module-level
278
318
  // `engine`, because `ask` is handed to the engine before the engine exists.
279
319
  const holder: { engine: BlenderEngine | null } = { engine: null };
@@ -283,8 +323,15 @@ async function startBlender(project: string, document?: string): Promise<unknown
283
323
  ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
284
324
  if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
285
325
  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);
326
+ // A CHANGE IS A NEW REVISION: every present of an unsaved document carries `saveDue`, and
327
+ // reporting each one would tell the workbench's backup the content changed when it did not.
328
+ if (saveDue && documentPath !== null) {
329
+ const revision = (frame as { revision?: unknown } | null)?.revision;
330
+ if (!documentDirty || (typeof revision === 'number' && revision !== dirtyRevision)) {
331
+ if (typeof revision === 'number') dirtyRevision = revision;
332
+ setDocumentDirty(true);
333
+ }
334
+ }
288
335
  // THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
289
336
  // typed arrays the tab draws from, and the record of what was sent
290
337
  // (`describeFrame`). After the post the buffers are detached and the
@@ -357,37 +404,9 @@ async function startBlender(project: string, document?: string): Promise<unknown
357
404
  holder.engine = started;
358
405
  await loadCheckpoint?.("engine-ready");
359
406
  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.
364
- if (document) {
365
- // The document itself not reaching the engine is a refusal, not a footnote: the open below
366
- // would fail on a missing file (or open a stale copy) without saying why.
367
- const failed = await stageProjectFiles(started.files, project);
368
- const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
369
- if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
370
- }
371
- await loadCheckpoint?.("project-imported");
372
- const banner = (await started.request({
373
- op: 'start',
374
- project,
375
- ...(document ? { document } : {}),
376
- })) as Record<string, unknown>;
377
- if (document) documentPath = document;
407
+ // The project's files are staged by each call that reads them: a document's open, a script's run.
408
+ const banner = (await started.request({ op: 'start', project })) as Record<string, unknown>;
378
409
  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
410
  sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
392
411
  objectInfo: async (name: string) =>
393
412
  JSON.stringify(await started.request({ op: 'object-info', name }), null, 2),
@@ -399,12 +418,10 @@ async function startBlender(project: string, document?: string): Promise<unknown
399
418
  return {
400
419
  ...banner,
401
420
  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.
421
+ // Report the engine build that answered.
404
422
  skew: started.skew,
405
423
  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.
424
+ // Released packed-payload size; WALI has no packed payload and reports null.
408
425
  releasedPayloadMB:
409
426
  started.releasedPayloadBytes === null
410
427
  ? null
@@ -429,19 +446,11 @@ export function projectMount(project: string): string {
429
446
  return /^[A-Za-z]:[\\/]/.test(project) ? `/${project.replaceAll('\\', '/')}` : project;
430
447
  }
431
448
 
432
- async function start(hostProject: string, document?: string): Promise<unknown> {
449
+ async function start(hostProject: string): Promise<unknown> {
433
450
  if (session) throw new Error('The Blender session is already started');
434
451
  const project = projectMount(hostProject);
435
452
  if (!project.startsWith('/'))
436
453
  throw new Error("The Blender session needs the project's absolute path");
437
- // The document is the project's own file and is named the project's own way.
438
- // An absolute path here would be a host path on the wire and a destination
439
- // the page chose, which is the one thing this transport does not carry.
440
- if (document !== undefined && !isDocumentPath(document))
441
- throw new Error(
442
- `The Blender document must be a project-relative .blend path with no traversal ` +
443
- `(models/model.blend); got ${JSON.stringify(document)}`,
444
- );
445
454
  const served = await blenderIsServed();
446
455
  if (!served.available)
447
456
  throw new Error(
@@ -450,7 +459,7 @@ async function start(hostProject: string, document?: string): Promise<unknown> {
450
459
  '(packages/blender-engine/wasm, or the directory VOLTER_BLENDER_WASM_DIR names); nothing stands in for it.',
451
460
  );
452
461
  projectRoot = project;
453
- return startBlender(project, document);
462
+ return startBlender(project);
454
463
  }
455
464
 
456
465
  /** A project-relative `.blend`, with no traversal and no absolute root. The
@@ -505,26 +514,7 @@ async function projectIndex(project: string): Promise<ProjectFile[] | string> {
505
514
  return files;
506
515
  }
507
516
 
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
- */
517
+ /** 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
518
  const staged = new Map<string, { host: string; engine: string }>();
529
519
 
530
520
  async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
@@ -532,16 +522,7 @@ async function stampOf(files_: BlenderFiles, path: string): Promise<string> {
532
522
  return info ? `${info.size}:${info.mtimeMs}` : '';
533
523
  }
534
524
 
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
- */
525
+ /** Stage changed project files and return copy failures. execute appends them to its answer; start refuses when its document failed to stage. */
545
526
  async function stageProjectFiles(files_: BlenderFiles, project: string): Promise<string[]> {
546
527
  const files = await projectIndex(project);
547
528
  if (typeof files === 'string') return [files];
@@ -590,7 +571,8 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
590
571
  /** What `session.py::follow_open_file` says after a script changed the file Blender holds. */
591
572
  type FollowedDocument =
592
573
  | { moved: true; from: string; document: string }
593
- | { moved: false; from: string; outside: string };
574
+ | { moved: false; from: string; outside: string }
575
+ | { moved: false; from: string; notSaving: true; note: string };
594
576
 
595
577
  /**
596
578
  * THE DOCUMENT FOLLOWS BLENDER'S SAVE AS, and the script's caller is told in its own answer.
@@ -600,9 +582,16 @@ type FollowedDocument =
600
582
  * whose runtime handle holds the same path. A file outside the project is refused: the editor
601
583
  * has no document there, so the session keeps its own and keeps saving to it, and says so
602
584
  * naming both files, since the script believes it is now working in the other one.
585
+ * A notSaving answer is a different state: the engine protects the bound file and writes nothing to it.
603
586
  */
604
587
  function followDocument(followed: FollowedDocument | undefined): string {
605
588
  if (!followed) return '';
589
+ if ('notSaving' in followed) {
590
+ post({ op: 'document-saving', document: followed.from,
591
+ 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.` });
592
+ log('warn', followed.note);
593
+ return `\n\n${followed.note}`;
594
+ }
606
595
  if (!followed.moved) {
607
596
  const line = `Blender now has ${followed.outside} open, which is outside the project, so the editor cannot ` +
608
597
  `follow it there: this session's document stays ${followed.from}, and the model keeps saving to ` +
@@ -614,6 +603,7 @@ function followDocument(followed: FollowedDocument | undefined): string {
614
603
  // The session checks the same shape, so this is a broken promise, not a refusal to explain.
615
604
  throw new Error(`Blender moved its document to ${JSON.stringify(followed.document)}, which is not a project .blend path`);
616
605
  }
606
+ post({ op: 'document-saving', document: followed.from, note: null });
617
607
  documentPath = followed.document;
618
608
  // Even a present that failed must not leave the new file only in the engine's filesystem.
619
609
  setDocumentDirty(true);
@@ -639,9 +629,9 @@ async function listSessionFiles(files: BlenderFiles, hostRoot: string): Promise<
639
629
  const root = projectMount(hostRoot);
640
630
  const out: FileEntry[] = [];
641
631
  // 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.
632
+ // listing. A directory that exists and cannot be read is NOT: answering "no files" would let a
633
+ // root the engine never had (a Windows host's `C:\…` instead of `/C:/…`) report success while
634
+ // nothing reached disk.
645
635
  if ((await files.stat(root)) === null) return out;
646
636
  const walk = async (dir: string): Promise<void> => {
647
637
  let names: string[];
@@ -683,7 +673,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
683
673
  switch (request.op) {
684
674
  case 'start': {
685
675
  if (loadJob) throw new Error('The Blender load has already started');
686
- return pullWork('start', () => start(request.project, request.document));
676
+ return pullWork('start', () => start(request.project));
687
677
  }
688
678
  case 'load-next':
689
679
  if (!loadJob) throw new Error('No Blender load to continue');
@@ -711,9 +701,20 @@ async function handle(request: WorkerRequest): Promise<unknown> {
711
701
  case 'flush-document':
712
702
  // A flush lands what is pending; a document nothing changed stays the
713
703
  // file it was opened from.
704
+ // (it names what it saved: the tab's handle may already name the next document)
714
705
  if (!documentDirty) return { saved: false };
715
- await saveDocument();
716
- return { saved: true };
706
+ {
707
+ const document = documentPath;
708
+ return { saved: await saveDocument(), ...(document === null ? {} : { document }) };
709
+ }
710
+ case 'snapshot-document': {
711
+ if (documentPath === null) throw new Error('The Blender session holds no document');
712
+ const snapshot = await engineDocument(documentPath, () => {});
713
+ if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
714
+ return snapshot.bytes;
715
+ }
716
+ case 'open-document':
717
+ return pullWork('open-document', () => openDocument(request.document, request.bytes));
717
718
  case 'history-begin':
718
719
  case 'history-end':
719
720
  return ask({ op: request.op });
@@ -756,12 +757,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
756
757
  ...(request.names === undefined ? {} : { names: request.names }),
757
758
  });
758
759
  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.
760
+ // Forward each request field explicitly to the Python session.
765
761
  return ask({
766
762
  op: 'rna-context',
767
763
  ...(request.object === undefined ? {} : { object: request.object }),
@@ -801,10 +797,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
801
797
  ...(request.object === undefined ? {} : { object: request.object }),
802
798
  ...(request.uvLayer === undefined ? {} : { uvLayer: request.uvLayer }),
803
799
  });
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.
800
+ // Forward the clip request fields to the Python session.
808
801
  case 'action-clip':
809
802
  return ask({
810
803
  op: 'action-clip',
@@ -813,6 +806,30 @@ async function handle(request: WorkerRequest): Promise<unknown> {
813
806
  ...(request.action === undefined ? {} : { action: request.action }),
814
807
  ...(request.summary === undefined ? {} : { summary: request.summary }),
815
808
  });
809
+ case 'scene-movie':
810
+ return ask({ op: 'scene-movie' });
811
+ case 'sound-scene':
812
+ return ask({ op: 'sound-scene', first: request.first, last: request.last });
813
+ case 'gltf-export':
814
+ return ask({ op: 'gltf-export', kind: request.kind, force: request.force === true,
815
+ ...(request.previous ? { previous: request.previous } : {}),
816
+ ...(request.blendSha256 ? { blendSha256: request.blendSha256 } : {}), ...(request.luts ? { luts: request.luts } : {}) });
817
+ // A script reads the project's files as `execute`'s does, so they are copied in first; what could not be is
818
+ // answered beside its run.
819
+ // (the files a run wrote are read here and taken away, so its answer carries them and no later run can touch them)
820
+ case 'run-script': {
821
+ const unstaged = await stageProjectFiles(files, projectRoot);
822
+ const ran = await ask({ op: 'run-script', source: request.source, args: request.args, dir: request.dir }) as
823
+ { output: string; error: string | null; files: string[] };
824
+ const written: { path: string; bytes: Uint8Array }[] = [];
825
+ for (const path of ran.files) {
826
+ written.push({ path, bytes: (await files.readFile(`${request.dir}/${path}`)).slice() });
827
+ await files.unlink(`${request.dir}/${path}`);
828
+ }
829
+ return { output: ran.output, error: ran.error, files: written, unstaged } satisfies ScriptRunAnswer;
830
+ }
831
+ case 'asset-library':
832
+ return ask({ op: 'asset-library', library: request.library });
816
833
  case 'outliner-set':
817
834
  return ask({
818
835
  op: 'outliner-set',
@@ -831,33 +848,19 @@ async function handle(request: WorkerRequest): Promise<unknown> {
831
848
  staged.delete(request.path);
832
849
  return undefined;
833
850
  }
834
- case 'list-files':
835
- return listSessionFiles(files, request.path);
836
851
  }
837
852
  throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
838
853
  }
839
854
 
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
- */
855
+ /** 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
856
  function reportMemory(): void {
853
857
  if (!engine) return;
854
858
  const bytes = engine.memoryBytes();
855
859
  if (bytes !== null) post({ op: 'memory', bytes });
856
860
  }
857
861
 
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]".
862
+ // Report heap size before the page handles a worker error so its diagnostic
863
+ // includes the engine's memory state.
861
864
  self.addEventListener('error', reportMemory);
862
865
 
863
866
  self.onmessage = (event: MessageEvent<WorkerRequest>) => {
@@ -869,10 +872,22 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
869
872
  void enqueue(() => answerRequest(request));
870
873
  };
871
874
 
875
+ /** How often the engine's memory is reported while a call runs (`reportMemory`). */
876
+ const MEMORY_REPORT_MS = 5_000;
877
+
872
878
  async function answerRequest(request: WorkerRequest): Promise<void> {
879
+ // Sample heap size while a long call runs; this worker remains responsive while Blender executes on its pthread.
880
+ const weighing = setInterval(reportMemory, MEMORY_REPORT_MS);
881
+ try {
882
+ await answerRequestNow(request);
883
+ } finally {
884
+ clearInterval(weighing);
885
+ }
886
+ }
887
+
888
+ async function answerRequestNow(request: WorkerRequest): Promise<void> {
873
889
  try {
874
890
  const result = await handle(request);
875
- if (!loadCheckpoint && documentDirty) await saveDocument();
876
891
  await reportHistory();
877
892
  post({ id: request.id, result });
878
893
  } catch (error) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.148",
5
+ "version": "0.1.150",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "peerDependencies": {
33
- "three": "^0.180.0",
33
+ "three": "^0.186.1",
34
34
  "zod": "^3.0.0 || ^4.0.0"
35
35
  },
36
36
  "peerDependenciesMeta": {
@@ -47,5 +47,8 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "meshoptimizer": "1.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "esbuild": "0.25.12"
50
53
  }
51
54
  }