1 # SPDX-License-Identifier: GPL-2.0-or-later
4 Pose Library - operators.
7 from pathlib
import Path
8 from typing
import Optional
, Set
10 _need_reload
= "functions" in locals()
11 from . import asset_browser
, functions
, pose_creation
, pose_usage
16 asset_browser
= importlib
.reload(asset_browser
)
17 functions
= importlib
.reload(functions
)
18 pose_creation
= importlib
.reload(pose_creation
)
19 pose_usage
= importlib
.reload(pose_usage
)
23 from bpy
.props
import BoolProperty
, StringProperty
24 from bpy
.types
import (
32 from bpy_extras
import asset_utils
35 class PoseAssetCreator
:
37 def poll(cls
, context
: Context
) -> bool:
39 # There must be an object.
41 # It must be in pose mode with selected bones.
42 and context
.object.mode
== "POSE"
43 and context
.object.pose
44 and context
.selected_pose_bones_from_active_object
48 class LocalPoseAssetUser
:
50 def poll(cls
, context
: Context
) -> bool:
52 isinstance(getattr(context
, "id", None), Action
)
54 and context
.object.mode
== "POSE" # This condition may not be desired.
58 class POSELIB_OT_create_pose_asset(PoseAssetCreator
, Operator
):
59 bl_idname
= "poselib.create_pose_asset"
60 bl_label
= "Create Pose Asset"
62 "Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
63 "The asset will be stored in the current blend file"
65 bl_options
= {"REGISTER", "UNDO"}
67 pose_name
: StringProperty(name
="Pose Name") # type: ignore
68 activate_new_action
: BoolProperty(name
="Activate New Action", default
=True) # type: ignore
72 def poll(cls
, context
: Context
) -> bool:
73 # Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
74 asset_browse_area
: Optional
[bpy
.types
.Area
] = asset_browser
.area_from_context(context
)
75 if not asset_browse_area
:
76 # No asset browser is visible, so there also aren't any expectations
77 # that this asset will be visible.
80 asset_space_params
= asset_browser
.params(asset_browse_area
)
81 if asset_space_params
.asset_library_ref
!= 'LOCAL':
82 cls
.poll_message_set("Asset Browser must be set to the Current File library")
87 def execute(self
, context
: Context
) -> Set
[str]:
88 pose_name
= self
.pose_name
or context
.object.name
89 asset
= pose_creation
.create_pose_asset_from_context(context
, pose_name
)
91 self
.report({"WARNING"}, "No keyframes were found for this pose")
94 if self
.activate_new_action
:
95 self
._set
_active
_action
(context
, asset
)
96 self
._activate
_asset
_in
_browser
(context
, asset
)
99 def _set_active_action(self
, context
: Context
, asset
: Action
) -> None:
100 self
._prevent
_action
_loss
(context
.object)
102 anim_data
= context
.object.animation_data_create()
103 context
.window_manager
.poselib_previous_action
= anim_data
.action
104 anim_data
.action
= asset
106 def _activate_asset_in_browser(self
, context
: Context
, asset
: Action
) -> None:
107 """Activate the new asset in the appropriate Asset Browser.
109 This makes it possible to immediately check & edit the created pose asset.
112 asset_browse_area
: Optional
[bpy
.types
.Area
] = asset_browser
.area_from_context(context
)
113 if not asset_browse_area
:
116 # After creating an asset, the window manager has to process the
117 # notifiers before editors should be manipulated.
118 pose_creation
.assign_from_asset_browser(asset
, asset_browse_area
)
120 # Pass deferred=True, because we just created a new asset that isn't
121 # known to the Asset Browser space yet. That requires the processing of
122 # notifiers, which will only happen after this code has finished
124 asset_browser
.activate_asset(asset
, asset_browse_area
, deferred
=True)
126 def _prevent_action_loss(self
, object: Object
) -> None:
127 """Mark the action with Fake User if necessary.
129 This is to prevent action loss when we reduce its reference counter by one.
132 if not object.animation_data
:
135 action
= object.animation_data
.action
139 if action
.use_fake_user
or action
.users
> 1:
140 # Removing one user won't GC it.
143 action
.use_fake_user
= True
144 self
.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action
.name
)
147 class POSELIB_OT_restore_previous_action(Operator
):
148 bl_idname
= "poselib.restore_previous_action"
149 bl_label
= "Restore Previous Action"
150 bl_description
= "Switch back to the previous Action, after creating a pose asset"
151 bl_options
= {"REGISTER", "UNDO"}
154 def poll(cls
, context
: Context
) -> bool:
156 context
.window_manager
.poselib_previous_action
158 and context
.object.animation_data
159 and context
.object.animation_data
.action
160 and context
.object.animation_data
.action
.asset_data
is not None
163 def execute(self
, context
: Context
) -> Set
[str]:
164 # This is the Action that was just created with "Create Pose Asset".
165 # It has to be re-applied after switching to the previous action,
166 # to ensure the character keeps the same pose.
167 self
.pose_action
= context
.object.animation_data
.action
169 prev_action
= context
.window_manager
.poselib_previous_action
170 context
.object.animation_data
.action
= prev_action
171 context
.window_manager
.poselib_previous_action
= None
173 # Wait a bit for the action assignment to be handled, before applying the pose.
174 wm
= context
.window_manager
175 self
._timer
= wm
.event_timer_add(0.001, window
=context
.window
)
176 wm
.modal_handler_add(self
)
178 return {'RUNNING_MODAL'}
180 def modal(self
, context
, event
):
181 if event
.type != 'TIMER':
182 return {'RUNNING_MODAL'}
184 wm
= context
.window_manager
185 wm
.event_timer_remove(self
._timer
)
187 context
.object.pose
.apply_pose_from_action(self
.pose_action
)
191 class ASSET_OT_assign_action(LocalPoseAssetUser
, Operator
):
192 bl_idname
= "asset.assign_action"
193 bl_label
= "Assign Action"
194 bl_description
= "Set this pose Action as active Action on the active Object"
195 bl_options
= {"REGISTER", "UNDO"}
197 def execute(self
, context
: Context
) -> Set
[str]:
198 context
.object.animation_data_create().action
= context
.id
202 class POSELIB_OT_copy_as_asset(PoseAssetCreator
, Operator
):
203 bl_idname
= "poselib.copy_as_asset"
204 bl_label
= "Copy Pose As Asset"
205 bl_description
= "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
206 bl_options
= {"REGISTER"}
208 CLIPBOARD_ASSET_MARKER
= "ASSET-BLEND="
210 def execute(self
, context
: Context
) -> Set
[str]:
211 asset
= pose_creation
.create_pose_asset_from_context(
212 context
, new_asset_name
=context
.object.name
215 self
.report({"WARNING"}, "No animation data found to create asset from")
218 filepath
= self
.save_datablock(asset
)
220 context
.window_manager
.clipboard
= "%s%s" % (
221 self
.CLIPBOARD_ASSET_MARKER
,
224 asset_browser
.tag_redraw(context
.screen
)
225 self
.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
227 # The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
229 asset
.use_fake_user
= False
231 # The asset can be removed from the main DB, as it was purely created to
232 # be stored to disk, and not to be used in this file.
234 # This should never happen, and indicates a bug in the code. Having a warning about it is nice,
235 # but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
236 self
.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
238 bpy
.data
.actions
.remove(asset
)
241 def save_datablock(self
, action
: Action
) -> Path
:
242 tempdir
= Path(bpy
.app
.tempdir
)
243 filepath
= tempdir
/ "copied_asset.blend"
244 bpy
.data
.libraries
.write(
249 compress
=True, # Single-datablock blend file, likely little need to diff.
254 class POSELIB_OT_paste_asset(Operator
):
255 bl_idname
= "poselib.paste_asset"
256 bl_label
= "Paste As New Asset"
257 bl_description
= "Paste the Asset that was previously copied using Copy As Asset"
258 bl_options
= {"REGISTER", "UNDO"}
261 def poll(cls
, context
: Context
) -> bool:
262 if not asset_utils
.SpaceAssetInfo
.is_asset_browser(context
.space_data
):
263 cls
.poll_message_set("Current editor is not an asset browser")
266 asset_lib_ref
= context
.space_data
.params
.asset_library_ref
267 if asset_lib_ref
!= 'LOCAL':
268 cls
.poll_message_set("Asset Browser must be set to the Current File library")
271 # Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
272 clipboard
: str = context
.window_manager
.clipboard
274 cls
.poll_message_set("Clipboard is empty")
277 marker
= POSELIB_OT_copy_as_asset
.CLIPBOARD_ASSET_MARKER
278 if not clipboard
.startswith(marker
):
279 cls
.poll_message_set("Clipboard does not contain an asset")
285 def execute(self
, context
: Context
) -> Set
[str]:
286 clipboard
= context
.window_manager
.clipboard
287 marker_len
= len(POSELIB_OT_copy_as_asset
.CLIPBOARD_ASSET_MARKER
)
288 filepath
= Path(clipboard
[marker_len
:])
290 assets
= functions
.load_assets_from(filepath
)
292 self
.report({"ERROR"}, "Did not find any assets on clipboard")
295 self
.report({"INFO"}, "Pasted %d assets" % len(assets
))
297 bpy
.ops
.asset
.library_refresh()
299 asset_browser_area
= asset_browser
.area_from_context(context
)
300 if not asset_browser_area
:
303 # Assign same catalog as in asset browser.
304 catalog_id
= asset_browser
.active_catalog_id(asset_browser_area
)
306 asset
.asset_data
.catalog_id
= catalog_id
307 asset_browser
.activate_asset(assets
[0], asset_browser_area
, deferred
=True)
314 def poll(cls
, context
: Context
) -> bool:
317 and context
.object.mode
== "POSE" # This condition may not be desired.
318 and context
.asset_library_ref
319 and context
.asset_file_handle
322 return context
.asset_file_handle
.id_type
== 'ACTION'
324 def execute(self
, context
: Context
) -> Set
[str]:
325 asset
: FileSelectEntry
= context
.asset_file_handle
327 return self
.use_pose(context
, asset
.local_id
)
328 return self
._load
_and
_use
_pose
(context
)
330 def use_pose(self
, context
: Context
, asset
: bpy
.types
.ID
) -> Set
[str]:
331 # Implement in subclass.
334 def _load_and_use_pose(self
, context
: Context
) -> Set
[str]:
335 asset_library_ref
= context
.asset_library_ref
336 asset
= context
.asset_file_handle
337 asset_lib_path
= bpy
.types
.AssetHandle
.get_full_library_path(asset
, asset_library_ref
)
339 if not asset_lib_path
:
340 self
.report( # type: ignore
342 # TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
343 f
"Selected asset {asset.name} could not be located inside the asset library",
346 if asset
.id_type
!= 'ACTION':
347 self
.report( # type: ignore
349 f
"Selected asset {asset.name} is not an Action",
353 with bpy
.types
.BlendData
.temp_data() as temp_data
:
354 with temp_data
.libraries
.load(asset_lib_path
) as (data_from
, data_to
):
355 data_to
.actions
= [asset
.name
]
357 action
: Action
= data_to
.actions
[0]
358 return self
.use_pose(context
, action
)
361 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser
, Operator
):
362 bl_idname
= "poselib.pose_asset_select_bones"
363 bl_label
= "Select Bones"
364 bl_description
= "Select those bones that are used in this pose"
365 bl_options
= {"REGISTER", "UNDO"}
367 select
: BoolProperty(name
="Select", default
=True) # type: ignore
368 flipped
: BoolProperty(name
="Flipped", default
=False) # type: ignore
370 def use_pose(self
, context
: Context
, pose_asset
: Action
) -> Set
[str]:
371 arm_object
: Object
= context
.object
372 pose_usage
.select_bones(arm_object
, pose_asset
, select
=self
.select
, flipped
=self
.flipped
)
373 verb
= "Selected" if self
.select
else "Deselected"
374 self
.report({"INFO"}, f
"{verb} bones from {pose_asset.name}")
379 cls
, _context
: Context
, properties
: 'POSELIB_OT_pose_asset_select_bones'
381 if properties
.select
:
382 return cls
.bl_description
383 return cls
.bl_description
.replace("Select", "Deselect")
386 # This operator takes the Window Manager's `poselib_flipped` property, and
387 # passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
388 # possible to bind a key to the operator and still have it respect the global
389 # "Flip Pose" checkbox.
390 class POSELIB_OT_blend_pose_asset_for_keymap(Operator
):
391 bl_idname
= "poselib.blend_pose_asset_for_keymap"
392 bl_options
= {"REGISTER", "UNDO", "INTERNAL"}
394 _rna
= bpy
.ops
.poselib
.blend_pose_asset
.get_rna_type()
396 bl_description
= _rna
.description
400 def poll(cls
, context
: Context
) -> bool:
401 return bpy
.ops
.poselib
.blend_pose_asset
.poll(context
.copy())
403 def execute(self
, context
: Context
) -> Set
[str]:
404 flipped
= context
.window_manager
.poselib_flipped
405 return bpy
.ops
.poselib
.blend_pose_asset(context
.copy(), 'EXEC_DEFAULT', flipped
=flipped
)
407 def invoke(self
, context
: Context
, event
: Event
) -> Set
[str]:
408 flipped
= context
.window_manager
.poselib_flipped
409 return bpy
.ops
.poselib
.blend_pose_asset(context
.copy(), 'INVOKE_DEFAULT', flipped
=flipped
)
412 # This operator takes the Window Manager's `poselib_flipped` property, and
413 # passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
414 # possible to bind a key to the operator and still have it respect the global
415 # "Flip Pose" checkbox.
416 class POSELIB_OT_apply_pose_asset_for_keymap(Operator
):
417 bl_idname
= "poselib.apply_pose_asset_for_keymap"
418 bl_options
= {"REGISTER", "UNDO", "INTERNAL"}
420 _rna
= bpy
.ops
.poselib
.apply_pose_asset
.get_rna_type()
422 bl_description
= _rna
.description
426 def poll(cls
, context
: Context
) -> bool:
427 if not asset_utils
.SpaceAssetInfo
.is_asset_browser(context
.space_data
):
429 return bpy
.ops
.poselib
.apply_pose_asset
.poll(context
.copy())
431 def execute(self
, context
: Context
) -> Set
[str]:
432 flipped
= context
.window_manager
.poselib_flipped
433 return bpy
.ops
.poselib
.apply_pose_asset(context
.copy(), 'EXEC_DEFAULT', flipped
=flipped
)
436 class POSELIB_OT_convert_old_poselib(Operator
):
437 bl_idname
= "poselib.convert_old_poselib"
438 bl_label
= "Convert Legacy Pose Library"
439 bl_description
= "Create a pose asset for each pose marker in the current action"
440 bl_options
= {"REGISTER", "UNDO"}
443 def poll(cls
, context
: Context
) -> bool:
444 action
= context
.object and context
.object.animation_data
and context
.object.animation_data
.action
446 cls
.poll_message_set("Active object has no Action")
448 if not action
.pose_markers
:
449 cls
.poll_message_set("Action %r is not a legacy pose library" % action
.name
)
453 def execute(self
, context
: Context
) -> Set
[str]:
454 from . import conversion
456 old_poselib
= context
.object.animation_data
.action
457 new_actions
= conversion
.convert_old_poselib(old_poselib
)
460 self
.report({'ERROR'}, "Unable to convert to pose assets")
463 self
.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions
))
467 class POSELIB_OT_convert_old_object_poselib(Operator
):
468 bl_idname
= "poselib.convert_old_object_poselib"
469 bl_label
= "Convert Legacy Pose Library"
470 bl_description
= "Create a pose asset for each pose marker in this legacy pose library data-block"
472 # Mark this one as "internal", as it converts `context.object.pose_library`
473 # instead of its current animation Action.
474 bl_options
= {"REGISTER", "UNDO", "INTERNAL"}
477 def poll(cls
, context
: Context
) -> bool:
478 action
= context
.object and context
.object.pose_library
480 cls
.poll_message_set("Active object has no pose library Action")
482 if not action
.pose_markers
:
483 cls
.poll_message_set("Action %r is not a legacy pose library" % action
.name
)
487 def execute(self
, context
: Context
) -> Set
[str]:
488 from . import conversion
490 old_poselib
= context
.object.pose_library
491 new_actions
= conversion
.convert_old_poselib(old_poselib
)
494 self
.report({'ERROR'}, "Unable to convert to pose assets")
497 self
.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions
))
502 ASSET_OT_assign_action
,
503 POSELIB_OT_apply_pose_asset_for_keymap
,
504 POSELIB_OT_blend_pose_asset_for_keymap
,
505 POSELIB_OT_convert_old_poselib
,
506 POSELIB_OT_convert_old_object_poselib
,
507 POSELIB_OT_copy_as_asset
,
508 POSELIB_OT_create_pose_asset
,
509 POSELIB_OT_paste_asset
,
510 POSELIB_OT_pose_asset_select_bones
,
511 POSELIB_OT_restore_previous_action
,
514 register
, unregister
= bpy
.utils
.register_classes_factory(classes
)