@volter/blender-engine 0.1.148 → 0.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +22 -5
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +66 -129
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +37 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +3 -0
  192. package/browser/protocol.ts +130 -43
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +271 -106
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1389 -1446
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +492 -306
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +195 -180
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -1,13 +1,16 @@
1
1
  import type { PullResult } from './pull-job.mts';
2
+ import { describeThrown } from './describe-thrown.mts';
2
3
  import { FrameStreamReader } from './frame-stream.mts';
4
+ import { HEAP_GROWTH_REFUSED } from './protocol';
3
5
  /**
4
- * The tab-side handle on a Blender session: spawns the worker, forwards the
5
- * four tool calls, and hands every presented frame to whoever displays it.
6
- * One instance per editor tab; the session and its model die with the worker.
6
+ * The tab-side handle on a Blender session: spawns the worker, forwards wire
7
+ * requests, and hands every presented frame to whoever displays it.
8
+ * One instance per editor tab; the documents it opens one after another share
9
+ * its Blender, and the session and its model die with the worker.
7
10
  */
8
11
  import type {
9
12
  CaptureRequest,
10
- FileEntry,
13
+ DocumentOpened,
11
14
  RuntimeStart,
12
15
  WorkerReply,
13
16
  WorkerRequest,
@@ -15,9 +18,16 @@ import type {
15
18
  NativePreviewRequest,
16
19
  NativePreviewResult,
17
20
  FileListSnapshot,
21
+ GltfExportAnswer,
22
+ GltfExportKind,
23
+ GltfExportPrevious,
24
+ ScriptRunAnswer,
25
+ BlenderAssetLibrary,
18
26
  } from './protocol';
19
27
  import type {
20
28
  BlenderActionClip,
29
+ BlenderSceneMovie,
30
+ BlenderSoundScene,
21
31
  BlenderNodeTree,
22
32
  BlenderOutlinerTree,
23
33
  BlenderOutlinerWrite,
@@ -30,6 +40,26 @@ import type {
30
40
  type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
31
41
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
32
42
 
43
+
44
+ /** An open that failed before Blender's own open (the path, the file's staging): Blender is as it was, holding the
45
+ * document it held. */
46
+ export class DocumentNotOpened extends Error {}
47
+
48
+ /** Calls a Blender giving up its document still takes ({@link BlenderRuntime.releaseDocument}): a backup's read of
49
+ * the document it saves, and an asset library in its preferences, which no file holds. Each runs in its turn,
50
+ * before the next document's open. */
51
+ const RELEASE_ADMITS: ReadonlySet<string> = new Set(['snapshot-document', 'asset-library']);
52
+
53
+ /** A script every save of a document runs in the save's own turn (`BlenderRuntime.saveDocument`), as the caller names
54
+ * it for the document saved: null for none. */
55
+ export type SaveScript = (document: string) => Promise<{ readonly source: string; readonly args: readonly string[] }> | null;
56
+
57
+ /** A save that landed: the document it wrote, and what its script did on the scene it wrote (null where none ran). */
58
+ export interface SavedDocument {
59
+ readonly document: string;
60
+ readonly script: ScriptRunAnswer | null;
61
+ }
62
+
33
63
  export interface BlenderRuntimeOptions {
34
64
  /** Stage one verified mesh/image without replacing the displayed frame. */
35
65
  stage?(part: { session: string; revision: number; evaluation?: 'render'; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
@@ -56,17 +86,14 @@ export interface BlenderRuntimeOptions {
56
86
  * names the new file and before the call that moved it answers. An observer: a throw here
57
87
  * changes nothing. */
58
88
  documentMoved?(moved: { readonly from: string; readonly document: string }): void;
89
+ /** The worker's save refusal or its recovery, for the named project document. */
90
+ documentSaving?(state: { readonly document: string; readonly note: string | null }): void;
91
+ /** The document changed (`true`, at every change) or became its file again (`false`). An
92
+ * observer: a throw here changes nothing. */
93
+ documentDirty?(dirty: boolean): void;
59
94
  }
60
95
 
61
- /**
62
- * What the tab answers a present with.
63
- *
64
- * `held` is WHAT THE PRESENTER HELD BEFORE THIS FRAME — the presenter's own
65
- * report of its state, which the session compares against its record of what
66
- * it sent (`session.py::_present`). `null` says it held nothing; an ABSENT
67
- * field says this presenter does not report one (an older editor integration), and
68
- * the session then judges nothing rather than resetting on every frame.
69
- */
96
+ /** The presenter’s state before this frame. held is null when it holds no frame; the session compares a reported state with what it sent. */
70
97
  export interface PresentAnswer {
71
98
  capture?: unknown;
72
99
  held?: { session: string; revision: number } | null;
@@ -94,24 +121,7 @@ export interface ScreenshotView {
94
121
  target?: number[];
95
122
  }
96
123
 
97
- /**
98
- * HOW LONG THE WORKER TOOK, measured by the PAGE.
99
- *
100
- * WHY THE PAGE MEASURES (2026-09-16): a single `blender-execute` held the
101
- * worker for over 1,800s and wedged the tab, and the only report anyone got
102
- * was a replay harness timing out. A blocked worker cannot report on itself —
103
- * the loop that would send the number is the loop that is stuck — so the one
104
- * vantage point that still works is the side that POSTED the message. Every
105
- * call goes through {@link BlenderRuntime.metrics}'s meter, which stamps the
106
- * `postMessage` and the reply.
107
- *
108
- * MEASUREMENT ONLY. Nothing here cancels, kills or budgets a call: a budget is
109
- * a policy decision, and this is the number a policy would have to be made
110
- * from.
111
- *
112
- * Times are milliseconds on `performance.now()`. Counters are monotonic since
113
- * this runtime was constructed (one per page load).
114
- */
124
+ /** Measure worker calls from the page between postMessage and reply, so a blocked worker remains observable. Metrics use performance.now milliseconds and monotonic counters for this runtime; they impose no call budget. */
115
125
  export interface BlenderCallMetrics {
116
126
  /** Age of the OLDEST outstanding call, or null when the worker is idle.
117
127
  * This is the field that has a number during the wedge — the counters
@@ -124,10 +134,10 @@ export interface BlenderCallMetrics {
124
134
  * exactly as long as it keeps running. */
125
135
  readonly maxCallMs: number | null;
126
136
  /** Operation and load boundary owning the longest request; no payload data. */
127
- readonly maxCallLabel?: string | null;
137
+ readonly maxCallLabel: string | null;
128
138
  /** Current cooperative phase and count of settled wire requests; sampled, not pushed per unit. */
129
- readonly currentPhase?: string | null;
130
- readonly completedCalls?: number;
139
+ readonly currentPhase: string | null;
140
+ readonly completedCalls: number;
131
141
  /** Calls whose duration passed 5s, counting outstanding ones already past it. */
132
142
  readonly callsOver5s: number;
133
143
  /** The same at 30s — a call this side of it is slow, past it is a wedge. */
@@ -153,12 +163,22 @@ export interface BlenderCallMetrics {
153
163
  * `blender_web_headless.cmake`). JavaScript cannot read a memory's maximum back. */
154
164
  const ENGINE_MEMORY_CEILING_MB = 4096;
155
165
 
166
+ /**
167
+ * Requests in OVERTAKES may overtake queued OVERTAKEN document reads. Export and read-file
168
+ * operations keep their order relative to edits; skin, movie, Properties, Outliner, node and UV
169
+ * inspections can yield their queue position.
170
+ */
171
+ const OVERTAKES: ReadonlySet<string> = new Set(['gltf-export', 'read-file']);
172
+ const OVERTAKEN: ReadonlySet<string> = new Set(['action-clip', 'scene-movie', 'rna', 'rna-context', 'outliner', 'node-tree', 'uv-layout']);
173
+
156
174
  export class BlenderRuntime {
157
175
  readonly #worker: Worker;
158
176
  readonly #options: BlenderRuntimeOptions;
159
177
  /** Blender's own last output lines, which are what says why it died when it dies: they reach
160
178
  * the page as ordinary output, so the failure below carries them. */
161
179
  readonly #lastLines: string[] = [];
180
+ /** The line a thread printed when the browser refused to grow the heap, kept for the failure it causes. */
181
+ #growthRefused: string | null = null;
162
182
  readonly #pending = new Map<
163
183
  number,
164
184
  { resolve: (value: unknown) => void; reject: (error: Error) => void }
@@ -166,10 +186,15 @@ export class BlenderRuntime {
166
186
  #nextId = 0;
167
187
  #started: Promise<RuntimeStart> | null = null;
168
188
  #stopping: Promise<void> | null = null;
189
+ /** The script runs this runtime has made, each in a directory of its own. */
190
+ #scriptRuns = 0;
169
191
  #terminated = false;
192
+ /** The document being given up for the next one ({@link releaseDocument}), and its last save. */
193
+ #release: { readonly document: string; readonly next: string; readonly saved: Promise<SavedDocument | null> } | null = null;
170
194
  #dirty = false;
171
195
  readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
172
- if (!this.#dirty && this.#acceptedRequests === 0) return;
196
+ // (a Blender holding no document has nothing to lose: its start is not work in progress)
197
+ if (this.#document === null || (!this.#dirty && this.#acceptedRequests === 0)) return;
173
198
  event.preventDefault();
174
199
  event.returnValue = '';
175
200
  };
@@ -198,33 +223,28 @@ export class BlenderRuntime {
198
223
  globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
199
224
  this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
200
225
  this.#worker.onerror = (event) => {
201
- // A module worker that fails to LOAD reports an ErrorEvent with an empty
202
- // message, so `${event.message}` alone said "undefined" and named
203
- // nothing. Every field the event carries goes in: the thrown error's own
204
- // message and stack when there is one, and the file and line otherwise,
205
- // which is what a failed nested import leaves behind.
226
+ // Worker errors include the thrown stack or message and the source location.
227
+ // Failed module imports may provide only the file and line.
206
228
  const thrown = event.error as Error | undefined;
207
229
  const where = event.filename ? ` at ${event.filename}:${event.lineno}:${event.colno}` : '';
208
230
  const said = thrown?.stack ?? thrown?.message ?? event.message;
209
- // AND BLENDER'S OWN LAST WORDS: a thread that dies throws whatever it threw, often not an
210
- // Error ("Uncaught [object Object]"), while the reason (an allocation that failed, the file
211
- // it was reading) is the last thing Blender printed.
231
+ // Include Blender's retained output alongside the worker error.
212
232
  const last = this.#lastLines.length > 0 ? `\nBlender's last output:\n${this.#lastLines.join('\n')}` : '';
213
233
  // OUT OF MEMORY IS SAID AS SUCH, from the heap the worker reported as the thread died.
214
234
  const heapMB = this.#wasmBytes === null ? null : Math.round(this.#wasmBytes / 1048576);
215
- const exhausted =
216
- heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
235
+ const refused = this.#growthRefused;
236
+ const exhausted = refused !== null
237
+ ? `Blender ran out of memory: the browser would not give its heap more (${refused.slice(HEAP_GROWTH_REFUSED.length + 2)}), ` +
238
+ "below the in-tab engine's ceiling, which happens when the machine itself is short of memory. " +
239
+ 'Free memory (other editors, tabs, builds) and run the call again. '
240
+ : heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
217
241
  ? `Blender ran out of memory: its heap reached ${heapMB} MB of the in-tab engine's ` +
218
242
  `${ENGINE_MEMORY_CEILING_MB} MB, and this scene needs more. `
219
243
  : '';
220
244
  const error = new Error(
221
245
  `${exhausted}Blender worker failed: ${said || 'the worker script did not load'}${where}${last}`,
222
246
  );
223
- // The page console is the editor's ledger (`installEditorConsoleReporting`
224
- // captures it, source-blind), and this package may import nothing of the
225
- // editor to say it any other way. Without this line the failure reached
226
- // a toast and `cyclotron console` read 0/0 while no Model document could open
227
- // (measured 2026-09-20 from a registry install).
247
+ // Report initialization failures to the page console, which the editor captures into its session ledger.
228
248
  console.error(error.message);
229
249
  for (const id of [...this.#pending.keys()]) this.#settled(id);
230
250
  for (const pending of this.#pending.values()) pending.reject(error);
@@ -241,9 +261,10 @@ export class BlenderRuntime {
241
261
  return this.#project;
242
262
  }
243
263
 
244
- /** The document owned by this worker, including while boot is pending: the startup one, until
245
- * a script's Save As leaves Blender holding another project `.blend` and the session follows
246
- * it there (`session.py::follow_open_file`). */
264
+ /** The document owned by this worker, from the moment its open is asked for: the one
265
+ * {@link openDocument} last named, until a script's Save As leaves Blender holding another
266
+ * project `.blend` and the session follows it there (`session.py::follow_open_file`). Null
267
+ * while Blender holds none. */
247
268
  get document(): string | null {
248
269
  return this.#document;
249
270
  }
@@ -254,30 +275,15 @@ export class BlenderRuntime {
254
275
  return [...this.#lastLines];
255
276
  }
256
277
 
257
- /** Idempotent: the first call boots Blender at `project`'s
258
- * absolute path; later calls await it (a different path is refused).
259
- *
260
- * `document` is the session's `.blend`, project-relative. An explicit
261
- * different file is a resource conflict, including while boot is pending.
262
- * Omitting it on an already-started handle means await that same session. */
263
- start(project: string, document?: string): Promise<RuntimeStart> {
278
+ /** Idempotent: the first call boots Blender at `project`'s absolute path, holding no
279
+ * document; later calls await it (a different path is refused). */
280
+ start(project: string): Promise<RuntimeStart> {
264
281
  if (this.#project !== null && this.#project !== project)
265
282
  return Promise.reject(
266
283
  new Error(`The Blender session is bound to ${this.#project}, not ${project}`),
267
284
  );
268
- if (document !== undefined && this.#document !== null && this.#document !== document)
269
- return Promise.reject(
270
- new Error(
271
- `Blender resource conflict: the session holds ${this.#document}; cannot open ${document}`,
272
- ),
273
- );
274
285
  this.#project = project;
275
- this.#document ??= document ?? 'models/model.blend';
276
- this.#started ??= (this.#request({
277
- op: 'start',
278
- project,
279
- ...(document === undefined ? {} : { document }),
280
- }) as Promise<RuntimeStart>).catch((error: unknown) => {
286
+ this.#started ??= (this.#request({ op: 'start', project }) as Promise<RuntimeStart>).catch((error: unknown) => {
281
287
  this.#startFailed = true;
282
288
  throw error;
283
289
  });
@@ -436,6 +442,39 @@ export class BlenderRuntime {
436
442
  }
437
443
 
438
444
 
445
+ /** Sampled scene animation for Timeline preview and export (`BlenderSceneMovie`). */
446
+ async sceneMovie(): Promise<BlenderSceneMovie> {
447
+ await this.#ready();
448
+ return (await this.#request({ op: 'scene-movie' })) as BlenderSceneMovie;
449
+ }
450
+
451
+ /** THE SCENE'S SOUND for frames `first` to `last` (`./rna.ts`, `BlenderSoundScene`), for the page to mix. */
452
+ async soundScene(first: number, last: number): Promise<BlenderSoundScene> {
453
+ await this.#ready();
454
+ return (await this.#request({ op: 'sound-scene', first, last })) as BlenderSoundScene;
455
+ }
456
+
457
+
458
+ /**
459
+ * Export the document as a component or level through Blender’s glTF exporter into the module
460
+ * filesystem. The answer names the files for the caller to carry into the project; the call holds
461
+ * the session until completion.
462
+ */
463
+ async gltfExport(options: { readonly kind: GltfExportKind; readonly force?: boolean; readonly previous?: GltfExportPrevious; readonly blendSha256?: string; readonly luts?: Readonly<Record<string, string>> }): Promise<GltfExportAnswer> {
464
+ await this.#ready();
465
+ return (await this.#request({ op: 'gltf-export', kind: options.kind,
466
+ ...(options.force ? { force: true } : {}), ...(options.previous ? { previous: options.previous } : {}),
467
+ ...(options.blendSha256 ? { blendSha256: options.blendSha256 } : {}), ...(options.luts ? { luts: options.luts } : {}) })) as GltfExportAnswer;
468
+ }
469
+
470
+
471
+
472
+ /** An asset library in Blender's preferences, set again under its name (Preferences > File Paths). */
473
+ async setAssetLibrary(library: BlenderAssetLibrary): Promise<void> {
474
+ await this.#ready();
475
+ await this.#request({ op: 'asset-library', library });
476
+ }
477
+
439
478
  /** Write ONE restriction column (the eye, the render camera, a collection's
440
479
  * Exclude). A column Blender draws on no row of that type is refused by
441
480
  * name rather than written somewhere near it. */
@@ -449,16 +488,7 @@ export class BlenderRuntime {
449
488
  })) as BlenderOutlinerWrite;
450
489
  }
451
490
 
452
- /**
453
- * PRESENT WHAT THE ENGINE HOLDS, with no capture.
454
- *
455
- * Every mutation presents (`session.py::dispatch`), so in the ordinary
456
- * course of a session nothing needs to ask. The exception is the first
457
- * frame: opening a `.blend` is not a mutation, so a freshly opened Model
458
- * document had nothing to display until something RAN — measured by I1 and
459
- * fixed here (WORK.md §Blender in the tab is Blender, "Inspection parity",
460
- * I2 decision 6). The document asks on mount.
461
- */
491
+ /** Present the engine’s current state without capture. Documents ask on mount so an opened .blend draws before its first mutation. */
462
492
  async present(): Promise<void> {
463
493
  await this.#ready();
464
494
  await this.#request({ op: 'present' });
@@ -472,6 +502,80 @@ export class BlenderRuntime {
472
502
  | { error: string };
473
503
  }
474
504
 
505
+ /** Whether the document holds changes its project file does not. */
506
+ get dirty(): boolean {
507
+ return this.#dirty;
508
+ }
509
+
510
+ /** A save of the document, and in the same turn, before any other request reaches this Blender, the script `after`
511
+ * names for the document it wrote (as the worker holds it once saved, a move accepted before it included), run on
512
+ * the scene just saved, in a working directory of its own, its files carried back in its answer. Null when there
513
+ * was nothing to save. While the document is being given up, the save is the release's own: this resolves when it
514
+ * lands, with null (its listeners hear it once, from the release), and fails with it. */
515
+ async saveDocument(after?: SaveScript): Promise<SavedDocument | null> {
516
+ await this.#ready();
517
+ if (this.#release !== null) return this.#release.saved.then(() => null);
518
+ return this.#inTurn('flush-document', false, () => this.#save(after));
519
+ }
520
+
521
+ /** The document's bytes as a save would write them, carried nowhere. */
522
+ async snapshotDocument(): Promise<Uint8Array> {
523
+ await this.#ready();
524
+ return (await this.#request({ op: 'snapshot-document' })) as Uint8Array;
525
+ }
526
+
527
+ /**
528
+ * Open `document` (project-relative) in this Blender, once it has started, as its File > Open does: from
529
+ * the project's file, or as `bytes`, its unsaved state (a backup). Naming the document it holds reopens
530
+ * it, discarding its changes (a revert, a restore). The handle claims the document at the call, so a
531
+ * request made after it waits for the open and finds it; the one held before is the caller's to save
532
+ * first ({@link releaseDocument}). A failure before Blender's own open rejects with {@link DocumentNotOpened}, and
533
+ * the handle names the document it held again.
534
+ */
535
+ openDocument(document: string, bytes?: Uint8Array): Promise<DocumentOpened> {
536
+ const held = this.#document;
537
+ this.#document = document;
538
+ const request = { op: 'open-document', document, ...(bytes === undefined ? {} : { bytes }) } as const;
539
+ return this.#ready().then(() => this.#inTurn('open-document', false, async () => {
540
+ // The open the release was for: the document given up is no longer held.
541
+ if (this.#release?.next === document) this.#release = null;
542
+ // From this turn on, a frame presented is the opened file's: the last one's is no longer on screen as its.
543
+ this.#presented = null;
544
+ const answer = (await this.#wire(request)) as DocumentOpened | { readonly notOpened: string };
545
+ if (!('notOpened' in answer)) return answer;
546
+ if (this.#document === document) this.#document = held;
547
+ throw new DocumentNotOpened(answer.notOpened);
548
+ }, document));
549
+ }
550
+
551
+ /**
552
+ * THE DOCUMENT GIVEN UP FOR `next`, ITS LAST SAVE FIRST, as a stop's is: queued behind every request accepted before
553
+ * it, with `retire.after`'s script in its turn, heard by `landed`. From the call on, no edit is taken, so none lands
554
+ * after the save, until `next`'s {@link openDocument} has its turn or {@link keepDocument} returns to the document;
555
+ * a save asked for meanwhile is this one, and a backup's read and an asset library still run in their turns
556
+ * (`RELEASE_ADMITS`). A save that fails, or a listener that throws, keeps the document, and edits are taken again.
557
+ */
558
+ releaseDocument(next: string, retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
559
+ if (this.#terminated || this.#stopping) return Promise.reject(new Error('The Blender session is stopping or terminated'));
560
+ const document = this.#document;
561
+ if (document === null) return Promise.resolve();
562
+ const saved = this.#ready().then(() => this.#inTurn('flush-document', true, () => this.#save(retire?.after)));
563
+ const release = { document, next, saved };
564
+ this.#release = release;
565
+ return saved
566
+ .then((value) => { if (value !== null) retire?.landed(value); })
567
+ .catch((error: unknown) => {
568
+ if (this.#release === release) this.#release = null;
569
+ throw error;
570
+ });
571
+ }
572
+
573
+ /** The document given up ({@link releaseDocument}) is kept after all, while the handle still names it: edits are
574
+ * taken again. Once the next document's open is asked for, that open ends the release instead. */
575
+ keepDocument(): void {
576
+ if (this.#release !== null && this.#release.document === this.#document) this.#release = null;
577
+ }
578
+
475
579
  async readFile(path: string): Promise<Uint8Array> {
476
580
  await this.#ready();
477
581
  return (await this.#request({ op: 'read-file', path })) as Uint8Array;
@@ -482,11 +586,6 @@ export class BlenderRuntime {
482
586
  await this.#request({ op: 'write-file', path, bytes });
483
587
  }
484
588
 
485
- async listFiles(path: string): Promise<FileEntry[]> {
486
- await this.#ready();
487
- return (await this.#request({ op: 'list-files', path })) as FileEntry[];
488
- }
489
-
490
589
  /** Files and their native document identity, without executing Python or
491
590
  * exporting/saving the model. The worker serializes this with authoring. */
492
591
  async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
@@ -501,15 +600,23 @@ export class BlenderRuntime {
501
600
  }
502
601
 
503
602
  /** Drain accepted calls and persist the document before destroying its only
504
- * copy. A failed save keeps the worker alive so the caller can retry. */
505
- stop(): Promise<void> {
603
+ * copy. A failed save keeps the worker alive so the caller can retry. With `retire`, the stop's save is the caller's
604
+ * too: it runs `retire.after`'s script in its turn, as every save does (`saveDocument`), and `landed` hears it. */
605
+ stop(retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
506
606
  if (this.#terminated) return Promise.resolve();
507
607
  if (this.#stopping) return this.#stopping;
608
+ // Holding no document, it has nothing to save, and its start is not waited for.
609
+ if (this.#document === null) {
610
+ this.terminate();
611
+ return Promise.resolve();
612
+ }
508
613
  this.#stopping = (async () => {
509
614
  // A start that failed opened no document, so there is nothing to flush: its stale
510
615
  // rejection must not refuse every later close.
511
616
  const started = this.#started ? await this.#started.then(() => true, () => false) : false;
512
- if (started) await this.#request({ op: 'flush-document' }, true);
617
+ // (one save, queued behind every request accepted before the stop; none is taken after it)
618
+ const saved = started ? await this.#inTurn('flush-document', true, () => this.#save(retire?.after)) : null;
619
+ if (saved !== null) retire?.landed(saved);
513
620
  this.terminate();
514
621
  })().catch(error => {
515
622
  this.#stopping = null;
@@ -518,6 +625,23 @@ export class BlenderRuntime {
518
625
  return this.#stopping;
519
626
  }
520
627
 
628
+ /** The save, and its script on the scene it wrote, inside one turn. */
629
+ async #save(after: SaveScript | undefined): Promise<SavedDocument | null> {
630
+ // The document the worker wrote, as it says: this handle may already name the next one.
631
+ const { saved, document } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean; document?: string };
632
+ if (!saved || document === undefined) return null;
633
+ const named = after?.(document) ?? null;
634
+ if (named === null) return { document, script: null };
635
+ let script: { readonly source: string; readonly args: readonly string[] };
636
+ try {
637
+ script = await named;
638
+ } catch (error) {
639
+ return { document, script: { output: '', error: `the save's script could not be read: ${describeThrown(error)}`, files: [], unstaged: [] } };
640
+ }
641
+ const dir = `/tmp/volter-script-run/${++this.#scriptRuns}`;
642
+ return { document, script: (await this.#wire({ op: 'run-script', source: script.source, args: script.args, dir })) as ScriptRunAnswer };
643
+ }
644
+
521
645
  /** Forced teardown for a lost session; explicit user stops must use stop(). */
522
646
  terminate(): void {
523
647
  this.#discardStagedFrame();
@@ -546,7 +670,8 @@ export class BlenderRuntime {
546
670
  * displaying the frame is main-thread time, which is the long-task observer's
547
671
  * subject (the host's `worker-call-metrics.ts`).
548
672
  */
549
- metrics(now: number = performance.now()): BlenderCallMetrics {
673
+ metrics(): BlenderCallMetrics {
674
+ const now = performance.now();
550
675
  let oldest: number | null = null;
551
676
  let max = this.#maxCompletedMs;
552
677
  let maxLabel = this.#maxCompletedLabel;
@@ -594,33 +719,64 @@ export class BlenderRuntime {
594
719
 
595
720
  #completedCalls = 0;
596
721
  #acceptedRequests = 0;
597
- #requestTail: Promise<void> = Promise.resolve();
598
- #request(request: Request, shutdown = false): Promise<unknown> {
722
+ /**
723
+ * Process requests in queue order. gltf-export and read-file may overtake queued document reads
724
+ * in OVERTAKEN, while preserving the order of edits and other operations.
725
+ */
726
+ #waiting: { readonly op: string; readonly start: () => void }[] = [];
727
+ #busy = false;
728
+ #enqueue(op: string, start: () => void): void {
729
+ const item = { op, start };
730
+ if (OVERTAKES.has(op)) {
731
+ let at = this.#waiting.length;
732
+ while (at > 0 && OVERTAKEN.has(this.#waiting[at - 1]!.op)) at--;
733
+ this.#waiting.splice(at, 0, item);
734
+ } else this.#waiting.push(item);
735
+ if (!this.#busy) this.#next();
736
+ }
737
+ #next(): void {
738
+ const item = this.#waiting.shift();
739
+ this.#busy = item !== undefined;
740
+ item?.start();
741
+ }
742
+ #request(request: Request): Promise<unknown> {
743
+ return this.#inTurn(request.op, false, () => this.#wire(request));
744
+ }
745
+ /** One turn of the queue: `run` alone talks to the worker until it is done, whatever it sends. */
746
+ #inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>, opens?: string): Promise<T> {
599
747
  if (this.#terminated || (this.#stopping && !shutdown))
600
748
  return Promise.reject(new Error('The Blender session is stopping or terminated'));
749
+ const release = this.#release;
750
+ if (release !== null && !shutdown && !RELEASE_ADMITS.has(op) && opens !== release.next)
751
+ return Promise.reject(new Error(`Blender is saving ${release.document} before opening ${release.next}, and takes no edit until ${release.next} is open.`));
601
752
  const began = performance.now();
602
753
  let endWork = () => {};
603
754
  this.#acceptedRequests++;
604
- const answer = this.#requestTail.then(async () => {
605
- const finishWork = this.#work(`running worker ${request.op}`);
755
+ const turn = new Promise<void>((start) => this.#enqueue(op, start));
756
+ const answer = turn.then(async () => {
757
+ const finishWork = this.#work(`running worker ${op}`);
606
758
  endWork = () => { if (this.#operationWork.delete(endWork)) finishWork(); };
607
759
  this.#operationWork.add(endWork);
608
- this.#loadBoundary = request.op;
609
- let result = await this.#wireRequest(request) as PullResult<unknown>;
610
- while (result?.load === 'continue') {
611
- this.#loadBoundary = result.phase;
612
- result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
613
- }
614
- return result?.load === 'done' ? result.value : result;
760
+ return run();
615
761
  }).finally(() => {
616
762
  this.#acceptedRequests--;
617
763
  endWork();
618
764
  const totalMs = Math.round(performance.now() - began);
619
- try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${request.op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
765
+ try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
766
+ this.#next();
620
767
  });
621
- this.#requestTail = answer.then(() => {}, () => {});
622
768
  return answer;
623
769
  }
770
+ /** One request to the worker, a load's continuations included. */
771
+ async #wire(request: Request): Promise<unknown> {
772
+ this.#loadBoundary = request.op;
773
+ let result = await this.#wireRequest(request) as PullResult<unknown>;
774
+ while (result?.load === 'continue') {
775
+ this.#loadBoundary = result.phase;
776
+ result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
777
+ }
778
+ return result?.load === 'done' ? result.value : result;
779
+ }
624
780
 
625
781
  #wireRequest(request: Request): Promise<unknown> {
626
782
  if (this.#terminated) return Promise.reject(new Error('The Blender session is terminated'));
@@ -683,6 +839,13 @@ export class BlenderRuntime {
683
839
  }
684
840
  if (reply.op === 'document-dirty') {
685
841
  this.#dirty = reply.dirty;
842
+ try { this.#options.documentDirty?.(reply.dirty); }
843
+ catch { /* an observer cannot change whether the document changed */ }
844
+ return;
845
+ }
846
+ if (reply.op === 'document-saving') {
847
+ try { this.#options.documentSaving?.({ document: reply.document, note: reply.note }); }
848
+ catch { /* an observer cannot change whether the document was saved */ }
686
849
  return;
687
850
  }
688
851
  if (reply.op === 'document-moved') {
@@ -698,6 +861,8 @@ export class BlenderRuntime {
698
861
  return;
699
862
  }
700
863
  if (reply.op === 'log') {
864
+ // (a heap the browser refused to grow is kept past the aborts it causes, which push it out of the last lines)
865
+ if (reply.text.startsWith(HEAP_GROWTH_REFUSED)) this.#growthRefused ??= reply.text;
701
866
  this.#lastLines.push(reply.text);
702
867
  if (this.#lastLines.length > 12) this.#lastLines.shift();
703
868
  this.#options.log?.(reply.level, reply.text);
@@ -721,8 +886,8 @@ export class BlenderRuntime {
721
886
  this.#worker.postMessage({
722
887
  op: 'present-result',
723
888
  id: reply.id,
724
- ...(answer?.capture === undefined ? {} : { capture: answer.capture }),
725
- ...(answer !== null && answer !== undefined && 'held' in answer
889
+ ...(answer.capture === undefined ? {} : { capture: answer.capture }),
890
+ ...('held' in answer
726
891
  ? { held: answer.held }
727
892
  : {}),
728
893
  } satisfies WorkerRequest);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * THE ADD-ONS THIS BLENDER SHIPS beside Blender's own, as the files each engine writes into the session's root before
3
+ * Blender starts (`<SESSION_ROOT>/addons/<package>/…`), where `session.py`'s `_enable_addons` enables every package it
4
+ * finds there for the whole session.
5
+ *
6
+ * - `addons/vrm`: saturday06's VRM Add-on for Blender ("VRM format"), the release `addons/vrm.json` names, as it was
7
+ * published. Blender reads its Python and its manifest (`common/version.py` reads `blender_manifest.toml` at import)
8
+ * and its MToon node-group libraries (`common/*.blend`); its documentation and licence texts stay in the package.
9
+ *
10
+ * Text crosses as the module's own source; a `.blend` is bytes, fetched from the URL the bundle gives it.
11
+ */
12
+ import { SESSION_ROOT } from './blender-engine.mts';
13
+
14
+ const texts = import.meta.glob<string>(['./addons/vrm/**/*.py', './addons/vrm/blender_manifest.toml'], {
15
+ query: '?raw',
16
+ import: 'default',
17
+ eager: true,
18
+ });
19
+ const libraries = import.meta.glob<string>('./addons/vrm/**/*.blend', { query: '?url', import: 'default', eager: true });
20
+
21
+ /** Every add-on file, at its path in the module filesystem. */
22
+ export async function sessionAddonFiles(): Promise<readonly { readonly path: string; readonly bytes: Uint8Array }[]> {
23
+ const at = (key: string): string => `${SESSION_ROOT}/${key.slice('./'.length)}`;
24
+ const utf8 = new TextEncoder();
25
+ return [
26
+ ...Object.entries(texts).map(([key, text]) => ({ path: at(key), bytes: utf8.encode(text) })),
27
+ ...(await Promise.all(Object.entries(libraries).map(async ([key, url]) => {
28
+ const answer = await fetch(url);
29
+ if (!answer.ok) throw new Error(`The add-on file ${key.slice('./'.length)} could not be read: ${url} answered HTTP ${answer.status}`);
30
+ return { path: at(key), bytes: new Uint8Array(await answer.arrayBuffer()) };
31
+ }))),
32
+ ];
33
+ }
@@ -54,8 +54,9 @@ type Typed =
54
54
  | Int16Array
55
55
  | Uint16Array;
56
56
 
57
- /** The door's own dtype vocabulary -- the C type each column was written from,
58
- * not numpy's codes: the Python exporter that spoke numpy is gone. */
57
+ /**
58
+ * Column dtype vocabulary matching the C types written by the export door.
59
+ */
59
60
  const READERS: Record<string, (bytes: Uint8Array) => Typed> = {
60
61
  f32: (b) => new Float32Array(b.buffer, b.byteOffset, b.byteLength >> 2),
61
62
  f64: (b) => new Float64Array(b.buffer, b.byteOffset, b.byteLength >> 3),
@@ -113,34 +114,14 @@ export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown
113
114
 
114
115
  /** A column as the RECORD of it: what it was, how many bytes, and their digest.
115
116
  * Never the bytes — see `describeFrame`. */
116
- export interface ColumnDigest {
117
+ interface ColumnDigest {
117
118
  dtype: string;
118
119
  length: number;
119
120
  /** Lowercase hex SHA-256 of exactly the `length` bytes the descriptor names. */
120
121
  sha256: string;
121
122
  }
122
123
 
123
- /**
124
- * THE FRAME THIS PRESENT SUBMITTED, as a record that can sit in a JSON file.
125
- *
126
- * The presenter is asked, later, whether what it DISPLAYS is what the session
127
- * SENT (`replay_draw_compare.compare_draws`), and that comparison had no second
128
- * side: the live document keeps the frame's identity and object table but drops
129
- * every geometry payload the moment it has built the `BufferGeometry` (850 MB of
130
- * main-thread heap otherwise — `blender-runtime-view.ts`), and nothing else
131
- * remembered the frame at all.
132
- *
133
- * So the worker records it here, while the arena is still the frame's own: every
134
- * `{offset, length, dtype, count, stride}` becomes `{dtype, length, sha256}` and
135
- * everything else — objects, materials, cameras, the world, the counts — is
136
- * copied as it stands. THE COLUMN BYTES NEVER LEAVE THE TAB; a record of the
137
- * numbers is not the numbers, and a battery run must not write a scene's meshes
138
- * to disk a second time.
139
- *
140
- * This runs BEFORE `presentToTab` posts, for two reasons that are both fatal
141
- * otherwise: the buffers are TRANSFERRED (detached the instant they are posted),
142
- * and the arena is overwritten by the next `export_frame`.
143
- */
124
+ /** Record the submitted frame’s metadata and hash each arena column before presentToTab transfers its buffers. Geometry bytes stay in the tab; the record identifies the data that was submitted. */
144
125
  export async function describeFrame(arena: Uint8Array, frame: unknown): Promise<unknown> {
145
126
  const walk = async (value: unknown, key: string): Promise<unknown> => {
146
127
  if (isColumnDescriptor(value)) {