@volter/blender-engine 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +505 -283
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +156 -151
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -26,11 +26,11 @@ import {
26
26
  WorldBackground,
27
27
  worldSchema,
28
28
  } from './blender-runtime-lighting';
29
- import { fitModelDirectionalShadow, visibleShadowReceivers } from './blender-runtime-shadows';
30
- import {BlenderWorldVisibility,WORLD_VISIBILITY,bindWorldVisibility} from './blender-world-visibility';
29
+ import { fitModelDirectionalShadow, farthestVisibleReceiver, fitPerspectiveShadowBias, VisibleReceivers, visibleShadowReceivers } from './blender-runtime-shadows';
31
30
  import { volumeMesh, volumeSchema } from './blender-runtime-volume';
32
31
  import { applyPhysicalMaterial, applyWorldExtinction, physicalMaterialSchema } from './blender-physical-material';
33
- import { prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
32
+ import { photographGraphs, prepareGraphGeometry, setMaterialGraph } from './blender-graph-material';
33
+ import { type BlenderPhotographSampling, eeveeSampling, workbenchSampling } from './blender-viewport-sampling';
34
34
  import {BlenderObjectInfoMaterials} from './blender-object-info-materials';
35
35
  import { type CompiledGraph, compileMaterialGraph, graphSeeThrough, materialGraphSchema } from './blender-node-graph';
36
36
  import {worldMedium, WorldVolumePass} from './blender-world-volume';
@@ -46,11 +46,32 @@ import {
46
46
  panCameraView,
47
47
  } from './blender-runtime-camera-view';
48
48
  import { DEFAULT_VIEWPORT_DISPLAY, workbenchMaterial } from './blender-workbench-material';
49
+ import { WorkbenchOutline } from './blender-workbench-outline';
50
+ import type { BlenderDisplaySettings } from './blender-display-transform';
49
51
  import { BlenderRuntimeInstances } from './blender-runtime-instances';
50
52
  import { BlenderTransparentInstances } from './blender-transparent-instances';
51
53
  import { BlenderMaterialRanges } from './blender-material-ranges';
52
54
  import { BlenderMotionGeometry } from './blender-motion-geometry';
53
55
 
56
+ /** What the stage tells {@link BlenderRuntimeView.prepareDraw} about the draw it is about to make. */
57
+ export interface BlenderDrawOptions {
58
+ readonly interactive: boolean;
59
+ readonly height: number;
60
+ readonly multiDraw?: boolean;
61
+ readonly renderer?: THREE.WebGLRenderer;
62
+ /** The stage's draw mode (`solid`, `rendered`, ...): Solid's object outline is Solid's alone. */
63
+ readonly drawMode?: string;
64
+ /** Target pixels per output pixel of this draw (Blender's `ui_scale`): the device pixel ratio
65
+ * on screen, the supersampling factor in a photograph. */
66
+ readonly pixelScale?: number;
67
+ /** This draw is a photograph: it gets one chance, so nothing it shows may settle later. */
68
+ readonly photograph?: boolean;
69
+ }
70
+
71
+ /** The Preferences' Viewport Anti-Aliasing (`U.viewport_aa`) at its factory value: the tab holds
72
+ * no Blender preferences. */
73
+ const PREFERENCES_VIEWPORT_AA = 8;
74
+
54
75
  /** A saved `View3DShading.type` as the stage's draw mode: Material Preview is `preview`. */
55
76
  const SAVED_SHADING = {
56
77
  WIREFRAME: 'wireframe',
@@ -148,6 +169,32 @@ const drawnSkinSchema = z
148
169
  weight: z.instanceof(Float32Array),
149
170
  })
150
171
  .strict();
172
+ /**
173
+ * ONE MESH THE PRESENTER CANNOT DRAW IS SKIPPED BY NAME; the rest of the frame presents.
174
+ *
175
+ * A throw anywhere in a frame would refuse the WHOLE frame: the Model document would keep showing
176
+ * the state before the call, every later present would fail the same way, and one mesh (a custom
177
+ * vertex attribute named as its render UV, say) would hide thousands of other objects.
178
+ *
179
+ * The skipped mesh is held as an empty geometry AT ITS SIGNATURE, so the session's record (it
180
+ * sent the mesh at that revision) and this presenter agree, and the next frame's reference to it
181
+ * is not a resync. Its objects stand in the graph with nothing to draw. It is drawn again when its
182
+ * revision changes, which is the edit that may have fixed it.
183
+ */
184
+ export const REFUSED_GEOMETRY = 'blenderRefused';
185
+ function drawnOrRefused(draw: () => THREE.BufferGeometry): THREE.BufferGeometry {
186
+ try {
187
+ return draw();
188
+ } catch (error) {
189
+ const geometry = new THREE.BufferGeometry();
190
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3));
191
+ geometry.userData[REFUSED_GEOMETRY] = error instanceof Error ? error.message : String(error);
192
+ return geometry;
193
+ }
194
+ }
195
+ /** Each (mesh, revision, reason) is named once per tab, not once per present or per follower. */
196
+ const NAMED_REFUSALS = new Set<string>();
197
+
151
198
  /** An exported mesh's geometry with its skin laid over its drawn vertices (`skinGeometry`). */
152
199
  function withFrameSkin(geometry: THREE.BufferGeometry, skin: FrameSkin | undefined): THREE.BufferGeometry {
153
200
  if (skin) skinGeometry(geometry, skin);
@@ -218,10 +265,8 @@ const unchangedMeshSchema = z
218
265
  /**
219
266
  * BLENDER'S OWN ARRAYS, as the columns the draw reads.
220
267
  *
221
- * This is the shape the export door emits -- `session.py`'s `foreach_get`
222
- * reader today, the C++ door later -- and `drawArraysFromColumns` is what
223
- * draws it, so the presenter takes the columns rather than a drawn mesh. The
224
- * buffers arrive transferred from the worker (`session-frame.mts` reads them
268
+ * The native export door emits these columns; `drawArraysFromColumns`
269
+ * builds their drawn geometry. Buffers arrive transferred from the worker (`session-frame.mts` reads them
225
270
  * out of the module filesystem), which is why every one is a typed array and
226
271
  * none is an array of numbers.
227
272
  */
@@ -283,50 +328,24 @@ const meshSchema = z.union([
283
328
  meshJsonSchema,
284
329
  unchangedMeshSchema,
285
330
  ]);
286
- /** Texture decodes still in flight. A RENDER MUST NOT START BEFORE THEY LAND:
287
- * `createImageBitmap` is asynchronous, so a frame drawn in the same turn the
288
- * material arrived would use an empty texture and look exactly like the
289
- * untextured render this whole path exists to stop. (A RASTER needs no wait:
290
- * a `DataTexture` holds its bytes the moment it is built.) */
291
- const pendingTextures = new Set<Promise<void>>();
292
-
293
- export async function texturesReady(): Promise<void> {
294
- while (pendingTextures.size > 0) await Promise.all([...pendingTextures]);
295
- }
296
-
297
331
  /** An image file's own bytes (PNG or JPEG) as a three texture, decoded by the browser's own
298
332
  * decoder. */
299
333
  function loadEncodedTexture(
300
334
  encoded: Uint8Array,
301
- mime: 'image/png' | 'image/jpeg',
335
+ mime: 'image/png' | 'image/jpeg' | 'image/webp',
302
336
  ): { texture: THREE.Texture; ready: Promise<void> } {
303
337
  const blob = new Blob([encoded as BlobPart], { type: mime });
304
338
  const texture = new THREE.Texture();
339
+ // Decoded rows are bottom-first for Blender's v-up UVs. Keep flipY false
340
+ // for exporters as well; WebGL ignores that flag for ImageBitmap sources.
341
+ texture.flipY = false;
305
342
  let disposed = false;
306
343
  texture.addEventListener('dispose', () => {
307
344
  disposed = true;
308
- texture.image?.close?.();
345
+ // r186's `Texture` types its image by a parameter, unknown here: an ImageBitmap once decoded.
346
+ (texture.image as ImageBitmap | null | undefined)?.close?.();
309
347
  });
310
- // DECODED BOTTOM ROW FIRST, because that is the row Blender's v=0 is. A PNG
311
- // stores its rows top-down -- this one is a FILE-backed image's own bytes,
312
- // sent as they sit on disk -- while a UV's v=0 in Blender samples the
313
- // image's BOTTOM row. A bitmap decoded in the PNG's own order therefore drew
314
- // every textured surface upside down: measured on `20-rigged-courier`, the
315
- // courier's painted face came back with the brows below the eyes and the
316
- // mouth under the hat.
317
- //
318
- // The flip has to be asked for HERE and cannot be asked for on the texture:
319
- // `Texture.flipY` (and `premultiplyAlpha`) are `UNPACK_FLIP_Y_WEBGL` state,
320
- // which WebGL ignores for an ImageBitmap source -- three says so itself at
321
- // `three/src/textures/Texture.js:274` (0.180.0), "this property has no
322
- // effect when using `ImageBitmap`. You need to configure the flip on bitmap
323
- // creation instead."
324
- //
325
- // Its alpha is asked for here too: the browser's `default` premultiplies (Chrome's does), and
326
- // every reader of this texture takes the file's own texels, straight unless the image says it
327
- // stores them premultiplied, which the reader then undoes. And its colour is the file's own: no
328
- // embedded profile or gamma is applied, because Blender reads the texels as stored and applies
329
- // the image's colour space setting itself, as the sampler does here.
348
+ // Decode image bitmaps bottom-row-first for Blender UVs, with explicit alpha and colour-conversion options. WebGL ignores Texture.flipY and premultiplyAlpha for ImageBitmap sources.
330
349
  const decoding = createImageBitmap(blob, {
331
350
  imageOrientation: 'flipY',
332
351
  premultiplyAlpha: 'none',
@@ -340,11 +359,7 @@ function loadEncodedTexture(
340
359
  texture.image = bitmap;
341
360
  texture.needsUpdate = true;
342
361
  presenterChanged();
343
- })
344
- .finally(() => {
345
- pendingTextures.delete(decoding);
346
362
  });
347
- pendingTextures.add(decoding);
348
363
  // Keep a rejection handled even if a document closes before its first
349
364
  // render. The owner's ready promise still reports that same failure.
350
365
  void decoding.catch(() => {});
@@ -425,8 +440,8 @@ function udimTextures(
425
440
  * one; a FILE's own PNG does not, because the decoder reads it off the bitmap
426
441
  * and asking Blender would have decoded the file just to answer
427
442
  * (`_image_values.frame_image`). Both carry the REVISION those bytes are. The
428
- * `*Base64` spellings are the same bytes on a channel with no transfer list
429
- * (`dispatch.mts`, `runtime_present`). */
443
+ * `*Base64` spellings are the same bytes on a channel with no transfer list.
444
+ */
430
445
  const rasterImageSchema = z
431
446
  .object({
432
447
  width: z.number().int().positive(),
@@ -447,16 +462,13 @@ const rasterImageSchema = z
447
462
  }).strict()).optional(),
448
463
  })
449
464
  .strict();
450
- /** An image as its FILE'S OWN BYTES, PNG or JPEG (`session.py::_encoded_image`): what the file
451
- * already carries compressed, decoded by the browser into the texture, so neither side holds
452
- * the picture as raw RGBA. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material images
453
- * are 171 MB as their packed files and 1,343 MB as RGBA. */
465
+ /** An encoded PNG or JPEG from session.py::_encoded_image, decoded by the browser without carrying raw RGBA through the frame. */
454
466
  const encodedImageSchema = z
455
467
  .object({
456
468
  revision: z.number().int().nonnegative(),
457
469
  encoded: z.instanceof(Uint8Array).optional(),
458
470
  encodedBase64: z.string().optional(),
459
- mime: z.enum(['image/png', 'image/jpeg']),
471
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
460
472
  colorspace: z.enum(['sRGB', 'data']).optional(),
461
473
  })
462
474
  .strict();
@@ -510,6 +522,12 @@ const materialSchema = z
510
522
  .object({ color: z.tuple([scalar, scalar, scalar, scalar]), roughness: scalar, metallic: scalar })
511
523
  .strict()
512
524
  .optional(),
525
+ /** `Material.use_backface_culling_shadow`: shadows from the faces turned to the light only. */
526
+ backface_culling_shadow: z.boolean().optional(),
527
+ /** `Material.use_backface_culling`: the back faces are not drawn (Rendered and Material Preview). */
528
+ backface_culling: z.boolean().optional(),
529
+ /** `Material.use_transparency_overlap`: a blended surface draws all its faces in one pass. */
530
+ transparency_overlap: z.boolean().optional(),
513
531
  })
