@volter/blender-engine 0.1.148 → 0.1.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +4 -11
- package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
- package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
- package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
- package/browser/addons/vrm/README.txt +3 -0
- package/browser/addons/vrm/__init__.py +41 -0
- package/browser/addons/vrm/blender_manifest.toml +16 -0
- package/browser/addons/vrm/common/__init__.py +1 -0
- package/browser/addons/vrm/common/animation.py +58 -0
- package/browser/addons/vrm/common/blender_manifest.py +82 -0
- package/browser/addons/vrm/common/char.py +16 -0
- package/browser/addons/vrm/common/convert.py +287 -0
- package/browser/addons/vrm/common/convert_any.py +48 -0
- package/browser/addons/vrm/common/debug.py +40 -0
- package/browser/addons/vrm/common/deep.py +162 -0
- package/browser/addons/vrm/common/error_dialog.py +274 -0
- package/browser/addons/vrm/common/fs.py +71 -0
- package/browser/addons/vrm/common/gl.py +20 -0
- package/browser/addons/vrm/common/gltf.py +1149 -0
- package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
- package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
- package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
- package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
- package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
- package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
- package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
- package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
- package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
- package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
- package/browser/addons/vrm/common/legacy_gltf.py +16 -0
- package/browser/addons/vrm/common/logger.py +84 -0
- package/browser/addons/vrm/common/mtoon0.blend +0 -0
- package/browser/addons/vrm/common/mtoon1.blend +0 -0
- package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
- package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
- package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
- package/browser/addons/vrm/common/ops/__init__.py +10 -0
- package/browser/addons/vrm/common/ops/export_scene.py +67 -0
- package/browser/addons/vrm/common/ops/icyp.py +53 -0
- package/browser/addons/vrm/common/ops/import_scene.py +57 -0
- package/browser/addons/vrm/common/ops/vrm.py +2380 -0
- package/browser/addons/vrm/common/ops/wm.py +167 -0
- package/browser/addons/vrm/common/preferences.py +362 -0
- package/browser/addons/vrm/common/progress.py +87 -0
- package/browser/addons/vrm/common/rotation.py +89 -0
- package/browser/addons/vrm/common/safe_removal.py +46 -0
- package/browser/addons/vrm/common/scene_watcher.py +230 -0
- package/browser/addons/vrm/common/shader.py +2243 -0
- package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
- package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
- package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
- package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
- package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
- package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
- package/browser/addons/vrm/common/version.py +161 -0
- package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
- package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
- package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
- package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
- package/browser/addons/vrm/common/workspace.py +227 -0
- package/browser/addons/vrm/common/writable_context.py +77 -0
- package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
- package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
- package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
- package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
- package/browser/addons/vrm/documentation/en-us/index.md +41 -0
- package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
- package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
- package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
- package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
- package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
- package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
- package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
- package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
- package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
- package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
- package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
- package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
- package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
- package/browser/addons/vrm/editor/__init__.py +1 -0
- package/browser/addons/vrm/editor/extension.py +425 -0
- package/browser/addons/vrm/editor/extension_accessor.py +75 -0
- package/browser/addons/vrm/editor/handler.py +84 -0
- package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
- package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
- package/browser/addons/vrm/editor/make_armature.py +709 -0
- package/browser/addons/vrm/editor/menu.py +147 -0
- package/browser/addons/vrm/editor/migration.py +297 -0
- package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
- package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
- package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
- package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
- package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
- package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
- package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
- package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
- package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
- package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
- package/browser/addons/vrm/editor/ops.py +471 -0
- package/browser/addons/vrm/editor/panel.py +315 -0
- package/browser/addons/vrm/editor/property_group.py +888 -0
- package/browser/addons/vrm/editor/search.py +682 -0
- package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
- package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
- package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
- package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
- package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
- package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
- package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
- package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
- package/browser/addons/vrm/editor/subscription.py +123 -0
- package/browser/addons/vrm/editor/t_pose.py +713 -0
- package/browser/addons/vrm/editor/validation.py +1271 -0
- package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
- package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
- package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
- package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
- package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
- package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
- package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
- package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
- package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
- package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
- package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
- package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
- package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
- package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
- package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
- package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
- package/browser/addons/vrm/exporter/__init__.py +1 -0
- package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
- package/browser/addons/vrm/exporter/export_scene.py +930 -0
- package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
- package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
- package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
- package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
- package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
- package/browser/addons/vrm/external/__init__.py +1 -0
- package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
- package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
- package/browser/addons/vrm/external/rigify_support.py +1 -0
- package/browser/addons/vrm/importer/__init__.py +1 -0
- package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
- package/browser/addons/vrm/importer/file_handler.py +34 -0
- package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
- package/browser/addons/vrm/importer/import_scene.py +646 -0
- package/browser/addons/vrm/importer/license_validation.py +198 -0
- package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
- package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
- package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
- package/browser/addons/vrm/importer/vrm_diff.py +316 -0
- package/browser/addons/vrm/locale/__init__.py +1 -0
- package/browser/addons/vrm/locale/ja_jp.py +826 -0
- package/browser/addons/vrm/locale/ruff.toml +8 -0
- package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
- package/browser/addons/vrm/locale/zh_hans.py +928 -0
- package/browser/addons/vrm/registration.py +670 -0
- package/browser/addons/vrm.json +18 -0
- package/browser/blender-emscripten-engine.mts +60 -128
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +35 -81
- package/browser/describe-thrown.mts +3 -6
- package/browser/document-chunks.mts +4 -16
- package/browser/gltf_export.py +3161 -0
- package/browser/heap-growth.mts +23 -0
- package/browser/index.ts +2 -0
- package/browser/protocol.ts +119 -35
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +197 -82
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1338 -1427
- package/browser/three/attach-presenter.ts +11 -39
- package/browser/three/blender-base64.ts +1 -36
- package/browser/three/blender-corner-normals.ts +1 -23
- package/browser/three/blender-display-shaders.json +1 -1
- package/browser/three/blender-display-shaders.lut +0 -0
- package/browser/three/blender-display-transform.ts +2 -1
- package/browser/three/blender-frame-columns.ts +5 -13
- package/browser/three/blender-graph-material.ts +18 -3
- package/browser/three/blender-node-graph.ts +10 -34
- package/browser/three/blender-physical-material.ts +2 -31
- package/browser/three/blender-runtime-armature.ts +7 -60
- package/browser/three/blender-runtime-frame.ts +9 -23
- package/browser/three/blender-runtime-geometry.ts +22 -26
- package/browser/three/blender-runtime-lighting.ts +48 -178
- package/browser/three/blender-runtime-shadows.ts +187 -1
- package/browser/three/blender-runtime-skeleton.ts +53 -1
- package/browser/three/blender-runtime-view.ts +505 -283
- package/browser/three/blender-runtime-weights.ts +9 -72
- package/browser/three/blender-sky.ts +13 -33
- package/browser/three/blender-triangulate.ts +7 -35
- package/browser/three/blender-viewport-sampling.ts +235 -0
- package/browser/three/blender-workbench-material.ts +25 -29
- package/browser/three/blender-workbench-outline.ts +199 -0
- package/browser/three/blender-world-volume.ts +62 -10
- package/browser/three/presenter.ts +10 -26
- package/browser/three/release.ts +5 -21
- package/browser/three/sky-precompute-worker.ts +1 -18
- package/browser/three/sky-worker.ts +5 -23
- package/browser/three/world-field-sampler.ts +12 -26
- package/browser/volter_three.py +9 -105
- package/browser/worker.ts +156 -151
- package/package.json +5 -2
- package/wasm/BUNDLE.json +17 -17
- package/wasm/DEPENDENCY-LICENSES.txt +14 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
- package/browser/frame-transfer.mts +0 -19
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/blender-agx.ts +0 -235
- package/browser/three/blender-display-lut.ts +0 -145
- package/browser/three/blender-filmic.ts +0 -49
- package/browser/three/blender-standard.ts +0 -68
- package/browser/three/blender-world-visibility.ts +0 -278
- package/browser/three/filmic-srgb.lut +0 -0
package/browser/session.py
CHANGED
|
@@ -1,96 +1,4 @@
|
|
|
1
|
-
"""The
|
|
2
|
-
|
|
3
|
-
THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
4
|
-
2026-09-17). Blender 5.2 LTS is compiled to WebAssembly and started
|
|
5
|
-
`--background --factory-startup --python <this file>`; `main()` never returns
|
|
6
|
-
because this script does not return, so the module's Blender pthread stays
|
|
7
|
-
alive and holds ONE `bpy.data` for as long as the worker does. That is the
|
|
8
|
-
session: `execute(code) -> {stdout, error}`, plus the same `get_scene_info` /
|
|
9
|
-
`get_object_info` the MCP door has always carried, plus the EXPORT that feeds
|
|
10
|
-
the presenter.
|
|
11
|
-
|
|
12
|
-
THE CHANNEL IS A DIRECTORY, NOT STDIN, and that is a measurement rather than a
|
|
13
|
-
preference (2026-09-17, node, the v0.1.1 release): the build's
|
|
14
|
-
`FS_stdin_getChar` runs on the BLENDER PTHREAD -- `isMainThread=false` -- where
|
|
15
|
-
node's `process.stdin.fd` is `undefined` and a browser pthread has no
|
|
16
|
-
`window.prompt`, so stdin is not a channel the page can write into. WasmFS
|
|
17
|
-
keeps its file table in the shared wasm memory, so a FILE is: the worker's own
|
|
18
|
-
thread writes `in/<id>.json` then `in/<id>.done`, this loop sees it, and the
|
|
19
|
-
answer goes back as `out/<id>.json` + `out/<id>.done`. Measured round trip on
|
|
20
|
-
that same run: 5 ms idle, 30 ms for a cube, 436 ms for a subdivide-and-apply.
|
|
21
|
-
|
|
22
|
-
THE CHANNEL HAS ONE WRITER PER DIRECTORY, and on the WALI skew that rule is
|
|
23
|
-
what keeps a session alive for more than a few dozen calls:
|
|
24
|
-
|
|
25
|
-
in/ the PAGE writes and unlinks; this file only ever READS it.
|
|
26
|
-
out/ THIS FILE writes and unlinks; the page only reads.
|
|
27
|
-
ask/ THIS FILE writes and unlinks; the page only reads.
|
|
28
|
-
reply/ the PAGE writes and unlinks; this file only ever READS it.
|
|
29
|
-
|
|
30
|
-
Under WALI the page and this program are two snapshot-backed views of one
|
|
31
|
-
tree, reconciled by patches, and the host REFUSES a patch whose base no longer
|
|
32
|
-
matches what it holds -- then terminates the program. A path can only
|
|
33
|
-
disagree if someone other than the patch's author moved it, so disjoint
|
|
34
|
-
writer sets leave the assertion nothing to fire on. Measured 2026-09-19: with
|
|
35
|
-
the page unlinking this loop's `out/<id>.*` after every answer, a render loop
|
|
36
|
-
in one session died at call 37 and at call 14, with no guest exit path run.
|
|
37
|
-
|
|
38
|
-
THE ACK IS THE OTHER SIDE'S REQUEST FILE DISAPPEARING, which costs no file and
|
|
39
|
-
no extra write. The page unlinks `in/<id>.json` and then `in/<id>.done` only
|
|
40
|
-
after it has read `out/<id>.json`, so `in/<id>.done` gone means the answer was
|
|
41
|
-
taken and this loop may remove its own `out/<id>.*` -- read straight out of
|
|
42
|
-
the `listdir(IN)` the loop already does every pass. Symmetrically, `ask` waits
|
|
43
|
-
for `reply/<id>.done`, reads `reply/<id>.json`, and then removes its OWN
|
|
44
|
-
`ask/<id>.*`; the page watches those vanish and retires the reply it wrote.
|
|
45
|
-
`.done` is unlinked last on both sides, because `.done` is the token the other
|
|
46
|
-
side watches.
|
|
47
|
-
|
|
48
|
-
THE DIRECTORIES FOLLOW THEIR CONTENTS' OWNER: this file creates `out` and
|
|
49
|
-
`ask` and never `in` or `reply` (`blender-engine.mts`'s
|
|
50
|
-
`PAGE_OWNED_DIRECTORIES` makes those before the program starts). A `mkdir` or
|
|
51
|
-
`chmod` on a path the other side has already touched is the same crossing
|
|
52
|
-
edit at a smaller scale.
|
|
53
|
-
|
|
54
|
-
THE LOAD HANDLER IS `@persistent` because Blender clears every non-persistent
|
|
55
|
-
handler on file load, and `open_mainfile` is an ordinary thing for a script
|
|
56
|
-
here to do.
|
|
57
|
-
|
|
58
|
-
THE EXPORT IS THE C++ DOOR, not Python. `_blender_web.export_frame` evaluates
|
|
59
|
-
the scene, takes its revisions from the depsgraph's own update record, and
|
|
60
|
-
writes every column into a side ARENA in wasm memory; the JSON frame names each
|
|
61
|
-
one by `{offset, length, dtype, count, stride}`; the worker reads those bytes
|
|
62
|
-
through the one door its skew has (`BlenderEngine.readArena`: the module heap
|
|
63
|
-
where the host can reach it, the `buffer_path` file where it cannot). Nothing
|
|
64
|
-
large crosses as JSON. What this file still describes is what the door
|
|
65
|
-
does not: the WORLD's shader expression and the CAMERAS a photograph is framed
|
|
66
|
-
through.
|
|
67
|
-
|
|
68
|
-
THE REVISION SIGNAL IS THE DEPSGRAPH'S OWN UPDATE RECORD, accumulated by the
|
|
69
|
-
door's C callbacks on BOTH `depsgraph_update_post` and `frame_change_post` (a
|
|
70
|
-
`frame_set` fires only the second), with a render's own evaluation skipped.
|
|
71
|
-
|
|
72
|
-
THE SESSION HAS A DOCUMENT, and it is a `.blend` Blender itself writes.
|
|
73
|
-
`blender-start` names it (project-relative, `models/model.blend` by default);
|
|
74
|
-
an existing one is OPENED at start and an absent one starts empty. The session
|
|
75
|
-
saves it through `bpy.ops.wm.save_as_mainfile` -- never a codec of ours, and
|
|
76
|
-
never a mutation of the script's own datablocks. A SCRIPT'S DATABLOCKS ARE THE
|
|
77
|
-
SCRIPT'S: the session does not rewrite the paths a script loaded its images
|
|
78
|
-
from in order to save. Where a document reopens from is a question the script
|
|
79
|
-
answers when it loads its files, by loading them through project-relative
|
|
80
|
-
paths; `save_as_mainfile`'s own Save-As semantics are the whole of what the
|
|
81
|
-
session applies (see `save_document`).
|
|
82
|
-
|
|
83
|
-
WHO CARRIES THE DOCUMENT OUT, and why it is not this loop's job: the save
|
|
84
|
-
writes into the module filesystem, which mirrors the project at the SAME
|
|
85
|
-
absolute path but is not the disk. Python cannot push it out on its own --
|
|
86
|
-
`ask` is only served from inside a request's poll loop
|
|
87
|
-
(`blender-engine.mts::serveAsks`), so an `ask` raised from this idle loop
|
|
88
|
-
would never be answered and would wedge the Blender pthread forever. So the
|
|
89
|
-
DEBOUNCE LIVES IN THE TAB: a present that leaves the document stale says so
|
|
90
|
-
(`saveDue` beside the frame), the worker waits out an idle second, calls
|
|
91
|
-
`save-document` as an ordinary request, and carries the bytes to the project
|
|
92
|
-
(`worker.ts`). Every step is an explicit call; nothing polls.
|
|
93
|
-
"""
|
|
1
|
+
"""The tab’s long-lived Blender session. A directory protocol carries requests and replies while one bpy.data remains alive on Blender’s pthread. The page owns in/ and reply/; the guest owns out/ and ask/. Each writer cleans its own files after acknowledgement and removes .done last. Explicit saves write Blender’s native format and let worker.ts return changed output to the project."""
|
|
94
2
|
|
|
95
3
|
import array
|
|
96
4
|
import base64
|
|
@@ -114,7 +22,12 @@ import mathutils
|
|
|
114
22
|
# THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
|
|
115
23
|
# reads the scene out as typed columns in a side arena the worker reads
|
|
116
24
|
# through `BlenderEngine.readArena` -- see EXPORT_BUFFER_PATH below.
|
|
117
|
-
|
|
25
|
+
try:
|
|
26
|
+
import _blender_web
|
|
27
|
+
except ModuleNotFoundError as error:
|
|
28
|
+
if error.name != "_blender_web":
|
|
29
|
+
raise
|
|
30
|
+
_blender_web = None
|
|
118
31
|
|
|
119
32
|
# The presenter's refusals, spelled in
|
|
120
33
|
# `packages/blender-engine/browser/three/blender-runtime-frame.ts`. This is the only
|
|
@@ -127,30 +40,12 @@ IN = os.path.join(ROOT, "in")
|
|
|
127
40
|
OUT = os.path.join(ROOT, "out")
|
|
128
41
|
ASK = os.path.join(ROOT, "ask")
|
|
129
42
|
REPLY = os.path.join(ROOT, "reply")
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
# `bpy_web_export.cc` was written: the C exports that name it in linear
|
|
134
|
-
# memory, and a `buffer_path` option that also writes the same bytes to a file
|
|
135
|
-
# -- "natively the path is the caller's own string". Which door the caller
|
|
136
|
-
# uses is the caller's business, and this file does not know its toolchain: it
|
|
137
|
-
# states the path it was given, or states nothing.
|
|
138
|
-
#
|
|
139
|
-
# EMPTY UNLESS THE ENGINE SETS IT, and that is the point. The standalone skew
|
|
140
|
-
# reads the arena straight off the module heap and must not pay a
|
|
141
|
-
# megabyte-scale file write per present. The engine that sets it is the one
|
|
142
|
-
# whose host cannot reach the module's memory at all
|
|
143
|
-
# (`blender-wali-engine.mts`), where the file is not a fallback but the
|
|
144
|
-
# native door onto the same bytes.
|
|
43
|
+
# Use the caller-supplied EXPORT_BUFFER_PATH when its host reads the arena through the filesystem.
|
|
44
|
+
# Hosts with access to linear memory leave it empty to avoid a second copy. Both doors expose the
|
|
45
|
+
# same native export bytes.
|
|
145
46
|
EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
|
|
146
47
|
|
|
147
|
-
#
|
|
148
|
-
# nothing about the stage before it; this records `_blender_web.memory()` at each, in MB
|
|
149
|
-
# ([label, heap, mallocInUse, mallocFree, guarded, guardedPeak]), and a script reads `MEMORY_MARKS`
|
|
150
|
-
# (the last 64) to say which stage raised the high-water mark. MEASURED 2026-09-29 on the
|
|
151
|
-
# Stoneguard file: the first export's door call took the heap from 1,442 to 3,644 MB while in-use
|
|
152
|
-
# rose 28 MB and the guarded peak did not move -- OpenSubdiv's limit-surface evaluator for one
|
|
153
|
-
# head (quality 3), which native Blender pays too (+1.2 GB peak RSS there).
|
|
48
|
+
# Retain the last 64 heap marks as [label, heap, mallocInUse, mallocFree, guarded, guardedPeak] in MB so callers can attribute growth to a stage.
|
|
154
49
|
MEMORY_MARKS = []
|
|
155
50
|
|
|
156
51
|
|
|
@@ -202,8 +97,13 @@ def _prepare_directories():
|
|
|
202
97
|
|
|
203
98
|
# ---------------------------------------------------------------- file load
|
|
204
99
|
|
|
100
|
+
# Each file load, counted: a request during which one came replaced the file, never removed its objects.
|
|
101
|
+
_LOADS = {"count": 0}
|
|
102
|
+
|
|
103
|
+
|
|
205
104
|
@bpy.app.handlers.persistent
|
|
206
105
|
def _load_post(_arg):
|
|
106
|
+
_LOADS["count"] += 1
|
|
207
107
|
# The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
|
|
208
108
|
# into the Main that just went away); this is the session's half.
|
|
209
109
|
SESSION.forget()
|
|
@@ -275,7 +175,7 @@ def _surface_backgrounds(node):
|
|
|
275
175
|
"""World Surface as `(what the camera sees, what lights the scene)`; a
|
|
276
176
|
world that is one Background is both and the second is None. A Mix Shader
|
|
277
177
|
by Light Path `Is Camera Ray` is how a scene gets a bright backdrop and a
|
|
278
|
-
restrained fill from one sky
|
|
178
|
+
restrained fill from one sky: input 2 is the camera's,
|
|
279
179
|
input 1 lights the scene."""
|
|
280
180
|
kind = node.bl_idname
|
|
281
181
|
if kind == "ShaderNodeBackground":
|
|
@@ -381,13 +281,12 @@ def _describe_world_socket(root_socket, camera_ray):
|
|
|
381
281
|
if kind == "ShaderNodeMapRange" and socket == "Result":
|
|
382
282
|
if node.data_type != "FLOAT":
|
|
383
283
|
raise NotImplementedError("World Map Range data type %s" % node.data_type)
|
|
384
|
-
steps = node.inputs.get("Steps")
|
|
385
284
|
return {"kind": "map_range", "interpolation": node.interpolation_type,
|
|
386
285
|
"clamp": bool(node.clamp) and node.interpolation_type not in ("SMOOTHSTEP", "SMOOTHERSTEP"),
|
|
387
286
|
"value": value(node.inputs["Value"]),
|
|
388
287
|
"from_min": value(node.inputs["From Min"]), "from_max": value(node.inputs["From Max"]),
|
|
389
288
|
"to_min": value(node.inputs["To Min"]), "to_max": value(node.inputs["To Max"]),
|
|
390
|
-
"steps": value(
|
|
289
|
+
"steps": value(node.inputs["Steps"])}
|
|
391
290
|
if kind == "ShaderNodeSeparateXYZ" and socket in ("X", "Y", "Z"):
|
|
392
291
|
return {"kind": "separate", "vector": value(node.inputs["Vector"]), "axis": ("X", "Y", "Z").index(socket)}
|
|
393
292
|
if kind == "ShaderNodeValToRGB" and socket == "Color":
|
|
@@ -406,8 +305,8 @@ def _describe_world_socket(root_socket, camera_ray):
|
|
|
406
305
|
if model not in ("SINGLE_SCATTERING", "MULTIPLE_SCATTERING"):
|
|
407
306
|
raise NotImplementedError(
|
|
408
307
|
"World Sky Texture: sky_type %r is not implemented (SINGLE_SCATTERING and MULTIPLE_SCATTERING are)" % model)
|
|
409
|
-
vector = node.inputs
|
|
410
|
-
if vector
|
|
308
|
+
vector = node.inputs["Vector"]
|
|
309
|
+
if vector.links:
|
|
411
310
|
raise NotImplementedError("Linked World Sky Texture Vector")
|
|
412
311
|
return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
|
|
413
312
|
"sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
|
|
@@ -500,10 +399,10 @@ def _describe_world(world):
|
|
|
500
399
|
# Only a wholly constant background can drop the expression: linked
|
|
501
400
|
# Strength may carry spatial radiance even when Color itself is constant.
|
|
502
401
|
if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
|
|
503
|
-
described
|
|
402
|
+
del described["shader"]
|
|
504
403
|
if "lighting" in described and not any(
|
|
505
404
|
(lighting or background).inputs[name].is_linked for name in ("Color", "Strength")):
|
|
506
|
-
described["lighting"]
|
|
405
|
+
del described["lighting"]["shader"]
|
|
507
406
|
return described
|
|
508
407
|
|
|
509
408
|
|
|
@@ -549,36 +448,7 @@ def draw_camera(obj):
|
|
|
549
448
|
}
|
|
550
449
|
|
|
551
450
|
|
|
552
|
-
#
|
|
553
|
-
|
|
554
|
-
# THE MATERIAL GRAPH, for a surface whose inputs are more than constants.
|
|
555
|
-
#
|
|
556
|
-
# The export door reduces each material to what a three.js standard material
|
|
557
|
-
# holds -- a constant or one image per input. A LINKED input of a Principled
|
|
558
|
-
# BSDF or an Emission is instead shipped as the node graph that drives it, and
|
|
559
|
-
# the presenter compiles that graph with Blender's own node GLSL
|
|
560
|
-
# (`three/blender-node-graph.ts`, `three/blender-node-glsl.generated.ts`). The
|
|
561
|
-
# door is told which materials those are (`graph_materials`), so it stays quiet
|
|
562
|
-
# about reducing them, which images their graphs sample (`graph_images`), so it
|
|
563
|
-
# ships those pictures with the same revisions as every other, and which read
|
|
564
|
-
# Generated coordinates (`graph_generated`), so a deformed mesh wearing one
|
|
565
|
-
# ships Blender's orco -- or the door names why it cannot.
|
|
566
|
-
#
|
|
567
|
-
# The graph is flattened here: reroutes and muted nodes are followed the way
|
|
568
|
-
# Blender's own node tree evaluation follows them, and group nodes are inlined
|
|
569
|
-
# (their nodes keyed by the group path), so the presenter sees one acyclic
|
|
570
|
-
# list of Blender nodes. What reaches the presenter is Blender's own data:
|
|
571
|
-
# every input socket in declaration order with its GPU type and value, every
|
|
572
|
-
# output socket's GPU type, and the node's own RNA properties.
|
|
573
|
-
|
|
574
|
-
# The node types the presenter compiles. A graph reaching any other type is
|
|
575
|
-
# not shipped; the door's reduction stands and a warning names the node.
|
|
576
|
-
|
|
577
|
-
# EVERY LINK INTO A SOCKET, from one pass over its tree. `NodeSocket.links`
|
|
578
|
-
# is Python in Blender (`_bpy_types.py`): each read walks ALL of the tree's
|
|
579
|
-
# links, and the graph pass reads it per socket per present -- measured on a
|
|
580
|
-
# 28-material scene, 875 reads and 9 of the pass's 15 profiled seconds. The
|
|
581
|
-
# map is built once per tree per present (`material_graphs` clears it).
|
|
451
|
+
# Ship linked Principled BSDF and Emission inputs as node graphs for blender-node-graph.ts to compile. Build socket-link indexes once per node tree per present.
|
|
582
452
|
_TREE_LINKS = {}
|
|
583
453
|
|
|
584
454
|
|
|
@@ -632,7 +502,7 @@ def _gpu_type(socket):
|
|
|
632
502
|
if kind in ("VALUE", "INT", "BOOLEAN"):
|
|
633
503
|
return "float"
|
|
634
504
|
if kind == "VECTOR":
|
|
635
|
-
return "vec%d" % len(socket.default_value)
|
|
505
|
+
return "vec%d" % len(socket.default_value)
|
|
636
506
|
if kind in ("RGBA", "ROTATION"):
|
|
637
507
|
return "vec4"
|
|
638
508
|
if kind == "SHADER":
|
|
@@ -642,8 +512,6 @@ def _gpu_type(socket):
|
|
|
642
512
|
|
|
643
513
|
def _socket_value(socket, gpu):
|
|
644
514
|
"""An unlinked socket's value in its GPU type (`nodestack_get_vec`)."""
|
|
645
|
-
if not hasattr(socket, "default_value"):
|
|
646
|
-
return 0.0 if gpu == "float" else [0.0] * int(gpu[3:])
|
|
647
515
|
value = socket.default_value
|
|
648
516
|
if gpu == "float":
|
|
649
517
|
return float(value)
|
|
@@ -785,7 +653,10 @@ def _saved_view():
|
|
|
785
653
|
Modeling), else the first 3D View any screen holds. `RegionView3D`'s pivot, rotation (view to
|
|
786
654
|
world, `(w, x, y, z)`), distance and projection (`PERSP`, `ORTHO`, or `CAMERA` for a view
|
|
787
655
|
through the scene camera), the `View3D`'s lens, and its SHADING TYPE, which Blender reopens
|
|
788
|
-
with the view: a file saved in Material Preview opens in Material Preview.
|
|
656
|
+
with the view: a file saved in Material Preview opens in Material Preview. Beside them, the
|
|
657
|
+
Solid options the tab draws as Blender does: the object outline (`show_object_outline`, in
|
|
658
|
+
`object_outline_color`), the specular term (`show_specular_highlight`) and what Solid and
|
|
659
|
+
Wireframe draw behind the model (`background_type`, with `background_color` for VIEWPORT)."""
|
|
789
660
|
screens = [window.screen for wm in bpy.data.window_managers for window in wm.windows
|
|
790
661
|
if window.screen is not None]
|
|
791
662
|
workspace = bpy.data.workspaces.get("Modeling")
|
|
@@ -806,6 +677,11 @@ def _saved_view():
|
|
|
806
677
|
"shading": space.shading.type,
|
|
807
678
|
"scene_world": bool(space.shading.use_scene_world),
|
|
808
679
|
"scene_lights": bool(space.shading.use_scene_lights),
|
|
680
|
+
"show_object_outline": bool(space.shading.show_object_outline),
|
|
681
|
+
"object_outline_color": [float(v) for v in space.shading.object_outline_color],
|
|
682
|
+
"show_specular_highlight": bool(space.shading.show_specular_highlight),
|
|
683
|
+
"background_type": space.shading.background_type,
|
|
684
|
+
"background_color": [float(v) for v in space.shading.background_color],
|
|
809
685
|
}
|
|
810
686
|
return None
|
|
811
687
|
|
|
@@ -862,7 +738,7 @@ def _subject_line(scene, view_layer):
|
|
|
862
738
|
bone = rig.data.bones.active
|
|
863
739
|
if bone is not None and _bone_shown(bone):
|
|
864
740
|
parts.append(" : " + bone.name)
|
|
865
|
-
key = ob.active_shape_key
|
|
741
|
+
key = ob.active_shape_key
|
|
866
742
|
if key is not None:
|
|
867
743
|
parts.append(" : " + key.name)
|
|
868
744
|
if ob.show_only_shape_key:
|
|
@@ -876,7 +752,7 @@ def _subject_line(scene, view_layer):
|
|
|
876
752
|
|
|
877
753
|
def _bone_shown(bone):
|
|
878
754
|
"""`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
|
|
879
|
-
collections =
|
|
755
|
+
collections = bone.collections
|
|
880
756
|
return not collections or any(c.is_visible for c in collections)
|
|
881
757
|
|
|
882
758
|
|
|
@@ -1009,7 +885,7 @@ def _follow(socket, stack):
|
|
|
1009
885
|
return stack, node
|
|
1010
886
|
|
|
1011
887
|
|
|
1012
|
-
def _light_path_side(socket
|
|
888
|
+
def _light_path_side(socket):
|
|
1013
889
|
"""A Mix Shader factor that separates the camera's surface (a Light Path's
|
|
1014
890
|
Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
|
|
1015
891
|
as the input index the camera sees; None for any other factor. The door
|
|
@@ -1027,7 +903,7 @@ def _light_path_side(socket, stack):
|
|
|
1027
903
|
def _collapse(stack, node):
|
|
1028
904
|
"""Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
|
|
1029
905
|
while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
|
|
1030
|
-
side = _light_path_side(node.inputs[0]
|
|
906
|
+
side = _light_path_side(node.inputs[0])
|
|
1031
907
|
if side is None:
|
|
1032
908
|
break
|
|
1033
909
|
stack, node = _follow(node.inputs[side], stack)
|
|
@@ -1140,23 +1016,17 @@ def _mixed_graph(material, stack, surface):
|
|
|
1140
1016
|
}
|
|
1141
1017
|
|
|
1142
1018
|
|
|
1143
|
-
def
|
|
1144
|
-
"""
|
|
1145
|
-
Jenkins hash_uint2(name_hash, 0), converted to float32 * 2**-32.
|
|
1146
|
-
Instances must use depsgraph.random_id instead; their export is separate.
|
|
1147
|
-
See draw_handle.hh::random and cycles/blender/object.cpp.
|
|
1148
|
-
"""
|
|
1019
|
+
def _hash_uint2_float(value):
|
|
1020
|
+
"""Jenkins hash_uint2(value, 0), converted to float32 * 2**-32."""
|
|
1149
1021
|
import struct
|
|
1150
1022
|
|
|
1151
1023
|
mask = 0xffffffff
|
|
1152
|
-
|
|
1153
|
-
# Blender's platform flags specify -funsigned-char (native and WASM).
|
|
1154
|
-
for byte in name.encode("utf8"):
|
|
1155
|
-
h = (h * 37 + byte) & mask
|
|
1156
|
-
a = (0xdeadbeef + 21 + h) & mask
|
|
1024
|
+
a = (0xdeadbeef + 21 + value) & mask
|
|
1157
1025
|
b = c = (0xdeadbeef + 21) & mask
|
|
1026
|
+
|
|
1158
1027
|
def rotate(x, bits):
|
|
1159
1028
|
return ((x << bits) | (x >> (32 - bits))) & mask
|
|
1029
|
+
|
|
1160
1030
|
c = ((c ^ b) - rotate(b, 14)) & mask
|
|
1161
1031
|
a = ((a ^ c) - rotate(c, 11)) & mask
|
|
1162
1032
|
b = ((b ^ a) - rotate(a, 25)) & mask
|
|
@@ -1167,6 +1037,18 @@ def _object_info_random(name):
|
|
|
1167
1037
|
return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
|
|
1168
1038
|
|
|
1169
1039
|
|
|
1040
|
+
def _object_info_random(name):
|
|
1041
|
+
"""Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
|
|
1042
|
+
hash_uint2(name_hash, 0). Instances use depsgraph.random_id instead.
|
|
1043
|
+
See draw_handle.hh::random and cycles/blender/object.cpp.
|
|
1044
|
+
"""
|
|
1045
|
+
h = 0
|
|
1046
|
+
# Blender's platform flags specify -funsigned-char (native and WASM).
|
|
1047
|
+
for byte in name.encode("utf8"):
|
|
1048
|
+
h = (h * 37 + byte) & 0xffffffff
|
|
1049
|
+
return _hash_uint2_float(h)
|
|
1050
|
+
|
|
1051
|
+
|
|
1170
1052
|
def _particle_info_random(index=0):
|
|
1171
1053
|
"""Cycles Particle Info Random: hash_uint2(particle.index, 0).
|
|
1172
1054
|
|
|
@@ -1174,20 +1056,7 @@ def _particle_info_random(index=0):
|
|
|
1174
1056
|
without a particle record use the zero-index dummy KernelParticle.
|
|
1175
1057
|
This is distinct from Object Info's placement random_id.
|
|
1176
1058
|
"""
|
|
1177
|
-
|
|
1178
|
-
mask = 0xffffffff
|
|
1179
|
-
a = (0xdeadbeef + 21 + index) & mask
|
|
1180
|
-
b = c = (0xdeadbeef + 21) & mask
|
|
1181
|
-
def rotate(x, bits):
|
|
1182
|
-
return ((x << bits) | (x >> (32 - bits))) & mask
|
|
1183
|
-
c = ((c ^ b) - rotate(b, 14)) & mask
|
|
1184
|
-
a = ((a ^ c) - rotate(c, 11)) & mask
|
|
1185
|
-
b = ((b ^ a) - rotate(a, 25)) & mask
|
|
1186
|
-
c = ((c ^ b) - rotate(b, 16)) & mask
|
|
1187
|
-
a = ((a ^ c) - rotate(c, 4)) & mask
|
|
1188
|
-
b = ((b ^ a) - rotate(a, 14)) & mask
|
|
1189
|
-
c = ((c ^ b) - rotate(b, 24)) & mask
|
|
1190
|
-
return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
|
|
1059
|
+
return _hash_uint2_float(index)
|
|
1191
1060
|
|
|
1192
1061
|
|
|
1193
1062
|
def _depsgraph_placements(frame, depsgraph):
|
|
@@ -1204,7 +1073,7 @@ def _depsgraph_placements(frame, depsgraph):
|
|
|
1204
1073
|
row["shadow_visible"] = bool(source.visible_shadow)
|
|
1205
1074
|
# Frame-local native addresses are comparison tokens, not authoring IDs.
|
|
1206
1075
|
# Consume them here so neither the presenter nor saved projects retain them.
|
|
1207
|
-
geometry = frame.pop("instance_geometry"
|
|
1076
|
+
geometry = frame.pop("instance_geometry")
|
|
1208
1077
|
placements = []
|
|
1209
1078
|
for instance in depsgraph.object_instances:
|
|
1210
1079
|
source = instance.object.original
|
|
@@ -1217,9 +1086,8 @@ def _depsgraph_placements(frame, depsgraph):
|
|
|
1217
1086
|
continue
|
|
1218
1087
|
if not instance.show_self:
|
|
1219
1088
|
continue
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
# opening. The native exporter skips the same duplis.
|
|
1089
|
+
# Omit unsupported placements and name each refusal through `warn`.
|
|
1090
|
+
# The rest of the frame remains drawable.
|
|
1223
1091
|
if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
|
|
1224
1092
|
(instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
|
|
1225
1093
|
warn("Depsgraph instances of %r are not drawn: their geometry differs from the exported source" % source.name)
|
|
@@ -1263,7 +1131,7 @@ def material_graphs(scene):
|
|
|
1263
1131
|
_TREE_LINKS.clear()
|
|
1264
1132
|
graphs = {}
|
|
1265
1133
|
for obj in scene.objects:
|
|
1266
|
-
for slot in
|
|
1134
|
+
for slot in obj.material_slots:
|
|
1267
1135
|
material = slot.material
|
|
1268
1136
|
if material is not None and material.name not in graphs:
|
|
1269
1137
|
graphs[material.name] = material_graph(material)
|
|
@@ -1272,9 +1140,7 @@ def material_graphs(scene):
|
|
|
1272
1140
|
|
|
1273
1141
|
# ------------------------------------------------------------- the overlays
|
|
1274
1142
|
#
|
|
1275
|
-
# INSPECTION OVERLAYS, READ OFF THE ENGINE
|
|
1276
|
-
# star, "Inspection parity, not editing parity"; WORK.md §Blender in the tab
|
|
1277
|
-
# is Blender, "Inspection parity", I4). Blender's overlay ENGINE
|
|
1143
|
+
# INSPECTION OVERLAYS, READ OFF THE ENGINE. Blender's overlay engine
|
|
1278
1144
|
# (`source/blender/draw/engines/overlay/`) is never run, ported or recorded
|
|
1279
1145
|
# here: what these answer is the DATA its draw functions read -- a bone's
|
|
1280
1146
|
# display matrix, a vertex's weight -- and a three.js presenter draws it
|
|
@@ -1304,7 +1170,7 @@ def _armature_bones(obj, locked_groups):
|
|
|
1304
1170
|
[...].matrix` in the RNA door reads back identical -- and the presenter
|
|
1305
1171
|
scales the unit shape itself."""
|
|
1306
1172
|
armature = obj.data
|
|
1307
|
-
active =
|
|
1173
|
+
active = armature.bones.active
|
|
1308
1174
|
active_name = active.name if active is not None else None
|
|
1309
1175
|
bones = []
|
|
1310
1176
|
for pchan in obj.pose.bones:
|
|
@@ -1319,19 +1185,8 @@ def _armature_bones(obj, locked_groups):
|
|
|
1319
1185
|
"hide": bool(bone.hide),
|
|
1320
1186
|
"length": float(bone.length),
|
|
1321
1187
|
"matrix": [[float(v) for v in row] for row in pchan.matrix],
|
|
1322
|
-
# BONE_SELECTED
|
|
1323
|
-
#
|
|
1324
|
-
#
|
|
1325
|
-
# SELECTION IS THE POSE CHANNEL'S, and that is a MEASUREMENT rather
|
|
1326
|
-
# than a preference: at this pin `Bone` carries `hide` and
|
|
1327
|
-
# `hide_select` and NO `select` — `rna_def_bone_common`
|
|
1328
|
-
# (`rna_armature.cc:1305`) declares none and `:1913` puts one on
|
|
1329
|
-
# `EditBone` alone, so `bone.select` is an AttributeError. Asked of
|
|
1330
|
-
# the engine itself, a `Bone`'s sel/hide properties are
|
|
1331
|
-
# `['hide', 'hide_select']` and a `PoseBone`'s are
|
|
1332
|
-
# `['hide', 'select']`. It is the same BIT either way — both write
|
|
1333
|
-
# `Bone.flag`'s `BONE_SELECTED`, which is what the overlay's
|
|
1334
|
-
# `bone.flag() & BONE_SELECTED` reads.
|
|
1188
|
+
# PoseBone.select supplies BONE_SELECTED; Bone has no select RNA property.
|
|
1189
|
+
# Selection and the active bone determine get_pchan_color_wire's colours.
|
|
1335
1190
|
"select": bool(pchan.select),
|
|
1336
1191
|
"active": bone.name == active_name,
|
|
1337
1192
|
# BONE_DRAW_LOCKED_WEIGHT, set in weight-paint mode for every bone
|
|
@@ -1374,7 +1229,7 @@ def _bone_inherit(bone):
|
|
|
1374
1229
|
flags.append("inherit_scale=%s" % bone.inherit_scale)
|
|
1375
1230
|
if not bone.use_local_location:
|
|
1376
1231
|
flags.append("use_local_location=False")
|
|
1377
|
-
if
|
|
1232
|
+
if bone.use_relative_parent:
|
|
1378
1233
|
flags.append("use_relative_parent=True")
|
|
1379
1234
|
return {"inherit": flags} if flags else {}
|
|
1380
1235
|
|
|
@@ -1458,10 +1313,25 @@ def _locked_weight_groups(view_layer):
|
|
|
1458
1313
|
obj = view_layer.objects.active
|
|
1459
1314
|
if obj is None or "WEIGHT_PAINT" not in obj.mode:
|
|
1460
1315
|
return frozenset()
|
|
1461
|
-
groups =
|
|
1316
|
+
groups = obj.vertex_groups
|
|
1462
1317
|
return frozenset(group.name for group in groups if group.lock_weight)
|
|
1463
1318
|
|
|
1464
1319
|
|
|
1320
|
+
def _warn_after_armature(view_layer):
|
|
1321
|
+
"""Warn once per object and stack when a modifier after Armature depends on the pose: exported vertices include its bind-time result, while the presenter subsequently moves only the skin."""
|
|
1322
|
+
for obj in view_layer.objects:
|
|
1323
|
+
if obj.type != "MESH":
|
|
1324
|
+
continue
|
|
1325
|
+
stack = [m for m in obj.modifiers if m.show_viewport]
|
|
1326
|
+
at = next((i for i, m in enumerate(stack) if m.type == "ARMATURE" and m.object is not None), None)
|
|
1327
|
+
if at is None or at == len(stack) - 1:
|
|
1328
|
+
continue
|
|
1329
|
+
after = ", ".join("%s (%s)" % (m.name, m.type) for m in stack[at + 1:])
|
|
1330
|
+
warn("%s: the modifiers after its Armature (%s) are evaluated once at the pose it was sent in; as its bones "
|
|
1331
|
+
"move in Play they are carried by the skin, not run again. Apply them, or move them "
|
|
1332
|
+
"before the Armature, where the pose should change their result." % (obj.name, after))
|
|
1333
|
+
|
|
1334
|
+
|
|
1465
1335
|
def _armatures(view_layer):
|
|
1466
1336
|
"""Every armature the view layer shows, with its display type and pose.
|
|
1467
1337
|
|
|
@@ -1496,39 +1366,12 @@ def _armatures(view_layer):
|
|
|
1496
1366
|
return armatures
|
|
1497
1367
|
|
|
1498
1368
|
|
|
1499
|
-
def _weights(scene, view_layer,
|
|
1500
|
-
"""
|
|
1501
|
-
|
|
1502
|
-
Blender's own evaluation, `evaluate_vertex_weight`
|
|
1503
|
-
(`draw/intern/mesh_extractors/extract_mesh_vbo_weights.cc:20-67`), in the
|
|
1504
|
-
default state -- no Multi-Paint, no Lock-Relative -- so the weight is
|
|
1505
|
-
`BKE_defvert_find_weight(dvert, active)` clamped to [0,1], and a vertex the
|
|
1506
|
-
group does not weight is the ALERT value the fragment shader paints
|
|
1507
|
-
`TH_VERTEX_UNREFERENCED` over. WHICH vertices alert is
|
|
1508
|
-
`scene.tool_settings.vertex_group_user` (`rna_scene.cc:3428-3434`, default
|
|
1509
|
-
ACTIVE): ACTIVE alerts a vertex with no weight in the ACTIVE group, ALL only
|
|
1510
|
-
one with no weight in ANY group, NONE never.
|
|
1511
|
-
|
|
1512
|
-
THE ARRAY IS A REFERENCE WHEN NOTHING MOVED, the same contract the meshes
|
|
1513
|
-
have (`blender-runtime-frame.ts`): it is the size of a vertex column and
|
|
1514
|
-
every mutation presents, so it ships only when its CONTENT has changed since
|
|
1515
|
-
the frame this session last sent.
|
|
1516
|
-
|
|
1517
|
-
ITS OWN DIGEST, NOT THE MESH'S REVISION, and that is a measurement rather
|
|
1518
|
-
than a preference. The first shape of this function keyed the reference on
|
|
1519
|
-
the mesh's export revision -- the number the geometry itself ships under --
|
|
1520
|
-
and MEASURED live 2026-09-19: writing 640 deform weights through
|
|
1521
|
-
`VertexGroup.add()` does not move it, because the door's revision follows
|
|
1522
|
-
the depsgraph's GEOMETRY update record and a deform layer is not geometry.
|
|
1523
|
-
So the array shipped once, all zeros, and every later present said
|
|
1524
|
-
"unchanged" while the engine held a full ramp. The walk is unavoidable
|
|
1525
|
-
either way -- an honest answer has to read every vertex -- so what the
|
|
1526
|
-
reference saves is the TRANSFER, and a digest of the bytes is the only key
|
|
1527
|
-
that cannot lie about them."""
|
|
1369
|
+
def _weights(scene, view_layer, known):
|
|
1370
|
+
"""Return the active vertex group’s weights and zero-weight alert flags using Blender’s default evaluation. Transfer typed arrays as base64 and identify retained arrays by their byte digest."""
|
|
1528
1371
|
obj = view_layer.objects.active
|
|
1529
1372
|
if obj is None or obj.type != "MESH" or obj.data is None:
|
|
1530
1373
|
return None
|
|
1531
|
-
group =
|
|
1374
|
+
group = obj.vertex_groups.active
|
|
1532
1375
|
if group is None:
|
|
1533
1376
|
return None
|
|
1534
1377
|
mesh = obj.data
|
|
@@ -1607,18 +1450,39 @@ class Session:
|
|
|
1607
1450
|
|
|
1608
1451
|
def forget(self):
|
|
1609
1452
|
self._known.clear()
|
|
1453
|
+
self._action_revisions = {}
|
|
1454
|
+
|
|
1455
|
+
def _kept_action_revisions(self, door):
|
|
1456
|
+
"""Keep action revisions stable across evaluation-only updates. An edit advances
|
|
1457
|
+
_ACTION_EDITS and permits the native revision to replace the retained revision. All
|
|
1458
|
+
writing requests, scripts, undo and redo count as edits; a file read resets the table."""
|
|
1459
|
+
held = getattr(self, "_action_revisions", None)
|
|
1460
|
+
if held is None:
|
|
1461
|
+
held = self._action_revisions = {}
|
|
1462
|
+
edits = _ACTION_EDITS["count"]
|
|
1463
|
+
shown = {}
|
|
1464
|
+
for name, revision in door.items():
|
|
1465
|
+
known = held.get(name)
|
|
1466
|
+
if known is None or (known[0] != revision and known[2] != edits):
|
|
1467
|
+
held[name] = (revision, revision, edits)
|
|
1468
|
+
else:
|
|
1469
|
+
held[name] = (revision, known[1], edits)
|
|
1470
|
+
shown[name] = held[name][1]
|
|
1471
|
+
for name in [name for name in held if name not in door]:
|
|
1472
|
+
del held[name]
|
|
1473
|
+
return shown
|
|
1610
1474
|
|
|
1611
1475
|
# -- the export
|
|
1612
1476
|
|
|
1613
|
-
def _export(self, depsgraph
|
|
1477
|
+
def _export(self, depsgraph):
|
|
1614
1478
|
"""THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
|
|
1615
1479
|
|
|
1616
1480
|
One call evaluates the scene, walks the depsgraph's own update record
|
|
1617
1481
|
for the revisions, reads Blender's arrays straight into a side arena
|
|
1618
1482
|
and answers a small JSON frame that names each column by
|
|
1619
1483
|
`{offset, length, dtype, count, stride}`; the worker copies those bytes
|
|
1620
|
-
|
|
1621
|
-
|
|
1484
|
+
from EXPORT_BUFFER_PATH (`session-frame.mts`). Large arrays cross
|
|
1485
|
+
through that binary arena rather than JSON.
|
|
1622
1486
|
|
|
1623
1487
|
WHAT THE DOOR DOES NOT DESCRIBE is reduced here and merged in: the
|
|
1624
1488
|
WORLD, whose sky/gradient graph becomes the presenter's world
|
|
@@ -1645,18 +1509,11 @@ class Session:
|
|
|
1645
1509
|
# that can see the ask can read the file.
|
|
1646
1510
|
if EXPORT_BUFFER_PATH:
|
|
1647
1511
|
options["buffer_path"] = EXPORT_BUFFER_PATH
|
|
1648
|
-
#
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
if hasattr(_blender_web, "memory_reset_peak"):
|
|
1654
|
-
_blender_web.memory_reset_peak()
|
|
1655
|
-
if hasattr(_blender_web, "export_frame_chunked"):
|
|
1656
|
-
exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
|
|
1657
|
-
lambda: _asked({"checkpoint": "native-export"}))
|
|
1658
|
-
else:
|
|
1659
|
-
exported = _blender_web.export_frame(json.dumps(options, ensure_ascii=False))
|
|
1512
|
+
# Pull changed resource columns individually after the frame describes them.
|
|
1513
|
+
options["defer"] = True
|
|
1514
|
+
_blender_web.memory_reset_peak()
|
|
1515
|
+
exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
|
|
1516
|
+
lambda: _asked({"checkpoint": "native-export"}))
|
|
1660
1517
|
_mark("export:door")
|
|
1661
1518
|
frame = json.loads(exported)
|
|
1662
1519
|
del exported
|
|
@@ -1664,22 +1521,14 @@ class Session:
|
|
|
1664
1521
|
error = frame.get("error")
|
|
1665
1522
|
if error:
|
|
1666
1523
|
raise RuntimeError("Blender export door: %s" % error)
|
|
1667
|
-
#
|
|
1668
|
-
# `(session, revision)` as a PAIR and refuses outright a frame whose
|
|
1669
|
-
# revision went backwards under a name it already holds ("The runtime
|
|
1670
|
-
# frame is older than the displayed model"). The door's own counter is
|
|
1671
|
-
# per-`Main` -- it starts over on every file read, which is what its
|
|
1672
|
-
# per-datablock revisions must do -- while the session name does not, so
|
|
1673
|
-
# leaving the two in different hands splits the pair the moment a script
|
|
1674
|
-
# opens a .blend. MEASURED 2026-09-18 through the editor tab:
|
|
1675
|
-
# `read_factory_settings` took the door's counter from 40 back to 1 and
|
|
1676
|
-
# nine of 17-workshop-interior's 63 calls died on that refusal.
|
|
1524
|
+
# Keep session and revision under one owner. The native per-Main counter restarts on file reads, while the session’s frame revision must remain monotonic.
|
|
1677
1525
|
self.revision += 1
|
|
1678
1526
|
frame["revision"] = self.revision
|
|
1527
|
+
frame["actions"] = self._kept_action_revisions(frame["actions"])
|
|
1679
1528
|
# THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
|
|
1680
1529
|
# named at the revision the door holds it at (`graph_images`, which is
|
|
1681
1530
|
# the session's to read and not part of the presenter's frame).
|
|
1682
|
-
revisions = frame.pop("graph_images"
|
|
1531
|
+
revisions = frame.pop("graph_images")
|
|
1683
1532
|
for name, graph in graphs.items():
|
|
1684
1533
|
if name not in frame["materials"]:
|
|
1685
1534
|
continue
|
|
@@ -1694,9 +1543,7 @@ class Session:
|
|
|
1694
1543
|
# automatic one is the evaluated bounds the presenter already holds.
|
|
1695
1544
|
if graphs:
|
|
1696
1545
|
depsgraph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1697
|
-
#
|
|
1698
|
-
# lookup per row was quadratic -- measured on the Stoneguard file (11,702 rows): 11,343
|
|
1699
|
-
# calls, 14.4 s of every present.
|
|
1546
|
+
# Index scene objects by name once for the export-row lookup.
|
|
1700
1547
|
by_name = {obj.name: obj for obj in scene.objects}
|
|
1701
1548
|
for row in frame["objects"]:
|
|
1702
1549
|
if not any(m in graphs for m in row["materials"] if m is not None):
|
|
@@ -1725,13 +1572,21 @@ class Session:
|
|
|
1725
1572
|
# by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
|
|
1726
1573
|
# `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
|
|
1727
1574
|
for entry in frame["materials"].values():
|
|
1728
|
-
material = bpy.data.materials.get(entry
|
|
1575
|
+
material = bpy.data.materials.get(entry["name"])
|
|
1729
1576
|
if material is not None:
|
|
1730
1577
|
entry["viewport"] = {
|
|
1731
1578
|
"color": [float(v) for v in material.diffuse_color],
|
|
1732
1579
|
"roughness": float(material.roughness),
|
|
1733
1580
|
"metallic": float(material.metallic),
|
|
1734
1581
|
}
|
|
1582
|
+
# Settings > Backface Culling > Shadow: a shadow from the faces turned to the light
|
|
1583
|
+
# only. Off (the default), EEVEE casts from both faces of every surface.
|
|
1584
|
+
entry["backface_culling_shadow"] = bool(material.use_backface_culling_shadow)
|
|
1585
|
+
# Settings > Backface Culling > Camera: the surface's back faces are not drawn (the
|
|
1586
|
+
# glTF importer sets it on every single-sided material). Transparency Overlap draws
|
|
1587
|
+
# all faces of a blended surface in one pass (on, the default), or only the nearest (off).
|
|
1588
|
+
entry["backface_culling"] = bool(material.use_backface_culling)
|
|
1589
|
+
entry["transparency_overlap"] = bool(material.use_transparency_overlap)
|
|
1735
1590
|
graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1736
1591
|
# ONE NAME, TWO OBJECTS: Blender tells a linked object from a local one (or from another
|
|
1737
1592
|
# file's) by its library as well as its name; the presenter knows them by name alone and
|
|
@@ -1763,8 +1618,8 @@ class Session:
|
|
|
1763
1618
|
# (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
|
|
1764
1619
|
# turn with the cursor's rotation in whichever `rotation_mode` it has.
|
|
1765
1620
|
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1766
|
-
#
|
|
1767
|
-
#
|
|
1621
|
+
# Inspection overlays are derived from the evaluated scene and view layer.
|
|
1622
|
+
# Weight-array reuse is keyed by their own byte digest.
|
|
1768
1623
|
view_layer = graph.view_layer
|
|
1769
1624
|
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1770
1625
|
# both from the scene, so they come from here (`_subject_line`).
|
|
@@ -1778,36 +1633,52 @@ class Session:
|
|
|
1778
1633
|
# WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
|
|
1779
1634
|
frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
|
|
1780
1635
|
frame["view"] = _saved_view()
|
|
1636
|
+
# A WORLD background is the World's Viewport Display colour (`World.color`).
|
|
1637
|
+
if frame["view"] is not None:
|
|
1638
|
+
frame["view"]["world_color"] = [float(v) for v in scene.world.color] if scene.world else None
|
|
1639
|
+
# THE SCENE'S COLOUR MANAGEMENT, with the frame it colours: Rendered and Material Preview
|
|
1640
|
+
# are drawn through it (`blender-display-transform.ts`), so it travels with every present
|
|
1641
|
+
# and the tab never draws a frame through settings it has not been sent yet.
|
|
1642
|
+
# HOW THE VIEWPORT ANTI-ALIASES: Solid by Workbench's accumulation (the scene's own
|
|
1643
|
+
# `display.viewport_aa` when Workbench renders it, the Preferences' otherwise) and
|
|
1644
|
+
# Rendered by EEVEE's film (`taa_samples` over `filter_size`).
|
|
1645
|
+
frame["sampling"] = {
|
|
1646
|
+
"engine": scene.render.engine,
|
|
1647
|
+
"viewport_aa": scene.display.viewport_aa,
|
|
1648
|
+
"taa_samples": int(scene.eevee.taa_samples),
|
|
1649
|
+
"filter_size": float(scene.render.filter_size),
|
|
1650
|
+
}
|
|
1651
|
+
view_settings = scene.view_settings
|
|
1652
|
+
frame["display"] = {
|
|
1653
|
+
"view_transform": view_settings.view_transform,
|
|
1654
|
+
"look": view_settings.look or "None",
|
|
1655
|
+
"exposure": float(view_settings.exposure),
|
|
1656
|
+
"gamma": float(view_settings.gamma),
|
|
1657
|
+
"display_device": scene.display_settings.display_device,
|
|
1658
|
+
}
|
|
1781
1659
|
frame["units"] = {
|
|
1782
1660
|
"system": scene.unit_settings.system,
|
|
1783
1661
|
"scale_length": float(scene.unit_settings.scale_length),
|
|
1784
1662
|
}
|
|
1785
1663
|
frame["armatures"] = _armatures(view_layer)
|
|
1664
|
+
_warn_after_armature(view_layer)
|
|
1786
1665
|
# THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
|
|
1787
1666
|
frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
|
|
1788
1667
|
"fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
|
|
1789
1668
|
# ACTIONS NOTHING KEEPS: no user and no fake user, so the next save drops them (Blender's own
|
|
1790
1669
|
# rule). A game that plays one is told before it vanishes.
|
|
1791
1670
|
frame["unkeptActions"] = sorted(a.name for a in bpy.data.actions if a.users == 0 and not a.use_fake_user)
|
|
1792
|
-
frame["weights"] = _weights(scene, view_layer,
|
|
1793
|
-
warnings = list(frame
|
|
1671
|
+
frame["weights"] = _weights(scene, view_layer, self._known)
|
|
1672
|
+
warnings = list(frame["warnings"])
|
|
1794
1673
|
warnings.extend(_WARNINGS)
|
|
1795
1674
|
del _WARNINGS[:]
|
|
1796
1675
|
frame["warnings"] = warnings
|
|
1797
1676
|
return frame
|
|
1798
1677
|
|
|
1799
1678
|
def present(self, capture=None):
|
|
1800
|
-
"""
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
it blocks: `bpy.ops.render.render()` is waiting on the pixels.
|
|
1804
|
-
|
|
1805
|
-
THE PRESENTER IS THE AUTHORITY ON WHAT IT HOLDS, and it says so by
|
|
1806
|
-
name: a page that reloaded under a still-running session holds none of
|
|
1807
|
-
this session's geometry while `_known` says it was sent. The record is
|
|
1808
|
-
dropped and the frame goes out again IN FULL -- the same answer
|
|
1809
|
-
`runtime_session.present` gives, for the same reason.
|
|
1810
|
-
"""
|
|
1679
|
+
"""Present a frame and return the tab’s answer. Rendering blocks on this same channel for its
|
|
1680
|
+
pixels. Reconcile resource identity with the presenter’s held frame; when a document
|
|
1681
|
+
reload has discarded it, resend the required resources in full."""
|
|
1811
1682
|
try:
|
|
1812
1683
|
return self._present(capture)
|
|
1813
1684
|
except RuntimeError as error:
|
|
@@ -1816,11 +1687,12 @@ class Session:
|
|
|
1816
1687
|
self._known.clear()
|
|
1817
1688
|
return self._present(capture)
|
|
1818
1689
|
|
|
1819
|
-
def _present(self, capture
|
|
1690
|
+
def _present(self, capture, depsgraph=None):
|
|
1820
1691
|
known = dict(self._known)
|
|
1821
1692
|
_mark("present:before")
|
|
1822
1693
|
frame = self._export(depsgraph)
|
|
1823
1694
|
_mark("present:exported")
|
|
1695
|
+
self._carry_browser_decoded_images(frame)
|
|
1824
1696
|
pulled = self._pull(frame, render=depsgraph is not None)
|
|
1825
1697
|
_mark("present:pulled")
|
|
1826
1698
|
self._drop_unreachable_textures(frame)
|
|
@@ -1840,22 +1712,14 @@ class Session:
|
|
|
1840
1712
|
# its value also depends on native undo initialization/checkpoints.
|
|
1841
1713
|
"dirty": bool(bpy.data.is_dirty),
|
|
1842
1714
|
}
|
|
1843
|
-
#
|
|
1844
|
-
# the model does (`mark_changed`, from `dispatch`). The predicate was the
|
|
1845
|
-
# present itself, and a present also runs with no change behind it --
|
|
1846
|
-
# opening the document, rebinding its view. MEASURED 2026-09-24 on
|
|
1847
|
-
# `arena`: opening a Model document and doing nothing rewrote its
|
|
1848
|
-
# `.blend` with 556 bytes changed, so a file a person only looked at read
|
|
1849
|
-
# as modified. `columns`/`images` above stay the wrong question for the
|
|
1850
|
-
# same reason as before: a MOVE ships zero columns and a DELETION ships
|
|
1851
|
-
# nothing, and both come from a request `mark_changed` sees.
|
|
1715
|
+
# Only a request that can mutate the model marks the document changed. A present or view rebind alone must not trigger a save.
|
|
1852
1716
|
for key, mesh in frame["meshes"].items():
|
|
1853
1717
|
self._known["mesh:" + key] = mesh["revision"]
|
|
1854
1718
|
for name, image in frame["images"].items():
|
|
1855
1719
|
self._known["image:" + name] = image["revision"]
|
|
1856
1720
|
# The weight array follows the meshes' own rule: recorded as sent, so
|
|
1857
1721
|
# the next present ships a reference instead of the bytes.
|
|
1858
|
-
weights = frame
|
|
1722
|
+
weights = frame["weights"]
|
|
1859
1723
|
if weights is not None:
|
|
1860
1724
|
self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
|
|
1861
1725
|
weights["digest"])
|
|
@@ -1880,18 +1744,11 @@ class Session:
|
|
|
1880
1744
|
self._reconcile_with_presenter(answer, frame)
|
|
1881
1745
|
return answer
|
|
1882
1746
|
|
|
1883
|
-
def _pull(self, frame, render
|
|
1884
|
-
"""
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
turn, copying each out before the next is written (every pull frees the one before). The
|
|
1889
|
-
engine's peak is the scene plus one datablock's columns, not plus every changed one's.
|
|
1890
|
-
|
|
1891
|
-
The pieces come back into the frame as the door would have written them: a picture with no
|
|
1892
|
-
readable pixels leaves `images` with its warning, as it did when the frame carried it
|
|
1893
|
-
whole, so `_drop_unreachable_textures` reads the same frame either way. Answers whether
|
|
1894
|
-
anything was pulled; the worker then presents the frame with its pieces."""
|
|
1747
|
+
def _pull(self, frame, render):
|
|
1748
|
+
"""Pull deferred mesh and image columns one datablock at a time after the worker copies the
|
|
1749
|
+
frame header. Copy each resource before the next pull releases its columns. Remove
|
|
1750
|
+
unreadable images with their warnings before texture reconciliation. Return whether any
|
|
1751
|
+
resource was pulled."""
|
|
1895
1752
|
meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
|
|
1896
1753
|
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1897
1754
|
if not meshes and not images and not render:
|
|
@@ -1909,7 +1766,7 @@ class Session:
|
|
|
1909
1766
|
piece = json.loads(door(json.dumps(options, ensure_ascii=False)))
|
|
1910
1767
|
if piece.get("error"):
|
|
1911
1768
|
raise RuntimeError("Blender export door: %s" % piece["error"])
|
|
1912
|
-
frame["warnings"].extend(piece
|
|
1769
|
+
frame["warnings"].extend(piece["warnings"])
|
|
1913
1770
|
if piece[kind] is None:
|
|
1914
1771
|
del frame["images"][key]
|
|
1915
1772
|
continue
|
|
@@ -1920,8 +1777,6 @@ class Session:
|
|
|
1920
1777
|
"""Render evaluation owns different geometry and visibility than a viewport.
|
|
1921
1778
|
Keep its native revisions, deferred pulls and presenter separate for the
|
|
1922
1779
|
entire blocking capture, and restore the authoring export in finally."""
|
|
1923
|
-
if not hasattr(_blender_web, "begin_render_export"):
|
|
1924
|
-
raise RuntimeError("This Blender artifact has no render-evaluated export; update the artifact and its corresponding source")
|
|
1925
1780
|
_blender_web.begin_render_export(depsgraph)
|
|
1926
1781
|
try:
|
|
1927
1782
|
rendering = Session()
|
|
@@ -1930,20 +1785,33 @@ class Session:
|
|
|
1930
1785
|
finally:
|
|
1931
1786
|
_blender_web.end_render_export()
|
|
1932
1787
|
|
|
1788
|
+
def _carry_browser_decoded_images(self, frame):
|
|
1789
|
+
"""Send WebP images as their encoded file bytes when Blender has no decoder and the browser can decode them. Revision them by the bytes and include them as deferred images."""
|
|
1790
|
+
carried = frame["images"]
|
|
1791
|
+
names = set()
|
|
1792
|
+
for material in frame["materials"].values():
|
|
1793
|
+
for slot in ("texture", "roughness_texture", "normal_texture"):
|
|
1794
|
+
reference = material.get(slot)
|
|
1795
|
+
if reference is not None:
|
|
1796
|
+
names.add(reference["image"]["name"])
|
|
1797
|
+
graph = material.get("graph")
|
|
1798
|
+
for node in graph["nodes"].values() if graph is not None else ():
|
|
1799
|
+
image = node["props"].get("image")
|
|
1800
|
+
if image is not None:
|
|
1801
|
+
names.add(image["name"])
|
|
1802
|
+
for name in names:
|
|
1803
|
+
if name in carried:
|
|
1804
|
+
continue
|
|
1805
|
+
data = _browser_decoded_bytes(bpy.data.images.get(name))
|
|
1806
|
+
if data is None:
|
|
1807
|
+
continue
|
|
1808
|
+
revision = int.from_bytes(hashlib.sha256(data).digest()[:4], "big") & 0x7fffffff
|
|
1809
|
+
if self._known.get("image:" + name) == revision:
|
|
1810
|
+
continue
|
|
1811
|
+
carried[name] = {"deferred": True, "revision": revision}
|
|
1812
|
+
|
|
1933
1813
|
def _send_encoded_image(self, key, deferred):
|
|
1934
|
-
"""
|
|
1935
|
-
|
|
1936
|
-
A modelling program keeps a picture compressed as its file and lets the GPU hold the
|
|
1937
|
-
decoded texture; only this side's raster door decoded every picture into RGBA and shipped
|
|
1938
|
-
that. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material pictures are 171 MB as
|
|
1939
|
-
their packed files and 1,343 MB as RGBA, and the hosted tab's renderer passed 10 GB while
|
|
1940
|
-
it opened. So a picture whose texels ARE its file -- a plain file or packed PNG or JPEG,
|
|
1941
|
-
not edited in this session -- is written to the engine's filesystem as those bytes and
|
|
1942
|
-
crosses as they are (`worker.ts`), decoded by the browser. The rest keep the raster door:
|
|
1943
|
-
a key with `@` (a roughness remap bakes new texels), a picture painted here (`is_dirty`),
|
|
1944
|
-
a UDIM or generated one, or bytes neither decoder reads.
|
|
1945
|
-
|
|
1946
|
-
Answers whether it was sent."""
|
|
1814
|
+
"""Send unchanged file-backed encoded images directly when their bytes represent the frame’s texels. Painted, generated and UDIM images retain the raw-pixel path. Return whether the image was sent."""
|
|
1947
1815
|
if "@" in key:
|
|
1948
1816
|
return False
|
|
1949
1817
|
image = bpy.data.images.get(key)
|
|
@@ -1957,11 +1825,8 @@ class Session:
|
|
|
1957
1825
|
data = fh.read()
|
|
1958
1826
|
except (OSError, TypeError, ValueError):
|
|
1959
1827
|
return False
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
elif data[:3] == b"\xff\xd8\xff":
|
|
1963
|
-
mime = "image/jpeg"
|
|
1964
|
-
else:
|
|
1828
|
+
mime = _encoded_mime(data)
|
|
1829
|
+
if mime is None:
|
|
1965
1830
|
return False
|
|
1966
1831
|
path = os.path.join(ROOT, "encoded-image.bin")
|
|
1967
1832
|
with open(path, "wb") as fh:
|
|
@@ -1970,7 +1835,9 @@ class Session:
|
|
|
1970
1835
|
_asked({"image": key, "piece": {
|
|
1971
1836
|
"revision": deferred["revision"],
|
|
1972
1837
|
"mime": mime,
|
|
1973
|
-
|
|
1838
|
+
# (a picture this Blender could not load has no colour space name; an 8-bit file's default is sRGB)
|
|
1839
|
+
"colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" or (
|
|
1840
|
+
image.colorspace_settings.name == "" and not image.colorspace_settings.is_data) else "data",
|
|
1974
1841
|
"encodedPath": path,
|
|
1975
1842
|
}})
|
|
1976
1843
|
finally:
|
|
@@ -1978,137 +1845,43 @@ class Session:
|
|
|
1978
1845
|
return True
|
|
1979
1846
|
|
|
1980
1847
|
def _drop_unreachable_textures(self, frame):
|
|
1981
|
-
"""
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
is named by the door (`image <name> has no readable pixels`) and left
|
|
1985
|
-
out of the frame -- while the MATERIAL still carries the texture
|
|
1986
|
-
reference. The presenter then refuses BY NAME
|
|
1987
|
-
(`RUNTIME_FRAME_UNKNOWN_IMAGE`), and that refusal cannot be satisfied:
|
|
1988
|
-
re-sending the frame in full re-exports the same unreadable image, so
|
|
1989
|
-
the second present dies the same way and the whole call fails.
|
|
1990
|
-
|
|
1991
|
-
WHY an image is unreadable is the EXPORT DOOR's question and is not
|
|
1992
|
-
answered here, so this says only what it observed. Three mechanisms
|
|
1993
|
-
that would explain it were measured in the tab on 2026-09-18 and all
|
|
1994
|
-
three SHIP correctly, so none of them is it: a `images.new` image
|
|
1995
|
-
filled through `pixels.foreach_set`; a PNG written with `image.save()`
|
|
1996
|
-
and read back with `images.load`; and the same PNG written and read
|
|
1997
|
-
through a `replay_fs`-style symlinked run directory.
|
|
1998
|
-
|
|
1999
|
-
MEASURED 2026-09-18: the five-model battery's `17-workshop-interior`,
|
|
2000
|
-
`18-riverside-bridge`, `19-tram-stop` and `20-rigged-courier` each got
|
|
2001
|
-
ZERO calls, refused inside `blender-start` -- `bind_document` opened
|
|
2002
|
-
the document the previous model wrote, whose textures live OUTSIDE the
|
|
2003
|
-
project (another folder of the machine that wrote it) and
|
|
2004
|
-
are in no fresh worker's filesystem.
|
|
2005
|
-
|
|
2006
|
-
THE PRESENTER'S REFUSAL IS RIGHT AND STAYS. It answers "the record says
|
|
2007
|
-
I hold this and I do not", which is the desync `_reconcile_with_presenter`
|
|
2008
|
-
exists for. What is wrong is asking it about a picture the session
|
|
2009
|
-
NEVER SENT AND CANNOT SEND. So a texture reference naming an image that
|
|
2010
|
-
is in neither this frame nor `_known` is dropped here, and the
|
|
2011
|
-
mechanism is named once (`warn`) -- the material falls back to its own
|
|
2012
|
-
socket colour, which is this file's stated rule for a capability the
|
|
2013
|
-
export cannot reach.
|
|
2014
|
-
|
|
2015
|
-
A name `_known` still holds is left alone: that IS the desync case, and
|
|
2016
|
-
the refusal it raises is the signal that clears the tables and re-ships.
|
|
2017
|
-
"""
|
|
2018
|
-
carried = frame.get("images") or {}
|
|
2019
|
-
for material in (frame.get("materials") or {}).values():
|
|
1848
|
+
"""Warn for unreadable images and mark their material references as missing so the presenter can draw Blender’s missing-image colour. Unknown previously sent images still trigger desynchronization recovery."""
|
|
1849
|
+
carried = frame["images"]
|
|
1850
|
+
for material in frame["materials"].values():
|
|
2020
1851
|
for slot in ("texture", "roughness_texture", "normal_texture"):
|
|
2021
1852
|
reference = material.get(slot)
|
|
2022
|
-
if
|
|
1853
|
+
if reference is None:
|
|
2023
1854
|
continue
|
|
2024
|
-
name =
|
|
2025
|
-
if name
|
|
1855
|
+
name = reference["image"]["name"]
|
|
1856
|
+
if name in carried or ("image:" + name) in self._known:
|
|
2026
1857
|
continue
|
|
2027
1858
|
del material[slot]
|
|
2028
|
-
#
|
|
2029
|
-
# tells them apart -- because the text names `write_image` and
|
|
2030
|
-
# a reader reasonably concludes the EXPORT is broken, which
|
|
2031
|
-
# twice it was not. `write_image` says "no readable pixels"
|
|
2032
|
-
# when `BKE_image_acquire_ibuf` hands back nothing, and the
|
|
2033
|
-
# image is usually the thing at fault, not the export:
|
|
2034
|
-
#
|
|
2035
|
-
# 1. THE IMAGE NEVER LOADED. `source == 'FILE'` and the
|
|
2036
|
-
# filepath does not resolve inside the program -- a
|
|
2037
|
-
# relative path saved against a directory the guest
|
|
2038
|
-
# filesystem does not carry is the worked case. Measured
|
|
2039
|
-
# 2026-09-19 (WS-W): a probe's `models/model.blend`
|
|
2040
|
-
# raised 18 of these, and every one of its images read
|
|
2041
|
-
# `has_data False, size (0, 0), len(pixels) 0`.
|
|
2042
|
-
# 2. THE PROGRAM WAS OUT OF MEMORY. Measured 2026-09-19 on
|
|
2043
|
-
# the WALI skew: `16-market-courtyard` raised eight of
|
|
2044
|
-
# these while the module was linked
|
|
2045
|
-
# `-Wl,--max-memory=1073741824`, and raising that link to
|
|
2046
|
-
# 4 GiB made all eight vanish -- same recording, same
|
|
2047
|
-
# staged textures, same door, 95/95 calls and a silent
|
|
2048
|
-
# console.
|
|
2049
|
-
#
|
|
2050
|
-
# So before suspecting the export, ask the images:
|
|
2051
|
-
# for im in bpy.data.images:
|
|
2052
|
-
# print(im.name, im.source, tuple(im.size), im.has_data,
|
|
2053
|
-
# im.filepath)
|
|
2054
|
-
# (0, 0) with has_data False is cause 1 or 2, never the export.
|
|
1859
|
+
# Warn when neither this frame nor the retained record can supply the image.
|
|
2055
1860
|
warn("material %s: the export could not read the picture %s, so this "
|
|
2056
1861
|
"frame draws the material without it. The image is in neither "
|
|
2057
1862
|
"this frame nor the presenter's record, so nothing can be "
|
|
2058
1863
|
"re-sent to satisfy it; the export door is what knows why "
|
|
2059
1864
|
"(`bpy_web_export.cc::write_image`)."
|
|
2060
|
-
% (material
|
|
2061
|
-
|
|
2062
|
-
#
|
|
2063
|
-
# the slots above do not cover: a graph naming a picture that was never sent had
|
|
2064
|
-
# the presenter refuse the whole frame, so a file whose textures are missing did
|
|
2065
|
-
# not open at all (measured on `16-market-courtyard` saved with its textures
|
|
2066
|
-
# outside the project). Blender opens such a file and draws each missing picture
|
|
2067
|
-
# magenta; the node is kept, marked, and the presenter draws it so.
|
|
1865
|
+
% (material["name"], name))
|
|
1866
|
+
|
|
1867
|
+
# Mark unreadable node-graph images as missing, retaining the nodes so the presenter draws them magenta.
|
|
2068
1868
|
graph = material.get("graph")
|
|
2069
|
-
for node in
|
|
2070
|
-
props = node
|
|
1869
|
+
for node in graph["nodes"].values() if graph is not None else ():
|
|
1870
|
+
props = node["props"]
|
|
2071
1871
|
image = props.get("image")
|
|
2072
|
-
|
|
2073
|
-
|
|
1872
|
+
if image is None:
|
|
1873
|
+
continue
|
|
1874
|
+
name = image["name"]
|
|
1875
|
+
if name in carried or ("image:" + name) in self._known:
|
|
2074
1876
|
continue
|
|
2075
1877
|
props["image"] = None
|
|
2076
1878
|
props["image_missing"] = True
|
|
2077
1879
|
warn("material %s: the picture %s is not readable in this program (%s), so it "
|
|
2078
1880
|
"draws magenta, as Blender draws a missing image."
|
|
2079
|
-
% (material
|
|
1881
|
+
% (material["name"], name, "a path outside the project, or no pixels"))
|
|
2080
1882
|
|
|
2081
1883
|
def _reconcile_with_presenter(self, answer, frame):
|
|
2082
|
-
"""
|
|
2083
|
-
|
|
2084
|
-
`_known` is what this session BELIEVES the tab holds, accumulated one
|
|
2085
|
-
present at a time. The presenter is what actually holds it, and the two
|
|
2086
|
-
come apart with neither side failing: the worker and this Python
|
|
2087
|
-
session belong to the TAB (`blender-runtime-host.ts` keeps one
|
|
2088
|
-
`BlenderRuntime`), while the presenter belongs to the Model DOCUMENT --
|
|
2089
|
-
so rebuilding that document (closing and reopening it, a play stall
|
|
2090
|
-
that remounts it) leaves a view holding nothing under a session whose
|
|
2091
|
-
tables say everything crossed. Every later frame then ships references
|
|
2092
|
-
to bytes that are gone.
|
|
2093
|
-
|
|
2094
|
-
MEASURED 2026-09-18, the battery's `19-tram-stop` and `20-rigged-courier`:
|
|
2095
|
-
both got zero calls, refused at `blender-start` with
|
|
2096
|
-
`RUNTIME_FRAME_UNKNOWN_IMAGE: the presenter does not hold runtime image
|
|
2097
|
-
wood_planks_roughness.png@roughness(...) (revision 1)`.
|
|
2098
|
-
|
|
2099
|
-
So every present's answer carries `held` -- what the presenter held
|
|
2100
|
-
BEFORE this frame (`protocol.ts`) -- and a holding that is not this
|
|
2101
|
-
session's means the accumulated record describes a presenter that is
|
|
2102
|
-
gone. What survives is exactly what THIS frame carried: a mesh whose
|
|
2103
|
-
columns crossed and every image in it (there is no unchanged-image
|
|
2104
|
-
form). A mesh that went as a reference did not cross and is dropped.
|
|
2105
|
-
|
|
2106
|
-
An ABSENT `held` is not a report and is judged as nothing: an older
|
|
2107
|
-
presenter must not be read as "holds nothing" on every frame.
|
|
2108
|
-
|
|
2109
|
-
`_known` is only ever narrowed here, never widened, so the correction
|
|
2110
|
-
costs one re-ship and can never suppress one.
|
|
2111
|
-
"""
|
|
1884
|
+
"""Reconcile the session’s known resources with the presenter’s held frame. A reopened document can discard data while the worker survives; narrow known resources so missing data is sent again."""
|
|
2112
1885
|
if not isinstance(answer, dict) or "held" not in answer:
|
|
2113
1886
|
self.presenter_held = "unreported"
|
|
2114
1887
|
return
|
|
@@ -2122,7 +1895,7 @@ class Session:
|
|
|
2122
1895
|
self._known["mesh:" + key] = mesh["revision"]
|
|
2123
1896
|
for name, image in frame["images"].items():
|
|
2124
1897
|
self._known["image:" + name] = image["revision"]
|
|
2125
|
-
weights = frame
|
|
1898
|
+
weights = frame["weights"]
|
|
2126
1899
|
if weights is not None and not weights.get("unchanged"):
|
|
2127
1900
|
self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
|
|
2128
1901
|
weights["digest"])
|
|
@@ -2151,9 +1924,7 @@ class Session:
|
|
|
2151
1924
|
return {"document": relative_path, "opened": False}
|
|
2152
1925
|
_asked({"checkpoint": "document-open"})
|
|
2153
1926
|
_mark("open:before")
|
|
2154
|
-
|
|
2155
|
-
if checkpointed_read:
|
|
2156
|
-
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
1927
|
+
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
2157
1928
|
self._binding = True
|
|
2158
1929
|
try:
|
|
2159
1930
|
bpy.ops.wm.open_mainfile(filepath=self.document)
|
|
@@ -2163,32 +1934,39 @@ class Session:
|
|
|
2163
1934
|
raise
|
|
2164
1935
|
finally:
|
|
2165
1936
|
self._binding = False
|
|
2166
|
-
|
|
2167
|
-
_blender_web.set_read_checkpoint(None)
|
|
1937
|
+
_blender_web.set_read_checkpoint(None)
|
|
2168
1938
|
_mark("open:after")
|
|
2169
1939
|
_asked({"checkpoint": "document-opened"})
|
|
2170
1940
|
size = os.path.getsize(self.document)
|
|
2171
|
-
#
|
|
2172
|
-
#
|
|
2173
|
-
# Blender's own memory, packed pictures included, and the next save writes the path anew.
|
|
2174
|
-
# Freed before the first present, its bytes serve that present's evaluation instead of
|
|
2175
|
-
# raising the heap's high-water mark by the file's size (307 MB for the Stoneguard file).
|
|
2176
|
-
# The worker still holds the host's stamp for it, so it is not staged in again.
|
|
1941
|
+
# Remove the staged .blend after Blender loads it to release its WasmFS bytes before
|
|
1942
|
+
# presentation. The worker keeps its host stamp, and the next save rewrites the file.
|
|
2177
1943
|
os.remove(self.document)
|
|
2178
|
-
|
|
1944
|
+
# A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
|
|
1945
|
+
# failed the open would leave a saved document unopenable and every MCP call refused.
|
|
1946
|
+
# The model is in Blender, so the session keeps it and says loudly that the view is behind;
|
|
1947
|
+
# the next edit presents again, and can remove whatever the presenter could not take.
|
|
1948
|
+
presented = True
|
|
1949
|
+
try:
|
|
1950
|
+
self.present()
|
|
1951
|
+
except Exception as thrown: # noqa: BLE001
|
|
1952
|
+
presented = False
|
|
1953
|
+
if _another_document_shown(thrown):
|
|
1954
|
+
# A document switched away during opening may refuse this frame. It presents again when it becomes active.
|
|
1955
|
+
_say("%s is open in Blender; the editor is showing another document, so it is presented when "
|
|
1956
|
+
"it is shown again" % relative_path)
|
|
1957
|
+
else:
|
|
1958
|
+
_say("@@VOLTER-ERROR %s is open in Blender, but its first present failed, so the Model "
|
|
1959
|
+
"document does not show it yet: %r" % (relative_path, thrown))
|
|
2179
1960
|
# The load did not dirty anything: what is in memory IS the file.
|
|
2180
1961
|
self.save_due = False
|
|
2181
1962
|
return {"document": relative_path, "opened": True,
|
|
2182
1963
|
"objects": len(bpy.data.objects),
|
|
2183
|
-
"size": size}
|
|
1964
|
+
"size": size, "presented": presented}
|
|
2184
1965
|
|
|
2185
1966
|
def loaded(self):
|
|
2186
|
-
"""
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
the document: a script that ran `read_homefile` on its own, meaning to Save As a new
|
|
2190
|
-
piece next, emptied the open level on disk. MEASURED 2026-10-08 in the editor's Blender:
|
|
2191
|
-
a document with four objects was saved with none."""
|
|
1967
|
+
"""Track file loads that replace the session’s document model. The session’s own document
|
|
1968
|
+
open retains ownership; other loads suspend saving until the model belongs to the document
|
|
1969
|
+
again."""
|
|
2192
1970
|
if self._binding:
|
|
2193
1971
|
return
|
|
2194
1972
|
self._foreign = True
|
|
@@ -2196,38 +1974,16 @@ class Session:
|
|
|
2196
1974
|
|
|
2197
1975
|
def mark_changed(self):
|
|
2198
1976
|
"""A request that can change the model ran: the next present carries
|
|
2199
|
-
`saveDue`, and the
|
|
1977
|
+
`saveDue`, and the document is dirty until the workbench saves it. (Its actions too: `_kept_action_revisions`.)"""
|
|
1978
|
+
_ACTION_EDITS["count"] += 1
|
|
2200
1979
|
if self.document is not None:
|
|
2201
1980
|
self.save_due = True
|
|
2202
1981
|
|
|
2203
1982
|
def follow_open_file(self):
|
|
2204
|
-
"""
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
and leaves X OPEN -- `bpy.data.filepath` becomes X, and Blender's next
|
|
2209
|
-
Save goes there. The session used to keep saving to the file it was
|
|
2210
|
-
started on, so the model a script had just saved as X was written over
|
|
2211
|
-
the OLD file on the next idle save, and the editor refused X's own tab
|
|
2212
|
-
because the worker still claimed the old one. SEEN 2026-10-07: an
|
|
2213
|
-
agent built an obby through the Blender MCP, saved it as
|
|
2214
|
-
`src/models/obby.blend`, and its level landed in `src/models/cube.blend`
|
|
2215
|
-
instead -- so Play looked for `cube.play.ts`.
|
|
2216
|
-
|
|
2217
|
-
What does NOT move, by Blender's own rules rather than ours:
|
|
2218
|
-
`save_mainfile()` and a Save As to the same path leave the path where it
|
|
2219
|
-
is, and `save_as_mainfile(copy=True)` writes a copy and keeps the
|
|
2220
|
-
original open (which is also how `save_document` writes, so the
|
|
2221
|
-
session's own save never moves anything). `open_mainfile` does move it:
|
|
2222
|
-
Blender then holds that file, and saving the old path would write one
|
|
2223
|
-
file's model over another's.
|
|
2224
|
-
|
|
2225
|
-
Answers None when nothing moved, `{"moved": True, "from", "document"}`
|
|
2226
|
-
when the document followed Blender to another project `.blend`, and
|
|
2227
|
-
`{"moved": False, "from", "outside"}` -- once per file -- when Blender
|
|
2228
|
-
holds a file outside the project: the editor has no document there, so
|
|
2229
|
-
the session keeps its own and keeps saving to it.
|
|
2230
|
-
"""
|
|
1983
|
+
"""Follow Blender’s open file after execute. Save As and open_mainfile can move the bound
|
|
1984
|
+
document to another project .blend; saving a copy preserves the current binding. Return
|
|
1985
|
+
None when unchanged, a moved answer for a project path, or an outside-project refusal once
|
|
1986
|
+
per file. Never save another file’s in-memory model over the bound document."""
|
|
2231
1987
|
if self.document is None:
|
|
2232
1988
|
return None
|
|
2233
1989
|
if not bpy.data.filepath:
|
|
@@ -2271,94 +2027,7 @@ class Session:
|
|
|
2271
2027
|
self.document_relative)}
|
|
2272
2028
|
|
|
2273
2029
|
def save_document(self):
|
|
2274
|
-
"""
|
|
2275
|
-
|
|
2276
|
-
ONE WRITE, AND IT DOES NOT TOUCH THE SCRIPT'S DATABLOCKS. The save used
|
|
2277
|
-
to run `bpy.ops.file.make_paths_relative()` and then write a second
|
|
2278
|
-
time, so that a document would reopen on another machine. That rewrote
|
|
2279
|
-
the filepath of every image in `bpy.data` -- datablocks the SCRIPT
|
|
2280
|
-
owns and did not ask to have moved. MEASURED 2026-09-18,
|
|
2281
|
-
`16-market-courtyard` seq 10: the model loads its textures from an
|
|
2282
|
-
absolute host path the harness maps into the sandbox, the first idle
|
|
2283
|
-
save at seq ~8 turned those filepaths into `//../../..` relative to
|
|
2284
|
-
`/project/models/`, and the next texture write resolved against the
|
|
2285
|
-
document's directory instead and died in Emscripten's FS with
|
|
2286
|
-
`ErrnoError`. Nine of the courtyard's 95 calls ran. The document the
|
|
2287
|
-
run left behind then named images that no longer resolved, so the three
|
|
2288
|
-
models that opened it afterwards got zero calls apiece.
|
|
2289
|
-
|
|
2290
|
-
`relative_remap` IS FALSE, and that is a measurement rather than a
|
|
2291
|
-
reading of the operator's documentation. `save_as_mainfile`'s
|
|
2292
|
-
`relative_remap` is described as remapping RELATIVE paths so they stay
|
|
2293
|
-
valid from a new location; on this build it also rewrites ABSOLUTE
|
|
2294
|
-
ones. MEASURED 2026-09-18 in the tab, four cells, one image loaded from
|
|
2295
|
-
an absolute path outside the project, `/outside/wsh-outside/textures/wsh_abs.png`:
|
|
2296
|
-
|
|
2297
|
-
relative_remap use_relative_paths img.filepath after the save
|
|
2298
|
-
False True /outside/.../wsh_abs.png
|
|
2299
|
-
False False /outside/.../wsh_abs.png
|
|
2300
|
-
True False //../../../wsh-outside/.../wsh_abs.png
|
|
2301
|
-
True True //../../../wsh-outside/.../wsh_abs.png
|
|
2302
|
-
|
|
2303
|
-
`relative_remap` alone decides it and the preference does not enter.
|
|
2304
|
-
So True is the same rewrite `make_paths_relative` was, arriving through
|
|
2305
|
-
a different door, and False is the only value that leaves a script's
|
|
2306
|
-
paths as the script wrote them. "Reopens on another machine" is served
|
|
2307
|
-
by a script loading its project files through project-relative paths in
|
|
2308
|
-
the first place, which is the script's choice to make.
|
|
2309
|
-
|
|
2310
|
-
`copy` IS SET, and it is what makes "the session mutates nothing" true
|
|
2311
|
-
of the save as a whole rather than only of the path flag above. Without
|
|
2312
|
-
it `save_as_mainfile` MOVES the file: `bpy.data.filepath` becomes the
|
|
2313
|
-
document, so a script that saved to its own path has been retargeted,
|
|
2314
|
-
and every RELATIVE path it wrote now resolves from the document's
|
|
2315
|
-
directory instead of the one the script chose.
|
|
2316
|
-
|
|
2317
|
-
MEASURED 2026-09-18 on `18-riverside-bridge`, which is an ordinary
|
|
2318
|
-
thing for a script to do: seq 26 is
|
|
2319
|
-
`save_as_mainfile(filepath=os.path.join(ITEM, 'model.blend'))`, which
|
|
2320
|
-
left its nine textures as `//inputs/<name>.png` relative to ITEM, and
|
|
2321
|
-
seq 27-34 call `save_mainfile()` again. With the session's save moving
|
|
2322
|
-
`bpy.data.filepath` to `models/model.blend` in between, those paths
|
|
2323
|
-
resolved to `models/inputs/` — nothing — and each later save rebased
|
|
2324
|
-
the already-relative path again, compounding to
|
|
2325
|
-
`//../../probes/probes/ws-h-probe/models/inputs/bark_color.png`. Every
|
|
2326
|
-
one of the bridge's pictures was unreadable from there, so the export
|
|
2327
|
-
could not write them and the model presented untextured.
|
|
2328
|
-
|
|
2329
|
-
The two flags are one rule, and neither alone is it: `relative_remap`
|
|
2330
|
-
False is what leaves an ABSOLUTE path absolute, and `copy` is what
|
|
2331
|
-
leaves a RELATIVE one resolving from the base the script gave it.
|
|
2332
|
-
|
|
2333
|
-
What this costs, stated: a script's own `bpy.ops.wm.save_mainfile()`
|
|
2334
|
-
writes where the SCRIPT pointed it, not the session's document. That is
|
|
2335
|
-
the script's choice to make, which is the whole of the rule.
|
|
2336
|
-
|
|
2337
|
-
`compress` IS stated, and that is the whole reason it appears here.
|
|
2338
|
-
MEASURED 2026-09-18 in the tab: the operator's own RNA default is
|
|
2339
|
-
False, but `save_as_mainfile` takes its value from
|
|
2340
|
-
`preferences.filepaths.use_file_compression`, which is True in this
|
|
2341
|
-
build -- so the first documents this lane wrote came out Zstandard
|
|
2342
|
-
(`file` says "Zstandard compressed data", `head -c 12` is not
|
|
2343
|
-
`BLENDER`). Blender reads either back, so nothing was broken; what was
|
|
2344
|
-
wrong is that the BYTES of a file the project commits depended on a
|
|
2345
|
-
per-machine preference. A document states its own format.
|
|
2346
|
-
|
|
2347
|
-
THE FORMAT IT STATES IS COMPRESSED (Zstandard, Blender's own). Measured
|
|
2348
|
-
2026-10-08 on a character with a 43-clip library (1.3 million keys): the
|
|
2349
|
-
uncompressed write was 101.9 MB, compressed 12.8 MB, and the save itself
|
|
2350
|
-
took the same time (0.07-0.17 s against 0.10-0.14 s). A game scene with
|
|
2351
|
-
that character was 120 MB uncompressed, past GitHub's 100 MB file limit,
|
|
2352
|
-
and 17 MB compressed. Blender reads both.
|
|
2353
|
-
|
|
2354
|
-
THE SAVE'S CHUNKED TRANSPORT READS THE BYTES (`document-chunks.mts`): it
|
|
2355
|
-
sends the server only the chunks it lacks, and that reuse was measured
|
|
2356
|
-
on uncompressed files. Measured again with compression, same character,
|
|
2357
|
-
one small edit per save after the first: uncompressed sent 4.19 MB (one
|
|
2358
|
-
chunk) in about 1.4 s end to end; compressed sent 2.31 MB in about
|
|
2359
|
-
0.26 s. Zstandard compresses in independent blocks, so an edit still
|
|
2360
|
-
changes part of the file, not all of it.
|
|
2361
|
-
"""
|
|
2030
|
+
"""Save Blender’s native document without rewriting script-owned datablock paths. Use relative_remap=False and compression; the worker returns the saved bytes to the bound project file."""
|
|
2362
2031
|
self.save_due = False
|
|
2363
2032
|
if self.document is None:
|
|
2364
2033
|
return {"saved": False, "reason": "no-document"}
|
|
@@ -2366,19 +2035,9 @@ class Session:
|
|
|
2366
2035
|
# Not the document's model (`loaded`): writing it would replace the file with another.
|
|
2367
2036
|
return {"saved": False, "reason": "not-the-document", "document": self.document_relative,
|
|
2368
2037
|
"holds": bpy.data.filepath or "a new, untitled file"}
|
|
2369
|
-
#
|
|
2370
|
-
#
|
|
2371
|
-
#
|
|
2372
|
-
# checkpoints it can stay True across read-only operations. It is not
|
|
2373
|
-
# a reliable change detector for this request-driven background session.
|
|
2374
|
-
#
|
|
2375
|
-
# It was briefly used to skip a redundant write, and it froze the
|
|
2376
|
-
# document after its first save -- a reopened session could model all
|
|
2377
|
-
# day and never write again. The predicate is `mark_changed`'s instead:
|
|
2378
|
-
# a request that can change the model ran. Coarser than a diff (a
|
|
2379
|
-
# read-only script rewrites the file once), bounded by the tab's
|
|
2380
|
-
# idle-second debounce, and it cannot miss a change. The reading still
|
|
2381
|
-
# rides in the answer, as an instrument.
|
|
2038
|
+
# Save requests follow mark_changed, which records potentially mutating
|
|
2039
|
+
# operations. bpy.data.is_dirty is diagnostic: native undo checkpoints
|
|
2040
|
+
# do not reliably track this background session's mutations.
|
|
2382
2041
|
dirty = bool(bpy.data.is_dirty)
|
|
2383
2042
|
existed = os.path.exists(self.document)
|
|
2384
2043
|
directory = os.path.dirname(self.document)
|
|
@@ -2397,12 +2056,38 @@ class Session:
|
|
|
2397
2056
|
"document": self.document_relative,
|
|
2398
2057
|
"size": os.path.getsize(self.document),
|
|
2399
2058
|
"revision": self.revision,
|
|
2400
|
-
#
|
|
2401
|
-
#
|
|
2402
|
-
# first write.
|
|
2059
|
+
# Report the dirty state and whether the document already existed before this
|
|
2060
|
+
# write.
|
|
2403
2061
|
"dirty": dirty, "existed": existed}
|
|
2404
2062
|
|
|
2405
2063
|
|
|
2064
|
+
def _encoded_mime(data):
|
|
2065
|
+
"""The MIME type of a picture file the browser decodes and the tab takes as it is, else None."""
|
|
2066
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
2067
|
+
return "image/png"
|
|
2068
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
2069
|
+
return "image/jpeg"
|
|
2070
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
2071
|
+
return "image/webp"
|
|
2072
|
+
return None
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _browser_decoded_bytes(image):
|
|
2076
|
+
"""A file image's bytes when this Blender holds no pixels for it and the file is one the browser decodes
|
|
2077
|
+
(WebP: the editor's Blender is built without it), else None."""
|
|
2078
|
+
if image is None or image.source != "FILE" or image.is_dirty or image.has_data:
|
|
2079
|
+
return None
|
|
2080
|
+
try:
|
|
2081
|
+
if image.packed_file is not None:
|
|
2082
|
+
data = bytes(image.packed_file.data)
|
|
2083
|
+
else:
|
|
2084
|
+
with open(bpy.path.abspath(image.filepath), "rb") as fh:
|
|
2085
|
+
data = fh.read()
|
|
2086
|
+
except (OSError, TypeError, ValueError):
|
|
2087
|
+
return None
|
|
2088
|
+
return data if _encoded_mime(data) == "image/webp" else None
|
|
2089
|
+
|
|
2090
|
+
|
|
2406
2091
|
SESSION = Session()
|
|
2407
2092
|
|
|
2408
2093
|
|
|
@@ -2421,12 +2106,7 @@ def _drop(path):
|
|
|
2421
2106
|
_say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
|
|
2422
2107
|
|
|
2423
2108
|
|
|
2424
|
-
#
|
|
2425
|
-
# and the next call of a gesture are usually a few milliseconds away, and a
|
|
2426
|
-
# fixed 2 ms sleep put a whole sleep into each of them: a no-op execute spent
|
|
2427
|
-
# 6-7 ms of its 8-10 ms coming back, most of it the present's `ask` waiting out
|
|
2428
|
-
# sleeps on both skews (measured 2026-09-28). Brisk for 50 ms after activity,
|
|
2429
|
-
# then the old 2 ms, so an idle session lists its directory no more than before.
|
|
2109
|
+
# Poll briskly for 50 ms after activity, then sleep 2 ms between idle directory reads.
|
|
2430
2110
|
_BRISK_SECONDS = 0.05
|
|
2431
2111
|
|
|
2432
2112
|
|
|
@@ -2434,10 +2114,15 @@ def _pause(since):
|
|
|
2434
2114
|
time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
|
|
2435
2115
|
|
|
2436
2116
|
|
|
2117
|
+
def _another_document_shown(thrown):
|
|
2118
|
+
"""Whether a refusal is the tab's for a document it does not show (`blender-runtime-host.ts`'s
|
|
2119
|
+
`modelDocumentConflict`, "Blender is editing <held>, but the editor is showing <other>"): the editor moved on to
|
|
2120
|
+
another document while this one was opening, which is no fault of the document's."""
|
|
2121
|
+
return "but the editor is showing" in str(thrown)
|
|
2122
|
+
|
|
2123
|
+
|
|
2437
2124
|
def _asked(payload):
|
|
2438
|
-
"""
|
|
2439
|
-
`{"error": ...}`, and a pull that read past it presented a frame missing that piece, refused
|
|
2440
|
-
later under another name ("deferred and never pulled")."""
|
|
2125
|
+
"""Raise the tab's structured error before a refused piece can enter a frame."""
|
|
2441
2126
|
answer = ask(payload)
|
|
2442
2127
|
if isinstance(answer, dict) and answer.get("error"):
|
|
2443
2128
|
raise RuntimeError("The tab refused %s: %s" % (
|
|
@@ -2449,11 +2134,9 @@ def ask(payload):
|
|
|
2449
2134
|
"""Block until the tab answers. The worker's own thread is free while this
|
|
2450
2135
|
waits; only the Blender pthread is held, which is where the operator is.
|
|
2451
2136
|
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
own two files; their disappearance is what tells the page it may retire
|
|
2456
|
-
the reply it wrote. `.done` goes last, both times."""
|
|
2137
|
+
The guest writes ask/<n>.json and ask/<n>.done, then reads reply/<n>.json
|
|
2138
|
+
after the page writes reply/<n>.done. It removes only its own ask files
|
|
2139
|
+
to acknowledge the reply. Each writer creates and removes .done last."""
|
|
2457
2140
|
_ASK_SEQUENCE[0] += 1
|
|
2458
2141
|
name = str(_ASK_SEQUENCE[0])
|
|
2459
2142
|
with open(os.path.join(ASK, name + ".json"), "w") as fh:
|
|
@@ -2474,20 +2157,7 @@ def ask(payload):
|
|
|
2474
2157
|
# ---------------------------------------------------------------- the render engine
|
|
2475
2158
|
|
|
2476
2159
|
def _vertical_extent(cam, width, height):
|
|
2477
|
-
"""
|
|
2478
|
-
orthographic camera, its vertical extent in Blender units), framed the way
|
|
2479
|
-
Blender frames a render: `BKE_camera_params_compute_viewplane`.
|
|
2480
|
-
|
|
2481
|
-
Blender's `sensor_fit` says which sensor dimension spans which image
|
|
2482
|
-
dimension. HORIZONTAL: `sensor_width` spans the image width, and the
|
|
2483
|
-
vertical extent follows from the aspect. VERTICAL: `sensor_height` spans the
|
|
2484
|
-
height. AUTO: `sensor_width` spans the LARGER image dimension. The previous
|
|
2485
|
-
line sent `camera.angle_y`, which Blender derives from `sensor_height`
|
|
2486
|
-
alone, so under AUTO or HORIZONTAL fit a 36x24 sensor photographed 1.5x too
|
|
2487
|
-
wide a view -- measured on the ten-model battery (2026-09-18): every hero
|
|
2488
|
-
image differed across ~100% of pixels by exactly the 36/24 ratio, and
|
|
2489
|
-
`sensor_height = 36` made rendered/analytic 0.9991. Pixel aspect is not
|
|
2490
|
-
applied; the presenter renders square pixels."""
|
|
2160
|
+
"""Compute vertical field of view or orthographic extent using Blender’s sensor-fit rules. HORIZONTAL uses sensor_width across image width; VERTICAL uses sensor_height across height; AUTO fits the larger image dimension. The presenter uses square pixels."""
|
|
2491
2161
|
import math
|
|
2492
2162
|
landscape = width >= height
|
|
2493
2163
|
fit = cam.sensor_fit
|
|
@@ -2503,11 +2173,11 @@ def _vertical_extent(cam, width, height):
|
|
|
2503
2173
|
return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
|
|
2504
2174
|
|
|
2505
2175
|
|
|
2506
|
-
def _photograph(depsgraph, width, height, linear=False):
|
|
2176
|
+
def _photograph(depsgraph, width, height, linear=False, viewport=False):
|
|
2507
2177
|
"""three.js IS the renderer: the render is a photograph of the scene the
|
|
2508
2178
|
engine holds, taken through the scene's own camera at `scene.render`'s
|
|
2509
2179
|
exact resolution. `bpy.ops.render.render` semantics are the contract."""
|
|
2510
|
-
scene = depsgraph.scene
|
|
2180
|
+
scene = depsgraph.scene
|
|
2511
2181
|
camera = scene.camera
|
|
2512
2182
|
if camera is None:
|
|
2513
2183
|
raise RuntimeError("The scene has no camera, so there is nothing to render")
|
|
@@ -2524,12 +2194,14 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2524
2194
|
# matrix (`blender-runtime-host.ts`).
|
|
2525
2195
|
up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
|
|
2526
2196
|
view = scene.view_settings
|
|
2527
|
-
look =
|
|
2197
|
+
look = view.look or "None"
|
|
2528
2198
|
transform = {
|
|
2529
2199
|
"Standard": "none",
|
|
2530
2200
|
"AgX": "agx",
|
|
2531
2201
|
"Filmic": "filmic",
|
|
2532
2202
|
"Khronos PBR Neutral": "neutral",
|
|
2203
|
+
"ACES 1.3": "aces13",
|
|
2204
|
+
"ACES 2.0": "aces20",
|
|
2533
2205
|
}.get(view.view_transform)
|
|
2534
2206
|
if transform is None:
|
|
2535
2207
|
raise RuntimeError(
|
|
@@ -2558,32 +2230,27 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2558
2230
|
# result is what a render leaves, and it holds scene-linear pixels.
|
|
2559
2231
|
if linear:
|
|
2560
2232
|
render["linear"] = True
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
)
|
|
2233
|
+
pose = {"position": position, "target": target, "up": up, "render": render}
|
|
2234
|
+
# Capture the already-presented scene through its camera using the ordinary incremental present path.
|
|
2235
|
+
answer = SESSION.present(pose) if viewport else SESSION.photograph(depsgraph, pose)
|
|
2564
2236
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
2565
2237
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
2566
2238
|
_assert_photographed_from(answer.get("camera"), position, target, up)
|
|
2567
2239
|
return base64.b64decode(answer["base64"]), render, answer
|
|
2568
2240
|
|
|
2569
2241
|
|
|
2242
|
+
def photograph_frame(width, height):
|
|
2243
|
+
"""A FILM'S FRAME (`render-movie`): the scene at its current frame, as the viewport presents it,
|
|
2244
|
+
photographed through the scene camera (marker cuts included, as Blender switches the camera on a
|
|
2245
|
+
frame change) at `width` x `height`, with the scene's view transform. Answers the PNG's bytes."""
|
|
2246
|
+
png, _render, _answer = _photograph(bpy.context.evaluated_depsgraph_get(), width, height, viewport=True)
|
|
2247
|
+
return png
|
|
2248
|
+
|
|
2249
|
+
|
|
2570
2250
|
def _assert_photographed_from(reported, position, target, up):
|
|
2571
|
-
"""
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
This is an instrument, not a nicety. A render's image is allowed to differ
|
|
2575
|
-
from Cycles (three.js is the renderer), so a camera placed in the wrong
|
|
2576
|
-
frame produces a picture nobody can call wrong: the lane shipped for its
|
|
2577
|
-
whole life photographing the model's underside from below the floor because
|
|
2578
|
-
Blender's Z-up numbers were read as three.js world space, and every hero
|
|
2579
|
-
image the battery graded came from that camera.
|
|
2580
|
-
|
|
2581
|
-
The check is exact because the conversion is exact: the model root's world
|
|
2582
|
-
matrix is a signed axis permutation (`blender-runtime-view.ts` sets the
|
|
2583
|
-
matrix outright rather than a float quarter turn), so a vector through it
|
|
2584
|
-
and its inverse is bit-identical. A mismatch is the conversion being wrong,
|
|
2585
|
-
never the check being too strict -- do not widen it.
|
|
2586
|
-
"""
|
|
2251
|
+
"""Require the photograph’s camera pose, transformed back into Blender’s frame, to equal the sent
|
|
2252
|
+
pose exactly. The model root uses a signed axis permutation, so the transform and its inverse
|
|
2253
|
+
preserve the values without a floating-point tolerance."""
|
|
2587
2254
|
sent = {"position": position, "target": target, "up": up}
|
|
2588
2255
|
if not isinstance(reported, dict):
|
|
2589
2256
|
raise RuntimeError(
|
|
@@ -2650,20 +2317,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
2650
2317
|
|
|
2651
2318
|
|
|
2652
2319
|
def _fill_result(engine, result, answer, png, width, height):
|
|
2653
|
-
"""
|
|
2654
|
-
|
|
2655
|
-
Image Editor), as it does over Cycles'. The photograph's PNG is the display
|
|
2656
|
-
image -- the transform already ran -- so loading it put every render
|
|
2657
|
-
through AgX or Filmic twice. Measured 2026-09-28 by Cyclotron's
|
|
2658
|
-
render view: a background 53 against the viewport's 59, a shadow's blue 2
|
|
2659
|
-
against 7. The same capture's linear frame goes in instead: half floats,
|
|
2660
|
-
RGBA, bottom row first, which is Blender's own row order. Linear 0.005,
|
|
2661
|
-
0.05, 0.5 and 4.0 then save under AgX as 8, 58, 170 and 234 (the PNG
|
|
2662
|
-
path saved 3, 53, 160 and 190), and under Standard as 15, 64, 188 and 255,
|
|
2663
|
-
as the PNG path did.
|
|
2664
|
-
|
|
2665
|
-
A renderer that sends no linear frame gets the old path, and says so."""
|
|
2666
|
-
linear = answer.get("linearBase64") if isinstance(answer, dict) else None
|
|
2320
|
+
"""Load scene-linear capture pixels into RenderResult so Blender applies the scene view transform exactly once. A presenter without linear pixels uses the declared PNG path."""
|
|
2321
|
+
linear = answer.get("linearBase64")
|
|
2667
2322
|
if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
|
|
2668
2323
|
import numpy
|
|
2669
2324
|
|
|
@@ -2696,8 +2351,7 @@ def _engine_class(identifier):
|
|
|
2696
2351
|
|
|
2697
2352
|
A Python-registered engine is exposed on `bpy.types` under its CLASS name
|
|
2698
2353
|
(`bpy.types.CyclesRender`), never under its id, so `bpy.types.CYCLES` is
|
|
2699
|
-
always absent
|
|
2700
|
-
in place. The subclass tree is the one true registry."""
|
|
2354
|
+
always absent. Search the RenderEngine subclass tree by `bl_idname`."""
|
|
2701
2355
|
pending = list(bpy.types.RenderEngine.__subclasses__())
|
|
2702
2356
|
while pending:
|
|
2703
2357
|
cls = pending.pop()
|
|
@@ -2708,28 +2362,8 @@ def _engine_class(identifier):
|
|
|
2708
2362
|
|
|
2709
2363
|
|
|
2710
2364
|
def _release_from_owning_addon(existing):
|
|
2711
|
-
"""
|
|
2712
|
-
|
|
2713
|
-
`unregister_class(existing)` is only half the gesture. The add-on that
|
|
2714
|
-
registered the class still holds it in its module-level `classes`, and
|
|
2715
|
-
Blender tears every add-on down again on `read_factory_settings` and on
|
|
2716
|
-
each `open_mainfile` -- so the add-on's own `unregister()` calls
|
|
2717
|
-
`unregister_class` on a class that is already gone and raises
|
|
2718
|
-
`RuntimeError: missing bl_rna attribute`. `addon_utils.disable` catches
|
|
2719
|
-
that and prints a full traceback, and the remainder of that add-on's
|
|
2720
|
-
`unregister()` never runs.
|
|
2721
|
-
|
|
2722
|
-
MEASURED 2026-09-18 through the editor tab, running Blender's own
|
|
2723
|
-
`tests/python` suites against the bundle: 25 of 43 suites printed
|
|
2724
|
-
`Exception in module unregister(): '/bw/scripts/addons_core/cycles/
|
|
2725
|
-
__init__.py'`, and the native oracle at the same pin (5.2.0 LTS,
|
|
2726
|
-
fbe6228777e7) printed it in NONE of them -- the add-on is intact there
|
|
2727
|
-
because nothing took its engine id. Dropping the class from `classes`
|
|
2728
|
-
makes the add-on's next `register()`/`unregister()` pair agree with what
|
|
2729
|
-
this session actually did, which is what keeps `scene.cycles` (the reason
|
|
2730
|
-
the add-on stays enabled at all) reachable after a factory reset.
|
|
2731
|
-
"""
|
|
2732
|
-
module = sys.modules.get(getattr(existing, "__module__", ""))
|
|
2365
|
+
"""Remove a replaced engine class from its add-on’s registration list as well as Blender’s registry, so file loads and factory resets can unregister the remaining add-on classes."""
|
|
2366
|
+
module = sys.modules.get(existing.__module__)
|
|
2733
2367
|
classes = getattr(module, "classes", None)
|
|
2734
2368
|
if isinstance(classes, tuple):
|
|
2735
2369
|
if existing in classes:
|
|
@@ -2778,14 +2412,7 @@ def _register_engine():
|
|
|
2778
2412
|
bpy.utils.register_class(engine)
|
|
2779
2413
|
made.append(identifier)
|
|
2780
2414
|
except Exception: # noqa: BLE001
|
|
2781
|
-
#
|
|
2782
|
-
# id ("is built-in"); the shipped bundle carries a patch that sets
|
|
2783
|
-
# the built-in type aside instead, since headless has no GPU for it
|
|
2784
|
-
# to render with, so the two GPU ids are taken there (measured
|
|
2785
|
-
# 2026-09-17). A bundle without that patch keeps `BLENDER_EEVEE`
|
|
2786
|
-
# and `BLENDER_WORKBENCH`: that is a fact about the build, stated
|
|
2787
|
-
# in the start reply and refused by name at the render, never a
|
|
2788
|
-
# warning on every boot.
|
|
2415
|
+
# Report an engine id whose registration failed.
|
|
2789
2416
|
unavailable.append(identifier)
|
|
2790
2417
|
# `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
|
|
2791
2418
|
# again after every file load (the ids have to be retaken), and Blender
|
|
@@ -2889,12 +2516,8 @@ class NativeHistory:
|
|
|
2889
2516
|
finally:
|
|
2890
2517
|
self.moving = False
|
|
2891
2518
|
self.cursor += -1 if direction == "undo" else 1
|
|
2892
|
-
#
|
|
2893
|
-
|
|
2894
|
-
# re-ships what changed and the presenter keeps the rest (`session_undo`). A step that
|
|
2895
|
-
# replaced the depsgraph starts both revision caches over, as does an engine without
|
|
2896
|
-
# the door.
|
|
2897
|
-
if not hasattr(_blender_web, "session_undo") or _blender_web.session_undo():
|
|
2519
|
+
# An undo that retains the depsgraph sends only changed IDs; a replaced depsgraph resets revision caches.
|
|
2520
|
+
if _blender_web.session_undo():
|
|
2898
2521
|
_blender_web.session_reset()
|
|
2899
2522
|
SESSION.forget()
|
|
2900
2523
|
SESSION.present()
|
|
@@ -2966,12 +2589,8 @@ def get_object_info(name):
|
|
|
2966
2589
|
|
|
2967
2590
|
|
|
2968
2591
|
def execute(code):
|
|
2969
|
-
"""Run one script in
|
|
2970
|
-
|
|
2971
|
-
a later call only through `bpy.app.driver_namespace`, and a name a later
|
|
2972
|
-
call binds never reaches an earlier call's closure: with one dict shared
|
|
2973
|
-
across calls, the courtyard's render helper (defined at seq 29) found its
|
|
2974
|
-
`sc` rebound to a tuple by seq 35 and failed where the recording did not."""
|
|
2592
|
+
"""Run one script in its own `{"bpy": bpy}` namespace. Calls can share helpers
|
|
2593
|
+
through `bpy.app.driver_namespace`; local names and closures stay isolated."""
|
|
2975
2594
|
namespace = {"bpy": bpy}
|
|
2976
2595
|
captured = io.StringIO()
|
|
2977
2596
|
error = None
|
|
@@ -2984,14 +2603,7 @@ def execute(code):
|
|
|
2984
2603
|
return {"executed": error is None, "result": captured.getvalue(), "error": error}
|
|
2985
2604
|
|
|
2986
2605
|
|
|
2987
|
-
#
|
|
2988
|
-
# built without them keeps their operators registered and reports success while
|
|
2989
|
-
# doing nothing: measured on `06-cable-gripper` (2026-09-18),
|
|
2990
|
-
# `bpy.ops.rigidbody.world_add()` returned {'FINISHED'} with `scene.rigidbody_world`
|
|
2991
|
-
# still None, and the script died three lines later on the None. That is the
|
|
2992
|
-
# silent degrade the rulings forbid. The build option is Blender's own fact
|
|
2993
|
-
# (`bpy.app.build_options`); the keyword is the operator namespace a script
|
|
2994
|
-
# reaches the capability through. Same shape as the render-engine refusal above.
|
|
2606
|
+
# Refuse script requests for capabilities disabled by bpy.app.build_options, naming the operator namespace and unavailable build feature.
|
|
2995
2607
|
_ABSENT_CAPABILITIES = (
|
|
2996
2608
|
("bullet", "rigidbody", "rigid body physics (Bullet)"),
|
|
2997
2609
|
("fluid", "bpy.ops.fluid", "fluid simulation (Mantaflow)"),
|
|
@@ -3002,31 +2614,14 @@ _ABSENT_CAPABILITIES = (
|
|
|
3002
2614
|
def _absent_capability(code):
|
|
3003
2615
|
options = bpy.app.build_options
|
|
3004
2616
|
for option, keyword, capability in _ABSENT_CAPABILITIES:
|
|
3005
|
-
if keyword in code and not getattr(options, option
|
|
2617
|
+
if keyword in code and not getattr(options, option):
|
|
3006
2618
|
return ("CapabilityUnavailable: %s is not compiled into this Blender build, so "
|
|
3007
2619
|
"`%s` operators are refused rather than reported as done with nothing "
|
|
3008
2620
|
"created. (bpy.app.build_options.%s is False.)" % (capability, keyword, option))
|
|
3009
2621
|
return _absent_essentials_assets(code)
|
|
3010
2622
|
|
|
3011
2623
|
|
|
3012
|
-
#
|
|
3013
|
-
# from `bpy.app.build_options` like the three above -- it is a PAYLOAD fact, and
|
|
3014
|
-
# the only honest predicate is the directory itself.
|
|
3015
|
-
#
|
|
3016
|
-
# MEASURED 2026-09-18 in the tab: `bpy.utils.system_resource('DATAFILES')` is
|
|
3017
|
-
# `/bw/datafiles` and holds exactly `['colormanagement', 'fonts']`, because the
|
|
3018
|
-
# preload payload ships those two and nothing else from `release/datafiles`
|
|
3019
|
-
# (recipe/blender_web_headless.cmake). So
|
|
3020
|
-
# `system_resource('DATAFILES', path='assets')` answers `''`, and Blender
|
|
3021
|
-
# reports the empty string back to the caller verbatim:
|
|
3022
|
-
#
|
|
3023
|
-
# bpy.ops.object.shade_auto_smooth(use_auto_smooth=True)
|
|
3024
|
-
# RuntimeError: Error: No asset found at path ""
|
|
3025
|
-
#
|
|
3026
|
-
# Running Blender's own suites against the bundle, that one absence is every
|
|
3027
|
-
# failing assertion in `object_edit` (1) and `sculpt_paint/brush_asset_test`
|
|
3028
|
-
# (4) -- both clean on the native oracle at the same pin. A message naming the
|
|
3029
|
-
# empty path is not something an author can act on; naming the library is.
|
|
2624
|
+
# Check the Essentials asset-library directory itself; availability is a payload property rather than a bpy.app.build_options flag.
|
|
3030
2625
|
_ESSENTIALS_KEYWORDS = ("shade_auto_smooth", "asset_activate", "ESSENTIALS")
|
|
3031
2626
|
|
|
3032
2627
|
|
|
@@ -3049,9 +2644,7 @@ def _absent_essentials_assets(code):
|
|
|
3049
2644
|
|
|
3050
2645
|
# ------------------------------------------------------------------ the RNA door
|
|
3051
2646
|
#
|
|
3052
|
-
#
|
|
3053
|
-
# §Blender north star, "Inspection parity, not editing parity"; WORK.md §Blender
|
|
3054
|
-
# in the tab is Blender, "Inspection parity", I1). Blender's UI layer is never
|
|
2647
|
+
# DATABLOCK PROPERTIES READ THROUGH RNA. Blender's UI layer is never
|
|
3055
2648
|
# run, ported or recorded here: what this answers is `bl_rna.properties` --
|
|
3056
2649
|
# Blender's own introspection of its own data -- as typed rows, and what our
|
|
3057
2650
|
# panels draw is those rows.
|
|
@@ -3076,20 +2669,7 @@ _RNA_STRING_LIMIT = 4096
|
|
|
3076
2669
|
# struct carries it and it points at the struct's own type.
|
|
3077
2670
|
_RNA_SKIP = ("rna_type",)
|
|
3078
2671
|
|
|
3079
|
-
#
|
|
3080
|
-
# is RNA's own list, and `scripts/modules/bpy_types.py` puts ordinary Python
|
|
3081
|
-
# `property` descriptors on top of the generated classes -- `Object.children`,
|
|
3082
|
-
# `Object.users_collection`, `Object.users_scene`, `Bone.children`. They are
|
|
3083
|
-
# part of the API Blender's OWN panels read: `properties_object.py:201` draws
|
|
3084
|
-
# the Collections panel straight out of `obj.users_collection`, which is why
|
|
3085
|
-
# that panel came up EMPTY here until the door answered them (measured
|
|
3086
|
-
# 2026-09-19; `users_collection` appears nowhere under `source/blender/makesrna`
|
|
3087
|
-
# at the pin, so nothing in the RNA iteration could ever have found it).
|
|
3088
|
-
#
|
|
3089
|
-
# SKIPPED BY A RULE, NOT A LIST: a name ending `_recursive` is Blender's own
|
|
3090
|
-
# spelling for a walker that re-derives a whole subtree on every read
|
|
3091
|
-
# (`children_recursive`, `parent_recursive`), and what it holds is one drill
|
|
3092
|
-
# away through the plain property beside it.
|
|
2672
|
+
# Include Python property descriptors from bpy_types alongside RNA properties, excluding recursive variants when the immediate property already offers traversal.
|
|
3093
2673
|
_PY_PROPERTY_SUFFIX_SKIP = ("_recursive",)
|
|
3094
2674
|
|
|
3095
2675
|
_ID_COLLECTIONS = None
|
|
@@ -3105,7 +2685,7 @@ def _id_collections():
|
|
|
3105
2685
|
for prop in bpy.data.bl_rna.properties:
|
|
3106
2686
|
if prop.type != "COLLECTION":
|
|
3107
2687
|
continue
|
|
3108
|
-
fixed =
|
|
2688
|
+
fixed = prop.fixed_type
|
|
3109
2689
|
if fixed is not None:
|
|
3110
2690
|
table.setdefault(fixed.identifier, prop.identifier)
|
|
3111
2691
|
return table
|
|
@@ -3122,7 +2702,7 @@ def _id_collection_for(datablock):
|
|
|
3122
2702
|
name = _ID_COLLECTIONS.get(rna.identifier)
|
|
3123
2703
|
if name:
|
|
3124
2704
|
return name
|
|
3125
|
-
rna =
|
|
2705
|
+
rna = rna.base
|
|
3126
2706
|
return None
|
|
3127
2707
|
|
|
3128
2708
|
|
|
@@ -3138,7 +2718,7 @@ def _rna_address(struct):
|
|
|
3138
2718
|
if collection is None:
|
|
3139
2719
|
return None
|
|
3140
2720
|
return 'bpy.data.%s["%s"]' % (collection, bpy.utils.escape_identifier(struct.name))
|
|
3141
|
-
owner =
|
|
2721
|
+
owner = struct.id_data
|
|
3142
2722
|
if owner is None:
|
|
3143
2723
|
return None
|
|
3144
2724
|
base = _rna_address(owner)
|
|
@@ -3164,23 +2744,17 @@ def _rna_resolve(path):
|
|
|
3164
2744
|
|
|
3165
2745
|
|
|
3166
2746
|
def _rna_group(struct_rna, identifier):
|
|
3167
|
-
"""
|
|
3168
|
-
exposes, and a real one: `Object`'s own properties read apart from the `ID`
|
|
3169
|
-
fields every datablock carries. It is NOT Blender's panel layout. Which
|
|
3170
|
-
properties a Properties panel draws, and in what order, lives in
|
|
3171
|
-
`scripts/startup/bl_ui/properties_*.py`, which the ruling forbids running,
|
|
3172
|
-
porting or recording; our own curated panels are I2 and name their RNA
|
|
3173
|
-
themselves. Cached per struct -- the base chain is walked once."""
|
|
2747
|
+
"""Find the RNA struct declaring a property and cache the result per struct. This groups RNA inheritance independently of Blender’s panel layout."""
|
|
3174
2748
|
cached = _RNA_GROUPS.get(struct_rna.identifier)
|
|
3175
2749
|
if cached is None:
|
|
3176
2750
|
cached = {}
|
|
3177
2751
|
for prop in struct_rna.properties:
|
|
3178
2752
|
declaring = struct_rna
|
|
3179
|
-
base =
|
|
2753
|
+
base = struct_rna.base
|
|
3180
2754
|
while base is not None:
|
|
3181
2755
|
if prop.identifier in base.properties:
|
|
3182
2756
|
declaring = base
|
|
3183
|
-
base =
|
|
2757
|
+
base = base.base
|
|
3184
2758
|
cached[prop.identifier] = (declaring.identifier, declaring.name)
|
|
3185
2759
|
_RNA_GROUPS[struct_rna.identifier] = cached
|
|
3186
2760
|
return cached.get(identifier, (struct_rna.identifier, struct_rna.name))
|
|
@@ -3220,7 +2794,7 @@ def _rna_value(target, prop):
|
|
|
3220
2794
|
if kind == "COLLECTION":
|
|
3221
2795
|
value = getattr(target, prop.identifier)
|
|
3222
2796
|
row = {"count": len(value)}
|
|
3223
|
-
fixed =
|
|
2797
|
+
fixed = prop.fixed_type
|
|
3224
2798
|
# A mesh's `vertices` has no `name`, and asking 100k of them for one is
|
|
3225
2799
|
# exactly the serialisation this door refuses -- so the question is
|
|
3226
2800
|
# asked of the TYPE, once, off its own RNA.
|
|
@@ -3236,14 +2810,7 @@ def _rna_value(target, prop):
|
|
|
3236
2810
|
if kind == "ENUM":
|
|
3237
2811
|
return sorted(value) if prop.is_enum_flag else value
|
|
3238
2812
|
if getattr(prop, "array_length", 0):
|
|
3239
|
-
#
|
|
3240
|
-
# multi-dimensional wherever `PropertyRNA.array_dimension > 1` --
|
|
3241
|
-
# `Bone.matrix` is 3x3, `matrix_local` and `Object.matrix_world` 4x4 --
|
|
3242
|
-
# and iterating one yields `Vector`s, which `float()` refuses by name
|
|
3243
|
-
# ("could not convert string to float: Vector((1.0, 0.0, 0.0))").
|
|
3244
|
-
# Measured live 2026-09-19 on the Bone tab's Transform panel, which is
|
|
3245
|
-
# where a matrix first got named. Flattened in ROW-MAJOR order, which
|
|
3246
|
-
# is how `array_length` already counts it (9 and 16, not 3 and 4).
|
|
2813
|
+
# Flatten multidimensional bpy_prop_array values in row-major order; RNA array_length counts scalar values rather than rows.
|
|
3247
2814
|
return [_rna_scalar(item, kind) for item in _rna_flatten(value)]
|
|
3248
2815
|
if kind == "BOOLEAN":
|
|
3249
2816
|
return bool(value)
|
|
@@ -3290,19 +2857,7 @@ def _rna_row(target, prop):
|
|
|
3290
2857
|
# by the presentation, so the door stays the whole surface.
|
|
3291
2858
|
"hidden": bool(prop.is_hidden),
|
|
3292
2859
|
}
|
|
3293
|
-
#
|
|
3294
|
-
# (WORK.md §Blender in the tab is Blender, "Inspection parity", I2, "The two
|
|
3295
|
-
# standing rules"). `rna_define.cc:1311-1312` gives every property
|
|
3296
|
-
# `prop->name = identifier` and `prop->description = ""` at definition, and
|
|
3297
|
-
# `RNA_def_property_ui_text` is what replaces them -- so a property whose
|
|
3298
|
-
# NAME is still its identifier and whose description is still empty is one
|
|
3299
|
-
# Blender never gave UI text to. That is what the modifier panel-open
|
|
3300
|
-
# booleans are (`rna_def_modifier_panel_open_prop`, `:2694-2705`, which sets
|
|
3301
|
-
# `PROP_NO_DEG_UPDATE` and an sdna bit and no flag at all, so the
|
|
3302
|
-
# PROP_HIDDEN rule above does not catch them): state a panel's open/closed
|
|
3303
|
-
# arrow writes, not data about the datablock. Reported here and FILTERED by
|
|
3304
|
-
# the generic view, exactly as `hidden` is; a curated list names none of
|
|
3305
|
-
# them because `bl_ui` draws none of them.
|
|
2860
|
+
# Mark properties without UI text as undrawn when their name equals the identifier and their description is empty.
|
|
3306
2861
|
if prop.name == prop.identifier and not prop.description:
|
|
3307
2862
|
row["undrawn"] = True
|
|
3308
2863
|
if prop.type in ("INT", "FLOAT"):
|
|
@@ -3314,20 +2869,20 @@ def _rna_row(target, prop):
|
|
|
3314
2869
|
if prop.type == "FLOAT":
|
|
3315
2870
|
row["precision"] = int(prop.precision)
|
|
3316
2871
|
if prop.type == "STRING":
|
|
3317
|
-
row["lengthMax"] = int(
|
|
2872
|
+
row["lengthMax"] = int(prop.length_max)
|
|
3318
2873
|
if prop.type == "ENUM":
|
|
3319
2874
|
row["isFlag"] = bool(prop.is_enum_flag)
|
|
3320
2875
|
try:
|
|
3321
2876
|
items = prop.enum_items
|
|
3322
2877
|
except Exception: # noqa: BLE001
|
|
3323
|
-
items =
|
|
2878
|
+
items = prop.enum_items_static
|
|
3324
2879
|
row["items"] = [
|
|
3325
2880
|
{"identifier": item.identifier, "name": item.name,
|
|
3326
2881
|
"description": item.description, "icon": item.icon}
|
|
3327
2882
|
for item in items
|
|
3328
2883
|
]
|
|
3329
2884
|
if prop.type in ("POINTER", "COLLECTION"):
|
|
3330
|
-
fixed =
|
|
2885
|
+
fixed = prop.fixed_type
|
|
3331
2886
|
if fixed is not None:
|
|
3332
2887
|
row["itemType"] = fixed.identifier
|
|
3333
2888
|
if length > _RNA_ARRAY_LIMIT:
|
|
@@ -3352,8 +2907,7 @@ def _py_properties(cls):
|
|
|
3352
2907
|
return cached
|
|
3353
2908
|
found = []
|
|
3354
2909
|
seen = set()
|
|
3355
|
-
|
|
3356
|
-
declared = set(rna.properties.keys()) if rna is not None else set()
|
|
2910
|
+
declared = set(cls.bl_rna.properties.keys())
|
|
3357
2911
|
for base in cls.__mro__:
|
|
3358
2912
|
for name, member in vars(base).items():
|
|
3359
2913
|
if name.startswith("_") or name in seen or name in declared:
|
|
@@ -3450,16 +3004,7 @@ def _py_row(target, name, descriptor, owner):
|
|
|
3450
3004
|
|
|
3451
3005
|
|
|
3452
3006
|
def _rna_member(collection_path, item, index):
|
|
3453
|
-
"""
|
|
3454
|
-
|
|
3455
|
-
`path_from_id()` is the first answer and the right one -- but it is not
|
|
3456
|
-
always an answer: a `VertexGroup` has no RNA path back to its object (the
|
|
3457
|
-
three groups of a skinned mesh came out with `path: null` on the first
|
|
3458
|
-
live walk, 2026-09-19), while `bpy.data.path_resolve` resolves
|
|
3459
|
-
`objects["X"].vertex_groups["chest"]` perfectly well. So where the struct
|
|
3460
|
-
cannot say where it lives, the COLLECTION says it: this path plus the
|
|
3461
|
-
member's own key. Index for a member with no name -- a mesh vertex -- which
|
|
3462
|
-
is also how `path_from_id` would spell it."""
|
|
3007
|
+
"""Address collection members with path_from_id when available, otherwise append their name or index to the collection’s own path."""
|
|
3463
3008
|
address = _rna_address(item)
|
|
3464
3009
|
name = getattr(item, "name", None)
|
|
3465
3010
|
if address is None:
|
|
@@ -3576,11 +3121,11 @@ def _rna_active_object(name):
|
|
|
3576
3121
|
return bpy.context.view_layer.objects.active
|
|
3577
3122
|
|
|
3578
3123
|
|
|
3579
|
-
def _named(value
|
|
3124
|
+
def _named(value):
|
|
3580
3125
|
if value is None:
|
|
3581
3126
|
return None
|
|
3582
|
-
return {"name":
|
|
3583
|
-
"path":
|
|
3127
|
+
return {"name": value.name,
|
|
3128
|
+
"path": _rna_address(value),
|
|
3584
3129
|
"type": value.bl_rna.identifier}
|
|
3585
3130
|
|
|
3586
3131
|
|
|
@@ -3590,14 +3135,7 @@ def _tab(identifier, label, icon, paths):
|
|
|
3590
3135
|
|
|
3591
3136
|
|
|
3592
3137
|
def _layer_collection_path(view_layer_path, root, target):
|
|
3593
|
-
"""
|
|
3594
|
-
the same shape as I1's `VertexGroup` finding, and it made
|
|
3595
|
-
`COLLECTION_PT_viewlayer_flags`'s datablock unaddressable so the View Layer
|
|
3596
|
-
sub-panel drew nothing. Its address is its POSITION in the view layer's
|
|
3597
|
-
tree, which the tree itself can spell."""
|
|
3598
|
-
if target is None:
|
|
3599
|
-
return None
|
|
3600
|
-
|
|
3138
|
+
"""Address a LayerCollection by its position in the view-layer tree because it has no path_from_id."""
|
|
3601
3139
|
def walk(node, path):
|
|
3602
3140
|
if node == target:
|
|
3603
3141
|
return path
|
|
@@ -3613,12 +3151,14 @@ def _layer_collection_path(view_layer_path, root, target):
|
|
|
3613
3151
|
|
|
3614
3152
|
def _texture_slot_users(users, owner, slots, label, name=None):
|
|
3615
3153
|
"""Blender's `buttons_texture_user_mtex_add`: one user per FILLED slot."""
|
|
3616
|
-
for
|
|
3617
|
-
|
|
3154
|
+
for slot in slots:
|
|
3155
|
+
if slot is None:
|
|
3156
|
+
continue
|
|
3157
|
+
texture = slot.texture
|
|
3618
3158
|
if texture is None:
|
|
3619
3159
|
continue
|
|
3620
3160
|
users.append({"label": label,
|
|
3621
|
-
"name": name if name else
|
|
3161
|
+
"name": name if name else owner.name,
|
|
3622
3162
|
"path": _rna_address(slot),
|
|
3623
3163
|
"property": "texture",
|
|
3624
3164
|
"texture": _named(texture)})
|
|
@@ -3629,11 +3169,11 @@ def _texture_node_users(users, tree, label):
|
|
|
3629
3169
|
`texture` pointer is filled."""
|
|
3630
3170
|
if tree is None:
|
|
3631
3171
|
return
|
|
3632
|
-
for node in
|
|
3172
|
+
for node in tree.nodes:
|
|
3633
3173
|
prop = node.bl_rna.properties.get("texture")
|
|
3634
3174
|
if prop is None or prop.type != "POINTER":
|
|
3635
3175
|
continue
|
|
3636
|
-
texture =
|
|
3176
|
+
texture = node.texture
|
|
3637
3177
|
if texture is None:
|
|
3638
3178
|
continue
|
|
3639
3179
|
users.append({"label": label, "name": node.name,
|
|
@@ -3642,58 +3182,41 @@ def _texture_node_users(users, tree, label):
|
|
|
3642
3182
|
|
|
3643
3183
|
|
|
3644
3184
|
def _texture_users(scene, view_layer, obj):
|
|
3645
|
-
"""
|
|
3646
|
-
`space_buttons/buttons_texture.cc::buttons_texture_users_from_context`
|
|
3647
|
-
(`:244-366`) at the engine's pin -- in ITS order, because
|
|
3648
|
-
`buttons_texture_context_compute` (`:369`) takes `ct->index` (0 unless a
|
|
3649
|
-
person picks another) and the Texture tab then shows THAT user's texture.
|
|
3650
|
-
|
|
3651
|
-
The C walks, in order: the scene's compositing node tree ("Compositor"),
|
|
3652
|
-
the active line style's slots and node tree ("Line Style"), the object's
|
|
3653
|
-
modifiers via `BKE_modifiers_foreach_tex_link` ("Modifiers"), the ACTIVE
|
|
3654
|
-
particle system's `part.mtex[]` ("Particles"), the object's force field
|
|
3655
|
-
when `forcefield == PFIELD_TEXTURE` ("Fields"), and the active paint
|
|
3656
|
-
brush's own two slots ("Brush").
|
|
3657
|
-
|
|
3658
|
-
NOTE, because it is easy to assume otherwise from older Blenders: a LIGHT
|
|
3659
|
-
is not a texture user at this pin. `buttons_texture_users_from_context`
|
|
3660
|
-
does not look at `ob->data` at all -- lamp textures went with 2.8's
|
|
3661
|
-
renderer rewrite. Measured, not remembered."""
|
|
3185
|
+
"""Report texture users in Blender buttons_texture_users_from_context order, with the active user index selecting the displayed texture."""
|
|
3662
3186
|
users = []
|
|
3663
|
-
_texture_node_users(users,
|
|
3187
|
+
_texture_node_users(users, scene.compositing_node_group, "Compositor")
|
|
3664
3188
|
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
linestyle = getattr(getattr(linesets, "active", None), "linestyle", None)
|
|
3189
|
+
lineset = view_layer.freestyle_settings.linesets.active
|
|
3190
|
+
linestyle = lineset.linestyle if lineset is not None else None
|
|
3668
3191
|
if linestyle is not None:
|
|
3669
|
-
_texture_slot_users(users, linestyle,
|
|
3192
|
+
_texture_slot_users(users, linestyle, linestyle.texture_slots,
|
|
3670
3193
|
"Line Style")
|
|
3671
|
-
_texture_node_users(users,
|
|
3194
|
+
_texture_node_users(users, linestyle.node_tree, "Line Style")
|
|
3672
3195
|
|
|
3673
3196
|
if obj is not None:
|
|
3674
3197
|
for modifier in obj.modifiers:
|
|
3675
3198
|
for prop in modifier.bl_rna.properties:
|
|
3676
3199
|
if prop.type != "POINTER":
|
|
3677
3200
|
continue
|
|
3678
|
-
fixed =
|
|
3201
|
+
fixed = prop.fixed_type
|
|
3679
3202
|
if fixed is None or fixed.identifier != "Texture":
|
|
3680
3203
|
continue
|
|
3681
|
-
texture = getattr(modifier, prop.identifier
|
|
3204
|
+
texture = getattr(modifier, prop.identifier)
|
|
3682
3205
|
if texture is None:
|
|
3683
3206
|
continue
|
|
3684
3207
|
users.append({"label": "Modifiers", "name": modifier.name,
|
|
3685
3208
|
"path": _rna_address(modifier),
|
|
3686
3209
|
"property": prop.identifier,
|
|
3687
3210
|
"texture": _named(texture)})
|
|
3688
|
-
systems =
|
|
3689
|
-
active_system =
|
|
3690
|
-
settings =
|
|
3211
|
+
systems = obj.particle_systems
|
|
3212
|
+
active_system = systems.active
|
|
3213
|
+
settings = active_system.settings if active_system is not None else None
|
|
3691
3214
|
if settings is not None:
|
|
3692
|
-
_texture_slot_users(users, settings,
|
|
3215
|
+
_texture_slot_users(users, settings, settings.texture_slots,
|
|
3693
3216
|
"Particles", name=active_system.name)
|
|
3694
|
-
field =
|
|
3695
|
-
if field is not None and
|
|
3696
|
-
and
|
|
3217
|
+
field = obj.field
|
|
3218
|
+
if field is not None and field.type == "TEXTURE" \
|
|
3219
|
+
and field.texture is not None:
|
|
3697
3220
|
users.append({"label": "Fields", "name": "Texture Field",
|
|
3698
3221
|
"path": _rna_address(field), "property": "texture",
|
|
3699
3222
|
"texture": _named(field.texture)})
|
|
@@ -3701,17 +3224,17 @@ def _texture_users(scene, view_layer, obj):
|
|
|
3701
3224
|
# `BKE_paint_brush(BKE_paint_get_active_from_context(C))`. Headless has no
|
|
3702
3225
|
# paint mode, so this is whichever tool settings hold a brush at all --
|
|
3703
3226
|
# asked of every `Paint` the tool settings carry rather than of a mode.
|
|
3704
|
-
tools =
|
|
3227
|
+
tools = bpy.context.tool_settings
|
|
3705
3228
|
for member in ("image_paint", "sculpt", "vertex_paint", "weight_paint",
|
|
3706
3229
|
"gpencil_paint", "curves_sculpt"):
|
|
3707
|
-
paint = getattr(tools, member
|
|
3708
|
-
brush =
|
|
3230
|
+
paint = getattr(tools, member) if tools is not None else None
|
|
3231
|
+
brush = paint.brush if paint is not None else None
|
|
3709
3232
|
if brush is None:
|
|
3710
3233
|
continue
|
|
3711
3234
|
for slot, prop in (("texture_slot", "texture"), ("mask_texture_slot", "mask_texture")):
|
|
3712
|
-
holder = getattr(brush, slot
|
|
3713
|
-
texture = getattr(brush, prop
|
|
3714
|
-
if
|
|
3235
|
+
holder = getattr(brush, slot)
|
|
3236
|
+
texture = getattr(brush, prop)
|
|
3237
|
+
if texture is None:
|
|
3715
3238
|
continue
|
|
3716
3239
|
users.append({"label": "Brush", "name": brush.name,
|
|
3717
3240
|
"path": _rna_address(holder), "property": "texture",
|
|
@@ -3740,7 +3263,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3740
3263
|
# then shows THAT collection. Activating it here would be a mutation the
|
|
3741
3264
|
# document saves, so the row's own address arrives as a parameter and stands
|
|
3742
3265
|
# in for `view_layer.active_layer_collection` for this read alone.
|
|
3743
|
-
active_layer_collection =
|
|
3266
|
+
active_layer_collection = view_layer.active_layer_collection
|
|
3744
3267
|
if collection_path:
|
|
3745
3268
|
named = _rna_resolve(collection_path)
|
|
3746
3269
|
if not isinstance(named, bpy.types.LayerCollection):
|
|
@@ -3749,7 +3272,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3749
3272
|
"LayerCollection (`buttons_context_path_collection`)."
|
|
3750
3273
|
% (collection_path, named.bl_rna.identifier))
|
|
3751
3274
|
active_layer_collection = named
|
|
3752
|
-
collection =
|
|
3275
|
+
collection = active_layer_collection.collection if active_layer_collection is not None else None
|
|
3753
3276
|
if collection is not None and collection == scene.collection:
|
|
3754
3277
|
collection = None
|
|
3755
3278
|
|
|
@@ -3774,7 +3297,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3774
3297
|
# object whose data supports materials even when the slot is empty --
|
|
3775
3298
|
# `OB_TYPE_SUPPORT_MATERIAL` is asked here as "does this data hold a
|
|
3776
3299
|
# `materials` collection", which is the same set, read off RNA.
|
|
3777
|
-
slot_index = max(int(
|
|
3300
|
+
slot_index = max(int(obj.active_material_index), 0)
|
|
3778
3301
|
slots = obj.material_slots
|
|
3779
3302
|
slot = slots[slot_index] if slot_index < len(slots) else None
|
|
3780
3303
|
held = slot.material if slot is not None else None
|
|
@@ -3790,16 +3313,15 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3790
3313
|
particle_system = None
|
|
3791
3314
|
if obj is not None:
|
|
3792
3315
|
if obj.type in _MODIFIER_OBJECT_TYPES:
|
|
3793
|
-
modifier = _named(
|
|
3794
|
-
groups =
|
|
3795
|
-
if groups
|
|
3316
|
+
modifier = _named(obj.modifiers.active)
|
|
3317
|
+
groups = obj.vertex_groups
|
|
3318
|
+
if groups.active is not None:
|
|
3796
3319
|
vertex_group = {"index": int(groups.active_index)}
|
|
3797
3320
|
vertex_group.update(_named(groups.active))
|
|
3798
|
-
shape_key = _named(
|
|
3799
|
-
constraint = _named(
|
|
3800
|
-
systems =
|
|
3801
|
-
|
|
3802
|
-
particle_system = _named(getattr(systems, "active", None))
|
|
3321
|
+
shape_key = _named(obj.active_shape_key)
|
|
3322
|
+
constraint = _named(obj.constraints.active)
|
|
3323
|
+
systems = obj.particle_systems
|
|
3324
|
+
particle_system = _named(systems.active)
|
|
3803
3325
|
|
|
3804
3326
|
# ED_buttons_tabs_list (`space_buttons.cc:218-252`), in its own order. The
|
|
3805
3327
|
# TOOL tab is Blender's active-tool settings and has no reader here; SHADERFX
|
|
@@ -3873,18 +3395,14 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3873
3395
|
# it is what nearly every panel on this tab draws -- `part` in
|
|
3874
3396
|
# every one of their draw functions. The system itself carries
|
|
3875
3397
|
# only a handful (`seed`, `parent`, the vertex-group names).
|
|
3876
|
-
systems_active =
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
paths.append(("Hair Dynamics",
|
|
3885
|
-
_rna_address(getattr(cloth, "settings", None))))
|
|
3886
|
-
paths.append(("Hair Collisions",
|
|
3887
|
-
_rna_address(getattr(cloth, "collision_settings", None))))
|
|
3398
|
+
systems_active = systems.active
|
|
3399
|
+
paths.append(("Settings", _rna_address(systems_active.settings)))
|
|
3400
|
+
cloth = systems_active.cloth
|
|
3401
|
+
if cloth is not None:
|
|
3402
|
+
# PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
|
|
3403
|
+
# and `.collision_settings` (`:390-404`, `:463-470`).
|
|
3404
|
+
paths.append(("Hair Dynamics", _rna_address(cloth.settings)))
|
|
3405
|
+
paths.append(("Hair Collisions", _rna_address(cloth.collision_settings)))
|
|
3888
3406
|
tabs.append(_tab("particle", "Particles", "properties-particles", paths))
|
|
3889
3407
|
# `buttons_context_path_object` answers Physics and Constraints both.
|
|
3890
3408
|
#
|
|
@@ -3903,34 +3421,28 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3903
3421
|
("Soft Body", "soft_body"),
|
|
3904
3422
|
("Collision", "collision"),
|
|
3905
3423
|
("Force Field", "field"))
|
|
3906
|
-
if getattr(obj, field
|
|
3907
|
-
for
|
|
3908
|
-
#
|
|
3909
|
-
# `point_cache_ui(self, md.point_cache, …)`
|
|
3910
|
-
# is the cache panel's datablock
|
|
3911
|
-
# (`properties_physics_cloth.py:269`), and
|
|
3912
|
-
# that lives on the modifier, not on
|
|
3913
|
-
# `md.settings`. Measured live 2026-09-19:
|
|
3914
|
-
# the Cache panel drew nothing without it.
|
|
3424
|
+
if getattr(obj, field) is not None]
|
|
3425
|
+
for kind, members in (("CLOTH",
|
|
3426
|
+
# Include the modifier itself beside its settings because the point-cache panel addresses md.point_cache.
|
|
3915
3427
|
(("Cloth Modifier", None),
|
|
3916
3428
|
("Cloth", "settings"),
|
|
3917
3429
|
("Cloth Collisions", "collision_settings"))),
|
|
3918
|
-
("
|
|
3430
|
+
("SOFT_BODY",
|
|
3919
3431
|
(("Soft Body Modifier", None),)),
|
|
3920
|
-
("
|
|
3432
|
+
("FLUID",
|
|
3921
3433
|
(("Fluid", None),
|
|
3922
3434
|
("Fluid Domain", "domain_settings"),
|
|
3923
3435
|
("Fluid Flow", "flow_settings"),
|
|
3924
3436
|
("Fluid Effector", "effector_settings"))),
|
|
3925
|
-
("
|
|
3437
|
+
("DYNAMIC_PAINT",
|
|
3926
3438
|
(("Dynamic Paint", None),
|
|
3927
3439
|
("Dynamic Paint Canvas", "canvas_settings"),
|
|
3928
3440
|
("Dynamic Paint Brush", "brush_settings")))):
|
|
3929
|
-
|
|
3930
|
-
if
|
|
3441
|
+
physics_modifier = next((item for item in obj.modifiers if item.type == kind), None)
|
|
3442
|
+
if physics_modifier is None:
|
|
3931
3443
|
continue
|
|
3932
3444
|
for name, member in members:
|
|
3933
|
-
target =
|
|
3445
|
+
target = physics_modifier if member is None else getattr(physics_modifier, member)
|
|
3934
3446
|
physics.append((name, _rna_address(target)))
|
|
3935
3447
|
tabs.append(_tab("physics", "Physics", "properties-physics",
|
|
3936
3448
|
physics or [("Object", object_path)]))
|
|
@@ -3945,7 +3457,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3945
3457
|
paths = [("Object Data", data_path)]
|
|
3946
3458
|
if obj.type == "ARMATURE":
|
|
3947
3459
|
paths.append(("Bones", "%s.bones" % data_path))
|
|
3948
|
-
if
|
|
3460
|
+
if len(obj.vertex_groups):
|
|
3949
3461
|
paths.append(("Vertex Groups", "%s.vertex_groups" % object_path))
|
|
3950
3462
|
keys = getattr(obj.data, "shape_keys", None)
|
|
3951
3463
|
if keys is not None:
|
|
@@ -3962,11 +3474,11 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3962
3474
|
[("Constraints", "%s.constraints" % pose_bone["path"])]))
|
|
3963
3475
|
if material is not None:
|
|
3964
3476
|
paths = [("Material Slots", "%s.material_slots" % object_path)]
|
|
3965
|
-
if material
|
|
3477
|
+
if material["path"]:
|
|
3966
3478
|
paths.insert(0, ("Material", material["path"]))
|
|
3967
3479
|
tabs.append(_tab("material", "Material", "properties-material", paths))
|
|
3968
3480
|
# Blender's Texture tab is `buttons_texture_context_compute`'s search for a
|
|
3969
|
-
# texture USER,
|
|
3481
|
+
# texture USER, mirrored in `_texture_users` -- in the C's own order,
|
|
3970
3482
|
# with user 0 the active one (`ct->index`). The tab stands when that search
|
|
3971
3483
|
# finds a user, or (as `buttons_context_path_texture` does through the
|
|
3972
3484
|
# pinned-ID branch) when the file holds textures at all.
|
|
@@ -3975,7 +3487,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3975
3487
|
if texture_users or len(bpy.data.textures):
|
|
3976
3488
|
paths = []
|
|
3977
3489
|
if active_texture_user is not None:
|
|
3978
|
-
paths.append(("Texture",
|
|
3490
|
+
paths.append(("Texture", active_texture_user["texture"]["path"]))
|
|
3979
3491
|
paths.append(("User", active_texture_user["path"]))
|
|
3980
3492
|
paths.append(("Textures", "bpy.data.textures"))
|
|
3981
3493
|
tabs.append(_tab("texture", "Texture", "properties-texture", paths))
|
|
@@ -4014,47 +3526,11 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
4014
3526
|
|
|
4015
3527
|
|
|
4016
3528
|
|
|
4017
|
-
#
|
|
4018
|
-
#
|
|
4019
|
-
#
|
|
4020
|
-
#
|
|
4021
|
-
#
|
|
4022
|
-
# object expands in), `tree_element_id_mesh.cc`, `tree_element_id_armature.cc`,
|
|
4023
|
-
# `tree_element_pose.cc`, `tree_element_modifier.cc`, `tree_element_defgroup.cc`
|
|
4024
|
-
# and `tree_element_particle_system.cc`, all read at the engine's pin (Blender
|
|
4025
|
-
# 5.2.0, `fbe6228777e7`). Blender's UI layer is never run, ported or recorded:
|
|
4026
|
-
# what this answers is the TREE those functions build, as rows, and OUR
|
|
4027
|
-
# hierarchy panel draws it (ARCHITECTURE-CORE §Blender north star, "Inspection
|
|
4028
|
-
# parity, not editing parity"; WORK.md §Blender in the tab is Blender,
|
|
4029
|
-
# "Inspection parity", I3).
|
|
4030
|
-
#
|
|
4031
|
-
# THE ADDRESS IS THE ENGINE'S OWN, exactly as `rna_view`'s is: a row's `path` is
|
|
4032
|
-
# `_rna_address` (or a collection's address plus the member's key, I1's
|
|
4033
|
-
# answer for a struct that cannot say where it lives), so a row opens in the
|
|
4034
|
-
# RNA door with no translation. `id` is the row's IDENTITY in the tree, and it
|
|
4035
|
-
# IS the path in every ordinary case -- the two differ only where Blender
|
|
4036
|
-
# itself draws one datablock twice (an object linked into two collections, and
|
|
4037
|
-
# the `TE_CHILD_NOT_IN_COLLECTION` duplicate
|
|
4038
|
-
# `make_object_parent_hierarchy_collections` adds), where an ordinal keeps the
|
|
4039
|
-
# rows distinct while `path` stays the engine's own address for both.
|
|
4040
|
-
#
|
|
4041
|
-
# WHAT IS OPEN AT REST. A fresh tree-store element is CLOSED
|
|
4042
|
-
# (`outliner_tree.cc:139`, `tselem->flag = TSE_CLOSED`); the Scene Collection
|
|
4043
|
-
# (`tree_display_view_layer.cc:130`) and every editable layer collection
|
|
4044
|
-
# (`:167`) clear that flag and NOTHING else does -- so a collection is open and
|
|
4045
|
-
# an OBJECT is closed, its data, modifiers and groups with it.
|
|
4046
|
-
#
|
|
4047
|
-
# BIG LISTS ARE PAGED, the way `rna_view`'s collections are counted: a child
|
|
4048
|
-
# list longer than _OUTLINER_PAGE answers its first page and says how many more
|
|
4049
|
-
# there are, so a scene of 20,000 objects cannot turn one read into a
|
|
4050
|
-
# 20,000-row payload.
|
|
4051
|
-
#
|
|
4052
|
-
# AND THE READ COSTS WHAT IT SHOWS, not what the scene holds: an object's row
|
|
4053
|
-
# and its expansion are built only once the page has kept its place
|
|
4054
|
-
# (`rna_outliner`). The ids do not move for it. An id's ordinal is the number of
|
|
4055
|
-
# rows that claimed the same address before it in the WHOLE tree's order, kept
|
|
4056
|
-
# rows or not, and `_OutlinerScope` answers that count for a kept object's rows
|
|
4057
|
-
# from what every earlier object would have claimed.
|
|
3529
|
+
# Build the outliner’s View Layer tree from Blender’s tree_display_view_layer and per-datablock
|
|
3530
|
+
# expansion rules. Rows use native RNA addresses; duplicate appearances get distinct ordinal ids
|
|
3531
|
+
# while preserving their shared path. Collections begin expanded and object rows collapsed. Page
|
|
3532
|
+
# long child lists and build expansions only for retained objects. _OutlinerScope counts preceding
|
|
3533
|
+
# claims across the full tree so row identities remain stable between pages.
|
|
4058
3534
|
|
|
4059
3535
|
_OUTLINER_PAGE = 64
|
|
4060
3536
|
|
|
@@ -4120,11 +3596,9 @@ def _enum_icon(target, identifier):
|
|
|
4120
3596
|
(`outliner_draw.cc:2773-2785`); `rna_enum_constraint_type_items` carries
|
|
4121
3597
|
that function's own constraint switch. Reading the engine's enum is reading
|
|
4122
3598
|
the table the Outliner draws from, rather than transcribing it."""
|
|
4123
|
-
prop = target.bl_rna.properties
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
item = prop.enum_items.get(getattr(target, identifier, None))
|
|
4127
|
-
return (getattr(item, "icon", None) or None) if item is not None else None
|
|
3599
|
+
prop = target.bl_rna.properties[identifier]
|
|
3600
|
+
item = prop.enum_items.get(getattr(target, identifier))
|
|
3601
|
+
return (item.icon or None) if item is not None else None
|
|
4128
3602
|
|
|
4129
3603
|
|
|
4130
3604
|
def _outliner_object_icon(obj):
|
|
@@ -4134,30 +3608,26 @@ def _outliner_object_icon(obj):
|
|
|
4134
3608
|
if obj.type == "EMPTY":
|
|
4135
3609
|
if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
|
|
4136
3610
|
return "OUTLINER_OB_GROUP_INSTANCE"
|
|
4137
|
-
if
|
|
3611
|
+
if obj.empty_display_type == "IMAGE":
|
|
4138
3612
|
return "OUTLINER_OB_IMAGE"
|
|
4139
|
-
field =
|
|
4140
|
-
if field is not None and
|
|
3613
|
+
field = obj.field
|
|
3614
|
+
if field is not None and field.type != "NONE":
|
|
4141
3615
|
return "OUTLINER_OB_FORCE_FIELD"
|
|
4142
3616
|
return _enum_icon(obj, "type") or "OBJECT_DATA"
|
|
4143
3617
|
|
|
4144
3618
|
|
|
4145
3619
|
def _outliner_id_icon(datablock):
|
|
4146
3620
|
"""`tree_element_get_icon_from_id` for anything that is not an object."""
|
|
4147
|
-
if datablock is None:
|
|
4148
|
-
return "DOT"
|
|
4149
3621
|
if isinstance(datablock, bpy.types.Light):
|
|
4150
|
-
return _OUTLINER_LIGHT_ICONS.
|
|
4151
|
-
"OUTLINER_DATA_LIGHT")
|
|
3622
|
+
return _OUTLINER_LIGHT_ICONS[datablock.type]
|
|
4152
3623
|
if isinstance(datablock, bpy.types.LightProbe):
|
|
4153
|
-
return _OUTLINER_PROBE_ICONS.
|
|
4154
|
-
"LIGHTPROBE_SPHERE")
|
|
3624
|
+
return _OUTLINER_PROBE_ICONS[datablock.type]
|
|
4155
3625
|
rna = datablock.bl_rna
|
|
4156
3626
|
while rna is not None:
|
|
4157
3627
|
icon = _OUTLINER_ID_ICONS.get(rna.identifier)
|
|
4158
3628
|
if icon:
|
|
4159
3629
|
return icon
|
|
4160
|
-
rna =
|
|
3630
|
+
rna = rna.base
|
|
4161
3631
|
return "DOT"
|
|
4162
3632
|
|
|
4163
3633
|
|
|
@@ -4250,7 +3720,7 @@ def _outliner_modifier(md, obj, object_path, seen):
|
|
|
4250
3720
|
linked = getattr(md, member, None)
|
|
4251
3721
|
if linked is None:
|
|
4252
3722
|
continue
|
|
4253
|
-
linked_path = _rna_address(linked)
|
|
3723
|
+
linked_path = _rna_address(linked)
|
|
4254
3724
|
children.append(_outliner_row(linked_path, linked.name, kind, icon, seen))
|
|
4255
3725
|
break
|
|
4256
3726
|
system = getattr(md, "particle_system", None)
|
|
@@ -4291,8 +3761,8 @@ def _outliner_data(obj, seen):
|
|
|
4291
3761
|
children.extend(_outliner_bone(bone, path, seen) for bone in page)
|
|
4292
3762
|
if more:
|
|
4293
3763
|
row["more"] = more
|
|
4294
|
-
collections =
|
|
4295
|
-
if collections
|
|
3764
|
+
collections = data.collections
|
|
3765
|
+
if collections:
|
|
4296
3766
|
base = _outliner_row("%s.collections" % path, "Bone Collections",
|
|
4297
3767
|
"TSE_BONE_COLLECTION_BASE", "GROUP_BONE", seen)
|
|
4298
3768
|
page, more = _outliner_page(collections)
|
|
@@ -4369,10 +3839,9 @@ def _outliner_object(obj, view_layer, seen, chosen, active):
|
|
|
4369
3839
|
"MODIFIER_DATA", seen, object=obj.name)
|
|
4370
3840
|
base["children"] = [_outliner_modifier(md, obj, path, seen) for md in obj.modifiers]
|
|
4371
3841
|
children.append(base)
|
|
4372
|
-
groups =
|
|
4373
|
-
if groups
|
|
4374
|
-
# `expand_vertex_groups`:
|
|
4375
|
-
# the same set as "this object carries a `vertex_groups` collection".
|
|
3842
|
+
groups = obj.vertex_groups
|
|
3843
|
+
if groups:
|
|
3844
|
+
# `expand_vertex_groups`: objects with vertex groups expose their collection here.
|
|
4376
3845
|
base_path = "%s.vertex_groups" % path
|
|
4377
3846
|
base = _outliner_row(base_path, "Vertex Groups", "TSE_DEFGROUP_BASE", "GROUP_VERTEX",
|
|
4378
3847
|
seen, object=obj.name)
|
|
@@ -4429,13 +3898,12 @@ class _OutlinerScope:
|
|
|
4429
3898
|
self.local = {}
|
|
4430
3899
|
|
|
4431
3900
|
def _before(self, path):
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
return before
|
|
3901
|
+
for prefix, address, before in self.scopes:
|
|
3902
|
+
if path == address or path.startswith(prefix):
|
|
3903
|
+
return before
|
|
4436
3904
|
return None
|
|
4437
3905
|
|
|
4438
|
-
def get(self, path, default
|
|
3906
|
+
def get(self, path, default):
|
|
4439
3907
|
if path in self.local:
|
|
4440
3908
|
return self.local[path]
|
|
4441
3909
|
before = self._before(path)
|
|
@@ -4449,30 +3917,16 @@ class _OutlinerScope:
|
|
|
4449
3917
|
|
|
4450
3918
|
# ---- the node editor's tree ------------------------------------------------
|
|
4451
3919
|
#
|
|
4452
|
-
#
|
|
4453
|
-
#
|
|
4454
|
-
#
|
|
4455
|
-
#
|
|
4456
|
-
# the default Principled material is 2 nodes and 37 sockets, so drawing it
|
|
4457
|
-
# through the generic door is 40 round trips at the door's own ~100 ms median.
|
|
4458
|
-
# This door answers the same facts in one, and answers NOTHING the generic view
|
|
4459
|
-
# could not: every field below is a `bl_rna` property of `Node`, `NodeSocket` or
|
|
4460
|
-
# `NodeLink`, named here so a reader can check it against `rna_nodetree.cc`.
|
|
4461
|
-
#
|
|
4462
|
-
# IT IS A READ AND ONLY A READ (ARCHITECTURE-CORE "Inspection parity, not
|
|
4463
|
-
# editing parity"): there is no node-tree writer beside it, because a gesture
|
|
4464
|
-
# that moved a node or retyped a value would be editing, and editing parity is
|
|
4465
|
-
# not the program. `rna_set` remains the one writer, and it refuses a node's
|
|
4466
|
-
# `location` the same way it refuses anything else RNA declares read-only --
|
|
4467
|
-
# which `location` is not, so the VIEW is what refuses, by name, in its own
|
|
4468
|
-
# status line.
|
|
3920
|
+
# Read a whole node tree in one response. Fields are bl_rna properties of
|
|
3921
|
+
# Node, NodeSocket and NodeLink, matching rna_nodetree.cc.
|
|
3922
|
+
# The node view inspects and navigates; it refuses editing gestures in its
|
|
3923
|
+
# status line. RNA property writes remain on rna_set.
|
|
4469
3924
|
_NODE_SOCKET_VALUE_MAX = 4
|
|
4470
3925
|
|
|
4471
3926
|
|
|
4472
3927
|
def _node_socket_value(socket):
|
|
4473
3928
|
"""A socket's `default_value`, flattened the way `_rna_flatten` flattens an
|
|
4474
|
-
array property
|
|
4475
|
-
a geometry socket) or Blender itself would not draw it (`hide_value`)."""
|
|
3929
|
+
array property; None when the socket has no default or its value is unsupported."""
|
|
4476
3930
|
if not hasattr(socket, "default_value"):
|
|
4477
3931
|
return None
|
|
4478
3932
|
value = socket.default_value
|
|
@@ -4509,18 +3963,17 @@ def _node_socket(socket, index):
|
|
|
4509
3963
|
"shape": socket.display_shape,
|
|
4510
3964
|
"enabled": bool(socket.enabled),
|
|
4511
3965
|
"hide": bool(socket.hide),
|
|
4512
|
-
"hideValue": bool(
|
|
3966
|
+
"hideValue": bool(socket.hide_value),
|
|
4513
3967
|
"linked": bool(socket.is_linked),
|
|
4514
|
-
"multiInput": bool(
|
|
3968
|
+
"multiInput": bool(socket.is_multi_input),
|
|
4515
3969
|
"index": index,
|
|
4516
3970
|
"value": _node_socket_value(socket),
|
|
4517
3971
|
# THE SLIDER'S RANGE. A scalar socket whose `default_value` property
|
|
4518
3972
|
# carries a BOUNDED soft range is drawn by Blender as a NUMBER SLIDER
|
|
4519
3973
|
# -- `ui_but_is_slider`'s `UI_BTYPE_NUM_SLIDER`, whose back is filled to
|
|
4520
3974
|
# the value's proportion of that range (`widget_numslider`,
|
|
4521
|
-
# `interface_widgets.cc`).
|
|
4522
|
-
#
|
|
4523
|
-
# text box where Blender's reads half-filled at a glance.
|
|
3975
|
+
# `interface_widgets.cc`). The soft range determines the filled
|
|
3976
|
+
# proportion; an unbounded field has no slider fill.
|
|
4524
3977
|
#
|
|
4525
3978
|
# `soft_min`/`soft_max` are the UI range and `min`/`max` the hard one;
|
|
4526
3979
|
# Blender sliders against the SOFT pair. An unbounded property (a
|
|
@@ -4533,11 +3986,7 @@ def _node_socket(socket, index):
|
|
|
4533
3986
|
def _node_socket_range(socket):
|
|
4534
3987
|
"""The soft range of a scalar socket drawn as a SLIDER, when it is one.
|
|
4535
3988
|
|
|
4536
|
-
|
|
4537
|
-
A first pass here reported the range for every bounded scalar, and the view
|
|
4538
|
-
drew IOR (soft range 1..3) half-filled at 1.500 -- while Blender's own
|
|
4539
|
-
shader editor leaves IOR FLAT and fills only Roughness and Alpha. The
|
|
4540
|
-
separating property is the SUBTYPE: `PROP_FACTOR` is what
|
|
3989
|
+
The socket subtype determines whether Blender draws a slider: `PROP_FACTOR` is what
|
|
4541
3990
|
`uiItemR`/`node_socket_button_default` pass `UI_ITEM_R_SLIDER` for, so a
|
|
4542
3991
|
factor draws as `UI_BTYPE_NUM_SLIDER` with a filled back and every other
|
|
4543
3992
|
number draws as a plain `UI_BTYPE_NUM`.
|
|
@@ -4550,61 +3999,28 @@ def _node_socket_range(socket):
|
|
|
4550
3999
|
prop = socket.bl_rna.properties["default_value"]
|
|
4551
4000
|
except (AttributeError, KeyError):
|
|
4552
4001
|
return {}
|
|
4553
|
-
if
|
|
4002
|
+
if prop.type not in ("FLOAT", "INT"):
|
|
4554
4003
|
return {}
|
|
4555
|
-
if
|
|
4556
|
-
return {}
|
|
4557
|
-
if getattr(prop, "subtype", None) != "FACTOR":
|
|
4004
|
+
if prop.array_length:
|
|
4558
4005
|
return {}
|
|
4559
|
-
|
|
4560
|
-
high = getattr(prop, "soft_max", None)
|
|
4561
|
-
if low is None or high is None:
|
|
4006
|
+
if prop.subtype != "FACTOR":
|
|
4562
4007
|
return {}
|
|
4008
|
+
low = prop.soft_min
|
|
4009
|
+
high = prop.soft_max
|
|
4563
4010
|
if not (math.isfinite(low) and math.isfinite(high)) or high <= low:
|
|
4564
4011
|
return {}
|
|
4565
4012
|
return {"softMin": float(low), "softMax": float(high)}
|
|
4566
4013
|
|
|
4567
4014
|
|
|
4568
4015
|
def _node_row(node):
|
|
4569
|
-
"""
|
|
4570
|
-
|
|
4571
|
-
`colorTag` is `Node.color_tag` (`rna_nodetree.cc:9480-9484`), which RNA
|
|
4572
|
-
reads from `bke::node_color_tag(*node)` -- the very value
|
|
4573
|
-
`node_get_colorid` (`node_draw.cc:1388-1434`) switches on to pick a header
|
|
4574
|
-
colour. So the header hue is a READ of the engine, not a table of ours.
|
|
4575
|
-
|
|
4576
|
-
`panels` is `Node.panel_states` (`rna_nodetree.cc:9430-9434`): per panel,
|
|
4577
|
-
the declaration's `persistent_uid` and `is_collapsed`, and NOTHING ELSE --
|
|
4578
|
-
no label, and no way to ask which sockets belong to it. That membership
|
|
4579
|
-
lives in the node's C++ declaration, which `node_update_basis_from_
|
|
4580
|
-
declaration` (`node_draw.cc:1086-1218`) walks and bpy does not expose.
|
|
4581
|
-
Measured 2026-09-19 on Blender 5.2.0 in the tab: `ShaderNodeBsdfPrincipled`
|
|
4582
|
-
answers 8 panel states (uids 9, 11, 18, 25, 27, 33, 37, 40), all collapsed,
|
|
4583
|
-
and not one of its 32 `NodeSocket`s carries a panel field.
|
|
4584
|
-
|
|
4585
|
-
RULED 2026-09-19: the MEMBERSHIP is traced from Blender's own `declare()`
|
|
4586
|
-
bodies instead, so what this door owes is the COLLAPSED STATE the trace
|
|
4587
|
-
cannot know -- per node, live, and changed under the person's hand. The
|
|
4588
|
-
states arrive IN ORDER, because Blender indexes `panel_states_array` by
|
|
4589
|
-
the declaration's panel INDEX (`node_draw.cc:1037`) and RNA's collection
|
|
4590
|
-
IS that array; the uid rides along so a disagreement is visible rather
|
|
4591
|
-
than silently mis-joined.
|
|
4592
|
-
|
|
4593
|
-
THE TRACE IS THE CONSUMER'S, NOT THIS PACKAGE'S, and the split is the
|
|
4594
|
-
point: `@volter/editor-blender`'s `blender-node-panels.source.mjs` reads a Blender
|
|
4595
|
-
CHECKOUT at the engine's pin and writes `blender.node-panels.json` beside
|
|
4596
|
-
the view that draws with it. Nothing of it belongs here -- this package is
|
|
4597
|
-
the engine and its wire, and it answers what the RUNNING engine knows.
|
|
4598
|
-
|
|
4599
|
-
`showOptions` is `Node.show_options` -> `NODE_OPTIONS`, the flag
|
|
4600
|
-
`add_flat_items_for_layout` (`node_draw.cc:739-746`) returns early on."""
|
|
4016
|
+
"""Report node header colour tags, live panel states, sockets and show_options from RNA. Socket-panel labels and membership belong to the consumer’s Blender source declarations."""
|
|
4601
4017
|
return {
|
|
4602
4018
|
"name": node.name,
|
|
4603
4019
|
"idname": node.bl_idname,
|
|
4604
4020
|
"type": node.type,
|
|
4605
4021
|
"typeLabel": node.bl_label,
|
|
4606
4022
|
"label": node.label or None,
|
|
4607
|
-
"colorTag":
|
|
4023
|
+
"colorTag": node.color_tag,
|
|
4608
4024
|
# `Node.location` is the tree-space corner Blender lays out from
|
|
4609
4025
|
# (`node_update_basis`: `dy = loc.y`, `node_draw.cc:1297`); `width` is
|
|
4610
4026
|
# the value `NODE_WIDTH(node)` scales (`node_intern.hh:337`).
|
|
@@ -4620,11 +4036,11 @@ def _node_row(node):
|
|
|
4620
4036
|
# gives an OUTPUT-class node the output colour only when it is the
|
|
4621
4037
|
# active output (`node_draw.cc:1395-1401`).
|
|
4622
4038
|
"activeOutput": bool(getattr(node, "is_active_output", False)),
|
|
4623
|
-
"panelCount": len(
|
|
4039
|
+
"panelCount": len(node.panel_states),
|
|
4624
4040
|
"panels": [{"identifier": int(state.identifier),
|
|
4625
4041
|
"collapsed": bool(state.is_collapsed)}
|
|
4626
|
-
for state in
|
|
4627
|
-
"showOptions": bool(
|
|
4042
|
+
for state in node.panel_states],
|
|
4043
|
+
"showOptions": bool(node.show_options),
|
|
4628
4044
|
"inputs": [_node_socket(socket, index) for index, socket in enumerate(node.inputs)],
|
|
4629
4045
|
"outputs": [_node_socket(socket, index) for index, socket in enumerate(node.outputs)],
|
|
4630
4046
|
}
|
|
@@ -4644,7 +4060,7 @@ def _node_tree_of(path, material):
|
|
|
4644
4060
|
raise ValueError("The engine holds no material named %r" % (material,))
|
|
4645
4061
|
else:
|
|
4646
4062
|
obj = bpy.context.view_layer.objects.active
|
|
4647
|
-
mat =
|
|
4063
|
+
mat = obj.active_material if obj is not None else None
|
|
4648
4064
|
if mat is None:
|
|
4649
4065
|
return None, None, None
|
|
4650
4066
|
if not mat.use_nodes:
|
|
@@ -4707,32 +4123,7 @@ def _uv_object_of(object_name):
|
|
|
4707
4123
|
|
|
4708
4124
|
|
|
4709
4125
|
def rna_uv_layout(object_name=None, uv_layer=None):
|
|
4710
|
-
"""
|
|
4711
|
-
|
|
4712
|
-
WHY A DOOR AND NOT THE GENERIC VIEW. `rna_view` answers a struct at a time
|
|
4713
|
-
and reports a collection as a COUNT, and a UV layout is
|
|
4714
|
-
`len(mesh.loops)` two-float corners plus a triangulation -- 8,008 corners
|
|
4715
|
-
over 31 meshes in `arena-weapons.blend`, measured 2026-09-19. The
|
|
4716
|
-
per-corner arrays therefore cross as BASE64 typed-array bytes, exactly as
|
|
4717
|
-
I4's weights do, and every scalar beside them is a `bl_rna` property the
|
|
4718
|
-
generic door also answers.
|
|
4719
|
-
|
|
4720
|
-
WHAT IT IS NOT GATED ON. Blender draws UVs only when
|
|
4721
|
-
`space_mode_is_uv && object_mode_is_edit` (`overlay_mesh.hh:593`), and both
|
|
4722
|
-
halves of that are its UI layer's -- a space this engine has none of, and a
|
|
4723
|
-
mode an inspection surface does not enter (orchestrator ruling 1,
|
|
4724
|
-
2026-09-19: "inspection is not mode-gated"). So this reads the DATA in
|
|
4725
|
-
whatever mode the engine is in and reports `mode` beside it, and the view
|
|
4726
|
-
names the Blender mode the same picture would need.
|
|
4727
|
-
|
|
4728
|
-
THE SELECTION FLAGS ARE REPORTED WHERE THE DATA CARRIES THEM, and at this
|
|
4729
|
-
pin an evaluated mesh usually carries none: `MeshUVLoopLayer`
|
|
4730
|
-
(`rna_mesh.cc:2380-2456`) declares `uv`, `pin`, `name`, `active`,
|
|
4731
|
-
`active_render` and `active_clone` -- and NO vertex or edge selection,
|
|
4732
|
-
because UV selection lives in the BMesh an edit-mode session holds. `pin`
|
|
4733
|
-
IS declared, so it is answered; everything else draws unselected, which is
|
|
4734
|
-
the second half of the same ruling.
|
|
4735
|
-
"""
|
|
4126
|
+
"""Return per-corner UVs, triangulation, polygon ranges and pin flags as base64 typed arrays. Selection remains null outside edit-mode BMesh because MeshUVLoopLayer exposes no selection field."""
|
|
4736
4127
|
obj = _uv_object_of(object_name)
|
|
4737
4128
|
if obj is None:
|
|
4738
4129
|
return {"object": None, "mesh": None, "layers": [], "active": None,
|
|
@@ -4811,7 +4202,7 @@ def _uv_backdrop_image(obj):
|
|
|
4811
4202
|
(`ED_space_image_auto_set`, `space_image.cc:60-101`). There is no
|
|
4812
4203
|
`SpaceImage` here, so the second half is the whole rule: the active
|
|
4813
4204
|
material's node tree's active `TEX_IMAGE`."""
|
|
4814
|
-
mat =
|
|
4205
|
+
mat = obj.active_material
|
|
4815
4206
|
if mat is None or not mat.use_nodes or mat.node_tree is None:
|
|
4816
4207
|
return None
|
|
4817
4208
|
nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image is not None]
|
|
@@ -4824,25 +4215,7 @@ def _uv_backdrop_image(obj):
|
|
|
4824
4215
|
"source": image.source, "hasData": bool(image.has_data)}
|
|
4825
4216
|
|
|
4826
4217
|
|
|
4827
|
-
#
|
|
4828
|
-
#
|
|
4829
|
-
# WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20). Blender
|
|
4830
|
-
# holds the animation as DATA; three.js PLAYS it. A Timeline that asked Blender
|
|
4831
|
-
# to re-evaluate the depsgraph and re-export the columns once per frame would
|
|
4832
|
-
# be the wrong architecture -- so these two doors hand the tab everything it
|
|
4833
|
-
# needs to build a `THREE.SkinnedMesh` + `Skeleton` + `AnimationMixer` ONCE,
|
|
4834
|
-
# and the scrub, the playback and the frame counter after that are three.js's
|
|
4835
|
-
# with ZERO calls back into this process.
|
|
4836
|
-
#
|
|
4837
|
-
# WHAT THE BIND POSE IS, and why it is not the rest pose. The export door runs
|
|
4838
|
-
# with `evaluate: True`, so the mesh columns the presenter already holds are
|
|
4839
|
-
# the EVALUATED mesh -- the armature modifier included -- which means they are
|
|
4840
|
-
# the deformed mesh at whatever frame Blender is sitting on. Binding a skeleton
|
|
4841
|
-
# whose bones are in that SAME pose makes the skinning an identity there
|
|
4842
|
-
# (`skinMatrix = sum(w_i * B_i * B_i^-1) = I`), so the picture at the bind
|
|
4843
|
-
# frame is byte-for-byte the frame Blender presented, and every other frame is
|
|
4844
|
-
# three.js's own evaluation of the same skin. That is what lets Blender's
|
|
4845
|
-
# `frame_current` stand still for the whole of playback.
|
|
4218
|
+
# Export rig and clip data once for the presenter’s Skeleton and AnimationMixer. Bind to the evaluated pose so skinning is the identity at the exported frame; playback does not advance Blender’s frame_current.
|
|
4846
4219
|
|
|
4847
4220
|
|
|
4848
4221
|
def _rig_armature_of(obj):
|
|
@@ -4854,170 +4227,18 @@ def _rig_armature_of(obj):
|
|
|
4854
4227
|
return obj.find_armature()
|
|
4855
4228
|
|
|
4856
4229
|
|
|
4857
|
-
def _pose_bones_parents_first(arm_obj):
|
|
4858
|
-
"""Every pose bone, parents before children. `Object.pose.bones` is already
|
|
4859
|
-
in hierarchy order at this pin, but three.js's `Skeleton` is built by
|
|
4860
|
-
INDEX and a child whose parent has no index yet cannot be parented, so the
|
|
4861
|
-
order is made explicit rather than assumed."""
|
|
4862
|
-
order = []
|
|
4863
|
-
seen = set()
|
|
4864
|
-
|
|
4865
|
-
def walk(pchan):
|
|
4866
|
-
if pchan.name in seen:
|
|
4867
|
-
return
|
|
4868
|
-
parent = pchan.parent
|
|
4869
|
-
if parent is not None:
|
|
4870
|
-
walk(parent)
|
|
4871
|
-
if pchan.name in seen:
|
|
4872
|
-
return
|
|
4873
|
-
seen.add(pchan.name)
|
|
4874
|
-
order.append(pchan)
|
|
4875
|
-
|
|
4876
|
-
for pchan in arm_obj.pose.bones:
|
|
4877
|
-
walk(pchan)
|
|
4878
|
-
return order
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
4230
|
def _matrix_rows(matrix):
|
|
4882
4231
|
return [[float(v) for v in row] for row in matrix]
|
|
4883
4232
|
|
|
4884
4233
|
|
|
4885
|
-
|
|
4886
|
-
def _rig_of(obj):
|
|
4887
|
-
"""ONE MESH'S SKIN BINDING: the armature's bones, and up to four weighted
|
|
4888
|
-
bone influences per Blender vertex.
|
|
4889
|
-
|
|
4890
|
-
WHY A DOOR AND NOT THE GENERIC VIEW, the same answer `rna_uv_layout` gives:
|
|
4891
|
-
`rna_view` reports a collection as a COUNT, and a skin binding is
|
|
4892
|
-
`len(mesh.vertices)` x 4 pairs -- 1,152 vertices is 4,608 of them on the
|
|
4893
|
-
smallest probe. They cross as base64 typed-array bytes, the shape I4's
|
|
4894
|
-
weights established: `skinIndexBase64` Uint16 (`vertices * 4`) and
|
|
4895
|
-
`skinWeightBase64` Float32 (`vertices * 4`), normalized so the four sum to
|
|
4896
|
-
one.
|
|
4897
|
-
|
|
4898
|
-
THE INDICES ARE BLENDER VERTEX INDICES, not drawn-vertex ones. The draw
|
|
4899
|
-
splits a Blender vertex into as many drawn vertices as its corners need,
|
|
4900
|
-
and `DrawArrays.sourceVertex` is the map -- so the presenter EXPANDS these
|
|
4901
|
-
arrays through it (`blender-runtime-skin.ts`), exactly as I4's weight
|
|
4902
|
-
overlay expands its ramp.
|
|
4903
|
-
|
|
4904
|
-
TWO MATRICES PER BONE, both in the armature OBJECT's space and row-major as
|
|
4905
|
-
four rows: `rest` is `Bone.matrix_local` (the armature's rest pose, what
|
|
4906
|
-
`pose.bones[...]` is measured against) and `pose` is `PoseBone.matrix` --
|
|
4907
|
-
`pchan->pose_mat`, the SAME matrix I4's bone overlay draws. `pose` is the
|
|
4908
|
-
BIND pose, because it is the pose the exported columns were evaluated at.
|
|
4909
|
-
"""
|
|
4910
|
-
if obj is None or obj.type != "MESH" or obj.data is None:
|
|
4911
|
-
return {"object": obj.name if obj is not None else None, "armature": None,
|
|
4912
|
-
"bones": [], "vertexCount": 0,
|
|
4913
|
-
"reason": "Only a mesh object carries a skin binding."}
|
|
4914
|
-
mesh = obj.data
|
|
4915
|
-
arm_obj = _rig_armature_of(obj)
|
|
4916
|
-
header = {
|
|
4917
|
-
"object": obj.name,
|
|
4918
|
-
"mesh": mesh.name,
|
|
4919
|
-
"vertexCount": len(mesh.vertices),
|
|
4920
|
-
"frame": int(bpy.context.scene.frame_current),
|
|
4921
|
-
"mode": bpy.context.mode,
|
|
4922
|
-
}
|
|
4923
|
-
if arm_obj is None:
|
|
4924
|
-
header.update({"armature": None, "bones": [],
|
|
4925
|
-
"reason": "%r is deformed by no armature -- `Object.find_armature()` "
|
|
4926
|
-
"answers none, so it presents as an ordinary Mesh." % (obj.name,)})
|
|
4927
|
-
return header
|
|
4928
|
-
order = _pose_bones_parents_first(arm_obj)
|
|
4929
|
-
index_of = {pchan.name: i for i, pchan in enumerate(order)}
|
|
4930
|
-
header.update({
|
|
4931
|
-
"armature": arm_obj.name,
|
|
4932
|
-
"armatureData": arm_obj.data.name,
|
|
4933
|
-
# THE CONSTRAINT COUNT IS REPORTED because the CLIP is derived from the
|
|
4934
|
-
# F-Curves alone (`rna_action_clip`) while this bind pose is read live
|
|
4935
|
-
# off `PoseBone.matrix`. For a constraint-free rig the two agree by
|
|
4936
|
-
# construction; a constrained bone's pose is Blender's solver's and the
|
|
4937
|
-
# clip cannot reproduce it, so the view says so rather than drifting.
|
|
4938
|
-
"constrainedBones": sorted(p.name for p in order if len(p.constraints)),
|
|
4939
|
-
"bones": [{
|
|
4940
|
-
"name": pchan.name,
|
|
4941
|
-
"parent": pchan.parent.name if pchan.parent is not None else None,
|
|
4942
|
-
"rest": _matrix_rows(pchan.bone.matrix_local),
|
|
4943
|
-
"pose": _matrix_rows(pchan.matrix),
|
|
4944
|
-
"length": float(pchan.bone.length),
|
|
4945
|
-
"deform": bool(pchan.bone.use_deform),
|
|
4946
|
-
"connected": bool(pchan.bone.use_connect),
|
|
4947
|
-
} for pchan in order],
|
|
4948
|
-
})
|
|
4949
|
-
group_to_bone = {}
|
|
4950
|
-
unmapped = []
|
|
4951
|
-
for group in obj.vertex_groups:
|
|
4952
|
-
bone = index_of.get(group.name)
|
|
4953
|
-
if bone is None:
|
|
4954
|
-
unmapped.append(group.name)
|
|
4955
|
-
else:
|
|
4956
|
-
group_to_bone[int(group.index)] = bone
|
|
4957
|
-
count = len(mesh.vertices)
|
|
4958
|
-
# FOUR INFLUENCES, which is three.js's `skinIndex`/`skinWeight` shape and
|
|
4959
|
-
# also glTF's; a vertex with more is truncated to its four heaviest and
|
|
4960
|
-
# RENORMALIZED, and the count of such vertices is reported rather than
|
|
4961
|
-
# quietly dropped.
|
|
4962
|
-
indices = array.array("H", bytes(count * 8))
|
|
4963
|
-
weights = array.array("f", bytes(count * 16))
|
|
4964
|
-
truncated = 0
|
|
4965
|
-
unweighted = 0
|
|
4966
|
-
most = 0
|
|
4967
|
-
checkpoint_at = time.monotonic()
|
|
4968
|
-
for i, vertex in enumerate(mesh.vertices):
|
|
4969
|
-
if i % 1024 == 0 and time.monotonic() - checkpoint_at >= 0.05:
|
|
4970
|
-
_asked({"checkpoint": "rig-weights"})
|
|
4971
|
-
checkpoint_at = time.monotonic()
|
|
4972
|
-
pairs = []
|
|
4973
|
-
for element in vertex.groups:
|
|
4974
|
-
bone = group_to_bone.get(int(element.group))
|
|
4975
|
-
if bone is None:
|
|
4976
|
-
continue
|
|
4977
|
-
value = float(element.weight)
|
|
4978
|
-
if value > 0.0:
|
|
4979
|
-
pairs.append((value, bone))
|
|
4980
|
-
most = max(most, len(pairs))
|
|
4981
|
-
if len(pairs) > 4:
|
|
4982
|
-
truncated += 1
|
|
4983
|
-
pairs.sort(reverse=True)
|
|
4984
|
-
pairs = pairs[:4]
|
|
4985
|
-
total = sum(value for value, _ in pairs)
|
|
4986
|
-
if total <= 0.0:
|
|
4987
|
-
unweighted += 1
|
|
4988
|
-
continue
|
|
4989
|
-
for slot, (value, bone) in enumerate(pairs):
|
|
4990
|
-
indices[i * 4 + slot] = bone
|
|
4991
|
-
weights[i * 4 + slot] = value / total
|
|
4992
|
-
header.update({
|
|
4993
|
-
"skinIndexBase64": base64.b64encode(indices.tobytes()).decode("ascii"),
|
|
4994
|
-
"skinWeightBase64": base64.b64encode(weights.tobytes()).decode("ascii"),
|
|
4995
|
-
"maxInfluences": most,
|
|
4996
|
-
"truncatedVertices": truncated,
|
|
4997
|
-
"unweightedVertices": unweighted,
|
|
4998
|
-
"unmappedGroups": unmapped,
|
|
4999
|
-
})
|
|
5000
|
-
return header
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
4234
|
def _action_channelbag(action, slot_handle):
|
|
5004
|
-
"""
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
rest of this file uses for a moved API."""
|
|
5012
|
-
legacy = getattr(action, "fcurves", None)
|
|
5013
|
-
if legacy is not None:
|
|
5014
|
-
return list(legacy), "legacy"
|
|
5015
|
-
for layer in getattr(action, "layers", ()):
|
|
5016
|
-
for strip in getattr(layer, "strips", ()):
|
|
5017
|
-
for bag in (getattr(strip, "channelbags", None) or ()):
|
|
5018
|
-
if slot_handle is None or int(getattr(bag, "slot_handle", -1)) == int(slot_handle):
|
|
5019
|
-
return list(bag.fcurves), "layered"
|
|
5020
|
-
return [], "none"
|
|
4235
|
+
"""Return the first matching channelbag's F-curves, or the first bag's when no slot is supplied; no match yields an empty list."""
|
|
4236
|
+
for layer in action.layers:
|
|
4237
|
+
for strip in layer.strips:
|
|
4238
|
+
for bag in strip.channelbags:
|
|
4239
|
+
if slot_handle is None or int(bag.slot_handle) == int(slot_handle):
|
|
4240
|
+
return list(bag.fcurves)
|
|
4241
|
+
return []
|
|
5021
4242
|
|
|
5022
4243
|
|
|
5023
4244
|
_BONE_PATH = re.compile(r'^pose\.bones\["(.+)"\]\.(location|rotation_quaternion|'
|
|
@@ -5040,7 +4261,7 @@ def _key_columns(curves):
|
|
|
5040
4261
|
# EXTREME wins over KEYFRAME the way Blender's own merge does:
|
|
5041
4262
|
# `nupdate_ak_bezt` keeps the "most significant" type
|
|
5042
4263
|
# (`keyframes_keylist.cc`, `KEYFRAME_STATE` ordering).
|
|
5043
|
-
if _KEY_TYPE_RANK
|
|
4264
|
+
if _KEY_TYPE_RANK[key.type] > _KEY_TYPE_RANK[record["type"]]:
|
|
5044
4265
|
record["type"] = key.type
|
|
5045
4266
|
return columns
|
|
5046
4267
|
|
|
@@ -5065,12 +4286,12 @@ def _summary_objects():
|
|
|
5065
4286
|
"""
|
|
5066
4287
|
out = []
|
|
5067
4288
|
for obj in bpy.context.view_layer.objects:
|
|
5068
|
-
adt =
|
|
5069
|
-
action =
|
|
4289
|
+
adt = obj.animation_data
|
|
4290
|
+
action = adt.action if adt is not None else None
|
|
5070
4291
|
if action is None:
|
|
5071
4292
|
continue
|
|
5072
|
-
slot =
|
|
5073
|
-
curves
|
|
4293
|
+
slot = adt.action_slot
|
|
4294
|
+
curves = _action_channelbag(action, slot.handle if slot is not None else None)
|
|
5074
4295
|
columns = _key_columns(curves)
|
|
5075
4296
|
out.append({
|
|
5076
4297
|
"object": obj.name,
|
|
@@ -5082,31 +4303,7 @@ def _summary_objects():
|
|
|
5082
4303
|
|
|
5083
4304
|
|
|
5084
4305
|
def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True):
|
|
5085
|
-
"""
|
|
5086
|
-
every integer frame of the action's own range.
|
|
5087
|
-
|
|
5088
|
-
WHAT CROSSES, and why it is a BAKE rather than the raw keys. Blender's
|
|
5089
|
-
channels are `PoseBone.location/rotation_*/scale` in the bone's own REST
|
|
5090
|
-
space (`pchan->chan_mat`), while three.js's `Bone` carries a LOCAL
|
|
5091
|
-
transform relative to its PARENT BONE. The relation is
|
|
5092
|
-
`local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so no
|
|
5093
|
-
per-channel re-labelling can turn one into the other -- the matrices have
|
|
5094
|
-
to be composed. Composing them per KEY would then also have to reproduce
|
|
5095
|
-
Blender's Bezier handles between keys, so the honest, one-code-path answer
|
|
5096
|
-
is to SAMPLE at every integer frame and ship LINEAR tracks: exact at every
|
|
5097
|
-
frame a Timeline can scrub to, linear in between, and stated as the
|
|
5098
|
-
justified difference rather than implied.
|
|
5099
|
-
|
|
5100
|
-
THE FRAME IS NEVER MOVED to do it. `FCurve.evaluate(frame)` reads the curve
|
|
5101
|
-
without touching `scene.frame_current` or the depsgraph, which is what
|
|
5102
|
-
makes this door cheap and what keeps bpy's own frame where the person left
|
|
5103
|
-
it.
|
|
5104
|
-
|
|
5105
|
-
A CONSTANT TRACK IS TWO KEYS. A rig's channels are overwhelmingly still --
|
|
5106
|
-
an untouched bone contributes three tracks of identical samples -- so a
|
|
5107
|
-
track whose every sample equals its first is shipped as its first and last
|
|
5108
|
-
only. Measured on the probe below.
|
|
5109
|
-
"""
|
|
4306
|
+
"""Bake each bone’s parent-relative transform at integer action frames for a three.js clip. Convert from Blender rest space through the parent rest matrix; compact constant tracks to one sample."""
|
|
5110
4307
|
if object_name:
|
|
5111
4308
|
obj = bpy.data.objects.get(object_name)
|
|
5112
4309
|
if obj is None:
|
|
@@ -5143,14 +4340,7 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5143
4340
|
"slot": None,
|
|
5144
4341
|
"tracks": [],
|
|
5145
4342
|
"keyframes": [],
|
|
5146
|
-
#
|
|
5147
|
-
# Timeline's summary row is filtered by SELECTION in Blender
|
|
5148
|
-
# (`show_keys_from_selected_only`) and the view is what decides which
|
|
5149
|
-
# side of that filter it is on, so the door answers both halves even
|
|
5150
|
-
# when the subject below turns out to be nothing at all.
|
|
5151
|
-
# `summary=False` for a reader that plays the clip (a game's clip library): the row walks
|
|
5152
|
-
# every animated object in the view layer, which was ~690 of a ~700 ms read on a level of
|
|
5153
|
-
# 23 armatures (measured 2026-10-08), and nothing that plays a clip draws it.
|
|
4343
|
+
# Include every animated object and its selection for Timeline summary filtering. A clip-only reader passes summary=False to skip the scene-wide summary walk.
|
|
5154
4344
|
"summary": _summary_objects() if summary else [],
|
|
5155
4345
|
}
|
|
5156
4346
|
adt = arm_obj.animation_data if arm_obj is not None else None
|
|
@@ -5164,11 +4354,11 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5164
4354
|
"and an empty summary row, which is Blender's own empty state.")
|
|
5165
4355
|
return header
|
|
5166
4356
|
action = assigned
|
|
5167
|
-
slot =
|
|
4357
|
+
slot = adt.action_slot if adt is not None else None
|
|
5168
4358
|
if action_name and (assigned is None or action_name != assigned.name):
|
|
5169
4359
|
# A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
|
|
5170
|
-
# baked against this armature WITHOUT assigning it, so the file is untouched.
|
|
5171
|
-
#
|
|
4360
|
+
# baked against this armature WITHOUT assigning it, so the file is untouched. The
|
|
4361
|
+
# owner's identifier selects its slot, otherwise the first object slot is used.
|
|
5172
4362
|
action = bpy.data.actions.get(action_name)
|
|
5173
4363
|
if action is None:
|
|
5174
4364
|
raise ValueError("The engine holds no action named %r" % (action_name,))
|
|
@@ -5176,23 +4366,20 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5176
4366
|
# identifier names this object (`OB<name>`), else the only object slot. A layered action
|
|
5177
4367
|
# with several object slots and none for this armature has no right answer; its first
|
|
5178
4368
|
# object slot is used and said in the reason.
|
|
5179
|
-
slots = [one for one in
|
|
5180
|
-
slot = next((one for one in slots if
|
|
4369
|
+
slots = [one for one in action.slots if one.target_id_type == "OBJECT"]
|
|
4370
|
+
slot = next((one for one in slots if one.identifier == "OB" + arm_obj.name), None)
|
|
5181
4371
|
if slot is None and slots:
|
|
5182
4372
|
slot = slots[0]
|
|
5183
4373
|
if len(slots) > 1:
|
|
5184
4374
|
header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
|
|
5185
|
-
% (action.name, len(slots), arm_obj.name,
|
|
5186
|
-
curves
|
|
5187
|
-
#
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
header["objectSlots"] = len([one for one in getattr(action, "slots", ())
|
|
5191
|
-
if getattr(one, "target_id_type", "OBJECT") == "OBJECT"])
|
|
4375
|
+
% (action.name, len(slots), arm_obj.name, slot.identifier))
|
|
4376
|
+
curves = _action_channelbag(action, slot.handle if slot is not None else None)
|
|
4377
|
+
# Report object-slot count so consumers share a clip across matching armatures only when its channel set is independent of the owner.
|
|
4378
|
+
header["objectSlots"] = len([one for one in action.slots
|
|
4379
|
+
if one.target_id_type == "OBJECT"])
|
|
5192
4380
|
header.update({
|
|
5193
4381
|
"action": action.name,
|
|
5194
|
-
"slot":
|
|
5195
|
-
"channels": shape,
|
|
4382
|
+
"slot": slot.name_display if slot is not None else None,
|
|
5196
4383
|
"fcurves": len(curves),
|
|
5197
4384
|
"keys": sum(len(fc.keyframe_points) for fc in curves),
|
|
5198
4385
|
})
|
|
@@ -5208,32 +4395,32 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5208
4395
|
# THE ACTION'S OWN RANGE (`Action.frame_range`: its keys, or its manual range) with its keys:
|
|
5209
4396
|
# what a strip or the action line plays, sampled through `FCurve.evaluate`, which applies the
|
|
5210
4397
|
# curves' extrapolation and modifiers (a Cycles modifier repeats them) as Blender does.
|
|
5211
|
-
span =
|
|
5212
|
-
first = int(math.floor(min(frames[0], span[0]
|
|
5213
|
-
last = int(math.ceil(max(frames[-1], span[1]
|
|
4398
|
+
span = action.frame_range
|
|
4399
|
+
first = int(math.floor(min(frames[0], span[0])))
|
|
4400
|
+
last = int(math.ceil(max(frames[-1], span[1])))
|
|
5214
4401
|
if last <= first:
|
|
5215
4402
|
last = first + 1
|
|
5216
4403
|
header["clipStart"] = first
|
|
5217
4404
|
header["clipEnd"] = last
|
|
5218
|
-
#
|
|
5219
|
-
# modifiers): its keys, opened to either side a Cycles modifier repeats into (null: unbounded).
|
|
5220
|
-
# A manual frame range does not enter it. Measured: a cycling action is not held at its end.
|
|
4405
|
+
# Report the active action range including Cycles modifier extensions; null bounds are unbounded. The manual frame range does not enter it.
|
|
5221
4406
|
header["keysStart"], header["keysEnd"] = _action_key_range(action, first, last)
|
|
5222
4407
|
channels = {}
|
|
5223
4408
|
others = set()
|
|
5224
4409
|
for fcurve in curves:
|
|
5225
4410
|
match = _BONE_PATH.match(fcurve.data_path)
|
|
5226
4411
|
if match is None:
|
|
5227
|
-
# NOT A BONE'S CHANNEL:
|
|
5228
|
-
#
|
|
5229
|
-
|
|
4412
|
+
# NOT A BONE'S CHANNEL: a custom property, a bone channel this door does not read.
|
|
4413
|
+
# Named, so it is never dropped quietly. The armature object's OWN transform is not
|
|
4414
|
+
# this door's either, but it plays: the scene's movie (`rna_scene_movie`) carries it.
|
|
4415
|
+
if _OBJECT_PATH.match(fcurve.data_path) is None:
|
|
4416
|
+
others.add(fcurve.data_path)
|
|
5230
4417
|
continue
|
|
5231
4418
|
bone, prop = match.group(1), match.group(2)
|
|
5232
4419
|
channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
|
|
5233
4420
|
if not channels:
|
|
5234
|
-
header["reason"] = ("%r
|
|
5235
|
-
"
|
|
5236
|
-
"Timeline still draws
|
|
4421
|
+
header["reason"] = ("%r has no pose-bone transform channels. The scene's movie "
|
|
4422
|
+
"(`rna_scene_movie`) carries supported object, material and shape-key "
|
|
4423
|
+
"animation; the Timeline still draws this action's keys." % (action.name,))
|
|
5237
4424
|
return header
|
|
5238
4425
|
tracks = []
|
|
5239
4426
|
unplayed = []
|
|
@@ -5247,12 +4434,621 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5247
4434
|
header["unsupported"] = ["curves on %s" % ", ".join(sorted(others))]
|
|
5248
4435
|
header.update({
|
|
5249
4436
|
"tracks": tracks,
|
|
4437
|
+
"bones": _three_bones(arm_obj, channels, first, last),
|
|
5250
4438
|
"duration": (last - first) / fps,
|
|
5251
4439
|
"unplayedBones": unplayed,
|
|
5252
4440
|
})
|
|
5253
4441
|
return header
|
|
5254
4442
|
|
|
5255
4443
|
|
|
4444
|
+
def _three_bones(arm_obj, channels, first, last):
|
|
4445
|
+
"""THE ACTION AS THREE.JS PLAYS IT: per bone it keys, the bone's LOCAL transform (relative to
|
|
4446
|
+
its parent bone, as a `THREE.Bone` carries it) at every integer frame from `first` to `last`.
|
|
4447
|
+
|
|
4448
|
+
Blender evaluates every channel (`FCurve.evaluate`: its keys, handles, interpolation, easing,
|
|
4449
|
+
extrapolation and modifiers); a component the action does not key keeps the bone's own value.
|
|
4450
|
+
The basis is composed by the bone's rotation mode and placed by its rest relative to its
|
|
4451
|
+
parent's rest, `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so a clip moves
|
|
4452
|
+
a bone exactly as Blender's pose does at every frame, and three.js interpolates between them.
|
|
4453
|
+
|
|
4454
|
+
Each column crosses as base64 Float32: `position` three per frame, `quaternion` four (x, y, z,
|
|
4455
|
+
w, as three reads them, kept on one hemisphere so a slerp never turns the long way), `scale`
|
|
4456
|
+
three. A column whose every sample equals its first crosses as that one sample."""
|
|
4457
|
+
frames = list(range(first, last + 1))
|
|
4458
|
+
by_bone = {}
|
|
4459
|
+
for (bone, prop), components in channels.items():
|
|
4460
|
+
by_bone.setdefault(bone, {})[prop] = components
|
|
4461
|
+
out = []
|
|
4462
|
+
for name in sorted(by_bone):
|
|
4463
|
+
pbone = arm_obj.pose.bones.get(name)
|
|
4464
|
+
if pbone is None:
|
|
4465
|
+
continue
|
|
4466
|
+
rest = pbone.bone.matrix_local
|
|
4467
|
+
parent = pbone.bone.parent
|
|
4468
|
+
rest_relative = (parent.matrix_local.inverted() @ rest) if parent is not None else rest.copy()
|
|
4469
|
+
props = by_bone[name]
|
|
4470
|
+
mode = pbone.rotation_mode
|
|
4471
|
+
own = {
|
|
4472
|
+
"location": list(pbone.location),
|
|
4473
|
+
"rotation_quaternion": list(pbone.rotation_quaternion),
|
|
4474
|
+
"rotation_euler": list(pbone.rotation_euler),
|
|
4475
|
+
"rotation_axis_angle": list(pbone.rotation_axis_angle),
|
|
4476
|
+
"scale": list(pbone.scale),
|
|
4477
|
+
}
|
|
4478
|
+
position = array.array("f")
|
|
4479
|
+
quaternion = array.array("f")
|
|
4480
|
+
scale = array.array("f")
|
|
4481
|
+
last_q = None
|
|
4482
|
+
keys = set()
|
|
4483
|
+
for components in props.values():
|
|
4484
|
+
for fcurve in components.values():
|
|
4485
|
+
for point in fcurve.keyframe_points:
|
|
4486
|
+
keys.add(round(float(point.co[0]), 3))
|
|
4487
|
+
for frame in frames:
|
|
4488
|
+
values = {prop: list(value) for prop, value in own.items()}
|
|
4489
|
+
for prop, components in props.items():
|
|
4490
|
+
for index, fcurve in components.items():
|
|
4491
|
+
if index < len(values[prop]):
|
|
4492
|
+
values[prop][index] = fcurve.evaluate(frame)
|
|
4493
|
+
if mode == "QUATERNION":
|
|
4494
|
+
rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
|
|
4495
|
+
elif mode == "AXIS_ANGLE":
|
|
4496
|
+
angle, x, y, z = values["rotation_axis_angle"]
|
|
4497
|
+
axis = mathutils.Vector((x, y, z))
|
|
4498
|
+
rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 1.0, 0.0), angle)
|
|
4499
|
+
else:
|
|
4500
|
+
rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
|
|
4501
|
+
basis = mathutils.Matrix.LocRotScale(mathutils.Vector(values["location"]), rotation,
|
|
4502
|
+
mathutils.Vector(values["scale"]))
|
|
4503
|
+
loc, rot, size = (rest_relative @ basis).decompose()
|
|
4504
|
+
if last_q is not None and last_q.dot(rot) < 0:
|
|
4505
|
+
rot.negate()
|
|
4506
|
+
last_q = rot
|
|
4507
|
+
position.extend((loc.x, loc.y, loc.z))
|
|
4508
|
+
quaternion.extend((rot.x, rot.y, rot.z, rot.w))
|
|
4509
|
+
scale.extend((size.x, size.y, size.z))
|
|
4510
|
+
|
|
4511
|
+
out.append({"bone": name, "keys": sorted(keys), "position": _column(position, 3),
|
|
4512
|
+
"quaternion": _column(quaternion, 4), "scale": _column(scale, 3)})
|
|
4513
|
+
return out
|
|
4514
|
+
|
|
4515
|
+
|
|
4516
|
+
# ------------------------------------------------------------- the scene's movie
|
|
4517
|
+
|
|
4518
|
+
# AN OBJECT'S OWN TRANSFORM CHANNELS, as an action keys them on the object (not on a bone).
|
|
4519
|
+
_OBJECT_PATH = re.compile(r'^(location|rotation_quaternion|rotation_euler|rotation_axis_angle|scale)$')
|
|
4520
|
+
# A CAMERA'S KEYABLE PROJECTION, as an action keys it on the camera's data.
|
|
4521
|
+
_CAMERA_PROPS = ("lens", "sensor_width", "sensor_height", "ortho_scale", "shift_x", "shift_y",
|
|
4522
|
+
"clip_start", "clip_end")
|
|
4523
|
+
|
|
4524
|
+
|
|
4525
|
+
def _slot_handle(action, owner, prefix, slot):
|
|
4526
|
+
"""Use the assigned slot, then the owner identifier, then the first slot of the owner’s ID type."""
|
|
4527
|
+
if slot is not None:
|
|
4528
|
+
return slot.handle
|
|
4529
|
+
slots = list(action.slots)
|
|
4530
|
+
if not slots:
|
|
4531
|
+
return None
|
|
4532
|
+
kind = {"OB": "OBJECT", "CA": "CAMERA", "NT": "NODETREE", "KE": "KEY", "SC": "SCENE"}[prefix]
|
|
4533
|
+
named = next((one for one in slots if one.identifier == prefix + owner.name), None)
|
|
4534
|
+
if named is not None:
|
|
4535
|
+
return named.handle
|
|
4536
|
+
typed = [one for one in slots if one.target_id_type == kind]
|
|
4537
|
+
return typed[0].handle if typed else None
|
|
4538
|
+
|
|
4539
|
+
|
|
4540
|
+
def _object_constraints(obj):
|
|
4541
|
+
"""An object's constraint stack, in order: Damped Track and Track To carry what the presenter
|
|
4542
|
+
evaluates them from; every other type is named and played only in Blender."""
|
|
4543
|
+
stack = []
|
|
4544
|
+
for con in obj.constraints:
|
|
4545
|
+
target = getattr(con, "target", None)
|
|
4546
|
+
entry = {"name": con.name, "type": con.type, "enabled": bool(con.enabled),
|
|
4547
|
+
"influence": float(con.influence)}
|
|
4548
|
+
if target is not None:
|
|
4549
|
+
entry["target"] = target.name
|
|
4550
|
+
if con.type in ("DAMPED_TRACK", "TRACK_TO"):
|
|
4551
|
+
entry["trackAxis"] = con.track_axis
|
|
4552
|
+
if con.type == "TRACK_TO":
|
|
4553
|
+
entry["upAxis"] = con.up_axis
|
|
4554
|
+
entry["useTargetZ"] = bool(con.use_target_z)
|
|
4555
|
+
stack.append(entry)
|
|
4556
|
+
return stack
|
|
4557
|
+
|
|
4558
|
+
|
|
4559
|
+
def _column(samples, stride):
|
|
4560
|
+
"""A sampled column as base64 Float32: its first sample alone when it never changes."""
|
|
4561
|
+
if len(samples) > stride and all(abs(samples[i] - samples[i % stride]) < 1e-7 for i in range(len(samples))):
|
|
4562
|
+
samples = samples[:stride]
|
|
4563
|
+
return {"count": len(samples) // stride, "base64": base64.b64encode(samples.tobytes()).decode("ascii")}
|
|
4564
|
+
|
|
4565
|
+
|
|
4566
|
+
def _owner_action_at(adt, frame):
|
|
4567
|
+
"""Use the active action at the scene frame, otherwise the last unmuted strip containing it,
|
|
4568
|
+
or the first unmuted strip when none contains it. Clamp to that strip's range and apply its
|
|
4569
|
+
scale and repeat. Return no action when no unmuted action strip exists."""
|
|
4570
|
+
if adt.action is not None:
|
|
4571
|
+
return adt.action, frame
|
|
4572
|
+
best = None
|
|
4573
|
+
for track in adt.nla_tracks:
|
|
4574
|
+
if track.mute:
|
|
4575
|
+
continue
|
|
4576
|
+
for strip in track.strips:
|
|
4577
|
+
if strip.mute or strip.action is None:
|
|
4578
|
+
continue
|
|
4579
|
+
if strip.frame_start <= frame <= strip.frame_end or best is None:
|
|
4580
|
+
best = strip
|
|
4581
|
+
if best is None:
|
|
4582
|
+
return None, frame
|
|
4583
|
+
length = (best.action_frame_end - best.action_frame_start) or 1.0
|
|
4584
|
+
scale = abs(best.scale) or 1.0
|
|
4585
|
+
at = min(max(frame, best.frame_start), best.frame_end)
|
|
4586
|
+
into = ((at - best.frame_start) / scale) % length if at < best.frame_end else length
|
|
4587
|
+
return best.action, best.action_frame_start + into
|
|
4588
|
+
|
|
4589
|
+
|
|
4590
|
+
def _sample_object(obj, pre, delta, first, last, unsupported):
|
|
4591
|
+
"""AN OBJECT'S MOVIE AS THREE.JS PLAYS IT: its local transform (`pre @ basis`, the basis with its
|
|
4592
|
+
delta transform, `BKE_object_to_mat4`) at every scene frame from `first` to `last`, each channel
|
|
4593
|
+
evaluated by Blender (`FCurve.evaluate`) from the action it shows there. Columns as the clip
|
|
4594
|
+
door's: position, quaternion (x, y, z, w, one hemisphere), scale; and `hidden` (one per frame,
|
|
4595
|
+
1 hidden) when its render visibility is keyed."""
|
|
4596
|
+
adt = obj.animation_data
|
|
4597
|
+
if len([track for track in adt.nla_tracks if not track.mute]) > 1 or (adt.action is not None and len(adt.nla_tracks)):
|
|
4598
|
+
unsupported.append("its NLA layering (the active action, else the top strip, is played)")
|
|
4599
|
+
position = array.array("f")
|
|
4600
|
+
quaternion = array.array("f")
|
|
4601
|
+
scale = array.array("f")
|
|
4602
|
+
hidden = array.array("f")
|
|
4603
|
+
keyed_hidden = False
|
|
4604
|
+
last_q = None
|
|
4605
|
+
mode = obj.rotation_mode
|
|
4606
|
+
own = {"location": list(obj.location), "rotation_quaternion": list(obj.rotation_quaternion),
|
|
4607
|
+
"rotation_euler": list(obj.rotation_euler), "rotation_axis_angle": list(obj.rotation_axis_angle),
|
|
4608
|
+
"scale": list(obj.scale), "hide_render": [float(obj.hide_render)]}
|
|
4609
|
+
curves_of = {}
|
|
4610
|
+
for frame in range(first, last + 1):
|
|
4611
|
+
action, at = _owner_action_at(adt, frame)
|
|
4612
|
+
values = {prop: list(value) for prop, value in own.items()}
|
|
4613
|
+
if action is not None:
|
|
4614
|
+
curves = curves_of.get(action.name)
|
|
4615
|
+
if curves is None:
|
|
4616
|
+
handle = _slot_handle(action, obj, "OB", adt.action_slot if action == adt.action else None)
|
|
4617
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4618
|
+
for fcurve in curves:
|
|
4619
|
+
if fcurve.data_path in values and fcurve.array_index < len(values[fcurve.data_path]):
|
|
4620
|
+
values[fcurve.data_path][fcurve.array_index] = fcurve.evaluate(at)
|
|
4621
|
+
if fcurve.data_path == "hide_render":
|
|
4622
|
+
keyed_hidden = True
|
|
4623
|
+
if mode == "QUATERNION":
|
|
4624
|
+
rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
|
|
4625
|
+
elif mode == "AXIS_ANGLE":
|
|
4626
|
+
angle, x, y, z = values["rotation_axis_angle"]
|
|
4627
|
+
axis = mathutils.Vector((x, y, z))
|
|
4628
|
+
rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 0.0, 1.0), angle)
|
|
4629
|
+
else:
|
|
4630
|
+
rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
|
|
4631
|
+
rotation = delta @ rotation
|
|
4632
|
+
location = mathutils.Vector(values["location"]) + obj.delta_location
|
|
4633
|
+
size = mathutils.Vector([a * b for a, b in zip(values["scale"], obj.delta_scale)])
|
|
4634
|
+
loc, rot, sca = (pre @ mathutils.Matrix.LocRotScale(location, rotation, size)).decompose()
|
|
4635
|
+
if last_q is not None and last_q.dot(rot) < 0:
|
|
4636
|
+
rot.negate()
|
|
4637
|
+
last_q = rot
|
|
4638
|
+
position.extend((loc.x, loc.y, loc.z))
|
|
4639
|
+
quaternion.extend((rot.x, rot.y, rot.z, rot.w))
|
|
4640
|
+
scale.extend((sca.x, sca.y, sca.z))
|
|
4641
|
+
hidden.append(1.0 if values["hide_render"][0] >= 0.5 else 0.0)
|
|
4642
|
+
out = {"start": first, "end": last, "position": _column(position, 3), "quaternion": _column(quaternion, 4),
|
|
4643
|
+
"scale": _column(scale, 3)}
|
|
4644
|
+
if keyed_hidden:
|
|
4645
|
+
out["hidden"] = _column(hidden, 1)
|
|
4646
|
+
return out
|
|
4647
|
+
|
|
4648
|
+
|
|
4649
|
+
def _sample_camera(data, first, last):
|
|
4650
|
+
"""A CAMERA'S PROJECTION AS THE MOVIE HAS IT: each of `_CAMERA_PROPS` at every scene frame, by
|
|
4651
|
+
Blender's evaluation of the action its data shows there."""
|
|
4652
|
+
adt = data.animation_data
|
|
4653
|
+
columns = {prop: array.array("f") for prop in _CAMERA_PROPS}
|
|
4654
|
+
curves_of = {}
|
|
4655
|
+
for frame in range(first, last + 1):
|
|
4656
|
+
action, at = _owner_action_at(adt, frame)
|
|
4657
|
+
values = {prop: float(getattr(data, prop)) for prop in _CAMERA_PROPS}
|
|
4658
|
+
if action is not None:
|
|
4659
|
+
curves = curves_of.get(action.name)
|
|
4660
|
+
if curves is None:
|
|
4661
|
+
handle = _slot_handle(action, data, "CA", adt.action_slot if action == adt.action else None)
|
|
4662
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4663
|
+
for fcurve in curves:
|
|
4664
|
+
if fcurve.data_path in values:
|
|
4665
|
+
values[fcurve.data_path] = fcurve.evaluate(at)
|
|
4666
|
+
for prop in _CAMERA_PROPS:
|
|
4667
|
+
columns[prop].append(values[prop])
|
|
4668
|
+
return {"start": first, "end": last, **{prop: _column(column, 1) for prop, column in columns.items()}}
|
|
4669
|
+
|
|
4670
|
+
|
|
4671
|
+
_MATERIAL_INPUTS = {"Base Color": 4, "Emission Color": 4, "Emission Strength": 1, "Alpha": 1}
|
|
4672
|
+
|
|
4673
|
+
|
|
4674
|
+
def _sample_material(material, first, last):
|
|
4675
|
+
"""Sample a material’s keyed Principled Base Color, Emission and Alpha through the action its
|
|
4676
|
+
node tree shows at each scene frame. Return only keyed inputs, or None when none are keyed."""
|
|
4677
|
+
tree = material.node_tree
|
|
4678
|
+
adt = tree.animation_data if tree is not None else None
|
|
4679
|
+
if adt is None:
|
|
4680
|
+
return None
|
|
4681
|
+
bsdf = next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None)
|
|
4682
|
+
if bsdf is None:
|
|
4683
|
+
return None
|
|
4684
|
+
index = {}
|
|
4685
|
+
for i, socket in enumerate(bsdf.inputs):
|
|
4686
|
+
index.setdefault(socket.name, i)
|
|
4687
|
+
paths = {}
|
|
4688
|
+
own = {}
|
|
4689
|
+
for name, size in _MATERIAL_INPUTS.items():
|
|
4690
|
+
paths['nodes["%s"].inputs[%d].default_value' % (bsdf.name, index[name])] = name
|
|
4691
|
+
value = bsdf.inputs[index[name]].default_value
|
|
4692
|
+
own[name] = [float(v) for v in value] if size > 1 else [float(value)]
|
|
4693
|
+
keyed = set()
|
|
4694
|
+
color, emissive, opacity = array.array("f"), array.array("f"), array.array("f")
|
|
4695
|
+
curves_of = {}
|
|
4696
|
+
for frame in range(first, last + 1):
|
|
4697
|
+
action, at = _owner_action_at(adt, frame)
|
|
4698
|
+
values = {name: list(value) for name, value in own.items()}
|
|
4699
|
+
if action is not None:
|
|
4700
|
+
curves = curves_of.get(action.name)
|
|
4701
|
+
if curves is None:
|
|
4702
|
+
handle = _slot_handle(action, tree, "NT", adt.action_slot if action == adt.action else None)
|
|
4703
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4704
|
+
for fcurve in curves:
|
|
4705
|
+
name = paths.get(fcurve.data_path)
|
|
4706
|
+
if name is not None and fcurve.array_index < len(values[name]):
|
|
4707
|
+
values[name][fcurve.array_index] = fcurve.evaluate(at)
|
|
4708
|
+
keyed.add(name)
|
|
4709
|
+
base = values["Base Color"]
|
|
4710
|
+
color.extend(base[:3])
|
|
4711
|
+
glow = values["Emission Color"]
|
|
4712
|
+
strength = values["Emission Strength"][0]
|
|
4713
|
+
emissive.extend((glow[0] * strength, glow[1] * strength, glow[2] * strength))
|
|
4714
|
+
opacity.append(values["Alpha"][0])
|
|
4715
|
+
if not keyed:
|
|
4716
|
+
return None
|
|
4717
|
+
out = {"start": first, "end": last}
|
|
4718
|
+
if "Base Color" in keyed:
|
|
4719
|
+
out["color"] = _column(color, 3)
|
|
4720
|
+
if keyed & {"Emission Color", "Emission Strength"}:
|
|
4721
|
+
out["emissive"] = _column(emissive, 3)
|
|
4722
|
+
if "Alpha" in keyed:
|
|
4723
|
+
out["opacity"] = _column(opacity, 1)
|
|
4724
|
+
return out
|
|
4725
|
+
|
|
4726
|
+
|
|
4727
|
+
def _sample_shapes(obj, first, last, depsgraph):
|
|
4728
|
+
"""Sample each keyed relative shape key’s vertex offsets and values at every scene frame,
|
|
4729
|
+
including the presented geometry’s current value. mapped is False when modifiers change vertex
|
|
4730
|
+
correspondence. Return None when no key is keyed."""
|
|
4731
|
+
mesh = obj.data
|
|
4732
|
+
keys = mesh.shape_keys if mesh is not None else None
|
|
4733
|
+
if keys is None or not keys.use_relative or len(keys.key_blocks) < 2:
|
|
4734
|
+
return None
|
|
4735
|
+
adt = keys.animation_data
|
|
4736
|
+
if adt is None:
|
|
4737
|
+
return None
|
|
4738
|
+
blocks = list(keys.key_blocks)[1:]
|
|
4739
|
+
path_of = {'key_blocks["%s"].value' % block.name: block.name for block in blocks}
|
|
4740
|
+
values = {block.name: array.array("f") for block in blocks}
|
|
4741
|
+
keyed = set()
|
|
4742
|
+
curves_of = {}
|
|
4743
|
+
for frame in range(first, last + 1):
|
|
4744
|
+
action, at = _owner_action_at(adt, frame)
|
|
4745
|
+
now = {block.name: float(block.value) for block in blocks}
|
|
4746
|
+
if action is not None:
|
|
4747
|
+
curves = curves_of.get(action.name)
|
|
4748
|
+
if curves is None:
|
|
4749
|
+
handle = _slot_handle(action, keys, "KE", adt.action_slot if action == adt.action else None)
|
|
4750
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4751
|
+
for fcurve in curves:
|
|
4752
|
+
name = path_of.get(fcurve.data_path)
|
|
4753
|
+
if name is not None:
|
|
4754
|
+
now[name] = fcurve.evaluate(at)
|
|
4755
|
+
keyed.add(name)
|
|
4756
|
+
for name, value in now.items():
|
|
4757
|
+
values[name].append(value)
|
|
4758
|
+
if not keyed:
|
|
4759
|
+
return None
|
|
4760
|
+
count = len(obj.data.vertices)
|
|
4761
|
+
try:
|
|
4762
|
+
mapped = len(obj.evaluated_get(depsgraph).data.vertices) == count
|
|
4763
|
+
except Exception:
|
|
4764
|
+
mapped = False
|
|
4765
|
+
out = []
|
|
4766
|
+
for block in blocks:
|
|
4767
|
+
if block.name not in keyed:
|
|
4768
|
+
continue
|
|
4769
|
+
own = array.array("f", [0.0]) * (3 * count)
|
|
4770
|
+
base = array.array("f", [0.0]) * (3 * count)
|
|
4771
|
+
block.data.foreach_get("co", own)
|
|
4772
|
+
(block.relative_key or keys.reference_key).data.foreach_get("co", base)
|
|
4773
|
+
delta = array.array("f", (a - b for a, b in zip(own, base)))
|
|
4774
|
+
out.append({"name": block.name, "delta": {"count": count, "base64": base64.b64encode(delta.tobytes()).decode("ascii")},
|
|
4775
|
+
"values": _column(values[block.name], 1), "current": float(block.value)})
|
|
4776
|
+
return {"object": obj.name, "start": first, "end": last, "vertices": count, "mapped": mapped, "keys": out}
|
|
4777
|
+
|
|
4778
|
+
|
|
4779
|
+
def rna_scene_movie():
|
|
4780
|
+
"""Scene animation for Timeline preview and export: range, rate, camera, bound
|
|
4781
|
+
markers and sampled object, camera, material and shape-key columns. Object
|
|
4782
|
+
records retain parent-space transforms and constraints. Cameras named by
|
|
4783
|
+
the scene or a marker are included whether animated or not. Sampling leaves
|
|
4784
|
+
the scene frame unchanged."""
|
|
4785
|
+
scene = bpy.context.scene
|
|
4786
|
+
view_layer = bpy.context.view_layer
|
|
4787
|
+
fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
|
|
4788
|
+
markers = []
|
|
4789
|
+
for marker in scene.timeline_markers:
|
|
4790
|
+
markers.append({"name": marker.name, "frame": int(marker.frame),
|
|
4791
|
+
"camera": marker.camera.name if marker.camera is not None else None})
|
|
4792
|
+
wanted = {marker["camera"] for marker in markers if marker["camera"]}
|
|
4793
|
+
if scene.camera is not None:
|
|
4794
|
+
wanted.add(scene.camera.name)
|
|
4795
|
+
|
|
4796
|
+
def transform(path):
|
|
4797
|
+
return path if _OBJECT_PATH.match(path) or path == "hide_render" else None
|
|
4798
|
+
|
|
4799
|
+
def projection(path):
|
|
4800
|
+
return path if path in _CAMERA_PROPS else None
|
|
4801
|
+
|
|
4802
|
+
objects = []
|
|
4803
|
+
animated = {}
|
|
4804
|
+
for obj in view_layer.objects:
|
|
4805
|
+
own = _owner_animated(obj, transform, animated)
|
|
4806
|
+
constraints = _object_constraints(obj)
|
|
4807
|
+
camera = None
|
|
4808
|
+
if obj.type == "CAMERA" and obj.data is not None:
|
|
4809
|
+
data = obj.data
|
|
4810
|
+
camera = {
|
|
4811
|
+
"type": data.type, "lens": float(data.lens), "sensor_width": float(data.sensor_width),
|
|
4812
|
+
"sensor_height": float(data.sensor_height), "sensor_fit": data.sensor_fit,
|
|
4813
|
+
"ortho_scale": float(data.ortho_scale), "clip_start": float(data.clip_start),
|
|
4814
|
+
"clip_end": float(data.clip_end), "shift_x": float(data.shift_x), "shift_y": float(data.shift_y),
|
|
4815
|
+
"renderHidden": bool(obj.hide_render),
|
|
4816
|
+
**({"keyed": True} if _owner_animated(data, projection, animated) else {}),
|
|
4817
|
+
}
|
|
4818
|
+
if not own and (camera is None or (obj.name not in wanted and "keyed" not in camera)):
|
|
4819
|
+
continue
|
|
4820
|
+
unsupported = []
|
|
4821
|
+
parent = obj.parent
|
|
4822
|
+
if parent is None:
|
|
4823
|
+
pre = mathutils.Matrix.Identity(4)
|
|
4824
|
+
elif obj.parent_type == "OBJECT":
|
|
4825
|
+
pre = obj.matrix_parent_inverse
|
|
4826
|
+
else:
|
|
4827
|
+
# Parented to a bone, a vertex or a curve: the offset it has now is held.
|
|
4828
|
+
pre = (parent.matrix_world.inverted_safe() @ obj.matrix_world) @ obj.matrix_basis.inverted_safe()
|
|
4829
|
+
unsupported.append("its %s parenting (held where it is now)" % obj.parent_type.lower())
|
|
4830
|
+
mode = obj.rotation_mode
|
|
4831
|
+
if mode == "QUATERNION":
|
|
4832
|
+
delta = obj.delta_rotation_quaternion.normalized()
|
|
4833
|
+
elif mode == "AXIS_ANGLE":
|
|
4834
|
+
delta = mathutils.Quaternion((1.0, 0.0, 0.0, 0.0))
|
|
4835
|
+
else:
|
|
4836
|
+
delta = mathutils.Euler(obj.delta_rotation_euler, mode).to_quaternion()
|
|
4837
|
+
adt = obj.animation_data
|
|
4838
|
+
if adt is not None:
|
|
4839
|
+
for path in sorted({fc.data_path for fc in adt.drivers if _OBJECT_PATH.match(fc.data_path)}):
|
|
4840
|
+
unsupported.append("the driver on its %s" % path)
|
|
4841
|
+
for con in constraints:
|
|
4842
|
+
if con["enabled"] and con["type"] not in ("DAMPED_TRACK", "TRACK_TO"):
|
|
4843
|
+
unsupported.append("its %s constraint %r" % (con["type"].replace("_", " ").title(), con["name"]))
|
|
4844
|
+
sampled = _sample_object(obj, pre, delta, int(scene.frame_start), int(scene.frame_end),
|
|
4845
|
+
unsupported) if own else None
|
|
4846
|
+
if camera is not None and camera.pop("keyed", False):
|
|
4847
|
+
camera["sampled"] = _sample_camera(obj.data, int(scene.frame_start), int(scene.frame_end))
|
|
4848
|
+
objects.append({
|
|
4849
|
+
"name": obj.name,
|
|
4850
|
+
"type": obj.type,
|
|
4851
|
+
"parent": parent.name if parent is not None else None,
|
|
4852
|
+
**({"sampled": sampled} if sampled is not None else {}),
|
|
4853
|
+
"rotationMode": mode,
|
|
4854
|
+
"channels": {
|
|
4855
|
+
"location": [float(v) for v in obj.location],
|
|
4856
|
+
"rotation_quaternion": [float(v) for v in obj.rotation_quaternion],
|
|
4857
|
+
"rotation_euler": [float(v) for v in obj.rotation_euler],
|
|
4858
|
+
"rotation_axis_angle": [float(v) for v in obj.rotation_axis_angle],
|
|
4859
|
+
"scale": [float(v) for v in obj.scale],
|
|
4860
|
+
},
|
|
4861
|
+
"delta": {"location": [float(v) for v in obj.delta_location], "rotation": [float(v) for v in delta],
|
|
4862
|
+
"scale": [float(v) for v in obj.delta_scale]},
|
|
4863
|
+
"pre": _matrix_rows(pre),
|
|
4864
|
+
"constraints": constraints,
|
|
4865
|
+
**({"camera": camera} if camera is not None else {}),
|
|
4866
|
+
**({"unsupported": unsupported} if unsupported else {}),
|
|
4867
|
+
})
|
|
4868
|
+
# Sample keyed material inputs and shape keys.
|
|
4869
|
+
first, last = int(scene.frame_start), int(scene.frame_end)
|
|
4870
|
+
materials = []
|
|
4871
|
+
seen = set()
|
|
4872
|
+
for obj in view_layer.objects:
|
|
4873
|
+
for slot in obj.material_slots:
|
|
4874
|
+
material = slot.material
|
|
4875
|
+
if material is None or material.name in seen:
|
|
4876
|
+
continue
|
|
4877
|
+
seen.add(material.name)
|
|
4878
|
+
sampled = _sample_material(material, first, last)
|
|
4879
|
+
if sampled is not None:
|
|
4880
|
+
materials.append({"name": material.name, **sampled})
|
|
4881
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
4882
|
+
shapes = [one for one in (_sample_shapes(obj, first, last, depsgraph) for obj in view_layer.objects if obj.type == "MESH") if one]
|
|
4883
|
+
return {
|
|
4884
|
+
"materials": materials,
|
|
4885
|
+
"shapes": shapes,
|
|
4886
|
+
"scene": {"name": scene.name, "start": int(scene.frame_start), "end": int(scene.frame_end), "fps": fps,
|
|
4887
|
+
"current": int(scene.frame_current),
|
|
4888
|
+
"camera": scene.camera.name if scene.camera is not None else None},
|
|
4889
|
+
"markers": sorted(markers, key=lambda marker: marker["frame"]),
|
|
4890
|
+
"objects": objects,
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
|
|
4894
|
+
def rna_sound_scene(first, last):
|
|
4895
|
+
"""The scene's sound for the page to mix (docs/adr/movie-sound-and-react-frames.md,
|
|
4896
|
+
decision 2). Native audio mixing is unavailable in this Blender. Describe the movie's frames
|
|
4897
|
+
`first` to `last` for `@volter/editor-blender` to render in an OfflineAudioContext:
|
|
4898
|
+
|
|
4899
|
+
- the sequencer's sound strips: each one's file (or, packed, a copy written beside the
|
|
4900
|
+
session), where its sound starts and where the strip is cut, its speed, whether it is muted
|
|
4901
|
+
or mono, and its volume and pan at every frame, read through the scene's F-Curves (a fade
|
|
4902
|
+
is keys on volume);
|
|
4903
|
+
- the speakers: each one's file, the frames its NLA sound strips start on, its volume and
|
|
4904
|
+
distance settings, and its place and facing at every frame, with the scene camera's (the
|
|
4905
|
+
listener). Those are evaluated, so the frame moves and is restored.
|
|
4906
|
+
|
|
4907
|
+
The scene's own volume and distance model come too. READ ONLY: no history, save or present."""
|
|
4908
|
+
scene = bpy.context.scene
|
|
4909
|
+
fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
|
|
4910
|
+
count = last - first + 1
|
|
4911
|
+
|
|
4912
|
+
def sound_file(sound):
|
|
4913
|
+
if sound is None:
|
|
4914
|
+
return None
|
|
4915
|
+
if sound.packed_file is not None:
|
|
4916
|
+
path = os.path.join(ROOT, "sound-%s" % hashlib.sha256(sound.name.encode("utf-8")).hexdigest()[:16])
|
|
4917
|
+
with open(path, "wb") as out:
|
|
4918
|
+
out.write(sound.packed_file.data)
|
|
4919
|
+
return {"packed": path}
|
|
4920
|
+
# normalized: a path saved relative to the .blend ("//../../sounds/x.wav") keeps its ".." segments otherwise
|
|
4921
|
+
return {"path": os.path.normpath(bpy.path.abspath(sound.filepath, library=sound.library))}
|
|
4922
|
+
|
|
4923
|
+
# A strip's keyed volume and pan are the scene's F-Curves on the strip's own path.
|
|
4924
|
+
adt = scene.animation_data
|
|
4925
|
+
curves = {}
|
|
4926
|
+
if adt is not None and adt.action is not None:
|
|
4927
|
+
handle = _slot_handle(adt.action, scene, "SC", adt.action_slot)
|
|
4928
|
+
for fcurve in _action_channelbag(adt.action, handle):
|
|
4929
|
+
curves[(fcurve.data_path, fcurve.array_index)] = fcurve
|
|
4930
|
+
|
|
4931
|
+
def sampled(strip, prop):
|
|
4932
|
+
constant = float(getattr(strip, prop))
|
|
4933
|
+
fcurve = next((curves[(path, 0)] for path in (
|
|
4934
|
+
'sequence_editor.strips_all["%s"].%s' % (strip.name, prop),
|
|
4935
|
+
'sequence_editor.sequences_all["%s"].%s' % (strip.name, prop)) if (path, 0) in curves), None)
|
|
4936
|
+
values = array.array("f", (fcurve.evaluate(frame) if fcurve is not None else constant
|
|
4937
|
+
for frame in range(first, last + 1)))
|
|
4938
|
+
return _column(values, 1)
|
|
4939
|
+
|
|
4940
|
+
editor = scene.sequence_editor
|
|
4941
|
+
strips = []
|
|
4942
|
+
if editor is not None:
|
|
4943
|
+
channels = editor.channels
|
|
4944
|
+
for strip in editor.strips_all:
|
|
4945
|
+
if strip.type != "SOUND":
|
|
4946
|
+
continue
|
|
4947
|
+
channel = channels[strip.channel] if 0 <= strip.channel < len(channels) else None
|
|
4948
|
+
strips.append({
|
|
4949
|
+
"name": strip.name,
|
|
4950
|
+
"file": sound_file(strip.sound),
|
|
4951
|
+
"mono": bool(strip.sound is not None and strip.sound.use_mono),
|
|
4952
|
+
"muted": bool(strip.mute or (channel is not None and channel.mute)),
|
|
4953
|
+
"start": float(strip.frame_start),
|
|
4954
|
+
"finalStart": float(strip.frame_final_start),
|
|
4955
|
+
"finalEnd": float(strip.frame_final_end),
|
|
4956
|
+
"speed": float(getattr(strip, "speed_factor", 1.0)),
|
|
4957
|
+
"volume": sampled(strip, "volume"),
|
|
4958
|
+
"pan": sampled(strip, "pan"),
|
|
4959
|
+
})
|
|
4960
|
+
|
|
4961
|
+
speakers = [obj for obj in scene.objects if obj.type == "SPEAKER" and obj.data is not None]
|
|
4962
|
+
described = []
|
|
4963
|
+
listener = None
|
|
4964
|
+
if speakers:
|
|
4965
|
+
tracks = {obj.name: [] for obj in speakers}
|
|
4966
|
+
for obj in speakers:
|
|
4967
|
+
for track in (obj.animation_data.nla_tracks if obj.animation_data is not None else ()):
|
|
4968
|
+
if track.mute:
|
|
4969
|
+
continue
|
|
4970
|
+
for strip in track.strips:
|
|
4971
|
+
if strip.type == "SOUND" and not strip.mute:
|
|
4972
|
+
tracks[obj.name].append(float(strip.frame_start))
|
|
4973
|
+
places = {obj.name: array.array("f") for obj in speakers}
|
|
4974
|
+
facings = {obj.name: array.array("f") for obj in speakers}
|
|
4975
|
+
volumes = {obj.name: array.array("f") for obj in speakers}
|
|
4976
|
+
heard = {"position": array.array("f"), "forward": array.array("f"), "up": array.array("f")}
|
|
4977
|
+
kept = scene.frame_current
|
|
4978
|
+
try:
|
|
4979
|
+
for frame in range(first, last + 1):
|
|
4980
|
+
scene.frame_set(frame)
|
|
4981
|
+
for obj in speakers:
|
|
4982
|
+
matrix = obj.matrix_world
|
|
4983
|
+
places[obj.name].extend(matrix.translation)
|
|
4984
|
+
facings[obj.name].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
|
|
4985
|
+
volumes[obj.name].append(float(obj.data.volume))
|
|
4986
|
+
camera = scene.camera
|
|
4987
|
+
matrix = camera.matrix_world if camera is not None else mathutils.Matrix.Identity(4)
|
|
4988
|
+
heard["position"].extend(matrix.translation)
|
|
4989
|
+
heard["forward"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
|
|
4990
|
+
heard["up"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 1.0, 0.0))).normalized())
|
|
4991
|
+
finally:
|
|
4992
|
+
scene.frame_set(kept)
|
|
4993
|
+
for obj in speakers:
|
|
4994
|
+
data = obj.data
|
|
4995
|
+
described.append({
|
|
4996
|
+
"name": obj.name,
|
|
4997
|
+
"file": sound_file(data.sound),
|
|
4998
|
+
"muted": bool(data.muted),
|
|
4999
|
+
"starts": tracks[obj.name],
|
|
5000
|
+
"volume": _column(volumes[obj.name], 1),
|
|
5001
|
+
"volumeMin": float(data.volume_min),
|
|
5002
|
+
"volumeMax": float(data.volume_max),
|
|
5003
|
+
"attenuation": float(data.attenuation),
|
|
5004
|
+
"distanceReference": float(data.distance_reference),
|
|
5005
|
+
"distanceMax": float(data.distance_max),
|
|
5006
|
+
"coneInner": float(data.cone_angle_inner),
|
|
5007
|
+
"coneOuter": float(data.cone_angle_outer),
|
|
5008
|
+
"coneOuterVolume": float(data.cone_volume_outer),
|
|
5009
|
+
"position": _column(places[obj.name], 3),
|
|
5010
|
+
"facing": _column(facings[obj.name], 3),
|
|
5011
|
+
})
|
|
5012
|
+
listener = {key: _column(column, 3) for key, column in heard.items()}
|
|
5013
|
+
|
|
5014
|
+
return {
|
|
5015
|
+
"fps": fps, "first": first, "last": last, "count": count,
|
|
5016
|
+
"volume": float(scene.audio_volume),
|
|
5017
|
+
# `use_audio` is the negation of `AUDIO_MUTE`: true, the default, plays the sequencer's sound
|
|
5018
|
+
"muted": not bool(scene.use_audio),
|
|
5019
|
+
"distanceModel": scene.audio_distance_model,
|
|
5020
|
+
"strips": strips,
|
|
5021
|
+
"speakers": described,
|
|
5022
|
+
"listener": listener,
|
|
5023
|
+
}
|
|
5024
|
+
|
|
5025
|
+
|
|
5026
|
+
def _owner_animated(owner, accept, cache):
|
|
5027
|
+
"""Whether an owner's own properties (`accept` names them) are keyed by an action it shows: its
|
|
5028
|
+
active action or an unmuted NLA strip's. One look per action and owner kind, kept in `cache`:
|
|
5029
|
+
a level's armatures share their few actions, and their curves are bones', not the object's."""
|
|
5030
|
+
adt = owner.animation_data
|
|
5031
|
+
if adt is None:
|
|
5032
|
+
return False
|
|
5033
|
+
actions = [adt.action] if adt.action is not None else []
|
|
5034
|
+
for track in adt.nla_tracks:
|
|
5035
|
+
if not track.mute:
|
|
5036
|
+
actions.extend(strip.action for strip in track.strips if strip.action is not None and not strip.mute)
|
|
5037
|
+
for action in actions:
|
|
5038
|
+
key = (action.name, accept)
|
|
5039
|
+
if key not in cache:
|
|
5040
|
+
# EVERY SLOT'S CURVES: a camera's object and its lens share one action through two slots.
|
|
5041
|
+
curves = []
|
|
5042
|
+
for layer in action.layers:
|
|
5043
|
+
for strip in layer.strips:
|
|
5044
|
+
for bag in strip.channelbags:
|
|
5045
|
+
curves.extend(bag.fcurves)
|
|
5046
|
+
cache[key] = any(accept(fcurve.data_path) for fcurve in curves)
|
|
5047
|
+
if cache[key]:
|
|
5048
|
+
return True
|
|
5049
|
+
return False
|
|
5050
|
+
|
|
5051
|
+
|
|
5256
5052
|
# `Keyframe.type`'s significance order, mirrored from the `KEYFRAME_STATE`
|
|
5257
5053
|
# merge in `keyframes_keylist.cc` -- a column drawn from several curves takes
|
|
5258
5054
|
# the most significant type any of them carries.
|
|
@@ -5271,10 +5067,10 @@ def _action_key_range(action, first, last):
|
|
|
5271
5067
|
repeats into, any other type opens both sides. Mute is not read. None is an open side."""
|
|
5272
5068
|
low, high = None, None
|
|
5273
5069
|
opened_low = opened_high = False
|
|
5274
|
-
curves =
|
|
5275
|
-
for layer in
|
|
5276
|
-
for strip in
|
|
5277
|
-
for bag in
|
|
5070
|
+
curves = []
|
|
5071
|
+
for layer in action.layers:
|
|
5072
|
+
for strip in layer.strips:
|
|
5073
|
+
for bag in strip.channelbags:
|
|
5278
5074
|
curves.extend(bag.fcurves)
|
|
5279
5075
|
# A curve's keys and its last modifier count independently: a keyless curve carrying a
|
|
5280
5076
|
# Generator or Noise still opens the range, as in Blender.
|
|
@@ -5334,8 +5130,8 @@ def _curve_body(fcurve):
|
|
|
5334
5130
|
if modifier.mute:
|
|
5335
5131
|
continue
|
|
5336
5132
|
if modifier.type == "CYCLES" and not modifier.use_restricted_range and not modifier.use_influence:
|
|
5337
|
-
cycles = [_CYCLE_MODE
|
|
5338
|
-
_CYCLE_MODE
|
|
5133
|
+
cycles = [_CYCLE_MODE[modifier.mode_before], int(modifier.cycles_before),
|
|
5134
|
+
_CYCLE_MODE[modifier.mode_after], int(modifier.cycles_after)]
|
|
5339
5135
|
else:
|
|
5340
5136
|
unsupported.append("%s modifier" % modifier.type.lower())
|
|
5341
5137
|
out = {
|
|
@@ -5449,9 +5245,7 @@ def rna_outliner(selected=None):
|
|
|
5449
5245
|
placed.add(obj.name)
|
|
5450
5246
|
ordered.append(obj)
|
|
5451
5247
|
|
|
5452
|
-
#
|
|
5453
|
-
# a walk of the whole ID list, and one per object made this loop quadratic
|
|
5454
|
-
# (43 ms of a 1,490-object read).
|
|
5248
|
+
# Reuse each placed object to avoid a linear ID-name lookup per row.
|
|
5455
5249
|
for entries in list(placements.values()):
|
|
5456
5250
|
order(entries[0][0]["place"])
|
|
5457
5251
|
# A move takes the place out of its collection's list; the lists are
|
|
@@ -5486,16 +5280,9 @@ def rna_outliner(selected=None):
|
|
|
5486
5280
|
for row in collections:
|
|
5487
5281
|
row["children"] = [child for child in row["children"] if id(child) not in moved]
|
|
5488
5282
|
|
|
5489
|
-
#
|
|
5490
|
-
#
|
|
5491
|
-
#
|
|
5492
|
-
# its row and every row above it however far down a long list it sits.
|
|
5493
|
-
# BOUNDED LIKE THE PAGE ITSELF: the active object always, then at most one
|
|
5494
|
-
# page of the selection, so selecting everything cannot turn one read back
|
|
5495
|
-
# into the whole tree the page exists to keep out (Canyon Comet, 1,493
|
|
5496
|
-
# objects selected: 1.77 MB a read uncapped, 142 KB capped).
|
|
5497
|
-
# The caller's choice comes before the rest of the engine's selection: a click
|
|
5498
|
-
# onto a selection already larger than a page keeps the object just clicked.
|
|
5283
|
+
# Retain at most one page of active/selected rows and their ancestors.
|
|
5284
|
+
# Prioritize the active object, then the caller's selection, then the
|
|
5285
|
+
# engine's remaining selection so a clicked object keeps its address.
|
|
5499
5286
|
wanted = [active] if active is not None else []
|
|
5500
5287
|
wanted += sorted(chosen)
|
|
5501
5288
|
wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
|
|
@@ -5508,7 +5295,7 @@ def rna_outliner(selected=None):
|
|
|
5508
5295
|
|
|
5509
5296
|
def hold(row):
|
|
5510
5297
|
inside = "place" in row and row["name"] in kept
|
|
5511
|
-
for child in row
|
|
5298
|
+
for child in row["children"]:
|
|
5512
5299
|
inside = hold(child) or inside
|
|
5513
5300
|
if inside:
|
|
5514
5301
|
holding.add(id(row))
|
|
@@ -5542,7 +5329,7 @@ def rna_outliner(selected=None):
|
|
|
5542
5329
|
# Cap the hierarchy's visible child lists, not the collection's flat input.
|
|
5543
5330
|
# Every parent participates in folding even when its child list needs a page.
|
|
5544
5331
|
def page_tree(row):
|
|
5545
|
-
members = row
|
|
5332
|
+
members = row["children"]
|
|
5546
5333
|
children, more = _outliner_page(members)
|
|
5547
5334
|
beyond = [child for child in members[len(children):] if id(child) in holding]
|
|
5548
5335
|
children = [kept_row(child) for child in children + beyond]
|
|
@@ -5559,8 +5346,7 @@ def rna_outliner(selected=None):
|
|
|
5559
5346
|
# page left behind adds only what it would have claimed that a later row
|
|
5560
5347
|
# can share -- its own address, its materials, its modifiers' linked
|
|
5561
5348
|
# datablocks, its instanced collection -- and one more expansion of itself
|
|
5562
|
-
# and of its data.
|
|
5563
|
-
# long way, every one in the shared count.
|
|
5349
|
+
# and of its data.
|
|
5564
5350
|
addresses = {}
|
|
5565
5351
|
|
|
5566
5352
|
def address(datablock):
|
|
@@ -5580,33 +5366,24 @@ def rna_outliner(selected=None):
|
|
|
5580
5366
|
object_key = obj.as_pointer()
|
|
5581
5367
|
object_path = address(obj)
|
|
5582
5368
|
data = obj.data
|
|
5583
|
-
|
|
5584
|
-
scopes = [] if exact else [(object_path + ".", None, expanded.get(object_key, 0))]
|
|
5369
|
+
scopes = [(object_path + ".", None, expanded.get(object_key, 0))]
|
|
5585
5370
|
data_key = bones = None
|
|
5586
5371
|
if data is not None:
|
|
5587
5372
|
data_key = data.as_pointer()
|
|
5588
5373
|
data_path = address(data)
|
|
5589
5374
|
keys = getattr(data, "shape_keys", None)
|
|
5590
5375
|
keys_path = address(keys) if keys is not None else None
|
|
5591
|
-
exact = exact or data_path is None or (keys is not None and keys_path is None)
|
|
5592
5376
|
bones = isinstance(data, bpy.types.Armature) and obj.mode != "POSE"
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
scopes.append((keys_path + ".", keys_path, before))
|
|
5599
|
-
if exact:
|
|
5600
|
-
scopes = []
|
|
5377
|
+
before = data_expanded.get(data_key, 0)
|
|
5378
|
+
scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
|
|
5379
|
+
(data_path + ".", data_path, before)]
|
|
5380
|
+
if keys is not None:
|
|
5381
|
+
scopes.append((keys_path + ".", keys_path, before))
|
|
5601
5382
|
row = spot.get("row")
|
|
5602
|
-
if row is not None
|
|
5603
|
-
# Built rows, or the long way for data the scopes cannot name.
|
|
5383
|
+
if row is not None:
|
|
5604
5384
|
scope = _OutlinerScope(seen, scopes)
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
else:
|
|
5608
|
-
for claimed in spot["claims"]:
|
|
5609
|
-
claimed["id"] = _outliner_unique(claimed["path"], scope)
|
|
5385
|
+
for claimed in spot["claims"]:
|
|
5386
|
+
claimed["id"] = _outliner_unique(claimed["path"], scope)
|
|
5610
5387
|
else:
|
|
5611
5388
|
bump(object_path)
|
|
5612
5389
|
if data is not None:
|
|
@@ -5621,9 +5398,7 @@ def rna_outliner(selected=None):
|
|
|
5621
5398
|
linked = getattr(md, member, None)
|
|
5622
5399
|
if linked is None:
|
|
5623
5400
|
continue
|
|
5624
|
-
|
|
5625
|
-
if linked_path:
|
|
5626
|
-
bump(linked_path)
|
|
5401
|
+
bump(address(linked))
|
|
5627
5402
|
break
|
|
5628
5403
|
if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
|
|
5629
5404
|
bump(address(obj.instance_collection))
|
|
@@ -5703,32 +5478,55 @@ def outliner_set(path, column, value):
|
|
|
5703
5478
|
% (column, target.bl_rna.identifier, path))
|
|
5704
5479
|
|
|
5705
5480
|
|
|
5706
|
-
#
|
|
5707
|
-
|
|
5708
|
-
#
|
|
5709
|
-
#
|
|
5710
|
-
|
|
5481
|
+
# Record requests that can remove objects so every disappearance has a named cause.
|
|
5482
|
+
_WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step", "gltf-export", "run-script")
|
|
5483
|
+
# The requests that may change an action's keys (`Session._kept_action_revisions`): every edit, not an
|
|
5484
|
+
# export (which puts everything back) and not a read.
|
|
5485
|
+
_ACTION_EDIT_OPS = ("execute", "rna-set", "outliner-set", "history-step")
|
|
5486
|
+
_ACTION_EDITS = {"count": 0}
|
|
5711
5487
|
|
|
5712
5488
|
|
|
5713
5489
|
def dispatch(request):
|
|
5714
5490
|
op = request.get("op")
|
|
5491
|
+
if op in _ACTION_EDIT_OPS:
|
|
5492
|
+
_ACTION_EDITS["count"] += 1
|
|
5715
5493
|
if op not in _WATCHED_OPS:
|
|
5716
5494
|
return _dispatch_request(request)
|
|
5717
|
-
#
|
|
5718
|
-
# Names alone falsely reported an ordinary rename as a deleted object.
|
|
5495
|
+
# Compare stable session_uids so renames do not count as object deletions.
|
|
5719
5496
|
before = {o.session_uid: o.name for o in bpy.data.objects}
|
|
5497
|
+
# A request that loads a file (`read_homefile`, `open_mainfile`, then perhaps `save_as_mainfile`, to the same path
|
|
5498
|
+
# or another) removes nothing from the document it replaced: its objects are that file's, as saved. What it can
|
|
5499
|
+
# lose is the document's unsaved changes, said where there were any.
|
|
5500
|
+
before_path = bpy.data.filepath
|
|
5501
|
+
# (the session's own record of changes not yet saved, `mark_changed`'s: Blender's `is_dirty` is instrumentation)
|
|
5502
|
+
before_dirty = SESSION.save_due
|
|
5503
|
+
before_loads = _LOADS["count"]
|
|
5720
5504
|
try:
|
|
5721
5505
|
return _dispatch_request(request)
|
|
5722
5506
|
finally:
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
if
|
|
5726
|
-
cause
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5507
|
+
cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
|
|
5508
|
+
if k in request}
|
|
5509
|
+
if op == "execute":
|
|
5510
|
+
cause["code"] = str(request.get("code", ""))[:160]
|
|
5511
|
+
if _LOADS["count"] != before_loads:
|
|
5512
|
+
same = bool(before_path) and os.path.normpath(bpy.data.filepath) == os.path.normpath(before_path)
|
|
5513
|
+
if before_dirty:
|
|
5514
|
+
_say("@@VOLTER-WARN %s now holds %s in place of %s, whose unsaved changes are lost (%s)" % (
|
|
5515
|
+
op, bpy.data.filepath or "a new, unsaved file", before_path or "an unsaved file",
|
|
5516
|
+
json.dumps(cause)))
|
|
5517
|
+
if same:
|
|
5518
|
+
# (the same file reloaded, rebuilt perhaps: its objects are new, so an object it no longer holds is
|
|
5519
|
+
# known by its name)
|
|
5520
|
+
gone = sorted(set(before.values()) - {o.name for o in bpy.data.objects})
|
|
5521
|
+
if gone:
|
|
5522
|
+
_say("@@VOLTER-WARN %s reloaded %s without objects %s (%s); %d remain" % (
|
|
5523
|
+
op, before_path, gone, json.dumps(cause), len(bpy.data.objects)))
|
|
5524
|
+
else:
|
|
5525
|
+
remaining = {o.session_uid for o in bpy.data.objects}
|
|
5526
|
+
removed = [name for uid, name in before.items() if uid not in remaining]
|
|
5527
|
+
if removed:
|
|
5528
|
+
_say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
|
|
5529
|
+
op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
|
|
5732
5530
|
|
|
5733
5531
|
|
|
5734
5532
|
def _dispatch_request(request):
|
|
@@ -5822,10 +5620,119 @@ def _native_preview(request):
|
|
|
5822
5620
|
bpy.data.scenes.remove(preview, do_unlink=True)
|
|
5823
5621
|
|
|
5824
5622
|
|
|
5623
|
+
# WHERE AN EXPORT IS WRITTEN in the module filesystem, before the tab carries it to the project:
|
|
5624
|
+
# one export's files at a time, outside the project's tree, so the MCP mirror never lists them.
|
|
5625
|
+
_GLTF_EXPORT_DIR = "/tmp/volter-gltf-export"
|
|
5626
|
+
|
|
5627
|
+
|
|
5628
|
+
def _gltf_export(request):
|
|
5629
|
+
"""A COMPONENT OR A LEVEL BY BLENDER'S OWN glTF EXPORTER (`gltf_export.py`, RFC 0007).
|
|
5630
|
+
|
|
5631
|
+
The document as it is, written as `<name>.glb` and its manifest `<name>.json` into
|
|
5632
|
+
`_GLTF_EXPORT_DIR`, which the tab reads and writes into the project's
|
|
5633
|
+
`.volter/export/components/` or `.volter/export/levels/`. The export puts back everything it
|
|
5634
|
+
sets up (its temporary markers and collection, the active collection, an instancer's
|
|
5635
|
+
realization, the frame) before it answers, so, like `native-preview`, no history checkpoint,
|
|
5636
|
+
save, frame export or revision bump is owed. `previous` is the project's earlier manifest and
|
|
5637
|
+
its file's SHA-256: when the `.blend`, the exporter and the settings still match, nothing is
|
|
5638
|
+
exported and the answer is `current`. `blendSha256` is the document's SHA-256 as the tab reads
|
|
5639
|
+
it from the project: the module filesystem keeps no copy of a document it streamed in.
|
|
5640
|
+
"""
|
|
5641
|
+
kind = request.get("kind")
|
|
5642
|
+
if kind not in ("component", "level"):
|
|
5643
|
+
raise ValueError("A glTF export is a component or a level")
|
|
5644
|
+
if SESSION.document is None or SESSION._foreign:
|
|
5645
|
+
raise RuntimeError("Blender holds no project document to export")
|
|
5646
|
+
if ROOT not in sys.path:
|
|
5647
|
+
sys.path.insert(0, ROOT)
|
|
5648
|
+
import gltf_export
|
|
5649
|
+
name = os.path.splitext(os.path.basename(SESSION.document))[0]
|
|
5650
|
+
os.makedirs(_GLTF_EXPORT_DIR, exist_ok=True)
|
|
5651
|
+
for stale in os.listdir(_GLTF_EXPORT_DIR):
|
|
5652
|
+
os.unlink(os.path.join(_GLTF_EXPORT_DIR, stale))
|
|
5653
|
+
out = os.path.join(_GLTF_EXPORT_DIR, name + ".glb")
|
|
5654
|
+
previous = request.get("previous")
|
|
5655
|
+
blend_sha = request.get("blendSha256")
|
|
5656
|
+
# The display LUTs the project holds intact (`.volter/export/display/<key>.cube`), each with its
|
|
5657
|
+
# recorded SHA-256: one is baked only when the scene's view settings have none.
|
|
5658
|
+
luts = request.get("luts")
|
|
5659
|
+
luts = ({str(k): str(v) for k, v in luts.items()} if isinstance(luts, dict)
|
|
5660
|
+
else [str(k) for k in luts] if isinstance(luts, list) else [])
|
|
5661
|
+
previous = previous if isinstance(previous, dict) else None
|
|
5662
|
+
blend_sha = blend_sha if isinstance(blend_sha, str) and blend_sha else None
|
|
5663
|
+
answer = None
|
|
5664
|
+
# Check whether the existing export is current before retaining document state. Unchanged
|
|
5665
|
+
# exports touch nothing; changed exports use gltf_export.export, which restores its temporary
|
|
5666
|
+
# changes.
|
|
5667
|
+
if not request.get("force") and previous is not None and blend_sha is not None:
|
|
5668
|
+
started = time.perf_counter()
|
|
5669
|
+
settings = dict(gltf_export.COMPONENT_SETTINGS if kind == "component" else gltf_export.LEVEL_SETTINGS)
|
|
5670
|
+
current = gltf_export._current(out, blend_sha, gltf_export.exporter_version(), settings, previous)
|
|
5671
|
+
if current is not None:
|
|
5672
|
+
answer = gltf_export._current_answer(out, current, started, luts)
|
|
5673
|
+
if answer is None:
|
|
5674
|
+
answer = gltf_export.export(kind, out, SESSION.project, force=bool(request.get("force")),
|
|
5675
|
+
previous=previous, blend_sha=blend_sha, luts=luts)
|
|
5676
|
+
# The file, what travels beside it (a newly baked display LUT), then its manifest, which names
|
|
5677
|
+
# them; a current export may still bring the LUT it names when the project lost it.
|
|
5678
|
+
sidecars = answer["sidecars"]
|
|
5679
|
+
files = list(sidecars) if answer["current"] else [out, *sidecars, os.path.splitext(out)[0] + ".json"]
|
|
5680
|
+
# What this Blender would write for either kind, so a caller can tell whether another file's
|
|
5681
|
+
# earlier export (a level's components) still stands without opening it.
|
|
5682
|
+
return {"kind": kind, "name": name, "document": SESSION.document_relative, "current": answer["current"],
|
|
5683
|
+
"ms": answer["ms"], "steps": answer.get("steps", {}), "manifest": answer["manifest"], "files": files,
|
|
5684
|
+
"exporter": gltf_export.exporter_version(),
|
|
5685
|
+
"settings": {"component": dict(gltf_export.COMPONENT_SETTINGS), "level": dict(gltf_export.LEVEL_SETTINGS)}}
|
|
5686
|
+
|
|
5687
|
+
|
|
5688
|
+
def _run_script(request):
|
|
5689
|
+
"""A SCRIPT RUN AS `blender -b <document> -P <script> -- <args>` RUNS ONE, in the document Blender holds.
|
|
5690
|
+
|
|
5691
|
+
Its `sys.argv` ends in `--` and `args`, as Blender's own does, and its working directory is the run's own `dir`
|
|
5692
|
+
(new for each run, outside the project's tree): a file it writes by a relative path is named in the answer's
|
|
5693
|
+
`files` by that path, and the worker carries it out. It runs as `execute` runs a script (`output` its printing,
|
|
5694
|
+
`error` its raise, `SystemExit` included). This path adds no history step, save or present;
|
|
5695
|
+
scripts restore any temporary document changes themselves. The runner restores argv and cwd.
|
|
5696
|
+
"""
|
|
5697
|
+
if SESSION.document is None or SESSION._foreign:
|
|
5698
|
+
raise RuntimeError("Blender holds no project document to run a script in")
|
|
5699
|
+
root = request["dir"]
|
|
5700
|
+
os.makedirs(root)
|
|
5701
|
+
argv, cwd = sys.argv, os.getcwd()
|
|
5702
|
+
sys.argv = [argv[0], "--", *[str(arg) for arg in request["args"]]]
|
|
5703
|
+
os.chdir(root)
|
|
5704
|
+
try:
|
|
5705
|
+
ran = execute(request["source"])
|
|
5706
|
+
finally:
|
|
5707
|
+
sys.argv = argv
|
|
5708
|
+
os.chdir(cwd)
|
|
5709
|
+
files = sorted(os.path.relpath(os.path.join(folder, name), root).replace(os.sep, "/")
|
|
5710
|
+
for folder, _, names in os.walk(root) for name in names)
|
|
5711
|
+
return {"output": ran["result"], "error": ran["error"], "files": files}
|
|
5712
|
+
|
|
5713
|
+
|
|
5714
|
+
def _asset_library(request):
|
|
5715
|
+
"""AN ASSET LIBRARY IN BLENDER'S PREFERENCES (Preferences > File Paths > Asset Libraries): `name`, at the
|
|
5716
|
+
project's folder `directory`, importing its assets by `importMethod` (`LINK`, `APPEND`, ...). One of that name
|
|
5717
|
+
already there is set again. A preference, not a document edit: nothing is owed."""
|
|
5718
|
+
library = request["library"]
|
|
5719
|
+
path = os.path.join(SESSION.project, library["directory"])
|
|
5720
|
+
libraries = bpy.context.preferences.filepaths.asset_libraries
|
|
5721
|
+
held = libraries.get(library["name"]) or libraries.new(name=library["name"], directory=path)
|
|
5722
|
+
held.path = path
|
|
5723
|
+
held.import_method = library["importMethod"]
|
|
5724
|
+
|
|
5725
|
+
|
|
5825
5726
|
def _dispatch(request):
|
|
5826
5727
|
op = request.get("op")
|
|
5827
5728
|
if op == "native-preview":
|
|
5828
5729
|
return _native_preview(request)
|
|
5730
|
+
if op == "gltf-export":
|
|
5731
|
+
return _gltf_export(request)
|
|
5732
|
+
if op == "run-script":
|
|
5733
|
+
return _run_script(request)
|
|
5734
|
+
if op == "asset-library":
|
|
5735
|
+
return _asset_library(request)
|
|
5829
5736
|
if op == "document-info":
|
|
5830
5737
|
return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
|
|
5831
5738
|
if op == "execute":
|
|
@@ -5854,15 +5761,7 @@ def _dispatch(request):
|
|
|
5854
5761
|
try:
|
|
5855
5762
|
SESSION.present()
|
|
5856
5763
|
except Exception as thrown: # noqa: BLE001
|
|
5857
|
-
#
|
|
5858
|
-
# showing the state BEFORE this call and nothing else says so.
|
|
5859
|
-
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
5860
|
-
# escalate (`blender-emscripten-engine.mts:159`,
|
|
5861
|
-
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
5862
|
-
# to the console and nothing else), so it logged at `log` level and
|
|
5863
|
-
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
5864
|
-
# the overlay walk froze the viewport through a dozen successful
|
|
5865
|
-
# `blender-execute` calls with `cyclotron console` silent throughout.
|
|
5764
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5866
5765
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5867
5766
|
"showing the state before it: " + repr(thrown))
|
|
5868
5767
|
return answer
|
|
@@ -5886,12 +5785,17 @@ def _dispatch(request):
|
|
|
5886
5785
|
return rna_node_tree(request.get("path"), request.get("material"))
|
|
5887
5786
|
if op == "uv-layout":
|
|
5888
5787
|
return rna_uv_layout(request.get("object"), request.get("uvLayer"))
|
|
5889
|
-
#
|
|
5890
|
-
#
|
|
5891
|
-
# them -- keying, moving a key and setting a range are edits.
|
|
5788
|
+
# The Timeline clip read neither presents nor creates a derivation.
|
|
5789
|
+
# Keying, moving a key and setting a range remain edits.
|
|
5892
5790
|
if op == "action-clip":
|
|
5893
5791
|
return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"),
|
|
5894
5792
|
request.get("summary", True))
|
|
5793
|
+
# The movie is a read.
|
|
5794
|
+
if op == "scene-movie":
|
|
5795
|
+
return rna_scene_movie()
|
|
5796
|
+
# The scene's sound for the page to mix: a read; speakers move the frame and restore it.
|
|
5797
|
+
if op == "sound-scene":
|
|
5798
|
+
return rna_sound_scene(int(request["first"]), int(request["last"]))
|
|
5895
5799
|
if op == "outliner":
|
|
5896
5800
|
return rna_outliner(request.get("selected"))
|
|
5897
5801
|
if op == "outliner-set":
|
|
@@ -5903,15 +5807,7 @@ def _dispatch(request):
|
|
|
5903
5807
|
try:
|
|
5904
5808
|
SESSION.present()
|
|
5905
5809
|
except Exception as thrown: # noqa: BLE001
|
|
5906
|
-
#
|
|
5907
|
-
# showing the state BEFORE this call and nothing else says so.
|
|
5908
|
-
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
5909
|
-
# escalate (`blender-emscripten-engine.mts:159`,
|
|
5910
|
-
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
5911
|
-
# to the console and nothing else), so it logged at `log` level and
|
|
5912
|
-
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
5913
|
-
# the overlay walk froze the viewport through a dozen successful
|
|
5914
|
-
# `blender-execute` calls with `cyclotron console` silent throughout.
|
|
5810
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5915
5811
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5916
5812
|
"showing the state before it: " + repr(thrown))
|
|
5917
5813
|
return answer
|
|
@@ -5926,20 +5822,16 @@ def _dispatch(request):
|
|
|
5926
5822
|
try:
|
|
5927
5823
|
SESSION.present()
|
|
5928
5824
|
except Exception as thrown: # noqa: BLE001
|
|
5929
|
-
#
|
|
5930
|
-
# showing the state BEFORE this call and nothing else says so.
|
|
5931
|
-
# `@@VOLTER-PRESENT-FAILED` was not one of the prefixes the engines
|
|
5932
|
-
# escalate (`blender-emscripten-engine.mts:159`,
|
|
5933
|
-
# `blender-wali-engine.mts:266` raise `@@VOLTER-WARN`/`@@VOLTER-ERROR`
|
|
5934
|
-
# to the console and nothing else), so it logged at `log` level and
|
|
5935
|
-
# reached no counter -- measured 2026-09-19 (I4), when a throw in
|
|
5936
|
-
# the overlay walk froze the viewport through a dozen successful
|
|
5937
|
-
# `blender-execute` calls with `cyclotron console` silent throughout.
|
|
5825
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5938
5826
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5939
5827
|
"showing the state before it: " + repr(thrown))
|
|
5940
5828
|
return answer
|
|
5941
5829
|
if op == "save-document":
|
|
5942
5830
|
return SESSION.save_document()
|
|
5831
|
+
if op == "reopen-document":
|
|
5832
|
+
# The document's file in the engine's filesystem was just rewritten (a revert, a
|
|
5833
|
+
# backup); open it again as the model.
|
|
5834
|
+
return SESSION.bind_document(SESSION.document_relative)
|
|
5943
5835
|
if op == "start":
|
|
5944
5836
|
SESSION.project = request.get("project", SESSION.project)
|
|
5945
5837
|
try:
|
|
@@ -5958,25 +5850,14 @@ def _dispatch(request):
|
|
|
5958
5850
|
|
|
5959
5851
|
# ---------------------------------------------------------------- the loop
|
|
5960
5852
|
|
|
5961
|
-
_prepare_directories()
|
|
5962
|
-
bpy.app.handlers.load_post.append(_load_post)
|
|
5963
|
-
bpy.app.handlers.undo_post.append(_history_post)
|
|
5964
|
-
bpy.app.handlers.redo_post.append(_history_post)
|
|
5965
|
-
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
5966
|
-
_say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
5967
|
-
"unavailableEngines": UNAVAILABLE_ENGINES}))
|
|
5968
|
-
|
|
5969
|
-
# Markers already dispatched, and the answers whose `out/` files this loop
|
|
5970
|
-
# still owes a cleanup. Both are pruned as the page acks, so neither grows
|
|
5971
|
-
# with the session.
|
|
5972
5853
|
def _channel_loop():
|
|
5973
5854
|
"""Serve the directory channel until the program ends.
|
|
5974
5855
|
|
|
5975
5856
|
A FUNCTION SO ITS DEATH HAS A NAME. Everything here below the per-request
|
|
5976
|
-
`try` is the CHANNEL itself
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
anywhere
|
|
5857
|
+
`try` is the CHANNEL itself. An exception in it that fell off the end of
|
|
5858
|
+
this script would let Blender finish `--python` and exit 0 through its
|
|
5859
|
+
ordinary path, so the page would be told "Blender exited 0" and nothing
|
|
5860
|
+
anywhere would say why: from outside, a channel death is
|
|
5980
5861
|
indistinguishable from a clean shutdown, and the traceback goes to stderr,
|
|
5981
5862
|
which is page output rather than a console condition. The caller below
|
|
5982
5863
|
makes it one.
|
|
@@ -5988,12 +5869,11 @@ def _channel_loop():
|
|
|
5988
5869
|
unacked = []
|
|
5989
5870
|
active = 0.0
|
|
5990
5871
|
while True:
|
|
5991
|
-
# A FAILED LISTING IS NOT AN EMPTY ONE.
|
|
5992
|
-
#
|
|
5993
|
-
#
|
|
5994
|
-
#
|
|
5995
|
-
#
|
|
5996
|
-
# 2026-09-19, 1,008 s). The directory is the page's and is made before
|
|
5872
|
+
# A FAILED LISTING IS NOT AN EMPTY ONE. Read as "no requests", the one
|
|
5873
|
+
# failure that matters -- the process out of file descriptors -- would
|
|
5874
|
+
# turn the session DEAF: looping at full speed, answering nothing ever
|
|
5875
|
+
# again, while the page waits on a call with a live program and no
|
|
5876
|
+
# error anywhere. The directory is the page's and is made before
|
|
5997
5877
|
# the program starts, so its absence is the only benign reading;
|
|
5998
5878
|
# anything else is named and ends the loop through the guard below.
|
|
5999
5879
|
try:
|
|
@@ -6001,12 +5881,9 @@ def _channel_loop():
|
|
|
6001
5881
|
if n.endswith(".done") and n[: -len(".done")].isdigit())
|
|
6002
5882
|
except FileNotFoundError:
|
|
6003
5883
|
markers = set()
|
|
6004
|
-
#
|
|
6005
|
-
#
|
|
6006
|
-
#
|
|
6007
|
-
# remove. This is the whole of the page's former four-unlink cleanup,
|
|
6008
|
-
# moved to the side that wrote the files (see the header's ownership
|
|
6009
|
-
# table).
|
|
5884
|
+
# The page removes in/<id>.done after reading out/<id>.json.
|
|
5885
|
+
# An absent request marker acknowledges the guest-owned reply files,
|
|
5886
|
+
# which the guest can then remove.
|
|
6010
5887
|
if unacked:
|
|
6011
5888
|
still_owed = []
|
|
6012
5889
|
for rid in unacked:
|
|
@@ -6044,12 +5921,46 @@ def _channel_loop():
|
|
|
6044
5921
|
active = time.monotonic()
|
|
6045
5922
|
|
|
6046
5923
|
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
5924
|
+
# THE ADD-ONS THIS BLENDER SHIPS beside Blender's own: each a package the engine writes under `_ADDONS` before
|
|
5925
|
+
# Blender starts (`session-addons.mts`), enabled for the whole session, whatever file it opens, as Preferences >
|
|
5926
|
+
# Add-ons enables an installed one.
|
|
5927
|
+
_ADDONS = os.path.join(ROOT, "addons")
|
|
5928
|
+
|
|
5929
|
+
|
|
5930
|
+
def _enable_addons():
|
|
5931
|
+
import addon_utils
|
|
5932
|
+
sys.path.insert(0, _ADDONS)
|
|
5933
|
+
for name in sorted(os.listdir(_ADDONS)):
|
|
5934
|
+
failed = []
|
|
5935
|
+
addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
|
|
5936
|
+
if failed:
|
|
5937
|
+
_say("@@VOLTER-WARN the %s add-on did not enable: %s: %s" % (name, type(failed[0]).__name__, failed[0]))
|
|
5938
|
+
|
|
5939
|
+
|
|
5940
|
+
def main():
|
|
5941
|
+
"""Start the worker channel; importing this module only exposes its RNA readers."""
|
|
5942
|
+
global ENGINES, UNAVAILABLE_ENGINES
|
|
5943
|
+
if _blender_web is None:
|
|
5944
|
+
raise RuntimeError("The session worker needs Blender with the _blender_web native door")
|
|
5945
|
+
_prepare_directories()
|
|
5946
|
+
bpy.app.handlers.load_post.append(_load_post)
|
|
5947
|
+
bpy.app.handlers.undo_post.append(_history_post)
|
|
5948
|
+
bpy.app.handlers.redo_post.append(_history_post)
|
|
5949
|
+
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
5950
|
+
_enable_addons()
|
|
5951
|
+
_say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
5952
|
+
"unavailableEngines": UNAVAILABLE_ENGINES}))
|
|
5953
|
+
|
|
5954
|
+
try:
|
|
5955
|
+
_channel_loop()
|
|
5956
|
+
except BaseException as _thrown: # noqa: BLE001
|
|
5957
|
+
# The one place a channel death can still be named. Without this the
|
|
5958
|
+
# program ends 0 and the page reports only that Blender is gone.
|
|
5959
|
+
_say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
|
|
5960
|
+
"every outstanding call will go unanswered. %s"
|
|
5961
|
+
% (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
|
|
5962
|
+
raise
|
|
5963
|
+
|
|
5964
|
+
|
|
5965
|
+
if __name__ == "__main__":
|
|
5966
|
+
main()
|