@volter/blender-engine 0.1.148 → 0.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +22 -5
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +66 -129
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +37 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +3 -0
  192. package/browser/protocol.ts +130 -43
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +271 -106
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1389 -1446
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +492 -306
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +195 -180
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -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 {
@@ -1101,24 +1185,9 @@ export class BlenderRuntimeView {
1101
1185
  }
1102
1186
 
1103
1187
  stageMode(): string | null {
1104
- return this.playing ? 'PLAY' : this.mode;
1105
- }
1106
-
1107
- /** PLAY IS A MODE OF THE STAGE: while the document plays, the keys are the player's, and the
1108
- * 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. */
1110
- private playing = false;
1111
-
1112
- setPlaying(playing: boolean): void {
1113
- this.playing = playing;
1188
+ return this.mode;
1114
1189
  }
1115
1190
 
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
1191
  /**
1123
1192
  * WHAT THE LAST FRAME SAYS ABOUT ANIMATION, for a reader that plays clips on `skeletons`: the
1124
1193
  * scene's current frame and clock, every action's revision, each armature's assigned action, the
@@ -1136,6 +1205,9 @@ export class BlenderRuntimeView {
1136
1205
  * pose), its action and its NLA stack. */
1137
1206
  armatures: Readonly<Record<string, BlenderArmature>>;
1138
1207
  skinArmature(object: string): string | null;
1208
+ /** The scene's camera, every camera's settings and placement, as one string: the movie, which
1209
+ * carries them, is read again when it changes (switching the scene camera, an unkeyed lens). */
1210
+ cameras: string;
1139
1211
  } {
1140
1212
  const frame = this.frame;
1141
1213
  return {
@@ -1145,6 +1217,11 @@ export class BlenderRuntimeView {
1145
1217
  active: frame?.active ?? null,
1146
1218
  actions: frame?.actions ?? {},
1147
1219
  armatures: frame?.armatures ?? {},
1220
+ cameras: JSON.stringify([
1221
+ frame?.camera_view?.scene_camera ?? null,
1222
+ frame?.cameras ?? {},
1223
+ (frame?.objects ?? []).filter((o) => o.type === 'CAMERA').map((o) => [o.name, o.matrix]),
1224
+ ]),
1148
1225
  skinArmature: (name) => {
1149
1226
  const object = this.objectForBlenderName(name) as THREE.Mesh | null;
1150
1227
  const skin = object?.geometry?.userData['blenderSkin'] as { armature: string } | undefined;
@@ -1153,6 +1230,12 @@ export class BlenderRuntimeView {
1153
1230
  };
1154
1231
  }
1155
1232
 
1233
+ /** The inverse: the presented object for a Blender datablock NAME, or null
1234
+ * when this tab is not showing one (a camera, an excluded collection's
1235
+ * member, an object type the presenter draws nothing for). The OUTLINER
1236
+ * needs it — its rows come from the engine by name, and every one that our
1237
+ * viewport can select has to find its way back to a three object through
1238
+ * the frame's own table, never through `object.name`. */
1156
1239
  objectForBlenderName(name: string): THREE.Object3D | null {
1157
1240
  const entry = this.frame?.objects.find((o) => o.name === name);
1158
1241
  return entry === undefined ? null : (this.objects.get(entry.id) ?? null);
@@ -1162,16 +1245,8 @@ export class BlenderRuntimeView {
1162
1245
  * Put `next` where `previous` stood — same parent, same id in the object
1163
1246
  * table, same place among its siblings' children.
1164
1247
  *
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.
1248
+ * Frame application and skeleton promotion use this door so replacements
1249
+ * retain the frame's object identity and the next frame can reuse them.
1175
1250
  */
1176
1251
  replacePresentedObject(previous: THREE.Object3D, next: THREE.Object3D): void {
1177
1252
  for (const [id, object] of this.objects)
@@ -1194,14 +1269,9 @@ export class BlenderRuntimeView {
1194
1269
  this.instances.sync();
1195
1270
  };
1196
1271
  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.
1272
+ // Map Blender Z-up into three.js Y-up with the exact signed permutation (x, y, z) → (x, z, -y).
1273
+ // Camera poses transformed through this matrix and its inverse round-trip without a
1274
+ // floating-point tolerance.
1205
1275
  this.root.matrixAutoUpdate = false;
1206
1276
  this.root.matrix.set(1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1);
1207
1277
  this.root.updateMatrixWorld(true);
@@ -1232,16 +1302,6 @@ export class BlenderRuntimeView {
1232
1302
  this.emptyExtrasRoot.name = 'BlenderEmptyExtras';
1233
1303
  }
1234
1304
 
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
1305
  /**
1246
1306
  * BLENDER'S SOLID-MODE STUDIO, for the document to hand to its stage as
1247
1307
  * `dressing.viewLocked`.
@@ -1257,6 +1317,15 @@ export class BlenderRuntimeView {
1257
1317
  return this.lighting.group;
1258
1318
  }
1259
1319
 
1320
+ /**
1321
+ * THE OVERLAY GROUPS, for the document to hand to its stage.
1322
+ *
1323
+ * `kind` is the `HelperVisibility` member whose checkbox owns it: `skeletons`
1324
+ * is the Helpers menu's Skeletons row, which is Blender's viewport
1325
+ * overlay "Bones" checkbox (`View3DOverlay.show_bones`,
1326
+ * `rna_space.cc:5125-5129`; `space_view3d.py:7161`) under this editor's own
1327
+ * noun; `weights` controls the vertex-group weight display.
1328
+ */
1260
1329
  overlayGroups(): readonly { kind: string; object: THREE.Object3D }[] {
1261
1330
  return [
1262
1331
  { kind: 'skeletons', object: this.armatureRoot },
@@ -1287,6 +1356,7 @@ export class BlenderRuntimeView {
1287
1356
  return placement;
1288
1357
  }
1289
1358
 
1359
+ private heldSceneLighting: { world: boolean; lights: boolean } | undefined;
1290
1360
  /**
1291
1361
  * BLENDER'S RENDERED SHADING: the viewport held in the lighting a render photographs with
1292
1362
  * ({@link setRendered}) — the scene's own lights, its World behind and around the model,
@@ -1296,7 +1366,6 @@ export class BlenderRuntimeView {
1296
1366
  * keeps viewport visibility independently of its shading and lighting choices.
1297
1367
  * A render taken meanwhile ends back in this state rather than in modeling.
1298
1368
  */
1299
- private heldSceneLighting: { world: boolean; lights: boolean } | undefined;
1300
1369
  holdRendered(drawCamera: (() => THREE.Camera) | null, sceneLighting?: { world: boolean; lights: boolean }, visibility: 'viewport' | 'render' = 'viewport'): void {
1301
1370
  if (drawCamera === this.heldRendered && sceneLighting?.world === this.heldSceneLighting?.world && sceneLighting?.lights === this.heldSceneLighting?.lights && visibility === this.heldVisibility) return;
1302
1371
  this.heldRendered = drawCamera;
@@ -1345,12 +1414,31 @@ export class BlenderRuntimeView {
1345
1414
  }
1346
1415
 
1347
1416
  private report(work: Promise<void>): void {
1417
+ this.renderedWork = work.then(() => undefined, () => undefined);
1348
1418
  void work.then(presenterChanged, (error: unknown) =>
1349
1419
  // biome-ignore lint/suspicious/noConsole: the editor console captures console.error session-wide; this is the report channel.
1350
1420
  console.error(`Rendered shading could not be applied: ${error instanceof Error ? error.message : String(error)}`),
1351
1421
  );
1352
1422
  }
1353
1423
 
1424
+ /** The Rendered shading the viewport was last put in, still settling (`applyRendered`). */
1425
+ private renderedWork: Promise<void> = Promise.resolve();
1426
+
1427
+ /**
1428
+ * WHAT THE FRAME ON SCREEN IS STILL WAITING ON before it draws as it will: Rendered shading's
1429
+ * settling (area-light tables, the World's sky) and every image's decode. A photograph through
1430
+ * {@link setRendered} waits for the same things; a view presented on the viewport waits here
1431
+ * (the model document's preparation), so a capture right after a model opens or Rendered is
1432
+ * entered does not catch any of them half-way. A graph program still compiling is the draw's
1433
+ * own business (`photographGraphs`).
1434
+ */
1435
+ async drawReady(): Promise<void> {
1436
+ await this.renderedWork;
1437
+ await lightingReady();
1438
+ await Promise.allSettled([...this.textures.values()].map((held) => held.ready));
1439
+ await this.world.ready().catch(() => undefined);
1440
+ }
1441
+
1354
1442
  /**
1355
1443
  * BLENDER'S SOLID SHADING ON OR OFF: while on, every presented surface is drawn by Blender's
1356
1444
  * Solid function over its material's viewport display; a render photograph, Material Preview
@@ -1363,6 +1451,29 @@ export class BlenderRuntimeView {
1363
1451
  presenterChanged();
1364
1452
  }
1365
1453
 
1454
+ /**
1455
+ * THE AUTHORED MATERIALS, whatever the viewport shows: `run` sees every presented mesh dressed in the
1456
+ * materials its object's slots name (what Material Preview and Rendered show), and each gets back what
1457
+ * it wore after. An export writes the model's own materials in Solid shading too (`blender-export-gltf`).
1458
+ */
1459
+ async withAuthoredMaterials<T>(run: () => Promise<T>): Promise<T> {
1460
+ const worn = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>();
1461
+ for (const obj of this.frame?.objects ?? []) {
1462
+ if (obj.mesh === null || obj.volume) continue;
1463
+ const mesh = this.objects.get(obj.id) as THREE.Mesh | undefined;
1464
+ if (!mesh?.isMesh) continue;
1465
+ const slots: readonly (string | null)[] = obj.materials.length ? obj.materials : [null];
1466
+ const authored = slots.map((id) => (id === null ? this.fallback : (this.materials.get(id) ?? this.fallback)));
1467
+ worn.set(mesh, mesh.material);
1468
+ mesh.material = obj.materials.length ? authored : authored[0]!;
1469
+ }
1470
+ try {
1471
+ return await run();
1472
+ } finally {
1473
+ for (const [mesh, material] of worn) mesh.material = material;
1474
+ }
1475
+ }
1476
+
1366
1477
  /** Each surface's material for the state: authored, or Solid's, derived from the frame every
1367
1478
  * time so a replaced node (the skin's) is never left wearing the other. */
1368
1479
  private applyWorkbench(): void {
@@ -1376,7 +1487,8 @@ export class BlenderRuntimeView {
1376
1487
  const slots: readonly (string | null)[] = obj.materials.length ? obj.materials : [null];
1377
1488
  const shown = slots.map((id) => {
1378
1489
  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);
1490
+ // Solid ignores a material's Backface Culling (the 3D View's own option governs it, off by default).
1491
+ return solid ? this.workbenchFor(id, THREE.DoubleSide, used) : this.objectInfoMaterials.get(authored,mesh);
1380
1492
  });
1381
1493
  mesh.material = obj.materials.length ? shown : shown[0]!;
1382
1494
  prepareGraphGeometry(mesh);
@@ -1416,7 +1528,9 @@ export class BlenderRuntimeView {
1416
1528
 
1417
1529
  /** Before the renderer uploads attributes/builds its queues, for this area's
1418
1530
  * 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 {
1531
+ prepareDraw(camera: THREE.Camera, options?: BlenderDrawOptions): () => void {
1532
+ const outline = this.outlineColor(options);
1533
+ photographGraphs(options?.photograph === true);
1420
1534
  try {
1421
1535
  if (!this.workbench || this.rendered) {
1422
1536
  if(this.objectInfoMaterials.needsRemap()) this.applyWorkbench();
@@ -1425,19 +1539,11 @@ export class BlenderRuntimeView {
1425
1539
  const start = performance.now();
1426
1540
  this.instances.prepareDraw(camera);
1427
1541
  this.root.updateMatrixWorld(true);
1542
+ this.skeletons.fitBounds();
1428
1543
  // The script/navigation has just posed the camera and the detached
1429
1544
  // objects. Fit shadows for that draw, rather than the camera's pose
1430
1545
  // 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
- }
1546
+ if (this.rendered) this.applyShadows(true, camera, options?.renderer, true);
1441
1547
  const opaqueDone = performance.now();
1442
1548
  const motion = this.motionGeometry.prepare(camera, !this.rendered && (this.mode ?? this.frame?.mode) === 'OBJECT' &&
1443
1549
  options?.interactive === true, options?.height ?? 0);
@@ -1455,6 +1561,7 @@ export class BlenderRuntimeView {
1455
1561
  this.drawPreparation.last = timing;
1456
1562
  if (motion) this.drawPreparation.navigation = timing;
1457
1563
  } catch (error) {
1564
+ photographGraphs(false);
1458
1565
  this.materialRanges.finish();
1459
1566
  this.motionGeometry.finish();
1460
1567
  this.instances.finishDraw();
@@ -1462,30 +1569,108 @@ export class BlenderRuntimeView {
1462
1569
  throw error;
1463
1570
  }
1464
1571
  return () => {
1465
- this.materialRanges.finish();
1466
- this.motionGeometry.finish();
1467
- this.instances.finishDraw();
1468
- this.transparentInstances.finishDraw();
1572
+ try {
1573
+ // Over the frame as it was just drawn: its draw geometry and batches still stand.
1574
+ if (outline && options?.renderer) this.drawOutline(options.renderer, camera, outline, options.pixelScale ?? 1);
1575
+ } finally {
1576
+ photographGraphs(false);
1577
+ this.materialRanges.finish();
1578
+ this.motionGeometry.finish();
1579
+ this.instances.finishDraw();
1580
+ this.transparentInstances.finishDraw();
1581
+ }
1582
+ };
1583
+ }
1584
+
1585
+ /** Solid's object outline colour for this draw, or null when it draws none: Blender's Solid
1586
+ * with the file's `show_object_outline` on, and a renderer to draw it with. */
1587
+ private outlineColor(options?: BlenderDrawOptions): readonly [number, number, number] | null {
1588
+ const saved = this.frame?.view;
1589
+ if (!options?.renderer || options.drawMode !== 'solid' || !this.workbench || this.rendered) return null;
1590
+ if (!saved?.show_object_outline) return null;
1591
+ return saved.object_outline_color ?? [0, 0, 0];
1592
+ }
1593
+
1594
+ private drawOutline(renderer: THREE.WebGLRenderer, camera: THREE.Camera, color: readonly [number, number, number], pixelScale: number): void {
1595
+ let scene: THREE.Object3D | null = this.root;
1596
+ while (scene && !(scene as THREE.Scene).isScene) scene = scene.parent;
1597
+ if (!scene) return;
1598
+ const presented = new Set(this.objects.values());
1599
+ // The presenter's own draws (batches) stand in for some objects; the id pass draws those
1600
+ // objects themselves, each with its own id, as Blender's id buffer holds them.
1601
+ const internal = this.root.children.filter((child) => (child as THREE.Mesh).isMesh && !presented.has(child));
1602
+ this.outline.draw(renderer, scene as THREE.Scene, camera, this.objects.values(),
1603
+ (object) => !this.hiddenSurfaceLayers.has(object), internal, color, pixelScale);
1604
+ }
1605
+
1606
+ /**
1607
+ * HOW EACH SHADING TYPE IS ANTI-ALIASED, as Blender's viewport accumulates it
1608
+ * (`blender-viewport-sampling.ts` holds both models): a photograph draws every sample at once,
1609
+ * and the stage's live view one per redraw while nothing changes, as Blender's viewport does.
1610
+ * - SOLID: Workbench's accumulation at the Preferences' Viewport Anti-Aliasing
1611
+ * (`workbench_state.cc`: `U.viewport_aa` whatever the engine), whose factory value, 8, the tab
1612
+ * assumes: it holds no Blender preferences. Merged over its backdrop as the display shader
1613
+ * merges the overlay.
1614
+ * - RENDERED: Workbench at the scene's `display.viewport_aa` when Workbench is the engine ('OFF'
1615
+ * one centred sample, unfiltered: no anti-aliasing at all; 'FXAA' its one centred sample, the
1616
+ * SMAA Workbench lays over it not reproduced); EEVEE's film otherwise, at its viewport
1617
+ * `taa_samples` and `filter_size`.
1618
+ * - WIREFRAME: none here. Blender's wires are overlay lines smoothed by their own pass, not
1619
+ * accumulated, so a wireframe photograph keeps the stage's own supersampling and the live
1620
+ * view its MSAA.
1621
+ * - MATERIAL PREVIEW: EEVEE's film, whatever the engine.
1622
+ * Null before a frame.
1623
+ */
1624
+ photographSampling(): Partial<Record<'solid' | 'wireframe' | 'preview' | 'rendered', BlenderPhotographSampling>> | null {
1625
+ const sampling = this.frame?.sampling;
1626
+ if (!sampling) return null;
1627
+ // The same answer for the same settings: the stage keeps its live accumulation while the
1628
+ // sampling it was begun by is the one it is handed (every Blender frame hands it again).
1629
+ const key = `${sampling.engine}|${sampling.viewport_aa}|${sampling.taa_samples}|${sampling.filter_size}`;
1630
+ if (this.#sampling?.key === key) return this.#sampling.value;
1631
+ const film = eeveeSampling(sampling.taa_samples, sampling.filter_size);
1632
+ const sceneAa = sampling.viewport_aa === 'OFF' ? 0 : sampling.viewport_aa === 'FXAA' ? 1 : Number.parseInt(sampling.viewport_aa, 10);
1633
+ const rendered = sampling.engine === 'BLENDER_WORKBENCH'
1634
+ ? workbenchSampling(Number.isFinite(sceneAa) ? sceneAa : 1, false) : film;
1635
+ const value = { solid: workbenchSampling(PREFERENCES_VIEWPORT_AA, true), rendered, preview: film };
1636
+ this.#sampling = { key, value };
1637
+ return value;
1638
+ }
1639
+
1640
+ #sampling: { readonly key: string; readonly value: Partial<Record<'solid' | 'wireframe' | 'preview' | 'rendered', BlenderPhotographSampling>> } | null = null;
1641
+
1642
+ /**
1643
+ * THE SCENE'S COLOUR MANAGEMENT for the frame on screen (`Scene.view_settings`, exposure as a
1644
+ * multiplier, and the display device), as `session.py` sends it with every present: Rendered and
1645
+ * Material Preview are drawn through it. Null before a frame, or from an engine that does not
1646
+ * send it.
1647
+ */
1648
+ displaySettings(): (BlenderDisplaySettings & { readonly device: string }) | null {
1649
+ const display = this.frame?.display;
1650
+ if (!display) return null;
1651
+ return {
1652
+ transform: display.view_transform,
1653
+ look: display.look,
1654
+ exposure: 2 ** display.exposure,
1655
+ gamma: display.gamma,
1656
+ device: display.display_device,
1469
1657
  };
1470
1658
  }
1471
1659
 
1472
1660
  private workbenchFor(id: string | null, side: THREE.Side, used: Set<string>): THREE.Material {
1473
1661
  const display = (id !== null ? this.frame?.materials[id]?.viewport : undefined) ?? DEFAULT_VIEWPORT_DISPLAY;
1474
- const key = `${display.color.join(',')}|${display.roughness}|${display.metallic}|${side}`;
1662
+ // Solid's Specular Lighting, as the file's 3D View holds it (on in a factory file).
1663
+ const specular = this.frame?.view?.show_specular_highlight ?? true;
1664
+ const key = `${display.color.join(',')}|${display.roughness}|${display.metallic}|${side}|${specular}`;
1475
1665
  used.add(key);
1476
1666
  let material = this.workbenchMaterials.get(key);
1477
1667
  if (!material) {
1478
- material = workbenchMaterial(display, side);
1668
+ material = workbenchMaterial(display, side, specular);
1479
1669
  this.workbenchMaterials.set(key, material);
1480
1670
  }
1481
1671
  return material;
1482
1672
  }
1483
1673
 
1484
- /** Whether the viewport is held in render lighting (Blender's Rendered shading). */
1485
- renderedHeld(): boolean {
1486
- return this.heldRendered !== null;
1487
- }
1488
-
1489
1674
  /**
1490
1675
  * Light the model the way a RENDER is lit — by the scene's own lights —
1491
1676
  * rather than the way modeling is, by the studio key. `bpy.ops.render.render()`
@@ -1527,12 +1712,7 @@ export class BlenderRuntimeView {
1527
1712
  this.applyVisibility();
1528
1713
  this.applyWorkbench();
1529
1714
  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.
1715
+ // Await preparation after apply registers its sky work, so ready observes every pending texture.
1536
1716
  if (rendered) {
1537
1717
  await lightingReady();
1538
1718
  await Promise.all([...this.textures.values()].map((held) => held.ready));
@@ -1544,27 +1724,17 @@ export class BlenderRuntimeView {
1544
1724
  }
1545
1725
 
1546
1726
  /**
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.
1727
+ * Enable mesh shadows for rendered shading and captures, and fit each
1728
+ * light's shadow camera to the visible receivers and eligible casters.
1562
1729
  */
1563
- private applyShadows(rendered: boolean, camera?: THREE.Camera): void {
1564
- this.root.updateMatrixWorld(true);
1730
+ private applyShadows(rendered: boolean, camera?: THREE.Camera, renderer?: THREE.WebGLRenderer, posed = false): void {
1731
+ // When posed is true, prepareDraw already refreshed world matrices; reuse them.
1732
+ if (!posed) this.root.updateMatrixWorld(true);
1565
1733
  this.world.updateSolarDirections(this.root);
1566
1734
  const fitted = this.shadowFit;
1567
- if (rendered && fitted && fitted.camera === camera &&
1735
+ // A fit made without a renderer (entering Rendered) is fitted again by the first draw,
1736
+ // which can see what the camera sees.
1737
+ if (rendered && fitted && fitted.camera === camera && (fitted.visible || !renderer || !camera) &&
1568
1738
  (!camera || (fitted.cameraMatrix?.equals(camera.matrixWorld) && fitted.projection?.equals(camera.projectionMatrix))) &&
1569
1739
  fitted.solarLights.length === this.world.solarLights.length &&
1570
1740
  fitted.solarLights.every((light, index) => light === this.world.solarLights[index]) &&
@@ -1575,6 +1745,7 @@ export class BlenderRuntimeView {
1575
1745
  })) return;
1576
1746
  this.shadowFit = rendered ? {
1577
1747
  camera,
1748
+ visible: renderer !== undefined && camera !== undefined,
1578
1749
  cameraMatrix: camera?.matrixWorld.clone(),
1579
1750
  projection: camera?.projectionMatrix.clone(),
1580
1751
  solarLights: [...this.world.solarLights],
@@ -1603,14 +1774,45 @@ export class BlenderRuntimeView {
1603
1774
  new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
1604
1775
  )
1605
1776
  : null;
1606
- const receivers = frustum
1607
- ? boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum))
1608
- : [];
1777
+ const suns = [...this.lights.values(), ...this.world.solarLights]
1778
+ .some((light) => (light as THREE.DirectionalLight).isDirectionalLight && light.castShadow);
1779
+ // WHAT THE CAMERA SEES is what a sun's map covers, as EEVEE tags its pages from the visible
1780
+ // surfaces (`VisibleReceivers`); the frustum's boxes only when no renderer is at hand to look.
1781
+ const seen = suns && renderer && camera ? this.sampleVisibleReceivers(renderer, camera) : [];
1782
+ // Every visible receiver is needed only to fit a sun without a renderer to look; a point or spot
1783
+ // light's bias needs only the farthest one (`farthestVisibleReceiver`).
1784
+ let clipped: THREE.Vector3[] | null = null;
1785
+ const receivers = (): THREE.Vector3[] => seen.length
1786
+ ? seen
1787
+ : frustum
1788
+ ? (clipped ??= boxes.flatMap((bounds) => visibleShadowReceivers(bounds, frustum)))
1789
+ : [];
1609
1790
  for (const light of [...this.lights.values(), ...this.world.solarLights]) {
1610
1791
  if ((light as THREE.DirectionalLight).isDirectionalLight && camera)
1611
- fitModelDirectionalShadow(light as THREE.DirectionalLight, receivers, casters);
1612
- else fitShadow(light, sphere.center, sphere.radius);
1792
+ fitModelDirectionalShadow(light as THREE.DirectionalLight, light.castShadow ? receivers() : [], casters);
1793
+ else {
1794
+ fitShadow(light, sphere.center, sphere.radius);
1795
+ if ((light as THREE.PointLight).isPointLight || (light as THREE.SpotLight).isSpotLight)
1796
+ fitPerspectiveShadowBias(light as THREE.PointLight | THREE.SpotLight, seen.length || !frustum ? receivers()
1797
+ : light.castShadow ? farthestVisibleReceiver(boxes, frustum, light.getWorldPosition(new THREE.Vector3())) : []);
1798
+ }
1799
+ }
1800
+ }
1801
+
1802
+ /** The visible surfaces through `camera`, leaving out what the drawing sees through. */
1803
+ private sampleVisibleReceivers(renderer: THREE.WebGLRenderer, camera: THREE.Camera): THREE.Vector3[] {
1804
+ let scene: THREE.Object3D | null = this.root;
1805
+ while (scene && !(scene as THREE.Scene).isScene) scene = scene.parent;
1806
+ if (!scene) return [];
1807
+ const seeThrough: THREE.Object3D[] = [];
1808
+ for (const object of this.objects.values()) {
1809
+ const mesh = object as THREE.Mesh;
1810
+ if (!mesh.isMesh) continue;
1811
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
1812
+ if (materials.every((material) => material.transparent || (material as THREE.MeshPhysicalMaterial).transmission > 0))
1813
+ seeThrough.push(mesh);
1613
1814
  }
1815
+ return this.visibleReceivers.sample(renderer, scene as THREE.Scene, camera, seeThrough);
1614
1816
  }
1615
1817
 
1616
1818
  /**
@@ -1708,6 +1910,8 @@ export class BlenderRuntimeView {
1708
1910
  });
1709
1911
  }
1710
1912
 
1913
+ /** Use render visibility for captures and views that request it. Editing
1914
+ * retains viewport visibility independently of its shading and lighting. */
1711
1915
  private applyVisibility(): void {
1712
1916
  // Restore canonical masks before suppressing an instancer's own surface.
1713
1917
  // Its geometry can be hidden while its placed children remain visible.
@@ -1765,10 +1969,9 @@ export class BlenderRuntimeView {
1765
1969
  const data = exportedMeshSchema.parse(part.piece);
1766
1970
  if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
1767
1971
  const signature = `revision:${data.revision}`;
1768
- const geometry = geometryFromDrawArrays(drawArraysFromColumns({
1972
+ const geometry = drawnOrRefused(() => withFrameSkin(geometryFromDrawArrays(drawArraysFromColumns({
1769
1973
  ...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
1770
- }, signature));
1771
- if (data.skin) skinGeometry(geometry, data.skin);
1974
+ }, signature)), data.skin));
1772
1975
  staged.meshes.set(part.mesh, { signature, geometry });
1773
1976
  } else if (part.image !== undefined) {
1774
1977
  if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
@@ -1856,7 +2059,7 @@ export class BlenderRuntimeView {
1856
2059
  // BufferGeometry stands. If it is not resident the worker's record
1857
2060
  // is ahead of this presenter — a page reloaded under a still-running
1858
2061
  // session — and saying so by name is what makes it re-send the frame
1859
- // in full (`dispatch.mts`, `runtime_present`).
2062
+ // in full.
1860
2063
  // AND AT THAT REVISION: the frame names every mesh with the revision the engine holds
1861
2064
  // it at, so it is a whole manifest of what this presenter should hold, and a resident
1862
2065
  // geometry at another revision has drifted from the engine (a present the session
@@ -1890,7 +2093,7 @@ export class BlenderRuntimeView {
1890
2093
  continue;
1891
2094
  prepared.set(id, {
1892
2095
  signature,
1893
- geometry: exported
2096
+ geometry: drawnOrRefused(() => exported
1894
2097
  ? withFrameSkin(geometryFromDrawArrays(
1895
2098
  drawArraysFromColumns(
1896
2099
  {
@@ -1904,7 +2107,7 @@ export class BlenderRuntimeView {
1904
2107
  ), data.skin)
1905
2108
  : drawn
1906
2109
  ? withDrawnSkin(geometryFromDrawArrays(data), data.skin)
1907
- : drawRuntimeGeometry(data),
2110
+ : drawRuntimeGeometry(data)),
1908
2111
  });
1909
2112
  }
1910
2113
  } catch (error) {
@@ -1939,7 +2142,7 @@ export class BlenderRuntimeView {
1939
2142
  }
1940
2143
  // THE PICTURES THE FRAME BROUGHT, before any material can ask for one. An
1941
2144
  // entry is here only because this session has not sent these bytes at this
1942
- // revision yet (`runtime_session._stage_images`); everything else the
2145
+ // revision yet (`session.py`'s image-carry record); everything else the
1943
2146
  // materials name is already resident, which is the whole point of the
1944
2147
  // revision.
1945
2148
  for (const [name, data] of Object.entries(next.images)) {
@@ -1999,27 +2202,22 @@ export class BlenderRuntimeView {
1999
2202
  });
2000
2203
  }
2001
2204
  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.
2205
+ // Render both surface faces, as Cycles does. Cast shadows from both faces unless the material
2206
+ // enables shadow backface culling. Fit the sun map to visible receivers and scale its normal
2207
+ // offset by the map’s texel size.
2007
2208
  const material = this.materials.get(id) ??
2008
- new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide, shadowSide: THREE.BackSide });
2209
+ new THREE.MeshPhysicalMaterial({ side: THREE.DoubleSide, shadowSide: THREE.DoubleSide });
2210
+ material.shadowSide = data.backface_culling_shadow ? THREE.FrontSide : THREE.DoubleSide;
2211
+ // Match Blender’s face passes: cull back faces when declared, and draw blended surfaces in one pass with Transparency Overlap enabled.
2212
+ const side = data.backface_culling ? THREE.FrontSide : THREE.DoubleSide;
2213
+ const singlePass = data.transparency_overlap !== false;
2214
+ if (material.side !== side || material.forceSinglePass !== singlePass) {
2215
+ material.side = side;
2216
+ material.forceSinglePass = singlePass;
2217
+ material.needsUpdate = true;
2218
+ }
2009
2219
  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.
2220
+ // 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
2221
  const tint = data.texture?.tint;
2024
2222
  if (data.texture !== undefined) {
2025
2223
  material.color.setRGB(
@@ -2117,10 +2315,7 @@ export class BlenderRuntimeView {
2117
2315
  material.needsUpdate = true;
2118
2316
  }
2119
2317
  }
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.
2318
+ // Base Color images are sRGB; declare that encoding so three linearizes their texels.
2124
2319
  const wanted = data.texture?.image.name ?? null;
2125
2320
  if (wanted === null) {
2126
2321
  if (this.textureNames.has(id)) {
@@ -2304,17 +2499,13 @@ export class BlenderRuntimeView {
2304
2499
  if (bound && rig) this.skinBinds.set(bound, { geometry: bound.geometry, rig });
2305
2500
  }
2306
2501
  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.
2502
+ // Keep frame identity and object metadata after presentation; BufferGeometry owns the geometry bytes and per-mesh signatures support reuse checks.
2312
2503
  this.frame = { ...next, meshes: {}, volumes: {}, images: {} };
2313
2504
  this.worldKey = JSON.stringify(next.world ?? null);
2314
2505
  // THE OVERLAYS, after the graph stands: the weight drawing is laid over
2315
2506
  // the presented mesh's own geometry and the bones over the armature
2316
2507
  // OBJECT's Blender matrix, so both need this frame's objects in place.
2317
- const overlayWarnings = this.applyOverlays(next);
2508
+ const overlayWarnings = [...this.applyOverlays(next), ...this.nameRefusals(next.objects)];
2318
2509
  if (overlayWarnings.length)
2319
2510
  this.frame = { ...this.frame, warnings: [...this.frame.warnings, ...overlayWarnings] };
2320
2511
  // Visibility is read off the frame, so it is applied once the frame stands.
@@ -2338,6 +2529,36 @@ export class BlenderRuntimeView {
2338
2529
  return { ...this.inspect(), held };
2339
2530
  }
2340
2531
 
2532
+ /** The meshes this presenter holds but could not draw, by mesh id, with the reason. */
2533
+ refusedMeshes(): Record<string, string> {
2534
+ const out: Record<string, string> = {};
2535
+ for (const [id, entry] of this.meshes) {
2536
+ const reason = entry.geometry.userData[REFUSED_GEOMETRY];
2537
+ if (typeof reason === 'string') out[id] = reason;
2538
+ }
2539
+ return out;
2540
+ }
2541
+
2542
+ /** Every held mesh that could not be drawn, named with the objects that show it: a frame
2543
+ * warning each frame, and on the editor's console once per mesh, revision and reason. */
2544
+ private nameRefusals(objects: readonly { name: string; mesh: string | null }[]): string[] {
2545
+ const said: string[] = [];
2546
+ for (const [id, reason] of Object.entries(this.refusedMeshes())) {
2547
+ const users = objects.filter((obj) => obj.mesh === id).map((obj) => JSON.stringify(obj.name));
2548
+ const shown = users.length > 8 ? `${users.slice(0, 8).join(', ')} and ${users.length - 8} more` : users.join(', ');
2549
+ const line = `The presenter skipped mesh ${JSON.stringify(id)}` +
2550
+ (users.length ? ` (${users.length > 1 ? 'objects' : 'object'} ${shown})` : '') +
2551
+ `, which it cannot draw: ${reason}. The rest of the document presents; the mesh is drawn again once it changes.`;
2552
+ said.push(line);
2553
+ const key = `${id}\u0000${this.meshes.get(id)?.signature}\u0000${reason}`;
2554
+ if (NAMED_REFUSALS.has(key)) continue;
2555
+ NAMED_REFUSALS.add(key);
2556
+ // biome-ignore lint/suspicious/noConsole: the editor console captures console.warn session-wide; this is the report channel.
2557
+ console.warn(`[blender] @@VOLTER-WARN ${line}`);
2558
+ }
2559
+ return said;
2560
+ }
2561
+
2341
2562
  inspect() {
2342
2563
  return {
2343
2564
  session: this.frame?.session,
@@ -2357,31 +2578,23 @@ export class BlenderRuntimeView {
2357
2578
  }
2358
2579
 
2359
2580
  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()};
2581
+ return {enabled: this.drawBatching, preparation: {...this.drawPreparation}, materialRanges: this.materialRanges.inspect(), opaque: this.instances.inspect(), transparent: this.transparentInstances.inspect(), motion: this.motionGeometry.inspect()};
2361
2582
  }
2362
2583
 
2363
2584
  snapshot() {
2364
2585
  return this.frame;
2365
2586
  }
2366
2587
 
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
- */
2374
2588
  /** Everything this view holds, as ONE self-contained frame another view can be built from:
2375
2589
  * every mesh's resident geometry and every resident image, with no reference to what a
2376
2590
  * receiver already holds. */
2377
- private fullFrame(options: { sourceVertices?: boolean } = {}): Frame {
2591
+ private fullFrame(): Frame {
2378
2592
  const source = this.frame;
2379
2593
  if (!source) throw new Error('Blender capture requires a presented frame');
2380
2594
  // Inspection overlays are not render content. In particular weight-paint
2381
2595
  // 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 });
2596
+ // needs and a render snapshot deliberately does not copy. A render snapshot draws no bones.
2597
+ const frame: Frame = structuredClone({ ...source, images: {}, armatures: {}, weights: null });
2385
2598
  for (const [id, { signature, geometry }] of this.meshes) {
2386
2599
  const attribute = (name: string): Float32Array<ArrayBuffer> | null => {
2387
2600
  const value = geometry.getAttribute(name);
@@ -2417,20 +2630,6 @@ export class BlenderRuntimeView {
2417
2630
  count: group.count,
2418
2631
  materialIndex: group.materialIndex ?? 0,
2419
2632
  })),
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
2633
  };
2435
2634
  }
2436
2635
  for (const [id, { signature }] of this.volumes)
@@ -2459,7 +2658,7 @@ export class BlenderRuntimeView {
2459
2658
  colorspace: held.encoded.colorspace,
2460
2659
  };
2461
2660
  } else {
2462
- const data = held.texture.image?.data;
2661
+ const data = (held.texture.image as { data?: unknown } | null | undefined)?.data;
2463
2662
  if (!(data instanceof Uint8Array))
2464
2663
  throw new Error(`Blender capture image ${name} has no resident raster`);
2465
2664
  frame.images[name] = {
@@ -2503,25 +2702,12 @@ export class BlenderRuntimeView {
2503
2702
  }
2504
2703
 
2505
2704
  /**
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.
2705
+ * A revision-owned render presenter, never a clone of the mounted scene.
2706
+ * Copy the resident draw arrays and image bytes synchronously, then rebuild
2707
+ * through the same frame contract. No live objects, callbacks, textures or
2708
+ * disposable geometry cross this boundary; delta-only frames are sufficient.
2709
+ * The caller owns this snapshot and must dispose it on every outcome.
2509
2710
  */
2510
- detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2511
- const detached = new BlenderRuntimeView();
2512
- if (this.frame) detached.applyFrame(this.fullFrame({ sourceVertices: true }));
2513
- let disposed = false;
2514
- return {
2515
- view: detached,
2516
- dispose: () => {
2517
- if (disposed) return;
2518
- disposed = true;
2519
- detached.dispose();
2520
- detached.root.removeFromParent();
2521
- },
2522
- };
2523
- }
2524
-
2525
2711
  captureSnapshot() {
2526
2712
  const frame = this.fullFrame();
2527
2713
  const source = this.frame!;
@@ -2556,7 +2742,7 @@ export class BlenderRuntimeView {
2556
2742
  },
2557
2743
  prepareDraw:(renderer:THREE.WebGLRenderer,camera:THREE.Camera)=>{
2558
2744
  if(disposed)throw new Error('Blender capture snapshot is disposed');
2559
- return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer});
2745
+ return detached.prepareDraw(camera,{interactive:false,height:renderer.domElement.height,renderer,photograph:true});
2560
2746
  },
2561
2747
  dispose,
2562
2748
  };
@@ -2584,7 +2770,7 @@ export class BlenderRuntimeView {
2584
2770
  },
2585
2771
  prepareDraw: (renderer, camera) => {
2586
2772
  if (disposed) throw new Error('Blender capture snapshot is disposed');
2587
- return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer });
2773
+ return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer, photograph: true });
2588
2774
  },
2589
2775
  dispose: () => {
2590
2776
  if (disposed) return;
@@ -2632,9 +2818,8 @@ export class BlenderRuntimeView {
2632
2818
  }
2633
2819
 
2634
2820
  /** 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. */
2821
+ * from outside through the editor's document REPL. Nothing in the product
2822
+ * reads it. */
2638
2823
  observations() {
2639
2824
  return { presentation: this.submitted, photographs: this.photographs };
2640
2825
  }
@@ -2642,7 +2827,6 @@ export class BlenderRuntimeView {
2642
2827
  private clear() {
2643
2828
  this.skeletons.dispose();
2644
2829
  this.skinBinds = new WeakMap();
2645
- this.worldVisibility.dispose();
2646
2830
  this.objectInfoMaterials.clear();
2647
2831
  this.materialRanges.clear();
2648
2832
  this.motionGeometry.clear();
@@ -2691,6 +2875,8 @@ export class BlenderRuntimeView {
2691
2875
  this.extrasOverlay.dispose();
2692
2876
  for (const material of this.workbenchMaterials.values()) material.dispose();
2693
2877
  this.workbenchMaterials.clear();
2878
+ this.outline.dispose();
2879
+ this.visibleReceivers.dispose();
2694
2880
  this.frame = null;
2695
2881
  this.retiredSessions.clear();
2696
2882
  this.submittedMeshes.clear();