@volter/blender-engine 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +505 -283
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +156 -151
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -0,0 +1,3161 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Export components and levels through Blender’s glTF exporter. Components include view-layer
3
+ content outside Notes and References and carry one animation per action. Levels replace linked
4
+ placements with markers carrying component identity, action, tint, visibility and NLA metadata,
5
+ and export their own scene animation. Standard glTF extensions describe display transforms,
6
+ environment, render settings, light units, ray visibility, animation tracks and blending data.
7
+ Collection membership, timelines and keyed-bone masks preserve authored relationships. Preserve
8
+ quaternion key signs and lengths for Blender-compatible layering, and carry declared runtime
9
+ constraints without baking them. Temporary objects, collection state, poses and keyed values are
10
+ restored before answering; export creates no save, history step or present. A sibling manifest
11
+ records source, exporter, settings and artifact identities so unchanged exports can return
12
+ current. This module runs in session.py or headless desktop Blender: blender -b --factory-startup
13
+ <file.blend> --python gltf_export.py -- <component|level> <out.glb> <project> [--force]."""
14
+ import bisect
15
+ import hashlib
16
+ import importlib
17
+ import json
18
+ import os
19
+ import re
20
+ import struct
21
+ import sys
22
+ import time
23
+ import types
24
+
25
+ import bpy
26
+ import math
27
+ import mathutils
28
+
29
+ FORMAT = 1
30
+ AUTHORING = ("Notes", "References")
31
+
32
+ # RFC 0007 section 1, exactly.
33
+ COMPONENT_SETTINGS = {
34
+ "export_format": "GLB",
35
+ "export_yup": True,
36
+ "export_skins": True,
37
+ "export_animations": True,
38
+ "export_animation_mode": "ACTIONS",
39
+ "export_force_sampling": True,
40
+ "export_extras": True,
41
+ # What Blender renders, not the raw mesh: modifiers applied (the exporter leaves an Armature
42
+ # modifier to the skin). The scene's lights and cameras go too.
43
+ "export_apply": True,
44
+ "export_cameras": True,
45
+ "export_lights": True,
46
+ # Light units as Blender renders them and three.js reads them: a sun's W/m^2 as is, a point or
47
+ # spot's watts over 4 pi. The default (SPEC) multiplies by 683 lm/W.
48
+ "export_import_convert_lighting_mode": "COMPAT",
49
+ }
50
+ # Sections 2 and 3: the same exporter and settings; the timeline as ONE scene animation (the
51
+ # exporter splits SCENE mode per object unless told not to).
52
+ LEVEL_SETTINGS = dict(COMPONENT_SETTINGS, export_animation_mode="SCENE", export_anim_scene_split_object=False)
53
+
54
+ # Override edits a marker carries (D1), and the hierarchy bookkeeping every override has.
55
+ CARRIED = ("location", "rotation_euler", "rotation_quaternion", "rotation_axis_angle", "scale",
56
+ "hide_render", "hide_viewport", "animation_data.action", "color")
57
+ STRUCTURAL = ("parent", "matrix_parent_inverse", "data")
58
+
59
+ _HOOK_MODULE = "volter_gltf_export_hooks"
60
+ _HOLDER = "__volter_export__"
61
+ # Display LUT size and log2 shaper range. t = (log2(x + 2^-10) + 10) / 18 maps black to the first sample.
62
+ LUT_SIZE = 48
63
+ LUT_DOMAIN = (-10.0, 8.0)
64
+ LUT_FORMAT = 2
65
+ # Blender's per-object ray visibility, as `STD_ray_visibility` keys.
66
+ RAY_VISIBILITY = (("camera", "camera"), ("shadow", "shadow"), ("diffuse", "diffuse"), ("glossy", "glossy"),
67
+ ("transmission", "transmission"), ("volume", "volume_scatter"))
68
+ # The standard's own `STD_*` extensions (their schemas are the standard's registry), listed in `extensionsUsed` only.
69
+ STD_EXTENSIONS = ("STD_display", "STD_environment", "STD_render", "STD_light", "STD_ray_visibility",
70
+ "STD_material_shadow", "STD_animation_tracks", "STD_animation_blending")
71
+ # Blender's NLA enums as the standard names them (AnimationLayers' blend modes; a strip's extrapolation).
72
+ BLENDS = {"REPLACE": "replace", "ADD": "add", "SUBTRACT": "subtract", "MULTIPLY": "multiply", "COMBINE": "combine"}
73
+ EXTRAPOLATIONS = {"HOLD": "hold", "HOLD_FORWARD": "hold-forward", "NOTHING": "none"}
74
+ # Blender's sky models, by their published names.
75
+ SKY_MODELS = {"NISHITA": "nishita", "SINGLE_SCATTERING": "nishita", "MULTIPLE_SCATTERING": "nishita-multiple",
76
+ "PREETHAM": "preetham", "HOSEK_WILKIE": "hosek-wilkie"}
77
+
78
+
79
+ def _sha256(path):
80
+ digest = hashlib.sha256()
81
+ with open(path, "rb") as handle:
82
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
83
+ digest.update(chunk)
84
+ return digest.hexdigest()
85
+
86
+
87
+ def _gltf_addon():
88
+ module = next((m for name, m in list(sys.modules.items()) if name.split(".")[-1] == "io_scene_gltf2"), None)
89
+ if module is None:
90
+ import addon_utils
91
+ addon_utils.enable("io_scene_gltf2", default_set=False)
92
+ module = sys.modules.get("io_scene_gltf2")
93
+ if module is None:
94
+ raise RuntimeError("Blender's glTF exporter (io_scene_gltf2) is not available in this Blender.")
95
+ return module
96
+
97
+
98
+ def exporter_version():
99
+ """What made a file: Blender, its glTF add-on, and this script (its SHA-256), so a change to any of
100
+ the three exports again."""
101
+ module = _gltf_addon()
102
+ version = getattr(module, "bl_info", {}).get("version", ())
103
+ return {"blender": bpy.app.version_string, "gltf": ".".join(str(part) for part in version),
104
+ "script": _sha256(os.path.abspath(__file__))[:16]}
105
+
106
+
107
+ def _extension_class():
108
+ module = _gltf_addon()
109
+ return importlib.import_module(module.__name__ + ".io.com.gltf2_io_extensions").Extension
110
+
111
+
112
+ def _project_relative(path, project):
113
+ absolute = os.path.normpath(os.path.abspath(path))
114
+ if project:
115
+ root = os.path.normpath(os.path.abspath(project))
116
+ relative = os.path.relpath(absolute, root)
117
+ if not relative.startswith(".."):
118
+ return relative.replace(os.sep, "/")
119
+ return absolute.replace(os.sep, "/")
120
+
121
+
122
+ def _library_file(library, project):
123
+ path = bpy.path.abspath(library.filepath, library=library.library)
124
+ return _project_relative(path, project)
125
+
126
+
127
+ def _hidden_collections(scene):
128
+ """Collections hidden from renders, a parent hiding its children."""
129
+ hidden = set()
130
+
131
+ def walk(collection, parent_hidden):
132
+ here = parent_hidden or collection.hide_render
133
+ if here:
134
+ hidden.add(collection.name_full)
135
+ for child in collection.children:
136
+ walk(child, here)
137
+
138
+ walk(scene.collection, False)
139
+ return hidden
140
+
141
+
142
+ def _render_visible(obj, hidden):
143
+ if obj.hide_render:
144
+ return False
145
+ owners = [c for c in obj.users_collection]
146
+ return not owners or any(c.name_full not in hidden for c in owners)
147
+
148
+
149
+ def _world(scene):
150
+ """The world as data (`STD_environment`): its surface when it is one Background colour (colour and
151
+ strength) or a sky by its parameters, which a renderer draws as its background and ambient light, or two of
152
+ them mixed by Light Path's Is Camera Ray (what lights the scene, and a `background` the camera sees), and
153
+ its volume when it is a homogeneous medium. Anything else (an image, a node graph) is named: it needs
154
+ a bake beside its parameters. Answers the data and what it could not carry."""
155
+ world = scene.world
156
+ if world is None:
157
+ return None, []
158
+ if not world.use_nodes or world.node_tree is None:
159
+ return {"color": [round(float(c), 6) for c in world.color[:3]], "strength": 1.0}, []
160
+ outputs = [n for n in world.node_tree.nodes if n.type == "OUTPUT_WORLD"]
161
+ output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
162
+ gaps = []
163
+ links = output.inputs["Surface"].links if output is not None else []
164
+ source = links[0].from_node if links else None
165
+ if source is None:
166
+ data = {"color": [0.0, 0.0, 0.0], "strength": 0.0}
167
+ elif source.type == "MIX_SHADER" and _camera_ray_mix(source):
168
+ # "THE CAMERA SEES ONE THING, THE SCENE IS LIT BY ANOTHER": a Mix Shader by Light Path's Is Camera Ray between
169
+ # two backgrounds. Its first input lights the scene (the radiance); its second is what camera rays see
170
+ # (`background`).
171
+ lit, seen = (_background_radiance(socket.links[0].from_node, world, gaps) for socket in source.inputs[1:3])
172
+ if lit is None or seen is None:
173
+ data = None
174
+ gaps.append("the world %s mixes by camera ray, but an input is not one colour or a sky: it is not carried yet" % world.name)
175
+ else:
176
+ data = dict(lit, background=seen)
177
+ else:
178
+ data = _background_radiance(source, world, gaps)
179
+ if data is None:
180
+ gaps.append("the world %s is a %s graph, not one colour or a sky: it is not carried yet" % (world.name, source.type))
181
+ volume, volume_gap = _world_volume(world, output)
182
+ if volume_gap:
183
+ gaps.append(volume_gap)
184
+ if volume is not None:
185
+ data = dict(data or {"strength": 0.0}, volume=volume)
186
+ gaps.append("the world %s has a volume: an infinite medium, which absorbs the sun and the world's own light before "
187
+ "they arrive, as Blender renders it (a haze around a play space is a volume object, a box)" % world.name)
188
+ return data, gaps
189
+
190
+
191
+ def _camera_ray_mix(node):
192
+ """A Mix Shader whose factor is Light Path's Is Camera Ray, between two linked backgrounds."""
193
+ factor = node.inputs[0]
194
+ if not factor.is_linked or not node.inputs[1].is_linked or not node.inputs[2].is_linked:
195
+ return False
196
+ link = factor.links[0]
197
+ return link.from_node.type == "LIGHT_PATH" and link.from_socket.name == "Is Camera Ray"
198
+
199
+
200
+ def _background_radiance(source, world, gaps):
201
+ """A Background node as the world's radiance: one colour and strength, or a sky by its published model and physical
202
+ parameters (rule G: a host with a physical sky draws it; a bake for hosts without one goes beside them, never
203
+ instead). None for anything else."""
204
+ if source is None or source.type != "BACKGROUND" or source.inputs[1].is_linked:
205
+ return None
206
+ if not source.inputs[0].is_linked:
207
+ return {"color": [round(float(c), 6) for c in source.inputs[0].default_value[:3]],
208
+ "strength": round(float(source.inputs[1].default_value), 6)}
209
+ sky = source.inputs[0].links[0].from_node
210
+ if sky.type != "TEX_SKY":
211
+ return None
212
+ model = SKY_MODELS.get(sky.sky_type)
213
+ number = lambda value: round(float(value), 6)
214
+ params = {"model": model or str(sky.sky_type).lower(),
215
+ "sun": {"elevation": number(sky.sun_elevation), "rotation": number(sky.sun_rotation),
216
+ "angularDiameter": number(sky.sun_size), "intensity": number(sky.sun_intensity),
217
+ "disc": bool(sky.sun_disc)},
218
+ "altitude": number(sky.altitude), "air": number(sky.air_density),
219
+ "aerosol": number(sky.aerosol_density),
220
+ "ozone": number(sky.ozone_density), "groundAlbedo": number(sky.ground_albedo)}
221
+ if model in ("preetham", "hosek-wilkie"):
222
+ params["turbidity"] = number(sky.turbidity)
223
+ gaps.append("the world %s is a sky: its parameters are carried; a host without a physical sky needs a bake" % world.name)
224
+ if model is None:
225
+ gaps.append("the world %s's sky model %s has no published name in the standard" % (world.name, sky.sky_type))
226
+ return {"sky": params, "strength": round(float(source.inputs[1].default_value), 6)}
227
+
228
+
229
+ def _volume_coefficients(node):
230
+ """A volume shader with unlinked inputs as a homogeneous medium's coefficients per metre (EEVEE's own
231
+ formulas): scattering, absorption and emission (linear RGB) and the phase function's anisotropy.
232
+ An unlinked Add Shader has no medium; unsupported or linked shader inputs raise ValueError."""
233
+ inputs = node.inputs
234
+ def value(name):
235
+ socket = inputs[name]
236
+ if socket.is_linked:
237
+ raise ValueError("its %s is linked" % name)
238
+ return socket.default_value
239
+ def rgb(v):
240
+ return [float(c) for c in v[:3]]
241
+ if node.type == "PRINCIPLED_VOLUME":
242
+ density = max(float(value("Density")), 0.0)
243
+ color = rgb(value("Color"))
244
+ absorption_color = [math.sqrt(max(c, 0.0)) for c in rgb(value("Absorption Color"))]
245
+ scattering = [c * density for c in color]
246
+ absorption = [max(1.0 - c, 0.0) * max(1.0 - a, 0.0) * density for c, a in zip(color, absorption_color)]
247
+ strength = max(float(value("Emission Strength")), 0.0)
248
+ emission = [c * strength for c in rgb(value("Emission Color"))]
249
+ if float(value("Blackbody Intensity")) > 0:
250
+ raise ValueError("its blackbody emission is not carried")
251
+ return scattering, absorption, emission, float(value("Anisotropy"))
252
+ if node.type == "VOLUME_SCATTER":
253
+ density = max(float(value("Density")), 0.0)
254
+ return [c * density for c in rgb(value("Color"))], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], float(value("Anisotropy"))
255
+ if node.type == "VOLUME_ABSORPTION":
256
+ density = max(float(value("Density")), 0.0)
257
+ return [0.0, 0.0, 0.0], [max(1.0 - c, 0.0) * density for c in rgb(value("Color"))], [0.0, 0.0, 0.0], 0.0
258
+ if node.type == "ADD_SHADER":
259
+ parts = [_volume_coefficients(link.from_node) for socket in node.inputs for link in socket.links]
260
+ if not parts:
261
+ return None
262
+ scattering = [sum(p[0][k] for p in parts) for k in range(3)]
263
+ weight = sum(sum(p[0]) for p in parts)
264
+ anisotropy = sum(sum(p[0]) * p[3] for p in parts) / weight if weight > 0 else 0.0
265
+ return (scattering, [sum(p[1][k] for p in parts) for k in range(3)],
266
+ [sum(p[2][k] for p in parts) for k in range(3)], anisotropy)
267
+ raise ValueError("it is a %s node" % node.type)
268
+
269
+
270
+ def _world_volume(world, output):
271
+ """The world's Volume socket as a homogeneous medium (`STD_environment.volume`), and what could not be
272
+ carried. Nothing is dropped silently: a volume that isn't homogeneous is named."""
273
+ socket = output.inputs["Volume"] if output is not None else None
274
+ if socket is None or not socket.is_linked:
275
+ return None, None
276
+ try:
277
+ found = _volume_coefficients(socket.links[0].from_node)
278
+ except ValueError as why:
279
+ return None, "the world %s's volume is not a homogeneous medium (%s): it is not carried" % (world.name, why)
280
+ if found is None:
281
+ return None, None
282
+ scattering, absorption, emission, anisotropy = found
283
+ r6 = lambda values: [round(v, 6) for v in values]
284
+ return {"scattering": r6(scattering), "absorption": r6(absorption), "emission": r6(emission),
285
+ "anisotropy": round(max(-1.0, min(1.0, anisotropy)), 6)}, None
286
+
287
+
288
+ def _object_volumes(objects, visibility):
289
+ """BOUNDED VOLUMES (`STD_volume`): a mesh whose material is a homogeneous volume and no surface (a haze or fog
290
+ bank's box) is the volume's domain: its coefficients per metre, as the world's are, and its shape (its bounding
291
+ box, a box when the mesh is one; else the box around it, named), in the node's own Y-up frame. The node is hidden
292
+ (KHR_node_visibility), as Blender renders no surface there. Any other volume (beside a surface, not homogeneous)
293
+ is named, not carried. Answers {object: data} and the warnings."""
294
+ found, warnings = {}, []
295
+ for obj in objects:
296
+ if obj.type != "MESH" or not visibility[obj.name]:
297
+ continue
298
+ volumes = []
299
+ for slot in obj.material_slots:
300
+ material = slot.material
301
+ if material is None or not material.use_nodes or material.node_tree is None:
302
+ continue
303
+ outputs = [n for n in material.node_tree.nodes if n.type == "OUTPUT_MATERIAL"]
304
+ output = next((n for n in outputs if n.is_active_output), outputs[0] if outputs else None)
305
+ if output is not None and output.inputs["Volume"].is_linked:
306
+ volumes.append((material, output))
307
+ if not volumes:
308
+ continue
309
+ material, output = volumes[0]
310
+ surface = output.inputs["Surface"]
311
+ if len(obj.material_slots) != 1 or surface.is_linked:
312
+ warnings.append("%s's material %s has a volume beside a surface: it is not carried (a volume's domain is a mesh "
313
+ "of a volume material alone)" % (obj.name, material.name))
314
+ continue
315
+ try:
316
+ coefficients = _volume_coefficients(output.inputs["Volume"].links[0].from_node)
317
+ except ValueError as why:
318
+ warnings.append("%s's volume is not a homogeneous medium (%s): it is not carried" % (obj.name, why))
319
+ continue
320
+ if coefficients is None:
321
+ continue
322
+ scattering, absorption, emission, anisotropy = coefficients
323
+ corners = [mathutils.Vector(c) for c in obj.bound_box]
324
+ low = mathutils.Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
325
+ high = mathutils.Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
326
+ centre, size = (low + high) / 2, high - low
327
+ if len(obj.data.vertices) != 8:
328
+ warnings.append("%s's volume domain is not a box: it is carried as the box around it" % obj.name)
329
+ r6 = lambda values: [round(float(v), 6) for v in values]
330
+ # (Blender's local x, y, z are the glTF node's x, z, -y)
331
+ found[obj.name] = {"shape": "box", "center": r6((centre.x, centre.z, -centre.y)), "size": r6((size.x, size.z, size.y)),
332
+ "scattering": r6(scattering), "absorption": r6(absorption), "emission": r6(emission),
333
+ "anisotropy": round(max(-1.0, min(1.0, anisotropy)), 6)}
334
+ return found, warnings
335
+
336
+
337
+ def _pick(owner, name):
338
+ return getattr(owner, name) if hasattr(owner, name) else None
339
+
340
+
341
+ def _render(scene):
342
+ """The scene's render settings, as the neutral data a renderer honours or names (Q7; `STD_render`).
343
+ Look-defining: shadows and whether light bounces; the rest are quality hints."""
344
+ render, eevee = scene.render, scene.eevee
345
+ data = {"shadows": bool(eevee.use_shadows)}
346
+ if render.engine == "CYCLES":
347
+ # A path tracer: light bounces everywhere.
348
+ data["globalIllumination"] = {"mode": "full"}
349
+ else:
350
+ data["softShadows"] = {"rays": int(eevee.shadow_ray_count), "steps": int(eevee.shadow_step_count)}
351
+ # EEVEE enables Fast GI only when ray tracing is enabled.
352
+ if eevee.use_fast_gi and eevee.use_raytracing:
353
+ data["globalIllumination"] = {"mode": "occlusion" if eevee.fast_gi_method == "AMBIENT_OCCLUSION_ONLY" else "screen-space",
354
+ "resolution": round(1.0 / max(1, int(eevee.fast_gi_resolution)), 6)}
355
+ data["raytracing"] = bool(eevee.use_raytracing)
356
+ # The range EEVEE draws volumes over: its custom range where that is on, else the camera's clip range
357
+ # (the scene's camera; none: the custom values, as EEVEE has nothing else to go by).
358
+ start, end = float(eevee.volumetric_start), float(eevee.volumetric_end)
359
+ if not eevee.use_volume_custom_range and scene.camera is not None and scene.camera.type == "CAMERA":
360
+ start, end = float(scene.camera.data.clip_start), float(scene.camera.data.clip_end)
361
+ data["volumetric"] = {"start": round(start, 6), "end": round(end, 6),
362
+ "tileSize": int(eevee.volumetric_tile_size), "samples": int(eevee.volumetric_samples),
363
+ "shadows": bool(eevee.use_volumetric_shadows)}
364
+ data["antialiasing"] = {"samples": int(eevee.taa_render_samples), "filterWidth": round(render.filter_size, 6)}
365
+ data["transparentBackground"] = bool(render.film_transparent)
366
+ data["motionBlur"] = bool(render.use_motion_blur)
367
+ return data
368
+
369
+
370
+ def _display(scene, lut):
371
+ """How the scene is seen (`STD_display`): its colour management, which glTF has no place for. The LUT
372
+ is what a host applies; the view's name is information only."""
373
+ view = scene.view_settings
374
+ display = {"exposure": round(view.exposure, 6), "gamma": round(view.gamma, 6), "view": view.view_transform}
375
+ display["lut"] = {"uri": lut["file"], "size": lut["size"],
376
+ "shaper": {"type": "log2", "min": lut["shaper"]["min"], "max": lut["shaper"]["max"]},
377
+ "input": "linear-rec709", "output": "display"}
378
+ display["key"] = lut["key"]
379
+ return display
380
+
381
+
382
+ def _light(light):
383
+ """What KHR_lights_punctual can't say of a light (`STD_light`): the units its intensity is in, its shadow
384
+ (the emitter's radius softens it), a sun's angular diameter, and an AREA light, which glTF has no light
385
+ for: its shape, size, colour and radiance. A Blender area light of power P (W, times 2^exposure; times its
386
+ area when not normalised) seen from the front is a point light of P: its radiance is P / (4 pi area)."""
387
+ data = {"units": "scene-linear",
388
+ "shadow": {"cast": bool(light.use_shadow), "radius": round(float(_pick(light, "shadow_soft_size") or 0.0), 6)}}
389
+ if light.type == "SUN":
390
+ data["angularDiameter"] = round(float(light.angle), 6)
391
+ elif light.type == "AREA":
392
+ width = float(light.size)
393
+ height = float(light.size_y) if light.shape in ("RECTANGLE", "ELLIPSE") else width
394
+ ellipse = light.shape in ("DISK", "ELLIPSE")
395
+ area = max((math.pi / 4 if ellipse else 1.0) * width * height, 1e-9)
396
+ power = float(light.energy) * 2 ** float(light.exposure)
397
+ if not light.normalize:
398
+ power *= area
399
+ data["area"] = {"shape": "ellipse" if ellipse else "rectangle", "width": round(width, 6), "height": round(height, 6),
400
+ "color": [round(float(c), 6) for c in light.color], "radiance": round(power / (4 * math.pi * area), 9)}
401
+ return data
402
+
403
+
404
+ def _area_lights(objects, visibility):
405
+ return ["the AREA light %s has no glTF light (KHR_lights_punctual is point, spot and sun): it goes as its node's "
406
+ "STD_light.area (shape, size, colour, radiance) only" % o.name
407
+ for o in objects if o.type == "LIGHT" and o.data.type == "AREA" and visibility[o.name]]
408
+
409
+
410
+ def _hidden_over_visible(objects, visibility):
411
+ """`KHR_node_visibility` hides a node's whole subtree; Blender's render visibility does not."""
412
+ exported = set(objects)
413
+ found = []
414
+ for obj in objects:
415
+ if visibility[obj.name]:
416
+ continue
417
+ shown = [c.name for c in obj.children_recursive if c in exported and visibility[c.name]]
418
+ if shown:
419
+ found.append("%s is hidden in renders but its children %s are not; the glTF hides them too"
420
+ % (obj.name, ", ".join(sorted(shown)[:5])))
421
+ return found
422
+
423
+
424
+ def _collection_ancestors(scene):
425
+ """Every collection of the scene's hierarchy, by name, with the collections that hold it."""
426
+ ancestors = {}
427
+
428
+ def walk(collection, chain):
429
+ for child in collection.children:
430
+ if child.name == _HOLDER:
431
+ continue
432
+ ancestors.setdefault(child.name, set()).update(chain)
433
+ walk(child, chain + [child.name])
434
+
435
+ walk(scene.collection, [])
436
+ # A collection of the file outside the scene (a variant a level places: `Helldiver.Rifleman`, some of
437
+ # the Helldiver's objects) holds its objects too, with the collections that hold it.
438
+ parents = {}
439
+ for collection in bpy.data.collections:
440
+ for child in collection.children:
441
+ parents.setdefault(child.name, set()).add(collection.name)
442
+ for collection in bpy.data.collections:
443
+ if collection.library is not None or collection.name in ancestors or collection.name == _HOLDER:
444
+ continue
445
+ chain, todo = set(), list(parents.get(collection.name, ()))
446
+ while todo:
447
+ name = todo.pop()
448
+ if name not in chain:
449
+ chain.add(name)
450
+ todo.extend(parents.get(name, ()))
451
+ ancestors[collection.name] = chain
452
+ return ancestors
453
+
454
+
455
+ def _collections_of(names, ancestors):
456
+ """The collections named and every collection holding them, sorted; the scene's own collection,
457
+ the authoring collections and the export's holder are not among them."""
458
+ found = set()
459
+ for name in names:
460
+ if name in ancestors and name not in AUTHORING:
461
+ found.add(name)
462
+ found.update(a for a in ancestors[name] if a not in AUTHORING)
463
+ return sorted(found)
464
+
465
+
466
+ def _node_collections(objects, ancestors):
467
+ return {o.name: _collections_of([c.name for c in o.users_collection], ancestors) for o in objects}
468
+
469
+
470
+ def _strip(strip):
471
+ """One NLA strip's timing, as Blender plays it, in the standard's names (`STD_animation_tracks`): its
472
+ scene range, the clip's range it plays, scale and repeats, blend in and out, extrapolation, blend,
473
+ influence, and whether it plays backwards. None for a strip with no action."""
474
+ if strip.action is None:
475
+ return None
476
+ data = {"clip": strip.action.name,
477
+ "start": round(float(strip.frame_start), 6), "end": round(float(strip.frame_end), 6),
478
+ "clipStart": round(float(strip.action_frame_start), 6), "clipEnd": round(float(strip.action_frame_end), 6),
479
+ "scale": round(float(strip.scale), 6), "repeat": round(float(strip.repeat), 6),
480
+ "blendIn": round(float(strip.blend_in), 6), "blendOut": round(float(strip.blend_out), 6),
481
+ "extrapolation": EXTRAPOLATIONS[strip.extrapolation], "blend": BLENDS[strip.blend_type],
482
+ "influence": round(float(strip.influence), 6) if strip.use_animated_influence else 1.0,
483
+ "reversed": bool(strip.use_reverse), "mute": bool(strip.mute)}
484
+ if strip.use_animated_influence:
485
+ # THE INFLUENCE AS THE FILE ANIMATES IT: its F-curve read at every frame of the strip.
486
+ curve = strip.fcurves.find("influence")
487
+ if curve is not None:
488
+ first, last = int(math.floor(strip.frame_start)), int(math.ceil(strip.frame_end))
489
+ data["influenceKeys"] = [[frame, round(min(1.0, max(0.0, float(curve.evaluate(frame)))), 6)]
490
+ for frame in range(first, last + 1)]
491
+ return data
492
+
493
+
494
+ def _tracks(obj):
495
+ """An armature's NLA tracks, bottom to top (RFC 0007 §1, `STD_animation_tracks.tracks`): each one's name
496
+ and mute, and its strips unless muted. A placement marker carries its override's own tracks the same way."""
497
+ data = obj.animation_data
498
+ if data is None:
499
+ return None
500
+ tracks = []
501
+ for track in data.nla_tracks:
502
+ entry = {"name": track.name, "mute": bool(track.mute)}
503
+ if track.is_solo:
504
+ entry["solo"] = True
505
+ if not track.mute:
506
+ entry["strips"] = [s for s in (_strip(strip) for strip in track.strips if strip.type == "CLIP") if s is not None]
507
+ tracks.append(entry)
508
+ return tracks
509
+
510
+
511
+ def _nla_settings(obj):
512
+ """An armature's NLA settings a game plays by, where they are not Blender's defaults: the NLA turned off
513
+ (`tracksEnabled: false`), and the active action's own blend over the strips (`clipBlend`: blend, influence)."""
514
+ data = obj.animation_data
515
+ if data is None:
516
+ return {}
517
+ settings = {}
518
+ if not data.use_nla:
519
+ settings["tracksEnabled"] = False
520
+ if data.action_blend_type != "REPLACE" or abs(data.action_influence - 1.0) > 1e-6:
521
+ settings["clipBlend"] = {"blend": BLENDS[data.action_blend_type], "influence": round(float(data.action_influence), 6)}
522
+ return settings
523
+
524
+
525
+ _OBJECT_CHANNELS = ("location", "rotation_quaternion", "rotation_euler", "rotation_axis_angle", "scale", "delta_location",
526
+ "delta_rotation_quaternion", "delta_rotation_euler", "delta_scale")
527
+
528
+
529
+ def _moves_itself(obj):
530
+ """Whether an object's own transform is animated: by its action, or by an unmuted NLA strip's."""
531
+ data = obj.animation_data
532
+ if data is None:
533
+ return False
534
+ actions = [data.action] if data.action is not None else []
535
+ for track in data.nla_tracks:
536
+ if not track.mute:
537
+ actions.extend(strip.action for strip in track.strips if strip.action is not None)
538
+ return any(c.data_path in _OBJECT_CHANNELS for action in actions for c in _action_curves(action))
539
+
540
+
541
+ def _bake_placement_motion(scene, moving):
542
+ """THE PLACEMENT'S OWN MOTION (RFC 0007 §3): key each moving placement's marker at every scene frame
543
+ where Blender puts its override root (its action and NLA strips, any blend, as Blender evaluates
544
+ them), so the level's scene animation carries it. Answers the actions made, for the caller to remove."""
545
+ made = []
546
+ if not moving:
547
+ return made
548
+ for marker, _root, _inverse in moving:
549
+ marker.rotation_mode = "QUATERNION"
550
+ marker.animation_data_create()
551
+ action = bpy.data.actions.new("__volter_marker_motion")
552
+ marker.animation_data.action = action
553
+ made.append(action)
554
+ for frame in range(scene.frame_start, scene.frame_end + 1):
555
+ scene.frame_set(frame)
556
+ for marker, root, inverse in moving:
557
+ marker.matrix_world = root.matrix_world @ inverse
558
+ for path in ("location", "rotation_quaternion", "scale"):
559
+ marker.keyframe_insert(path, frame=frame)
560
+ return made
561
+
562
+
563
+ def _level_actions(root):
564
+ """The actions a placement plays that the level made (its active action or its NLA strips' actions
565
+ that are not its component's), keying its bones."""
566
+ data = root.animation_data
567
+ if data is None or root.type != "ARMATURE":
568
+ return []
569
+ candidates = [data.action] if data.action is not None else []
570
+ for track in data.nla_tracks:
571
+ if not track.mute:
572
+ candidates.extend(strip.action for strip in track.strips if strip.action is not None)
573
+ found = []
574
+ for action in candidates:
575
+ if action.library is None and action not in found and any(c.data_path.startswith("pose.bones[") for c in _action_curves(action)):
576
+ found.append(action)
577
+ return found
578
+
579
+
580
+ def _companion(out, placement):
581
+ """The file a placement's own actions are written to, beside the level's."""
582
+ base = os.path.splitext(os.path.basename(out))[0]
583
+ return "%s.%s.actions.glb" % (base, re.sub(r"[^A-Za-z0-9._-]", "_", placement))
584
+
585
+
586
+ def _export_placement_actions(scene, view_layer, root, actions, path):
587
+ """A PLACEMENT'S OWN ACTIONS (made in the level, played by the override's NLA): Blender's exporter writes
588
+ them for the placed armature alone (its bones, no meshes), one animation each, through the same passes as
589
+ a component's (RFC 0007 §1, §2): its runtime constraints muted while Blender samples (they are the script's,
590
+ applied at runtime, never baked into the keys), only what each action moves, samples between frames where
591
+ whole frames miss its motion, each rotation key in its action's sign with its keyed length. Answers its
592
+ warnings and what the sub-frame pass did, for the level's manifest."""
593
+ module = _gltf_addon()
594
+ keep = {action.name for action in actions}
595
+ runtime, _data, warnings = _runtime_constraints([root])
596
+ registered = hasattr(bpy.types.Scene, "gltf_action_filter")
597
+ if not registered:
598
+ bpy.types.Scene.gltf_action_filter = bpy.props.CollectionProperty(type=module.GLTF2_filter_action)
599
+ # (a scene that stored no list before stores none after: the property is unset again)
600
+ stored = {scene_.name for scene_ in bpy.data.scenes if scene_.is_property_set("gltf_action_filter")}
601
+ listed = bpy.data.scenes[0].gltf_action_filter
602
+ saved = [(item.action, item.keep) for item in listed]
603
+ listed.clear()
604
+ try:
605
+ for action in bpy.data.actions:
606
+ item = listed.add()
607
+ item.action = action
608
+ item.keep = action.name in keep
609
+ settings = dict(COMPONENT_SETTINGS, export_extras=False, export_cameras=False, export_lights=False, export_apply=False)
610
+ _with_hooks(_Hooks({}, {"keyRate": _key_rate(scene)}, {}), lambda: _muted(runtime, lambda: _at_rest([root], lambda: _export_set(
611
+ scene, view_layer, [root], lambda: _gltf(path, settings)))))
612
+ _unique_channels(path)
613
+ _weights_keyed(path)
614
+ candidates = {key: value for key, value in _subframe_candidates([root]).items() if key[1] in keep}
615
+ dense = _muted(runtime, lambda: _densify_subframes(path, [root], scene, candidates))
616
+ warnings.extend(_subframe_warnings(dense, candidates))
617
+ _align_rotation_signs(path, [root], scene)
618
+ finally:
619
+ listed.clear()
620
+ for action, kept in saved:
621
+ item = listed.add()
622
+ item.action, item.keep = action, kept
623
+ for scene_ in bpy.data.scenes:
624
+ if scene_.name not in stored and scene_.is_property_set("gltf_action_filter"):
625
+ scene_.property_unset("gltf_action_filter")
626
+ if not registered:
627
+ del bpy.types.Scene.gltf_action_filter
628
+ warnings.extend(_euler_bones([root], keep))
629
+ warnings.extend(_evaluated_bones([root]))
630
+ return {"warnings": warnings, "subframeSampling": _subframe_manifest(dense)}
631
+
632
+
633
+ def _strip_warnings(objects):
634
+ """What of the NLA a game cannot play as Blender does, by name."""
635
+ found = []
636
+ for obj in objects:
637
+ data = obj.animation_data if obj.type == "ARMATURE" else None
638
+ if data is None:
639
+ continue
640
+ for track in data.nla_tracks:
641
+ if track.mute:
642
+ continue
643
+ for strip in track.strips:
644
+ if strip.type != "CLIP":
645
+ found.append("%s's NLA strip %s is a %s strip: only action strips play" % (obj.name, strip.name, strip.type))
646
+ if strip.use_animated_time:
647
+ found.append("%s's NLA strip %s has an animated strip time: a game plays the strip's own timing"
648
+ % (obj.name, strip.name))
649
+ if data.action_extrapolation != "HOLD" and data.action is not None:
650
+ found.append("%s's active action extrapolates as %s: a game plays its active action looping, or held at its "
651
+ "end, as the script sets it" % (obj.name, data.action_extrapolation))
652
+ return found
653
+
654
+
655
+ def _euler_bones(objects, only=None):
656
+ """Actions that key bones' rotations in Euler angles (or axis-angle): glTF carries rotations as
657
+ quaternions, so layered they blend as quaternions, where Blender's NLA blends the angles. Named per
658
+ action, with its bones (RFC 0007 §5); `only`, the action names to look at (all when None)."""
659
+ found = []
660
+ for obj in objects:
661
+ if obj.type != "ARMATURE" or obj.pose is None:
662
+ continue
663
+ angled = {b.name for b in obj.pose.bones if b.rotation_mode != "QUATERNION"}
664
+ if not angled:
665
+ continue
666
+ by_action = {}
667
+ for action in bpy.data.actions:
668
+ if only is not None and action.name not in only:
669
+ continue
670
+ for curve in _action_curves(action):
671
+ found_path = _BONE_PATH.match(curve.data_path)
672
+ if found_path and found_path.group(2) in ("rotation_euler", "rotation_axis_angle") and found_path.group(1) in angled:
673
+ by_action.setdefault(action.name, set()).add(found_path.group(1))
674
+ for action, bones in sorted(by_action.items()):
675
+ found.append("%s's action %s keys Euler rotations (%s): glTF carries rotations as quaternions, so layered it "
676
+ "blends as quaternions where Blender's NLA blends the angles; the two differ most for angles far "
677
+ "apart" % (obj.name, action, ", ".join(sorted(bones))))
678
+ return found
679
+
680
+
681
+ def _evaluated_bones(objects):
682
+ """Bones Blender poses from other bones as it plays (a constraint baked into the clips, every bone of
683
+ an IK chain, a bone not inheriting its parent's rotation or scale): each action carries their
684
+ evaluated pose, so a layer over another action blends those poses, where Blender evaluates them
685
+ again from the blended keys. Named, with the bones."""
686
+ found = []
687
+ for obj in objects:
688
+ if obj.type != "ARMATURE" or obj.pose is None:
689
+ continue
690
+ solved, loose = set(), set()
691
+ for pb in obj.pose.bones:
692
+ # (a runtime Damped Track is the script's, not baked)
693
+ runtime = {c.name for c in pb.constraints if c.name in _runtime_names(pb) and c.type == "DAMPED_TRACK"}
694
+ for constraint in pb.constraints:
695
+ if constraint.mute or constraint.influence <= 0 or constraint.name in runtime:
696
+ continue
697
+ if constraint.type in ("IK", "SPLINE_IK"):
698
+ length = constraint.chain_count
699
+ bone, depth = pb, 0
700
+ while bone is not None and (length == 0 or depth < length):
701
+ solved.add(bone.name)
702
+ bone, depth = bone.parent, depth + 1
703
+ else:
704
+ solved.add(pb.name)
705
+ if not pb.bone.use_inherit_rotation or pb.bone.inherit_scale != "FULL":
706
+ loose.add(pb.name)
707
+ parts = []
708
+ if solved:
709
+ parts.append("constrained (IK chains included): " + ", ".join(sorted(solved)))
710
+ if loose:
711
+ parts.append("not inheriting their parent's rotation or scale: " + ", ".join(sorted(loose)))
712
+ if parts:
713
+ found.append("%s's bones that Blender poses from others are carried as each action evaluates them, so a "
714
+ "layer over another action blends those poses, not Blender's evaluation of the blended keys; "
715
+ "%s" % (obj.name, "; ".join(parts)))
716
+ return found
717
+
718
+
719
+ # BETWEEN FRAMES (RFC 0007 §1): where interpolating whole-frame samples is this far from what Blender
720
+ # evaluates at a half step, the export samples there too.
721
+ SUBFRAME_DEGREES = 0.25
722
+ SUBFRAME_METRES = 0.001
723
+ SUBFRAME_SCALE = 0.001
724
+ SUBFRAME_MOST = 32
725
+ _Y_UP = mathutils.Matrix(((1.0, 0.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (0.0, -1.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0)))
726
+
727
+
728
+ def _world_unit(obj):
729
+ """How long one unit of an armature's own space is in the world (its largest scale)."""
730
+ return max(abs(v) for v in obj.matrix_world.to_scale()) or 1.0
731
+
732
+
733
+ def _subframe_candidates(objects):
734
+ """WHICH ACTIONS MAY NEED SAMPLING BETWEEN FRAMES, from their curves alone (no frame is evaluated): per
735
+ armature and action, the largest difference at a half frame between Blender's curve and the interpolation
736
+ of its whole-frame values, for each keyed bone rotation (quaternion or Euler: its own rotation, in degrees)
737
+ and location (in metres in the world). Answers {(armature, action): (degrees, metres, bone, frame)}."""
738
+ found = {}
739
+ for obj in objects:
740
+ if obj.type != "ARMATURE" or obj.pose is None:
741
+ continue
742
+ unit = _world_unit(obj)
743
+ modes = {b.name: b.rotation_mode for b in obj.pose.bones}
744
+ modes[_OBJECT] = obj.rotation_mode
745
+ for action in bpy.data.actions:
746
+ curves = {}
747
+ for curve in _action_curves(action):
748
+ matched = _BONE_PATH.match(curve.data_path)
749
+ if matched and matched.group(1) in modes:
750
+ bone, path = matched.group(1), matched.group(2)
751
+ elif curve.data_path in ("location", "rotation_quaternion", "rotation_euler"):
752
+ bone, path = _OBJECT, curve.data_path
753
+ else:
754
+ continue
755
+ if path in ("rotation_quaternion", "rotation_euler", "location") and 0 <= curve.array_index < 4:
756
+ curves.setdefault((bone, path), [None, None, None, None])[curve.array_index] = curve
757
+ if not curves:
758
+ continue
759
+ first, last = (int(math.floor(v)) for v in action.frame_range)
760
+ halves = range(2 * first, 2 * last + 1)
761
+ worst = [0.0, 0.0, None, None]
762
+ for (bone, path), four in curves.items():
763
+ if path == "rotation_quaternion" and modes[bone] != "QUATERNION":
764
+ continue
765
+ if path == "rotation_euler" and modes[bone] in ("QUATERNION", "AXIS_ANGLE"):
766
+ continue
767
+ size = 4 if path == "rotation_quaternion" else 3
768
+ rest = (1.0, 0.0, 0.0, 0.0) if path == "rotation_quaternion" else (0.0, 0.0, 0.0)
769
+ at = [[c.evaluate(f / 2.0) if c is not None else rest[i] for f in halves] for i, c in enumerate(four[:size])]
770
+
771
+ def value(k):
772
+ v = [at[i][k] for i in range(size)]
773
+ if path == "rotation_quaternion":
774
+ return tuple(_unit_quaternion([v[1], v[2], v[3], v[0]]))
775
+ if path == "rotation_euler":
776
+ q = mathutils.Euler(v, modes[bone]).to_quaternion()
777
+ return (q.x, q.y, q.z, q.w)
778
+ return v
779
+ values = [value(k) for k in range(len(at[0]))]
780
+ for k in range(0, len(at[0]) - 2, 2):
781
+ a, b, half = values[k], values[k + 2], values[k + 1]
782
+ if path == "location":
783
+ metres = math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(a, b, half))) * unit
784
+ if metres > worst[1]:
785
+ worst[1] = metres
786
+ if worst[2] is None:
787
+ worst[2], worst[3] = bone, first + k / 2.0 + 0.5
788
+ continue
789
+ points = [(0.5, half)]
790
+ if abs(sum(x * y for x, y in zip(a, b))) < 0.9962:
791
+ # Turning over 10 degrees in a frame: Blender's normalised interpolation meets the arc in
792
+ # the middle and parts from it near the quarters, so look there too.
793
+ for u in (0.25, 0.75):
794
+ at_u = first + k / 2.0 + u
795
+ v = [c.evaluate(at_u) if c is not None else rest[i] for i, c in enumerate(four[:size])]
796
+ if path == "rotation_quaternion":
797
+ points.append((u, (v[1], v[2], v[3], v[0])))
798
+ else:
799
+ q = mathutils.Euler(v, modes[bone]).to_quaternion()
800
+ points.append((u, (q.x, q.y, q.z, q.w)))
801
+ difference = _off_arc(a, b, points)
802
+ if difference > worst[0]:
803
+ worst[0], worst[2], worst[3] = difference, bone, first + k / 2.0 + 0.5
804
+ if worst[0] > 0.0 or worst[1] > 0.0:
805
+ found[(obj.name, action.name)] = tuple(worst)
806
+ return found
807
+
808
+
809
+ def _scene_actions(obj):
810
+ """What moves an object in the scene's animation: its active action (scene frame = action frame) and each
811
+ unmuted action strip of its unmuted NLA tracks, with the strip that maps scene frames into the action."""
812
+ data = obj.animation_data
813
+ if data is None:
814
+ return []
815
+ found = [(data.action, None)] if data.action is not None else []
816
+ if data.use_nla:
817
+ for track in data.nla_tracks:
818
+ if track.mute:
819
+ continue
820
+ found.extend((strip.action, strip) for strip in track.strips
821
+ if strip.type == "CLIP" and not strip.mute and strip.action is not None)
822
+ return found
823
+
824
+
825
+ def _strip_frame(strip, frame):
826
+ """The action frame a strip shows at a scene frame inside it (its range, scale, repeats, reversed)."""
827
+ if strip is None:
828
+ return frame
829
+ span = strip.action_frame_end - strip.action_frame_start
830
+ into = max(0.0, frame - strip.frame_start) / (strip.scale or 1.0)
831
+ if span > 0 and into > span:
832
+ into = span if frame >= strip.frame_end else into % span
833
+ at = strip.action_frame_start + into
834
+ return strip.action_frame_end - into if strip.use_reverse else at
835
+
836
+
837
+ def _scene_subframe_worst(objects, scene):
838
+ """Measure scene-animation interpolation error from active actions and NLA strips at half frames, adding quarter frames for rotations over ten degrees per frame. Return worst rotation and position errors with object, bone and frame."""
839
+ worst_turn, worst_move = (0.0, None, None, None), (0.0, None, None, None)
840
+ first, last = scene.frame_start, scene.frame_end
841
+ for obj in objects:
842
+ for action, strip in _scene_actions(obj):
843
+ groups = _curve_groups(obj, action)
844
+ if not groups:
845
+ continue
846
+ lo, hi = (first, last) if strip is None else (max(first, int(math.ceil(strip.frame_start))), min(last, int(math.floor(strip.frame_end))))
847
+ unit = _world_unit(obj) if obj.type == "ARMATURE" else 1.0
848
+ for group in groups:
849
+ bone = None if group[0] == _OBJECT else group[0]
850
+
851
+ def value(frame):
852
+ found = _curve_value(group, _strip_frame(strip, frame))
853
+ return found if group[1] == "location" else tuple(_unit_quaternion(found))
854
+ previous = value(lo) if lo <= hi else None
855
+ for frame in range(lo, hi):
856
+ a, b = previous, value(frame + 1)
857
+ previous = b
858
+ if group[1] == "location":
859
+ mid = value(frame + 0.5)
860
+ gap = math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(a, b, mid))) * (unit if bone else 1.0)
861
+ if gap > worst_move[0]:
862
+ worst_move = (gap, obj.name, bone, frame + 0.5)
863
+ continue
864
+ points = [(0.5, value(frame + 0.5))]
865
+ if abs(sum(x * y for x, y in zip(a, b))) < 0.9962:
866
+ points += [(u, value(frame + u)) for u in (0.25, 0.75)]
867
+ gap = _off_arc(a, b, points)
868
+ if gap > worst_turn[0]:
869
+ worst_turn = (gap, obj.name, bone, frame + 0.5)
870
+ return worst_turn, worst_move
871
+
872
+
873
+ def _scene_subframe_warnings(worst):
874
+ turn, move = worst
875
+ if turn[0] < 0.01 and move[0] < 0.0001:
876
+ return []
877
+ where = lambda w: "%s%s, frame %.2f" % (w[1], " / " + w[2] if w[2] else "", w[3] or 0.0)
878
+ return ["between frames: the level's scene animation (cameras, keyed objects, the placements' motion, its own rigs) "
879
+ "is sampled at whole frames, and a cutscene plays between them; its curves part from the interpolation of "
880
+ "their whole-frame values by up to %.2f deg (%s) and %.1f mm (%s)" % (turn[0], where(turn) if turn[1] else "-",
881
+ move[0] * 1000.0, where(move) if move[1] else "-")]
882
+
883
+
884
+ def _action_slot_for(action):
885
+ """The action's only object slot, or None when there is not exactly one."""
886
+ slots = [slot for slot in action.slots if slot.target_id_type == "OBJECT"]
887
+ return slots[0] if len(slots) == 1 else None
888
+
889
+
890
+ def _as_exported(obj, action, slot, run):
891
+ """Run with `obj` posed as Blender's glTF exporter samples an action (its animation/action.py): the NLA off,
892
+ no track soloed, out of tweak mode, every pose bone at its rest, the action and its slot assigned; then put
893
+ everything back."""
894
+ data = obj.animation_data_create()
895
+ kept = (data.action, data.action_slot if data.action is not None else None, data.use_nla, data.use_tweak_mode)
896
+ soloed = [track for track in data.nla_tracks if track.is_solo]
897
+ held = _pose_channels([obj])
898
+ try:
899
+ if data.use_tweak_mode:
900
+ data.use_tweak_mode = False
901
+ for track in soloed:
902
+ track.is_solo = False
903
+ data.use_nla = False
904
+ data.action = None
905
+ _clear_pose([obj])
906
+ data.action = action
907
+ if slot is not None:
908
+ data.action_slot = slot
909
+ return run()
910
+ finally:
911
+ data.action = None
912
+ _put_back(held)
913
+ data.action = kept[0]
914
+ if kept[0] is not None and kept[1] is not None:
915
+ data.action_slot = kept[1]
916
+ data.use_nla = kept[2]
917
+ for track in soloed:
918
+ track.is_solo = True
919
+ if kept[3] and not data.use_tweak_mode:
920
+ try:
921
+ data.use_tweak_mode = True
922
+ except Exception: # noqa: BLE001 (no strip to tweak any more)
923
+ pass
924
+
925
+
926
+ def _bone_transforms(obj, parents):
927
+ """Each exported bone's glTF translation, rotation and scale from its pose now, computed as Blender's
928
+ exporter computes them (sampling_cache.armature_caching, then armature/sampler.py's correction and
929
+ gltf2_blender_math.transform): `parents` gives each bone's exported parent bone, or None at the root."""
930
+ axis = _Y_UP
931
+ fixed = {}
932
+ for name, parent in parents.items():
933
+ bone = obj.pose.bones[name]
934
+ if parent is not None:
935
+ rest = obj.pose.bones[parent].bone.matrix_local.inverted_safe() @ bone.bone.matrix_local
936
+ fixed[name] = (rest.inverted_safe(), rest, parent)
937
+ elif bone.parent is None:
938
+ fixed[name] = (bone.bone.matrix_local.inverted_safe(), axis @ bone.bone.matrix_local, None)
939
+ else:
940
+ fixed[name] = (None, axis, None)
941
+
942
+ def now():
943
+ out = {}
944
+ # The armature object's node: its world matrix in its parent's (sampling_cache.object_caching), each part
945
+ # through gltf2_blender_math.transform and swizzled to Y-up (object/sampler.py).
946
+ matrix = (obj.parent.matrix_world.inverted_safe() @ obj.matrix_world) if obj.parent is not None else obj.matrix_world.copy()
947
+ t, r, sc = matrix.decompose()
948
+ r.normalize()
949
+ q = r.to_matrix().to_4x4().to_quaternion()
950
+ sc = mathutils.Matrix.Diagonal(sc).to_4x4().to_scale()
951
+ out[_OBJECT] = {"translation": [t[0], t[2], -t[1]],
952
+ "rotation": [q.x, q.z, -q.y, q.w],
953
+ "scale": [sc[0], sc[2], sc[1]]}
954
+ for name, (before, correction, parent) in fixed.items():
955
+ bone = obj.pose.bones[name]
956
+ if parent is not None:
957
+ matrix = before @ obj.pose.bones[parent].matrix.inverted_safe() @ bone.matrix
958
+ elif before is not None:
959
+ matrix = before @ bone.matrix
960
+ else:
961
+ matrix = bone.matrix.copy()
962
+ t, r, sc = matrix.decompose()
963
+ r.normalize()
964
+ stretch = mathutils.Matrix.Identity(4)
965
+ stretch[0][0], stretch[1][1], stretch[2][2] = sc
966
+ q = (correction @ r.to_matrix().to_4x4()).to_quaternion()
967
+ out[name] = {"translation": list((correction @ mathutils.Matrix.Translation(t)).to_translation()),
968
+ "rotation": [q.x, q.y, q.z, q.w], "scale": list((correction @ stretch).to_scale())}
969
+ return out
970
+ return now
971
+
972
+
973
+ def _unit_quaternion(q):
974
+ n = math.sqrt(sum(v * v for v in q)) or 1.0
975
+ return [v / n for v in q]
976
+
977
+
978
+ def _quaternion_degrees(a, b):
979
+ """The angle between two rotations ([x, y, z, w]), in double precision, either sign."""
980
+ a, b = _unit_quaternion(a), _unit_quaternion(b)
981
+ # conj(a) * b
982
+ w = a[3] * b[3] + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
983
+ x = a[3] * b[0] - a[0] * b[3] - a[1] * b[2] + a[2] * b[1]
984
+ y = a[3] * b[1] + a[0] * b[2] - a[1] * b[3] - a[2] * b[0]
985
+ z = a[3] * b[2] - a[0] * b[1] + a[1] * b[0] - a[2] * b[3]
986
+ return math.degrees(2.0 * math.atan2(math.sqrt(x * x + y * y + z * z), abs(w)))
987
+
988
+
989
+ def _quaternion_slerp(a, b, t):
990
+ """The shortest arc from `a` to `b` at `t` ([x, y, z, w]), as a reader interpolates two keys."""
991
+ a, b = _unit_quaternion(a), _unit_quaternion(b)
992
+ dot = sum(x * y for x, y in zip(a, b))
993
+ if dot < 0.0:
994
+ b, dot = [-v for v in b], -dot
995
+ if dot > 0.9999999:
996
+ return _unit_quaternion([x + (y - x) * t for x, y in zip(a, b)])
997
+ theta = math.acos(min(1.0, dot))
998
+ s0, s1 = math.sin((1.0 - t) * theta) / math.sin(theta), math.sin(t * theta) / math.sin(theta)
999
+ return [s0 * x + s1 * y for x, y in zip(a, b)]
1000
+
1001
+
1002
+ def _off_arc(a, b, points):
1003
+ """The largest angle, in degrees, between each (u, rotation) of `points` and the shortest arc from `a` to `b`
1004
+ at u: what a reader interpolating keys `a` and `b` misses there. `a` and `b` are unit [x, y, z, w]; the
1005
+ points' rotations need not be unit. Pure Python, the arc's angle worked out once."""
1006
+ ax, ay, az, aw = a
1007
+ bx, by, bz, bw = b
1008
+ dot = ax * bx + ay * by + az * bz + aw * bw
1009
+ if dot < 0.0:
1010
+ bx, by, bz, bw, dot = -bx, -by, -bz, -bw, -dot
1011
+ theta = math.acos(min(1.0, dot))
1012
+ sine = math.sin(theta)
1013
+ worst = 0.0
1014
+ for u, (mx, my, mz, mw) in points:
1015
+ if sine < 1e-9:
1016
+ w0, w1 = 1.0 - u, u
1017
+ else:
1018
+ w0, w1 = math.sin((1.0 - u) * theta) / sine, math.sin(u * theta) / sine
1019
+ px, py, pz, pw = w0 * ax + w1 * bx, w0 * ay + w1 * by, w0 * az + w1 * bz, w0 * aw + w1 * bw
1020
+ # conj(p) * m: its vector part against its scalar part is half the angle between them.
1021
+ w = pw * mw + px * mx + py * my + pz * mz
1022
+ x = pw * mx - px * mw - py * mz + pz * my
1023
+ y = pw * my + px * mz - py * mw - pz * mx
1024
+ z = pw * mz - px * my + py * mx - pz * mw
1025
+ angle = math.degrees(2.0 * math.atan2(math.sqrt(x * x + y * y + z * z), abs(w)))
1026
+ if angle > worst:
1027
+ worst = angle
1028
+ return worst
1029
+
1030
+
1031
+ _OBJECT = "\u0000object"
1032
+
1033
+
1034
+ def _curve_groups(obj, action):
1035
+ """The action's curves on `obj`'s bones, per bone and property: (bone, path, four curves, rotation mode), for
1036
+ a quaternion or Euler rotation and a location. The armature object's own channels (root motion keyed on the
1037
+ object) are one more group, its bone named _OBJECT."""
1038
+ modes = {b.name: b.rotation_mode for b in obj.pose.bones} if obj.pose is not None else {}
1039
+ modes[_OBJECT] = obj.rotation_mode
1040
+ curves = {}
1041
+ for curve in _action_curves(action):
1042
+ matched = _BONE_PATH.match(curve.data_path)
1043
+ if matched and matched.group(1) in modes:
1044
+ bone, path = matched.group(1), matched.group(2)
1045
+ elif curve.data_path in ("location", "rotation_quaternion", "rotation_euler"):
1046
+ bone, path = _OBJECT, curve.data_path
1047
+ else:
1048
+ continue
1049
+ if path in ("rotation_quaternion", "rotation_euler", "location") and 0 <= curve.array_index < 4:
1050
+ curves.setdefault((bone, path), [None, None, None, None])[curve.array_index] = curve
1051
+ groups = []
1052
+ for (bone, path), four in sorted(curves.items()):
1053
+ mode = modes[bone]
1054
+ if path == "rotation_quaternion" and mode != "QUATERNION":
1055
+ continue
1056
+ if path == "rotation_euler" and mode in ("QUATERNION", "AXIS_ANGLE"):
1057
+ continue
1058
+ groups.append((bone, path, four, mode))
1059
+ return groups
1060
+
1061
+
1062
+ def _curve_value(group, frame):
1063
+ """A group's value at a frame: a rotation as [x, y, z, w], a location as [x, y, z]."""
1064
+ _bone, path, four, mode = group
1065
+ if path == "rotation_quaternion":
1066
+ v = [c.evaluate(frame) if c is not None else (1.0 if i == 0 else 0.0) for i, c in enumerate(four)]
1067
+ return [v[1], v[2], v[3], v[0]]
1068
+ v = [c.evaluate(frame) if c is not None else 0.0 for c in four[:3]]
1069
+ if path == "rotation_euler":
1070
+ q = mathutils.Euler(v, mode).to_quaternion()
1071
+ return [q.x, q.y, q.z, q.w]
1072
+ return v
1073
+
1074
+
1075
+ def _straight(group):
1076
+ """Whether every key of the group's curves interpolates linearly, with no modifier: then between two of its
1077
+ keys each component moves in a straight line."""
1078
+ return all(c is None or (len(c.modifiers) == 0 and all(p.interpolation == "LINEAR" for p in c.keyframe_points))
1079
+ for c in group[2])
1080
+
1081
+
1082
+ def _sample_frames(groups, first, last, unit, chains):
1083
+ """WHERE TO SAMPLE AN ACTION: its whole frames (as the exporter samples it), its own keys (an action keyed
1084
+ off whole frames, every 0.8 frames for a 30 fps source in a 24 fps scene, turns between whole frames), and,
1085
+ where its curves still part from what a reader interpolates between two of those by more than the limit
1086
+ (looked at a quarter, half and three quarters of the way), the middle, down to 1 / SUBFRAME_MOST of a frame. A rotation is judged along its bone's chain: the sum of
1087
+ its own and its parents' differences (`chains`: each bone with its parents), so a fingertip, which turns
1088
+ with every bone above it, stays within the limit too. Rotations keyed linearly still part from a reader's
1089
+ interpolation (Blender normalises the interpolated components, a reader turns along the arc), so every
1090
+ rotation is judged; a linearly keyed location is not. Answers the frames, sorted, and what the curves leave
1091
+ between them: (degrees along a chain, bone, frame)."""
1092
+ frames = {float(f) for f in range(int(math.ceil(first)), int(math.floor(last)) + 1)}
1093
+ frames.update((first, last))
1094
+ for group in groups:
1095
+ for curve in group[2]:
1096
+ if curve is not None:
1097
+ frames.update(round(p.co[0], 4) for p in curve.keyframe_points if first < p.co[0] < last)
1098
+ judged = [g for g in groups if g[1] != "location" or not _straight(g)]
1099
+ straight = {id(g) for g in judged if _straight(g)}
1100
+ left = (0.0, None, None)
1101
+ if not judged:
1102
+ return sorted(frames), left
1103
+ cache = {}
1104
+
1105
+ def value(group, at):
1106
+ key = (id(group), at)
1107
+ if key not in cache:
1108
+ found = _curve_value(group, at)
1109
+ cache[key] = found if group[1] == "location" else tuple(_unit_quaternion(found))
1110
+ return cache[key]
1111
+
1112
+ ordered = sorted(frames)
1113
+ todo = list(zip(ordered, ordered[1:]))
1114
+ while todo:
1115
+ a, b = todo.pop()
1116
+ middle = (a + b) / 2.0
1117
+ turned, moved = {}, False
1118
+ for group in judged:
1119
+ va, vb = value(group, a), value(group, b)
1120
+ if group[1] == "location":
1121
+ vm = value(group, middle)
1122
+ moved = moved or math.sqrt(sum((m - (x + y) / 2.0) ** 2 for x, y, m in zip(va, vb, vm))) * unit > SUBFRAME_METRES
1123
+ continue
1124
+ if id(group) in straight and abs(sum(x * y for x, y in zip(va, vb))) > 0.99985:
1125
+ # Keyed linearly and turning under 2 degrees here: its normalised interpolation is on the arc
1126
+ # to within thousandths of a degree.
1127
+ continue
1128
+ # At the quarters too: Blender's normalised interpolation meets the arc in the middle and parts from
1129
+ # it most near a quarter.
1130
+ turned[group[0]] = _off_arc(va, vb, [(u, value(group, a + (b - a) * u)) for u in (0.25, 0.5, 0.75)])
1131
+ along = max(((sum(turned.get(b, 0.0) for b in chains[bone]), bone) for bone in turned), default=(0.0, None))
1132
+ if (moved or along[0] > SUBFRAME_DEGREES) and (b - a) / 2.0 >= 1.0 / SUBFRAME_MOST:
1133
+ frames.add(middle)
1134
+ todo.extend(((a, middle), (middle, b)))
1135
+ continue
1136
+ if along[0] > left[0]:
1137
+ left = (along[0], along[1], middle)
1138
+ return sorted(frames), left
1139
+
1140
+
1141
+ def _densify_subframes(out, objects, scene, candidates):
1142
+ """SAMPLE BETWEEN FRAMES WHERE IT MATTERS (RFC 0007 §1). Blender's exporter samples each action at whole
1143
+ frames only: it calls `scene.frame_set(int(frame))` (its sampled/sampling_cache.py), so a fractional
1144
+ `export_frame_step` is cut to whole frames, and the step itself is a whole number.
1145
+
1146
+ For each action whose curves say a half frame may be off by more than SUBFRAME_DEGREES (or
1147
+ SUBFRAME_METRES), this chooses where to sample (_sample_frames: whole frames, the action's own keys, and
1148
+ the middles its bending curves need), poses the armature as the exporter does (_as_exported) and evaluates
1149
+ Blender at each of those frames that isn't whole, computing each bone's glTF values with the exporter's own
1150
+ formulas (_bone_transforms). Whole frames keep the exporter's own keys; a few are evaluated too, and must
1151
+ give the same values, else the action is left as exported and named. A bone channel whose new samples lie
1152
+ more than the limit from its whole-frame interpolation gets all of them; every other channel stays as the
1153
+ exporter wrote it. Runs before the sign pass, which then writes every key, the new ones too, in its
1154
+ action's sign with its keyed length.
1155
+
1156
+ Answers {"actions": {action: keys}, "channels": n, "bytes": change in the file's size, "remaining":
1157
+ (degrees, action, bone, frame), "mismatch": [...], "evaluated": frames evaluated}."""
1158
+ result = {"actions": {}, "channels": 0, "bytes": 0, "remaining": (0.0, None, None, None), "mismatch": [], "evaluated": 0}
1159
+ wanted = {action for (_armature, action), (degrees, metres, _b, _f) in candidates.items()
1160
+ if degrees > SUBFRAME_DEGREES or metres > SUBFRAME_METRES}
1161
+ armatures = {o.name: o for o in objects if o.type == "ARMATURE" and o.pose is not None}
1162
+ if not wanted or not armatures:
1163
+ return result
1164
+ before_bytes = os.path.getsize(out)
1165
+ data, length, document, binary = _glb_parts(out)
1166
+ nodes = document.get("nodes", [])
1167
+ accessors, views = document.get("accessors", []), document.get("bufferViews", [])
1168
+ parent_of = {child: index for index, node in enumerate(nodes) for child in node.get("children", [])}
1169
+ rate = _key_rate(scene)
1170
+ frame, subframe = scene.frame_current, scene.frame_subframe
1171
+
1172
+ def read(index):
1173
+ accessor = accessors[index]
1174
+ view = views[accessor["bufferView"]]
1175
+ width = {"SCALAR": 1, "VEC3": 3, "VEC4": 4}[accessor["type"]]
1176
+ start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
1177
+ flat = struct.unpack_from("<%df" % (accessor["count"] * width), binary, start)
1178
+ return [list(flat[i * width:(i + 1) * width]) for i in range(accessor["count"])] if width > 1 else list(flat)
1179
+
1180
+ def append(values, kind, bounds=False):
1181
+ flat = [v for value in values for v in value] if kind != "SCALAR" else list(values)
1182
+ packed = struct.pack("<%df" % len(flat), *flat)
1183
+ binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
1184
+ views.append({"buffer": 0, "byteOffset": len(binary), "byteLength": len(packed)})
1185
+ binary.extend(packed)
1186
+ accessor = {"bufferView": len(views) - 1, "componentType": 5126, "count": len(values), "type": kind}
1187
+ if bounds:
1188
+ accessor["min"], accessor["max"] = [min(values)], [max(values)]
1189
+ accessors.append(accessor)
1190
+ return len(accessors) - 1
1191
+
1192
+ def interpolate(path, frames, values, at):
1193
+ """A channel's exported keys read at a frame, as a reader reads them (held past the ends)."""
1194
+ if at <= frames[0]:
1195
+ return values[0]
1196
+ if at >= frames[-1]:
1197
+ return values[-1]
1198
+ for k in range(1, len(frames)):
1199
+ if at <= frames[k]:
1200
+ u = (at - frames[k - 1]) / (frames[k] - frames[k - 1]) if frames[k] > frames[k - 1] else 1.0
1201
+ if path == "rotation":
1202
+ return _quaternion_slerp(values[k - 1], values[k], u)
1203
+ return [x + (y - x) * u for x, y in zip(values[k - 1], values[k])]
1204
+ try:
1205
+ for animation in document.get("animations", []):
1206
+ name = animation.get("name", "")
1207
+ action = bpy.data.actions.get(name)
1208
+ if action is None or name not in wanted:
1209
+ continue
1210
+ channels = []
1211
+ for channel in animation.get("channels", []):
1212
+ target = channel.get("target", {})
1213
+ node = target.get("node")
1214
+ if node is None or target.get("path") not in ("translation", "rotation", "scale"):
1215
+ continue
1216
+ bone = nodes[node].get("name", "")
1217
+ owner = next((a for a in armatures.values() if a.pose.bones.get(bone) is not None), None)
1218
+ if owner is None and bone in armatures:
1219
+ owner, bone = armatures[bone], _OBJECT
1220
+ sampler = animation["samplers"][channel["sampler"]]
1221
+ if owner is None or sampler.get("interpolation", "LINEAR") != "LINEAR":
1222
+ continue
1223
+ channels.append((node, channel, owner, bone, target["path"], sampler))
1224
+ owners = {owner.name for _n, _c, owner, _b, _p, _s in channels}
1225
+ if len(owners) != 1:
1226
+ continue
1227
+ obj = armatures[owners.pop()]
1228
+ slot = _action_slot_for(action)
1229
+ if slot is None and len(action.slots) > 1:
1230
+ result["mismatch"].append("%s has several slots: sampled at whole frames only" % name)
1231
+ continue
1232
+ slide = _action_slide(action)
1233
+ exported = {}
1234
+ for _n, _c, _o, _b, _p, sampler in channels:
1235
+ key = (sampler["input"], sampler["output"])
1236
+ if key not in exported:
1237
+ exported[key] = ([round(t * rate + slide, 4) for t in read(sampler["input"])], read(sampler["output"]))
1238
+ first = min(f[0][0] for f in exported.values())
1239
+ last = max(f[0][-1] for f in exported.values())
1240
+ if last - first < 1:
1241
+ continue
1242
+ unit = _world_unit(obj)
1243
+ groups = _curve_groups(obj, action)
1244
+ chains = {_OBJECT: [_OBJECT]}
1245
+ for bone in obj.pose.bones:
1246
+ chains[bone.name] = [bone.name] + [parent.name for parent in bone.parent_recursive] + [_OBJECT]
1247
+ frames, curves_left = _sample_frames(groups, first, last, unit, chains)
1248
+ between = [f for f in frames if abs(f - round(f)) > 1e-4]
1249
+ if not between:
1250
+ continue
1251
+ parents = {}
1252
+ for node, _c, _o, bone, _p, _s in channels:
1253
+ if bone == _OBJECT:
1254
+ continue
1255
+ parent = parent_of.get(node)
1256
+ parent_name = nodes[parent].get("name") if parent is not None else None
1257
+ parents[bone] = parent_name if parent_name is not None and obj.pose.bones.get(parent_name) is not None else None
1258
+ transforms = _bone_transforms(obj, parents)
1259
+ # Three whole frames are evaluated too: the exporter's formulas must give its own keys there.
1260
+ checks = sorted({float(math.ceil(first)), float(round((first + last) / 2.0)), float(math.floor(last))})
1261
+
1262
+ def evaluate():
1263
+ found = {}
1264
+ for at in checks + between:
1265
+ whole = math.floor(at)
1266
+ scene.frame_set(int(whole), subframe=at - whole)
1267
+ found[at] = transforms()
1268
+ result["evaluated"] += len(found)
1269
+ return found
1270
+ posed = _as_exported(obj, action, slot, evaluate)
1271
+ worst_match = 0.0
1272
+ for _n, _c, _o, bone, path, sampler in channels:
1273
+ key_frames, values = exported[(sampler["input"], sampler["output"])]
1274
+ for at in checks:
1275
+ if at in key_frames:
1276
+ mine, theirs = posed[at][bone][path], values[key_frames.index(at)]
1277
+ worst_match = max(worst_match, _quaternion_degrees(mine, theirs) if path == "rotation" else
1278
+ max(abs(x - y) / max(1.0, abs(y)) for x, y in zip(mine, theirs)) * 1000.0)
1279
+ if worst_match > 0.01:
1280
+ result["mismatch"].append("%s: Blender's poses at whole frames differ from the exported keys by %.4f "
1281
+ "(degrees or mm): sampled at whole frames only" % (name, worst_match))
1282
+ continue
1283
+ seconds = [(f - slide) / rate for f in frames]
1284
+ new_input = None
1285
+ moved = 0
1286
+ placed = {}
1287
+ for _n, channel, _o, bone, path, sampler in channels:
1288
+ key_frames, values = exported[(sampler["input"], sampler["output"])]
1289
+ if sampler["input"] not in placed:
1290
+ where = []
1291
+ for at in between:
1292
+ k = min(max(bisect.bisect_left(key_frames, at), 1), len(key_frames) - 1)
1293
+ span = key_frames[k] - key_frames[k - 1]
1294
+ where.append((at, k, min(1.0, max(0.0, (at - key_frames[k - 1]) / span)) if span > 0 else 1.0))
1295
+ placed[sampler["input"]] = where
1296
+ where = placed[sampler["input"]]
1297
+ if path == "rotation":
1298
+ off = 0.0
1299
+ for at, k, u in where:
1300
+ off = max(off, _off_arc(_unit_quaternion(values[k - 1]), _unit_quaternion(values[k]), [(u, posed[at][bone][path])]))
1301
+ else:
1302
+ off = max(max(abs(m - (x + (y - x) * u)) for x, y, m in zip(values[k - 1], values[k], posed[at][bone][path]))
1303
+ for at, k, u in where) * (unit if path == "translation" else 1.0)
1304
+ if off <= (SUBFRAME_DEGREES / 25.0 if path == "rotation" else SUBFRAME_METRES / 10.0 if path == "translation"
1305
+ else SUBFRAME_SCALE / 10.0):
1306
+ continue
1307
+ if new_input is None:
1308
+ new_input = append(seconds, "SCALAR", bounds=True)
1309
+ at_key = dict(zip(key_frames, values))
1310
+ dense = [posed[f][bone][path] if f in posed and f not in checks else at_key[f] if f in at_key
1311
+ else interpolate(path, key_frames, values, f) for f in frames]
1312
+ output = append(dense, "VEC4" if path == "rotation" else "VEC3")
1313
+ animation["samplers"].append({"input": new_input, "output": output, "interpolation": "LINEAR"})
1314
+ channel["sampler"] = len(animation["samplers"]) - 1
1315
+ moved += 1
1316
+ if curves_left[0] > result["remaining"][0]:
1317
+ result["remaining"] = (curves_left[0], name, curves_left[1], curves_left[2])
1318
+ if not moved:
1319
+ continue
1320
+ used = sorted({channel["sampler"] for channel in animation["channels"]})
1321
+ index = {old: new for new, old in enumerate(used)}
1322
+ animation["samplers"] = [animation["samplers"][old] for old in used]
1323
+ for channel in animation["channels"]:
1324
+ channel["sampler"] = index[channel["sampler"]]
1325
+ result["actions"][name] = len(frames)
1326
+ result["channels"] += moved
1327
+ finally:
1328
+ scene.frame_set(frame, subframe=subframe)
1329
+ if not result["actions"]:
1330
+ return result
1331
+ binary = _compact(document, binary)
1332
+ document["buffers"][0]["byteLength"] = len(binary) + (4 - len(binary) % 4) % 4
1333
+ _write_glb(out, document, binary)
1334
+ result["bytes"] = os.path.getsize(out) - before_bytes
1335
+ return result
1336
+
1337
+
1338
+ _COMPACT_SAFE = ("KHR_node_visibility", "KHR_lights_punctual", "KHR_texture_transform", "KHR_animation_pointer",
1339
+ "KHR_mesh_quantization") + STD_EXTENSIONS
1340
+
1341
+
1342
+ def _compact(document, binary):
1343
+ """Drop the accessors and buffer views nothing uses any more (the samples a denser channel replaced) and
1344
+ pack the rest, each view's bytes as they were, in their order. Left as it is when the file uses an
1345
+ extension this does not know the references of."""
1346
+ used_extensions = [e for e in document.get("extensionsUsed", []) if not e.startswith("KHR_materials_") and e not in _COMPACT_SAFE]
1347
+ accessors, views = document.get("accessors", []), document.get("bufferViews", [])
1348
+ if used_extensions or any("sparse" in a for a in accessors) or len(document.get("buffers", [])) != 1:
1349
+ return binary
1350
+ refs = [] # (holder, key) pairs holding an accessor index
1351
+ for mesh in document.get("meshes", []):
1352
+ for primitive in mesh.get("primitives", []):
1353
+ attributes = primitive.get("attributes", {})
1354
+ refs.extend((attributes, key) for key in attributes)
1355
+ if "indices" in primitive:
1356
+ refs.append((primitive, "indices"))
1357
+ for target in primitive.get("targets", []):
1358
+ refs.extend((target, key) for key in target)
1359
+ for skin in document.get("skins", []):
1360
+ if "inverseBindMatrices" in skin:
1361
+ refs.append((skin, "inverseBindMatrices"))
1362
+ for animation in document.get("animations", []):
1363
+ for sampler in animation.get("samplers", []):
1364
+ refs.extend([(sampler, "input"), (sampler, "output")])
1365
+ lengths = ((animation.get("extensions") or {}).get("STD_animation_blending") or {}).get("rotationLengths") or {}
1366
+ refs.extend((lengths, key) for key in lengths)
1367
+ keep = sorted({holder[key] for holder, key in refs})
1368
+ remap = {old: new for new, old in enumerate(keep)}
1369
+ for holder, key in refs:
1370
+ holder[key] = remap[holder[key]]
1371
+ document["accessors"] = accessors = [accessors[old] for old in keep]
1372
+ view_refs = [(a, "bufferView") for a in accessors if "bufferView" in a] + \
1373
+ [(image, "bufferView") for image in document.get("images", []) if "bufferView" in image]
1374
+ kept_views = sorted({holder[key] for holder, key in view_refs})
1375
+ view_map = {old: new for new, old in enumerate(kept_views)}
1376
+ packed = bytearray()
1377
+ new_views = []
1378
+ for old in kept_views:
1379
+ view = dict(views[old])
1380
+ start = view.get("byteOffset", 0)
1381
+ packed.extend(b"\0" * ((4 - len(packed) % 4) % 4))
1382
+ chunk = binary[start:start + view["byteLength"]]
1383
+ view["byteOffset"] = len(packed)
1384
+ packed.extend(chunk)
1385
+ new_views.append(view)
1386
+ for holder, key in view_refs:
1387
+ holder[key] = view_map[holder[key]]
1388
+ document["bufferViews"] = new_views
1389
+ document["buffers"][0]["byteLength"] = len(packed) + (4 - len(packed) % 4) % 4
1390
+ return packed
1391
+
1392
+
1393
+ def _subframe_warnings(dense, candidates):
1394
+ """What remains between frames, named (RFC 0007 §5)."""
1395
+ found = list(dense["mismatch"])
1396
+ if dense["actions"]:
1397
+ found.append("between frames: %d actions sampled also off whole frames where a half frame was over %.2f deg "
1398
+ "(%s keys); the largest half-step difference their curves leave is %.2f deg (%s, %s, frame %.2f), in "
1399
+ "that bone's own rotation" % (len(dense["actions"]), SUBFRAME_DEGREES,
1400
+ ", ".join("%s %d" % (name, k) for name, k in sorted(dense["actions"].items())),
1401
+ dense["remaining"][0], dense["remaining"][1], dense["remaining"][2], dense["remaining"][3] or 0.0))
1402
+ under = [(degrees, action, bone, frame) for (_armature, action), (degrees, metres, bone, frame) in candidates.items()
1403
+ if action not in dense["actions"] and degrees > 0.01]
1404
+ if under:
1405
+ worst = max(under)
1406
+ found.append("between frames, the actions sampled at whole frames: the largest half-frame difference of their "
1407
+ "curves is %.2f deg (%s, %s, frame %.1f); only keyed curves are measured, so a bone posed by IK or "
1408
+ "a constraint can differ more" % worst)
1409
+ return found
1410
+
1411
+
1412
+ def _subframe_manifest(dense):
1413
+ return {"limitDegrees": SUBFRAME_DEGREES, "limitMetres": SUBFRAME_METRES, "actions": dense["actions"],
1414
+ "channels": dense["channels"], "bytes": dense["bytes"], "framesEvaluated": dense["evaluated"],
1415
+ "remaining": {"degrees": round(dense["remaining"][0], 4), "action": dense["remaining"][1],
1416
+ "bone": dense["remaining"][2], "frame": dense["remaining"][3]}}
1417
+
1418
+
1419
+ def _driven_shape_keys(objects):
1420
+ """Shape keys a driver sets (a corrective shape following a bone): the glTF exporter bakes them into each
1421
+ action as morph weights, so a layer over another action blends those weights, where Blender drives them
1422
+ again from the blended pose. Named, with the keys."""
1423
+ found = []
1424
+ for obj in objects:
1425
+ keys = obj.data.shape_keys if obj.type == "MESH" and obj.data is not None else None
1426
+ drivers = keys.animation_data.drivers if keys is not None and keys.animation_data is not None else []
1427
+ names = sorted({d.data_path.split('"')[1] for d in drivers if d.data_path.startswith("key_blocks[")})
1428
+ if names:
1429
+ found.append("%s's shape keys %s are driven: each action carries them as morph weights as it evaluates "
1430
+ "them, so a layer over another action blends those weights, not Blender's drivers on the "
1431
+ "blended pose" % (obj.name, ", ".join(names)))
1432
+ return found
1433
+
1434
+
1435
+ def _object_only_armature_actions(objects):
1436
+ """An armature's actions that key no bone: Blender's glTF exporter carries an armature's action only
1437
+ when a bone is keyed (its __is_armature_slot), so the armature object's own moves in them are lost."""
1438
+ found = []
1439
+ for obj in objects:
1440
+ if obj.type != "ARMATURE":
1441
+ continue
1442
+ animation = obj.animation_data
1443
+ used = {animation.action} if animation and animation.action else set()
1444
+ for track in (animation.nla_tracks if animation else []):
1445
+ used.update(strip.action for strip in track.strips if strip.action)
1446
+ lone = []
1447
+ for action in bpy.data.actions:
1448
+ slots = [s for s in action.slots if s.target_id_type == "OBJECT"
1449
+ and (action in used or s.identifier == "OB" + obj.name)]
1450
+ if not slots:
1451
+ continue
1452
+ curves = _action_curves(action)
1453
+ if curves and not any(_BONE_PATH.match(c.data_path) or c.data_path.startswith("pose.bones[") for c in curves):
1454
+ lone.append(action.name)
1455
+ if lone:
1456
+ found.append("%s's actions %s key the armature object and no bone: the glTF exporter carries an armature's "
1457
+ "action only with a bone keyed, so they are not exported (key a bone in them too)"
1458
+ % (obj.name, ", ".join(sorted(lone))))
1459
+ return found
1460
+
1461
+
1462
+ def _action_curves(action):
1463
+ from bpy_extras import anim_utils
1464
+ curves = []
1465
+ for slot in action.slots:
1466
+ bag = anim_utils.action_get_channelbag_for_slot(action, slot)
1467
+ if bag is not None:
1468
+ curves.extend(bag.fcurves)
1469
+ return curves
1470
+
1471
+
1472
+ def _key_rate(scene):
1473
+ """Blender's exporter writes a key at frame f at time f / (fps * fps_base)."""
1474
+ return round(float(scene.render.fps * (scene.render.fps_base or 1.0)), 6)
1475
+
1476
+
1477
+ def _action_slide(action):
1478
+ """The frame an action's clip starts at, when the exporter slid it to zero (its negative frames,
1479
+ SLIDE, the exporter's default); 0 otherwise. A key at action frame f is at clip time
1480
+ (f - slide) / keyRate."""
1481
+ start = float(action.frame_range[0])
1482
+ return start if start < 0 else 0.0
1483
+
1484
+
1485
+ def _action_slides():
1486
+ slides = {}
1487
+ for action in bpy.data.actions:
1488
+ slide = _action_slide(action)
1489
+ if slide != 0.0:
1490
+ slides[action.name] = slide
1491
+ return slides
1492
+
1493
+
1494
+ def _collection_tree(scene, authoring):
1495
+ """Each collection of the scene's hierarchy with its parents (`extras.collections` on the scene)."""
1496
+ tree = {}
1497
+
1498
+ def walk(collection, parent):
1499
+ for child in collection.children:
1500
+ if child.name == _HOLDER or child.name_full in authoring:
1501
+ continue
1502
+ tree.setdefault(child.name, [])
1503
+ if parent is not None and parent not in tree[child.name]:
1504
+ tree[child.name].append(parent)
1505
+ walk(child, child.name)
1506
+
1507
+ walk(scene.collection, None)
1508
+ return tree
1509
+
1510
+
1511
+ def _runtime_names(bone):
1512
+ """The constraints a pose bone's `std.runtime` marks for a script (a name, several separated by commas, or
1513
+ true for all of them), by name."""
1514
+ marked = bone.get("std.runtime")
1515
+ if marked is None or marked is False or marked == 0:
1516
+ return []
1517
+ if isinstance(marked, str):
1518
+ return [n.strip() for n in marked.split(",") if n.strip()]
1519
+ return [c.name for c in bone.constraints]
1520
+
1521
+
1522
+ def _runtime_constraints(objects):
1523
+ """The constraints scripts drive, by armature and bone, and the warnings for those that cannot be."""
1524
+ found, warnings = {}, []
1525
+ for obj in objects:
1526
+ if obj.type != "ARMATURE" or obj.pose is None:
1527
+ continue
1528
+ for bone in obj.pose.bones:
1529
+ for name in _runtime_names(bone):
1530
+ constraint = bone.constraints.get(name)
1531
+ if constraint is None:
1532
+ warnings.append("%s/%s names a runtime constraint %s the bone does not have" % (obj.name, bone.name, name))
1533
+ continue
1534
+ if constraint.type != "DAMPED_TRACK":
1535
+ warnings.append("%s/%s's %s is a %s: only a Damped Track runs at runtime, so it is baked into the clips"
1536
+ % (obj.name, bone.name, name, constraint.type))
1537
+ continue
1538
+ found.setdefault((obj.name, bone.name), []).append(constraint)
1539
+ # Read now, before the export mutes them for sampling: the file's own mute state.
1540
+ data = {key: [_constraint_data(c) for c in constraints] for key, constraints in found.items()}
1541
+ return found, data, warnings
1542
+
1543
+
1544
+ def _constraint_data(constraint):
1545
+ target = constraint.target
1546
+ data = {"name": constraint.name, "type": constraint.type,
1547
+ "target": target.name if target is not None else None, "subtarget": constraint.subtarget or None,
1548
+ "trackAxis": constraint.track_axis, "headTail": round(float(constraint.head_tail), 6),
1549
+ "influence": round(float(constraint.influence), 6), "mute": bool(constraint.mute)}
1550
+ # Head/Tail places the aim point along the TARGET bone, head to tail: its length goes with it.
1551
+ if target is not None and target.type == "ARMATURE" and constraint.subtarget and target.data.bones.get(constraint.subtarget):
1552
+ data["subtargetLength"] = round(float(target.data.bones[constraint.subtarget].length), 6)
1553
+ return data
1554
+
1555
+
1556
+ _BONE_PATH = re.compile(r'^pose\.bones\["((?:[^"\\]|\\.)*)"\]\.(\w+)')
1557
+ _BONE_NAME = re.compile(r'^pose\.bones\["((?:[^"\\]|\\.)*)"\]')
1558
+ _GLTF_PATH = {"location": "translation", "rotation_quaternion": "rotation", "rotation_euler": "rotation",
1559
+ "rotation_axis_angle": "rotation", "scale": "scale"}
1560
+
1561
+
1562
+ def _action_keyed_bones(action):
1563
+ """std's character-layer mask: the authored F-curves' bones, never the baked channels.
1564
+ Read every action slot, and keep constant keys and bone custom-property/constraint curves too."""
1565
+ return sorted({bpy.utils.unescape_identifier(found.group(1))
1566
+ for curve in _action_curves(action)
1567
+ if (found := _BONE_NAME.match(curve.data_path)) is not None})
1568
+
1569
+
1570
+ def _animation_keyed_bones(animation, bones):
1571
+ """Write std's reserved mask while retaining other animation extras and std members."""
1572
+ extras = dict(animation.extras or {})
1573
+ namespace = extras.get("std")
1574
+ namespace = dict(namespace) if isinstance(namespace, dict) else {}
1575
+ namespace["keyedBones"] = sorted(set(bones))
1576
+ extras["std"] = namespace
1577
+ animation.extras = extras
1578
+
1579
+
1580
+ def _keyed_bone_channels(action):
1581
+ """The (bone, glTF path) pairs an action keys."""
1582
+ keyed = set()
1583
+ for curve in _action_curves(action):
1584
+ found = _BONE_PATH.match(curve.data_path)
1585
+ if found and found.group(2) in _GLTF_PATH:
1586
+ keyed.add((found.group(1).replace('\\"', '"'), _GLTF_PATH[found.group(2)]))
1587
+ return keyed
1588
+
1589
+
1590
+ def _rest_channels(armature):
1591
+ """Each bone's rest local transform as the exporter writes a joint: translation, rotation
1592
+ (x, y, z, w) and scale, by bone name."""
1593
+ axis = _Y_UP
1594
+ rest = {}
1595
+ for bone in armature.data.bones:
1596
+ local = (bone.parent.matrix_local.inverted_safe() @ bone.matrix_local) if bone.parent is not None else axis @ bone.matrix_local
1597
+ t, r, s = local.decompose()
1598
+ rest[bone.name] = {"translation": (t.x, t.y, t.z), "rotation": (r.x, r.y, r.z, r.w), "scale": (s.x, s.y, s.z)}
1599
+ return rest
1600
+
1601
+
1602
+ def _sampled_values(channel):
1603
+ accessor = channel.sampler.output
1604
+ data = getattr(accessor.buffer_view, "data", None)
1605
+ if data is None:
1606
+ return None
1607
+ return struct.unpack("<%df" % (len(data) // 4), bytes(data))
1608
+
1609
+
1610
+ def _leaves_rest(path, values, rest):
1611
+ if path == "rotation":
1612
+ for k in range(0, len(values) - 3, 4):
1613
+ dot = abs(values[k] * rest[0] + values[k + 1] * rest[1] + values[k + 2] * rest[2] + values[k + 3] * rest[3])
1614
+ # A turn of more than about 0.1 degrees (float32 keys carry about 1e-7 of noise).
1615
+ if dot < 1.0 - 5e-7:
1616
+ return True
1617
+ return False
1618
+ width = len(rest)
1619
+ for k in range(0, len(values) - width + 1, width):
1620
+ if any(abs(values[k + i] - rest[i]) > 1e-5 for i in range(width)):
1621
+ return True
1622
+ return False
1623
+
1624
+
1625
+ def _glb_parts(path):
1626
+ with open(path, "rb") as handle:
1627
+ data = bytearray(handle.read())
1628
+ length = int.from_bytes(data[12:16], "little")
1629
+ document = json.loads(bytes(data[20:20 + length]).decode("utf-8"))
1630
+ binary_length = int.from_bytes(data[20 + length:24 + length], "little")
1631
+ binary = bytearray(data[28 + length:28 + length + binary_length])
1632
+ return data, length, document, binary
1633
+
1634
+
1635
+ def _write_glb(path, document, binary):
1636
+ text = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
1637
+ text += b" " * ((4 - len(text) % 4) % 4)
1638
+ binary = bytes(binary) + b"\0" * ((4 - len(binary) % 4) % 4)
1639
+ total = 12 + 8 + len(text) + 8 + len(binary)
1640
+ with open(path, "wb") as handle:
1641
+ handle.write(b"glTF" + (2).to_bytes(4, "little") + total.to_bytes(4, "little"))
1642
+ handle.write(len(text).to_bytes(4, "little") + b"JSON" + text)
1643
+ handle.write(len(binary).to_bytes(4, "little") + b"BIN\0" + binary)
1644
+
1645
+
1646
+ def _keys_bone_rotation(action, bone):
1647
+ """Whether the action keys `bone`'s rotation in any mode (quaternion, Euler, axis-angle)."""
1648
+ prefix = 'pose.bones["%s"].rotation_' % bone.replace('"', '\\"')
1649
+ return any(c.data_path.startswith(prefix) for c in _action_curves(action))
1650
+
1651
+
1652
+ def _keyed_quaternion_curves(action, bone):
1653
+ """The action's four rotation_quaternion curves of `bone` (None where one is not keyed), or None."""
1654
+ from bpy_extras import anim_utils
1655
+ bags = [anim_utils.action_get_channelbag_for_slot(action, slot) for slot in action.slots]
1656
+ curves_of = [list(bag.fcurves) for bag in bags if bag is not None]
1657
+ path = 'pose.bones["%s"].rotation_quaternion' % bone.replace('"', '\\"')
1658
+ for curves in curves_of:
1659
+ found = [None, None, None, None]
1660
+ for curve in curves:
1661
+ if curve.data_path == path and 0 <= curve.array_index < 4:
1662
+ found[curve.array_index] = curve
1663
+ if any(c is not None for c in found):
1664
+ return found
1665
+ return None
1666
+
1667
+
1668
+ _SHAPE_KEY_PATH = re.compile(r'^key_blocks\["((?:[^"\\]|\\.)*)"\]\.value$')
1669
+
1670
+
1671
+ def _blending(document, animation):
1672
+ """An animation's `STD_animation_blending` (made, and listed in the file's `extensionsUsed`, when it has
1673
+ none yet)."""
1674
+ used = document.setdefault("extensionsUsed", [])
1675
+ if "STD_animation_blending" not in used:
1676
+ used.append("STD_animation_blending")
1677
+ return animation.setdefault("extensions", {}).setdefault("STD_animation_blending", {})
1678
+
1679
+
1680
+ def _weights_keyed(out):
1681
+ """WHICH MORPH TARGETS AN ACTION KEYS (RFC 0007 §2, `STD_animation_blending.weightsKeyed`). A glTF `weights` channel
1682
+ animates every target of its mesh together, so an action keying one shape key writes the others too (at
1683
+ their value when Blender sampled it). Blender's NLA leaves a shape key the action does not key to the
1684
+ layers below. Where an action keys only some of a mesh's targets, the animation names the ones it keys:
1685
+ `{ "<channel index>": [target index, ...] }`, the indices into the mesh's `extras.targetNames`. Answers how
1686
+ many channels were named."""
1687
+ data, length, document, binary = _glb_parts(out)
1688
+ nodes, meshes = document.get("nodes", []), document.get("meshes", [])
1689
+ named = 0
1690
+ for animation in document.get("animations", []):
1691
+ action = bpy.data.actions.get(animation.get("name", ""))
1692
+ if action is None:
1693
+ continue
1694
+ keyed = set()
1695
+ for curve in _action_curves(action):
1696
+ found = _SHAPE_KEY_PATH.match(curve.data_path)
1697
+ if found:
1698
+ keyed.add(found.group(1).replace('\\"', '"'))
1699
+ if not keyed:
1700
+ continue
1701
+ written = {}
1702
+ for index, channel in enumerate(animation.get("channels", [])):
1703
+ target = channel.get("target", {})
1704
+ if target.get("path") != "weights" or "node" not in target:
1705
+ continue
1706
+ mesh = nodes[target["node"]].get("mesh")
1707
+ names = ((meshes[mesh].get("extras") or {}).get("targetNames") or []) if mesh is not None else []
1708
+ indices = [i for i, name in enumerate(names) if name in keyed]
1709
+ if names and len(indices) < len(names):
1710
+ written[str(index)] = indices
1711
+ if written:
1712
+ _blending(document, animation)["weightsKeyed"] = written
1713
+ named += len(written)
1714
+ if named:
1715
+ _write_glb(out, document, binary)
1716
+ return named
1717
+
1718
+
1719
+ def _unique_channels(out):
1720
+ """One channel per target in each animation, as glTF requires: Blender's exporter gathers an action once
1721
+ for every unmuted NLA strip that plays it and merges them, so two tracks playing one action give its
1722
+ animation every channel twice. The first of each is kept (they are the same samples); samplers no channel
1723
+ uses go. Answers how many channels were taken out."""
1724
+ data, length, document, binary = _glb_parts(out)
1725
+ removed = 0
1726
+ for animation in document.get("animations", []):
1727
+ seen, kept = set(), []
1728
+ for channel in animation.get("channels", []):
1729
+ target = channel.get("target", {})
1730
+ key = (target.get("node"), target.get("path"))
1731
+ if key in seen:
1732
+ removed += 1
1733
+ continue
1734
+ seen.add(key)
1735
+ kept.append(channel)
1736
+ if len(kept) == len(animation.get("channels", [])):
1737
+ continue
1738
+ used = sorted({channel["sampler"] for channel in kept})
1739
+ index = {old: new for new, old in enumerate(used)}
1740
+ animation["samplers"] = [animation["samplers"][old] for old in used]
1741
+ for channel in kept:
1742
+ channel["sampler"] = index[channel["sampler"]]
1743
+ animation["channels"] = kept
1744
+ if removed:
1745
+ _write_glb(out, document, binary)
1746
+ return removed
1747
+
1748
+
1749
+ def _align_rotation_signs(out, objects, scene):
1750
+ """Write every rotation key of each action in the sign the action keys it (the rest rotation times
1751
+ the keyed basis quaternion): Blender blends quaternion components as keyed. A bone the action does not
1752
+ key, which moves only as Blender evaluates it (an IK chain, a bone not inheriting its parent's
1753
+ rotation), has no keyed sign: its keys are written in the sign nearest its rest, so two actions'
1754
+ poses of it blend the short way. A bone keyed in Euler angles or axis-angle is left as written
1755
+ (named by _euler_bones).
1756
+
1757
+ THE KEYED LENGTH: between keys far apart Blender interpolates a quaternion's four components, whose
1758
+ length falls below 1, and its NLA blends those unnormalised components. The channel stays unit
1759
+ quaternions, as glTF requires; where the keyed length departs from 1, its length at every key is
1760
+ written beside it as a float accessor, named in the animation's `STD_animation_blending.rotationLengths`
1761
+ ({channel index: accessor index}), so a reader that layers actions rebuilds `length * q` as Blender
1762
+ blends it, and one that ignores it still reads valid rotations.
1763
+
1764
+ Answers how many keys were turned, how many shared outputs were split, how many channels carry their
1765
+ lengths and the bytes those add."""
1766
+ armatures = [o for o in objects if o.type == "ARMATURE" and o.pose is not None]
1767
+ if not armatures:
1768
+ return {"turned": 0, "split": 0, "lengths": 0, "lengthBytes": 0}
1769
+ data, length, document, binary = _glb_parts(out)
1770
+ rate = _key_rate(scene)
1771
+ nodes = document.get("nodes", [])
1772
+ accessors, views = document.get("accessors", []), document.get("bufferViews", [])
1773
+
1774
+ def floats(index):
1775
+ accessor = accessors[index]
1776
+ view = views[accessor["bufferView"]]
1777
+ start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
1778
+ width = {"SCALAR": 1, "VEC3": 3, "VEC4": 4}[accessor["type"]]
1779
+ return start, list(struct.unpack_from("<%df" % (accessor["count"] * width), binary, start))
1780
+
1781
+ wanted = {} # output accessor -> list of (sampler dict, per-key flips)
1782
+ by_sampler = {}
1783
+ lengthed = [] # (animation, channel index, the keyed length at each key)
1784
+ for animation in document.get("animations", []):
1785
+ action = bpy.data.actions.get(animation.get("name", ""))
1786
+ if action is None:
1787
+ continue
1788
+ for channel_index, channel in enumerate(animation.get("channels", [])):
1789
+ target = channel.get("target", {})
1790
+ if target.get("path") != "rotation" or "node" not in target:
1791
+ continue
1792
+ bone = nodes[target["node"]].get("name", "")
1793
+ owner = next((a for a in armatures if a.pose.bones.get(bone) is not None), None)
1794
+ if owner is None:
1795
+ continue
1796
+ quaternion = owner.pose.bones[bone].rotation_mode == "QUATERNION"
1797
+ curves = _keyed_quaternion_curves(action, bone) if quaternion else None
1798
+ keyed_curves = curves is not None
1799
+ if curves is None:
1800
+ if _keys_bone_rotation(action, bone):
1801
+ continue
1802
+ curves = [None, None, None, None] # evaluated only: the sign nearest rest
1803
+ rest = nodes[target["node"]].get("rotation", [0.0, 0.0, 0.0, 1.0])
1804
+ rest = mathutils.Quaternion((rest[3], rest[0], rest[1], rest[2]))
1805
+ sampler = animation["samplers"][channel["sampler"]]
1806
+ _, times = floats(sampler["input"])
1807
+ _, values = floats(sampler["output"])
1808
+ flips, lengths = [], []
1809
+ slide = _action_slide(action)
1810
+ for k, seconds in enumerate(times):
1811
+ frame = seconds * rate + slide
1812
+ keyed = mathutils.Quaternion([curves[i].evaluate(frame) if curves[i] is not None else (1.0 if i == 0 else 0.0)
1813
+ for i in range(4)])
1814
+ lengths.append(keyed.magnitude)
1815
+ reference = rest @ keyed
1816
+ x, y, z, w = values[4 * k:4 * k + 4]
1817
+ flips.append(w * reference.w + x * reference.x + y * reference.y + z * reference.z < 0.0)
1818
+ if keyed_curves and max(abs(value - 1.0) for value in lengths) > 1e-4:
1819
+ lengthed.append((animation, channel_index, lengths))
1820
+ # One sampler serving two channels that need different signs cannot be written for both.
1821
+ known = by_sampler.get(id(sampler))
1822
+ if known is not None and known != flips:
1823
+ raise RuntimeError("the exporter shares one sampler between rotation channels that need different signs "
1824
+ "(%s, bone %s): the keyed signs cannot be kept" % (animation.get("name"), bone))
1825
+ if known is None:
1826
+ by_sampler[id(sampler)] = flips
1827
+ wanted.setdefault(sampler["output"], []).append((sampler, flips))
1828
+ turned = split = 0
1829
+ for output, uses in wanted.items():
1830
+ start, values = floats(output)
1831
+ # The first use's signs are written in place; a use that needs other signs gets its own copy.
1832
+ groups = {}
1833
+ for sampler, flips in uses:
1834
+ groups.setdefault(tuple(flips), []).append(sampler)
1835
+ for index, (flips, samplers) in enumerate(sorted(groups.items(), key=lambda item: -len(item[1]))):
1836
+ turned_values = list(values)
1837
+ for k, flip in enumerate(flips):
1838
+ if flip:
1839
+ for i in range(4):
1840
+ turned_values[4 * k + i] = -turned_values[4 * k + i]
1841
+ packed = struct.pack("<%df" % len(turned_values), *turned_values)
1842
+ if index == 0:
1843
+ binary[start:start + len(packed)] = packed
1844
+ else:
1845
+ binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
1846
+ views.append({"buffer": 0, "byteOffset": len(binary), "byteLength": len(packed)})
1847
+ binary.extend(packed)
1848
+ accessors.append(dict(accessors[output], bufferView=len(views) - 1, byteOffset=0))
1849
+ for sampler in samplers:
1850
+ sampler["output"] = len(accessors) - 1
1851
+ split += 1
1852
+ turned += sum(1 for flip in flips if flip) * len(samplers)
1853
+ added = 0
1854
+ # Each animation's lengths in one buffer view, an accessor per channel into it.
1855
+ by_animation = {}
1856
+ for animation, channel_index, lengths in lengthed:
1857
+ by_animation.setdefault(id(animation), (animation, []))[1].append((channel_index, lengths))
1858
+ for animation, entries in by_animation.values():
1859
+ binary.extend(b"\0" * ((4 - len(binary) % 4) % 4))
1860
+ start = len(binary)
1861
+ views.append({"buffer": 0, "byteOffset": start, "byteLength": 0})
1862
+ named = _blending(document, animation).setdefault("rotationLengths", {})
1863
+ for channel_index, lengths in entries:
1864
+ packed = struct.pack("<%df" % len(lengths), *lengths)
1865
+ accessors.append({"bufferView": len(views) - 1, "byteOffset": len(binary) - start, "componentType": 5126,
1866
+ "count": len(lengths), "type": "SCALAR"})
1867
+ binary.extend(packed)
1868
+ named[str(channel_index)] = len(accessors) - 1
1869
+ added += len(packed)
1870
+ views[-1]["byteLength"] = len(binary) - start
1871
+ if turned == 0 and not lengthed:
1872
+ return {"turned": 0, "split": 0, "lengths": 0, "lengthBytes": 0}
1873
+ if split == 0 and not lengthed:
1874
+ # Only key signs changed: the file keeps its JSON, its new binary is written in place.
1875
+ data[28 + length:28 + length + len(binary)] = binary
1876
+ with open(out, "wb") as handle:
1877
+ handle.write(bytes(data))
1878
+ else:
1879
+ document["buffers"][0]["byteLength"] = len(binary) + (4 - len(binary) % 4) % 4
1880
+ _write_glb(out, document, binary)
1881
+ return {"turned": turned, "split": split, "lengths": len(lengthed), "lengthBytes": added}
1882
+
1883
+
1884
+ def _pose_channels(objects):
1885
+ """Every pose bone's channels of the armatures among `objects`, exactly as the document stores them: its
1886
+ rotation mode, location, quaternion, Euler angles, axis-angle and scale, each as written (an Euler past 180
1887
+ degrees, a quaternion with a negative w or off unit length, a negative scale), for _put_back."""
1888
+ return [(bone, bone.rotation_mode, tuple(bone.location), tuple(bone.rotation_quaternion), tuple(bone.rotation_euler),
1889
+ tuple(bone.rotation_axis_angle), tuple(bone.scale))
1890
+ for obj in objects if obj.type == "ARMATURE" and obj.pose is not None for bone in obj.pose.bones]
1891
+
1892
+
1893
+ def _put_back(held):
1894
+ """Write each bone's channels back bit for bit: the mode first (changing it converts the rotation), then
1895
+ every channel as it was. Never through matrix_basis, which wraps angles, flips and normalises quaternions
1896
+ and folds a negative scale into the rotation."""
1897
+ for bone, mode, location, quaternion, euler, axis_angle, scale in held:
1898
+ if bone.rotation_mode != mode:
1899
+ bone.rotation_mode = mode
1900
+ bone.location, bone.rotation_quaternion, bone.rotation_euler = location, quaternion, euler
1901
+ bone.rotation_axis_angle, bone.scale = axis_angle, scale
1902
+
1903
+
1904
+ def _clear_pose(objects):
1905
+ """Each pose bone at its rest, in whatever rotation mode it has: what clearing its basis gives, written
1906
+ channel by channel."""
1907
+ for obj in objects:
1908
+ if obj.type == "ARMATURE" and obj.pose is not None:
1909
+ for bone in obj.pose.bones:
1910
+ bone.location, bone.scale = (0.0, 0.0, 0.0), (1.0, 1.0, 1.0)
1911
+ bone.rotation_quaternion, bone.rotation_euler = (1.0, 0.0, 0.0, 0.0), (0.0, 0.0, 0.0)
1912
+ bone.rotation_axis_angle = (0.0, 0.0, 1.0, 0.0)
1913
+
1914
+
1915
+ def _at_rest(objects, run):
1916
+ """Run with every pose bone of the armatures among `objects` at its rest, then put their channels back
1917
+ exactly. Blender's exporter clears the pose when it switches to an action, but not for the action already
1918
+ playing: without this, a bone that action does not key would carry whatever pose the file was saved with
1919
+ (RFC 0007 §1: each action is sampled from the rest pose)."""
1920
+ held = _pose_channels(objects)
1921
+ try:
1922
+ _clear_pose(objects)
1923
+ return run()
1924
+ finally:
1925
+ _put_back(held)
1926
+
1927
+
1928
+ _ANIMATION_FIELDS = ("action", "action_slot", "use_nla", "use_tweak_mode", "action_blend_type", "action_influence",
1929
+ "action_extrapolation", "last_slot_identifier")
1930
+
1931
+
1932
+ def _raw(value):
1933
+ """A stored value as it is, comparable bit for bit (repr keeps -0.0 apart from 0.0)."""
1934
+ try:
1935
+ return tuple(value) if not isinstance(value, str) and hasattr(value, "__len__") else value
1936
+ except TypeError:
1937
+ return value
1938
+
1939
+
1940
+ def _same(a, b):
1941
+ return repr(a) == repr(b)
1942
+
1943
+
1944
+ def _animated_ids(scene):
1945
+ """Every data-block of the scene an action may animate: its objects, their data, shape keys and materials,
1946
+ the scene and its world."""
1947
+ found, seen = [], set()
1948
+
1949
+ def add(idb):
1950
+ if idb is not None and isinstance(idb, bpy.types.ID) and idb.as_pointer() not in seen:
1951
+ seen.add(idb.as_pointer())
1952
+ found.append(idb)
1953
+ add(scene)
1954
+ add(scene.world)
1955
+ for obj in scene.objects:
1956
+ add(obj)
1957
+ data = obj.data
1958
+ add(data)
1959
+ add(getattr(data, "shape_keys", None))
1960
+ for slot in obj.material_slots:
1961
+ add(slot.material)
1962
+ return found
1963
+
1964
+
1965
+ def _paths_by_type():
1966
+ """Every property path any action keys (its F-curves' data paths), by the type of data-block its slot animates."""
1967
+ from bpy_extras import anim_utils
1968
+ found = {}
1969
+ for action in bpy.data.actions:
1970
+ for slot in action.slots:
1971
+ bag = anim_utils.action_get_channelbag_for_slot(action, slot)
1972
+ if bag is not None:
1973
+ found.setdefault(slot.target_id_type, set()).update(c.data_path for c in bag.fcurves)
1974
+ return found
1975
+
1976
+
1977
+ def _owner(idb, path):
1978
+ """The struct a data path ends on and the property's name there, and whether it is a custom property."""
1979
+ if path.endswith('"]'):
1980
+ head, _, key = path.rpartition('["')
1981
+ return (idb.path_resolve(head) if head else idb), key[:-2], True
1982
+ head, _, name = path.rpartition(".")
1983
+ return (idb.path_resolve(head) if head else idb), name, False
1984
+
1985
+
1986
+ def _keyed_values(scene):
1987
+ """The raw value of every property an action keys on the scene's data-blocks, the active action's or not:
1988
+ switching actions writes what each evaluates, and nothing else writes them back."""
1989
+ held = []
1990
+ paths = _paths_by_type()
1991
+ for idb in _animated_ids(scene):
1992
+ for path in paths.get(idb.id_type, ()):
1993
+ try:
1994
+ owner, name, custom = _owner(idb, path)
1995
+ value = owner[name] if custom else getattr(owner, name)
1996
+ except (ValueError, KeyError, AttributeError, TypeError):
1997
+ continue
1998
+ if isinstance(value, (bpy.types.ID, bpy.types.bpy_struct)):
1999
+ continue
2000
+ held.append((owner, name, custom, _raw(value)))
2001
+ return held
2002
+
2003
+
2004
+ def _put_back_values(held):
2005
+ for owner, name, custom, value in held:
2006
+ try:
2007
+ now = _raw(owner[name] if custom else getattr(owner, name))
2008
+ if _same(now, value):
2009
+ continue
2010
+ if custom:
2011
+ owner[name] = value
2012
+ else:
2013
+ setattr(owner, name, value)
2014
+ except (ValueError, KeyError, AttributeError, TypeError):
2015
+ continue
2016
+
2017
+
2018
+ def _kept_document(scene, run):
2019
+ """AN EXPORT NEVER CHANGES THE DOCUMENT. Run, then put back everything an export (Blender's exporter or this
2020
+ script's passes) may touch, exactly as it was, raw:
2021
+ - every object's transform channels (its rotation mode, location, quaternion, Euler, axis-angle, scale and
2022
+ their deltas), never through a matrix;
2023
+ - every armature's pose channels;
2024
+ - every shape key's value;
2025
+ - every property any action keys, the active action's or another's;
2026
+ - the animation data of every data-block that has it (objects, their data, shape keys, materials, the
2027
+ scene, the world): its action and slot, the NLA's flags, each track's mute and solo;
2028
+ - every constraint's mute, and the scene's frame.
2029
+ Only what differs is written back, and the scene is evaluated again at its own frame."""
2030
+ objects = list(scene.objects)
2031
+ pose = _pose_channels(objects)
2032
+ transforms = [(obj, obj.rotation_mode, [(name, _raw(getattr(obj, name))) for name in _OBJECT_CHANNELS]) for obj in objects]
2033
+ shapes = [(block, _raw(block.value)) for idb in _animated_ids(scene) if isinstance(idb, bpy.types.Key)
2034
+ for block in idb.key_blocks]
2035
+ keyed = _keyed_values(scene)
2036
+ animation = []
2037
+ for idb in _animated_ids(scene):
2038
+ data = getattr(idb, "animation_data", None)
2039
+ if data is None:
2040
+ continue
2041
+ fields = {name: getattr(data, name) for name in _ANIMATION_FIELDS}
2042
+ animation.append((data, fields, [(track, track.mute, track.is_solo) for track in data.nla_tracks]))
2043
+ mutes = [(c, c.mute) for obj in objects for c in obj.constraints]
2044
+ mutes += [(c, c.mute) for obj in objects if obj.type == "ARMATURE" and obj.pose is not None
2045
+ for bone in obj.pose.bones for c in bone.constraints]
2046
+ frame, subframe = scene.frame_current, scene.frame_subframe
2047
+ try:
2048
+ return run()
2049
+ finally:
2050
+ for constraint, mute in mutes:
2051
+ if constraint.mute != mute:
2052
+ constraint.mute = mute
2053
+ for data, fields, tracks in animation:
2054
+ tweak = fields.pop("use_tweak_mode")
2055
+ if data.use_tweak_mode and not tweak:
2056
+ data.use_tweak_mode = False
2057
+ for name in ("action", "action_slot", "last_slot_identifier", "use_nla", "action_blend_type",
2058
+ "action_influence", "action_extrapolation"):
2059
+ if getattr(data, name) != fields[name] and not data.is_property_readonly(name):
2060
+ try:
2061
+ setattr(data, name, fields[name])
2062
+ except (AttributeError, TypeError, RuntimeError): # (a slot the restored action does not hold)
2063
+ pass
2064
+ for track, mute, solo in tracks:
2065
+ if track.mute != mute:
2066
+ track.mute = mute
2067
+ if track.is_solo != solo:
2068
+ track.is_solo = solo
2069
+ if tweak and not data.use_tweak_mode:
2070
+ try:
2071
+ data.use_tweak_mode = True
2072
+ except Exception: # noqa: BLE001 (no strip to tweak any more)
2073
+ pass
2074
+ _put_back(pose)
2075
+ for obj, mode, channels in transforms:
2076
+ if obj.rotation_mode != mode:
2077
+ obj.rotation_mode = mode
2078
+ for name, value in channels:
2079
+ if not _same(_raw(getattr(obj, name)), value):
2080
+ setattr(obj, name, value)
2081
+ for block, value in shapes:
2082
+ if not _same(_raw(block.value), value):
2083
+ block.value = value
2084
+ _put_back_values(keyed)
2085
+ # Evaluated again at its own frame, so the poses the document holds (and saves) are its own again.
2086
+ scene.frame_set(frame, subframe=subframe)
2087
+
2088
+
2089
+ def _muted(runtime, run):
2090
+ """Run with the runtime constraints muted, so the sampled clips do not bake them."""
2091
+ held = [(c, c.mute) for constraints in runtime.values() for c in constraints]
2092
+ try:
2093
+ for constraint, _ in held:
2094
+ constraint.mute = True
2095
+ return run()
2096
+ finally:
2097
+ for constraint, was in held:
2098
+ constraint.mute = was
2099
+
2100
+
2101
+ def _display_key(scene):
2102
+ """What the display LUT depends on: the view transform, look, display device and curve, and the LUT's
2103
+ own format. Two scenes with the same key share one file."""
2104
+ view, display = scene.view_settings, scene.display_settings
2105
+ curve = []
2106
+ if view.use_curve_mapping:
2107
+ mapping = view.curve_mapping
2108
+ curve = [[round(p.location[0], 6), round(p.location[1], 6)] for c in mapping.curves for p in c.points]
2109
+ ocio = getattr(bpy.app, "ocio", None)
2110
+ basis = json.dumps({"format": LUT_FORMAT, "size": LUT_SIZE, "domain": LUT_DOMAIN, "view": view.view_transform,
2111
+ "look": view.look, "device": display.display_device, "curve": curve,
2112
+ "blender": bpy.app.version_string, "ocio": getattr(ocio, "version_string", None)}, sort_keys=True)
2113
+ return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
2114
+
2115
+
2116
+ def _display_dir(out):
2117
+ """`.volter/export/display`, beside the kinds' folders."""
2118
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(out))), "display")
2119
+
2120
+
2121
+ def _display_description(key):
2122
+ lo, hi = LUT_DOMAIN
2123
+ return {"file": "../display/%s.cube" % key, "key": key, "size": LUT_SIZE,
2124
+ "shaper": {"type": "log2-offset", "min": lo, "max": hi},
2125
+ "input": "scene-linear Rec.709", "output": "display-encoded"}
2126
+
2127
+
2128
+ def _display_lut_for(scene, out, luts):
2129
+ """The display LUT for this scene's view settings: baked into `.volter/export/display/<key>.cube`
2130
+ only when no file for them exists (`luts`, the keys the caller holds intact, each with its recorded
2131
+ SHA-256, when it holds the project's
2132
+ files; else the folder beside `out`). Answers the baked file (None when it stood) and the
2133
+ description."""
2134
+ key = _display_key(scene)
2135
+ cube = os.path.join(_display_dir(out), key + ".cube")
2136
+ exists = (key in luts) if luts is not None else os.path.isfile(cube)
2137
+ if exists:
2138
+ return None, _display_description(key)
2139
+ _display_lut(scene, cube)
2140
+ return cube, _display_description(key)
2141
+
2142
+
2143
+ def _display_lut(scene, cube):
2144
+ """Bake the scene's display transform into a 3D LUT at `cube`.
2145
+
2146
+ The lattice is LUT_SIZE^3 scene-linear Rec.709 colours, 2^(lo + t (hi - lo)) - 2^lo per channel
2147
+ (0 at t = 0), red fastest (the Cube format's order). Blender draws them through the scene's view transform, look and
2148
+ display device with `save_render`, at exposure 0 and gamma 1 (carried as numbers, applied by a
2149
+ host), into a 16-bit PNG read back as raw values. Pure Python, so the editor's Blender needs no
2150
+ numpy. The scene is left as it was."""
2151
+ import array
2152
+ import tempfile
2153
+
2154
+ n, (lo, hi) = LUT_SIZE, LUT_DOMAIN
2155
+ axis = [2.0 ** (lo + (hi - lo) * i / (n - 1)) - 2.0 ** lo for i in range(n)]
2156
+ width, height = n * n, n
2157
+ pixels = array.array("f", bytes(4 * 4 * width * height))
2158
+ at = 0
2159
+ for b in range(n):
2160
+ for g in range(n):
2161
+ for r in range(n):
2162
+ pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3] = axis[r], axis[g], axis[b], 1.0
2163
+ at += 4
2164
+ render, view = scene.render, scene.view_settings
2165
+ settings = render.image_settings
2166
+ kept = {"format": settings.file_format, "mode": settings.color_mode, "depth": settings.color_depth,
2167
+ "management": settings.color_management, "dither": render.dither_intensity,
2168
+ "exposure": view.exposure, "gamma": view.gamma}
2169
+ lattice = written = None
2170
+ path = os.path.join(tempfile.gettempdir(), "volter-display-lut-%d.png" % os.getpid())
2171
+ try:
2172
+ settings.file_format, settings.color_mode, settings.color_depth = "PNG", "RGB", "16"
2173
+ settings.color_management = "FOLLOW_SCENE"
2174
+ render.dither_intensity = 0.0
2175
+ view.exposure, view.gamma = 0.0, 1.0
2176
+ lattice = bpy.data.images.new("__volter_display_lut", width, height, float_buffer=True, alpha=False)
2177
+ lattice.colorspace_settings.name = "Linear Rec.709"
2178
+ lattice.pixels.foreach_set(pixels)
2179
+ lattice.save_render(path, scene=scene)
2180
+ written = bpy.data.images.load(path)
2181
+ written.colorspace_settings.name = "Non-Color"
2182
+ shown = array.array("f", bytes(4 * len(written.pixels)))
2183
+ written.pixels.foreach_get(shown)
2184
+ finally:
2185
+ settings.file_format, settings.color_mode, settings.color_depth = kept["format"], kept["mode"], kept["depth"]
2186
+ settings.color_management = kept["management"]
2187
+ render.dither_intensity = kept["dither"]
2188
+ view.exposure, view.gamma = kept["exposure"], kept["gamma"]
2189
+ for image in (lattice, written):
2190
+ if image is not None:
2191
+ bpy.data.images.remove(image)
2192
+ if os.path.exists(path):
2193
+ os.remove(path)
2194
+ channels = len(shown) // (width * height)
2195
+ lines = ["TITLE \"%s %s on %s\"" % (view.view_transform, view.look, scene.display_settings.display_device),
2196
+ "# Input: scene-linear Rec.709 encoded as (log2(x + 2^%g) - %g) / %g per channel; output: display values." % (lo, lo, hi - lo),
2197
+ "LUT_3D_SIZE %d" % n, "DOMAIN_MIN 0 0 0", "DOMAIN_MAX 1 1 1"]
2198
+ for index in range(width * height):
2199
+ base = index * channels
2200
+ lines.append("%.6f %.6f %.6f" % (shown[base], shown[base + 1], shown[base + 2]))
2201
+ os.makedirs(os.path.dirname(cube), exist_ok=True)
2202
+ with open(cube, "w", encoding="utf-8", newline="\n") as handle:
2203
+ handle.write("\n".join(lines) + "\n")
2204
+
2205
+
2206
+ def _authoring(obj):
2207
+ return any(c.name in AUTHORING for c in obj.users_collection)
2208
+
2209
+
2210
+ def _authoring_collections(scene):
2211
+ found = set()
2212
+
2213
+ def walk(collection, inside):
2214
+ here = inside or collection.name in AUTHORING
2215
+ if here:
2216
+ found.add(collection.name_full)
2217
+ for child in collection.children:
2218
+ walk(child, here)
2219
+
2220
+ walk(scene.collection, False)
2221
+ return found
2222
+
2223
+
2224
+ def _fcurves(idblock):
2225
+ data = getattr(idblock, "animation_data", None)
2226
+ if data is None or data.action is None:
2227
+ return []
2228
+ action = data.action
2229
+ from bpy_extras import anim_utils
2230
+ bag = anim_utils.action_get_channelbag_for_slot(action, data.action_slot)
2231
+ return list(bag.fcurves) if bag is not None else []
2232
+
2233
+
2234
+ def _manifest_path(out):
2235
+ return os.path.splitext(out)[0] + ".json"
2236
+
2237
+
2238
+ def _current(out, blend_sha, version, settings, previous):
2239
+ """The earlier export's manifest when it still stands: the same `.blend`, exporter and settings,
2240
+ and its file intact. `previous` is that manifest and the file's SHA-256 as a caller holding the
2241
+ files reads them (the editor, whose project files are not in Blender's filesystem); without it,
2242
+ both are read beside `out`."""
2243
+ if previous is not None:
2244
+ manifest, file_sha = previous.get("manifest"), previous.get("sha256")
2245
+ if not isinstance(manifest, dict):
2246
+ return None
2247
+ else:
2248
+ path = _manifest_path(out)
2249
+ if not (os.path.isfile(path) and os.path.isfile(out)):
2250
+ return None
2251
+ try:
2252
+ with open(path, "r", encoding="utf-8") as handle:
2253
+ manifest = json.load(handle)
2254
+ except (OSError, ValueError):
2255
+ return None
2256
+ file_sha = _sha256(out)
2257
+ if (manifest.get("format") != FORMAT or manifest.get("blendSha256") != blend_sha
2258
+ or manifest.get("exporter") != version or manifest.get("settings") != settings):
2259
+ return None
2260
+ if manifest.get("sha256") != file_sha:
2261
+ return None
2262
+ return manifest
2263
+
2264
+
2265
+ def _lut_sha(out, key, luts):
2266
+ """The display LUT's SHA-256: of its file beside the export, or, when the caller holds the project's files
2267
+ (the editor), as the caller recorded it (`luts`: {key: SHA-256})."""
2268
+ path = os.path.join(_display_dir(out), key + ".cube")
2269
+ if os.path.isfile(path):
2270
+ return _sha256(path)
2271
+ return luts.get(key) if isinstance(luts, dict) else None
2272
+
2273
+
2274
+ def _lut_missing(manifest, out, luts):
2275
+ """The display LUT a current export names, when its file is gone (it is baked again alone)."""
2276
+ key = (manifest.get("display") or {}).get("lut")
2277
+ if not key:
2278
+ return None
2279
+ if luts is not None:
2280
+ return None if key in luts else key
2281
+ path = os.path.join(_display_dir(out), key + ".cube")
2282
+ recorded = (manifest.get("display") or {}).get("sha256")
2283
+ # Gone, or not the file this export wrote (damaged): baked again.
2284
+ if not os.path.isfile(path) or (recorded and _sha256(path) != recorded):
2285
+ return key
2286
+ return None
2287
+
2288
+
2289
+ def _color_inputs(node):
2290
+ """A colour Mix factor and its two colour inputs for ShaderNodeMix and ShaderNodeMixRGB."""
2291
+ if node.bl_idname == "ShaderNodeMix" and node.data_type == "RGBA":
2292
+ colours = [i for i in node.inputs if i.type == "RGBA" and i.enabled]
2293
+ factor = next((i for i in node.inputs if i.name == "Factor" and i.enabled), None)
2294
+ return (factor, colours[0], colours[1]) if len(colours) == 2 else None
2295
+ if node.bl_idname == "ShaderNodeMixRGB":
2296
+ return node.inputs["Fac"], node.inputs["Color1"], node.inputs["Color2"]
2297
+ return None
2298
+
2299
+
2300
+ def _multiply(node):
2301
+ return _color_inputs(node) is not None and node.blend_type == "MULTIPLY"
2302
+
2303
+
2304
+ def _from(socket):
2305
+ return socket.links[0].from_node if socket.is_linked else None
2306
+
2307
+
2308
+ def _occluded_base(material):
2309
+ """Recover Base Color multiplied by a Color Attribute through a Mix node when the glTF exporter omits that factor. Return the colour or texture, attribute name and multiply strength."""
2310
+ # (the exporter also hands this hook node trees that are no Material, e.g. Blender 5's InlineShaderNodes)
2311
+ tree = getattr(material, "node_tree", None)
2312
+ if tree is None:
2313
+ return None
2314
+ bsdf = next((n for n in tree.nodes if n.type == "BSDF_PRINCIPLED"), None)
2315
+ mix = _from(bsdf.inputs["Base Color"]) if bsdf is not None else None
2316
+ if mix is None or not _multiply(mix):
2317
+ return None
2318
+ strength, first, second = _color_inputs(mix)
2319
+ for colour, other in ((first, second), (second, first)):
2320
+ attribute = _from(colour)
2321
+ if attribute is None or attribute.bl_idname not in ("ShaderNodeVertexColor", "ShaderNodeAttribute"):
2322
+ continue
2323
+ name = attribute.layer_name if attribute.bl_idname == "ShaderNodeVertexColor" else attribute.attribute_name
2324
+ level = float(strength.default_value) if strength is not None and not strength.is_linked else 1.0
2325
+ source = _from(other)
2326
+ if source is None:
2327
+ return list(other.default_value), name, level
2328
+ if source.bl_idname == "ShaderNodeTexImage":
2329
+ return None, name, level
2330
+ if _multiply(source):
2331
+ factor, a, b = _color_inputs(source)
2332
+ if factor is not None and not factor.is_linked and abs(float(factor.default_value) - 1.0) < 1e-6:
2333
+ for texture, constant in ((a, b), (b, a)):
2334
+ if (_from(texture) is not None and _from(texture).bl_idname == "ShaderNodeTexImage"
2335
+ and not constant.is_linked):
2336
+ return list(constant.default_value), name, level
2337
+ return None
2338
+ return None
2339
+
2340
+
2341
+ class _Hooks:
2342
+ """The exporter's own extension hooks, for one call."""
2343
+ is_critical = True
2344
+
2345
+ def __init__(self, nodes, scene_extras, visibility, collections=None, runtime=None, scene_extensions=None, volumes=None):
2346
+ self.nodes = nodes # Blender object name -> {"name": ..., "extras": {...}, "tracks": {...}}
2347
+ self.scene_extras = scene_extras # what the scene's extras gain (a level's timeline, its key rate)
2348
+ self.scene_extensions = scene_extensions or {} # the scene's std extensions (display, environment, render)
2349
+ self.volumes = volumes or {} # Blender object name -> its STD_volume (a bounded volume's domain)
2350
+ self.visibility = visibility # Blender object name -> render-visible
2351
+ self.collections = collections or {} # Blender object name -> the collections holding it
2352
+ self.runtime = runtime or {} # (armature, bone) -> its runtime constraints' data
2353
+ self.extension = _extension_class()
2354
+ self.used_visibility = 0
2355
+ self.kept_moved = {} # action -> bones kept because they leave rest without a key
2356
+ self.occluded = {} # material name -> its base colour restored from behind a Color Attribute multiply
2357
+ self.slide_mismatch = []
2358
+ self._rests = {}
2359
+ self._scene_bones = {}
2360
+
2361
+ def _extend(self, owner, name, data):
2362
+ """`data` as the owner's std extension `name` (listed in `extensionsUsed`, never required)."""
2363
+ if owner.extensions is None:
2364
+ owner.extensions = {}
2365
+ owner.extensions[name] = self.extension(name=name, extension=data, required=False)
2366
+
2367
+ def animation_action_hook(self, gltf2_animation, blender_object, action_data, export_settings):
2368
+ # Use the actual action before the add-on merges/renames animations. This also runs for
2369
+ # placement-action sidecars, whose export_extras=False suppresses user properties only.
2370
+ _animation_keyed_bones(gltf2_animation, _action_keyed_bones(action_data.action))
2371
+
2372
+ def gather_gltf_hook(self, active_scene, scenes, animations, export_settings):
2373
+ # SCENE mode has no single action and bypasses animation_action_hook. Its one animation
2374
+ # carries the union of the exported rigs' authored actions, not every baked joint.
2375
+ scene_mode = export_settings.get("gltf_animation_mode") == "SCENE"
2376
+ for animation in animations:
2377
+ namespace = (animation.extras or {}).get("std")
2378
+ if scene_mode or not isinstance(namespace, dict) or "keyedBones" not in namespace:
2379
+ _animation_keyed_bones(animation, self._scene_bones.get(animation.name, ()))
2380
+
2381
+ def gather_node_hook(self, gltf2_node, blender_object, export_settings):
2382
+ if blender_object is None:
2383
+ return
2384
+ name = blender_object.name
2385
+ entry = self.nodes.get(name)
2386
+ if entry is not None:
2387
+ gltf2_node.name = entry["name"]
2388
+ extras = dict(gltf2_node.extras or {})
2389
+ extras.update(entry["extras"])
2390
+ gltf2_node.extras = extras
2391
+ if entry.get("tracks"):
2392
+ self._extend(gltf2_node, "STD_animation_tracks", entry["tracks"])
2393
+ held = self.collections.get(name)
2394
+ if held and entry is None:
2395
+ extras = dict(gltf2_node.extras or {})
2396
+ extras["collections"] = held
2397
+ gltf2_node.extras = extras
2398
+ if getattr(blender_object, "type", None) == "ARMATURE" and entry is None:
2399
+ # ITS CLIP AND ITS NLA (`STD_animation_tracks`): the action the file has the armature play (so a
2400
+ # component shows the pose Blender shows), the frame each clip's time 0 is at, its tracks and settings.
2401
+ tracks = _tracks(blender_object)
2402
+ slides = _action_slides()
2403
+ data = dict(_nla_settings(blender_object))
2404
+ if blender_object.animation_data is not None and blender_object.animation_data.action is not None:
2405
+ data["clip"] = blender_object.animation_data.action.name
2406
+ if slides:
2407
+ data["clipOffsets"] = slides
2408
+ if tracks:
2409
+ data["tracks"] = tracks
2410
+ if data:
2411
+ self._extend(gltf2_node, "STD_animation_tracks", data)
2412
+ flags = {key: _pick(blender_object, "visible_" + name) for key, name in RAY_VISIBILITY}
2413
+ if any(value is False for value in flags.values()):
2414
+ # WHAT THE OBJECT IS SEEN BY in Blender's render (its ray visibility): an object kept out of
2415
+ # shadows (scattered pebbles, flora) must not cast one in a host either.
2416
+ self._extend(gltf2_node, "STD_ray_visibility", {key: value for key, value in flags.items() if value is not None})
2417
+ if name in self.volumes:
2418
+ self._extend(gltf2_node, "STD_volume", self.volumes[name])
2419
+ if getattr(blender_object, "type", None) == "LIGHT":
2420
+ # WHAT KHR_lights_punctual CAN'T SAY of the light: its units, its shadow and softness, and an AREA
2421
+ # light, which glTF has no light for at all (rule G: a host that can draw it gets it).
2422
+ self._extend(gltf2_node, "STD_light", _light(blender_object.data))
2423
+ if self.visibility.get(name, True) is False:
2424
+ if gltf2_node.extensions is None:
2425
+ gltf2_node.extensions = {}
2426
+ gltf2_node.extensions["KHR_node_visibility"] = self.extension(
2427
+ name="KHR_node_visibility", extension={"visible": False}, required=False)
2428
+ self.used_visibility += 1
2429
+
2430
+ def animation_channels_armature_sampled(self, channels, blender_object, blender_action, slot_identifier, cache_key,
2431
+ export_settings):
2432
+ # AN ACTION CARRIES WHAT IT MOVES: a bone property stays when the action keys it, or when what
2433
+ # Blender evaluated with this action alone (constraints live) leaves the rest pose; channels on
2434
+ # the armature node itself and morph weights always stay.
2435
+ if blender_action is None or blender_object is None or blender_object.type != "ARMATURE":
2436
+ return
2437
+ keyed = _keyed_bone_channels(blender_action)
2438
+ rest = self._rests.get(blender_object.name)
2439
+ if rest is None:
2440
+ rest = self._rests[blender_object.name] = _rest_channels(blender_object)
2441
+ bones = set(rest)
2442
+ kept = []
2443
+ for channel in channels:
2444
+ target = channel.target
2445
+ node = target.node if target is not None else None
2446
+ name = node.name if node is not None else None
2447
+ if name not in bones or target.path not in ("translation", "rotation", "scale"):
2448
+ kept.append(channel)
2449
+ continue
2450
+ if (name, target.path) in keyed:
2451
+ kept.append(channel)
2452
+ continue
2453
+ values = _sampled_values(channel)
2454
+ if values is not None and _leaves_rest(target.path, values, rest[name][target.path]):
2455
+ kept.append(channel)
2456
+ self.kept_moved.setdefault(blender_action.name, set()).add(name)
2457
+ channels[:] = kept
2458
+ # The mapping from clip time to action frame the sign pass and the sampler use: the exporter's.
2459
+ vtree = export_settings.get("vtree")
2460
+ uuid = next((u for u, n in vtree.nodes.items() if getattr(n, "blender_object", None) == blender_object), None) if vtree else None
2461
+ exported = export_settings.get("slide", {}).get(uuid, {}).get(blender_action.name, 0.0) if uuid is not None else 0.0
2462
+ if abs(float(exported) - _action_slide(blender_action)) > 1e-6:
2463
+ self.slide_mismatch.append("%s: the exporter slid it by %s, the artifact says %s" % (blender_action.name, exported, _action_slide(blender_action)))
2464
+
2465
+ def gather_joint_hook(self, gltf2_node, blender_bone, export_settings):
2466
+ constraints = self.runtime.get((blender_bone.id_data.name, blender_bone.name))
2467
+ if constraints:
2468
+ extras = dict(gltf2_node.extras or {})
2469
+ extras["constraints"] = constraints
2470
+ gltf2_node.extras = extras
2471
+
2472
+ def gather_material_hook(self, gltf2_material, blender_material, export_settings):
2473
+ # WHICH FACES CAST SHADOWS (RFC 0007 §2, `STD_material_shadow`): both, unless the material culls its back
2474
+ # faces for shadows; then only the front.
2475
+ if isinstance(blender_material, bpy.types.Material) and blender_material.use_backface_culling_shadow:
2476
+ self._extend(gltf2_material, "STD_material_shadow", {"side": "front"})
2477
+ # Restore a base colour behind a Color Attribute multiply (`_occluded_base`) when the
2478
+ # exporter's factor is absent or white, preserving its alpha for blended materials.
2479
+ occluded = _occluded_base(blender_material)
2480
+ pbr = gltf2_material.pbr_metallic_roughness
2481
+ sent = None if pbr is None else pbr.base_color_factor
2482
+ if (occluded is not None and occluded[0] is not None and pbr is not None
2483
+ and (sent is None or [float(v) for v in sent[:3]] == [1.0, 1.0, 1.0])):
2484
+ factor = [float(v) for v in occluded[0]][:3] + [float(sent[3]) if sent is not None and len(sent) > 3 else 1.0]
2485
+ if factor != [float(v) for v in (sent or [1.0, 1.0, 1.0, 1.0])]:
2486
+ pbr.base_color_factor = factor
2487
+ self.occluded[getattr(blender_material, "name", gltf2_material.name)] = {"factor": [round(v, 6) for v in factor],
2488
+ "attribute": occluded[1], "strength": occluded[2]}
2489
+
2490
+ def gather_scene_hook(self, gltf2_scene, blender_scene, export_settings):
2491
+ if export_settings.get("gltf_animation_mode") == "SCENE":
2492
+ # Capture this scene while its filtered tree is current: the final glTF hook runs
2493
+ # after all scenes, when vtree describes only the last one.
2494
+ bones = self._scene_bones.setdefault(blender_scene.name, set())
2495
+ tree = export_settings["vtree"]
2496
+ for uuid in tree.get_all_objects():
2497
+ node = tree.nodes[uuid]
2498
+ obj = node.blender_object
2499
+ # A removed armature object still exports its joints. Filtered-out rigs with
2500
+ # no exported joints remain in the virtual tree too; they contribute no mask.
2501
+ if obj is not None and obj.type == "ARMATURE" and (node.node is not None or node.bones):
2502
+ for action, _strip in _scene_actions(obj):
2503
+ bones.update(_action_keyed_bones(action))
2504
+ for name, data in self.scene_extensions.items():
2505
+ if data is not None:
2506
+ self._extend(gltf2_scene, name, data)
2507
+ if not self.scene_extras:
2508
+ return
2509
+ extras = dict(gltf2_scene.extras or {})
2510
+ extras.update(self.scene_extras)
2511
+ gltf2_scene.extras = extras
2512
+
2513
+
2514
+ def _with_hooks(hooks, run):
2515
+ module = types.ModuleType(_HOOK_MODULE)
2516
+ module.glTF2ExportUserExtension = lambda: hooks
2517
+ sys.modules[_HOOK_MODULE] = module
2518
+ addons = bpy.context.preferences.addons
2519
+ entry = addons.new()
2520
+ entry.module = _HOOK_MODULE
2521
+ try:
2522
+ return run()
2523
+ finally:
2524
+ try:
2525
+ addons.remove(entry)
2526
+ finally:
2527
+ sys.modules.pop(_HOOK_MODULE, None)
2528
+
2529
+
2530
+ def _export_set(scene, view_layer, objects, run):
2531
+ """Export exactly `objects` (and nothing that is not in the view layer), through a temporary
2532
+ collection made the active one, then take the collection away again."""
2533
+ holder = bpy.data.collections.new(_HOLDER)
2534
+ scene.collection.children.link(holder)
2535
+ previous = view_layer.active_layer_collection
2536
+ try:
2537
+ for obj in objects:
2538
+ if holder.objects.get(obj.name) is None:
2539
+ holder.objects.link(obj)
2540
+ layer = view_layer.layer_collection.children.get(holder.name)
2541
+ if layer is None:
2542
+ raise RuntimeError("The export collection did not join the view layer.")
2543
+ view_layer.active_layer_collection = layer
2544
+ return run()
2545
+ finally:
2546
+ try:
2547
+ view_layer.active_layer_collection = previous
2548
+ finally:
2549
+ scene.collection.children.unlink(holder)
2550
+ bpy.data.collections.remove(holder)
2551
+
2552
+
2553
+ class _Steps:
2554
+ """Where an export's time goes, step by step (in its answer and the runner's line, never in the
2555
+ manifest, which carries no time)."""
2556
+
2557
+ def __init__(self):
2558
+ self.ms = {}
2559
+
2560
+ def run(self, name, fn):
2561
+ started = time.perf_counter()
2562
+ try:
2563
+ return fn()
2564
+ finally:
2565
+ self.ms[name] = self.ms.get(name, 0) + round((time.perf_counter() - started) * 1000)
2566
+
2567
+
2568
+ def _gltf(out, settings):
2569
+ os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
2570
+ export = lambda: bpy.ops.export_scene.gltf(filepath=out, use_active_collection=True,
2571
+ use_active_collection_with_nested=False, **settings)
2572
+ if settings.get("export_animation_mode") != "SCENE":
2573
+ result = export()
2574
+ elif tuple(getattr(_gltf_addon(), "bl_info", {}).get("version", ())) in SAMPLING_READ_FROM:
2575
+ result = _sampling_what_moves(export)
2576
+ else:
2577
+ print("gltf_export: the glTF add-on %s is not one whose scene bake _sampling_what_moves was read against (%s): "
2578
+ "every object is baked, as the add-on does" % (getattr(_gltf_addon(), "bl_info", {}).get("version"),
2579
+ ", ".join(".".join(map(str, v)) for v in SAMPLING_READ_FROM)), file=sys.stderr)
2580
+ result = export()
2581
+ if "FINISHED" not in result:
2582
+ raise RuntimeError("Blender's glTF exporter did not finish: %s" % sorted(result))
2583
+
2584
+
2585
+ def _holds_still(obj, seen=None):
2586
+ """Whether an object's transform stays as it is over the timeline, relative to the exported node above it: it,
2587
+ and every object above it, holds still by its own account (`_moves`). A parent's motion would leave a child where
2588
+ it is relative to the parent, which is what a glTF node's transform says, but the add-on's bake of the child
2589
+ carries it (and a curve's path moves its children along it), so a child of anything that moves is baked."""
2590
+ seen = set() if seen is None else seen
2591
+ at = obj
2592
+ while at is not None:
2593
+ if at in seen:
2594
+ # (asked already further up this question: a constraint's target cycle, or a parent asked again)
2595
+ return True
2596
+ seen.add(at)
2597
+ if _moves(at, seen):
2598
+ return False
2599
+ at = at.parent
2600
+ return True
2601
+
2602
+
2603
+ def _animated(block):
2604
+ """Whether an ID is keyed or driven: an action, an NLA strip or a driver on it."""
2605
+ animation = getattr(block, "animation_data", None) if block is not None else None
2606
+ return animation is not None and (animation.action is not None or len(animation.drivers) > 0
2607
+ or any(len(track.strips) > 0 for track in animation.nla_tracks))
2608
+
2609
+
2610
+ def _targets(constraint):
2611
+ """The objects a constraint reads: its target, its pole target, an Armature constraint's targets."""
2612
+ found = []
2613
+ for name in ("target", "pole_target"):
2614
+ target = getattr(constraint, name, None)
2615
+ if isinstance(target, bpy.types.Object):
2616
+ found.append(target)
2617
+ for one in getattr(constraint, "targets", ()) or ():
2618
+ target = getattr(one, "target", None)
2619
+ if isinstance(target, bpy.types.Object):
2620
+ found.append(target)
2621
+ return found
2622
+
2623
+
2624
+ def _moves(obj, seen):
2625
+ """Whether something of the object's own may move it (or its children) over the timeline:
2626
+ - it is keyed or driven (an action, an NLA strip or a driver on it);
2627
+ - its data is keyed or driven, or its data's shape keys are: a curve's `eval_time` (the path its children
2628
+ follow, which Ctrl+P > Follow Path keys) or its points, a light's or a camera's settings, a mesh's keys;
2629
+ - it is a curve whose path its children follow (`use_path`), which hooks and shape keys change too;
2630
+ - a constraint moves it: one that is on, or one whose target moves (each constraint's targets are asked);
2631
+ - it follows a vertex (which a deforming parent moves), or a simulation (a rigid body).
2632
+ Armatures (their bones) and meshes with shape keys are never counted still. Materials are not asked: their
2633
+ animation is exported, if at all, apart from the objects' bake this step narrows."""
2634
+ if obj.type == "ARMATURE":
2635
+ return True
2636
+ data = obj.data
2637
+ if obj.type == "MESH" and data is not None and data.shape_keys is not None:
2638
+ return True
2639
+ if _animated(obj) or _animated(data) or _animated(getattr(data, "shape_keys", None)):
2640
+ return True
2641
+ if obj.type == "CURVE" and data is not None and data.use_path:
2642
+ return True
2643
+ for constraint in obj.constraints:
2644
+ if constraint.enabled or any(not _holds_still(target, seen) for target in _targets(constraint)):
2645
+ return True
2646
+ if obj.parent is not None and obj.parent_type in ("VERTEX", "VERTEX_3"):
2647
+ return True
2648
+ return obj.rigid_body is not None
2649
+
2650
+
2651
+ def _evaluating_what_exports(view_layer, placements, exported, run):
2652
+ """The level's placements out of the view layer while Blender's exporter bakes the scene, where nothing
2653
+ exported needs them: the add-on's own "Disable Viewport for Other Objects, for performance", which it does only
2654
+ in its other modes. Their markers stand in for them in the file and carry their motion (already keyed), so every
2655
+ frame the bake sets evaluates the level without the placement rigs and skins absent from the output. A placement
2656
+ something exported uses (a parent, a constraint's or a driver's target, a modifier's object), or that such a
2657
+ placement uses in turn, stays. Put back as it was after the export."""
2658
+ exported_ids = set(exported)
2659
+ for obj in exported:
2660
+ if obj.data is not None:
2661
+ exported_ids.add(obj.data)
2662
+ members = {}
2663
+ for collection in placements:
2664
+ for obj in collection.all_objects:
2665
+ members.setdefault(obj, collection)
2666
+ users = bpy.data.user_map(subset=set(members))
2667
+ needed = set()
2668
+ grew = True
2669
+ while grew:
2670
+ grew = False
2671
+ for obj in members:
2672
+ if obj not in needed and any(user in exported_ids or user in needed for user in users.get(obj, ())):
2673
+ needed.add(obj)
2674
+ grew = True
2675
+ kept = {members[obj] for obj in needed}
2676
+ excluded = []
2677
+
2678
+ def walk(layer):
2679
+ for child in layer.children:
2680
+ if child.collection in placements:
2681
+ if child.collection not in kept and not child.exclude:
2682
+ excluded.append(child)
2683
+ else:
2684
+ walk(child)
2685
+ walk(view_layer.layer_collection)
2686
+ for layer in excluded:
2687
+ layer.exclude = True
2688
+ try:
2689
+ return run()
2690
+ finally:
2691
+ for layer in excluded:
2692
+ layer.exclude = False
2693
+
2694
+
2695
+ # Apply the scene-bake optimization only to the glTF add-on versions with matching sampling
2696
+ # internals. Other versions use the add-on’s complete bake.
2697
+ SAMPLING_READ_FROM = ((5, 2, 39), (5, 2, 40))
2698
+
2699
+
2700
+ def _sampling_what_moves(run):
2701
+ """Bake only scene objects that may move, including descendants of moving nodes.
2702
+ Wrap the exporter’s gather and cache functions for this call and restore them afterwards.
2703
+ Preserve its sampled values and output bytes."""
2704
+ from io_scene_gltf2.blender.exp.animation import scene_animation
2705
+ from io_scene_gltf2.blender.exp.animation.sampled import sampling_cache
2706
+ from io_scene_gltf2.blender.exp.tree import VExportNode
2707
+ gather, caching = scene_animation.gather_object_sampled_channels, sampling_cache.object_caching
2708
+ known = {}
2709
+
2710
+ def still(uuid, export_settings):
2711
+ """The node and every node above it hold still. (A child of a moving node keeps its place relative to it,
2712
+ but the add-on's bake of it carries the rounding of the parent's motion, which it keeps: such a child is
2713
+ baked as the add-on bakes it, so the file stays the same.)"""
2714
+ if uuid in known:
2715
+ return known[uuid]
2716
+ nodes = export_settings["vtree"].nodes
2717
+ node = nodes[uuid]
2718
+ if node.blender_type == VExportNode.COLLECTION:
2719
+ answer = True
2720
+ elif node.blender_type == VExportNode.BONE or node.blender_object is None:
2721
+ answer = False
2722
+ else:
2723
+ answer = _holds_still(node.blender_object)
2724
+ if answer and node.parent_uuid is not None:
2725
+ answer = still(node.parent_uuid, export_settings)
2726
+ known[uuid] = answer
2727
+ return answer
2728
+
2729
+ def gather_moving(uuid, *rest):
2730
+ if still(uuid, rest[-1]):
2731
+ return None, None
2732
+ return gather(uuid, *rest)
2733
+
2734
+ def caching_moving(data, uuids, *rest):
2735
+ export_settings = rest[-1]
2736
+ return caching(data, [uuid for uuid in uuids if not still(uuid, export_settings)], *rest)
2737
+
2738
+ scene_animation.gather_object_sampled_channels = gather_moving
2739
+ sampling_cache.object_caching = caching_moving
2740
+ try:
2741
+ return run()
2742
+ finally:
2743
+ scene_animation.gather_object_sampled_channels = gather
2744
+ sampling_cache.object_caching = caching
2745
+
2746
+
2747
+ def _contents(out):
2748
+ """What the written GLB holds, read from its JSON chunk."""
2749
+ with open(out, "rb") as handle:
2750
+ header = handle.read(20)
2751
+ length = int.from_bytes(header[12:16], "little")
2752
+ document = json.loads(handle.read(length).decode("utf-8"))
2753
+ return {
2754
+ "nodes": len(document.get("nodes", [])),
2755
+ "meshes": len(document.get("meshes", [])),
2756
+ "skins": len(document.get("skins", [])),
2757
+ "animations": [a.get("name", "") for a in document.get("animations", [])],
2758
+ "extensionsUsed": document.get("extensionsUsed", []),
2759
+ }
2760
+
2761
+
2762
+ def _finish(kind, out, project, blend_sha, version, settings, extra, started, sidecars, steps):
2763
+ finishing = time.perf_counter()
2764
+ body = {
2765
+ "format": FORMAT,
2766
+ "kind": kind,
2767
+ "blend": _project_relative(bpy.data.filepath, project),
2768
+ "blendSha256": blend_sha,
2769
+ "exporter": version,
2770
+ "settings": settings,
2771
+ "file": os.path.basename(out),
2772
+ "sha256": _sha256(out),
2773
+ "bytes": os.path.getsize(out),
2774
+ }
2775
+ if sidecars:
2776
+ body["sidecars"] = [{"file": os.path.basename(path), "sha256": _sha256(path)} for path in sidecars]
2777
+ body.update(_contents(out))
2778
+ body.update(extra)
2779
+ with open(_manifest_path(out), "w", encoding="utf-8", newline="\n") as handle:
2780
+ json.dump(body, handle, indent=2, sort_keys=False)
2781
+ handle.write("\n")
2782
+ steps.ms["manifest"] = round((time.perf_counter() - finishing) * 1000)
2783
+ return {"current": False, "ms": round((time.perf_counter() - started) * 1000), "manifest": body,
2784
+ "steps": steps.ms,
2785
+ "sidecars": [os.path.abspath(path) for path in sidecars]}
2786
+
2787
+
2788
+ def _current_answer(out, current, started, luts):
2789
+ """A current export; its display LUT baked again alone when its file is gone."""
2790
+ missing = _lut_missing(current, out, luts)
2791
+ sidecars = []
2792
+ if missing is not None:
2793
+ cube, description = _display_lut_for(bpy.context.scene, out, luts)
2794
+ if cube is not None and description["key"] == missing:
2795
+ sidecars.append(os.path.abspath(cube))
2796
+ return {"current": True, "ms": round((time.perf_counter() - started) * 1000), "manifest": current, "sidecars": sidecars}
2797
+
2798
+
2799
+ def export_component(out, project=None, force=False, previous=None, blend_sha=None, luts=None):
2800
+ started = time.perf_counter()
2801
+ scene, view_layer = bpy.context.scene, bpy.context.view_layer
2802
+ blend_sha = blend_sha or _sha256(bpy.data.filepath)
2803
+ version = exporter_version()
2804
+ settings = dict(COMPONENT_SETTINGS)
2805
+ if not force:
2806
+ current = _current(out, blend_sha, version, settings, previous)
2807
+ if current is not None:
2808
+ return _current_answer(out, current, started, luts)
2809
+ authoring = _authoring_collections(scene)
2810
+ collections = [c.name for c in scene.collection.children if c.name_full not in authoring]
2811
+ hidden = _hidden_collections(scene)
2812
+ objects = [o for o in view_layer.objects if not _authoring(o) and not any(c.name_full in authoring for c in o.users_collection)]
2813
+ visibility = {o.name: _render_visible(o, hidden) for o in objects}
2814
+ warnings = []
2815
+ if len(collections) != 1:
2816
+ warnings.append("a component holds one collection named after it; this file has %d: %s"
2817
+ % (len(collections), ", ".join(collections) or "none"))
2818
+ warnings.extend(_hidden_over_visible(objects, visibility))
2819
+ warnings.extend(_area_lights(objects, visibility))
2820
+ warnings.extend(_strip_warnings(objects))
2821
+ warnings.extend(_euler_bones(objects))
2822
+ warnings.extend(_object_only_armature_actions(objects))
2823
+ warnings.extend(_evaluated_bones(objects))
2824
+ warnings.extend(_driven_shape_keys(objects))
2825
+ world, world_gaps = _world(scene)
2826
+ warnings.extend(world_gaps)
2827
+ volumes, volume_warnings = _object_volumes(objects, visibility)
2828
+ warnings.extend(volume_warnings)
2829
+ for name in volumes:
2830
+ visibility[name] = False
2831
+ runtime, runtime_data, runtime_warnings = _runtime_constraints(objects)
2832
+ warnings.extend(runtime_warnings)
2833
+ steps = _Steps()
2834
+ candidates = steps.run("subframeMeasure", lambda: _subframe_candidates(objects))
2835
+ cube, lut = steps.run("displayLut", lambda: _display_lut_for(scene, out, luts))
2836
+ hooks = _Hooks({}, {"keyRate": _key_rate(scene), "collections": _collection_tree(scene, authoring)}, visibility,
2837
+ _node_collections(objects, _collection_ancestors(scene)), runtime_data,
2838
+ scene_extensions={"STD_display": _display(scene, lut), "STD_environment": world, "STD_render": _render(scene)},
2839
+ volumes=volumes)
2840
+ steps.run("gltfExporter", lambda: _with_hooks(hooks, lambda: _muted(runtime, lambda: _at_rest(objects, lambda: _export_set(
2841
+ scene, view_layer, objects, lambda: _gltf(out, settings))))))
2842
+ duplicates = steps.run("uniqueChannels", lambda: _unique_channels(out))
2843
+ weights = steps.run("weightsKeyed", lambda: _weights_keyed(out))
2844
+ dense = steps.run("subframeSampling", lambda: _muted(runtime, lambda: _densify_subframes(out, objects, scene, candidates)))
2845
+ warnings.extend(_subframe_warnings(dense, candidates))
2846
+ signs = steps.run("rotationSigns", lambda: _align_rotation_signs(out, objects, scene))
2847
+ warnings.extend("an action's frames are mapped differently from the exporter's: " + one for one in hooks.slide_mismatch)
2848
+ return _finish("component", out, project, blend_sha, version, settings,
2849
+ {"collections": collections, "hiddenNodes": hooks.used_visibility, "warnings": warnings,
2850
+ "display": {"lut": lut["key"], "sha256": _lut_sha(out, lut["key"], luts)},
2851
+ "runtimeConstraints": sum(len(c) for c in runtime.values()), "rotationKeysTurned": signs["turned"],
2852
+ "rotationOutputsSplit": signs["split"], "duplicateChannelsRemoved": duplicates,
2853
+ "rotationLengths": {"channels": signs["lengths"], "bytes": signs["lengthBytes"]},
2854
+ "weightsKeyed": weights,
2855
+ "subframeSampling": _subframe_manifest(dense),
2856
+ "movedWithoutKeys": {action: sorted(bones) for action, bones in sorted(hooks.kept_moved.items())}},
2857
+ started, [cube] if cube else [], steps)
2858
+
2859
+
2860
+ def _marker_component(component):
2861
+ """What a marker names (RFC 0007 §2, §4): its component's exported file beside the level's folder
2862
+ (`../components/<stem>.glb`, as assembly resolves it) and its collection; never the .blend."""
2863
+ stem = os.path.splitext(os.path.basename(component["file"]))[0]
2864
+ return {"uri": "../components/%s.glb" % stem, "collection": component["collection"]}
2865
+
2866
+
2867
+ def _placement_root(collection):
2868
+ members = set(collection.all_objects)
2869
+ roots = [o for o in collection.all_objects if o.parent is None or o.parent not in members]
2870
+ roots.sort(key=lambda o: (o.type != "ARMATURE", o.name))
2871
+ return roots[0] if roots else None, roots
2872
+
2873
+
2874
+ def _tint(root):
2875
+ if "tint" in root.keys():
2876
+ value = root["tint"]
2877
+ try:
2878
+ return [round(float(x), 6) for x in value]
2879
+ except TypeError:
2880
+ return value if isinstance(value, str) else None
2881
+ reference = root.override_library.reference if root.override_library else None
2882
+ if reference is not None and tuple(root.color) != tuple(reference.color):
2883
+ return [round(float(x), 6) for x in root.color]
2884
+ return None
2885
+
2886
+
2887
+ def _unsupported_edits(collection):
2888
+ counts = {}
2889
+ for obj in collection.all_objects:
2890
+ library = obj.override_library
2891
+ if library is None:
2892
+ continue
2893
+ for prop in library.properties:
2894
+ path = prop.rna_path
2895
+ # (the placement's NLA is carried: extras.tracks, and its own motion keyed on the marker)
2896
+ if path in CARRIED or path in STRUCTURAL or path.startswith(("modifiers[", "animation_data.nla_tracks",
2897
+ "animation_data.action_slot")):
2898
+ continue
2899
+ key = path.split("[")[0] if path.startswith("pose.bones") else path
2900
+ key = "pose.bones" if key.startswith("pose.bones") else key
2901
+ counts["%s:%s" % (obj.name, key)] = counts.get("%s:%s" % (obj.name, key), 0) + 1
2902
+ return ["%s (%d)" % (key, n) for key, n in sorted(counts.items())]
2903
+
2904
+
2905
+ def _keyed_visibility(obj):
2906
+ keys = []
2907
+ for curve in _fcurves(obj):
2908
+ if curve.data_path == "hide_render":
2909
+ keys.extend([float(p.co[0]), not bool(round(p.co[1]))] for p in curve.keyframe_points)
2910
+ return sorted(keys)
2911
+
2912
+
2913
+ def export_level(out, project=None, force=False, previous=None, blend_sha=None, luts=None):
2914
+ started = time.perf_counter()
2915
+ scene, view_layer = bpy.context.scene, bpy.context.view_layer
2916
+ blend_sha = blend_sha or _sha256(bpy.data.filepath)
2917
+ version = exporter_version()
2918
+ settings = dict(LEVEL_SETTINGS)
2919
+ if not force:
2920
+ current = _current(out, blend_sha, version, settings, previous)
2921
+ # (a placement's own actions are beside the level: gone, the level is exported again)
2922
+ if current is not None and (previous is not None or all(
2923
+ os.path.isfile(os.path.join(os.path.dirname(out), entry["file"]))
2924
+ for entry in (current.get("placementActions") or {}).values())):
2925
+ return _current_answer(out, current, started, luts)
2926
+ authoring = _authoring_collections(scene)
2927
+ hidden = _hidden_collections(scene)
2928
+ in_layer = set(view_layer.objects)
2929
+ # THE PLACEMENTS: the outermost override collections in the scene, and the instancers of
2930
+ # linked collections.
2931
+ overrides = []
2932
+
2933
+ def walk(collection, inside):
2934
+ for child in collection.children:
2935
+ if child.name_full in authoring:
2936
+ continue
2937
+ is_override = child.override_library is not None and child.override_library.reference is not None \
2938
+ and child.override_library.reference.library is not None
2939
+ if is_override and not inside:
2940
+ overrides.append(child)
2941
+ walk(child, inside or is_override)
2942
+
2943
+ walk(scene.collection, False)
2944
+ placed = set()
2945
+ for collection in overrides:
2946
+ placed.update(collection.all_objects)
2947
+ instancers = [o for o in view_layer.objects if o.instance_type == "COLLECTION" and o.instance_collection is not None
2948
+ and o.instance_collection.library is not None and o not in placed and not _authoring(o)]
2949
+ objects = [o for o in view_layer.objects
2950
+ if o not in placed and o.override_library is None and o.library is None and not _authoring(o)
2951
+ and not any(c.name_full in authoring for c in o.users_collection)]
2952
+ markers, nodes, unsupported, warnings = [], {}, [], []
2953
+ ancestors = _collection_ancestors(scene)
2954
+ components = {}
2955
+ made = []
2956
+ moving = []
2957
+ motion = []
2958
+ companions = []
2959
+ companion_results = {}
2960
+ switched = []
2961
+ stranded = sorted(o.name for o in objects if o.parent is not None and o.parent in placed)
2962
+ if stranded:
2963
+ warnings.append("objects parented to a placement stand where they are now, without its motion: " + ", ".join(stranded))
2964
+ try:
2965
+ for index, collection in enumerate(overrides):
2966
+ root, roots = _placement_root(collection)
2967
+ if root is None:
2968
+ warnings.append("override %s holds no object; it has no marker" % collection.name)
2969
+ continue
2970
+ reference = root.override_library.reference if root.override_library else None
2971
+ placement = root.matrix_world.copy()
2972
+ if reference is not None:
2973
+ placement = root.matrix_world @ reference.matrix_world.inverted()
2974
+ for other in roots[1:]:
2975
+ other_ref = other.override_library.reference if other.override_library else None
2976
+ if other_ref is None:
2977
+ continue
2978
+ expected = placement @ other_ref.matrix_world
2979
+ if any(abs(a - b) > 1e-4 for row_a, row_b in zip(expected, other.matrix_world) for a, b in zip(row_a, row_b)):
2980
+ unsupported.append({"placement": collection.name, "edit": "%s is moved apart from %s" % (other.name, root.name)})
2981
+ library_collection = collection.override_library.reference
2982
+ component = {"file": _library_file(library_collection.library, project), "collection": library_collection.name}
2983
+ components[(component["file"], component["collection"])] = component
2984
+ tracks = {}
2985
+ action = root.animation_data.action.name if root.animation_data and root.animation_data.action else None
2986
+ visible = any(o.type == "MESH" and _render_visible(o, hidden) for o in collection.all_objects) \
2987
+ and collection.name_full not in hidden
2988
+ extras = {"component": _marker_component(component)}
2989
+ held = _collections_of([collection.name], ancestors)
2990
+ if held:
2991
+ extras["collections"] = held
2992
+ # The placement's active action as Blender plays it, `false` when it has none (an override may
2993
+ # clear the component's: then only its NLA plays). Not null: the glTF writer drops a null, and the
2994
+ # placement would then play its component's clip.
2995
+ if root.type == "ARMATURE":
2996
+ tracks["clip"] = action if action is not None else False
2997
+ # THE PLACEMENT'S OWN NLA, as authored on the override (its tracks over the component's):
2998
+ # it plays on the placed clone in a cutscene (RFC 0007 §3).
2999
+ own = _level_actions(root)
3000
+ if own:
3001
+ extras["actions"] = {"file": _companion(out, collection.name)}
3002
+ companions.append((root, own, os.path.join(os.path.dirname(out), extras["actions"]["file"]), collection.name))
3003
+ placement_tracks = _tracks(root) if root.type == "ARMATURE" else None
3004
+ if placement_tracks and any(t.get("strips") for t in placement_tracks):
3005
+ tracks["tracks"] = placement_tracks
3006
+ tracks.update(_nla_settings(root))
3007
+ tint = _tint(root)
3008
+ if tint is not None:
3009
+ extras["tint"] = tint
3010
+ marker = bpy.data.objects.new("__volter_marker.%d" % index, None)
3011
+ marker.empty_display_type = "PLAIN_AXES"
3012
+ made.append(marker)
3013
+ if root.parent is not None and root.parent not in placed and root.parent in in_layer:
3014
+ marker.parent = root.parent
3015
+ marker.matrix_world = placement
3016
+ nodes[marker.name] = {"name": collection.name, "extras": extras, "tracks": tracks}
3017
+ markers.append({"name": collection.name, "component": component, "action": action, "visible": visible,
3018
+ "tint": tint, "parent": marker.parent.name if marker.parent else None})
3019
+ for edit in _unsupported_edits(collection):
3020
+ unsupported.append({"placement": collection.name, "edit": edit})
3021
+ if _moves_itself(root):
3022
+ moving.append((marker, root, reference.matrix_world.inverted() if reference is not None else mathutils.Matrix()))
3023
+ for obj in instancers:
3024
+ linked = obj.instance_collection
3025
+ component = {"file": _library_file(linked.library, project), "collection": linked.name}
3026
+ components[(component["file"], component["collection"])] = component
3027
+ if tuple(linked.instance_offset) != (0.0, 0.0, 0.0):
3028
+ unsupported.append({"placement": obj.name, "edit": "the collection's instance offset %s is not carried"
3029
+ % tuple(round(v, 4) for v in linked.instance_offset)})
3030
+ extras = {"component": _marker_component(component)}
3031
+ held = _collections_of([c.name for c in obj.users_collection], ancestors)
3032
+ if held:
3033
+ extras["collections"] = held
3034
+ tint = obj["tint"] if "tint" in obj.keys() else None
3035
+ if tint is not None:
3036
+ extras["tint"] = [float(x) for x in tint] if not isinstance(tint, str) else tint
3037
+ nodes[obj.name] = {"name": obj.name, "extras": extras}
3038
+ markers.append({"name": obj.name, "component": component, "action": None,
3039
+ "visible": _render_visible(obj, hidden), "tint": extras.get("tint"), "parent": obj.parent.name if obj.parent else None})
3040
+ switched.append(obj)
3041
+ obj.instance_type = "NONE"
3042
+ visibility = {o.name: _render_visible(o, hidden) for o in objects}
3043
+ for marker in made:
3044
+ entry = next(m for m in markers if m["name"] == nodes[marker.name]["name"])
3045
+ visibility[marker.name] = entry["visible"]
3046
+ fps = scene.render.fps / (scene.render.fps_base or 1.0)
3047
+ timeline = {
3048
+ "fps": round(fps, 6),
3049
+ "keyRate": _key_rate(scene),
3050
+ "start": scene.frame_start,
3051
+ "end": scene.frame_end,
3052
+ "current": scene.frame_current,
3053
+ "camera": scene.camera.name if scene.camera else None,
3054
+ "markers": [{"name": m.name, "frame": m.frame, "camera": m.camera.name if m.camera else None}
3055
+ for m in sorted(scene.timeline_markers, key=lambda m: (m.frame, m.name))],
3056
+ }
3057
+ warnings.extend(_hidden_over_visible(objects, visibility))
3058
+ keyed = {o.name: _keyed_visibility(o) for o in objects}
3059
+ keyed = {name: keys for name, keys in keyed.items() if keys}
3060
+ if keyed:
3061
+ timeline["visibility"] = keyed
3062
+ exported = objects + made
3063
+ frame, subframe = scene.frame_current, scene.frame_subframe
3064
+ steps = _Steps()
3065
+ moving_names = sorted(nodes[marker.name]["name"] for marker, _root, _inverse in moving)
3066
+ try:
3067
+ motion = steps.run("placementMotion", lambda: _bake_placement_motion(scene, moving))
3068
+ finally:
3069
+ scene.frame_set(frame, subframe=subframe)
3070
+ # HOW THE LEVEL IS SEEN AND RENDERED: its colour management (without which a renderer cannot
3071
+ # draw its emission and highlights as Blender does: AgX maps an emission of 8 to a pale tint,
3072
+ # where no tone mapping clips it white), its world and its render settings.
3073
+ warnings.extend(_area_lights(objects, visibility))
3074
+ warnings.extend(_strip_warnings(objects + [c for c in placed if c.type == "ARMATURE"]))
3075
+ warnings.extend(_euler_bones(objects))
3076
+ warnings.extend(_object_only_armature_actions(objects))
3077
+ warnings.extend(_evaluated_bones(objects))
3078
+ warnings.extend(_driven_shape_keys(objects))
3079
+ scene_worst = steps.run("subframeMeasure", lambda: _scene_subframe_worst(
3080
+ objects + [root for _marker, root, _inverse in moving], scene))
3081
+ warnings.extend(_scene_subframe_warnings(scene_worst))
3082
+ world, world_gaps = _world(scene)
3083
+ warnings.extend(world_gaps)
3084
+ volumes, volume_warnings = _object_volumes(objects, visibility)
3085
+ warnings.extend(volume_warnings)
3086
+ for name in volumes:
3087
+ visibility[name] = False
3088
+ runtime, runtime_data, runtime_warnings = _runtime_constraints(objects)
3089
+ warnings.extend(runtime_warnings)
3090
+ cube, lut = steps.run("displayLut", lambda: _display_lut_for(scene, out, luts))
3091
+ hooks = _Hooks(nodes, {"timeline": timeline, "keyRate": _key_rate(scene), "collections": _collection_tree(scene, authoring)},
3092
+ visibility, _node_collections(objects, ancestors), runtime_data,
3093
+ scene_extensions={"STD_display": _display(scene, lut), "STD_environment": world, "STD_render": _render(scene)},
3094
+ volumes=volumes)
3095
+ try:
3096
+ steps.run("gltfExporter", lambda: _with_hooks(hooks, lambda: _muted(runtime, lambda: _export_set(
3097
+ scene, view_layer, exported, lambda: _evaluating_what_exports(
3098
+ view_layer, overrides, exported, lambda: _gltf(out, settings))))))
3099
+ for root, own, path, name in companions:
3100
+ companion = steps.run("placementActions", lambda root=root, own=own, path=path: _export_placement_actions(
3101
+ scene, view_layer, root, own, path))
3102
+ companion_results[name] = companion
3103
+ warnings.extend("placement %s's own actions: %s" % (name, one) for one in companion["warnings"])
3104
+ finally:
3105
+ scene.frame_set(frame, subframe=subframe)
3106
+ finally:
3107
+ for obj in switched:
3108
+ obj.instance_type = "COLLECTION"
3109
+ for marker in made:
3110
+ bpy.data.objects.remove(marker, do_unlink=True)
3111
+ for action in motion:
3112
+ bpy.data.actions.remove(action)
3113
+ if hooks.occluded:
3114
+ attributes = sorted({(row["attribute"], row["strength"]) for row in hooks.occluded.values()})
3115
+ warnings.append("%d material(s) multiply their base colour by a Color Attribute (%s); their base colour is "
3116
+ "carried, the multiply is not (glTF draws only COLOR_0, as a factor of 1)" % (
3117
+ len(hooks.occluded), ", ".join("%r at %g" % pair for pair in attributes)))
3118
+ return _finish("level", out, project, blend_sha, version, settings, {
3119
+ "markers": markers,
3120
+ "components": sorted(components.values(), key=lambda c: (c["file"], c["collection"])),
3121
+ "unsupported": unsupported,
3122
+ "movingPlacements": moving_names,
3123
+ "placementActions": {name: {"file": os.path.basename(path), "actions": sorted(a.name for a in own),
3124
+ "subframeSampling": companion_results[name]["subframeSampling"]}
3125
+ for _root, own, path, name in companions},
3126
+ "hiddenNodes": hooks.used_visibility,
3127
+ # The base colours put back from behind a Color Attribute multiply (`_occluded_base`), by material.
3128
+ "occludedBaseColors": hooks.occluded,
3129
+ "warnings": warnings,
3130
+ "display": {"lut": lut["key"], "sha256": _lut_sha(out, lut["key"], luts)},
3131
+ "runtimeConstraints": sum(len(c) for c in runtime.values()),
3132
+ "sceneBetweenFrames": {"degrees": round(scene_worst[0][0], 4), "degreesAt": list(scene_worst[0][1:]),
3133
+ "metres": round(scene_worst[1][0], 6), "metresAt": list(scene_worst[1][1:])},
3134
+ }, started, ([cube] if cube else []) + [path for _root, _own, path, _name in companions], steps)
3135
+
3136
+
3137
+ def export(kind, out, project=None, force=False, previous=None, blend_sha=None, luts=None):
3138
+ """Export the open `.blend` as a component or a level to `out` (a `.glb`), its manifest beside
3139
+ it, and answer `{current, ms, manifest}`; `current` when the earlier export still stands.
3140
+ `blend_sha` is the `.blend`'s SHA-256 when the caller holds the file (the editor, where Blender
3141
+ reads the document from a stream and keeps no copy); without it the file is read here."""
3142
+ if not bpy.data.filepath:
3143
+ raise RuntimeError("Save the model as a .blend in the project before exporting it.")
3144
+ if kind not in ("component", "level"):
3145
+ raise ValueError("kind is component or level, not %r" % (kind,))
3146
+ run = export_component if kind == "component" else export_level
3147
+ return _kept_document(bpy.context.scene, lambda: run(out, project, force, previous, blend_sha, luts))
3148
+
3149
+
3150
+ if __name__ == "__main__" and "--" in sys.argv:
3151
+ arguments = sys.argv[sys.argv.index("--") + 1:]
3152
+ kind, out = arguments[0], os.path.abspath(arguments[1])
3153
+ project = arguments[2] if len(arguments) > 2 and not arguments[2].startswith("--") else None
3154
+ answer = export(kind, out, project, force="--force" in arguments)
3155
+ manifest = answer["manifest"]
3156
+ print("@@GLTF-EXPORT " + json.dumps({"current": answer["current"], "ms": answer["ms"], "steps": answer.get("steps", {}), "file": manifest["file"],
3157
+ "bytes": manifest["bytes"], "nodes": manifest["nodes"],
3158
+ "animations": len(manifest["animations"]),
3159
+ "markers": len(manifest.get("markers", [])),
3160
+ "unsupported": len(manifest.get("unsupported", [])),
3161
+ "warnings": manifest.get("warnings", [])}))