514
532
  .strict();
515
533
  /** THE FRAME CONTRACT, and the reason it is exported: the C++ export door
@@ -595,8 +613,7 @@ export const frameSchema = z
595
613
  /** `scene.frame_current`. */
596
614
  frame: z.number().int().default(0),
597
615
  /**
598
- * THE INSPECTION OVERLAYS (WORK.md §Blender in the tab is Blender,
599
- * "Inspection parity", I4), keyed by armature OBJECT name. What Blender's
616
+ * Inspection overlays keyed by armature object name. What Blender's
600
617
  * own overlay engine READS, never what it draws: per bone a pose matrix, a
601
618
  * length, a parent and its flags, plus the armature's display type and
602
619
  * `show_in_front`. `blender-runtime-armature.ts` is the drawing.
@@ -639,9 +656,44 @@ export const frameSchema = z
639
656
  shading: z.enum(['WIREFRAME', 'SOLID', 'MATERIAL', 'RENDERED']).optional(),
640
657
  scene_world: z.boolean().optional(),
641
658
  scene_lights: z.boolean().optional(),
659
+ /** Solid's object outline (`View3DShading.show_object_outline`) and its colour. */
660
+ show_object_outline: z.boolean().optional(),
661
+ object_outline_color: z.tuple([z.number(), z.number(), z.number()]).optional(),
662
+ /** Solid's Specular Lighting (`View3DShading.show_specular_highlight`). */
663
+ show_specular_highlight: z.boolean().optional(),
664
+ /** What Solid and Wireframe draw behind the model (`View3DShading.background_type`), the
665
+ * VIEWPORT colour (`background_color`, scene-linear) and the World's Viewport Display
666
+ * colour (`World.color`, scene-linear) a WORLD background shows. */
667
+ background_type: z.enum(['THEME', 'WORLD', 'VIEWPORT']).optional(),
668
+ background_color: z.tuple([z.number(), z.number(), z.number()]).optional(),
669
+ world_color: z.tuple([z.number(), z.number(), z.number()]).nullable().optional(),
642
670
  })
643
671
  .nullable()
644
672
  .optional(),
673
+ /** How the viewport anti-aliases (`session.py`): the scene's engine, Workbench's
674
+ * `display.viewport_aa`, EEVEE's viewport `taa_samples` and the film's `filter_size`. */
675
+ sampling: z
676
+ .object({
677
+ engine: z.string(),
678
+ viewport_aa: z.string(),
679
+ taa_samples: z.number().int().nonnegative(),
680
+ filter_size: z.number().finite().nonnegative(),
681
+ })
682
+ .strict()
683
+ .optional(),
684
+ /** The scene's colour management as this frame was presented with it (`session.py`):
685
+ * `view_settings` (exposure in stops) and the display device. Rendered and Material Preview
686
+ * are drawn through it ({@link BlenderRuntimeView.displaySettings}). */
687
+ display: z
688
+ .object({
689
+ view_transform: z.string(),
690
+ look: z.string(),
691
+ exposure: z.number().finite(),
692
+ gamma: z.number().finite(),
693
+ display_device: z.string(),
694
+ })
695
+ .strict()
696
+ .optional(),
645
697
  /** The viewport's subject line as Blender composes it (`draw_selected_name`), less the
646
698
  * frame and its marker, with the scene's markers in list order. */
647
699
  subject: z
@@ -657,7 +709,8 @@ export const frameSchema = z
657
709
  .optional(),
658
710
  })
659
711
  .strict();
660
- type Frame = z.infer<typeof frameSchema>;
712
+ /** One presented frame, as `applyFrame` takes it (`frameSchema`). */
713
+ export type Frame = z.infer<typeof frameSchema>;
661
714
 
662
715
  /** `BKE_scene_find_marker_name`: the list walked from both ends at once, the front one asked
663
716
  * first, so of several markers on one frame the answer is not simply the first. */
@@ -693,6 +746,32 @@ const LENGTH_UNITS: Readonly<Record<'METRIC' | 'IMPERIAL', readonly (readonly [n
693
746
  [1609.344, 'Miles'],
694
747
  ],
695
748
  };
749
+ /** The factory theme's 3D Viewport background (`userdef_default_theme.c`, `high_gradient`,
750
+ * single colour): 0x3d. */
751
+ const THEME_VIEWPORT_BACK = 0x3d / 255;
752
+
753
+ /**
754
+ * SOLID'S BACKGROUND as Blender's viewport shows it (`overlay_background.cc`), for a saved 3D
755
+ * View. The overlay draws it "alpha under" the render, and the OCIO display shader merges the two
756
+ * in display-linear: `pow(c, 2.2)`, the overlay added, `pow(c, 1/2.2)`
757
+ * (`gpu_shader_display_transform_frag.glsl`). So a colour the overlay holds is shown as
758
+ * `pow(c, 1/2.2)`:
759
+ * - THEME: the theme's colour taken to linear by `srgb_to_linearrgb` (`overlay_instance.cc`), so
760
+ * the factory grey 0x3d (61) is shown as 63;
761
+ * - WORLD: the World's Viewport Display colour (`world->horr`), as it is;
762
+ * - VIEWPORT: the shading's `background_color`, as it is.
763
+ * The stage photographs Solid over this colour by the same merge (`PhotographSampling.backdropMerge`).
764
+ */
765
+ function savedBackground(saved: NonNullable<Frame['view']>): string {
766
+ const theme = THREE.MathUtils.clamp(THEME_VIEWPORT_BACK, 0, 1);
767
+ const themeLinear = new THREE.Color().setRGB(theme, theme, theme, THREE.SRGBColorSpace);
768
+ const held = saved.background_type === 'WORLD' ? (saved.world_color ?? null)
769
+ : saved.background_type === 'VIEWPORT' ? (saved.background_color ?? null) : null;
770
+ const [r, g, b] = held ?? [themeLinear.r, themeLinear.g, themeLinear.b];
771
+ const shown = (c: number) => Math.round(Math.max(0, Math.min(1, c)) ** (1 / 2.2) * 255);
772
+ return `#${[r, g, b].map((c) => shown(c).toString(16).padStart(2, '0')).join('')}`;
773
+ }
774
+
696
775
  /** One render's two poses, in Blender's own (Z-up) frame. */
