@volter/blender-engine 0.1.148 → 0.1.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -5
- 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 +66 -129
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +37 -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 +3 -0
- package/browser/protocol.ts +130 -43
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +271 -106
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1389 -1446
- 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 +492 -306
- 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 +195 -180
- 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,9 @@
|
|
|
1
|
-
"""The
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# The session module begins loading (a start step: see `main`).
|
|
6
|
+
print("@@VOLTER-PHASE module", file=sys.stderr, flush=True)
|
|
94
7
|
|
|
95
8
|
import array
|
|
96
9
|
import base64
|
|
@@ -100,7 +13,6 @@ import json
|
|
|
100
13
|
import math
|
|
101
14
|
import os
|
|
102
15
|
import re
|
|
103
|
-
import sys
|
|
104
16
|
import time
|
|
105
17
|
import traceback
|
|
106
18
|
from contextlib import redirect_stdout
|
|
@@ -114,7 +26,12 @@ import mathutils
|
|
|
114
26
|
# THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
|
|
115
27
|
# reads the scene out as typed columns in a side arena the worker reads
|
|
116
28
|
# through `BlenderEngine.readArena` -- see EXPORT_BUFFER_PATH below.
|
|
117
|
-
|
|
29
|
+
try:
|
|
30
|
+
import _blender_web
|
|
31
|
+
except ModuleNotFoundError as error:
|
|
32
|
+
if error.name != "_blender_web":
|
|
33
|
+
raise
|
|
34
|
+
_blender_web = None
|
|
118
35
|
|
|
119
36
|
# The presenter's refusals, spelled in
|
|
120
37
|
# `packages/blender-engine/browser/three/blender-runtime-frame.ts`. This is the only
|
|
@@ -127,30 +44,12 @@ IN = os.path.join(ROOT, "in")
|
|
|
127
44
|
OUT = os.path.join(ROOT, "out")
|
|
128
45
|
ASK = os.path.join(ROOT, "ask")
|
|
129
46
|
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.
|
|
47
|
+
# Use the caller-supplied EXPORT_BUFFER_PATH when its host reads the arena through the filesystem.
|
|
48
|
+
# Hosts with access to linear memory leave it empty to avoid a second copy. Both doors expose the
|
|
49
|
+
# same native export bytes.
|
|
145
50
|
EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
|
|
146
51
|
|
|
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).
|
|
52
|
+
# Retain the last 64 heap marks as [label, heap, mallocInUse, mallocFree, guarded, guardedPeak] in MB so callers can attribute growth to a stage.
|
|
154
53
|
MEMORY_MARKS = []
|
|
155
54
|
|
|
156
55
|
|
|
@@ -202,11 +101,17 @@ def _prepare_directories():
|
|
|
202
101
|
|
|
203
102
|
# ---------------------------------------------------------------- file load
|
|
204
103
|
|
|
104
|
+
# Each file load, counted: a request during which one came replaced the file, never removed its objects.
|
|
105
|
+
_LOADS = {"count": 0}
|
|
106
|
+
|
|
107
|
+
|
|
205
108
|
@bpy.app.handlers.persistent
|
|
206
109
|
def _load_post(_arg):
|
|
110
|
+
_LOADS["count"] += 1
|
|
207
111
|
# The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
|
|
208
112
|
# into the Main that just went away); this is the session's half.
|
|
209
113
|
SESSION.forget()
|
|
114
|
+
SESSION.renew()
|
|
210
115
|
SESSION.loaded()
|
|
211
116
|
# Factory reset reloads add-ons. Preserve native Cycles and re-register
|
|
212
117
|
# only the headless GPU presenters we own.
|
|
@@ -275,7 +180,7 @@ def _surface_backgrounds(node):
|
|
|
275
180
|
"""World Surface as `(what the camera sees, what lights the scene)`; a
|
|
276
181
|
world that is one Background is both and the second is None. A Mix Shader
|
|
277
182
|
by Light Path `Is Camera Ray` is how a scene gets a bright backdrop and a
|
|
278
|
-
restrained fill from one sky
|
|
183
|
+
restrained fill from one sky: input 2 is the camera's,
|
|
279
184
|
input 1 lights the scene."""
|
|
280
185
|
kind = node.bl_idname
|
|
281
186
|
if kind == "ShaderNodeBackground":
|
|
@@ -381,13 +286,12 @@ def _describe_world_socket(root_socket, camera_ray):
|
|
|
381
286
|
if kind == "ShaderNodeMapRange" and socket == "Result":
|
|
382
287
|
if node.data_type != "FLOAT":
|
|
383
288
|
raise NotImplementedError("World Map Range data type %s" % node.data_type)
|
|
384
|
-
steps = node.inputs.get("Steps")
|
|
385
289
|
return {"kind": "map_range", "interpolation": node.interpolation_type,
|
|
386
290
|
"clamp": bool(node.clamp) and node.interpolation_type not in ("SMOOTHSTEP", "SMOOTHERSTEP"),
|
|
387
291
|
"value": value(node.inputs["Value"]),
|
|
388
292
|
"from_min": value(node.inputs["From Min"]), "from_max": value(node.inputs["From Max"]),
|
|
389
293
|
"to_min": value(node.inputs["To Min"]), "to_max": value(node.inputs["To Max"]),
|
|
390
|
-
"steps": value(
|
|
294
|
+
"steps": value(node.inputs["Steps"])}
|
|
391
295
|
if kind == "ShaderNodeSeparateXYZ" and socket in ("X", "Y", "Z"):
|
|
392
296
|
return {"kind": "separate", "vector": value(node.inputs["Vector"]), "axis": ("X", "Y", "Z").index(socket)}
|
|
393
297
|
if kind == "ShaderNodeValToRGB" and socket == "Color":
|
|
@@ -406,8 +310,8 @@ def _describe_world_socket(root_socket, camera_ray):
|
|
|
406
310
|
if model not in ("SINGLE_SCATTERING", "MULTIPLE_SCATTERING"):
|
|
407
311
|
raise NotImplementedError(
|
|
408
312
|
"World Sky Texture: sky_type %r is not implemented (SINGLE_SCATTERING and MULTIPLE_SCATTERING are)" % model)
|
|
409
|
-
vector = node.inputs
|
|
410
|
-
if vector
|
|
313
|
+
vector = node.inputs["Vector"]
|
|
314
|
+
if vector.links:
|
|
411
315
|
raise NotImplementedError("Linked World Sky Texture Vector")
|
|
412
316
|
return {"kind": "sky", "sky_model": model, "sun_disc": bool(node.sun_disc),
|
|
413
317
|
"sun_size": float(node.sun_size), "sun_intensity": float(node.sun_intensity),
|
|
@@ -500,10 +404,10 @@ def _describe_world(world):
|
|
|
500
404
|
# Only a wholly constant background can drop the expression: linked
|
|
501
405
|
# Strength may carry spatial radiance even when Color itself is constant.
|
|
502
406
|
if not any(background.inputs[name].is_linked for name in ("Color", "Strength")):
|
|
503
|
-
described
|
|
407
|
+
del described["shader"]
|
|
504
408
|
if "lighting" in described and not any(
|
|
505
409
|
(lighting or background).inputs[name].is_linked for name in ("Color", "Strength")):
|
|
506
|
-
described["lighting"]
|
|
410
|
+
del described["lighting"]["shader"]
|
|
507
411
|
return described
|
|
508
412
|
|
|
509
413
|
|
|
@@ -549,36 +453,7 @@ def draw_camera(obj):
|
|
|
549
453
|
}
|
|
550
454
|
|
|
551
455
|
|
|
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).
|
|
456
|
+
# 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
457
|
_TREE_LINKS = {}
|
|
583
458
|
|
|
584
459
|
|
|
@@ -632,7 +507,7 @@ def _gpu_type(socket):
|
|
|
632
507
|
if kind in ("VALUE", "INT", "BOOLEAN"):
|
|
633
508
|
return "float"
|
|
634
509
|
if kind == "VECTOR":
|
|
635
|
-
return "vec%d" % len(socket.default_value)
|
|
510
|
+
return "vec%d" % len(socket.default_value)
|
|
636
511
|
if kind in ("RGBA", "ROTATION"):
|
|
637
512
|
return "vec4"
|
|
638
513
|
if kind == "SHADER":
|
|
@@ -642,8 +517,6 @@ def _gpu_type(socket):
|
|
|
642
517
|
|
|
643
518
|
def _socket_value(socket, gpu):
|
|
644
519
|
"""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
520
|
value = socket.default_value
|
|
648
521
|
if gpu == "float":
|
|
649
522
|
return float(value)
|
|
@@ -785,7 +658,10 @@ def _saved_view():
|
|
|
785
658
|
Modeling), else the first 3D View any screen holds. `RegionView3D`'s pivot, rotation (view to
|
|
786
659
|
world, `(w, x, y, z)`), distance and projection (`PERSP`, `ORTHO`, or `CAMERA` for a view
|
|
787
660
|
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.
|
|
661
|
+
with the view: a file saved in Material Preview opens in Material Preview. Beside them, the
|
|
662
|
+
Solid options the tab draws as Blender does: the object outline (`show_object_outline`, in
|
|
663
|
+
`object_outline_color`), the specular term (`show_specular_highlight`) and what Solid and
|
|
664
|
+
Wireframe draw behind the model (`background_type`, with `background_color` for VIEWPORT)."""
|
|
789
665
|
screens = [window.screen for wm in bpy.data.window_managers for window in wm.windows
|
|
790
666
|
if window.screen is not None]
|
|
791
667
|
workspace = bpy.data.workspaces.get("Modeling")
|
|
@@ -806,6 +682,11 @@ def _saved_view():
|
|
|
806
682
|
"shading": space.shading.type,
|
|
807
683
|
"scene_world": bool(space.shading.use_scene_world),
|
|
808
684
|
"scene_lights": bool(space.shading.use_scene_lights),
|
|
685
|
+
"show_object_outline": bool(space.shading.show_object_outline),
|
|
686
|
+
"object_outline_color": [float(v) for v in space.shading.object_outline_color],
|
|
687
|
+
"show_specular_highlight": bool(space.shading.show_specular_highlight),
|
|
688
|
+
"background_type": space.shading.background_type,
|
|
689
|
+
"background_color": [float(v) for v in space.shading.background_color],
|
|
809
690
|
}
|
|
810
691
|
return None
|
|
811
692
|
|
|
@@ -862,7 +743,7 @@ def _subject_line(scene, view_layer):
|
|
|
862
743
|
bone = rig.data.bones.active
|
|
863
744
|
if bone is not None and _bone_shown(bone):
|
|
864
745
|
parts.append(" : " + bone.name)
|
|
865
|
-
key = ob.active_shape_key
|
|
746
|
+
key = ob.active_shape_key
|
|
866
747
|
if key is not None:
|
|
867
748
|
parts.append(" : " + key.name)
|
|
868
749
|
if ob.show_only_shape_key:
|
|
@@ -876,7 +757,7 @@ def _subject_line(scene, view_layer):
|
|
|
876
757
|
|
|
877
758
|
def _bone_shown(bone):
|
|
878
759
|
"""`ANIM_bonecoll_is_visible_actbone`: a bone in no collection, or in a visible one."""
|
|
879
|
-
collections =
|
|
760
|
+
collections = bone.collections
|
|
880
761
|
return not collections or any(c.is_visible for c in collections)
|
|
881
762
|
|
|
882
763
|
|
|
@@ -1009,7 +890,7 @@ def _follow(socket, stack):
|
|
|
1009
890
|
return stack, node
|
|
1010
891
|
|
|
1011
892
|
|
|
1012
|
-
def _light_path_side(socket
|
|
893
|
+
def _light_path_side(socket):
|
|
1013
894
|
"""A Mix Shader factor that separates the camera's surface (a Light Path's
|
|
1014
895
|
Is Camera Ray: 1, Is Shadow Ray: 0 on a camera ray), or a constant 0 or 1,
|
|
1015
896
|
as the input index the camera sees; None for any other factor. The door
|
|
@@ -1027,7 +908,7 @@ def _light_path_side(socket, stack):
|
|
|
1027
908
|
def _collapse(stack, node):
|
|
1028
909
|
"""Follow the Mix Shaders the camera sees one side of (`_light_path_side`)."""
|
|
1029
910
|
while node is not None and node.bl_idname == "ShaderNodeMixShader" and not node.mute:
|
|
1030
|
-
side = _light_path_side(node.inputs[0]
|
|
911
|
+
side = _light_path_side(node.inputs[0])
|
|
1031
912
|
if side is None:
|
|
1032
913
|
break
|
|
1033
914
|
stack, node = _follow(node.inputs[side], stack)
|
|
@@ -1140,23 +1021,17 @@ def _mixed_graph(material, stack, surface):
|
|
|
1140
1021
|
}
|
|
1141
1022
|
|
|
1142
1023
|
|
|
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
|
-
"""
|
|
1024
|
+
def _hash_uint2_float(value):
|
|
1025
|
+
"""Jenkins hash_uint2(value, 0), converted to float32 * 2**-32."""
|
|
1149
1026
|
import struct
|
|
1150
1027
|
|
|
1151
1028
|
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
|
|
1029
|
+
a = (0xdeadbeef + 21 + value) & mask
|
|
1157
1030
|
b = c = (0xdeadbeef + 21) & mask
|
|
1031
|
+
|
|
1158
1032
|
def rotate(x, bits):
|
|
1159
1033
|
return ((x << bits) | (x >> (32 - bits))) & mask
|
|
1034
|
+
|
|
1160
1035
|
c = ((c ^ b) - rotate(b, 14)) & mask
|
|
1161
1036
|
a = ((a ^ c) - rotate(c, 11)) & mask
|
|
1162
1037
|
b = ((b ^ a) - rotate(a, 25)) & mask
|
|
@@ -1167,6 +1042,18 @@ def _object_info_random(name):
|
|
|
1167
1042
|
return struct.unpack("f", struct.pack("f", c / 4294967296.0))[0]
|
|
1168
1043
|
|
|
1169
1044
|
|
|
1045
|
+
def _object_info_random(name):
|
|
1046
|
+
"""Ordinary-object Random, shared by EEVEE and Cycles: hash_string then
|
|
1047
|
+
hash_uint2(name_hash, 0). Instances use depsgraph.random_id instead.
|
|
1048
|
+
See draw_handle.hh::random and cycles/blender/object.cpp.
|
|
1049
|
+
"""
|
|
1050
|
+
h = 0
|
|
1051
|
+
# Blender's platform flags specify -funsigned-char (native and WASM).
|
|
1052
|
+
for byte in name.encode("utf8"):
|
|
1053
|
+
h = (h * 37 + byte) & 0xffffffff
|
|
1054
|
+
return _hash_uint2_float(h)
|
|
1055
|
+
|
|
1056
|
+
|
|
1170
1057
|
def _particle_info_random(index=0):
|
|
1171
1058
|
"""Cycles Particle Info Random: hash_uint2(particle.index, 0).
|
|
1172
1059
|
|
|
@@ -1174,20 +1061,7 @@ def _particle_info_random(index=0):
|
|
|
1174
1061
|
without a particle record use the zero-index dummy KernelParticle.
|
|
1175
1062
|
This is distinct from Object Info's placement random_id.
|
|
1176
1063
|
"""
|
|
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]
|
|
1064
|
+
return _hash_uint2_float(index)
|
|
1191
1065
|
|
|
1192
1066
|
|
|
1193
1067
|
def _depsgraph_placements(frame, depsgraph):
|
|
@@ -1204,7 +1078,7 @@ def _depsgraph_placements(frame, depsgraph):
|
|
|
1204
1078
|
row["shadow_visible"] = bool(source.visible_shadow)
|
|
1205
1079
|
# Frame-local native addresses are comparison tokens, not authoring IDs.
|
|
1206
1080
|
# Consume them here so neither the presenter nor saved projects retain them.
|
|
1207
|
-
geometry = frame.pop("instance_geometry"
|
|
1081
|
+
geometry = frame.pop("instance_geometry")
|
|
1208
1082
|
placements = []
|
|
1209
1083
|
for instance in depsgraph.object_instances:
|
|
1210
1084
|
source = instance.object.original
|
|
@@ -1217,9 +1091,8 @@ def _depsgraph_placements(frame, depsgraph):
|
|
|
1217
1091
|
continue
|
|
1218
1092
|
if not instance.show_self:
|
|
1219
1093
|
continue
|
|
1220
|
-
#
|
|
1221
|
-
#
|
|
1222
|
-
# opening. The native exporter skips the same duplis.
|
|
1094
|
+
# Omit unsupported placements and name each refusal through `warn`.
|
|
1095
|
+
# The rest of the frame remains drawable.
|
|
1223
1096
|
if (instance.instance_object is not None and instance.object.data != instance.instance_object.data and
|
|
1224
1097
|
(instance.object.data is None or geometry.get(source.name) != instance.object.data.as_pointer())):
|
|
1225
1098
|
warn("Depsgraph instances of %r are not drawn: their geometry differs from the exported source" % source.name)
|
|
@@ -1263,7 +1136,7 @@ def material_graphs(scene):
|
|
|
1263
1136
|
_TREE_LINKS.clear()
|
|
1264
1137
|
graphs = {}
|
|
1265
1138
|
for obj in scene.objects:
|
|
1266
|
-
for slot in
|
|
1139
|
+
for slot in obj.material_slots:
|
|
1267
1140
|
material = slot.material
|
|
1268
1141
|
if material is not None and material.name not in graphs:
|
|
1269
1142
|
graphs[material.name] = material_graph(material)
|
|
@@ -1272,9 +1145,7 @@ def material_graphs(scene):
|
|
|
1272
1145
|
|
|
1273
1146
|
# ------------------------------------------------------------- the overlays
|
|
1274
1147
|
#
|
|
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
|
|
1148
|
+
# INSPECTION OVERLAYS, READ OFF THE ENGINE. Blender's overlay engine
|
|
1278
1149
|
# (`source/blender/draw/engines/overlay/`) is never run, ported or recorded
|
|
1279
1150
|
# here: what these answer is the DATA its draw functions read -- a bone's
|
|
1280
1151
|
# display matrix, a vertex's weight -- and a three.js presenter draws it
|
|
@@ -1304,7 +1175,7 @@ def _armature_bones(obj, locked_groups):
|
|
|
1304
1175
|
[...].matrix` in the RNA door reads back identical -- and the presenter
|
|
1305
1176
|
scales the unit shape itself."""
|
|
1306
1177
|
armature = obj.data
|
|
1307
|
-
active =
|
|
1178
|
+
active = armature.bones.active
|
|
1308
1179
|
active_name = active.name if active is not None else None
|
|
1309
1180
|
bones = []
|
|
1310
1181
|
for pchan in obj.pose.bones:
|
|
@@ -1319,19 +1190,8 @@ def _armature_bones(obj, locked_groups):
|
|
|
1319
1190
|
"hide": bool(bone.hide),
|
|
1320
1191
|
"length": float(bone.length),
|
|
1321
1192
|
"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.
|
|
1193
|
+
# PoseBone.select supplies BONE_SELECTED; Bone has no select RNA property.
|
|
1194
|
+
# Selection and the active bone determine get_pchan_color_wire's colours.
|
|
1335
1195
|
"select": bool(pchan.select),
|
|
1336
1196
|
"active": bone.name == active_name,
|
|
1337
1197
|
# BONE_DRAW_LOCKED_WEIGHT, set in weight-paint mode for every bone
|
|
@@ -1374,7 +1234,7 @@ def _bone_inherit(bone):
|
|
|
1374
1234
|
flags.append("inherit_scale=%s" % bone.inherit_scale)
|
|
1375
1235
|
if not bone.use_local_location:
|
|
1376
1236
|
flags.append("use_local_location=False")
|
|
1377
|
-
if
|
|
1237
|
+
if bone.use_relative_parent:
|
|
1378
1238
|
flags.append("use_relative_parent=True")
|
|
1379
1239
|
return {"inherit": flags} if flags else {}
|
|
1380
1240
|
|
|
@@ -1458,10 +1318,25 @@ def _locked_weight_groups(view_layer):
|
|
|
1458
1318
|
obj = view_layer.objects.active
|
|
1459
1319
|
if obj is None or "WEIGHT_PAINT" not in obj.mode:
|
|
1460
1320
|
return frozenset()
|
|
1461
|
-
groups =
|
|
1321
|
+
groups = obj.vertex_groups
|
|
1462
1322
|
return frozenset(group.name for group in groups if group.lock_weight)
|
|
1463
1323
|
|
|
1464
1324
|
|
|
1325
|
+
def _warn_after_armature(view_layer):
|
|
1326
|
+
"""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."""
|
|
1327
|
+
for obj in view_layer.objects:
|
|
1328
|
+
if obj.type != "MESH":
|
|
1329
|
+
continue
|
|
1330
|
+
stack = [m for m in obj.modifiers if m.show_viewport]
|
|
1331
|
+
at = next((i for i, m in enumerate(stack) if m.type == "ARMATURE" and m.object is not None), None)
|
|
1332
|
+
if at is None or at == len(stack) - 1:
|
|
1333
|
+
continue
|
|
1334
|
+
after = ", ".join("%s (%s)" % (m.name, m.type) for m in stack[at + 1:])
|
|
1335
|
+
warn("%s: the modifiers after its Armature (%s) are evaluated once at the pose it was sent in; as its bones "
|
|
1336
|
+
"move in Play they are carried by the skin, not run again. Apply them, or move them "
|
|
1337
|
+
"before the Armature, where the pose should change their result." % (obj.name, after))
|
|
1338
|
+
|
|
1339
|
+
|
|
1465
1340
|
def _armatures(view_layer):
|
|
1466
1341
|
"""Every armature the view layer shows, with its display type and pose.
|
|
1467
1342
|
|
|
@@ -1496,39 +1371,12 @@ def _armatures(view_layer):
|
|
|
1496
1371
|
return armatures
|
|
1497
1372
|
|
|
1498
1373
|
|
|
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."""
|
|
1374
|
+
def _weights(scene, view_layer, known):
|
|
1375
|
+
"""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
1376
|
obj = view_layer.objects.active
|
|
1529
1377
|
if obj is None or obj.type != "MESH" or obj.data is None:
|
|
1530
1378
|
return None
|
|
1531
|
-
group =
|
|
1379
|
+
group = obj.vertex_groups.active
|
|
1532
1380
|
if group is None:
|
|
1533
1381
|
return None
|
|
1534
1382
|
mesh = obj.data
|
|
@@ -1578,10 +1426,10 @@ class Session:
|
|
|
1578
1426
|
self._known = {}
|
|
1579
1427
|
# The last frame's accounting (`_present`), read by `dispatch`.
|
|
1580
1428
|
self.last_shipped = None
|
|
1581
|
-
# THE SESSION'S DOCUMENT: the `.blend` it
|
|
1429
|
+
# THE SESSION'S DOCUMENT: the `.blend` it last opened and saves back
|
|
1582
1430
|
# into. Absolute (the module filesystem mirrors the project at its own
|
|
1583
1431
|
# host path) plus the project-relative spelling the tab needs to name
|
|
1584
|
-
# the destination. None until `
|
|
1432
|
+
# the destination. None until `open-document` names one.
|
|
1585
1433
|
self.document = None
|
|
1586
1434
|
self.document_relative = None
|
|
1587
1435
|
# Set by a present that left the document behind the model; cleared by
|
|
@@ -1607,18 +1455,48 @@ class Session:
|
|
|
1607
1455
|
|
|
1608
1456
|
def forget(self):
|
|
1609
1457
|
self._known.clear()
|
|
1458
|
+
self._action_revisions = {}
|
|
1459
|
+
|
|
1460
|
+
def renew(self):
|
|
1461
|
+
"""A NEW PRESENTATION SESSION FOR EACH FILE LOAD: the editor's open of a document, a
|
|
1462
|
+
script's `open_mainfile` or `read_homefile` alike. A load restarts the revisions the export
|
|
1463
|
+
door numbers its datablocks with, and the view keeps a mesh whose session and revision it
|
|
1464
|
+
already holds (`blender-runtime-view.ts`), so another file's "Cube" at the same revision
|
|
1465
|
+
would be drawn as the last one's. A new id makes the view clear what it held and refuse the
|
|
1466
|
+
last file's late frames."""
|
|
1467
|
+
self.session = "blender-%d" % time.time_ns()
|
|
1468
|
+
|
|
1469
|
+
def _kept_action_revisions(self, door):
|
|
1470
|
+
"""Keep action revisions stable across evaluation-only updates. An edit advances
|
|
1471
|
+
_ACTION_EDITS and permits the native revision to replace the retained revision. All
|
|
1472
|
+
writing requests, scripts, undo and redo count as edits; a file read resets the table."""
|
|
1473
|
+
held = getattr(self, "_action_revisions", None)
|
|
1474
|
+
if held is None:
|
|
1475
|
+
held = self._action_revisions = {}
|
|
1476
|
+
edits = _ACTION_EDITS["count"]
|
|
1477
|
+
shown = {}
|
|
1478
|
+
for name, revision in door.items():
|
|
1479
|
+
known = held.get(name)
|
|
1480
|
+
if known is None or (known[0] != revision and known[2] != edits):
|
|
1481
|
+
held[name] = (revision, revision, edits)
|
|
1482
|
+
else:
|
|
1483
|
+
held[name] = (revision, known[1], edits)
|
|
1484
|
+
shown[name] = held[name][1]
|
|
1485
|
+
for name in [name for name in held if name not in door]:
|
|
1486
|
+
del held[name]
|
|
1487
|
+
return shown
|
|
1610
1488
|
|
|
1611
1489
|
# -- the export
|
|
1612
1490
|
|
|
1613
|
-
def _export(self, depsgraph
|
|
1491
|
+
def _export(self, depsgraph):
|
|
1614
1492
|
"""THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
|
|
1615
1493
|
|
|
1616
1494
|
One call evaluates the scene, walks the depsgraph's own update record
|
|
1617
1495
|
for the revisions, reads Blender's arrays straight into a side arena
|
|
1618
1496
|
and answers a small JSON frame that names each column by
|
|
1619
1497
|
`{offset, length, dtype, count, stride}`; the worker copies those bytes
|
|
1620
|
-
|
|
1621
|
-
|
|
1498
|
+
from EXPORT_BUFFER_PATH (`session-frame.mts`). Large arrays cross
|
|
1499
|
+
through that binary arena rather than JSON.
|
|
1622
1500
|
|
|
1623
1501
|
WHAT THE DOOR DOES NOT DESCRIBE is reduced here and merged in: the
|
|
1624
1502
|
WORLD, whose sky/gradient graph becomes the presenter's world
|
|
@@ -1645,18 +1523,11 @@ class Session:
|
|
|
1645
1523
|
# that can see the ask can read the file.
|
|
1646
1524
|
if EXPORT_BUFFER_PATH:
|
|
1647
1525
|
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))
|
|
1526
|
+
# Pull changed resource columns individually after the frame describes them.
|
|
1527
|
+
options["defer"] = True
|
|
1528
|
+
_blender_web.memory_reset_peak()
|
|
1529
|
+
exported = _blender_web.export_frame_chunked(json.dumps(options, ensure_ascii=False),
|
|
1530
|
+
lambda: _asked({"checkpoint": "native-export"}))
|
|
1660
1531
|
_mark("export:door")
|
|
1661
1532
|
frame = json.loads(exported)
|
|
1662
1533
|
del exported
|
|
@@ -1664,22 +1535,14 @@ class Session:
|
|
|
1664
1535
|
error = frame.get("error")
|
|
1665
1536
|
if error:
|
|
1666
1537
|
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.
|
|
1538
|
+
# 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
1539
|
self.revision += 1
|
|
1678
1540
|
frame["revision"] = self.revision
|
|
1541
|
+
frame["actions"] = self._kept_action_revisions(frame["actions"])
|
|
1679
1542
|
# THE GRAPHS JOIN THE DOOR'S MATERIALS, each image a graph samples
|
|
1680
1543
|
# named at the revision the door holds it at (`graph_images`, which is
|
|
1681
1544
|
# the session's to read and not part of the presenter's frame).
|
|
1682
|
-
revisions = frame.pop("graph_images"
|
|
1545
|
+
revisions = frame.pop("graph_images")
|
|
1683
1546
|
for name, graph in graphs.items():
|
|
1684
1547
|
if name not in frame["materials"]:
|
|
1685
1548
|
continue
|
|
@@ -1694,9 +1557,7 @@ class Session:
|
|
|
1694
1557
|
# automatic one is the evaluated bounds the presenter already holds.
|
|
1695
1558
|
if graphs:
|
|
1696
1559
|
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.
|
|
1560
|
+
# Index scene objects by name once for the export-row lookup.
|
|
1700
1561
|
by_name = {obj.name: obj for obj in scene.objects}
|
|
1701
1562
|
for row in frame["objects"]:
|
|
1702
1563
|
if not any(m in graphs for m in row["materials"] if m is not None):
|
|
@@ -1725,13 +1586,21 @@ class Session:
|
|
|
1725
1586
|
# by (`View3DShading.color_type` MATERIAL reads `Material.diffuse_color`, `roughness`,
|
|
1726
1587
|
# `metallic`; `workbench_world_light_lib.glsl`), not its node graph.
|
|
1727
1588
|
for entry in frame["materials"].values():
|
|
1728
|
-
material = bpy.data.materials.get(entry
|
|
1589
|
+
material = bpy.data.materials.get(entry["name"])
|
|
1729
1590
|
if material is not None:
|
|
1730
1591
|
entry["viewport"] = {
|
|
1731
1592
|
"color": [float(v) for v in material.diffuse_color],
|
|
1732
1593
|
"roughness": float(material.roughness),
|
|
1733
1594
|
"metallic": float(material.metallic),
|
|
1734
1595
|
}
|
|
1596
|
+
# Settings > Backface Culling > Shadow: a shadow from the faces turned to the light
|
|
1597
|
+
# only. Off (the default), EEVEE casts from both faces of every surface.
|
|
1598
|
+
entry["backface_culling_shadow"] = bool(material.use_backface_culling_shadow)
|
|
1599
|
+
# Settings > Backface Culling > Camera: the surface's back faces are not drawn (the
|
|
1600
|
+
# glTF importer sets it on every single-sided material). Transparency Overlap draws
|
|
1601
|
+
# all faces of a blended surface in one pass (on, the default), or only the nearest (off).
|
|
1602
|
+
entry["backface_culling"] = bool(material.use_backface_culling)
|
|
1603
|
+
entry["transparency_overlap"] = bool(material.use_transparency_overlap)
|
|
1735
1604
|
graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
|
|
1736
1605
|
# ONE NAME, TWO OBJECTS: Blender tells a linked object from a local one (or from another
|
|
1737
1606
|
# file's) by its library as well as its name; the presenter knows them by name alone and
|
|
@@ -1763,8 +1632,8 @@ class Session:
|
|
|
1763
1632
|
# (`overlay_cursor.hh`). Its MATRIX, because the overlay's axis lines
|
|
1764
1633
|
# turn with the cursor's rotation in whichever `rotation_mode` it has.
|
|
1765
1634
|
frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
|
|
1766
|
-
#
|
|
1767
|
-
#
|
|
1635
|
+
# Inspection overlays are derived from the evaluated scene and view layer.
|
|
1636
|
+
# Weight-array reuse is keyed by their own byte digest.
|
|
1768
1637
|
view_layer = graph.view_layer
|
|
1769
1638
|
# THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
|
|
1770
1639
|
# both from the scene, so they come from here (`_subject_line`).
|
|
@@ -1778,36 +1647,52 @@ class Session:
|
|
|
1778
1647
|
# WHAT THE OVERLAY DRAWS FOR AN EMPTY (`overlay_empty.hh`): its display type and size.
|
|
1779
1648
|
frame["empties"] = {obj.name: _empty_display(obj) for obj in scene.objects if obj.type == "EMPTY"}
|
|
1780
1649
|
frame["view"] = _saved_view()
|
|
1650
|
+
# A WORLD background is the World's Viewport Display colour (`World.color`).
|
|
1651
|
+
if frame["view"] is not None:
|
|
1652
|
+
frame["view"]["world_color"] = [float(v) for v in scene.world.color] if scene.world else None
|
|
1653
|
+
# THE SCENE'S COLOUR MANAGEMENT, with the frame it colours: Rendered and Material Preview
|
|
1654
|
+
# are drawn through it (`blender-display-transform.ts`), so it travels with every present
|
|
1655
|
+
# and the tab never draws a frame through settings it has not been sent yet.
|
|
1656
|
+
# HOW THE VIEWPORT ANTI-ALIASES: Solid by Workbench's accumulation (the scene's own
|
|
1657
|
+
# `display.viewport_aa` when Workbench renders it, the Preferences' otherwise) and
|
|
1658
|
+
# Rendered by EEVEE's film (`taa_samples` over `filter_size`).
|
|
1659
|
+
frame["sampling"] = {
|
|
1660
|
+
"engine": scene.render.engine,
|
|
1661
|
+
"viewport_aa": scene.display.viewport_aa,
|
|
1662
|
+
"taa_samples": int(scene.eevee.taa_samples),
|
|
1663
|
+
"filter_size": float(scene.render.filter_size),
|
|
1664
|
+
}
|
|
1665
|
+
view_settings = scene.view_settings
|
|
1666
|
+
frame["display"] = {
|
|
1667
|
+
"view_transform": view_settings.view_transform,
|
|
1668
|
+
"look": view_settings.look or "None",
|
|
1669
|
+
"exposure": float(view_settings.exposure),
|
|
1670
|
+
"gamma": float(view_settings.gamma),
|
|
1671
|
+
"display_device": scene.display_settings.display_device,
|
|
1672
|
+
}
|
|
1781
1673
|
frame["units"] = {
|
|
1782
1674
|
"system": scene.unit_settings.system,
|
|
1783
1675
|
"scale_length": float(scene.unit_settings.scale_length),
|
|
1784
1676
|
}
|
|
1785
1677
|
frame["armatures"] = _armatures(view_layer)
|
|
1678
|
+
_warn_after_armature(view_layer)
|
|
1786
1679
|
# THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
|
|
1787
1680
|
frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
|
|
1788
1681
|
"fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
|
|
1789
1682
|
# ACTIONS NOTHING KEEPS: no user and no fake user, so the next save drops them (Blender's own
|
|
1790
1683
|
# rule). A game that plays one is told before it vanishes.
|
|
1791
1684
|
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
|
|
1685
|
+
frame["weights"] = _weights(scene, view_layer, self._known)
|
|
1686
|
+
warnings = list(frame["warnings"])
|
|
1794
1687
|
warnings.extend(_WARNINGS)
|
|
1795
1688
|
del _WARNINGS[:]
|
|
1796
1689
|
frame["warnings"] = warnings
|
|
1797
1690
|
return frame
|
|
1798
1691
|
|
|
1799
1692
|
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
|
-
"""
|
|
1693
|
+
"""Present a frame and return the tab’s answer. Rendering blocks on this same channel for its
|
|
1694
|
+
pixels. Reconcile resource identity with the presenter’s held frame; when a document
|
|
1695
|
+
reload has discarded it, resend the required resources in full."""
|
|
1811
1696
|
try:
|
|
1812
1697
|
return self._present(capture)
|
|
1813
1698
|
except RuntimeError as error:
|
|
@@ -1816,11 +1701,12 @@ class Session:
|
|
|
1816
1701
|
self._known.clear()
|
|
1817
1702
|
return self._present(capture)
|
|
1818
1703
|
|
|
1819
|
-
def _present(self, capture
|
|
1704
|
+
def _present(self, capture, depsgraph=None):
|
|
1820
1705
|
known = dict(self._known)
|
|
1821
1706
|
_mark("present:before")
|
|
1822
1707
|
frame = self._export(depsgraph)
|
|
1823
1708
|
_mark("present:exported")
|
|
1709
|
+
self._carry_browser_decoded_images(frame)
|
|
1824
1710
|
pulled = self._pull(frame, render=depsgraph is not None)
|
|
1825
1711
|
_mark("present:pulled")
|
|
1826
1712
|
self._drop_unreachable_textures(frame)
|
|
@@ -1840,22 +1726,14 @@ class Session:
|
|
|
1840
1726
|
# its value also depends on native undo initialization/checkpoints.
|
|
1841
1727
|
"dirty": bool(bpy.data.is_dirty),
|
|
1842
1728
|
}
|
|
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.
|
|
1729
|
+
# Only a request that can mutate the model marks the document changed. A present or view rebind alone must not trigger a save.
|
|
1852
1730
|
for key, mesh in frame["meshes"].items():
|
|
1853
1731
|
self._known["mesh:" + key] = mesh["revision"]
|
|
1854
1732
|
for name, image in frame["images"].items():
|
|
1855
1733
|
self._known["image:" + name] = image["revision"]
|
|
1856
1734
|
# The weight array follows the meshes' own rule: recorded as sent, so
|
|
1857
1735
|
# the next present ships a reference instead of the bytes.
|
|
1858
|
-
weights = frame
|
|
1736
|
+
weights = frame["weights"]
|
|
1859
1737
|
if weights is not None:
|
|
1860
1738
|
self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
|
|
1861
1739
|
weights["digest"])
|
|
@@ -1880,18 +1758,11 @@ class Session:
|
|
|
1880
1758
|
self._reconcile_with_presenter(answer, frame)
|
|
1881
1759
|
return answer
|
|
1882
1760
|
|
|
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."""
|
|
1761
|
+
def _pull(self, frame, render):
|
|
1762
|
+
"""Pull deferred mesh and image columns one datablock at a time after the worker copies the
|
|
1763
|
+
frame header. Copy each resource before the next pull releases its columns. Remove
|
|
1764
|
+
unreadable images with their warnings before texture reconciliation. Return whether any
|
|
1765
|
+
resource was pulled."""
|
|
1895
1766
|
meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
|
|
1896
1767
|
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1897
1768
|
if not meshes and not images and not render:
|
|
@@ -1909,7 +1780,7 @@ class Session:
|
|
|
1909
1780
|
piece = json.loads(door(json.dumps(options, ensure_ascii=False)))
|
|
1910
1781
|
if piece.get("error"):
|
|
1911
1782
|
raise RuntimeError("Blender export door: %s" % piece["error"])
|
|
1912
|
-
frame["warnings"].extend(piece
|
|
1783
|
+
frame["warnings"].extend(piece["warnings"])
|
|
1913
1784
|
if piece[kind] is None:
|
|
1914
1785
|
del frame["images"][key]
|
|
1915
1786
|
continue
|
|
@@ -1920,8 +1791,6 @@ class Session:
|
|
|
1920
1791
|
"""Render evaluation owns different geometry and visibility than a viewport.
|
|
1921
1792
|
Keep its native revisions, deferred pulls and presenter separate for the
|
|
1922
1793
|
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
1794
|
_blender_web.begin_render_export(depsgraph)
|
|
1926
1795
|
try:
|
|
1927
1796
|
rendering = Session()
|
|
@@ -1930,20 +1799,33 @@ class Session:
|
|
|
1930
1799
|
finally:
|
|
1931
1800
|
_blender_web.end_render_export()
|
|
1932
1801
|
|
|
1802
|
+
def _carry_browser_decoded_images(self, frame):
|
|
1803
|
+
"""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."""
|
|
1804
|
+
carried = frame["images"]
|
|
1805
|
+
names = set()
|
|
1806
|
+
for material in frame["materials"].values():
|
|
1807
|
+
for slot in ("texture", "roughness_texture", "normal_texture"):
|
|
1808
|
+
reference = material.get(slot)
|
|
1809
|
+
if reference is not None:
|
|
1810
|
+
names.add(reference["image"]["name"])
|
|
1811
|
+
graph = material.get("graph")
|
|
1812
|
+
for node in graph["nodes"].values() if graph is not None else ():
|
|
1813
|
+
image = node["props"].get("image")
|
|
1814
|
+
if image is not None:
|
|
1815
|
+
names.add(image["name"])
|
|
1816
|
+
for name in names:
|
|
1817
|
+
if name in carried:
|
|
1818
|
+
continue
|
|
1819
|
+
data = _browser_decoded_bytes(bpy.data.images.get(name))
|
|
1820
|
+
if data is None:
|
|
1821
|
+
continue
|
|
1822
|
+
revision = int.from_bytes(hashlib.sha256(data).digest()[:4], "big") & 0x7fffffff
|
|
1823
|
+
if self._known.get("image:" + name) == revision:
|
|
1824
|
+
continue
|
|
1825
|
+
carried[name] = {"deferred": True, "revision": revision}
|
|
1826
|
+
|
|
1933
1827
|
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."""
|
|
1828
|
+
"""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
1829
|
if "@" in key:
|
|
1948
1830
|
return False
|
|
1949
1831
|
image = bpy.data.images.get(key)
|
|
@@ -1957,11 +1839,8 @@ class Session:
|
|
|
1957
1839
|
data = fh.read()
|
|
1958
1840
|
except (OSError, TypeError, ValueError):
|
|
1959
1841
|
return False
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
elif data[:3] == b"\xff\xd8\xff":
|
|
1963
|
-
mime = "image/jpeg"
|
|
1964
|
-
else:
|
|
1842
|
+
mime = _encoded_mime(data)
|
|
1843
|
+
if mime is None:
|
|
1965
1844
|
return False
|
|
1966
1845
|
path = os.path.join(ROOT, "encoded-image.bin")
|
|
1967
1846
|
with open(path, "wb") as fh:
|
|
@@ -1970,7 +1849,9 @@ class Session:
|
|
|
1970
1849
|
_asked({"image": key, "piece": {
|
|
1971
1850
|
"revision": deferred["revision"],
|
|
1972
1851
|
"mime": mime,
|
|
1973
|
-
|
|
1852
|
+
# (a picture this Blender could not load has no colour space name; an 8-bit file's default is sRGB)
|
|
1853
|
+
"colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" or (
|
|
1854
|
+
image.colorspace_settings.name == "" and not image.colorspace_settings.is_data) else "data",
|
|
1974
1855
|
"encodedPath": path,
|
|
1975
1856
|
}})
|
|
1976
1857
|
finally:
|
|
@@ -1978,137 +1859,43 @@ class Session:
|
|
|
1978
1859
|
return True
|
|
1979
1860
|
|
|
1980
1861
|
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():
|
|
1862
|
+
"""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."""
|
|
1863
|
+
carried = frame["images"]
|
|
1864
|
+
for material in frame["materials"].values():
|
|
2020
1865
|
for slot in ("texture", "roughness_texture", "normal_texture"):
|
|
2021
1866
|
reference = material.get(slot)
|
|
2022
|
-
if
|
|
1867
|
+
if reference is None:
|
|
2023
1868
|
continue
|
|
2024
|
-
name =
|
|
2025
|
-
if name
|
|
1869
|
+
name = reference["image"]["name"]
|
|
1870
|
+
if name in carried or ("image:" + name) in self._known:
|
|
2026
1871
|
continue
|
|
2027
1872
|
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.
|
|
1873
|
+
# Warn when neither this frame nor the retained record can supply the image.
|
|
2055
1874
|
warn("material %s: the export could not read the picture %s, so this "
|
|
2056
1875
|
"frame draws the material without it. The image is in neither "
|
|
2057
1876
|
"this frame nor the presenter's record, so nothing can be "
|
|
2058
1877
|
"re-sent to satisfy it; the export door is what knows why "
|
|
2059
1878
|
"(`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.
|
|
1879
|
+
% (material["name"], name))
|
|
1880
|
+
|
|
1881
|
+
# Mark unreadable node-graph images as missing, retaining the nodes so the presenter draws them magenta.
|
|
2068
1882
|
graph = material.get("graph")
|
|
2069
|
-
for node in
|
|
2070
|
-
props = node
|
|
1883
|
+
for node in graph["nodes"].values() if graph is not None else ():
|
|
1884
|
+
props = node["props"]
|
|
2071
1885
|
image = props.get("image")
|
|
2072
|
-
|
|
2073
|
-
|
|
1886
|
+
if image is None:
|
|
1887
|
+
continue
|
|
1888
|
+
name = image["name"]
|
|
1889
|
+
if name in carried or ("image:" + name) in self._known:
|
|
2074
1890
|
continue
|
|
2075
1891
|
props["image"] = None
|
|
2076
1892
|
props["image_missing"] = True
|
|
2077
1893
|
warn("material %s: the picture %s is not readable in this program (%s), so it "
|
|
2078
1894
|
"draws magenta, as Blender draws a missing image."
|
|
2079
|
-
% (material
|
|
1895
|
+
% (material["name"], name, "a path outside the project, or no pixels"))
|
|
2080
1896
|
|
|
2081
1897
|
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
|
-
"""
|
|
1898
|
+
"""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
1899
|
if not isinstance(answer, dict) or "held" not in answer:
|
|
2113
1900
|
self.presenter_held = "unreported"
|
|
2114
1901
|
return
|
|
@@ -2122,7 +1909,7 @@ class Session:
|
|
|
2122
1909
|
self._known["mesh:" + key] = mesh["revision"]
|
|
2123
1910
|
for name, image in frame["images"].items():
|
|
2124
1911
|
self._known["image:" + name] = image["revision"]
|
|
2125
|
-
weights = frame
|
|
1912
|
+
weights = frame["weights"]
|
|
2126
1913
|
if weights is not None and not weights.get("unchanged"):
|
|
2127
1914
|
self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
|
|
2128
1915
|
weights["digest"])
|
|
@@ -2132,28 +1919,36 @@ class Session:
|
|
|
2132
1919
|
def bind_document(self, relative_path):
|
|
2133
1920
|
"""Name the session's document and OPEN it when the project has one.
|
|
2134
1921
|
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
`open_mainfile`) to another
|
|
2138
|
-
`bpy.data.filepath`, and
|
|
2139
|
-
after the call.
|
|
1922
|
+
The session holds one document at a time: the one the editor last opened
|
|
1923
|
+
here (`open-document`), and it moves otherwise only where Blender itself
|
|
1924
|
+
moved: a script's own `save_as_mainfile` (or `open_mainfile`) to another
|
|
1925
|
+
`.blend` in the project retargets `bpy.data.filepath`, and
|
|
1926
|
+
`follow_open_file` moves the document with it after the call.
|
|
2140
1927
|
|
|
2141
1928
|
`open_mainfile` runs the session's `_load_post` like any other load
|
|
2142
1929
|
(the export door's revision table is reset, the render engine ids are
|
|
2143
1930
|
retaken), and the one present afterwards is what puts the reopened
|
|
2144
|
-
model on the tab's screen.
|
|
1931
|
+
model on the tab's screen, as a new presentation session (`renew`).
|
|
2145
1932
|
"""
|
|
2146
1933
|
self.document_relative = relative_path
|
|
2147
1934
|
self.document = os.path.join(self.project, relative_path)
|
|
2148
1935
|
self._foreign = False
|
|
2149
1936
|
self._foreign_said = False
|
|
1937
|
+
self._outside_file = None
|
|
2150
1938
|
if not os.path.exists(self.document):
|
|
2151
|
-
|
|
1939
|
+
# A DOCUMENT NOT YET WRITTEN OPENS EMPTY: Blender's empty file
|
|
1940
|
+
# (`read_homefile(use_empty=True)`: its scenes, windows and workspaces alone), loaded as the session's
|
|
1941
|
+
# own open, so its first save writes the new model, never the factory startup's cube, light and camera
|
|
1942
|
+
# a fresh worker holds, or the file it held before.
|
|
1943
|
+
self._binding = True
|
|
1944
|
+
try:
|
|
1945
|
+
bpy.ops.wm.read_homefile(use_empty=True)
|
|
1946
|
+
finally:
|
|
1947
|
+
self._binding = False
|
|
1948
|
+
return {"document": relative_path, "opened": False, "presented": self._present_opened(relative_path)}
|
|
2152
1949
|
_asked({"checkpoint": "document-open"})
|
|
2153
1950
|
_mark("open:before")
|
|
2154
|
-
|
|
2155
|
-
if checkpointed_read:
|
|
2156
|
-
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
1951
|
+
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
2157
1952
|
self._binding = True
|
|
2158
1953
|
try:
|
|
2159
1954
|
bpy.ops.wm.open_mainfile(filepath=self.document)
|
|
@@ -2163,32 +1958,46 @@ class Session:
|
|
|
2163
1958
|
raise
|
|
2164
1959
|
finally:
|
|
2165
1960
|
self._binding = False
|
|
2166
|
-
|
|
2167
|
-
_blender_web.set_read_checkpoint(None)
|
|
1961
|
+
_blender_web.set_read_checkpoint(None)
|
|
2168
1962
|
_mark("open:after")
|
|
2169
1963
|
_asked({"checkpoint": "document-opened"})
|
|
2170
1964
|
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.
|
|
1965
|
+
# Remove the staged .blend after Blender loads it to release its WasmFS bytes before
|
|
1966
|
+
# presentation. The worker keeps its host stamp, and the next save rewrites the file.
|
|
2177
1967
|
os.remove(self.document)
|
|
2178
|
-
self.
|
|
2179
|
-
# The load did not dirty anything: what is in memory IS the file.
|
|
2180
|
-
self.save_due = False
|
|
1968
|
+
presented = self._present_opened(relative_path)
|
|
2181
1969
|
return {"document": relative_path, "opened": True,
|
|
2182
1970
|
"objects": len(bpy.data.objects),
|
|
2183
|
-
"size": size}
|
|
1971
|
+
"size": size, "presented": presented}
|
|
1972
|
+
|
|
1973
|
+
def _present_opened(self, relative_path):
|
|
1974
|
+
"""The first present of a document just opened, and whether it was taken.
|
|
1975
|
+
|
|
1976
|
+
A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
|
|
1977
|
+
failed the open would leave a saved document unopenable and every MCP call refused.
|
|
1978
|
+
The model is in Blender, so the session keeps it and says loudly that the view is behind;
|
|
1979
|
+
the next edit presents again, and can remove whatever the presenter could not take.
|
|
1980
|
+
"""
|
|
1981
|
+
presented = True
|
|
1982
|
+
try:
|
|
1983
|
+
self.present()
|
|
1984
|
+
except Exception as thrown: # noqa: BLE001
|
|
1985
|
+
presented = False
|
|
1986
|
+
if _another_document_shown(thrown):
|
|
1987
|
+
# A document switched away during opening may refuse this frame. It presents again when it becomes active.
|
|
1988
|
+
_say("%s is open in Blender; the editor is showing another document, so it is presented when "
|
|
1989
|
+
"it is shown again" % relative_path)
|
|
1990
|
+
else:
|
|
1991
|
+
_say("@@VOLTER-ERROR %s is open in Blender, but its first present failed, so the Model "
|
|
1992
|
+
"document does not show it yet: %r" % (relative_path, thrown))
|
|
1993
|
+
# The load did not dirty anything: what is in memory IS the file.
|
|
1994
|
+
self.save_due = False
|
|
1995
|
+
return presented
|
|
2184
1996
|
|
|
2185
1997
|
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."""
|
|
1998
|
+
"""Track file loads that replace the session’s document model. The session’s own document
|
|
1999
|
+
open retains ownership; other loads suspend saving until the model belongs to the document
|
|
2000
|
+
again."""
|
|
2192
2001
|
if self._binding:
|
|
2193
2002
|
return
|
|
2194
2003
|
self._foreign = True
|
|
@@ -2196,38 +2005,16 @@ class Session:
|
|
|
2196
2005
|
|
|
2197
2006
|
def mark_changed(self):
|
|
2198
2007
|
"""A request that can change the model ran: the next present carries
|
|
2199
|
-
`saveDue`, and the
|
|
2008
|
+
`saveDue`, and the document is dirty until the workbench saves it. (Its actions too: `_kept_action_revisions`.)"""
|
|
2009
|
+
_ACTION_EDITS["count"] += 1
|
|
2200
2010
|
if self.document is not None:
|
|
2201
2011
|
self.save_due = True
|
|
2202
2012
|
|
|
2203
2013
|
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
|
-
"""
|
|
2014
|
+
"""Follow Blender’s open file after execute. Save As and open_mainfile can move the bound
|
|
2015
|
+
document to another project .blend; saving a copy preserves the current binding. Return
|
|
2016
|
+
None when unchanged, a moved answer for a project path, or an outside-project refusal once
|
|
2017
|
+
per file. Never save another file’s in-memory model over the bound document."""
|
|
2231
2018
|
if self.document is None:
|
|
2232
2019
|
return None
|
|
2233
2020
|
if not bpy.data.filepath:
|
|
@@ -2271,94 +2058,7 @@ class Session:
|
|
|
2271
2058
|
self.document_relative)}
|
|
2272
2059
|
|
|
2273
2060
|
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
|
-
"""
|
|
2061
|
+
"""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
2062
|
self.save_due = False
|
|
2363
2063
|
if self.document is None:
|
|
2364
2064
|
return {"saved": False, "reason": "no-document"}
|
|
@@ -2366,19 +2066,9 @@ class Session:
|
|
|
2366
2066
|
# Not the document's model (`loaded`): writing it would replace the file with another.
|
|
2367
2067
|
return {"saved": False, "reason": "not-the-document", "document": self.document_relative,
|
|
2368
2068
|
"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.
|
|
2069
|
+
# Save requests follow mark_changed, which records potentially mutating
|
|
2070
|
+
# operations. bpy.data.is_dirty is diagnostic: native undo checkpoints
|
|
2071
|
+
# do not reliably track this background session's mutations.
|
|
2382
2072
|
dirty = bool(bpy.data.is_dirty)
|
|
2383
2073
|
existed = os.path.exists(self.document)
|
|
2384
2074
|
directory = os.path.dirname(self.document)
|
|
@@ -2397,12 +2087,38 @@ class Session:
|
|
|
2397
2087
|
"document": self.document_relative,
|
|
2398
2088
|
"size": os.path.getsize(self.document),
|
|
2399
2089
|
"revision": self.revision,
|
|
2400
|
-
#
|
|
2401
|
-
#
|
|
2402
|
-
# first write.
|
|
2090
|
+
# Report the dirty state and whether the document already existed before this
|
|
2091
|
+
# write.
|
|
2403
2092
|
"dirty": dirty, "existed": existed}
|
|
2404
2093
|
|
|
2405
2094
|
|
|
2095
|
+
def _encoded_mime(data):
|
|
2096
|
+
"""The MIME type of a picture file the browser decodes and the tab takes as it is, else None."""
|
|
2097
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
2098
|
+
return "image/png"
|
|
2099
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
2100
|
+
return "image/jpeg"
|
|
2101
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
2102
|
+
return "image/webp"
|
|
2103
|
+
return None
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _browser_decoded_bytes(image):
|
|
2107
|
+
"""A file image's bytes when this Blender holds no pixels for it and the file is one the browser decodes
|
|
2108
|
+
(WebP: the editor's Blender is built without it), else None."""
|
|
2109
|
+
if image is None or image.source != "FILE" or image.is_dirty or image.has_data:
|
|
2110
|
+
return None
|
|
2111
|
+
try:
|
|
2112
|
+
if image.packed_file is not None:
|
|
2113
|
+
data = bytes(image.packed_file.data)
|
|
2114
|
+
else:
|
|
2115
|
+
with open(bpy.path.abspath(image.filepath), "rb") as fh:
|
|
2116
|
+
data = fh.read()
|
|
2117
|
+
except (OSError, TypeError, ValueError):
|
|
2118
|
+
return None
|
|
2119
|
+
return data if _encoded_mime(data) == "image/webp" else None
|
|
2120
|
+
|
|
2121
|
+
|
|
2406
2122
|
SESSION = Session()
|
|
2407
2123
|
|
|
2408
2124
|
|
|
@@ -2421,12 +2137,7 @@ def _drop(path):
|
|
|
2421
2137
|
_say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
|
|
2422
2138
|
|
|
2423
2139
|
|
|
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.
|
|
2140
|
+
# Poll briskly for 50 ms after activity, then sleep 2 ms between idle directory reads.
|
|
2430
2141
|
_BRISK_SECONDS = 0.05
|
|
2431
2142
|
|
|
2432
2143
|
|
|
@@ -2434,10 +2145,15 @@ def _pause(since):
|
|
|
2434
2145
|
time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
|
|
2435
2146
|
|
|
2436
2147
|
|
|
2148
|
+
def _another_document_shown(thrown):
|
|
2149
|
+
"""Whether a refusal is the tab's for a document it does not show (`blender-runtime-host.ts`'s
|
|
2150
|
+
`modelDocumentConflict`, "Blender is editing <held>, but the editor is showing <other>"): the editor moved on to
|
|
2151
|
+
another document while this one was opening, which is no fault of the document's."""
|
|
2152
|
+
return "but the editor is showing" in str(thrown)
|
|
2153
|
+
|
|
2154
|
+
|
|
2437
2155
|
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")."""
|
|
2156
|
+
"""Raise the tab's structured error before a refused piece can enter a frame."""
|
|
2441
2157
|
answer = ask(payload)
|
|
2442
2158
|
if isinstance(answer, dict) and answer.get("error"):
|
|
2443
2159
|
raise RuntimeError("The tab refused %s: %s" % (
|
|
@@ -2449,11 +2165,9 @@ def ask(payload):
|
|
|
2449
2165
|
"""Block until the tab answers. The worker's own thread is free while this
|
|
2450
2166
|
waits; only the Blender pthread is held, which is where the operator is.
|
|
2451
2167
|
|
|
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."""
|
|
2168
|
+
The guest writes ask/<n>.json and ask/<n>.done, then reads reply/<n>.json
|
|
2169
|
+
after the page writes reply/<n>.done. It removes only its own ask files
|
|
2170
|
+
to acknowledge the reply. Each writer creates and removes .done last."""
|
|
2457
2171
|
_ASK_SEQUENCE[0] += 1
|
|
2458
2172
|
name = str(_ASK_SEQUENCE[0])
|
|
2459
2173
|
with open(os.path.join(ASK, name + ".json"), "w") as fh:
|
|
@@ -2474,20 +2188,7 @@ def ask(payload):
|
|
|
2474
2188
|
# ---------------------------------------------------------------- the render engine
|
|
2475
2189
|
|
|
2476
2190
|
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."""
|
|
2191
|
+
"""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
2192
|
import math
|
|
2492
2193
|
landscape = width >= height
|
|
2493
2194
|
fit = cam.sensor_fit
|
|
@@ -2503,11 +2204,11 @@ def _vertical_extent(cam, width, height):
|
|
|
2503
2204
|
return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
|
|
2504
2205
|
|
|
2505
2206
|
|
|
2506
|
-
def _photograph(depsgraph, width, height, linear=False):
|
|
2207
|
+
def _photograph(depsgraph, width, height, linear=False, viewport=False):
|
|
2507
2208
|
"""three.js IS the renderer: the render is a photograph of the scene the
|
|
2508
2209
|
engine holds, taken through the scene's own camera at `scene.render`'s
|
|
2509
2210
|
exact resolution. `bpy.ops.render.render` semantics are the contract."""
|
|
2510
|
-
scene = depsgraph.scene
|
|
2211
|
+
scene = depsgraph.scene
|
|
2511
2212
|
camera = scene.camera
|
|
2512
2213
|
if camera is None:
|
|
2513
2214
|
raise RuntimeError("The scene has no camera, so there is nothing to render")
|
|
@@ -2524,12 +2225,14 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2524
2225
|
# matrix (`blender-runtime-host.ts`).
|
|
2525
2226
|
up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
|
|
2526
2227
|
view = scene.view_settings
|
|
2527
|
-
look =
|
|
2228
|
+
look = view.look or "None"
|
|
2528
2229
|
transform = {
|
|
2529
2230
|
"Standard": "none",
|
|
2530
2231
|
"AgX": "agx",
|
|
2531
2232
|
"Filmic": "filmic",
|
|
2532
2233
|
"Khronos PBR Neutral": "neutral",
|
|
2234
|
+
"ACES 1.3": "aces13",
|
|
2235
|
+
"ACES 2.0": "aces20",
|
|
2533
2236
|
}.get(view.view_transform)
|
|
2534
2237
|
if transform is None:
|
|
2535
2238
|
raise RuntimeError(
|
|
@@ -2558,32 +2261,27 @@ def _photograph(depsgraph, width, height, linear=False):
|
|
|
2558
2261
|
# result is what a render leaves, and it holds scene-linear pixels.
|
|
2559
2262
|
if linear:
|
|
2560
2263
|
render["linear"] = True
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
)
|
|
2264
|
+
pose = {"position": position, "target": target, "up": up, "render": render}
|
|
2265
|
+
# Capture the already-presented scene through its camera using the ordinary incremental present path.
|
|
2266
|
+
answer = SESSION.present(pose) if viewport else SESSION.photograph(depsgraph, pose)
|
|
2564
2267
|
if not isinstance(answer, dict) or "base64" not in answer:
|
|
2565
2268
|
raise RuntimeError("The renderer did not answer with a photograph")
|
|
2566
2269
|
_assert_photographed_from(answer.get("camera"), position, target, up)
|
|
2567
2270
|
return base64.b64decode(answer["base64"]), render, answer
|
|
2568
2271
|
|
|
2569
2272
|
|
|
2273
|
+
def photograph_frame(width, height):
|
|
2274
|
+
"""A FILM'S FRAME (`render-movie`): the scene at its current frame, as the viewport presents it,
|
|
2275
|
+
photographed through the scene camera (marker cuts included, as Blender switches the camera on a
|
|
2276
|
+
frame change) at `width` x `height`, with the scene's view transform. Answers the PNG's bytes."""
|
|
2277
|
+
png, _render, _answer = _photograph(bpy.context.evaluated_depsgraph_get(), width, height, viewport=True)
|
|
2278
|
+
return png
|
|
2279
|
+
|
|
2280
|
+
|
|
2570
2281
|
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
|
-
"""
|
|
2282
|
+
"""Require the photograph’s camera pose, transformed back into Blender’s frame, to equal the sent
|
|
2283
|
+
pose exactly. The model root uses a signed axis permutation, so the transform and its inverse
|
|
2284
|
+
preserve the values without a floating-point tolerance."""
|
|
2587
2285
|
sent = {"position": position, "target": target, "up": up}
|
|
2588
2286
|
if not isinstance(reported, dict):
|
|
2589
2287
|
raise RuntimeError(
|
|
@@ -2650,20 +2348,8 @@ class VolterRenderEngine(bpy.types.RenderEngine):
|
|
|
2650
2348
|
|
|
2651
2349
|
|
|
2652
2350
|
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
|
|
2351
|
+
"""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."""
|
|
2352
|
+
linear = answer.get("linearBase64")
|
|
2667
2353
|
if linear and answer.get("linearWidth") == width and answer.get("linearHeight") == height:
|
|
2668
2354
|
import numpy
|
|
2669
2355
|
|
|
@@ -2696,8 +2382,7 @@ def _engine_class(identifier):
|
|
|
2696
2382
|
|
|
2697
2383
|
A Python-registered engine is exposed on `bpy.types` under its CLASS name
|
|
2698
2384
|
(`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."""
|
|
2385
|
+
always absent. Search the RenderEngine subclass tree by `bl_idname`."""
|
|
2701
2386
|
pending = list(bpy.types.RenderEngine.__subclasses__())
|
|
2702
2387
|
while pending:
|
|
2703
2388
|
cls = pending.pop()
|
|
@@ -2708,28 +2393,8 @@ def _engine_class(identifier):
|
|
|
2708
2393
|
|
|
2709
2394
|
|
|
2710
2395
|
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__", ""))
|
|
2396
|
+
"""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."""
|
|
2397
|
+
module = sys.modules.get(existing.__module__)
|
|
2733
2398
|
classes = getattr(module, "classes", None)
|
|
2734
2399
|
if isinstance(classes, tuple):
|
|
2735
2400
|
if existing in classes:
|
|
@@ -2778,14 +2443,7 @@ def _register_engine():
|
|
|
2778
2443
|
bpy.utils.register_class(engine)
|
|
2779
2444
|
made.append(identifier)
|
|
2780
2445
|
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.
|
|
2446
|
+
# Report an engine id whose registration failed.
|
|
2789
2447
|
unavailable.append(identifier)
|
|
2790
2448
|
# `VOLTER_THREE` is the engine under its own name, registered ONCE: this runs
|
|
2791
2449
|
# again after every file load (the ids have to be retaken), and Blender
|
|
@@ -2889,12 +2547,8 @@ class NativeHistory:
|
|
|
2889
2547
|
finally:
|
|
2890
2548
|
self.moving = False
|
|
2891
2549
|
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():
|
|
2550
|
+
# An undo that retains the depsgraph sends only changed IDs; a replaced depsgraph resets revision caches.
|
|
2551
|
+
if _blender_web.session_undo():
|
|
2898
2552
|
_blender_web.session_reset()
|
|
2899
2553
|
SESSION.forget()
|
|
2900
2554
|
SESSION.present()
|
|
@@ -2966,12 +2620,8 @@ def get_object_info(name):
|
|
|
2966
2620
|
|
|
2967
2621
|
|
|
2968
2622
|
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."""
|
|
2623
|
+
"""Run one script in its own `{"bpy": bpy}` namespace. Calls can share helpers
|
|
2624
|
+
through `bpy.app.driver_namespace`; local names and closures stay isolated."""
|
|
2975
2625
|
namespace = {"bpy": bpy}
|
|
2976
2626
|
captured = io.StringIO()
|
|
2977
2627
|
error = None
|
|
@@ -2984,14 +2634,7 @@ def execute(code):
|
|
|
2984
2634
|
return {"executed": error is None, "result": captured.getvalue(), "error": error}
|
|
2985
2635
|
|
|
2986
2636
|
|
|
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.
|
|
2637
|
+
# Refuse script requests for capabilities disabled by bpy.app.build_options, naming the operator namespace and unavailable build feature.
|
|
2995
2638
|
_ABSENT_CAPABILITIES = (
|
|
2996
2639
|
("bullet", "rigidbody", "rigid body physics (Bullet)"),
|
|
2997
2640
|
("fluid", "bpy.ops.fluid", "fluid simulation (Mantaflow)"),
|
|
@@ -3002,31 +2645,14 @@ _ABSENT_CAPABILITIES = (
|
|
|
3002
2645
|
def _absent_capability(code):
|
|
3003
2646
|
options = bpy.app.build_options
|
|
3004
2647
|
for option, keyword, capability in _ABSENT_CAPABILITIES:
|
|
3005
|
-
if keyword in code and not getattr(options, option
|
|
2648
|
+
if keyword in code and not getattr(options, option):
|
|
3006
2649
|
return ("CapabilityUnavailable: %s is not compiled into this Blender build, so "
|
|
3007
2650
|
"`%s` operators are refused rather than reported as done with nothing "
|
|
3008
2651
|
"created. (bpy.app.build_options.%s is False.)" % (capability, keyword, option))
|
|
3009
2652
|
return _absent_essentials_assets(code)
|
|
3010
2653
|
|
|
3011
2654
|
|
|
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.
|
|
2655
|
+
# Check the Essentials asset-library directory itself; availability is a payload property rather than a bpy.app.build_options flag.
|
|
3030
2656
|
_ESSENTIALS_KEYWORDS = ("shade_auto_smooth", "asset_activate", "ESSENTIALS")
|
|
3031
2657
|
|
|
3032
2658
|
|
|
@@ -3049,9 +2675,7 @@ def _absent_essentials_assets(code):
|
|
|
3049
2675
|
|
|
3050
2676
|
# ------------------------------------------------------------------ the RNA door
|
|
3051
2677
|
#
|
|
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
|
|
2678
|
+
# DATABLOCK PROPERTIES READ THROUGH RNA. Blender's UI layer is never
|
|
3055
2679
|
# run, ported or recorded here: what this answers is `bl_rna.properties` --
|
|
3056
2680
|
# Blender's own introspection of its own data -- as typed rows, and what our
|
|
3057
2681
|
# panels draw is those rows.
|
|
@@ -3076,20 +2700,7 @@ _RNA_STRING_LIMIT = 4096
|
|
|
3076
2700
|
# struct carries it and it points at the struct's own type.
|
|
3077
2701
|
_RNA_SKIP = ("rna_type",)
|
|
3078
2702
|
|
|
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.
|
|
2703
|
+
# Include Python property descriptors from bpy_types alongside RNA properties, excluding recursive variants when the immediate property already offers traversal.
|
|
3093
2704
|
_PY_PROPERTY_SUFFIX_SKIP = ("_recursive",)
|
|
3094
2705
|
|
|
3095
2706
|
_ID_COLLECTIONS = None
|
|
@@ -3105,7 +2716,7 @@ def _id_collections():
|
|
|
3105
2716
|
for prop in bpy.data.bl_rna.properties:
|
|
3106
2717
|
if prop.type != "COLLECTION":
|
|
3107
2718
|
continue
|
|
3108
|
-
fixed =
|
|
2719
|
+
fixed = prop.fixed_type
|
|
3109
2720
|
if fixed is not None:
|
|
3110
2721
|
table.setdefault(fixed.identifier, prop.identifier)
|
|
3111
2722
|
return table
|
|
@@ -3122,7 +2733,7 @@ def _id_collection_for(datablock):
|
|
|
3122
2733
|
name = _ID_COLLECTIONS.get(rna.identifier)
|
|
3123
2734
|
if name:
|
|
3124
2735
|
return name
|
|
3125
|
-
rna =
|
|
2736
|
+
rna = rna.base
|
|
3126
2737
|
return None
|
|
3127
2738
|
|
|
3128
2739
|
|
|
@@ -3138,7 +2749,7 @@ def _rna_address(struct):
|
|
|
3138
2749
|
if collection is None:
|
|
3139
2750
|
return None
|
|
3140
2751
|
return 'bpy.data.%s["%s"]' % (collection, bpy.utils.escape_identifier(struct.name))
|
|
3141
|
-
owner =
|
|
2752
|
+
owner = struct.id_data
|
|
3142
2753
|
if owner is None:
|
|
3143
2754
|
return None
|
|
3144
2755
|
base = _rna_address(owner)
|
|
@@ -3164,23 +2775,17 @@ def _rna_resolve(path):
|
|
|
3164
2775
|
|
|
3165
2776
|
|
|
3166
2777
|
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."""
|
|
2778
|
+
"""Find the RNA struct declaring a property and cache the result per struct. This groups RNA inheritance independently of Blender’s panel layout."""
|
|
3174
2779
|
cached = _RNA_GROUPS.get(struct_rna.identifier)
|
|
3175
2780
|
if cached is None:
|
|
3176
2781
|
cached = {}
|
|
3177
2782
|
for prop in struct_rna.properties:
|
|
3178
2783
|
declaring = struct_rna
|
|
3179
|
-
base =
|
|
2784
|
+
base = struct_rna.base
|
|
3180
2785
|
while base is not None:
|
|
3181
2786
|
if prop.identifier in base.properties:
|
|
3182
2787
|
declaring = base
|
|
3183
|
-
base =
|
|
2788
|
+
base = base.base
|
|
3184
2789
|
cached[prop.identifier] = (declaring.identifier, declaring.name)
|
|
3185
2790
|
_RNA_GROUPS[struct_rna.identifier] = cached
|
|
3186
2791
|
return cached.get(identifier, (struct_rna.identifier, struct_rna.name))
|
|
@@ -3220,7 +2825,7 @@ def _rna_value(target, prop):
|
|
|
3220
2825
|
if kind == "COLLECTION":
|
|
3221
2826
|
value = getattr(target, prop.identifier)
|
|
3222
2827
|
row = {"count": len(value)}
|
|
3223
|
-
fixed =
|
|
2828
|
+
fixed = prop.fixed_type
|
|
3224
2829
|
# A mesh's `vertices` has no `name`, and asking 100k of them for one is
|
|
3225
2830
|
# exactly the serialisation this door refuses -- so the question is
|
|
3226
2831
|
# asked of the TYPE, once, off its own RNA.
|
|
@@ -3236,14 +2841,7 @@ def _rna_value(target, prop):
|
|
|
3236
2841
|
if kind == "ENUM":
|
|
3237
2842
|
return sorted(value) if prop.is_enum_flag else value
|
|
3238
2843
|
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).
|
|
2844
|
+
# Flatten multidimensional bpy_prop_array values in row-major order; RNA array_length counts scalar values rather than rows.
|
|
3247
2845
|
return [_rna_scalar(item, kind) for item in _rna_flatten(value)]
|
|
3248
2846
|
if kind == "BOOLEAN":
|
|
3249
2847
|
return bool(value)
|
|
@@ -3290,19 +2888,7 @@ def _rna_row(target, prop):
|
|
|
3290
2888
|
# by the presentation, so the door stays the whole surface.
|
|
3291
2889
|
"hidden": bool(prop.is_hidden),
|
|
3292
2890
|
}
|
|
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.
|
|
2891
|
+
# Mark properties without UI text as undrawn when their name equals the identifier and their description is empty.
|
|
3306
2892
|
if prop.name == prop.identifier and not prop.description:
|
|
3307
2893
|
row["undrawn"] = True
|
|
3308
2894
|
if prop.type in ("INT", "FLOAT"):
|
|
@@ -3314,20 +2900,20 @@ def _rna_row(target, prop):
|
|
|
3314
2900
|
if prop.type == "FLOAT":
|
|
3315
2901
|
row["precision"] = int(prop.precision)
|
|
3316
2902
|
if prop.type == "STRING":
|
|
3317
|
-
row["lengthMax"] = int(
|
|
2903
|
+
row["lengthMax"] = int(prop.length_max)
|
|
3318
2904
|
if prop.type == "ENUM":
|
|
3319
2905
|
row["isFlag"] = bool(prop.is_enum_flag)
|
|
3320
2906
|
try:
|
|
3321
2907
|
items = prop.enum_items
|
|
3322
2908
|
except Exception: # noqa: BLE001
|
|
3323
|
-
items =
|
|
2909
|
+
items = prop.enum_items_static
|
|
3324
2910
|
row["items"] = [
|
|
3325
2911
|
{"identifier": item.identifier, "name": item.name,
|
|
3326
2912
|
"description": item.description, "icon": item.icon}
|
|
3327
2913
|
for item in items
|
|
3328
2914
|
]
|
|
3329
2915
|
if prop.type in ("POINTER", "COLLECTION"):
|
|
3330
|
-
fixed =
|
|
2916
|
+
fixed = prop.fixed_type
|
|
3331
2917
|
if fixed is not None:
|
|
3332
2918
|
row["itemType"] = fixed.identifier
|
|
3333
2919
|
if length > _RNA_ARRAY_LIMIT:
|
|
@@ -3352,8 +2938,7 @@ def _py_properties(cls):
|
|
|
3352
2938
|
return cached
|
|
3353
2939
|
found = []
|
|
3354
2940
|
seen = set()
|
|
3355
|
-
|
|
3356
|
-
declared = set(rna.properties.keys()) if rna is not None else set()
|
|
2941
|
+
declared = set(cls.bl_rna.properties.keys())
|
|
3357
2942
|
for base in cls.__mro__:
|
|
3358
2943
|
for name, member in vars(base).items():
|
|
3359
2944
|
if name.startswith("_") or name in seen or name in declared:
|
|
@@ -3450,16 +3035,7 @@ def _py_row(target, name, descriptor, owner):
|
|
|
3450
3035
|
|
|
3451
3036
|
|
|
3452
3037
|
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."""
|
|
3038
|
+
"""Address collection members with path_from_id when available, otherwise append their name or index to the collection’s own path."""
|
|
3463
3039
|
address = _rna_address(item)
|
|
3464
3040
|
name = getattr(item, "name", None)
|
|
3465
3041
|
if address is None:
|
|
@@ -3576,11 +3152,11 @@ def _rna_active_object(name):
|
|
|
3576
3152
|
return bpy.context.view_layer.objects.active
|
|
3577
3153
|
|
|
3578
3154
|
|
|
3579
|
-
def _named(value
|
|
3155
|
+
def _named(value):
|
|
3580
3156
|
if value is None:
|
|
3581
3157
|
return None
|
|
3582
|
-
return {"name":
|
|
3583
|
-
"path":
|
|
3158
|
+
return {"name": value.name,
|
|
3159
|
+
"path": _rna_address(value),
|
|
3584
3160
|
"type": value.bl_rna.identifier}
|
|
3585
3161
|
|
|
3586
3162
|
|
|
@@ -3590,14 +3166,7 @@ def _tab(identifier, label, icon, paths):
|
|
|
3590
3166
|
|
|
3591
3167
|
|
|
3592
3168
|
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
|
-
|
|
3169
|
+
"""Address a LayerCollection by its position in the view-layer tree because it has no path_from_id."""
|
|
3601
3170
|
def walk(node, path):
|
|
3602
3171
|
if node == target:
|
|
3603
3172
|
return path
|
|
@@ -3613,12 +3182,14 @@ def _layer_collection_path(view_layer_path, root, target):
|
|
|
3613
3182
|
|
|
3614
3183
|
def _texture_slot_users(users, owner, slots, label, name=None):
|
|
3615
3184
|
"""Blender's `buttons_texture_user_mtex_add`: one user per FILLED slot."""
|
|
3616
|
-
for
|
|
3617
|
-
|
|
3185
|
+
for slot in slots:
|
|
3186
|
+
if slot is None:
|
|
3187
|
+
continue
|
|
3188
|
+
texture = slot.texture
|
|
3618
3189
|
if texture is None:
|
|
3619
3190
|
continue
|
|
3620
3191
|
users.append({"label": label,
|
|
3621
|
-
"name": name if name else
|
|
3192
|
+
"name": name if name else owner.name,
|
|
3622
3193
|
"path": _rna_address(slot),
|
|
3623
3194
|
"property": "texture",
|
|
3624
3195
|
"texture": _named(texture)})
|
|
@@ -3629,11 +3200,11 @@ def _texture_node_users(users, tree, label):
|
|
|
3629
3200
|
`texture` pointer is filled."""
|
|
3630
3201
|
if tree is None:
|
|
3631
3202
|
return
|
|
3632
|
-
for node in
|
|
3203
|
+
for node in tree.nodes:
|
|
3633
3204
|
prop = node.bl_rna.properties.get("texture")
|
|
3634
3205
|
if prop is None or prop.type != "POINTER":
|
|
3635
3206
|
continue
|
|
3636
|
-
texture =
|
|
3207
|
+
texture = node.texture
|
|
3637
3208
|
if texture is None:
|
|
3638
3209
|
continue
|
|
3639
3210
|
users.append({"label": label, "name": node.name,
|
|
@@ -3642,58 +3213,41 @@ def _texture_node_users(users, tree, label):
|
|
|
3642
3213
|
|
|
3643
3214
|
|
|
3644
3215
|
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."""
|
|
3216
|
+
"""Report texture users in Blender buttons_texture_users_from_context order, with the active user index selecting the displayed texture."""
|
|
3662
3217
|
users = []
|
|
3663
|
-
_texture_node_users(users,
|
|
3218
|
+
_texture_node_users(users, scene.compositing_node_group, "Compositor")
|
|
3664
3219
|
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
linestyle = getattr(getattr(linesets, "active", None), "linestyle", None)
|
|
3220
|
+
lineset = view_layer.freestyle_settings.linesets.active
|
|
3221
|
+
linestyle = lineset.linestyle if lineset is not None else None
|
|
3668
3222
|
if linestyle is not None:
|
|
3669
|
-
_texture_slot_users(users, linestyle,
|
|
3223
|
+
_texture_slot_users(users, linestyle, linestyle.texture_slots,
|
|
3670
3224
|
"Line Style")
|
|
3671
|
-
_texture_node_users(users,
|
|
3225
|
+
_texture_node_users(users, linestyle.node_tree, "Line Style")
|
|
3672
3226
|
|
|
3673
3227
|
if obj is not None:
|
|
3674
3228
|
for modifier in obj.modifiers:
|
|
3675
3229
|
for prop in modifier.bl_rna.properties:
|
|
3676
3230
|
if prop.type != "POINTER":
|
|
3677
3231
|
continue
|
|
3678
|
-
fixed =
|
|
3232
|
+
fixed = prop.fixed_type
|
|
3679
3233
|
if fixed is None or fixed.identifier != "Texture":
|
|
3680
3234
|
continue
|
|
3681
|
-
texture = getattr(modifier, prop.identifier
|
|
3235
|
+
texture = getattr(modifier, prop.identifier)
|
|
3682
3236
|
if texture is None:
|
|
3683
3237
|
continue
|
|
3684
3238
|
users.append({"label": "Modifiers", "name": modifier.name,
|
|
3685
3239
|
"path": _rna_address(modifier),
|
|
3686
3240
|
"property": prop.identifier,
|
|
3687
3241
|
"texture": _named(texture)})
|
|
3688
|
-
systems =
|
|
3689
|
-
active_system =
|
|
3690
|
-
settings =
|
|
3242
|
+
systems = obj.particle_systems
|
|
3243
|
+
active_system = systems.active
|
|
3244
|
+
settings = active_system.settings if active_system is not None else None
|
|
3691
3245
|
if settings is not None:
|
|
3692
|
-
_texture_slot_users(users, settings,
|
|
3246
|
+
_texture_slot_users(users, settings, settings.texture_slots,
|
|
3693
3247
|
"Particles", name=active_system.name)
|
|
3694
|
-
field =
|
|
3695
|
-
if field is not None and
|
|
3696
|
-
and
|
|
3248
|
+
field = obj.field
|
|
3249
|
+
if field is not None and field.type == "TEXTURE" \
|
|
3250
|
+
and field.texture is not None:
|
|
3697
3251
|
users.append({"label": "Fields", "name": "Texture Field",
|
|
3698
3252
|
"path": _rna_address(field), "property": "texture",
|
|
3699
3253
|
"texture": _named(field.texture)})
|
|
@@ -3701,17 +3255,17 @@ def _texture_users(scene, view_layer, obj):
|
|
|
3701
3255
|
# `BKE_paint_brush(BKE_paint_get_active_from_context(C))`. Headless has no
|
|
3702
3256
|
# paint mode, so this is whichever tool settings hold a brush at all --
|
|
3703
3257
|
# asked of every `Paint` the tool settings carry rather than of a mode.
|
|
3704
|
-
tools =
|
|
3258
|
+
tools = bpy.context.tool_settings
|
|
3705
3259
|
for member in ("image_paint", "sculpt", "vertex_paint", "weight_paint",
|
|
3706
3260
|
"gpencil_paint", "curves_sculpt"):
|
|
3707
|
-
paint = getattr(tools, member
|
|
3708
|
-
brush =
|
|
3261
|
+
paint = getattr(tools, member) if tools is not None else None
|
|
3262
|
+
brush = paint.brush if paint is not None else None
|
|
3709
3263
|
if brush is None:
|
|
3710
3264
|
continue
|
|
3711
3265
|
for slot, prop in (("texture_slot", "texture"), ("mask_texture_slot", "mask_texture")):
|
|
3712
|
-
holder = getattr(brush, slot
|
|
3713
|
-
texture = getattr(brush, prop
|
|
3714
|
-
if
|
|
3266
|
+
holder = getattr(brush, slot)
|
|
3267
|
+
texture = getattr(brush, prop)
|
|
3268
|
+
if texture is None:
|
|
3715
3269
|
continue
|
|
3716
3270
|
users.append({"label": "Brush", "name": brush.name,
|
|
3717
3271
|
"path": _rna_address(holder), "property": "texture",
|
|
@@ -3740,7 +3294,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3740
3294
|
# then shows THAT collection. Activating it here would be a mutation the
|
|
3741
3295
|
# document saves, so the row's own address arrives as a parameter and stands
|
|
3742
3296
|
# in for `view_layer.active_layer_collection` for this read alone.
|
|
3743
|
-
active_layer_collection =
|
|
3297
|
+
active_layer_collection = view_layer.active_layer_collection
|
|
3744
3298
|
if collection_path:
|
|
3745
3299
|
named = _rna_resolve(collection_path)
|
|
3746
3300
|
if not isinstance(named, bpy.types.LayerCollection):
|
|
@@ -3749,7 +3303,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3749
3303
|
"LayerCollection (`buttons_context_path_collection`)."
|
|
3750
3304
|
% (collection_path, named.bl_rna.identifier))
|
|
3751
3305
|
active_layer_collection = named
|
|
3752
|
-
collection =
|
|
3306
|
+
collection = active_layer_collection.collection if active_layer_collection is not None else None
|
|
3753
3307
|
if collection is not None and collection == scene.collection:
|
|
3754
3308
|
collection = None
|
|
3755
3309
|
|
|
@@ -3774,7 +3328,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3774
3328
|
# object whose data supports materials even when the slot is empty --
|
|
3775
3329
|
# `OB_TYPE_SUPPORT_MATERIAL` is asked here as "does this data hold a
|
|
3776
3330
|
# `materials` collection", which is the same set, read off RNA.
|
|
3777
|
-
slot_index = max(int(
|
|
3331
|
+
slot_index = max(int(obj.active_material_index), 0)
|
|
3778
3332
|
slots = obj.material_slots
|
|
3779
3333
|
slot = slots[slot_index] if slot_index < len(slots) else None
|
|
3780
3334
|
held = slot.material if slot is not None else None
|
|
@@ -3790,16 +3344,15 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3790
3344
|
particle_system = None
|
|
3791
3345
|
if obj is not None:
|
|
3792
3346
|
if obj.type in _MODIFIER_OBJECT_TYPES:
|
|
3793
|
-
modifier = _named(
|
|
3794
|
-
groups =
|
|
3795
|
-
if groups
|
|
3347
|
+
modifier = _named(obj.modifiers.active)
|
|
3348
|
+
groups = obj.vertex_groups
|
|
3349
|
+
if groups.active is not None:
|
|
3796
3350
|
vertex_group = {"index": int(groups.active_index)}
|
|
3797
3351
|
vertex_group.update(_named(groups.active))
|
|
3798
|
-
shape_key = _named(
|
|
3799
|
-
constraint = _named(
|
|
3800
|
-
systems =
|
|
3801
|
-
|
|
3802
|
-
particle_system = _named(getattr(systems, "active", None))
|
|
3352
|
+
shape_key = _named(obj.active_shape_key)
|
|
3353
|
+
constraint = _named(obj.constraints.active)
|
|
3354
|
+
systems = obj.particle_systems
|
|
3355
|
+
particle_system = _named(systems.active)
|
|
3803
3356
|
|
|
3804
3357
|
# ED_buttons_tabs_list (`space_buttons.cc:218-252`), in its own order. The
|
|
3805
3358
|
# TOOL tab is Blender's active-tool settings and has no reader here; SHADERFX
|
|
@@ -3873,18 +3426,14 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3873
3426
|
# it is what nearly every panel on this tab draws -- `part` in
|
|
3874
3427
|
# every one of their draw functions. The system itself carries
|
|
3875
3428
|
# 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))))
|
|
3429
|
+
systems_active = systems.active
|
|
3430
|
+
paths.append(("Settings", _rna_address(systems_active.settings)))
|
|
3431
|
+
cloth = systems_active.cloth
|
|
3432
|
+
if cloth is not None:
|
|
3433
|
+
# PARTICLE_PT_hair_dynamics reads `psys.cloth.settings`
|
|
3434
|
+
# and `.collision_settings` (`:390-404`, `:463-470`).
|
|
3435
|
+
paths.append(("Hair Dynamics", _rna_address(cloth.settings)))
|
|
3436
|
+
paths.append(("Hair Collisions", _rna_address(cloth.collision_settings)))
|
|
3888
3437
|
tabs.append(_tab("particle", "Particles", "properties-particles", paths))
|
|
3889
3438
|
# `buttons_context_path_object` answers Physics and Constraints both.
|
|
3890
3439
|
#
|
|
@@ -3903,34 +3452,28 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3903
3452
|
("Soft Body", "soft_body"),
|
|
3904
3453
|
("Collision", "collision"),
|
|
3905
3454
|
("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.
|
|
3455
|
+
if getattr(obj, field) is not None]
|
|
3456
|
+
for kind, members in (("CLOTH",
|
|
3457
|
+
# Include the modifier itself beside its settings because the point-cache panel addresses md.point_cache.
|
|
3915
3458
|
(("Cloth Modifier", None),
|
|
3916
3459
|
("Cloth", "settings"),
|
|
3917
3460
|
("Cloth Collisions", "collision_settings"))),
|
|
3918
|
-
("
|
|
3461
|
+
("SOFT_BODY",
|
|
3919
3462
|
(("Soft Body Modifier", None),)),
|
|
3920
|
-
("
|
|
3463
|
+
("FLUID",
|
|
3921
3464
|
(("Fluid", None),
|
|
3922
3465
|
("Fluid Domain", "domain_settings"),
|
|
3923
3466
|
("Fluid Flow", "flow_settings"),
|
|
3924
3467
|
("Fluid Effector", "effector_settings"))),
|
|
3925
|
-
("
|
|
3468
|
+
("DYNAMIC_PAINT",
|
|
3926
3469
|
(("Dynamic Paint", None),
|
|
3927
3470
|
("Dynamic Paint Canvas", "canvas_settings"),
|
|
3928
3471
|
("Dynamic Paint Brush", "brush_settings")))):
|
|
3929
|
-
|
|
3930
|
-
if
|
|
3472
|
+
physics_modifier = next((item for item in obj.modifiers if item.type == kind), None)
|
|
3473
|
+
if physics_modifier is None:
|
|
3931
3474
|
continue
|
|
3932
3475
|
for name, member in members:
|
|
3933
|
-
target =
|
|
3476
|
+
target = physics_modifier if member is None else getattr(physics_modifier, member)
|
|
3934
3477
|
physics.append((name, _rna_address(target)))
|
|
3935
3478
|
tabs.append(_tab("physics", "Physics", "properties-physics",
|
|
3936
3479
|
physics or [("Object", object_path)]))
|
|
@@ -3945,7 +3488,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3945
3488
|
paths = [("Object Data", data_path)]
|
|
3946
3489
|
if obj.type == "ARMATURE":
|
|
3947
3490
|
paths.append(("Bones", "%s.bones" % data_path))
|
|
3948
|
-
if
|
|
3491
|
+
if len(obj.vertex_groups):
|
|
3949
3492
|
paths.append(("Vertex Groups", "%s.vertex_groups" % object_path))
|
|
3950
3493
|
keys = getattr(obj.data, "shape_keys", None)
|
|
3951
3494
|
if keys is not None:
|
|
@@ -3962,11 +3505,11 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3962
3505
|
[("Constraints", "%s.constraints" % pose_bone["path"])]))
|
|
3963
3506
|
if material is not None:
|
|
3964
3507
|
paths = [("Material Slots", "%s.material_slots" % object_path)]
|
|
3965
|
-
if material
|
|
3508
|
+
if material["path"]:
|
|
3966
3509
|
paths.insert(0, ("Material", material["path"]))
|
|
3967
3510
|
tabs.append(_tab("material", "Material", "properties-material", paths))
|
|
3968
3511
|
# Blender's Texture tab is `buttons_texture_context_compute`'s search for a
|
|
3969
|
-
# texture USER,
|
|
3512
|
+
# texture USER, mirrored in `_texture_users` -- in the C's own order,
|
|
3970
3513
|
# with user 0 the active one (`ct->index`). The tab stands when that search
|
|
3971
3514
|
# finds a user, or (as `buttons_context_path_texture` does through the
|
|
3972
3515
|
# pinned-ID branch) when the file holds textures at all.
|
|
@@ -3975,7 +3518,7 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
3975
3518
|
if texture_users or len(bpy.data.textures):
|
|
3976
3519
|
paths = []
|
|
3977
3520
|
if active_texture_user is not None:
|
|
3978
|
-
paths.append(("Texture",
|
|
3521
|
+
paths.append(("Texture", active_texture_user["texture"]["path"]))
|
|
3979
3522
|
paths.append(("User", active_texture_user["path"]))
|
|
3980
3523
|
paths.append(("Textures", "bpy.data.textures"))
|
|
3981
3524
|
tabs.append(_tab("texture", "Texture", "properties-texture", paths))
|
|
@@ -4014,47 +3557,11 @@ def rna_context(object_name=None, collection_path=None):
|
|
|
4014
3557
|
|
|
4015
3558
|
|
|
4016
3559
|
|
|
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.
|
|
3560
|
+
# Build the outliner’s View Layer tree from Blender’s tree_display_view_layer and per-datablock
|
|
3561
|
+
# expansion rules. Rows use native RNA addresses; duplicate appearances get distinct ordinal ids
|
|
3562
|
+
# while preserving their shared path. Collections begin expanded and object rows collapsed. Page
|
|
3563
|
+
# long child lists and build expansions only for retained objects. _OutlinerScope counts preceding
|
|
3564
|
+
# claims across the full tree so row identities remain stable between pages.
|
|
4058
3565
|
|
|
4059
3566
|
_OUTLINER_PAGE = 64
|
|
4060
3567
|
|
|
@@ -4120,11 +3627,9 @@ def _enum_icon(target, identifier):
|
|
|
4120
3627
|
(`outliner_draw.cc:2773-2785`); `rna_enum_constraint_type_items` carries
|
|
4121
3628
|
that function's own constraint switch. Reading the engine's enum is reading
|
|
4122
3629
|
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
|
|
3630
|
+
prop = target.bl_rna.properties[identifier]
|
|
3631
|
+
item = prop.enum_items.get(getattr(target, identifier))
|
|
3632
|
+
return (item.icon or None) if item is not None else None
|
|
4128
3633
|
|
|
4129
3634
|
|
|
4130
3635
|
def _outliner_object_icon(obj):
|
|
@@ -4134,30 +3639,26 @@ def _outliner_object_icon(obj):
|
|
|
4134
3639
|
if obj.type == "EMPTY":
|
|
4135
3640
|
if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
|
|
4136
3641
|
return "OUTLINER_OB_GROUP_INSTANCE"
|
|
4137
|
-
if
|
|
3642
|
+
if obj.empty_display_type == "IMAGE":
|
|
4138
3643
|
return "OUTLINER_OB_IMAGE"
|
|
4139
|
-
field =
|
|
4140
|
-
if field is not None and
|
|
3644
|
+
field = obj.field
|
|
3645
|
+
if field is not None and field.type != "NONE":
|
|
4141
3646
|
return "OUTLINER_OB_FORCE_FIELD"
|
|
4142
3647
|
return _enum_icon(obj, "type") or "OBJECT_DATA"
|
|
4143
3648
|
|
|
4144
3649
|
|
|
4145
3650
|
def _outliner_id_icon(datablock):
|
|
4146
3651
|
"""`tree_element_get_icon_from_id` for anything that is not an object."""
|
|
4147
|
-
if datablock is None:
|
|
4148
|
-
return "DOT"
|
|
4149
3652
|
if isinstance(datablock, bpy.types.Light):
|
|
4150
|
-
return _OUTLINER_LIGHT_ICONS.
|
|
4151
|
-
"OUTLINER_DATA_LIGHT")
|
|
3653
|
+
return _OUTLINER_LIGHT_ICONS[datablock.type]
|
|
4152
3654
|
if isinstance(datablock, bpy.types.LightProbe):
|
|
4153
|
-
return _OUTLINER_PROBE_ICONS.
|
|
4154
|
-
"LIGHTPROBE_SPHERE")
|
|
3655
|
+
return _OUTLINER_PROBE_ICONS[datablock.type]
|
|
4155
3656
|
rna = datablock.bl_rna
|
|
4156
3657
|
while rna is not None:
|
|
4157
3658
|
icon = _OUTLINER_ID_ICONS.get(rna.identifier)
|
|
4158
3659
|
if icon:
|
|
4159
3660
|
return icon
|
|
4160
|
-
rna =
|
|
3661
|
+
rna = rna.base
|
|
4161
3662
|
return "DOT"
|
|
4162
3663
|
|
|
4163
3664
|
|
|
@@ -4250,7 +3751,7 @@ def _outliner_modifier(md, obj, object_path, seen):
|
|
|
4250
3751
|
linked = getattr(md, member, None)
|
|
4251
3752
|
if linked is None:
|
|
4252
3753
|
continue
|
|
4253
|
-
linked_path = _rna_address(linked)
|
|
3754
|
+
linked_path = _rna_address(linked)
|
|
4254
3755
|
children.append(_outliner_row(linked_path, linked.name, kind, icon, seen))
|
|
4255
3756
|
break
|
|
4256
3757
|
system = getattr(md, "particle_system", None)
|
|
@@ -4291,8 +3792,8 @@ def _outliner_data(obj, seen):
|
|
|
4291
3792
|
children.extend(_outliner_bone(bone, path, seen) for bone in page)
|
|
4292
3793
|
if more:
|
|
4293
3794
|
row["more"] = more
|
|
4294
|
-
collections =
|
|
4295
|
-
if collections
|
|
3795
|
+
collections = data.collections
|
|
3796
|
+
if collections:
|
|
4296
3797
|
base = _outliner_row("%s.collections" % path, "Bone Collections",
|
|
4297
3798
|
"TSE_BONE_COLLECTION_BASE", "GROUP_BONE", seen)
|
|
4298
3799
|
page, more = _outliner_page(collections)
|
|
@@ -4369,10 +3870,9 @@ def _outliner_object(obj, view_layer, seen, chosen, active):
|
|
|
4369
3870
|
"MODIFIER_DATA", seen, object=obj.name)
|
|
4370
3871
|
base["children"] = [_outliner_modifier(md, obj, path, seen) for md in obj.modifiers]
|
|
4371
3872
|
children.append(base)
|
|
4372
|
-
groups =
|
|
4373
|
-
if groups
|
|
4374
|
-
# `expand_vertex_groups`:
|
|
4375
|
-
# the same set as "this object carries a `vertex_groups` collection".
|
|
3873
|
+
groups = obj.vertex_groups
|
|
3874
|
+
if groups:
|
|
3875
|
+
# `expand_vertex_groups`: objects with vertex groups expose their collection here.
|
|
4376
3876
|
base_path = "%s.vertex_groups" % path
|
|
4377
3877
|
base = _outliner_row(base_path, "Vertex Groups", "TSE_DEFGROUP_BASE", "GROUP_VERTEX",
|
|
4378
3878
|
seen, object=obj.name)
|
|
@@ -4429,13 +3929,12 @@ class _OutlinerScope:
|
|
|
4429
3929
|
self.local = {}
|
|
4430
3930
|
|
|
4431
3931
|
def _before(self, path):
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
return before
|
|
3932
|
+
for prefix, address, before in self.scopes:
|
|
3933
|
+
if path == address or path.startswith(prefix):
|
|
3934
|
+
return before
|
|
4436
3935
|
return None
|
|
4437
3936
|
|
|
4438
|
-
def get(self, path, default
|
|
3937
|
+
def get(self, path, default):
|
|
4439
3938
|
if path in self.local:
|
|
4440
3939
|
return self.local[path]
|
|
4441
3940
|
before = self._before(path)
|
|
@@ -4449,30 +3948,16 @@ class _OutlinerScope:
|
|
|
4449
3948
|
|
|
4450
3949
|
# ---- the node editor's tree ------------------------------------------------
|
|
4451
3950
|
#
|
|
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.
|
|
3951
|
+
# Read a whole node tree in one response. Fields are bl_rna properties of
|
|
3952
|
+
# Node, NodeSocket and NodeLink, matching rna_nodetree.cc.
|
|
3953
|
+
# The node view inspects and navigates; it refuses editing gestures in its
|
|
3954
|
+
# status line. RNA property writes remain on rna_set.
|
|
4469
3955
|
_NODE_SOCKET_VALUE_MAX = 4
|
|
4470
3956
|
|
|
4471
3957
|
|
|
4472
3958
|
def _node_socket_value(socket):
|
|
4473
3959
|
"""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`)."""
|
|
3960
|
+
array property; None when the socket has no default or its value is unsupported."""
|
|
4476
3961
|
if not hasattr(socket, "default_value"):
|
|
4477
3962
|
return None
|
|
4478
3963
|
value = socket.default_value
|
|
@@ -4509,18 +3994,17 @@ def _node_socket(socket, index):
|
|
|
4509
3994
|
"shape": socket.display_shape,
|
|
4510
3995
|
"enabled": bool(socket.enabled),
|
|
4511
3996
|
"hide": bool(socket.hide),
|
|
4512
|
-
"hideValue": bool(
|
|
3997
|
+
"hideValue": bool(socket.hide_value),
|
|
4513
3998
|
"linked": bool(socket.is_linked),
|
|
4514
|
-
"multiInput": bool(
|
|
3999
|
+
"multiInput": bool(socket.is_multi_input),
|
|
4515
4000
|
"index": index,
|
|
4516
4001
|
"value": _node_socket_value(socket),
|
|
4517
4002
|
# THE SLIDER'S RANGE. A scalar socket whose `default_value` property
|
|
4518
4003
|
# carries a BOUNDED soft range is drawn by Blender as a NUMBER SLIDER
|
|
4519
4004
|
# -- `ui_but_is_slider`'s `UI_BTYPE_NUM_SLIDER`, whose back is filled to
|
|
4520
4005
|
# 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.
|
|
4006
|
+
# `interface_widgets.cc`). The soft range determines the filled
|
|
4007
|
+
# proportion; an unbounded field has no slider fill.
|
|
4524
4008
|
#
|
|
4525
4009
|
# `soft_min`/`soft_max` are the UI range and `min`/`max` the hard one;
|
|
4526
4010
|
# Blender sliders against the SOFT pair. An unbounded property (a
|
|
@@ -4533,11 +4017,7 @@ def _node_socket(socket, index):
|
|
|
4533
4017
|
def _node_socket_range(socket):
|
|
4534
4018
|
"""The soft range of a scalar socket drawn as a SLIDER, when it is one.
|
|
4535
4019
|
|
|
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
|
|
4020
|
+
The socket subtype determines whether Blender draws a slider: `PROP_FACTOR` is what
|
|
4541
4021
|
`uiItemR`/`node_socket_button_default` pass `UI_ITEM_R_SLIDER` for, so a
|
|
4542
4022
|
factor draws as `UI_BTYPE_NUM_SLIDER` with a filled back and every other
|
|
4543
4023
|
number draws as a plain `UI_BTYPE_NUM`.
|
|
@@ -4550,61 +4030,28 @@ def _node_socket_range(socket):
|
|
|
4550
4030
|
prop = socket.bl_rna.properties["default_value"]
|
|
4551
4031
|
except (AttributeError, KeyError):
|
|
4552
4032
|
return {}
|
|
4553
|
-
if
|
|
4554
|
-
return {}
|
|
4555
|
-
if getattr(prop, "array_length", 0):
|
|
4033
|
+
if prop.type not in ("FLOAT", "INT"):
|
|
4556
4034
|
return {}
|
|
4557
|
-
if
|
|
4035
|
+
if prop.array_length:
|
|
4558
4036
|
return {}
|
|
4559
|
-
|
|
4560
|
-
high = getattr(prop, "soft_max", None)
|
|
4561
|
-
if low is None or high is None:
|
|
4037
|
+
if prop.subtype != "FACTOR":
|
|
4562
4038
|
return {}
|
|
4039
|
+
low = prop.soft_min
|
|
4040
|
+
high = prop.soft_max
|
|
4563
4041
|
if not (math.isfinite(low) and math.isfinite(high)) or high <= low:
|
|
4564
4042
|
return {}
|
|
4565
4043
|
return {"softMin": float(low), "softMax": float(high)}
|
|
4566
4044
|
|
|
4567
4045
|
|
|
4568
4046
|
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."""
|
|
4047
|
+
"""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
4048
|
return {
|
|
4602
4049
|
"name": node.name,
|
|
4603
4050
|
"idname": node.bl_idname,
|
|
4604
4051
|
"type": node.type,
|
|
4605
4052
|
"typeLabel": node.bl_label,
|
|
4606
4053
|
"label": node.label or None,
|
|
4607
|
-
"colorTag":
|
|
4054
|
+
"colorTag": node.color_tag,
|
|
4608
4055
|
# `Node.location` is the tree-space corner Blender lays out from
|
|
4609
4056
|
# (`node_update_basis`: `dy = loc.y`, `node_draw.cc:1297`); `width` is
|
|
4610
4057
|
# the value `NODE_WIDTH(node)` scales (`node_intern.hh:337`).
|
|
@@ -4620,11 +4067,11 @@ def _node_row(node):
|
|
|
4620
4067
|
# gives an OUTPUT-class node the output colour only when it is the
|
|
4621
4068
|
# active output (`node_draw.cc:1395-1401`).
|
|
4622
4069
|
"activeOutput": bool(getattr(node, "is_active_output", False)),
|
|
4623
|
-
"panelCount": len(
|
|
4070
|
+
"panelCount": len(node.panel_states),
|
|
4624
4071
|
"panels": [{"identifier": int(state.identifier),
|
|
4625
4072
|
"collapsed": bool(state.is_collapsed)}
|
|
4626
|
-
for state in
|
|
4627
|
-
"showOptions": bool(
|
|
4073
|
+
for state in node.panel_states],
|
|
4074
|
+
"showOptions": bool(node.show_options),
|
|
4628
4075
|
"inputs": [_node_socket(socket, index) for index, socket in enumerate(node.inputs)],
|
|
4629
4076
|
"outputs": [_node_socket(socket, index) for index, socket in enumerate(node.outputs)],
|
|
4630
4077
|
}
|
|
@@ -4644,7 +4091,7 @@ def _node_tree_of(path, material):
|
|
|
4644
4091
|
raise ValueError("The engine holds no material named %r" % (material,))
|
|
4645
4092
|
else:
|
|
4646
4093
|
obj = bpy.context.view_layer.objects.active
|
|
4647
|
-
mat =
|
|
4094
|
+
mat = obj.active_material if obj is not None else None
|
|
4648
4095
|
if mat is None:
|
|
4649
4096
|
return None, None, None
|
|
4650
4097
|
if not mat.use_nodes:
|
|
@@ -4707,32 +4154,7 @@ def _uv_object_of(object_name):
|
|
|
4707
4154
|
|
|
4708
4155
|
|
|
4709
4156
|
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
|
-
"""
|
|
4157
|
+
"""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
4158
|
obj = _uv_object_of(object_name)
|
|
4737
4159
|
if obj is None:
|
|
4738
4160
|
return {"object": None, "mesh": None, "layers": [], "active": None,
|
|
@@ -4811,7 +4233,7 @@ def _uv_backdrop_image(obj):
|
|
|
4811
4233
|
(`ED_space_image_auto_set`, `space_image.cc:60-101`). There is no
|
|
4812
4234
|
`SpaceImage` here, so the second half is the whole rule: the active
|
|
4813
4235
|
material's node tree's active `TEX_IMAGE`."""
|
|
4814
|
-
mat =
|
|
4236
|
+
mat = obj.active_material
|
|
4815
4237
|
if mat is None or not mat.use_nodes or mat.node_tree is None:
|
|
4816
4238
|
return None
|
|
4817
4239
|
nodes = [n for n in mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image is not None]
|
|
@@ -4824,25 +4246,7 @@ def _uv_backdrop_image(obj):
|
|
|
4824
4246
|
"source": image.source, "hasData": bool(image.has_data)}
|
|
4825
4247
|
|
|
4826
4248
|
|
|
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.
|
|
4249
|
+
# 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
4250
|
|
|
4847
4251
|
|
|
4848
4252
|
def _rig_armature_of(obj):
|
|
@@ -4854,170 +4258,18 @@ def _rig_armature_of(obj):
|
|
|
4854
4258
|
return obj.find_armature()
|
|
4855
4259
|
|
|
4856
4260
|
|
|
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
4261
|
def _matrix_rows(matrix):
|
|
4882
4262
|
return [[float(v) for v in row] for row in matrix]
|
|
4883
4263
|
|
|
4884
4264
|
|
|
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
4265
|
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"
|
|
4266
|
+
"""Return the first matching channelbag's F-curves, or the first bag's when no slot is supplied; no match yields an empty list."""
|
|
4267
|
+
for layer in action.layers:
|
|
4268
|
+
for strip in layer.strips:
|
|
4269
|
+
for bag in strip.channelbags:
|
|
4270
|
+
if slot_handle is None or int(bag.slot_handle) == int(slot_handle):
|
|
4271
|
+
return list(bag.fcurves)
|
|
4272
|
+
return []
|
|
5021
4273
|
|
|
5022
4274
|
|
|
5023
4275
|
_BONE_PATH = re.compile(r'^pose\.bones\["(.+)"\]\.(location|rotation_quaternion|'
|
|
@@ -5040,7 +4292,7 @@ def _key_columns(curves):
|
|
|
5040
4292
|
# EXTREME wins over KEYFRAME the way Blender's own merge does:
|
|
5041
4293
|
# `nupdate_ak_bezt` keeps the "most significant" type
|
|
5042
4294
|
# (`keyframes_keylist.cc`, `KEYFRAME_STATE` ordering).
|
|
5043
|
-
if _KEY_TYPE_RANK
|
|
4295
|
+
if _KEY_TYPE_RANK[key.type] > _KEY_TYPE_RANK[record["type"]]:
|
|
5044
4296
|
record["type"] = key.type
|
|
5045
4297
|
return columns
|
|
5046
4298
|
|
|
@@ -5065,12 +4317,12 @@ def _summary_objects():
|
|
|
5065
4317
|
"""
|
|
5066
4318
|
out = []
|
|
5067
4319
|
for obj in bpy.context.view_layer.objects:
|
|
5068
|
-
adt =
|
|
5069
|
-
action =
|
|
4320
|
+
adt = obj.animation_data
|
|
4321
|
+
action = adt.action if adt is not None else None
|
|
5070
4322
|
if action is None:
|
|
5071
4323
|
continue
|
|
5072
|
-
slot =
|
|
5073
|
-
curves
|
|
4324
|
+
slot = adt.action_slot
|
|
4325
|
+
curves = _action_channelbag(action, slot.handle if slot is not None else None)
|
|
5074
4326
|
columns = _key_columns(curves)
|
|
5075
4327
|
out.append({
|
|
5076
4328
|
"object": obj.name,
|
|
@@ -5082,31 +4334,7 @@ def _summary_objects():
|
|
|
5082
4334
|
|
|
5083
4335
|
|
|
5084
4336
|
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
|
-
"""
|
|
4337
|
+
"""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
4338
|
if object_name:
|
|
5111
4339
|
obj = bpy.data.objects.get(object_name)
|
|
5112
4340
|
if obj is None:
|
|
@@ -5143,14 +4371,7 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5143
4371
|
"slot": None,
|
|
5144
4372
|
"tracks": [],
|
|
5145
4373
|
"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.
|
|
4374
|
+
# 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
4375
|
"summary": _summary_objects() if summary else [],
|
|
5155
4376
|
}
|
|
5156
4377
|
adt = arm_obj.animation_data if arm_obj is not None else None
|
|
@@ -5164,11 +4385,11 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5164
4385
|
"and an empty summary row, which is Blender's own empty state.")
|
|
5165
4386
|
return header
|
|
5166
4387
|
action = assigned
|
|
5167
|
-
slot =
|
|
4388
|
+
slot = adt.action_slot if adt is not None else None
|
|
5168
4389
|
if action_name and (assigned is None or action_name != assigned.name):
|
|
5169
4390
|
# 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
|
-
#
|
|
4391
|
+
# baked against this armature WITHOUT assigning it, so the file is untouched. The
|
|
4392
|
+
# owner's identifier selects its slot, otherwise the first object slot is used.
|
|
5172
4393
|
action = bpy.data.actions.get(action_name)
|
|
5173
4394
|
if action is None:
|
|
5174
4395
|
raise ValueError("The engine holds no action named %r" % (action_name,))
|
|
@@ -5176,23 +4397,20 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5176
4397
|
# identifier names this object (`OB<name>`), else the only object slot. A layered action
|
|
5177
4398
|
# with several object slots and none for this armature has no right answer; its first
|
|
5178
4399
|
# object slot is used and said in the reason.
|
|
5179
|
-
slots = [one for one in
|
|
5180
|
-
slot = next((one for one in slots if
|
|
4400
|
+
slots = [one for one in action.slots if one.target_id_type == "OBJECT"]
|
|
4401
|
+
slot = next((one for one in slots if one.identifier == "OB" + arm_obj.name), None)
|
|
5181
4402
|
if slot is None and slots:
|
|
5182
4403
|
slot = slots[0]
|
|
5183
4404
|
if len(slots) > 1:
|
|
5184
4405
|
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"])
|
|
4406
|
+
% (action.name, len(slots), arm_obj.name, slot.identifier))
|
|
4407
|
+
curves = _action_channelbag(action, slot.handle if slot is not None else None)
|
|
4408
|
+
# Report object-slot count so consumers share a clip across matching armatures only when its channel set is independent of the owner.
|
|
4409
|
+
header["objectSlots"] = len([one for one in action.slots
|
|
4410
|
+
if one.target_id_type == "OBJECT"])
|
|
5192
4411
|
header.update({
|
|
5193
4412
|
"action": action.name,
|
|
5194
|
-
"slot":
|
|
5195
|
-
"channels": shape,
|
|
4413
|
+
"slot": slot.name_display if slot is not None else None,
|
|
5196
4414
|
"fcurves": len(curves),
|
|
5197
4415
|
"keys": sum(len(fc.keyframe_points) for fc in curves),
|
|
5198
4416
|
})
|
|
@@ -5208,32 +4426,32 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5208
4426
|
# THE ACTION'S OWN RANGE (`Action.frame_range`: its keys, or its manual range) with its keys:
|
|
5209
4427
|
# what a strip or the action line plays, sampled through `FCurve.evaluate`, which applies the
|
|
5210
4428
|
# 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]
|
|
4429
|
+
span = action.frame_range
|
|
4430
|
+
first = int(math.floor(min(frames[0], span[0])))
|
|
4431
|
+
last = int(math.ceil(max(frames[-1], span[1])))
|
|
5214
4432
|
if last <= first:
|
|
5215
4433
|
last = first + 1
|
|
5216
4434
|
header["clipStart"] = first
|
|
5217
4435
|
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.
|
|
4436
|
+
# Report the active action range including Cycles modifier extensions; null bounds are unbounded. The manual frame range does not enter it.
|
|
5221
4437
|
header["keysStart"], header["keysEnd"] = _action_key_range(action, first, last)
|
|
5222
4438
|
channels = {}
|
|
5223
4439
|
others = set()
|
|
5224
4440
|
for fcurve in curves:
|
|
5225
4441
|
match = _BONE_PATH.match(fcurve.data_path)
|
|
5226
4442
|
if match is None:
|
|
5227
|
-
# NOT A BONE'S CHANNEL:
|
|
5228
|
-
#
|
|
5229
|
-
|
|
4443
|
+
# NOT A BONE'S CHANNEL: a custom property, a bone channel this door does not read.
|
|
4444
|
+
# Named, so it is never dropped quietly. The armature object's OWN transform is not
|
|
4445
|
+
# this door's either, but it plays: the scene's movie (`rna_scene_movie`) carries it.
|
|
4446
|
+
if _OBJECT_PATH.match(fcurve.data_path) is None:
|
|
4447
|
+
others.add(fcurve.data_path)
|
|
5230
4448
|
continue
|
|
5231
4449
|
bone, prop = match.group(1), match.group(2)
|
|
5232
4450
|
channels.setdefault((bone, prop), {})[int(fcurve.array_index)] = fcurve
|
|
5233
4451
|
if not channels:
|
|
5234
|
-
header["reason"] = ("%r
|
|
5235
|
-
"
|
|
5236
|
-
"Timeline still draws
|
|
4452
|
+
header["reason"] = ("%r has no pose-bone transform channels. The scene's movie "
|
|
4453
|
+
"(`rna_scene_movie`) carries supported object, material and shape-key "
|
|
4454
|
+
"animation; the Timeline still draws this action's keys." % (action.name,))
|
|
5237
4455
|
return header
|
|
5238
4456
|
tracks = []
|
|
5239
4457
|
unplayed = []
|
|
@@ -5247,12 +4465,621 @@ def rna_action_clip(object_name=None, bake=True, action_name=None, summary=True)
|
|
|
5247
4465
|
header["unsupported"] = ["curves on %s" % ", ".join(sorted(others))]
|
|
5248
4466
|
header.update({
|
|
5249
4467
|
"tracks": tracks,
|
|
4468
|
+
"bones": _three_bones(arm_obj, channels, first, last),
|
|
5250
4469
|
"duration": (last - first) / fps,
|
|
5251
4470
|
"unplayedBones": unplayed,
|
|
5252
4471
|
})
|
|
5253
4472
|
return header
|
|
5254
4473
|
|
|
5255
4474
|
|
|
4475
|
+
def _three_bones(arm_obj, channels, first, last):
|
|
4476
|
+
"""THE ACTION AS THREE.JS PLAYS IT: per bone it keys, the bone's LOCAL transform (relative to
|
|
4477
|
+
its parent bone, as a `THREE.Bone` carries it) at every integer frame from `first` to `last`.
|
|
4478
|
+
|
|
4479
|
+
Blender evaluates every channel (`FCurve.evaluate`: its keys, handles, interpolation, easing,
|
|
4480
|
+
extrapolation and modifiers); a component the action does not key keeps the bone's own value.
|
|
4481
|
+
The basis is composed by the bone's rotation mode and placed by its rest relative to its
|
|
4482
|
+
parent's rest, `local = (parent.matrix_local^-1 @ bone.matrix_local) @ basis`, so a clip moves
|
|
4483
|
+
a bone exactly as Blender's pose does at every frame, and three.js interpolates between them.
|
|
4484
|
+
|
|
4485
|
+
Each column crosses as base64 Float32: `position` three per frame, `quaternion` four (x, y, z,
|
|
4486
|
+
w, as three reads them, kept on one hemisphere so a slerp never turns the long way), `scale`
|
|
4487
|
+
three. A column whose every sample equals its first crosses as that one sample."""
|
|
4488
|
+
frames = list(range(first, last + 1))
|
|
4489
|
+
by_bone = {}
|
|
4490
|
+
for (bone, prop), components in channels.items():
|
|
4491
|
+
by_bone.setdefault(bone, {})[prop] = components
|
|
4492
|
+
out = []
|
|
4493
|
+
for name in sorted(by_bone):
|
|
4494
|
+
pbone = arm_obj.pose.bones.get(name)
|
|
4495
|
+
if pbone is None:
|
|
4496
|
+
continue
|
|
4497
|
+
rest = pbone.bone.matrix_local
|
|
4498
|
+
parent = pbone.bone.parent
|
|
4499
|
+
rest_relative = (parent.matrix_local.inverted() @ rest) if parent is not None else rest.copy()
|
|
4500
|
+
props = by_bone[name]
|
|
4501
|
+
mode = pbone.rotation_mode
|
|
4502
|
+
own = {
|
|
4503
|
+
"location": list(pbone.location),
|
|
4504
|
+
"rotation_quaternion": list(pbone.rotation_quaternion),
|
|
4505
|
+
"rotation_euler": list(pbone.rotation_euler),
|
|
4506
|
+
"rotation_axis_angle": list(pbone.rotation_axis_angle),
|
|
4507
|
+
"scale": list(pbone.scale),
|
|
4508
|
+
}
|
|
4509
|
+
position = array.array("f")
|
|
4510
|
+
quaternion = array.array("f")
|
|
4511
|
+
scale = array.array("f")
|
|
4512
|
+
last_q = None
|
|
4513
|
+
keys = set()
|
|
4514
|
+
for components in props.values():
|
|
4515
|
+
for fcurve in components.values():
|
|
4516
|
+
for point in fcurve.keyframe_points:
|
|
4517
|
+
keys.add(round(float(point.co[0]), 3))
|
|
4518
|
+
for frame in frames:
|
|
4519
|
+
values = {prop: list(value) for prop, value in own.items()}
|
|
4520
|
+
for prop, components in props.items():
|
|
4521
|
+
for index, fcurve in components.items():
|
|
4522
|
+
if index < len(values[prop]):
|
|
4523
|
+
values[prop][index] = fcurve.evaluate(frame)
|
|
4524
|
+
if mode == "QUATERNION":
|
|
4525
|
+
rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
|
|
4526
|
+
elif mode == "AXIS_ANGLE":
|
|
4527
|
+
angle, x, y, z = values["rotation_axis_angle"]
|
|
4528
|
+
axis = mathutils.Vector((x, y, z))
|
|
4529
|
+
rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 1.0, 0.0), angle)
|
|
4530
|
+
else:
|
|
4531
|
+
rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
|
|
4532
|
+
basis = mathutils.Matrix.LocRotScale(mathutils.Vector(values["location"]), rotation,
|
|
4533
|
+
mathutils.Vector(values["scale"]))
|
|
4534
|
+
loc, rot, size = (rest_relative @ basis).decompose()
|
|
4535
|
+
if last_q is not None and last_q.dot(rot) < 0:
|
|
4536
|
+
rot.negate()
|
|
4537
|
+
last_q = rot
|
|
4538
|
+
position.extend((loc.x, loc.y, loc.z))
|
|
4539
|
+
quaternion.extend((rot.x, rot.y, rot.z, rot.w))
|
|
4540
|
+
scale.extend((size.x, size.y, size.z))
|
|
4541
|
+
|
|
4542
|
+
out.append({"bone": name, "keys": sorted(keys), "position": _column(position, 3),
|
|
4543
|
+
"quaternion": _column(quaternion, 4), "scale": _column(scale, 3)})
|
|
4544
|
+
return out
|
|
4545
|
+
|
|
4546
|
+
|
|
4547
|
+
# ------------------------------------------------------------- the scene's movie
|
|
4548
|
+
|
|
4549
|
+
# AN OBJECT'S OWN TRANSFORM CHANNELS, as an action keys them on the object (not on a bone).
|
|
4550
|
+
_OBJECT_PATH = re.compile(r'^(location|rotation_quaternion|rotation_euler|rotation_axis_angle|scale)$')
|
|
4551
|
+
# A CAMERA'S KEYABLE PROJECTION, as an action keys it on the camera's data.
|
|
4552
|
+
_CAMERA_PROPS = ("lens", "sensor_width", "sensor_height", "ortho_scale", "shift_x", "shift_y",
|
|
4553
|
+
"clip_start", "clip_end")
|
|
4554
|
+
|
|
4555
|
+
|
|
4556
|
+
def _slot_handle(action, owner, prefix, slot):
|
|
4557
|
+
"""Use the assigned slot, then the owner identifier, then the first slot of the owner’s ID type."""
|
|
4558
|
+
if slot is not None:
|
|
4559
|
+
return slot.handle
|
|
4560
|
+
slots = list(action.slots)
|
|
4561
|
+
if not slots:
|
|
4562
|
+
return None
|
|
4563
|
+
kind = {"OB": "OBJECT", "CA": "CAMERA", "NT": "NODETREE", "KE": "KEY", "SC": "SCENE"}[prefix]
|
|
4564
|
+
named = next((one for one in slots if one.identifier == prefix + owner.name), None)
|
|
4565
|
+
if named is not None:
|
|
4566
|
+
return named.handle
|
|
4567
|
+
typed = [one for one in slots if one.target_id_type == kind]
|
|
4568
|
+
return typed[0].handle if typed else None
|
|
4569
|
+
|
|
4570
|
+
|
|
4571
|
+
def _object_constraints(obj):
|
|
4572
|
+
"""An object's constraint stack, in order: Damped Track and Track To carry what the presenter
|
|
4573
|
+
evaluates them from; every other type is named and played only in Blender."""
|
|
4574
|
+
stack = []
|
|
4575
|
+
for con in obj.constraints:
|
|
4576
|
+
target = getattr(con, "target", None)
|
|
4577
|
+
entry = {"name": con.name, "type": con.type, "enabled": bool(con.enabled),
|
|
4578
|
+
"influence": float(con.influence)}
|
|
4579
|
+
if target is not None:
|
|
4580
|
+
entry["target"] = target.name
|
|
4581
|
+
if con.type in ("DAMPED_TRACK", "TRACK_TO"):
|
|
4582
|
+
entry["trackAxis"] = con.track_axis
|
|
4583
|
+
if con.type == "TRACK_TO":
|
|
4584
|
+
entry["upAxis"] = con.up_axis
|
|
4585
|
+
entry["useTargetZ"] = bool(con.use_target_z)
|
|
4586
|
+
stack.append(entry)
|
|
4587
|
+
return stack
|
|
4588
|
+
|
|
4589
|
+
|
|
4590
|
+
def _column(samples, stride):
|
|
4591
|
+
"""A sampled column as base64 Float32: its first sample alone when it never changes."""
|
|
4592
|
+
if len(samples) > stride and all(abs(samples[i] - samples[i % stride]) < 1e-7 for i in range(len(samples))):
|
|
4593
|
+
samples = samples[:stride]
|
|
4594
|
+
return {"count": len(samples) // stride, "base64": base64.b64encode(samples.tobytes()).decode("ascii")}
|
|
4595
|
+
|
|
4596
|
+
|
|
4597
|
+
def _owner_action_at(adt, frame):
|
|
4598
|
+
"""Use the active action at the scene frame, otherwise the last unmuted strip containing it,
|
|
4599
|
+
or the first unmuted strip when none contains it. Clamp to that strip's range and apply its
|
|
4600
|
+
scale and repeat. Return no action when no unmuted action strip exists."""
|
|
4601
|
+
if adt.action is not None:
|
|
4602
|
+
return adt.action, frame
|
|
4603
|
+
best = None
|
|
4604
|
+
for track in adt.nla_tracks:
|
|
4605
|
+
if track.mute:
|
|
4606
|
+
continue
|
|
4607
|
+
for strip in track.strips:
|
|
4608
|
+
if strip.mute or strip.action is None:
|
|
4609
|
+
continue
|
|
4610
|
+
if strip.frame_start <= frame <= strip.frame_end or best is None:
|
|
4611
|
+
best = strip
|
|
4612
|
+
if best is None:
|
|
4613
|
+
return None, frame
|
|
4614
|
+
length = (best.action_frame_end - best.action_frame_start) or 1.0
|
|
4615
|
+
scale = abs(best.scale) or 1.0
|
|
4616
|
+
at = min(max(frame, best.frame_start), best.frame_end)
|
|
4617
|
+
into = ((at - best.frame_start) / scale) % length if at < best.frame_end else length
|
|
4618
|
+
return best.action, best.action_frame_start + into
|
|
4619
|
+
|
|
4620
|
+
|
|
4621
|
+
def _sample_object(obj, pre, delta, first, last, unsupported):
|
|
4622
|
+
"""AN OBJECT'S MOVIE AS THREE.JS PLAYS IT: its local transform (`pre @ basis`, the basis with its
|
|
4623
|
+
delta transform, `BKE_object_to_mat4`) at every scene frame from `first` to `last`, each channel
|
|
4624
|
+
evaluated by Blender (`FCurve.evaluate`) from the action it shows there. Columns as the clip
|
|
4625
|
+
door's: position, quaternion (x, y, z, w, one hemisphere), scale; and `hidden` (one per frame,
|
|
4626
|
+
1 hidden) when its render visibility is keyed."""
|
|
4627
|
+
adt = obj.animation_data
|
|
4628
|
+
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)):
|
|
4629
|
+
unsupported.append("its NLA layering (the active action, else the top strip, is played)")
|
|
4630
|
+
position = array.array("f")
|
|
4631
|
+
quaternion = array.array("f")
|
|
4632
|
+
scale = array.array("f")
|
|
4633
|
+
hidden = array.array("f")
|
|
4634
|
+
keyed_hidden = False
|
|
4635
|
+
last_q = None
|
|
4636
|
+
mode = obj.rotation_mode
|
|
4637
|
+
own = {"location": list(obj.location), "rotation_quaternion": list(obj.rotation_quaternion),
|
|
4638
|
+
"rotation_euler": list(obj.rotation_euler), "rotation_axis_angle": list(obj.rotation_axis_angle),
|
|
4639
|
+
"scale": list(obj.scale), "hide_render": [float(obj.hide_render)]}
|
|
4640
|
+
curves_of = {}
|
|
4641
|
+
for frame in range(first, last + 1):
|
|
4642
|
+
action, at = _owner_action_at(adt, frame)
|
|
4643
|
+
values = {prop: list(value) for prop, value in own.items()}
|
|
4644
|
+
if action is not None:
|
|
4645
|
+
curves = curves_of.get(action.name)
|
|
4646
|
+
if curves is None:
|
|
4647
|
+
handle = _slot_handle(action, obj, "OB", adt.action_slot if action == adt.action else None)
|
|
4648
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4649
|
+
for fcurve in curves:
|
|
4650
|
+
if fcurve.data_path in values and fcurve.array_index < len(values[fcurve.data_path]):
|
|
4651
|
+
values[fcurve.data_path][fcurve.array_index] = fcurve.evaluate(at)
|
|
4652
|
+
if fcurve.data_path == "hide_render":
|
|
4653
|
+
keyed_hidden = True
|
|
4654
|
+
if mode == "QUATERNION":
|
|
4655
|
+
rotation = mathutils.Quaternion(values["rotation_quaternion"]).normalized()
|
|
4656
|
+
elif mode == "AXIS_ANGLE":
|
|
4657
|
+
angle, x, y, z = values["rotation_axis_angle"]
|
|
4658
|
+
axis = mathutils.Vector((x, y, z))
|
|
4659
|
+
rotation = mathutils.Quaternion(axis.normalized() if axis.length > 0 else (0.0, 0.0, 1.0), angle)
|
|
4660
|
+
else:
|
|
4661
|
+
rotation = mathutils.Euler(values["rotation_euler"], mode).to_quaternion()
|
|
4662
|
+
rotation = delta @ rotation
|
|
4663
|
+
location = mathutils.Vector(values["location"]) + obj.delta_location
|
|
4664
|
+
size = mathutils.Vector([a * b for a, b in zip(values["scale"], obj.delta_scale)])
|
|
4665
|
+
loc, rot, sca = (pre @ mathutils.Matrix.LocRotScale(location, rotation, size)).decompose()
|
|
4666
|
+
if last_q is not None and last_q.dot(rot) < 0:
|
|
4667
|
+
rot.negate()
|
|
4668
|
+
last_q = rot
|
|
4669
|
+
position.extend((loc.x, loc.y, loc.z))
|
|
4670
|
+
quaternion.extend((rot.x, rot.y, rot.z, rot.w))
|
|
4671
|
+
scale.extend((sca.x, sca.y, sca.z))
|
|
4672
|
+
hidden.append(1.0 if values["hide_render"][0] >= 0.5 else 0.0)
|
|
4673
|
+
out = {"start": first, "end": last, "position": _column(position, 3), "quaternion": _column(quaternion, 4),
|
|
4674
|
+
"scale": _column(scale, 3)}
|
|
4675
|
+
if keyed_hidden:
|
|
4676
|
+
out["hidden"] = _column(hidden, 1)
|
|
4677
|
+
return out
|
|
4678
|
+
|
|
4679
|
+
|
|
4680
|
+
def _sample_camera(data, first, last):
|
|
4681
|
+
"""A CAMERA'S PROJECTION AS THE MOVIE HAS IT: each of `_CAMERA_PROPS` at every scene frame, by
|
|
4682
|
+
Blender's evaluation of the action its data shows there."""
|
|
4683
|
+
adt = data.animation_data
|
|
4684
|
+
columns = {prop: array.array("f") for prop in _CAMERA_PROPS}
|
|
4685
|
+
curves_of = {}
|
|
4686
|
+
for frame in range(first, last + 1):
|
|
4687
|
+
action, at = _owner_action_at(adt, frame)
|
|
4688
|
+
values = {prop: float(getattr(data, prop)) for prop in _CAMERA_PROPS}
|
|
4689
|
+
if action is not None:
|
|
4690
|
+
curves = curves_of.get(action.name)
|
|
4691
|
+
if curves is None:
|
|
4692
|
+
handle = _slot_handle(action, data, "CA", adt.action_slot if action == adt.action else None)
|
|
4693
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4694
|
+
for fcurve in curves:
|
|
4695
|
+
if fcurve.data_path in values:
|
|
4696
|
+
values[fcurve.data_path] = fcurve.evaluate(at)
|
|
4697
|
+
for prop in _CAMERA_PROPS:
|
|
4698
|
+
columns[prop].append(values[prop])
|
|
4699
|
+
return {"start": first, "end": last, **{prop: _column(column, 1) for prop, column in columns.items()}}
|
|
4700
|
+
|
|
4701
|
+
|
|
4702
|
+
_MATERIAL_INPUTS = {"Base Color": 4, "Emission Color": 4, "Emission Strength": 1, "Alpha": 1}
|
|
4703
|
+
|
|
4704
|
+
|
|
4705
|
+
def _sample_material(material, first, last):
|
|
4706
|
+
"""Sample a material’s keyed Principled Base Color, Emission and Alpha through the action its
|
|
4707
|
+
node tree shows at each scene frame. Return only keyed inputs, or None when none are keyed."""
|
|
4708
|
+
tree = material.node_tree
|
|
4709
|
+
adt = tree.animation_data if tree is not None else None
|
|
4710
|
+
if adt is None:
|
|
4711
|
+
return None
|
|
4712
|
+
bsdf = next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None)
|
|
4713
|
+
if bsdf is None:
|
|
4714
|
+
return None
|
|
4715
|
+
index = {}
|
|
4716
|
+
for i, socket in enumerate(bsdf.inputs):
|
|
4717
|
+
index.setdefault(socket.name, i)
|
|
4718
|
+
paths = {}
|
|
4719
|
+
own = {}
|
|
4720
|
+
for name, size in _MATERIAL_INPUTS.items():
|
|
4721
|
+
paths['nodes["%s"].inputs[%d].default_value' % (bsdf.name, index[name])] = name
|
|
4722
|
+
value = bsdf.inputs[index[name]].default_value
|
|
4723
|
+
own[name] = [float(v) for v in value] if size > 1 else [float(value)]
|
|
4724
|
+
keyed = set()
|
|
4725
|
+
color, emissive, opacity = array.array("f"), array.array("f"), array.array("f")
|
|
4726
|
+
curves_of = {}
|
|
4727
|
+
for frame in range(first, last + 1):
|
|
4728
|
+
action, at = _owner_action_at(adt, frame)
|
|
4729
|
+
values = {name: list(value) for name, value in own.items()}
|
|
4730
|
+
if action is not None:
|
|
4731
|
+
curves = curves_of.get(action.name)
|
|
4732
|
+
if curves is None:
|
|
4733
|
+
handle = _slot_handle(action, tree, "NT", adt.action_slot if action == adt.action else None)
|
|
4734
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4735
|
+
for fcurve in curves:
|
|
4736
|
+
name = paths.get(fcurve.data_path)
|
|
4737
|
+
if name is not None and fcurve.array_index < len(values[name]):
|
|
4738
|
+
values[name][fcurve.array_index] = fcurve.evaluate(at)
|
|
4739
|
+
keyed.add(name)
|
|
4740
|
+
base = values["Base Color"]
|
|
4741
|
+
color.extend(base[:3])
|
|
4742
|
+
glow = values["Emission Color"]
|
|
4743
|
+
strength = values["Emission Strength"][0]
|
|
4744
|
+
emissive.extend((glow[0] * strength, glow[1] * strength, glow[2] * strength))
|
|
4745
|
+
opacity.append(values["Alpha"][0])
|
|
4746
|
+
if not keyed:
|
|
4747
|
+
return None
|
|
4748
|
+
out = {"start": first, "end": last}
|
|
4749
|
+
if "Base Color" in keyed:
|
|
4750
|
+
out["color"] = _column(color, 3)
|
|
4751
|
+
if keyed & {"Emission Color", "Emission Strength"}:
|
|
4752
|
+
out["emissive"] = _column(emissive, 3)
|
|
4753
|
+
if "Alpha" in keyed:
|
|
4754
|
+
out["opacity"] = _column(opacity, 1)
|
|
4755
|
+
return out
|
|
4756
|
+
|
|
4757
|
+
|
|
4758
|
+
def _sample_shapes(obj, first, last, depsgraph):
|
|
4759
|
+
"""Sample each keyed relative shape key’s vertex offsets and values at every scene frame,
|
|
4760
|
+
including the presented geometry’s current value. mapped is False when modifiers change vertex
|
|
4761
|
+
correspondence. Return None when no key is keyed."""
|
|
4762
|
+
mesh = obj.data
|
|
4763
|
+
keys = mesh.shape_keys if mesh is not None else None
|
|
4764
|
+
if keys is None or not keys.use_relative or len(keys.key_blocks) < 2:
|
|
4765
|
+
return None
|
|
4766
|
+
adt = keys.animation_data
|
|
4767
|
+
if adt is None:
|
|
4768
|
+
return None
|
|
4769
|
+
blocks = list(keys.key_blocks)[1:]
|
|
4770
|
+
path_of = {'key_blocks["%s"].value' % block.name: block.name for block in blocks}
|
|
4771
|
+
values = {block.name: array.array("f") for block in blocks}
|
|
4772
|
+
keyed = set()
|
|
4773
|
+
curves_of = {}
|
|
4774
|
+
for frame in range(first, last + 1):
|
|
4775
|
+
action, at = _owner_action_at(adt, frame)
|
|
4776
|
+
now = {block.name: float(block.value) for block in blocks}
|
|
4777
|
+
if action is not None:
|
|
4778
|
+
curves = curves_of.get(action.name)
|
|
4779
|
+
if curves is None:
|
|
4780
|
+
handle = _slot_handle(action, keys, "KE", adt.action_slot if action == adt.action else None)
|
|
4781
|
+
curves = curves_of[action.name] = _action_channelbag(action, handle)
|
|
4782
|
+
for fcurve in curves:
|
|
4783
|
+
name = path_of.get(fcurve.data_path)
|
|
4784
|
+
if name is not None:
|
|
4785
|
+
now[name] = fcurve.evaluate(at)
|
|
4786
|
+
keyed.add(name)
|
|
4787
|
+
for name, value in now.items():
|
|
4788
|
+
values[name].append(value)
|
|
4789
|
+
if not keyed:
|
|
4790
|
+
return None
|
|
4791
|
+
count = len(obj.data.vertices)
|
|
4792
|
+
try:
|
|
4793
|
+
mapped = len(obj.evaluated_get(depsgraph).data.vertices) == count
|
|
4794
|
+
except Exception:
|
|
4795
|
+
mapped = False
|
|
4796
|
+
out = []
|
|
4797
|
+
for block in blocks:
|
|
4798
|
+
if block.name not in keyed:
|
|
4799
|
+
continue
|
|
4800
|
+
own = array.array("f", [0.0]) * (3 * count)
|
|
4801
|
+
base = array.array("f", [0.0]) * (3 * count)
|
|
4802
|
+
block.data.foreach_get("co", own)
|
|
4803
|
+
(block.relative_key or keys.reference_key).data.foreach_get("co", base)
|
|
4804
|
+
delta = array.array("f", (a - b for a, b in zip(own, base)))
|
|
4805
|
+
out.append({"name": block.name, "delta": {"count": count, "base64": base64.b64encode(delta.tobytes()).decode("ascii")},
|
|
4806
|
+
"values": _column(values[block.name], 1), "current": float(block.value)})
|
|
4807
|
+
return {"object": obj.name, "start": first, "end": last, "vertices": count, "mapped": mapped, "keys": out}
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def rna_scene_movie():
|
|
4811
|
+
"""Scene animation for Timeline preview and export: range, rate, camera, bound
|
|
4812
|
+
markers and sampled object, camera, material and shape-key columns. Object
|
|
4813
|
+
records retain parent-space transforms and constraints. Cameras named by
|
|
4814
|
+
the scene or a marker are included whether animated or not. Sampling leaves
|
|
4815
|
+
the scene frame unchanged."""
|
|
4816
|
+
scene = bpy.context.scene
|
|
4817
|
+
view_layer = bpy.context.view_layer
|
|
4818
|
+
fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
|
|
4819
|
+
markers = []
|
|
4820
|
+
for marker in scene.timeline_markers:
|
|
4821
|
+
markers.append({"name": marker.name, "frame": int(marker.frame),
|
|
4822
|
+
"camera": marker.camera.name if marker.camera is not None else None})
|
|
4823
|
+
wanted = {marker["camera"] for marker in markers if marker["camera"]}
|
|
4824
|
+
if scene.camera is not None:
|
|
4825
|
+
wanted.add(scene.camera.name)
|
|
4826
|
+
|
|
4827
|
+
def transform(path):
|
|
4828
|
+
return path if _OBJECT_PATH.match(path) or path == "hide_render" else None
|
|
4829
|
+
|
|
4830
|
+
def projection(path):
|
|
4831
|
+
return path if path in _CAMERA_PROPS else None
|
|
4832
|
+
|
|
4833
|
+
objects = []
|
|
4834
|
+
animated = {}
|
|
4835
|
+
for obj in view_layer.objects:
|
|
4836
|
+
own = _owner_animated(obj, transform, animated)
|
|
4837
|
+
constraints = _object_constraints(obj)
|
|
4838
|
+
camera = None
|
|
4839
|
+
if obj.type == "CAMERA" and obj.data is not None:
|
|
4840
|
+
data = obj.data
|
|
4841
|
+
camera = {
|
|
4842
|
+
"type": data.type, "lens": float(data.lens), "sensor_width": float(data.sensor_width),
|
|
4843
|
+
"sensor_height": float(data.sensor_height), "sensor_fit": data.sensor_fit,
|
|
4844
|
+
"ortho_scale": float(data.ortho_scale), "clip_start": float(data.clip_start),
|
|
4845
|
+
"clip_end": float(data.clip_end), "shift_x": float(data.shift_x), "shift_y": float(data.shift_y),
|
|
4846
|
+
"renderHidden": bool(obj.hide_render),
|
|
4847
|
+
**({"keyed": True} if _owner_animated(data, projection, animated) else {}),
|
|
4848
|
+
}
|
|
4849
|
+
if not own and (camera is None or (obj.name not in wanted and "keyed" not in camera)):
|
|
4850
|
+
continue
|
|
4851
|
+
unsupported = []
|
|
4852
|
+
parent = obj.parent
|
|
4853
|
+
if parent is None:
|
|
4854
|
+
pre = mathutils.Matrix.Identity(4)
|
|
4855
|
+
elif obj.parent_type == "OBJECT":
|
|
4856
|
+
pre = obj.matrix_parent_inverse
|
|
4857
|
+
else:
|
|
4858
|
+
# Parented to a bone, a vertex or a curve: the offset it has now is held.
|
|
4859
|
+
pre = (parent.matrix_world.inverted_safe() @ obj.matrix_world) @ obj.matrix_basis.inverted_safe()
|
|
4860
|
+
unsupported.append("its %s parenting (held where it is now)" % obj.parent_type.lower())
|
|
4861
|
+
mode = obj.rotation_mode
|
|
4862
|
+
if mode == "QUATERNION":
|
|
4863
|
+
delta = obj.delta_rotation_quaternion.normalized()
|
|
4864
|
+
elif mode == "AXIS_ANGLE":
|
|
4865
|
+
delta = mathutils.Quaternion((1.0, 0.0, 0.0, 0.0))
|
|
4866
|
+
else:
|
|
4867
|
+
delta = mathutils.Euler(obj.delta_rotation_euler, mode).to_quaternion()
|
|
4868
|
+
adt = obj.animation_data
|
|
4869
|
+
if adt is not None:
|
|
4870
|
+
for path in sorted({fc.data_path for fc in adt.drivers if _OBJECT_PATH.match(fc.data_path)}):
|
|
4871
|
+
unsupported.append("the driver on its %s" % path)
|
|
4872
|
+
for con in constraints:
|
|
4873
|
+
if con["enabled"] and con["type"] not in ("DAMPED_TRACK", "TRACK_TO"):
|
|
4874
|
+
unsupported.append("its %s constraint %r" % (con["type"].replace("_", " ").title(), con["name"]))
|
|
4875
|
+
sampled = _sample_object(obj, pre, delta, int(scene.frame_start), int(scene.frame_end),
|
|
4876
|
+
unsupported) if own else None
|
|
4877
|
+
if camera is not None and camera.pop("keyed", False):
|
|
4878
|
+
camera["sampled"] = _sample_camera(obj.data, int(scene.frame_start), int(scene.frame_end))
|
|
4879
|
+
objects.append({
|
|
4880
|
+
"name": obj.name,
|
|
4881
|
+
"type": obj.type,
|
|
4882
|
+
"parent": parent.name if parent is not None else None,
|
|
4883
|
+
**({"sampled": sampled} if sampled is not None else {}),
|
|
4884
|
+
"rotationMode": mode,
|
|
4885
|
+
"channels": {
|
|
4886
|
+
"location": [float(v) for v in obj.location],
|
|
4887
|
+
"rotation_quaternion": [float(v) for v in obj.rotation_quaternion],
|
|
4888
|
+
"rotation_euler": [float(v) for v in obj.rotation_euler],
|
|
4889
|
+
"rotation_axis_angle": [float(v) for v in obj.rotation_axis_angle],
|
|
4890
|
+
"scale": [float(v) for v in obj.scale],
|
|
4891
|
+
},
|
|
4892
|
+
"delta": {"location": [float(v) for v in obj.delta_location], "rotation": [float(v) for v in delta],
|
|
4893
|
+
"scale": [float(v) for v in obj.delta_scale]},
|
|
4894
|
+
"pre": _matrix_rows(pre),
|
|
4895
|
+
"constraints": constraints,
|
|
4896
|
+
**({"camera": camera} if camera is not None else {}),
|
|
4897
|
+
**({"unsupported": unsupported} if unsupported else {}),
|
|
4898
|
+
})
|
|
4899
|
+
# Sample keyed material inputs and shape keys.
|
|
4900
|
+
first, last = int(scene.frame_start), int(scene.frame_end)
|
|
4901
|
+
materials = []
|
|
4902
|
+
seen = set()
|
|
4903
|
+
for obj in view_layer.objects:
|
|
4904
|
+
for slot in obj.material_slots:
|
|
4905
|
+
material = slot.material
|
|
4906
|
+
if material is None or material.name in seen:
|
|
4907
|
+
continue
|
|
4908
|
+
seen.add(material.name)
|
|
4909
|
+
sampled = _sample_material(material, first, last)
|
|
4910
|
+
if sampled is not None:
|
|
4911
|
+
materials.append({"name": material.name, **sampled})
|
|
4912
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
4913
|
+
shapes = [one for one in (_sample_shapes(obj, first, last, depsgraph) for obj in view_layer.objects if obj.type == "MESH") if one]
|
|
4914
|
+
return {
|
|
4915
|
+
"materials": materials,
|
|
4916
|
+
"shapes": shapes,
|
|
4917
|
+
"scene": {"name": scene.name, "start": int(scene.frame_start), "end": int(scene.frame_end), "fps": fps,
|
|
4918
|
+
"current": int(scene.frame_current),
|
|
4919
|
+
"camera": scene.camera.name if scene.camera is not None else None},
|
|
4920
|
+
"markers": sorted(markers, key=lambda marker: marker["frame"]),
|
|
4921
|
+
"objects": objects,
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
|
|
4925
|
+
def rna_sound_scene(first, last):
|
|
4926
|
+
"""The scene's sound for the page to mix (docs/adr/movie-sound-and-react-frames.md,
|
|
4927
|
+
decision 2). Native audio mixing is unavailable in this Blender. Describe the movie's frames
|
|
4928
|
+
`first` to `last` for `@volter/editor-blender` to render in an OfflineAudioContext:
|
|
4929
|
+
|
|
4930
|
+
- the sequencer's sound strips: each one's file (or, packed, a copy written beside the
|
|
4931
|
+
session), where its sound starts and where the strip is cut, its speed, whether it is muted
|
|
4932
|
+
or mono, and its volume and pan at every frame, read through the scene's F-Curves (a fade
|
|
4933
|
+
is keys on volume);
|
|
4934
|
+
- the speakers: each one's file, the frames its NLA sound strips start on, its volume and
|
|
4935
|
+
distance settings, and its place and facing at every frame, with the scene camera's (the
|
|
4936
|
+
listener). Those are evaluated, so the frame moves and is restored.
|
|
4937
|
+
|
|
4938
|
+
The scene's own volume and distance model come too. READ ONLY: no history, save or present."""
|
|
4939
|
+
scene = bpy.context.scene
|
|
4940
|
+
fps = float(scene.render.fps) / float(scene.render.fps_base or 1.0)
|
|
4941
|
+
count = last - first + 1
|
|
4942
|
+
|
|
4943
|
+
def sound_file(sound):
|
|
4944
|
+
if sound is None:
|
|
4945
|
+
return None
|
|
4946
|
+
if sound.packed_file is not None:
|
|
4947
|
+
path = os.path.join(ROOT, "sound-%s" % hashlib.sha256(sound.name.encode("utf-8")).hexdigest()[:16])
|
|
4948
|
+
with open(path, "wb") as out:
|
|
4949
|
+
out.write(sound.packed_file.data)
|
|
4950
|
+
return {"packed": path}
|
|
4951
|
+
# normalized: a path saved relative to the .blend ("//../../sounds/x.wav") keeps its ".." segments otherwise
|
|
4952
|
+
return {"path": os.path.normpath(bpy.path.abspath(sound.filepath, library=sound.library))}
|
|
4953
|
+
|
|
4954
|
+
# A strip's keyed volume and pan are the scene's F-Curves on the strip's own path.
|
|
4955
|
+
adt = scene.animation_data
|
|
4956
|
+
curves = {}
|
|
4957
|
+
if adt is not None and adt.action is not None:
|
|
4958
|
+
handle = _slot_handle(adt.action, scene, "SC", adt.action_slot)
|
|
4959
|
+
for fcurve in _action_channelbag(adt.action, handle):
|
|
4960
|
+
curves[(fcurve.data_path, fcurve.array_index)] = fcurve
|
|
4961
|
+
|
|
4962
|
+
def sampled(strip, prop):
|
|
4963
|
+
constant = float(getattr(strip, prop))
|
|
4964
|
+
fcurve = next((curves[(path, 0)] for path in (
|
|
4965
|
+
'sequence_editor.strips_all["%s"].%s' % (strip.name, prop),
|
|
4966
|
+
'sequence_editor.sequences_all["%s"].%s' % (strip.name, prop)) if (path, 0) in curves), None)
|
|
4967
|
+
values = array.array("f", (fcurve.evaluate(frame) if fcurve is not None else constant
|
|
4968
|
+
for frame in range(first, last + 1)))
|
|
4969
|
+
return _column(values, 1)
|
|
4970
|
+
|
|
4971
|
+
editor = scene.sequence_editor
|
|
4972
|
+
strips = []
|
|
4973
|
+
if editor is not None:
|
|
4974
|
+
channels = editor.channels
|
|
4975
|
+
for strip in editor.strips_all:
|
|
4976
|
+
if strip.type != "SOUND":
|
|
4977
|
+
continue
|
|
4978
|
+
channel = channels[strip.channel] if 0 <= strip.channel < len(channels) else None
|
|
4979
|
+
strips.append({
|
|
4980
|
+
"name": strip.name,
|
|
4981
|
+
"file": sound_file(strip.sound),
|
|
4982
|
+
"mono": bool(strip.sound is not None and strip.sound.use_mono),
|
|
4983
|
+
"muted": bool(strip.mute or (channel is not None and channel.mute)),
|
|
4984
|
+
"start": float(strip.frame_start),
|
|
4985
|
+
"finalStart": float(strip.frame_final_start),
|
|
4986
|
+
"finalEnd": float(strip.frame_final_end),
|
|
4987
|
+
"speed": float(getattr(strip, "speed_factor", 1.0)),
|
|
4988
|
+
"volume": sampled(strip, "volume"),
|
|
4989
|
+
"pan": sampled(strip, "pan"),
|
|
4990
|
+
})
|
|
4991
|
+
|
|
4992
|
+
speakers = [obj for obj in scene.objects if obj.type == "SPEAKER" and obj.data is not None]
|
|
4993
|
+
described = []
|
|
4994
|
+
listener = None
|
|
4995
|
+
if speakers:
|
|
4996
|
+
tracks = {obj.name: [] for obj in speakers}
|
|
4997
|
+
for obj in speakers:
|
|
4998
|
+
for track in (obj.animation_data.nla_tracks if obj.animation_data is not None else ()):
|
|
4999
|
+
if track.mute:
|
|
5000
|
+
continue
|
|
5001
|
+
for strip in track.strips:
|
|
5002
|
+
if strip.type == "SOUND" and not strip.mute:
|
|
5003
|
+
tracks[obj.name].append(float(strip.frame_start))
|
|
5004
|
+
places = {obj.name: array.array("f") for obj in speakers}
|
|
5005
|
+
facings = {obj.name: array.array("f") for obj in speakers}
|
|
5006
|
+
volumes = {obj.name: array.array("f") for obj in speakers}
|
|
5007
|
+
heard = {"position": array.array("f"), "forward": array.array("f"), "up": array.array("f")}
|
|
5008
|
+
kept = scene.frame_current
|
|
5009
|
+
try:
|
|
5010
|
+
for frame in range(first, last + 1):
|
|
5011
|
+
scene.frame_set(frame)
|
|
5012
|
+
for obj in speakers:
|
|
5013
|
+
matrix = obj.matrix_world
|
|
5014
|
+
places[obj.name].extend(matrix.translation)
|
|
5015
|
+
facings[obj.name].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
|
|
5016
|
+
volumes[obj.name].append(float(obj.data.volume))
|
|
5017
|
+
camera = scene.camera
|
|
5018
|
+
matrix = camera.matrix_world if camera is not None else mathutils.Matrix.Identity(4)
|
|
5019
|
+
heard["position"].extend(matrix.translation)
|
|
5020
|
+
heard["forward"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 0.0, -1.0))).normalized())
|
|
5021
|
+
heard["up"].extend((matrix.to_3x3() @ mathutils.Vector((0.0, 1.0, 0.0))).normalized())
|
|
5022
|
+
finally:
|
|
5023
|
+
scene.frame_set(kept)
|
|
5024
|
+
for obj in speakers:
|
|
5025
|
+
data = obj.data
|
|
5026
|
+
described.append({
|
|
5027
|
+
"name": obj.name,
|
|
5028
|
+
"file": sound_file(data.sound),
|
|
5029
|
+
"muted": bool(data.muted),
|
|
5030
|
+
"starts": tracks[obj.name],
|
|
5031
|
+
"volume": _column(volumes[obj.name], 1),
|
|
5032
|
+
"volumeMin": float(data.volume_min),
|
|
5033
|
+
"volumeMax": float(data.volume_max),
|
|
5034
|
+
"attenuation": float(data.attenuation),
|
|
5035
|
+
"distanceReference": float(data.distance_reference),
|
|
5036
|
+
"distanceMax": float(data.distance_max),
|
|
5037
|
+
"coneInner": float(data.cone_angle_inner),
|
|
5038
|
+
"coneOuter": float(data.cone_angle_outer),
|
|
5039
|
+
"coneOuterVolume": float(data.cone_volume_outer),
|
|
5040
|
+
"position": _column(places[obj.name], 3),
|
|
5041
|
+
"facing": _column(facings[obj.name], 3),
|
|
5042
|
+
})
|
|
5043
|
+
listener = {key: _column(column, 3) for key, column in heard.items()}
|
|
5044
|
+
|
|
5045
|
+
return {
|
|
5046
|
+
"fps": fps, "first": first, "last": last, "count": count,
|
|
5047
|
+
"volume": float(scene.audio_volume),
|
|
5048
|
+
# `use_audio` is the negation of `AUDIO_MUTE`: true, the default, plays the sequencer's sound
|
|
5049
|
+
"muted": not bool(scene.use_audio),
|
|
5050
|
+
"distanceModel": scene.audio_distance_model,
|
|
5051
|
+
"strips": strips,
|
|
5052
|
+
"speakers": described,
|
|
5053
|
+
"listener": listener,
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
|
|
5057
|
+
def _owner_animated(owner, accept, cache):
|
|
5058
|
+
"""Whether an owner's own properties (`accept` names them) are keyed by an action it shows: its
|
|
5059
|
+
active action or an unmuted NLA strip's. One look per action and owner kind, kept in `cache`:
|
|
5060
|
+
a level's armatures share their few actions, and their curves are bones', not the object's."""
|
|
5061
|
+
adt = owner.animation_data
|
|
5062
|
+
if adt is None:
|
|
5063
|
+
return False
|
|
5064
|
+
actions = [adt.action] if adt.action is not None else []
|
|
5065
|
+
for track in adt.nla_tracks:
|
|
5066
|
+
if not track.mute:
|
|
5067
|
+
actions.extend(strip.action for strip in track.strips if strip.action is not None and not strip.mute)
|
|
5068
|
+
for action in actions:
|
|
5069
|
+
key = (action.name, accept)
|
|
5070
|
+
if key not in cache:
|
|
5071
|
+
# EVERY SLOT'S CURVES: a camera's object and its lens share one action through two slots.
|
|
5072
|
+
curves = []
|
|
5073
|
+
for layer in action.layers:
|
|
5074
|
+
for strip in layer.strips:
|
|
5075
|
+
for bag in strip.channelbags:
|
|
5076
|
+
curves.extend(bag.fcurves)
|
|
5077
|
+
cache[key] = any(accept(fcurve.data_path) for fcurve in curves)
|
|
5078
|
+
if cache[key]:
|
|
5079
|
+
return True
|
|
5080
|
+
return False
|
|
5081
|
+
|
|
5082
|
+
|
|
5256
5083
|
# `Keyframe.type`'s significance order, mirrored from the `KEYFRAME_STATE`
|
|
5257
5084
|
# merge in `keyframes_keylist.cc` -- a column drawn from several curves takes
|
|
5258
5085
|
# the most significant type any of them carries.
|
|
@@ -5271,10 +5098,10 @@ def _action_key_range(action, first, last):
|
|
|
5271
5098
|
repeats into, any other type opens both sides. Mute is not read. None is an open side."""
|
|
5272
5099
|
low, high = None, None
|
|
5273
5100
|
opened_low = opened_high = False
|
|
5274
|
-
curves =
|
|
5275
|
-
for layer in
|
|
5276
|
-
for strip in
|
|
5277
|
-
for bag in
|
|
5101
|
+
curves = []
|
|
5102
|
+
for layer in action.layers:
|
|
5103
|
+
for strip in layer.strips:
|
|
5104
|
+
for bag in strip.channelbags:
|
|
5278
5105
|
curves.extend(bag.fcurves)
|
|
5279
5106
|
# A curve's keys and its last modifier count independently: a keyless curve carrying a
|
|
5280
5107
|
# Generator or Noise still opens the range, as in Blender.
|
|
@@ -5334,8 +5161,8 @@ def _curve_body(fcurve):
|
|
|
5334
5161
|
if modifier.mute:
|
|
5335
5162
|
continue
|
|
5336
5163
|
if modifier.type == "CYCLES" and not modifier.use_restricted_range and not modifier.use_influence:
|
|
5337
|
-
cycles = [_CYCLE_MODE
|
|
5338
|
-
_CYCLE_MODE
|
|
5164
|
+
cycles = [_CYCLE_MODE[modifier.mode_before], int(modifier.cycles_before),
|
|
5165
|
+
_CYCLE_MODE[modifier.mode_after], int(modifier.cycles_after)]
|
|
5339
5166
|
else:
|
|
5340
5167
|
unsupported.append("%s modifier" % modifier.type.lower())
|
|
5341
5168
|
out = {
|
|
@@ -5449,9 +5276,7 @@ def rna_outliner(selected=None):
|
|
|
5449
5276
|
placed.add(obj.name)
|
|
5450
5277
|
ordered.append(obj)
|
|
5451
5278
|
|
|
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).
|
|
5279
|
+
# Reuse each placed object to avoid a linear ID-name lookup per row.
|
|
5455
5280
|
for entries in list(placements.values()):
|
|
5456
5281
|
order(entries[0][0]["place"])
|
|
5457
5282
|
# A move takes the place out of its collection's list; the lists are
|
|
@@ -5486,16 +5311,9 @@ def rna_outliner(selected=None):
|
|
|
5486
5311
|
for row in collections:
|
|
5487
5312
|
row["children"] = [child for child in row["children"] if id(child) not in moved]
|
|
5488
5313
|
|
|
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.
|
|
5314
|
+
# Retain at most one page of active/selected rows and their ancestors.
|
|
5315
|
+
# Prioritize the active object, then the caller's selection, then the
|
|
5316
|
+
# engine's remaining selection so a clicked object keeps its address.
|
|
5499
5317
|
wanted = [active] if active is not None else []
|
|
5500
5318
|
wanted += sorted(chosen)
|
|
5501
5319
|
wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
|
|
@@ -5508,7 +5326,7 @@ def rna_outliner(selected=None):
|
|
|
5508
5326
|
|
|
5509
5327
|
def hold(row):
|
|
5510
5328
|
inside = "place" in row and row["name"] in kept
|
|
5511
|
-
for child in row
|
|
5329
|
+
for child in row["children"]:
|
|
5512
5330
|
inside = hold(child) or inside
|
|
5513
5331
|
if inside:
|
|
5514
5332
|
holding.add(id(row))
|
|
@@ -5542,7 +5360,7 @@ def rna_outliner(selected=None):
|
|
|
5542
5360
|
# Cap the hierarchy's visible child lists, not the collection's flat input.
|
|
5543
5361
|
# Every parent participates in folding even when its child list needs a page.
|
|
5544
5362
|
def page_tree(row):
|
|
5545
|
-
members = row
|
|
5363
|
+
members = row["children"]
|
|
5546
5364
|
children, more = _outliner_page(members)
|
|
5547
5365
|
beyond = [child for child in members[len(children):] if id(child) in holding]
|
|
5548
5366
|
children = [kept_row(child) for child in children + beyond]
|
|
@@ -5559,8 +5377,7 @@ def rna_outliner(selected=None):
|
|
|
5559
5377
|
# page left behind adds only what it would have claimed that a later row
|
|
5560
5378
|
# can share -- its own address, its materials, its modifiers' linked
|
|
5561
5379
|
# datablocks, its instanced collection -- and one more expansion of itself
|
|
5562
|
-
# and of its data.
|
|
5563
|
-
# long way, every one in the shared count.
|
|
5380
|
+
# and of its data.
|
|
5564
5381
|
addresses = {}
|
|
5565
5382
|
|
|
5566
5383
|
def address(datablock):
|
|
@@ -5580,33 +5397,24 @@ def rna_outliner(selected=None):
|
|
|
5580
5397
|
object_key = obj.as_pointer()
|
|
5581
5398
|
object_path = address(obj)
|
|
5582
5399
|
data = obj.data
|
|
5583
|
-
|
|
5584
|
-
scopes = [] if exact else [(object_path + ".", None, expanded.get(object_key, 0))]
|
|
5400
|
+
scopes = [(object_path + ".", None, expanded.get(object_key, 0))]
|
|
5585
5401
|
data_key = bones = None
|
|
5586
5402
|
if data is not None:
|
|
5587
5403
|
data_key = data.as_pointer()
|
|
5588
5404
|
data_path = address(data)
|
|
5589
5405
|
keys = getattr(data, "shape_keys", None)
|
|
5590
5406
|
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
5407
|
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 = []
|
|
5408
|
+
before = data_expanded.get(data_key, 0)
|
|
5409
|
+
scopes += [(data_path + ".bones[", None, bones_expanded.get(data_key, 0)),
|
|
5410
|
+
(data_path + ".", data_path, before)]
|
|
5411
|
+
if keys is not None:
|
|
5412
|
+
scopes.append((keys_path + ".", keys_path, before))
|
|
5601
5413
|
row = spot.get("row")
|
|
5602
|
-
if row is not None
|
|
5603
|
-
# Built rows, or the long way for data the scopes cannot name.
|
|
5414
|
+
if row is not None:
|
|
5604
5415
|
scope = _OutlinerScope(seen, scopes)
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
else:
|
|
5608
|
-
for claimed in spot["claims"]:
|
|
5609
|
-
claimed["id"] = _outliner_unique(claimed["path"], scope)
|
|
5416
|
+
for claimed in spot["claims"]:
|
|
5417
|
+
claimed["id"] = _outliner_unique(claimed["path"], scope)
|
|
5610
5418
|
else:
|
|
5611
5419
|
bump(object_path)
|
|
5612
5420
|
if data is not None:
|
|
@@ -5621,9 +5429,7 @@ def rna_outliner(selected=None):
|
|
|
5621
5429
|
linked = getattr(md, member, None)
|
|
5622
5430
|
if linked is None:
|
|
5623
5431
|
continue
|
|
5624
|
-
|
|
5625
|
-
if linked_path:
|
|
5626
|
-
bump(linked_path)
|
|
5432
|
+
bump(address(linked))
|
|
5627
5433
|
break
|
|
5628
5434
|
if obj.instance_collection is not None and obj.instance_type == "COLLECTION":
|
|
5629
5435
|
bump(address(obj.instance_collection))
|
|
@@ -5703,32 +5509,55 @@ def outliner_set(path, column, value):
|
|
|
5703
5509
|
% (column, target.bl_rna.identifier, path))
|
|
5704
5510
|
|
|
5705
5511
|
|
|
5706
|
-
#
|
|
5707
|
-
|
|
5708
|
-
#
|
|
5709
|
-
#
|
|
5710
|
-
|
|
5512
|
+
# Record requests that can remove objects so every disappearance has a named cause.
|
|
5513
|
+
_WATCHED_OPS = ("execute", "rna-set", "outliner-set", "history-step", "gltf-export", "run-script")
|
|
5514
|
+
# The requests that may change an action's keys (`Session._kept_action_revisions`): every edit, not an
|
|
5515
|
+
# export (which puts everything back) and not a read.
|
|
5516
|
+
_ACTION_EDIT_OPS = ("execute", "rna-set", "outliner-set", "history-step")
|
|
5517
|
+
_ACTION_EDITS = {"count": 0}
|
|
5711
5518
|
|
|
5712
5519
|
|
|
5713
5520
|
def dispatch(request):
|
|
5714
5521
|
op = request.get("op")
|
|
5522
|
+
if op in _ACTION_EDIT_OPS:
|
|
5523
|
+
_ACTION_EDITS["count"] += 1
|
|
5715
5524
|
if op not in _WATCHED_OPS:
|
|
5716
5525
|
return _dispatch_request(request)
|
|
5717
|
-
#
|
|
5718
|
-
# Names alone falsely reported an ordinary rename as a deleted object.
|
|
5526
|
+
# Compare stable session_uids so renames do not count as object deletions.
|
|
5719
5527
|
before = {o.session_uid: o.name for o in bpy.data.objects}
|
|
5528
|
+
# A request that loads a file (`read_homefile`, `open_mainfile`, then perhaps `save_as_mainfile`, to the same path
|
|
5529
|
+
# or another) removes nothing from the document it replaced: its objects are that file's, as saved. What it can
|
|
5530
|
+
# lose is the document's unsaved changes, said where there were any.
|
|
5531
|
+
before_path = bpy.data.filepath
|
|
5532
|
+
# (the session's own record of changes not yet saved, `mark_changed`'s: Blender's `is_dirty` is instrumentation)
|
|
5533
|
+
before_dirty = SESSION.save_due
|
|
5534
|
+
before_loads = _LOADS["count"]
|
|
5720
5535
|
try:
|
|
5721
5536
|
return _dispatch_request(request)
|
|
5722
5537
|
finally:
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
if
|
|
5726
|
-
cause
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5538
|
+
cause = {k: request[k] for k in ("label", "property", "column", "direction", "token", "path")
|
|
5539
|
+
if k in request}
|
|
5540
|
+
if op == "execute":
|
|
5541
|
+
cause["code"] = str(request.get("code", ""))[:160]
|
|
5542
|
+
if _LOADS["count"] != before_loads:
|
|
5543
|
+
same = bool(before_path) and os.path.normpath(bpy.data.filepath) == os.path.normpath(before_path)
|
|
5544
|
+
if before_dirty:
|
|
5545
|
+
_say("@@VOLTER-WARN %s now holds %s in place of %s, whose unsaved changes are lost (%s)" % (
|
|
5546
|
+
op, bpy.data.filepath or "a new, unsaved file", before_path or "an unsaved file",
|
|
5547
|
+
json.dumps(cause)))
|
|
5548
|
+
if same:
|
|
5549
|
+
# (the same file reloaded, rebuilt perhaps: its objects are new, so an object it no longer holds is
|
|
5550
|
+
# known by its name)
|
|
5551
|
+
gone = sorted(set(before.values()) - {o.name for o in bpy.data.objects})
|
|
5552
|
+
if gone:
|
|
5553
|
+
_say("@@VOLTER-WARN %s reloaded %s without objects %s (%s); %d remain" % (
|
|
5554
|
+
op, before_path, gone, json.dumps(cause), len(bpy.data.objects)))
|
|
5555
|
+
else:
|
|
5556
|
+
remaining = {o.session_uid for o in bpy.data.objects}
|
|
5557
|
+
removed = [name for uid, name in before.items() if uid not in remaining]
|
|
5558
|
+
if removed:
|
|
5559
|
+
_say("@@VOLTER-WARN %s removed objects %s (%s); %d remain" % (
|
|
5560
|
+
op, sorted(removed), json.dumps(cause), len(bpy.data.objects)))
|
|
5732
5561
|
|
|
5733
5562
|
|
|
5734
5563
|
def _dispatch_request(request):
|
|
@@ -5822,10 +5651,119 @@ def _native_preview(request):
|
|
|
5822
5651
|
bpy.data.scenes.remove(preview, do_unlink=True)
|
|
5823
5652
|
|
|
5824
5653
|
|
|
5654
|
+
# WHERE AN EXPORT IS WRITTEN in the module filesystem, before the tab carries it to the project:
|
|
5655
|
+
# one export's files at a time, outside the project's tree, so the MCP mirror never lists them.
|
|
5656
|
+
_GLTF_EXPORT_DIR = "/tmp/volter-gltf-export"
|
|
5657
|
+
|
|
5658
|
+
|
|
5659
|
+
def _gltf_export(request):
|
|
5660
|
+
"""A COMPONENT OR A LEVEL BY BLENDER'S OWN glTF EXPORTER (`gltf_export.py`, RFC 0007).
|
|
5661
|
+
|
|
5662
|
+
The document as it is, written as `<name>.glb` and its manifest `<name>.json` into
|
|
5663
|
+
`_GLTF_EXPORT_DIR`, which the tab reads and writes into the project's
|
|
5664
|
+
`.volter/export/components/` or `.volter/export/levels/`. The export puts back everything it
|
|
5665
|
+
sets up (its temporary markers and collection, the active collection, an instancer's
|
|
5666
|
+
realization, the frame) before it answers, so, like `native-preview`, no history checkpoint,
|
|
5667
|
+
save, frame export or revision bump is owed. `previous` is the project's earlier manifest and
|
|
5668
|
+
its file's SHA-256: when the `.blend`, the exporter and the settings still match, nothing is
|
|
5669
|
+
exported and the answer is `current`. `blendSha256` is the document's SHA-256 as the tab reads
|
|
5670
|
+
it from the project: the module filesystem keeps no copy of a document it streamed in.
|
|
5671
|
+
"""
|
|
5672
|
+
kind = request.get("kind")
|
|
5673
|
+
if kind not in ("component", "level"):
|
|
5674
|
+
raise ValueError("A glTF export is a component or a level")
|
|
5675
|
+
if SESSION.document is None or SESSION._foreign:
|
|
5676
|
+
raise RuntimeError("Blender holds no project document to export")
|
|
5677
|
+
if ROOT not in sys.path:
|
|
5678
|
+
sys.path.insert(0, ROOT)
|
|
5679
|
+
import gltf_export
|
|
5680
|
+
name = os.path.splitext(os.path.basename(SESSION.document))[0]
|
|
5681
|
+
os.makedirs(_GLTF_EXPORT_DIR, exist_ok=True)
|
|
5682
|
+
for stale in os.listdir(_GLTF_EXPORT_DIR):
|
|
5683
|
+
os.unlink(os.path.join(_GLTF_EXPORT_DIR, stale))
|
|
5684
|
+
out = os.path.join(_GLTF_EXPORT_DIR, name + ".glb")
|
|
5685
|
+
previous = request.get("previous")
|
|
5686
|
+
blend_sha = request.get("blendSha256")
|
|
5687
|
+
# The display LUTs the project holds intact (`.volter/export/display/<key>.cube`), each with its
|
|
5688
|
+
# recorded SHA-256: one is baked only when the scene's view settings have none.
|
|
5689
|
+
luts = request.get("luts")
|
|
5690
|
+
luts = ({str(k): str(v) for k, v in luts.items()} if isinstance(luts, dict)
|
|
5691
|
+
else [str(k) for k in luts] if isinstance(luts, list) else [])
|
|
5692
|
+
previous = previous if isinstance(previous, dict) else None
|
|
5693
|
+
blend_sha = blend_sha if isinstance(blend_sha, str) and blend_sha else None
|
|
5694
|
+
answer = None
|
|
5695
|
+
# Check whether the existing export is current before retaining document state. Unchanged
|
|
5696
|
+
# exports touch nothing; changed exports use gltf_export.export, which restores its temporary
|
|
5697
|
+
# changes.
|
|
5698
|
+
if not request.get("force") and previous is not None and blend_sha is not None:
|
|
5699
|
+
started = time.perf_counter()
|
|
5700
|
+
settings = dict(gltf_export.COMPONENT_SETTINGS if kind == "component" else gltf_export.LEVEL_SETTINGS)
|
|
5701
|
+
current = gltf_export._current(out, blend_sha, gltf_export.exporter_version(), settings, previous)
|
|
5702
|
+
if current is not None:
|
|
5703
|
+
answer = gltf_export._current_answer(out, current, started, luts)
|
|
5704
|
+
if answer is None:
|
|
5705
|
+
answer = gltf_export.export(kind, out, SESSION.project, force=bool(request.get("force")),
|
|
5706
|
+
previous=previous, blend_sha=blend_sha, luts=luts)
|
|
5707
|
+
# The file, what travels beside it (a newly baked display LUT), then its manifest, which names
|
|
5708
|
+
# them; a current export may still bring the LUT it names when the project lost it.
|
|
5709
|
+
sidecars = answer["sidecars"]
|
|
5710
|
+
files = list(sidecars) if answer["current"] else [out, *sidecars, os.path.splitext(out)[0] + ".json"]
|
|
5711
|
+
# What this Blender would write for either kind, so a caller can tell whether another file's
|
|
5712
|
+
# earlier export (a level's components) still stands without opening it.
|
|
5713
|
+
return {"kind": kind, "name": name, "document": SESSION.document_relative, "current": answer["current"],
|
|
5714
|
+
"ms": answer["ms"], "steps": answer.get("steps", {}), "manifest": answer["manifest"], "files": files,
|
|
5715
|
+
"exporter": gltf_export.exporter_version(),
|
|
5716
|
+
"settings": {"component": dict(gltf_export.COMPONENT_SETTINGS), "level": dict(gltf_export.LEVEL_SETTINGS)}}
|
|
5717
|
+
|
|
5718
|
+
|
|
5719
|
+
def _run_script(request):
|
|
5720
|
+
"""A SCRIPT RUN AS `blender -b <document> -P <script> -- <args>` RUNS ONE, in the document Blender holds.
|
|
5721
|
+
|
|
5722
|
+
Its `sys.argv` ends in `--` and `args`, as Blender's own does, and its working directory is the run's own `dir`
|
|
5723
|
+
(new for each run, outside the project's tree): a file it writes by a relative path is named in the answer's
|
|
5724
|
+
`files` by that path, and the worker carries it out. It runs as `execute` runs a script (`output` its printing,
|
|
5725
|
+
`error` its raise, `SystemExit` included). This path adds no history step, save or present;
|
|
5726
|
+
scripts restore any temporary document changes themselves. The runner restores argv and cwd.
|
|
5727
|
+
"""
|
|
5728
|
+
if SESSION.document is None or SESSION._foreign:
|
|
5729
|
+
raise RuntimeError("Blender holds no project document to run a script in")
|
|
5730
|
+
root = request["dir"]
|
|
5731
|
+
os.makedirs(root)
|
|
5732
|
+
argv, cwd = sys.argv, os.getcwd()
|
|
5733
|
+
sys.argv = [argv[0], "--", *[str(arg) for arg in request["args"]]]
|
|
5734
|
+
os.chdir(root)
|
|
5735
|
+
try:
|
|
5736
|
+
ran = execute(request["source"])
|
|
5737
|
+
finally:
|
|
5738
|
+
sys.argv = argv
|
|
5739
|
+
os.chdir(cwd)
|
|
5740
|
+
files = sorted(os.path.relpath(os.path.join(folder, name), root).replace(os.sep, "/")
|
|
5741
|
+
for folder, _, names in os.walk(root) for name in names)
|
|
5742
|
+
return {"output": ran["result"], "error": ran["error"], "files": files}
|
|
5743
|
+
|
|
5744
|
+
|
|
5745
|
+
def _asset_library(request):
|
|
5746
|
+
"""AN ASSET LIBRARY IN BLENDER'S PREFERENCES (Preferences > File Paths > Asset Libraries): `name`, at the
|
|
5747
|
+
project's folder `directory`, importing its assets by `importMethod` (`LINK`, `APPEND`, ...). One of that name
|
|
5748
|
+
already there is set again. A preference, not a document edit: nothing is owed."""
|
|
5749
|
+
library = request["library"]
|
|
5750
|
+
path = os.path.join(SESSION.project, library["directory"])
|
|
5751
|
+
libraries = bpy.context.preferences.filepaths.asset_libraries
|
|
5752
|
+
held = libraries.get(library["name"]) or libraries.new(name=library["name"], directory=path)
|
|
5753
|
+
held.path = path
|
|
5754
|
+
held.import_method = library["importMethod"]
|
|
5755
|
+
|
|
5756
|
+
|
|
5825
5757
|
def _dispatch(request):
|
|
5826
5758
|
op = request.get("op")
|
|
5827
5759
|
if op == "native-preview":
|
|
5828
5760
|
return _native_preview(request)
|
|
5761
|
+
if op == "gltf-export":
|
|
5762
|
+
return _gltf_export(request)
|
|
5763
|
+
if op == "run-script":
|
|
5764
|
+
return _run_script(request)
|
|
5765
|
+
if op == "asset-library":
|
|
5766
|
+
return _asset_library(request)
|
|
5829
5767
|
if op == "document-info":
|
|
5830
5768
|
return {"filepath": bpy.data.filepath, "session": SESSION.session, "revision": SESSION.revision}
|
|
5831
5769
|
if op == "execute":
|
|
@@ -5854,15 +5792,7 @@ def _dispatch(request):
|
|
|
5854
5792
|
try:
|
|
5855
5793
|
SESSION.present()
|
|
5856
5794
|
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.
|
|
5795
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5866
5796
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5867
5797
|
"showing the state before it: " + repr(thrown))
|
|
5868
5798
|
return answer
|
|
@@ -5886,12 +5816,17 @@ def _dispatch(request):
|
|
|
5886
5816
|
return rna_node_tree(request.get("path"), request.get("material"))
|
|
5887
5817
|
if op == "uv-layout":
|
|
5888
5818
|
return rna_uv_layout(request.get("object"), request.get("uvLayer"))
|
|
5889
|
-
#
|
|
5890
|
-
#
|
|
5891
|
-
# them -- keying, moving a key and setting a range are edits.
|
|
5819
|
+
# The Timeline clip read neither presents nor creates a derivation.
|
|
5820
|
+
# Keying, moving a key and setting a range remain edits.
|
|
5892
5821
|
if op == "action-clip":
|
|
5893
5822
|
return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"),
|
|
5894
5823
|
request.get("summary", True))
|
|
5824
|
+
# The movie is a read.
|
|
5825
|
+
if op == "scene-movie":
|
|
5826
|
+
return rna_scene_movie()
|
|
5827
|
+
# The scene's sound for the page to mix: a read; speakers move the frame and restore it.
|
|
5828
|
+
if op == "sound-scene":
|
|
5829
|
+
return rna_sound_scene(int(request["first"]), int(request["last"]))
|
|
5895
5830
|
if op == "outliner":
|
|
5896
5831
|
return rna_outliner(request.get("selected"))
|
|
5897
5832
|
if op == "outliner-set":
|
|
@@ -5903,15 +5838,7 @@ def _dispatch(request):
|
|
|
5903
5838
|
try:
|
|
5904
5839
|
SESSION.present()
|
|
5905
5840
|
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.
|
|
5841
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5915
5842
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5916
5843
|
"showing the state before it: " + repr(thrown))
|
|
5917
5844
|
return answer
|
|
@@ -5926,57 +5853,38 @@ def _dispatch(request):
|
|
|
5926
5853
|
try:
|
|
5927
5854
|
SESSION.present()
|
|
5928
5855
|
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.
|
|
5856
|
+
# Escalate present failures because the displayed document still holds its preceding state.
|
|
5938
5857
|
_say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
|
|
5939
5858
|
"showing the state before it: " + repr(thrown))
|
|
5940
5859
|
return answer
|
|
5941
5860
|
if op == "save-document":
|
|
5942
5861
|
return SESSION.save_document()
|
|
5862
|
+
if op == "open-document":
|
|
5863
|
+
# The worker just staged the document's file into the engine's filesystem (another
|
|
5864
|
+
# `.blend`, or this one again for a revert or a backup); open it as the model.
|
|
5865
|
+
return SESSION.bind_document(request["document"])
|
|
5943
5866
|
if op == "start":
|
|
5944
5867
|
SESSION.project = request.get("project", SESSION.project)
|
|
5945
5868
|
try:
|
|
5946
5869
|
os.chdir(SESSION.project)
|
|
5947
5870
|
except OSError:
|
|
5948
5871
|
pass
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
document = request.get("document")
|
|
5953
|
-
if document:
|
|
5954
|
-
answer.update(SESSION.bind_document(document))
|
|
5955
|
-
return answer
|
|
5872
|
+
return {"session": SESSION.session, "python": sys.version,
|
|
5873
|
+
"blender": bpy.app.version_string, "engines": ENGINES,
|
|
5874
|
+
"unavailableEngines": UNAVAILABLE_ENGINES}
|
|
5956
5875
|
raise ValueError("Unknown session op %r" % (op,))
|
|
5957
5876
|
|
|
5958
5877
|
|
|
5959
5878
|
# ---------------------------------------------------------------- the loop
|
|
5960
5879
|
|
|
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
5880
|
def _channel_loop():
|
|
5973
5881
|
"""Serve the directory channel until the program ends.
|
|
5974
5882
|
|
|
5975
5883
|
A FUNCTION SO ITS DEATH HAS A NAME. Everything here below the per-request
|
|
5976
|
-
`try` is the CHANNEL itself
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
anywhere
|
|
5884
|
+
`try` is the CHANNEL itself. An exception in it that fell off the end of
|
|
5885
|
+
this script would let Blender finish `--python` and exit 0 through its
|
|
5886
|
+
ordinary path, so the page would be told "Blender exited 0" and nothing
|
|
5887
|
+
anywhere would say why: from outside, a channel death is
|
|
5980
5888
|
indistinguishable from a clean shutdown, and the traceback goes to stderr,
|
|
5981
5889
|
which is page output rather than a console condition. The caller below
|
|
5982
5890
|
makes it one.
|
|
@@ -5988,12 +5896,11 @@ def _channel_loop():
|
|
|
5988
5896
|
unacked = []
|
|
5989
5897
|
active = 0.0
|
|
5990
5898
|
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
|
|
5899
|
+
# A FAILED LISTING IS NOT AN EMPTY ONE. Read as "no requests", the one
|
|
5900
|
+
# failure that matters -- the process out of file descriptors -- would
|
|
5901
|
+
# turn the session DEAF: looping at full speed, answering nothing ever
|
|
5902
|
+
# again, while the page waits on a call with a live program and no
|
|
5903
|
+
# error anywhere. The directory is the page's and is made before
|
|
5997
5904
|
# the program starts, so its absence is the only benign reading;
|
|
5998
5905
|
# anything else is named and ends the loop through the guard below.
|
|
5999
5906
|
try:
|
|
@@ -6001,12 +5908,9 @@ def _channel_loop():
|
|
|
6001
5908
|
if n.endswith(".done") and n[: -len(".done")].isdigit())
|
|
6002
5909
|
except FileNotFoundError:
|
|
6003
5910
|
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).
|
|
5911
|
+
# The page removes in/<id>.done after reading out/<id>.json.
|
|
5912
|
+
# An absent request marker acknowledges the guest-owned reply files,
|
|
5913
|
+
# which the guest can then remove.
|
|
6010
5914
|
if unacked:
|
|
6011
5915
|
still_owed = []
|
|
6012
5916
|
for rid in unacked:
|
|
@@ -6044,12 +5948,51 @@ def _channel_loop():
|
|
|
6044
5948
|
active = time.monotonic()
|
|
6045
5949
|
|
|
6046
5950
|
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
5951
|
+
# THE ADD-ONS THIS BLENDER SHIPS beside Blender's own: each a package the engine writes under `_ADDONS` before
|
|
5952
|
+
# Blender starts (`session-addons.mts`), enabled for the whole session, whatever file it opens, as Preferences >
|
|
5953
|
+
# Add-ons enables an installed one.
|
|
5954
|
+
_ADDONS = os.path.join(ROOT, "addons")
|
|
5955
|
+
|
|
5956
|
+
|
|
5957
|
+
def _enable_addons():
|
|
5958
|
+
import addon_utils
|
|
5959
|
+
sys.path.insert(0, _ADDONS)
|
|
5960
|
+
for name in sorted(os.listdir(_ADDONS)):
|
|
5961
|
+
_say("@@VOLTER-PHASE addon/%s" % name)
|
|
5962
|
+
failed = []
|
|
5963
|
+
addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
|
|
5964
|
+
if failed:
|
|
5965
|
+
_say("@@VOLTER-WARN the %s add-on did not enable: %s: %s" % (name, type(failed[0]).__name__, failed[0]))
|
|
5966
|
+
|
|
5967
|
+
|
|
5968
|
+
def main():
|
|
5969
|
+
"""Start the worker channel; importing this module only exposes its RNA readers."""
|
|
5970
|
+
global ENGINES, UNAVAILABLE_ENGINES
|
|
5971
|
+
if _blender_web is None:
|
|
5972
|
+
raise RuntimeError("The session worker needs Blender with the _blender_web native door")
|
|
5973
|
+
# Each step of the session's start says it began (`@@VOLTER-PHASE`): the page makes each a load boundary, so the
|
|
5974
|
+
# wait for the ready line is timed step by step, Blender's and Python's own start being the wait before the first.
|
|
5975
|
+
_say("@@VOLTER-PHASE session")
|
|
5976
|
+
_prepare_directories()
|
|
5977
|
+
bpy.app.handlers.load_post.append(_load_post)
|
|
5978
|
+
bpy.app.handlers.undo_post.append(_history_post)
|
|
5979
|
+
bpy.app.handlers.redo_post.append(_history_post)
|
|
5980
|
+
_say("@@VOLTER-PHASE engines")
|
|
5981
|
+
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
5982
|
+
_enable_addons()
|
|
5983
|
+
_say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
5984
|
+
"unavailableEngines": UNAVAILABLE_ENGINES}))
|
|
5985
|
+
|
|
5986
|
+
try:
|
|
5987
|
+
_channel_loop()
|
|
5988
|
+
except BaseException as _thrown: # noqa: BLE001
|
|
5989
|
+
# The one place a channel death can still be named. Without this the
|
|
5990
|
+
# program ends 0 and the page reports only that Blender is gone.
|
|
5991
|
+
_say("@@VOLTER-ERROR the session's channel loop raised %s: %s -- the session is over and "
|
|
5992
|
+
"every outstanding call will go unanswered. %s"
|
|
5993
|
+
% (type(_thrown).__name__, _thrown, traceback.format_exc().replace("\n", " | ")))
|
|
5994
|
+
raise
|
|
5995
|
+
|
|
5996
|
+
|
|
5997
|
+
if __name__ == "__main__":
|
|
5998
|
+
main()
|