1 # SPDX-FileCopyrightText: 2021-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 Pose Library - operators.
9 from pathlib
import Path
10 from typing
import Optional
, Set
12 _need_reload
= "functions" in locals()
13 from . import asset_browser
, functions
, pose_creation
, pose_usage
18 asset_browser
= importlib
.reload(asset_browser
)
19 functions
= importlib
.reload(functions
)
20 pose_creation
= importlib
.reload(pose_creation
)
21 pose_usage
= importlib
.reload(pose_usage
)
25 from bpy
.props
import BoolProperty
, StringProperty
26 from bpy
.types
import (
34 from bpy_extras
import asset_utils
35 from bpy
.app
.translations
import pgettext_tip
as tip_
38 class PoseAssetCreator
:
40 def poll(cls
, context
: Context
) -> bool:
42 # There must be an object.
44 # It must be in pose mode with selected bones.
45 and context
.object.mode
== "POSE"
46 and context
.object.pose
47 and context
.selected_pose_bones_from_active_object
51 class POSELIB_OT_create_pose_asset(PoseAssetCreator
, Operator
):
52 bl_idname
= "poselib.create_pose_asset"
53 bl_label
= "Create Pose Asset"
55 "Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
56 "The asset will be stored in the current blend file"
58 bl_options
= {'REGISTER', 'UNDO'}
60 pose_name
: StringProperty(name
="Pose Name") # type: ignore
61 activate_new_action
: BoolProperty(name
="Activate New Action", default
=True) # type: ignore
64 def poll(cls
, context
: Context
) -> bool:
65 if context
.object is None or context
.object.mode
!= "POSE":
66 # The operator assumes pose mode, so that bone selection is visible.
67 cls
.poll_message_set("An active armature object in pose mode is needed")
70 # Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
71 asset_browse_area
: Optional
[bpy
.types
.Area
] = asset_browser
.area_from_context(context
)
72 if not asset_browse_area
:
73 # No asset browser is visible, so there also aren't any expectations
74 # that this asset will be visible.
77 asset_space_params
= asset_browser
.params(asset_browse_area
)
78 if asset_space_params
.asset_library_reference
!= 'LOCAL':
79 cls
.poll_message_set("Asset Browser must be set to the Current File library")
84 def execute(self
, context
: Context
) -> Set
[str]:
85 pose_name
= self
.pose_name
or context
.object.name
86 asset
= pose_creation
.create_pose_asset_from_context(context
, pose_name
)
88 self
.report({"WARNING"}, "No keyframes were found for this pose")
91 if self
.activate_new_action
:
92 self
._set
_active
_action
(context
, asset
)
93 self
._activate
_asset
_in
_browser
(context
, asset
)
96 def _set_active_action(self
, context
: Context
, asset
: Action
) -> None:
97 self
._prevent
_action
_loss
(context
.object)
99 anim_data
= context
.object.animation_data_create()
100 context
.window_manager
.poselib_previous_action
= anim_data
.action
101 anim_data
.action
= asset
103 def _activate_asset_in_browser(self
, context
: Context
, asset
: Action
) -> None:
104 """Activate the new asset in the appropriate Asset Browser.
106 This makes it possible to immediately check & edit the created pose asset.
109 asset_browse_area
: Optional
[bpy
.types
.Area
] = asset_browser
.area_from_context(context
)
110 if not asset_browse_area
:
113 # After creating an asset, the window manager has to process the
114 # notifiers before editors should be manipulated.
115 pose_creation
.assign_from_asset_browser(asset
, asset_browse_area
)
117 # Pass deferred=True, because we just created a new asset that isn't
118 # known to the Asset Browser space yet. That requires the processing of
119 # notifiers, which will only happen after this code has finished
121 asset_browser
.activate_asset(asset
, asset_browse_area
, deferred
=True)
123 def _prevent_action_loss(self
, object: Object
) -> None:
124 """Mark the action with Fake User if necessary.
126 This is to prevent action loss when we reduce its reference counter by one.
129 if not object.animation_data
:
132 action
= object.animation_data
.action
136 if action
.use_fake_user
or action
.users
> 1:
137 # Removing one user won't GC it.
140 action
.use_fake_user
= True
141 self
.report({'WARNING'}, tip_("Action %s marked Fake User to prevent loss") % action
.name
)
144 class POSELIB_OT_restore_previous_action(Operator
):
145 bl_idname
= "poselib.restore_previous_action"
146 bl_label
= "Restore Previous Action"
147 bl_description
= "Switch back to the previous Action, after creating a pose asset"
148 bl_options
= {'REGISTER', 'UNDO'}
151 def poll(cls
, context
: Context
) -> bool:
153 context
.window_manager
.poselib_previous_action
155 and context
.object.animation_data
156 and context
.object.animation_data
.action
157 and context
.object.animation_data
.action
.asset_data
is not None
160 def execute(self
, context
: Context
) -> Set
[str]:
161 # This is the Action that was just created with "Create Pose Asset".
162 # It has to be re-applied after switching to the previous action,
163 # to ensure the character keeps the same pose.
164 self
.pose_action
= context
.object.animation_data
.action
166 prev_action
= context
.window_manager
.poselib_previous_action
167 context
.object.animation_data
.action
= prev_action
168 context
.window_manager
.poselib_previous_action
= None
170 # Wait a bit for the action assignment to be handled, before applying the pose.
171 wm
= context
.window_manager
172 self
._timer
= wm
.event_timer_add(0.001, window
=context
.window
)
173 wm
.modal_handler_add(self
)
175 return {'RUNNING_MODAL'}
177 def modal(self
, context
, event
):
178 if event
.type != 'TIMER':
179 return {'RUNNING_MODAL'}
181 wm
= context
.window_manager
182 wm
.event_timer_remove(self
._timer
)
184 context
.object.pose
.apply_pose_from_action(self
.pose_action
)
188 class ASSET_OT_assign_action(Operator
):
189 bl_idname
= "asset.assign_action"
190 bl_label
= "Assign Action"
191 bl_description
= "Set this pose Action as active Action on the active Object"
192 bl_options
= {'REGISTER', 'UNDO'}
195 def poll(cls
, context
: Context
) -> bool:
196 return bool(isinstance(getattr(context
, "id", None), Action
) and context
.object)
198 def execute(self
, context
: Context
) -> Set
[str]:
199 context
.object.animation_data_create().action
= context
.id
203 class POSELIB_OT_copy_as_asset(PoseAssetCreator
, Operator
):
204 bl_idname
= "poselib.copy_as_asset"
205 bl_label
= "Copy Pose as Asset"
206 bl_description
= "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
207 bl_options
= {'REGISTER'}
209 CLIPBOARD_ASSET_MARKER
= "ASSET-BLEND="
211 def execute(self
, context
: Context
) -> Set
[str]:
212 asset
= pose_creation
.create_pose_asset_from_context(context
, new_asset_name
=context
.object.name
)
214 self
.report({"WARNING"}, "No animation data found to create asset from")
217 filepath
= self
.save_datablock(asset
)
219 context
.window_manager
.clipboard
= "%s%s" % (
220 self
.CLIPBOARD_ASSET_MARKER
,
223 asset_browser
.tag_redraw(context
.screen
)
224 self
.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
226 # The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
228 asset
.use_fake_user
= False
230 # The asset can be removed from the main DB, as it was purely created to
231 # be stored to disk, and not to be used in this file.
233 # This should never happen, and indicates a bug in the code. Having a warning about it is nice,
234 # but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
235 self
.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
237 bpy
.data
.actions
.remove(asset
)
240 def save_datablock(self
, action
: Action
) -> Path
:
241 tempdir
= Path(bpy
.app
.tempdir
)
242 filepath
= tempdir
/ "copied_asset.blend"
243 bpy
.data
.libraries
.write(
248 compress
=True, # Single-datablock blend file, likely little need to diff.
253 class POSELIB_OT_paste_asset(Operator
):
254 bl_idname
= "poselib.paste_asset"
255 bl_label
= "Paste as New Asset"
256 bl_description
= "Paste the Asset that was previously copied using Copy As Asset"
257 bl_options
= {'REGISTER', 'UNDO'}
260 def poll(cls
, context
: Context
) -> bool:
261 if not asset_utils
.SpaceAssetInfo
.is_asset_browser(context
.space_data
):
262 cls
.poll_message_set("Current editor is not an asset browser")
265 asset_lib_ref
= context
.space_data
.params
.asset_library_reference
266 if asset_lib_ref
!= 'LOCAL':
267 cls
.poll_message_set("Asset Browser must be set to the Current File library")
270 # Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
271 clipboard
: str = context
.window_manager
.clipboard
273 cls
.poll_message_set("Clipboard is empty")
276 marker
= POSELIB_OT_copy_as_asset
.CLIPBOARD_ASSET_MARKER
277 if not clipboard
.startswith(marker
):
278 cls
.poll_message_set("Clipboard does not contain an asset")
283 def execute(self
, context
: Context
) -> Set
[str]:
284 clipboard
= context
.window_manager
.clipboard
285 marker_len
= len(POSELIB_OT_copy_as_asset
.CLIPBOARD_ASSET_MARKER
)
286 filepath
= Path(clipboard
[marker_len
:])
288 assets
= functions
.load_assets_from(filepath
)
290 self
.report({"ERROR"}, "Did not find any assets on clipboard")
293 self
.report({"INFO"}, tip_("Pasted %d assets") % len(assets
))
295 bpy
.ops
.asset
.library_refresh()
297 asset_browser_area
= asset_browser
.area_from_context(context
)
298 if not asset_browser_area
:
301 # Assign same catalog as in asset browser.
302 catalog_id
= asset_browser
.active_catalog_id(asset_browser_area
)
304 asset
.asset_data
.catalog_id
= catalog_id
305 asset_browser
.activate_asset(assets
[0], asset_browser_area
, deferred
=True)
312 def poll(cls
, context
: Context
) -> bool:
315 and context
.object.mode
== "POSE" # This condition may not be desired.
319 return context
.asset
.id_type
== 'ACTION'
321 def execute(self
, context
: Context
) -> Set
[str]:
322 asset
: AssetRepresentation
= context
.asset
324 return self
.use_pose(context
, asset
.local_id
)
325 return self
._load
_and
_use
_pose
(context
)
327 def use_pose(self
, context
: Context
, asset
: bpy
.types
.ID
) -> Set
[str]:
328 # Implement in subclass.
331 def _load_and_use_pose(self
, context
: Context
) -> Set
[str]:
332 asset
= context
.asset
333 asset_lib_path
= asset
.full_library_path
335 if not asset_lib_path
:
336 self
.report( # type: ignore
338 # TODO: Add some way to get the library name from the library reference
339 # (just asset_library_reference.name?).
340 tip_("Selected asset %s could not be located inside the asset library") % asset
.name
,
343 if asset
.id_type
!= 'ACTION':
344 self
.report( # type: ignore
346 tip_("Selected asset %s is not an Action") % asset
.name
,
350 with bpy
.types
.BlendData
.temp_data() as temp_data
:
351 with temp_data
.libraries
.load(asset_lib_path
) as (data_from
, data_to
):
352 data_to
.actions
= [asset
.name
]
354 action
: Action
= data_to
.actions
[0]
355 return self
.use_pose(context
, action
)
358 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser
, Operator
):
359 bl_idname
= "poselib.pose_asset_select_bones"
360 bl_label
= "Select Bones"
361 bl_description
= "Select those bones that are used in this pose"
362 bl_options
= {'REGISTER', 'UNDO'}
364 select
: BoolProperty(name
="Select", default
=True) # type: ignore
365 flipped
: BoolProperty(name
="Flipped", default
=False) # type: ignore
367 def use_pose(self
, context
: Context
, pose_asset
: Action
) -> Set
[str]:
368 arm_object
: Object
= context
.object
369 pose_usage
.select_bones(arm_object
, pose_asset
, select
=self
.select
, flipped
=self
.flipped
)
371 msg
= tip_("Selected bones from %s") % pose_asset
.name
373 msg
= tip_("Deselected bones from %s") % pose_asset
.name
374 self
.report({"INFO"}, msg
)
378 def description(cls
, _context
: Context
, properties
: 'POSELIB_OT_pose_asset_select_bones') -> str:
379 if properties
.select
:
380 return cls
.bl_description
381 return cls
.bl_description
.replace("Select", "Deselect")
384 class POSELIB_OT_convert_old_poselib(Operator
):
385 bl_idname
= "poselib.convert_old_poselib"
386 bl_label
= "Convert Legacy Pose Library"
387 bl_description
= "Create a pose asset for each pose marker in the current action"
388 bl_options
= {'REGISTER', 'UNDO'}
391 def poll(cls
, context
: Context
) -> bool:
392 action
= context
.object and context
.object.animation_data
and context
.object.animation_data
.action
394 cls
.poll_message_set("Active object has no Action")
396 if not action
.pose_markers
:
397 cls
.poll_message_set(tip_("Action %r is not a legacy pose library") % action
.name
)
401 def execute(self
, context
: Context
) -> Set
[str]:
402 from . import conversion
404 old_poselib
= context
.object.animation_data
.action
405 new_actions
= conversion
.convert_old_poselib(old_poselib
)
408 self
.report({'ERROR'}, "Unable to convert to pose assets")
411 self
.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions
))
415 class POSELIB_OT_convert_old_object_poselib(Operator
):
416 bl_idname
= "poselib.convert_old_object_poselib"
417 bl_label
= "Convert Legacy Pose Library"
418 bl_description
= "Create a pose asset for each pose marker in this legacy pose library data-block"
420 # Mark this one as "internal", as it converts `context.object.pose_library`
421 # instead of its current animation Action.
422 bl_options
= {'REGISTER', 'UNDO', 'INTERNAL'}
425 def poll(cls
, context
: Context
) -> bool:
426 action
= context
.object and context
.object.pose_library
428 cls
.poll_message_set("Active object has no pose library Action")
430 if not action
.pose_markers
:
431 cls
.poll_message_set(tip_("Action %r is not a legacy pose library") % action
.name
)
435 def execute(self
, context
: Context
) -> Set
[str]:
436 from . import conversion
438 old_poselib
= context
.object.pose_library
439 new_actions
= conversion
.convert_old_poselib(old_poselib
)
442 self
.report({'ERROR'}, "Unable to convert to pose assets")
445 self
.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions
))
450 ASSET_OT_assign_action
,
451 POSELIB_OT_convert_old_poselib
,
452 POSELIB_OT_convert_old_object_poselib
,
453 POSELIB_OT_copy_as_asset
,
454 POSELIB_OT_create_pose_asset
,
455 POSELIB_OT_paste_asset
,
456 POSELIB_OT_pose_asset_select_bones
,
457 POSELIB_OT_restore_previous_action
,
460 register
, unregister
= bpy
.utils
.register_classes_factory(classes
)