697
776
  export interface PhotographRecord {
698
777
  /** `scene.camera.matrix_world`, as `session.py::_photograph` sent it. */
@@ -742,31 +821,26 @@ export class BlenderRuntimeView {
742
821
  private readonly lights = new Map<string, THREE.Light>();
743
822
  private readonly lighting = new ViewportLighting();
744
823
  private readonly world = new WorldBackground();
745
- private readonly worldVisibility=new BlenderWorldVisibility();
746
824
  /**
747
- * THE INSPECTION OVERLAYS (I4). They are NOT children of {@link root}, and
825
+ * Inspection overlays are not children of {@link root}, and
748
826
  * that is the whole design: an overlay is EDITOR FURNITURE, so its
749
827
  * visibility belongs to the viewport's Helpers menu rather than to this
750
828
  * presenter, and the door for that is the host's own — a document hands
751
- * each group to its stage's `setHelper(kind, object)`
752
- * (`@volter/sdk/host`, `EditorHostStage`), which marks it
829
+ * each group to editor-threejs's viewport `setHelper(kind, object)`, which marks it
753
830
  * `editorHelper`, keeps it out of the hierarchy and the raycast, and turns
754
831
  * it on and off with the kind's checkbox. `blender-runtime.document.tsx`
755
832
  * is where they are handed over.
756
833
  *
757
- * They therefore carry the Blender → document permutation THEMSELVES
758
- * ({@link overlayRoots}), because a helper is a scene-root object and does
759
- * not inherit this root's matrix.
834
+ * Each helper root carries the Blender → document permutation because it
835
+ * is a scene-root object and does not inherit this root's matrix.
760
836
  */
761
837
  private readonly armatureOverlay = new ArmatureOverlay();
762
838
  private readonly weightOverlay = new WeightOverlay();
763
839
  private readonly cursorOverlay = new CursorOverlay();
764
840
  /** Blender's overlay extras: cameras, lights and empties (`blender-runtime-extras.ts`). */
765
841
  private readonly extrasOverlay = new ExtrasOverlay();
766
- /** The two groups the stage is handed: the Helpers menu owns THEIR
767
- * `visible`, and the inner group is what a RENDER stands down (an overlay
768
- * is modeling chrome and never appears in a photograph — the same
769
- * distinction `applyVisibility` draws for the scene's own objects). */
842
+ /** The Helpers menu controls these roots' visibility. Their inner overlay
843
+ * groups are hidden during photographs without changing the menu state. */
770
844
  private readonly armatureRoot = new THREE.Group();
771
845
  private readonly weightRoot = new THREE.Group();
772
846
  private readonly cursorRoot = new THREE.Group();
@@ -778,6 +852,8 @@ export class BlenderRuntimeView {
778
852
  private readonly hiddenSurfaceLayers = new WeakMap<THREE.Object3D,number>();
779
853
  private shadowFit: {
780
854
  camera: THREE.Camera | undefined;
855
+ /** Fitted to the surfaces the camera sees (a renderer was at hand), not to the frustum. */
856
+ visible: boolean;
781
857
  cameraMatrix: THREE.Matrix4 | undefined;
782
858
  projection: THREE.Matrix4 | undefined;
783
859
  solarLights: THREE.DirectionalLight[];
@@ -799,7 +875,11 @@ export class BlenderRuntimeView {
799
875
  * (`blender-workbench-material.ts`) over their viewport display. */
800
876
  private workbench = false;
801
877
  private readonly workbenchMaterials = new Map<string, THREE.Material>();
802
- private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72, side: THREE.DoubleSide, shadowSide: THREE.BackSide });
878
+ /** Solid's object outline (`blender-workbench-outline.ts`). */
879
+ private readonly outline = new WorkbenchOutline();
880
+ /** What the drawing camera sees, for a sun's shadow map (`blender-runtime-shadows.ts`). */
881
+ private readonly visibleReceivers = new VisibleReceivers();
882
+ private readonly fallback = new THREE.MeshPhysicalMaterial({ color: 0xb9bec6, roughness: 0.72, side: THREE.DoubleSide, shadowSide: THREE.DoubleSide });
803
883
  /** Base Color images, by image name -- ONE texture per image however many
804
884
  * materials read it, and the cache OWNS it: a material points at one and
805
885
  * never disposes it. Held with the size and revision the resident bytes
@@ -812,7 +892,7 @@ export class BlenderRuntimeView {
812
892
  width: number;
813
893
  height: number;
814
894
  revision: number;
815
- encoded?: { bytes: Uint8Array; mime: 'image/png' | 'image/jpeg'; colorspace: 'sRGB' | 'data' };
895
+ encoded?: { bytes: Uint8Array; mime: 'image/png' | 'image/jpeg' | 'image/webp'; colorspace: 'sRGB' | 'data' };
816
896
  ready?: Promise<void>;
817
897
  /** A UDIM image's tiles as a graph samples them (`udimTextures`). */
818
898
  udim?: {tiles: THREE.DataArrayTexture; map: THREE.DataTexture; frame: NonNullable<z.infer<typeof rasterImageSchema>['tiles']>};
@@ -856,12 +936,11 @@ export class BlenderRuntimeView {
856
936
  private readonly submittedMeshes = new Map<string, unknown>();
857
937
  private submitted: Record<string, unknown> | null = null;
858
938
  private submittedSession: string | null = null;
859
- /** EVERY PHOTOGRAPH THIS TAB TOOK FOR A RENDER: the pose the session sent in
860
- * Blender's frame, and the pose the photograph was actually taken from,
861
- * back in that same frame (`blender-runtime-host.ts`). Python asserts the
862
- * two are equal on every render; this is the record a HARNESS grades, and
863
- * it is written before that assertion can throw, so a conversion defect
864
- * leaves both poses behind instead of only an exception. */
939
+ /**
940
+ * Record the scene-camera pose sent by Blender and the photograph’s actual pose transformed back
941
+ * into Blender’s frame. Python asserts equality on every render; retain both poses before that
942
+ * assertion can throw.
943
+ */
865
944
  private readonly photographs: PhotographRecord[] = [];
866
945
  /** WHO WANTS TO KNOW THE MODEL MOVED. The Properties sections read the
867
946
  * engine through the RNA door, and a frame is the one signal in the tab
@@ -876,14 +955,12 @@ export class BlenderRuntimeView {
876
955
  private leader: BlenderRuntimeView | null = null;
877
956
 
878
957
  /**
879
- * A POSE MOVED HERE MID-GESTURE, DRAWN BY EVERY OTHER VIEW OF THE MODEL NOW. The gizmo moves a
880
- * presented object in the view whose area it is dragged in (`blender-outliner-authoring.ts`,
881
- * `apply`), and Blender's frame for the move comes only when the gesture ends; without this the
882
- * other area — a Rendered one, as Blender's always-on render preview is — stood still until the
883
- * release, where Blender redraws every area on every move. Each view holds its own object for
884
- * the same Blender object (the frame's id) under the same parent, so the local matrix carries.
958
+ * Mirror a live gizmo move into every other view of the same model using the presented object’s
959
+ * local matrix. Blender confirms the pose when the gesture ends. Signal every move so drawing and
960
+ * temporal accumulation follow it in single-view and split-view documents.
885
961
  */
886
962
  mirrorPose(object: THREE.Object3D): void {
963
+ presenterChanged();
887
964
  let id: string | null = null;
888
965
  for (const [key, held] of this.objects)
889
966
  if (held === object) {
@@ -892,7 +969,6 @@ export class BlenderRuntimeView {
892
969
  }
893
970
  if (id === null) return;
894
971
  const views = this.leader ? [this.leader, ...this.leader.followers] : [...this.followers];
895
- let moved = false;
896
972
  for (const view of views) {
897
973
  if (view === this) continue;
898
974
  const twin = view.objects.get(id);
@@ -900,9 +976,7 @@ export class BlenderRuntimeView {
900
976
  twin.matrix.copy(object.matrix);
901
977
  twin.matrix.decompose(twin.position, twin.quaternion, twin.scale);
902
978
  twin.updateMatrixWorld(true);
903
- moved = true;
904
979
  }
905
- if (moved) presenterChanged();
906
980
  }
907
981
 
908
982
  /** Subscribe to frames. Returns the unsubscribe. */
@@ -936,35 +1010,19 @@ export class BlenderRuntimeView {
936
1010
  return null;
937
1011
  }
938
1012
 
939
- /**
940
- * BLENDER'S OWN SELECTION, AS THIS FRAME CARRIES IT — the per-object
941
- * `selected` flag and the scene's `active`, by Blender datablock NAME.
942
- *
943
- * The session exports both on every present, which is what makes the FRAME
944
- * the state rather than a copy of it: an agent's `select_set` through the
945
- * script door, an operator that activates what it just created, and a
946
- * person's click in this editor all land in one place and come back the
947
- * same way. Nothing in the tab holds a selection of its own —
948
- * `blender-outliner-authoring.ts` reads this on every frame and overwrites
949
- * the cache its panels render from.
950
- *
951
- * THE ACTIVE OBJECT IS REPORTED SEPARATELY because Blender's own state
952
- * allows an active object that is not selected, and the Properties editor
953
- * follows the ACTIVE one while the outline follows the selected set.
954
- */
1013
+ noteSavedShading(shading: keyof typeof SAVED_SHADING): void {
1014
+ if (this.frame?.view) this.frame.view.shading = shading;
1015
+ }
1016
+
955
1017
  /**
956
1018
  * WHERE BLENDER OPENS THIS FILE: its saved 3D View in the stage's frame — the pivot, the
957
1019
  * direction from the pivot to the eye (the view's +Z, `view_rotation` turning view space into
958
1020
  * the world: RNA's inverse of `viewquat`), the screen's up (the view's +Y), the distance and
959
- * the projection. A view saved through the scene camera opens in perspective along the same
960
- * axis: looking through a camera is not a view this stage has. With it the view's SHADING, the
1021
+ * the projection. A saved camera view supplies a perspective orbit pose along its saved axis;
1022
+ * `cameraView` separately provides the through-camera projection. With it the view's SHADING, the
961
1023
  * draw mode Blender reopens it in (null from a file whose view carried none). Null before a
962
1024
  * frame or when the file saved no 3D View.
963
1025
  */
964
- noteSavedShading(shading: keyof typeof SAVED_SHADING): void {
965
- if (this.frame?.view) this.frame.view.shading = shading;
966
- }
967
-
968
1026
  savedView(): {
969
1027
  readonly target: readonly [number, number, number];
970
1028
  readonly direction: readonly [number, number, number];
@@ -975,6 +1033,8 @@ export class BlenderRuntimeView {
975
1033
  readonly drawMode: 'wireframe' | 'solid' | 'preview' | 'rendered' | null;
976
1034
  readonly sceneWorld: boolean;
977
1035
  readonly sceneLights: boolean;
1036
+ /** Solid's and Wireframe's resolved background as an sRGB `#rrggbb`. */
1037
+ readonly background: string;
978
1038
  } | null {
979
1039
  const saved = this.frame?.view;
980
1040
  if (!saved) return null;
@@ -992,9 +1052,20 @@ export class BlenderRuntimeView {
992
1052
  drawMode: saved.shading ? SAVED_SHADING[saved.shading] : null,
993
1053
  sceneWorld: saved.scene_world ?? false,
994
1054
  sceneLights: saved.scene_lights ?? false,
1055
+ background: savedBackground(saved),
995
1056
  };
996
1057
  }
997
1058
 
1059
+ #playbackCamera: string | null = null;
1060
+ #playbackCameraData: Record<string, unknown> | null = null;
1061
+ /** The camera the scene's playback shows at the Timeline's frame (the markers' cut), and how it
1062
+ * stands there (its matrix and projection as the Timeline's mixer posed it); null: the scene's
1063
+ * own camera, as Blender last evaluated it. */
1064
+ setPlaybackCamera(name: string | null, data: Record<string, unknown> | null = null): void {
1065
+ this.#playbackCamera = name;
1066
+ this.#playbackCameraData = name === null ? null : data;
1067
+ }
1068
+
998
1069
  /**
999
1070
  * THE CAMERA A CAMERA VIEW ENTERED NOW WOULD LOOK THROUGH, as `view_camera_exec` picks it:
1000
1071
  * the scene's camera, else the active object when it is a camera, else the view layer's first
@@ -1004,6 +1075,11 @@ export class BlenderRuntimeView {
1004
1075
  const frame = this.frame;
1005
1076
  const view = frame?.camera_view;
1006
1077
  if (!frame || !view) return null;
1078
+ // WHILE THE TIMELINE PLAYS OR SCRUBS, the camera of the marker at that frame (Blender switches
1079
+ // `scene.camera` at bound markers as it plays; the frame here is the one it last evaluated)
1080
+ // (described by the Timeline itself when Blender's last frame did not carry it: a present ships
1081
+ // only what moved)
1082
+ if (this.#playbackCamera !== null && (this.#playbackCameraData || frame.cameras[this.#playbackCamera])) return this.#playbackCamera;
1007
1083
  if (view.scene_camera !== null && frame.cameras[view.scene_camera]) return view.scene_camera;
1008
1084
  if (frame.active !== null && frame.cameras[frame.active]) return frame.active;
1009
1085
  return view.view_layer_cameras.find((name) => frame.cameras[name]) ?? null;
@@ -1037,14 +1113,19 @@ export class BlenderRuntimeView {
1037
1113
  offset: readonly [number, number],
1038
1114
  ): BlenderCameraView | null {
1039
1115
  const frame = this.frame;
1040
- const data = cameraDataSchema.safeParse(frame?.cameras[camera]);
1116
+ const evaluated = frame?.cameras[camera];
1117
+ // (while the Timeline plays, the camera as its mixer posed it at that frame)
1118
+ const posed = camera === this.#playbackCamera && this.#playbackCameraData
1119
+ ? { ...((evaluated ?? {}) as Record<string, unknown>), ...this.#playbackCameraData } : evaluated;
1120
+ const data = cameraDataSchema.safeParse(posed);
1041
1121
  if (!frame?.camera_view || !data.success) return null;
1042
1122
  return blenderCameraView(data.data, this.root.matrix, region, zoom, offset, frame.camera_view.aspect);
1043
1123
  }
1044
1124
 
1045
- /** The viewport's subject line at `frame`, `(1) Collection | Cube`: the engine's body with
1046
- * the frame before it and that frame's marker after it; `frame` null is the scene's own
1047
- * `frame_current`. Null before a frame or from an engine that does not report it. */
1125
+ /**
1126
+ * The viewport subject line at frame, prefixed by the frame and followed by its marker. A null
1127
+ * frame uses scene.frame_current; null output means no subject line is held.
1128
+ */
1048
1129
  subjectLine(playhead: number | null): string | null {
1049
1130
  const subject = this.frame?.subject;
1050
1131
  if (subject === undefined) return null;
@@ -1071,6 +1152,22 @@ export class BlenderRuntimeView {
1071
1152
  return step[1];
1072
1153
  }
1073
1154
 
1155
+ /**
1156
+ * BLENDER'S OWN SELECTION, AS THIS FRAME CARRIES IT — the per-object
1157
+ * `selected` flag and the scene's `active`, by Blender datablock NAME.
1158
+ *
1159
+ * The session exports both on every present, which is what makes the FRAME
1160
+ * the state rather than a copy of it: an agent's `select_set` through the
1161
+ * script door, an operator that activates what it just created, and a
1162
+ * person's click in this editor all land in one place and come back the
1163
+ * same way. Nothing in the tab holds a selection of its own —
1164
+ * `blender-outliner-authoring.ts` reads this on every frame and overwrites
1165
+ * the cache its panels render from.
1166
+ *
1167
+ * THE ACTIVE OBJECT IS REPORTED SEPARATELY because Blender's own state
1168
+ * allows an active object that is not selected, and the Properties editor
1169
+ * follows the ACTIVE one while the outline follows the selected set.
1170
+ */
1074
1171
  blenderSelection(): { readonly selected: readonly string[]; readonly active: string | null } {
1075
1172
  const frame = this.frame;
1076
1173
  if (frame === null) return { selected: [], active: null };
@@ -1080,20 +1177,7 @@ export class BlenderRuntimeView {
1080
1177
  };
1081
1178
  }
1082
1179
 
1083
- /**
1084
- * BLENDER'S MODE, PUBLISHED ON THE DOCUMENT'S OWN CONTEXT.
1085
- *
1086
- * `volter.stage.mode` (U6's context key, `editor-host-door.ts`'s `stage()`) had
1087
- * no reporter at all: Edit Mesh went with the mesh kit and the Model document
1088
- * had no mode strip, so every `when` clause that reads it was answering
1089
- * `null`. The engine HAS a mode — `bpy.context.mode`, and `Object.mode` on the
1090
- * active object, which is what the Outliner's own pose rows key on
1091
- * (`tree_element_pose.cc`: the channels exist only in pose mode) — and the
1092
- * TREE door reports both. So the document publishes it here, on the context it
1093
- * already publishes (`publishContext(view)`), rather than through a second
1094
- * door: `blender-outliner-model.ts` sets it on every read, and the host's
1095
- * `stage()` asks the ACTIVE document's context for it.
1096
- */
1180
+ /** Publish bpy.context.mode and active-object mode on the document context for stage conditions and the Outliner. */
1097
1181
  private mode: string | null = null;
1098
1182
 
1099
1183
  setStageMode(mode: string | null): void {
@@ -1106,19 +1190,13 @@ export class BlenderRuntimeView {
1106
1190
 
1107
1191
  /** PLAY IS A MODE OF THE STAGE: while the document plays, the keys are the player's, and the
1108
1192
  * mode this context publishes is what the frame's key rules stand down on
1109
- * (`scripts/workbench/generate-keymaps.mjs`, `whenFor`). Blender's own mode returns on stop. */
1193
+ * (`volter.stage.mode != 'PLAY'`). Blender's own mode returns on stop. */
1110
1194
  private playing = false;
1111
1195
 
1112
1196
  setPlaying(playing: boolean): void {
1113
1197
  this.playing = playing;
1114
1198
  }
1115
1199
 
1116
- /** The inverse: the presented object for a Blender datablock NAME, or null
1117
- * when this tab is not showing one (a camera, an excluded collection's
1118
- * member, an object type the presenter draws nothing for). The OUTLINER
1119
- * needs it — its rows come from the engine by name, and every one that our
1120
- * viewport can select has to find its way back to a three object through
1121
- * the frame's own table, never through `object.name`. */
1122
1200
  /**
1123
1201
  * WHAT THE LAST FRAME SAYS ABOUT ANIMATION, for a reader that plays clips on `skeletons`: the
1124
1202
  * scene's current frame and clock, every action's revision, each armature's assigned action, the
@@ -1136,6 +1214,9 @@ export class BlenderRuntimeView {
1136
1214
  * pose), its action and its NLA stack. */
1137
1215
  armatures: Readonly<Record<string, BlenderArmature>>;
1138
1216
  skinArmature(object: string): string | null;
1217
+ /** The scene's camera, every camera's settings and placement, as one string: the movie, which
1218
+ * carries them, is read again when it changes (switching the scene camera, an unkeyed lens). */
1219
+ cameras: string;
1139
1220
  } {
1140
1221
  const frame = this.frame;
1141
1222
  return {
@@ -1145,6 +1226,11 @@ export class BlenderRuntimeView {
1145
1226
  active: frame?.active ?? null,
1146
1227
  actions: frame?.actions ?? {},
1147
1228
  armatures: frame?.armatures ?? {},
1229
+ cameras: JSON.stringify([
1230
+ frame?.camera_view?.scene_camera ?? null,
1231
+ frame?.cameras ?? {},
1232
+ (frame?.objects ?? []).filter((o) => o.type === 'CAMERA').map((o) => [o.name, o.matrix]),
1233
+ ]),
1148
1234
  skinArmature: (name) => {
1149
1235
  const object = this.objectForBlenderName(name) as THREE.Mesh | null;
1150
1236
  const skin = object?.geometry?.userData['blenderSkin'] as { armature: string } | undefined;
@@ -1153,6 +1239,12 @@ export class BlenderRuntimeView {
1153
1239
  };
1154
1240
  }
1155
1241
 
1242
+ /** The inverse: the presented object for a Blender datablock NAME, or null
1243
+ * when this tab is not showing one (a camera, an excluded collection's
1244
+ * member, an object type the presenter draws nothing for). The OUTLINER
1245
+ * needs it — its rows come from the engine by name, and every one that our
1246
+ * viewport can select has to find its way back to a three object through
1247
+ * the frame's own table, never through `object.name`. */
1156
1248
  objectForBlenderName(name: string): THREE.Object3D | null {
1157
1249
  const entry = this.frame?.objects.find((o) => o.name === name);
1158
1250
  return entry === undefined ? null : (this.objects.get(entry.id) ?? null);
@@ -1162,16 +1254,8 @@ export class BlenderRuntimeView {
1162
1254
  * Put `next` where `previous` stood — same parent, same id in the object
1163
1255
  * table, same place among its siblings' children.
1164
1256
  *
1165
- * THE ONE CALLER IS THE SKIN (`blender-runtime-skin.ts`): a mesh that turns
1166
- * out to be deformed by an armature has to become a `THREE.SkinnedMesh`, and
1167
- * three has no way to promote a `Mesh` in place. It goes through this method
1168
- * rather than the map directly because the table's KEY is the frame's object
1169
- * id and only this class knows it — a swap that missed the table would be
1170
- * replaced again by the next frame's reuse check, every frame, forever.
1171
- *
1172
- * A `SkinnedMesh` still answers `isMesh`, so the next frame's check
1173
- * (`isMesh === (obj.mesh !== null)`) keeps it and merely re-points its
1174
- * geometry and materials, which is exactly right.
1257
+ * Frame application and skeleton promotion use this door so replacements
1258
+ * retain the frame's object identity and the next frame can reuse them.
1175
1259
  */
1176
1260
  replacePresentedObject(previous: THREE.Object3D, next: THREE.Object3D): void {
1177
1261
  for (const [id, object] of this.objects)
@@ -1194,14 +1278,9 @@ export class BlenderRuntimeView {
1194
1278
  this.instances.sync();
1195
1279
  };
1196
1280
  this.root.name = 'Model';
1197
- // BLENDER IS Z-UP, THREE IS Y-UP: (x, y, z) -> (x, z, -y). Written as the
1198
- // matrix rather than `rotation.x = -Math.PI / 2`, because that Euler is a
1199
- // FLOAT quarter turn -- `Math.cos(-Math.PI / 2)` is 6.12e-17, not 0 -- and
1200
- // this matrix is the one a render inverts to answer with the pose it
1201
- // photographed from (`blender-runtime-host.ts`). An exact signed axis
1202
- // permutation inverts exactly, so that round trip is checkable with no
1203
- // tolerance; the float quarter turn is not, and it also leaves every model
1204
- // rotated by 89.999999999999996 degrees for nothing.
1281
+ // Map Blender Z-up into three.js Y-up with the exact signed permutation (x, y, z) → (x, z, -y).
1282
+ // Camera poses transformed through this matrix and its inverse round-trip without a
1283
+ // floating-point tolerance.
1205
1284
  this.root.matrixAutoUpdate = false;
1206
1285
  this.root.matrix.set(1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1);
1207
1286
  this.root.updateMatrixWorld(true);
@@ -1232,16 +1311,6 @@ export class BlenderRuntimeView {
1232
1311
  this.emptyExtrasRoot.name = 'BlenderEmptyExtras';
1233
1312
  }
1234
1313
 
1235
- /**
1236
- * THE OVERLAY GROUPS, for the document to hand to its stage.
1237
- *
1238
- * `kind` is the `HelperVisibility` member whose checkbox owns it: `skeletons`
1239
- * is the Helpers menu's existing Skeletons row, which is Blender's viewport
1240
- * overlay "Bones" checkbox (`View3DOverlay.show_bones`,
1241
- * `rna_space.cc:5125-5129`; `space_view3d.py:7161`) under this editor's own
1242
- * noun; `weights` is a member this unit added, because nothing in the set
1243
- * stood for a vertex-group weight display.
1244
- */
1245
1314
  /**
1246
1315
  * BLENDER'S SOLID-MODE STUDIO, for the document to hand to its stage as
1247
1316
  * `dressing.viewLocked`.
@@ -1257,6 +1326,15 @@ export class BlenderRuntimeView {
1257
1326
  return this.lighting.group;
1258
1327
  }
1259
1328
 
1329
+ /**
1330
+ * THE OVERLAY GROUPS, for the document to hand to its stage.
1331
+ *
1332
+ * `kind` is the `HelperVisibility` member whose checkbox owns it: `skeletons`
1333
+ * is the Helpers menu's Skeletons row, which is Blender's viewport
1334
+ * overlay "Bones" checkbox (`View3DOverlay.show_bones`,
1335
+ * `rna_space.cc:5125-5129`; `space_view3d.py:7161`) under this editor's own
1336
+ * noun; `weights` controls the vertex-group weight display.
1337
+ */
1260
1338
  overlayGroups(): readonly { kind: string; object: THREE.Object3D }[] {
1261
1339
  return [
1262
1340
  { kind: 'skeletons', object: this.armatureRoot },
@@ -1287,6 +1365,7 @@ export class BlenderRuntimeView {
1287
1365
  return placement;
1288
1366
  }
1289
1367
 
1368
+ private heldSceneLighting: { world: boolean; lights: boolean } | undefined;
1290
1369
  /**
1291
1370
  * BLENDER'S RENDERED SHADING: the viewport held in the lighting a render photographs with
1292
1371
  * ({@link setRendered}) — the scene's own lights, its World behind and around the model,
@@ -1296,7 +1375,6 @@ export class BlenderRuntimeView {
1296
1375
  * keeps viewport visibility independently of its shading and lighting choices.
1297
1376
  * A render taken meanwhile ends back in this state rather than in modeling.
1298
1377
  */
1299
- private heldSceneLighting: { world: boolean; lights: boolean } | undefined;
1300
1378
  holdRendered(drawCamera: (() => THREE.Camera) | null, sceneLighting?: { world: boolean; lights: boolean }, visibility: 'viewport' | 'render' = 'viewport'): void {
1301
1379
  if (drawCamera === this.heldRendered && sceneLighting?.world === this.heldSceneLighting?.world && sceneLighting?.lights === this.heldSceneLighting?.lights && visibility === this.heldVisibility) return;
1302
1380
  this.heldRendered = drawCamera;
@@ -1345,12 +1423,31 @@ export class BlenderRuntimeView {
1345
1423
  }
1346
1424
 
1347
1425
  private report(work: Promise<void>): void {
1426
+ this.renderedWork = work.then(() => undefined, () => undefined);
1348
1427
  void work.then(presenterChanged, (error: unknown) =>
1349
1428
  // biome-ignore lint/suspicious/noConsole: the editor console captures console.error session-wide; this is the report channel.
1350
1429
  console.error(`Rendered shading could not be applied: ${error instanceof Error ? error.message : String(error)}`),
1351
1430
  );
1352
1431
  }
1353
1432
 
1433
+ /** The Rendered shading the viewport was last put in, still settling (`applyRendered`). */
1434
+ private renderedWork: Promise<void> = Promise.resolve();
1435
+
1436
+ /**
1437
+ * WHAT THE FRAME ON SCREEN IS STILL WAITING ON before it draws as it will: Rendered shading's
1438
+ * settling (area-light tables, the World's sky) and every image's decode. A photograph through
1439
+ * {@link setRendered} waits for the same things; a view presented on the viewport waits here
1440
+ * (the model document's preparation), so a capture right after a model opens or Rendered is
1441
+ * entered does not catch any of them half-way. A graph program still compiling is the draw's
1442
+ * own business (`photographGraphs`).
1443
+ */
1444
+ async drawReady(): Promise<void> {
1445
+ await this.renderedWork;
1446
+ await lightingReady();
1447
+ await Promise.allSettled([...this.textures.values()].map((held) => held.ready));
1448
+ await this.world.ready().catch(() => undefined);
1449
+ }
1450
+
1354
1451
  /**
1355
1452
  * BLENDER'S SOLID SHADING ON OR OFF: while on, every presented surface is drawn by Blender's
1356
1453
  * Solid function over its material's viewport display; a render photograph, Material Preview
@@ -1363,6 +1460,29 @@ export class BlenderRuntimeView {
1363
1460
  presenterChanged();
1364
1461
  }
1365
1462
 
1463
+ /**
1464
+ * THE AUTHORED MATERIALS, whatever the viewport shows: `run` sees every presented mesh dressed in the
1465
+ * materials its object's slots name (what Material Preview and Rendered show), and each gets back what
1466
+ * it wore after. An export writes the model's own materials in Solid shading too (`blender-export-gltf`).
1467
+ */
1468
+ async withAuthoredMaterials<T>(run: () => Promise<T>): Promise<T> {
1469
+ const worn = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>();
1470
+ for (const obj of this.frame?.objects ?? []) {
1471
+ if (obj.mesh === null || obj.volume) continue;
1472
+ const mesh = this.objects.get(obj.id) as THREE.Mesh | undefined;
1473
+ if (!mesh?.isMesh) continue;
1474
+ const slots: readonly (string | null)[] = obj.materials.length ? obj.materials : [null];
1475
+ const authored = slots.map((id) => (id === null ? this.fallback : (this.materials.get(id) ?? this.fallback)));
1476
+ worn.set(mesh, mesh.material);
1477
+ mesh.material = obj.materials.length ? authored : authored[0]!;
1478
+ }
1479
+ try {
1480
+ return await run();
1481
+ } finally {
1482
+ for (const [mesh, material] of worn) mesh.material = material;
1483
+ }
1484
+ }
1485
+
1366
1486
  /** Each surface's material for the state: authored, or Solid's, derived from the frame every
1367
1487
  * time so a replaced node (the skin's) is never left wearing the other. */
1368
1488
  private applyWorkbench(): void {
@@ -1376,7 +1496,8 @@ export class BlenderRuntimeView {
1376
1496
  const slots: readonly (string | null)[] = obj.materials.length ? obj.materials : [null];
1377
1497
  const shown = slots.map((id) => {
1378
1498
  const authored = id === null ? this.fallback : (this.materials.get(id) ?? this.fallback);
1379
- return solid ? this.workbenchFor(id, authored.side, used) : this.objectInfoMaterials.get(authored,mesh);
1499
+ // Solid ignores a material's Backface Culling (the 3D View's own option governs it, off by default).
1500
+ return solid ? this.workbenchFor(id, THREE.DoubleSide, used) : this.objectInfoMaterials.get(authored,mesh);
1380
1501
  });
1381
1502
  mesh.material = obj.materials.length ? shown : shown[0]!;
1382
1503
  prepareGraphGeometry(mesh);
@@ -1416,7 +1537,9 @@ export class BlenderRuntimeView {
1416
1537
 
1417
1538
  /** Before the renderer uploads attributes/builds its queues, for this area's
1418
1539
  * actual camera. Transparent instance runs are camera-order dependent. */
1419
- prepareDraw(camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}): () => void {
1540
+ prepareDraw(camera: THREE.Camera, options?: BlenderDrawOptions): () => void {
1541
+ const outline = this.outlineColor(options);
1542
+ photographGraphs(options?.photograph === true);
1420
1543
  try {
1421
1544
  if (!this.workbench || this.rendered) {
1422
1545
  if(this.objectInfoMaterials.needsRemap()) this.applyWorkbench();
@@ -1425,19 +1548,11 @@ export class BlenderRuntimeView {
1425
1548
  const start = performance.now();
1426
1549
  this.instances.prepareDraw(camera);
1427
1550
  this.root.updateMatrixWorld(true);
1551
+ this.skeletons.fitBounds();
1428
1552
  // The script/navigation has just posed the camera and the detached
1429
1553
  // objects. Fit shadows for that draw, rather than the camera's pose
1430
1554
  // when Rendered shading was first entered. Captures use this path too.
1431
- if (this.rendered) this.applyShadows(true, camera);
1432
- if (WORLD_VISIBILITY) {
1433
- this.worldVisibility.sync(this.objects.values(),this.world.lightingTexture(),this.root,this.rendered);
1434
- for(const material of [...this.materials.values(),this.fallback])bindWorldVisibility(material,this.worldVisibility);
1435
- for(const object of this.objects.values()) {
1436
- const mesh=object as THREE.Mesh;if(!mesh.isMesh)continue;
1437
- for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])bindWorldVisibility(material,this.worldVisibility);
1438
- }
1439
- if(options?.renderer)this.worldVisibility.prepare(options.renderer);
1440
- }
1555
+ if (this.rendered) this.applyShadows(true, camera, options?.renderer, true);
1441
1556
  const opaqueDone = performance.now();
1442
1557
  const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1443
1558
  options?.interactive === true, options?.height ?? 0);
@@ -1455,6 +1570,7 @@ export class BlenderRuntimeView {
1455
1570
  this.drawPreparation.last = timing;
1456
1571
  if (motion) this.drawPreparation.navigation = timing;
1457
1572
  } catch (error) {
1573
+ photographGraphs(false);
1458
1574
  this.materialRanges.finish();
1459
1575
  this.motionGeometry.finish();
1460
1576
  this.instances.finishDraw();
@@ -1462,30 +1578,108 @@ export class BlenderRuntimeView {
1462
1578
  throw error;
1463
1579
  }
1464
1580
  return () => {
1465
- this.materialRanges.finish();
1466
- this.motionGeometry.finish();
1467
- this.instances.finishDraw();
1468
- this.transparentInstances.finishDraw();
1581
+ try {
1582
+ // Over the frame as it was just drawn: its draw geometry and batches still stand.
1583
+ if (outline && options?.renderer) this.drawOutline(options.renderer, camera, outline, options.pixelScale ?? 1);
1584
+ } finally {
1585
+ photographGraphs(false);
1586
+ this.materialRanges.finish();
1587
+ this.motionGeometry.finish();
1588
+ this.instances.finishDraw();
1589
+ this.transparentInstances.finishDraw();
1590
+ }
1591
+ };
1592
+ }
1593
+
1594
+ /** Solid's object outline colour for this draw, or null when it draws none: Blender's Solid
1595
+ * with the file's `show_object_outline` on, and a renderer to draw it with. */
1596
+ private outlineColor(options?: BlenderDrawOptions): readonly [number, number, number] | null {
1597
+ const saved = this.frame?.view;
1598
+ if (!options?.renderer || options.drawMode !== 'solid' || !this.workbench || this.rendered) return null;
1599
+ if (!saved?.show_object_outline) return null;
1600
+ return saved.object_outline_color ?? [0, 0, 0];
1601
+ }
1602
+
1603
+ private drawOutline(renderer: THREE.WebGLRenderer, camera: THREE.Camera, color: readonly [number, number, number], pixelScale: number): void {
1604
+ let scene: THREE.Object3D | null = this.root;
1605
+ while (scene && !(scene as THREE.Scene).isScene) scene = scene.parent;
1606
+ if (!scene) return;
1607
+ const presented = new Set(this.objects.values());
1608
+ // The presenter's own draws (batches) stand in for some objects; the id pass draws those
1609
+ // objects themselves, each with its own id, as Blender's id buffer holds them.
1610
+ const internal = this.root.children.filter((child) => (child as THREE.Mesh).isMesh && !presented.has(child));
1611
+ this.outline.draw(renderer, scene as THREE.Scene, camera, this.objects.values(),
1612
+ (object) => !this.hiddenSurfaceLayers.has(object), internal, color, pixelScale);
1613
+ }
1614
+
1615
+ /**
1616
+ * HOW EACH SHADING TYPE IS ANTI-ALIASED, as Blender's viewport accumulates it
1617
+ * (`blender-viewport-sampling.ts` holds both models): a photograph draws every sample at once,
1618
+ * and the stage's live view one per redraw while nothing changes, as Blender's viewport does.
1619
+ * - SOLID: Workbench's accumulation at the Preferences' Viewport Anti-Aliasing
1620
+ * (`workbench_state.cc`: `U.viewport_aa` whatever the engine), whose factory value, 8, the tab
1621
+ * assumes: it holds no Blender preferences. Merged over its backdrop as the display shader
1622
+ * merges the overlay.
1623
+ * - RENDERED: Workbench at the scene's `display.viewport_aa` when Workbench is the engine ('OFF'
1624
+ * one centred sample, unfiltered: no anti-aliasing at all; 'FXAA' its one centred sample, the
1625
+ * SMAA Workbench lays over it not reproduced); EEVEE's film otherwise, at its viewport
1626
+ * `taa_samples` and `filter_size`.
1627
+ * - WIREFRAME: none here. Blender's wires are overlay lines smoothed by their own pass, not
1628
+ * accumulated, so a wireframe photograph keeps the stage's own supersampling and the live
1629
+ * view its MSAA.
1630
+ * - MATERIAL PREVIEW: EEVEE's film, whatever the engine.
1631
+ * Null before a frame.
1632
+ */
1633
+ photographSampling(): Partial<Record<'solid' | 'wireframe' | 'preview' | 'rendered', BlenderPhotographSampling>> | null {
1634
+ const sampling = this.frame?.sampling;
1635
+ if (!sampling) return null;
1636
+ // The same answer for the same settings: the stage keeps its live accumulation while the
1637
+ // sampling it was begun by is the one it is handed (every Blender frame hands it again).
1638
+ const key = `${sampling.engine}|${sampling.viewport_aa}|${sampling.taa_samples}|${sampling.filter_size}`;
1639
+ if (this.#sampling?.key === key) return this.#sampling.value;
1640
+ const film = eeveeSampling(sampling.taa_samples, sampling.filter_size);
1641
+ const sceneAa = sampling.viewport_aa === 'OFF' ? 0 : sampling.viewport_aa === 'FXAA' ? 1 : Number.parseInt(sampling.viewport_aa, 10);
1642
+ const rendered = sampling.engine === 'BLENDER_WORKBENCH'
1643
+ ? workbenchSampling(Number.isFinite(sceneAa) ? sceneAa : 1, false) : film;
1644
+ const value = { solid: workbenchSampling(PREFERENCES_VIEWPORT_AA, true), rendered, preview: film };
1645
+ this.#sampling = { key, value };
1646
+ return value;
1647
+ }
1648
+
1649
+ #sampling: { readonly key: string; readonly value: Partial<Record<'solid' | 'wireframe' | 'preview' | 'rendered', BlenderPhotographSampling>> } | null = null;
1650
+
1651
+ /**
1652
+ * THE SCENE'S COLOUR MANAGEMENT for the frame on screen (`Scene.view_settings`, exposure as a
1653
+ * multiplier, and the display device), as `session.py` sends it with every present: Rendered and
1654
+ * Material Preview are drawn through it. Null before a frame, or from an engine that does not
1655
+ * send it.
1656
+ */
1657
+ displaySettings(): (BlenderDisplaySettings & { readonly device: string }) | null {
1658
+ const display = this.frame?.display;
1659
+ if (!display) return null;
1660
+ return {
1661
+ transform: display.view_transform,
1662
+ look: display.look,
1663
+ exposure: 2 ** display.exposure,
1664
+ gamma: display.gamma,
1665
+ device: display.display_device,
1469
1666
  };
1470
1667
  }
1471
1668
 
1472
1669
  private workbenchFor(id: string | null, side: THREE.Side, used: Set<string>): THREE.Material {
1473
1670
  const display = (id !== null ? this.frame?.materials[id]?.viewport : undefined) ?? DEFAULT_VIEWPORT_DISPLAY;
1474
- const key = `${display.color.join(',')}|${display.roughness}|${display.metallic}|${side}`;
1671
+ // Solid's Specular Lighting, as the file's 3D View holds it (on in a factory file).
1672
+ const specular = this.frame?.view?.show_specular_highlight ?? true;
1673
+ const key = `${display.color.join(',')}|${display.roughness}|${display.metallic}|${side}|${specular}`;
1475
1674
  used.add(key);
1476
1675
  let material = this.workbenchMaterials.get(key);
1477
1676
  if (!material) {
1478
- material = workbenchMaterial(display, side);
1677
+ material = workbenchMaterial(display, side, specular);
1479
1678
  this.workbenchMaterials.set(key, material);
1480
1679
  }
1481
1680
  return material;
1482
1681
  }
1483
1682
 
1484
- /** Whether the viewport is held in render lighting (Blender's Rendered shading). */
1485
- renderedHeld(): boolean {
1486
- return this.heldRendered !== null;
1487
- }
1488
-
1489
1683
  /**
1490
1684
  * Light the model the way a RENDER is lit — by the scene's own lights —
1491
1685
  * rather than the way modeling is, by the studio key. `bpy.ops.render.render()`
@@ -1527,12 +1721,7 @@ export class BlenderRuntimeView {
1527
1721
  this.applyVisibility();
1528
1722
  this.applyWorkbench();
1529
1723
  this.applyShadows(rendered, camera);
1530
- // AFTER the applies, because they are what REGISTERS the work. Awaiting
1531
- // first made every one of these a no-op on the first render: `apply` had
1532
- // not started the sky yet, so `worldReady()` saw nothing pending and the
1533
- // photograph went out with an empty sky texture. Measured -- a sky the page
1534
- // had never derived returned a render in 0.08s, which is not fast, it is
1535
- // wrong. One photograph, no second chance at it.
1724
+ // Await preparation after apply registers its sky work, so ready observes every pending texture.
1536
1725
  if (rendered) {
1537
1726
  await lightingReady();
1538
1727
  await Promise.all([...this.textures.values()].map((held) => held.ready));
@@ -1544,27 +1733,17 @@ export class BlenderRuntimeView {
1544
1733
  }
1545
1734
 
1546
1735
  /**
1547
- * Who is drawn, which is a different question in each state.
1548
- *
1549
- * MODELING reads the viewport's visibility and lights nothing the scene
1550
- * owns; a RENDER reads `hide_render` — the flag the Cycles path's own
1551
- * snapshot skipped objects by — and lights exactly the scene's lights.
1552
- */
1553
- /**
1554
- * Shadows, for a render only.
1555
- *
1556
- * The renderer's shadow map is already on (`StageHost`); what is
1557
- * missing is anything opting into it, because MODELING wants none — a
1558
- * shadow across the model is in the way while it is being built, and the
1559
- * viewport has never cast one. So the meshes opt in for the photograph and
1560
- * back out afterwards, and every shadow camera is fitted to the model,
1561
- * whose size no default could know.
1736
+ * Enable mesh shadows for rendered shading and captures, and fit each
1737
+ * light's shadow camera to the visible receivers and eligible casters.
1562
1738
  */
1563
- private applyShadows(rendered: boolean, camera?: THREE.Camera): void {
1564
- this.root.updateMatrixWorld(true);
1739
+ private applyShadows(rendered: boolean, camera?: THREE.Camera, renderer?: THREE.WebGLRenderer, posed = false): void {
1740
+ // When posed is true, prepareDraw already refreshed world matrices; reuse them.
1741
+ if (!posed) this.root.updateMatrixWorld(true);
1565
1742
  this.world.updateSolarDirections(this.root);
1566
1743
  const fitted = this.shadowFit;
1567
- if (rendered && fitted && fitted.camera === camera &&
1744
+ // A fit made without a renderer (entering Rendered) is fitted again by the first draw,
1745
+ // which can see what the camera sees.
1746
+ if (rendered && fitted && fitted.camera === camera && (fitted.visible || !renderer || !camera) &&
1568
1747
  (!camera || (fitted.cameraMatrix?.equals(camera.matrixWorld) && fitted.projection?.equals(camera.projectionMatrix))) &&
1569
1748
  fitted.solarLights.length === this.world.solarLights.length &&
1570
1749
  fitted.solarLights.every((light, index) => light === this.world.solarLights[index]) &&
@@ -1575,6 +1754,7 @@ export class BlenderRuntimeView {
1575
1754
  })) return;
1576
1755
  this.shadowFit = rendered ? {
1577
1756
  camera,
1757
+ visible: renderer !== undefined && camera !== undefined,
1578
1758
  cameraMatrix: camera?.matrixWorld.clone(),
1579
1759
  projection: camera?.projectionMatrix.clone(),
1580
1760
  solarLights: [...this.world.solarLights],
@@ -1603,14 +1783,45 @@ export class BlenderRuntimeView {
1603
1783
  new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
1604
1784
  )
1605
1785
  : null;
1606
- const receivers = frustum
1607
- ? boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum))
1608
- : [];
1786
+ const suns = [...this.lights.values(), ...this.world.solarLights]
1787
+ .some((light) => (light as THREE.DirectionalLight).isDirectionalLight && light.castShadow);
1788
+ // WHAT THE CAMERA SEES is what a sun's map covers, as EEVEE tags its pages from the visible
1789
+ // surfaces (`VisibleReceivers`); the frustum's boxes only when no renderer is at hand to look.
1790
+ const seen = suns && renderer && camera ? this.sampleVisibleReceivers(renderer, camera) : [];
1791
+ // Every visible receiver is needed only to fit a sun without a renderer to look; a point or spot
1792
+ // light's bias needs only the farthest one (`farthestVisibleReceiver`).
1793
+ let clipped: THREE.Vector3[] | null = null;
1794
+ const receivers = (): THREE.Vector3[] => seen.length
1795
+ ? seen
1796
+ : frustum
1797
+ ? (clipped ??= boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum)))
1798
+ : [];
1609
1799
  for (const light of [...this.lights.values(), ...this.world.solarLights]) {
1610
1800
  if ((light as THREE.DirectionalLight).isDirectionalLight && camera)
1611
- fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, casters);
1612
- else fitShadow(light, sphere.center, sphere.radius);
1801
+ fitModelDirectionalShadow(light as THREE.DirectionalLight, light.castShadow ? receivers() : [], casters);
1802
+ else {
1803
+ fitShadow(light, sphere.center, sphere.radius);
1804
+ if ((light as THREE.PointLight).isPointLight || (light as THREE.SpotLight).isSpotLight)
1805
+ fitPerspectiveShadowBias(light as THREE.PointLight | THREE.SpotLight, seen.length || !frustum ? receivers()
1806
+ : light.castShadow ? farthestVisibleReceiver(boxes, frustum, light.getWorldPosition(new THREE.Vector3())) : []);
1807
+ }
1808
+ }
1809
+ }
1810
+
1811
+ /** The visible surfaces through `camera`, leaving out what the drawing sees through. */
1812
+ private sampleVisibleReceivers(renderer: THREE.WebGLRenderer, camera: THREE.Camera): THREE.Vector3[] {
1813
+ let scene: THREE.Object3D | null = this.root;
1814
+ while (scene && !(scene as THREE.Scene).isScene) scene = scene.parent;
1815
+ if (!scene) return [];
1816
+ const seeThrough: THREE.Object3D[] = [];
1817
+ for (const object of this.objects.values()) {
1818
+ const mesh = object as THREE.Mesh;
1819
+ if (!mesh.isMesh) continue;
1820
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
1821
+ if (materials.every((material) => material.transparent || (material as THREE.MeshPhysicalMaterial).transmission > 0))
1822
+ seeThrough.push(mesh);
1613
1823
  }
1824
+ return this.visibleReceivers.sample(renderer, scene as THREE.Scene, camera, seeThrough);
1614
1825
  }
1615
1826
 
1616
1827
  /**
@@ -1708,6 +1919,8 @@ export class BlenderRuntimeView {
1708
1919
  });
1709
1920
  }
1710
1921
 
1922
+ /** Use render visibility for captures and views that request it. Editing
1923
+ * retains viewport visibility independently of its shading and lighting. */
1711
1924
  private applyVisibility(): void {
1712
1925
  // Restore canonical masks before suppressing an instancer's own surface.
1713
1926
  // Its geometry can be hidden while its placed children remain visible.
@@ -1765,10 +1978,9 @@ export class BlenderRuntimeView {
1765
1978
  const data = exportedMeshSchema.parse(part.piece);
1766
1979
  if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
1767
1980
  const signature = `revision:${data.revision}`;
1768
- const geometry = geometryFromDrawArrays(drawArraysFromColumns({
1981
+ const geometry = drawnOrRefused(() => withFrameSkin(geometryFromDrawArrays(drawArraysFromColumns({
1769
1982
  ...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
1770
- }, signature));
1771
- if (data.skin) skinGeometry(geometry, data.skin);
1983
+ }, signature)), data.skin));
1772
1984
  staged.meshes.set(part.mesh, { signature, geometry });
1773
1985
  } else if (part.image !== undefined) {
1774
1986
  if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
@@ -1856,7 +2068,7 @@ export class BlenderRuntimeView {
1856
2068
  // BufferGeometry stands. If it is not resident the worker's record
1857
2069
  // is ahead of this presenter — a page reloaded under a still-running
1858
2070
  // session — and saying so by name is what makes it re-send the frame
1859
- // in full (`dispatch.mts`, `runtime_present`).
2071
+ // in full.
1860
2072
  // AND AT THAT REVISION: the frame names every mesh with the revision the engine holds
1861
2073
  // it at, so it is a whole manifest of what this presenter should hold, and a resident
1862
2074
  // geometry at another revision has drifted from the engine (a present the session
@@ -1890,7 +2102,7 @@ export class BlenderRuntimeView {
1890
2102
  continue;
1891
2103
  prepared.set(id, {
1892
2104
  signature,
1893
- geometry: exported
2105
+ geometry: drawnOrRefused(() => exported
1894
2106
  ? withFrameSkin(geometryFromDrawArrays(
1895
2107
  drawArraysFromColumns(
1896
2108
  {
@@ -1904,7 +2116,7 @@ export class BlenderRuntimeView {
1904
2116
  ), data.skin)
1905
2117
  : drawn
1906
2118
  ? withDrawnSkin(geometryFromDrawArrays(data), data.skin)
1907
- : drawRuntimeGeometry(data),
2119
+ : drawRuntimeGeometry(data)),
1908
2120
  });
1909
2121
  }
1910
2122
  } catch (error) {
@@ -1939,7 +2151,7 @@ export class BlenderRuntimeView {
1939
2151
  }
1940
2152
  // THE PICTURES THE FRAME BROUGHT, before any material can ask for one. An
1941
2153
  // entry is here only because this session has not sent these bytes at this
1942
- // revision yet (`runtime_session._stage_images`); everything else the
2154
+ // revision yet (`session.py`'s image-carry record); everything else the
1943
2155
  // materials name is already resident, which is the whole point of the
1944
2156
  // revision.
1945
2157
  for (const [name, data] of Object.entries(next.images)) {
@@ -1999,27 +2211,22 @@ export class BlenderRuntimeView {
1999
2211
  });
2000
2212
  }
2001
2213
  for (const [id, data] of Object.entries(next.materials)) {
2002
- // Cycles intersects and shades both faces, including an arcade viewed
2003
- // from underneath. Three's front-face default drops those surfaces.
2004
- // The shadow map keeps the back faces a front-faced material draws
2005
- // there: a two-sided depth pass puts every lit face into its own map, and
2006
- // the normal offset cannot hold that off at grazing light.
2214
+ // Render both surface faces, as Cycles does. Cast shadows from both faces unless the material
2215
+ // enables shadow backface culling. Fit the sun map to visible receivers and scale its normal
2216
+ // offset by the map’s texel size.
2007
2217
  const material = this.materials.get(id) ??
2008
- new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide, shadowSide: THREE.BackSide });
2218
+ new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide, shadowSide: THREE.DoubleSide });
2219
+ material.shadowSide = data.backface_culling_shadow ? THREE.FrontSide : THREE.DoubleSide;
2220
+ // Match Blender’s face passes: cull back faces when declared, and draw blended surfaces in one pass with Transparency Overlap enabled.
2221
+ const side = data.backface_culling ? THREE.FrontSide : THREE.DoubleSide;
2222
+ const singlePass = data.transparency_overlap !== false;
2223
+ if (material.side !== side || material.forceSinglePass !== singlePass) {
2224
+ material.side = side;
2225
+ material.forceSinglePass = singlePass;
2226
+ material.needsUpdate = true;
2227
+ }
2009
2228
  material.name = data.name;
2010
- // A LINKED BASE COLOUR REPLACES THE SOCKET'S VALUE, it does not multiply
2011
- // it. Python's `_reduce` fills `color` from the Base Color socket's
2012
- // default even when the socket is LINKED (the socket keeps its last
2013
- // constant, and native never draws it), while three multiplies `map` by
2014
- // `material.color` -- so carrying that constant alongside a map tinted
2015
- // every textured surface by a leftover. Measured on `20-rigged-courier`:
2016
- // the painted face's skin photographed (107,59,41) against native's
2017
- // (154,108,77) at the same pixel, from a texture whose own skin is
2018
- // (168,118,84) on both sides.
2019
- //
2020
- // With a map, the only legitimate multiplier is the `tint` a MULTIPLY mix
2021
- // carries; without one it is white. Only a material with NO map draws the
2022
- // constant.
2229
+ // A linked Base Color replaces the socket constant. Textured materials multiply only by the tint carried by a MULTIPLY mix; untextured materials use the constant.
2023
2230
  const tint = data.texture?.tint;
2024
2231
  if (data.texture !== undefined) {
2025
2232
  material.color.setRGB(
@@ -2117,10 +2324,7 @@ export class BlenderRuntimeView {
2117
2324
  material.needsUpdate = true;
2118
2325
  }
2119
2326
  }
2120
- // THE AUTHORED TEXTURE. Without it a Base Color link rendered as the
2121
- // socket's default and the image never reached a pixel -- measured as a
2122
- // byte-identical render with and without one. Blender's Base Color is an
2123
- // sRGB-encoded image, so the texture says so and three linearises it.
2327
+ // Base Color images are sRGB; declare that encoding so three linearizes their texels.
2124
2328
  const wanted = data.texture?.image.name ?? null;
2125
2329
  if (wanted === null) {
2126
2330
  if (this.textureNames.has(id)) {
@@ -2304,17 +2508,13 @@ export class BlenderRuntimeView {
2304
2508
  if (bound && rig) this.skinBinds.set(bound, { geometry: bound.geometry, rig });
2305
2509
  }
2306
2510
  this.root.updateMatrixWorld(true);
2307
- // Keep the frame's identity and object table, not its geometry payloads:
2308
- // a presented mesh is already resident as its BufferGeometry, and the
2309
- // nested number arrays it arrived as cost ~15x their JSON (the blower's
2310
- // last frame held 850 MB of main-thread heap this way, measured
2311
- // 2026-09-13). Reuse checks compare the per-mesh signatures kept above.
2511
+ // Keep frame identity and object metadata after presentation; BufferGeometry owns the geometry bytes and per-mesh signatures support reuse checks.
2312
2512
  this.frame = { ...next, meshes: {}, volumes: {}, images: {} };
2313
2513
  this.worldKey = JSON.stringify(next.world ?? null);
2314
2514
  // THE OVERLAYS, after the graph stands: the weight drawing is laid over
2315
2515
  // the presented mesh's own geometry and the bones over the armature
2316
2516
  // OBJECT's Blender matrix, so both need this frame's objects in place.
2317
- const overlayWarnings = this.applyOverlays(next);
2517
+ const overlayWarnings = [...this.applyOverlays(next), ...this.nameRefusals(next.objects)];
2318
2518
  if (overlayWarnings.length)
2319
2519
  this.frame = { ...this.frame, warnings: [...this.frame.warnings, ...overlayWarnings] };
2320
2520
  // Visibility is read off the frame, so it is applied once the frame stands.
@@ -2338,6 +2538,36 @@ export class BlenderRuntimeView {
2338
2538
  return { ...this.inspect(), held };
2339
2539
  }
2340
2540
 
2541
+ /** The meshes this presenter holds but could not draw, by mesh id, with the reason. */
2542
+ refusedMeshes(): Record<string, string> {
2543
+ const out: Record<string, string> = {};
2544
+ for (const [id, entry] of this.meshes) {
2545
+ const reason = entry.geometry.userData[REFUSED_GEOMETRY];
2546
+ if (typeof reason === 'string') out[id] = reason;
2547
+ }
2548
+ return out;
2549
+ }
2550
+
2551
+ /** Every held mesh that could not be drawn, named with the objects that show it: a frame
2552
+ * warning each frame, and on the editor's console once per mesh, revision and reason. */
2553
+ private nameRefusals(objects: readonly { name: string; mesh: string | null }[]): string[] {
2554
+ const said: string[] = [];
2555
+ for (const [id, reason] of Object.entries(this.refusedMeshes())) {
2556
+ const users = objects.filter((obj) => obj.mesh === id).map((obj) => JSON.stringify(obj.name));
2557
+ const shown = users.length > 8 ? `${users.slice(0, 8).join(', ')} and ${users.length - 8} more` : users.join(', ');
2558
+ const line = `The presenter skipped mesh ${JSON.stringify(id)}` +
2559
+ (users.length ? ` (${users.length > 1 ? 'objects' : 'object'} ${shown})` : '') +
2560
+ `, which it cannot draw: ${reason}. The rest of the document presents; the mesh is drawn again once it changes.`;
2561
+ said.push(line);
2562
+ const key = `${id}\u0000${this.meshes.get(id)?.signature}\u0000${reason}`;
2563
+ if (NAMED_REFUSALS.has(key)) continue;
2564
+ NAMED_REFUSALS.add(key);
2565
+ // biome-ignore lint/suspicious/noConsole: the editor console captures console.warn session-wide; this is the report channel.
2566
+ console.warn(`[blender] @@VOLTER-WARN ${line}`);
2567
+ }
2568
+ return said;
2569
+ }
2570
+
2341
2571
  inspect() {
2342
2572
  return {
2343
2573
  session: this.frame?.session,
@@ -2357,31 +2587,30 @@ export class BlenderRuntimeView {
2357
2587
  }
2358
2588
 
2359
2589
  drawStatistics() {
2360
- return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, worldVisibility:this.worldVisibility.diagnostics(), materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2590
+ return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2361
2591
  }
2362
2592
 
2363
2593
  snapshot() {
2364
2594
  return this.frame;
2365
2595
  }
2366
2596
 
2367
- /**
2368
- * A revision-owned render presenter, never a clone of the mounted scene.
2369
- * Copy the resident draw arrays and image bytes synchronously, then rebuild
2370
- * through the same frame contract. No live objects, callbacks, textures or
2371
- * disposable geometry cross this boundary; delta-only frames are sufficient.
2372
- * The caller owns this snapshot and must dispose it on every outcome.
2373
- */
2597
+ /** The frame this view holds with no objects in it: its World, render and view settings. */
2598
+ private worldFrame(): Frame {
2599
+ const source = this.frame;
2600
+ if (!source) throw new Error('Blender capture requires a presented frame');
2601
+ return structuredClone({ ...source, objects: [], meshes: {}, materials: {}, instances: [], lights: {}, volumes: {}, images: {}, armatures: {}, weights: null });
2602
+ }
2603
+
2374
2604
  /** Everything this view holds, as ONE self-contained frame another view can be built from:
2375
2605
  * every mesh's resident geometry and every resident image, with no reference to what a
2376
2606
  * receiver already holds. */
2377
- private fullFrame(options: { sourceVertices?: boolean } = {}): Frame {
2607
+ private fullFrame(): Frame {
2378
2608
  const source = this.frame;
2379
2609
  if (!source) throw new Error('Blender capture requires a presented frame');
2380
2610
  // Inspection overlays are not render content. In particular weight-paint
2381
2611
  // overlays reference source vertices, which the evaluated draw no longer
2382
- // needs and a render snapshot deliberately does not copy.
2383
- // A copy that plays keeps the armatures its skins bind to; a render snapshot draws no bones.
2384
- const frame: Frame = structuredClone({ ...source, images: {}, armatures: options.sourceVertices ? source.armatures : {}, weights: null });
2612
+ // needs and a render snapshot deliberately does not copy. A render snapshot draws no bones.
2613
+ const frame: Frame = structuredClone({ ...source, images: {}, armatures: {}, weights: null });
2385
2614
  for (const [id, { signature, geometry }] of this.meshes) {
2386
2615
  const attribute = (name: string): Float32Array<ArrayBuffer> | null => {
2387
2616
  const value = geometry.getAttribute(name);
@@ -2417,20 +2646,6 @@ export class BlenderRuntimeView {
2417
2646
  count: group.count,
2418
2647
  materialIndex: group.materialIndex ?? 0,
2419
2648
  })),
2420
- // A COPY THAT PLAYS keeps the draw's vertex map: Play binds skins over it (a Blender
2421
- // vertex's weights expand onto its drawn vertices through it), where a render snapshot
2422
- // needs none.
2423
- ...(options.sourceVertices && geometry.getAttribute('blenderVertex')
2424
- ? { sourceVertex: new Uint32Array((geometry.getAttribute('blenderVertex') as THREE.BufferAttribute).array) }
2425
- : {}),
2426
- // A COPY THAT PLAYS keeps its skin too, per drawn vertex, so its own skeletons bind it.
2427
- ...(options.sourceVertices && geometry.userData['blenderSkin'] && geometry.getAttribute('skinIndex')
2428
- ? { skin: {
2429
- ...(geometry.userData['blenderSkin'] as { armature: string; groups: string[] }),
2430
- index: new Uint16Array((geometry.getAttribute('skinIndex') as THREE.BufferAttribute).array),
2431
- weight: new Float32Array((geometry.getAttribute('skinWeight') as THREE.BufferAttribute).array),
2432
- } }
2433
- : {}),
2434
2649
  };
2435
2650
  }
2436
2651
  for (const [id, { signature }] of this.volumes)
@@ -2459,7 +2674,7 @@ export class BlenderRuntimeView {
2459
2674
  colorspace: held.encoded.colorspace,
2460
2675
  };
2461
2676
  } else {
2462
- const data = held.texture.image?.data;
2677
+ const data = (held.texture.image as { data?: unknown } | null | undefined)?.data;
2463
2678
  if (!(data instanceof Uint8Array))
2464
2679
  throw new Error(`Blender capture image ${name} has no resident raster`);
2465
2680
  frame.images[name] = {
@@ -2503,13 +2718,13 @@ export class BlenderRuntimeView {
2503
2718
  }
2504
2719
 
2505
2720
  /**
2506
- * A COPY OF THE MODEL THAT NO LONGER FOLLOWS IT, for Play: the graph `follow` builds, from the
2507
- * frame this view holds now, and handed nothing afterwards. What moves its objects is the
2508
- * caller's; Blender's data and this view never see it, and disposing it is the whole undo.
2721
+ * THE WORLD OF THE MODEL, FOR PLAY: a view of the World and the scene's settings this view holds now, with none of
2722
+ * the model's objects (a game over the standard artifact brings its own, RFC 0007), handed nothing afterwards.
2723
+ * Blender's data and this view never see it, and disposing it is the whole undo.
2509
2724
  */
2510
2725
  detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2511
2726
  const detached = new BlenderRuntimeView();
2512
- if (this.frame) detached.applyFrame(this.fullFrame({ sourceVertices: true }));
2727
+ if (this.frame) detached.applyFrame(this.worldFrame());
2513
2728
  let disposed = false;
2514
2729
  return {
2515
2730
  view: detached,
@@ -2522,6 +2737,13 @@ export class BlenderRuntimeView {
2522
2737
  };
2523
2738
  }
2524
2739
 
2740
+ /**
2741
+ * A revision-owned render presenter, never a clone of the mounted scene.
2742
+ * Copy the resident draw arrays and image bytes synchronously, then rebuild
2743
+ * through the same frame contract. No live objects, callbacks, textures or
2744
+ * disposable geometry cross this boundary; delta-only frames are sufficient.
2745
+ * The caller owns this snapshot and must dispose it on every outcome.
2746
+ */
2525
2747
  captureSnapshot() {
2526
2748
  const frame = this.fullFrame();
2527
2749
  const source = this.frame!;
@@ -2556,7 +2778,7 @@ export class BlenderRuntimeView {
2556
2778
  },
2557
2779
  prepareDraw:(renderer:THREE.WebGLRenderer,camera:THREE.Camera)=>{
2558
2780
  if(disposed)throw new Error('Blender capture snapshot is disposed');
2559
- return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer});
2781
+ return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer,photograph:true});
2560
2782
  },
2561
2783
  dispose,
2562
2784
  };
@@ -2584,7 +2806,7 @@ export class BlenderRuntimeView {
2584
2806
  },
2585
2807
  prepareDraw: (renderer, camera) => {
2586
2808
  if (disposed) throw new Error('Blender capture snapshot is disposed');
2587
- return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer });
2809
+ return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer, photograph: true });
2588
2810
  },
2589
2811
  dispose: () => {
2590
2812
  if (disposed) return;
@@ -2632,9 +2854,8 @@ export class BlenderRuntimeView {
2632
2854
  }
2633
2855
 
2634
2856
  /** THE OBSERVATION DOOR onto both records, for whoever is grading this tab
2635
- * from outside (`packages/blender-engine/bench/battery/capture_live_model.mts`
2636
- * reads it through the editor's document REPL and writes the two files a
2637
- * replay compares). Nothing in the product reads it. */
2857
+ * from outside through the editor's document REPL. Nothing in the product
2858
+ * reads it. */
2638
2859
  observations() {
2639
2860
  return { presentation: this.submitted, photographs: this.photographs };
2640
2861
  }
@@ -2642,7 +2863,6 @@ export class BlenderRuntimeView {
2642
2863
  private clear() {
2643
2864
  this.skeletons.dispose();
2644
2865
  this.skinBinds = new WeakMap();
2645
- this.worldVisibility.dispose();
2646
2866
  this.objectInfoMaterials.clear();
2647
2867
  this.materialRanges.clear();
2648
2868
  this.motionGeometry.clear();
@@ -2691,6 +2911,8 @@ export class BlenderRuntimeView {
2691
2911
  this.extrasOverlay.dispose();
2692
2912
  for (const material of this.workbenchMaterials.values()) material.dispose();
2693
2913
  this.workbenchMaterials.clear();
2914
+ this.outline.dispose();
2915
+ this.visibleReceivers.dispose();
2694
2916
  this.frame = null;
2695
2917
  this.retiredSessions.clear();
2696
2918
  this.submittedMeshes.clear();