Merge branch 'blender-v4.0-release'
[blender-addons.git] / pose_library / operators.py
blob624a785f11a532e23d0c11b76f512113a5ff0c07
1 # SPDX-FileCopyrightText: 2021-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Pose Library - operators.
7 """
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
15 if _need_reload:
16 import importlib
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)
24 import bpy
25 from bpy.props import BoolProperty, StringProperty
26 from bpy.types import (
27 Action,
28 AssetRepresentation,
29 Context,
30 Event,
31 Object,
32 Operator,
34 from bpy_extras import asset_utils
35 from bpy.app.translations import pgettext_tip as tip_
38 class PoseAssetCreator:
39 @classmethod
40 def poll(cls, context: Context) -> bool:
41 return bool(
42 # There must be an object.
43 context.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"
54 bl_description = (
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
63 @classmethod
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")
68 return False
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.
75 return True
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")
80 return False
82 return True
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)
87 if not asset:
88 self.report({"WARNING"}, "No keyframes were found for this pose")
89 return {"CANCELLED"}
91 if self.activate_new_action:
92 self._set_active_action(context, asset)
93 self._activate_asset_in_browser(context, asset)
94 return {'FINISHED'}
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:
111 return
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
120 # running.
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:
130 return
132 action = object.animation_data.action
133 if not action:
134 return
136 if action.use_fake_user or action.users > 1:
137 # Removing one user won't GC it.
138 return
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'}
150 @classmethod
151 def poll(cls, context: Context) -> bool:
152 return bool(
153 context.window_manager.poselib_previous_action
154 and context.object
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)
185 return {'FINISHED'}
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'}
194 @classmethod
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
200 return {"FINISHED"}
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)
213 if asset is None:
214 self.report({"WARNING"}, "No animation data found to create asset from")
215 return {"CANCELLED"}
217 filepath = self.save_datablock(asset)
219 context.window_manager.clipboard = "%s%s" % (
220 self.CLIPBOARD_ASSET_MARKER,
221 filepath,
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.
227 asset.asset_clear()
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.
232 if asset.users > 0:
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)
238 return {"FINISHED"}
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(
244 str(filepath),
245 datablocks={action},
246 path_remap="NONE",
247 fake_user=True,
248 compress=True, # Single-datablock blend file, likely little need to diff.
250 return filepath
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'}
259 @classmethod
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")
263 return False
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")
268 return False
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
272 if not clipboard:
273 cls.poll_message_set("Clipboard is empty")
274 return False
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")
279 return False
281 return True
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)
289 if not assets:
290 self.report({"ERROR"}, "Did not find any assets on clipboard")
291 return {"CANCELLED"}
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:
299 return {"FINISHED"}
301 # Assign same catalog as in asset browser.
302 catalog_id = asset_browser.active_catalog_id(asset_browser_area)
303 for asset in assets:
304 asset.asset_data.catalog_id = catalog_id
305 asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
307 return {"FINISHED"}
310 class PoseAssetUser:
311 @classmethod
312 def poll(cls, context: Context) -> bool:
313 if not (
314 context.object
315 and context.object.mode == "POSE" # This condition may not be desired.
316 and context.asset
318 return False
319 return context.asset.id_type == 'ACTION'
321 def execute(self, context: Context) -> Set[str]:
322 asset: AssetRepresentation = context.asset
323 if asset.local_id:
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.
329 pass
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
337 {"ERROR"},
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,
342 return {"CANCELLED"}
343 if asset.id_type != 'ACTION':
344 self.report( # type: ignore
345 {"ERROR"},
346 tip_("Selected asset %s is not an Action") % asset.name,
348 return {"CANCELLED"}
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)
370 if self.select:
371 msg = tip_("Selected bones from %s") % pose_asset.name
372 else:
373 msg = tip_("Deselected bones from %s") % pose_asset.name
374 self.report({"INFO"}, msg)
375 return {"FINISHED"}
377 @classmethod
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'}
390 @classmethod
391 def poll(cls, context: Context) -> bool:
392 action = context.object and context.object.animation_data and context.object.animation_data.action
393 if not action:
394 cls.poll_message_set("Active object has no Action")
395 return False
396 if not action.pose_markers:
397 cls.poll_message_set(tip_("Action %r is not a legacy pose library") % action.name)
398 return False
399 return True
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)
407 if not new_actions:
408 self.report({'ERROR'}, "Unable to convert to pose assets")
409 return {'CANCELLED'}
411 self.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions))
412 return {'FINISHED'}
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'}
424 @classmethod
425 def poll(cls, context: Context) -> bool:
426 action = context.object and context.object.pose_library
427 if not action:
428 cls.poll_message_set("Active object has no pose library Action")
429 return False
430 if not action.pose_markers:
431 cls.poll_message_set(tip_("Action %r is not a legacy pose library") % action.name)
432 return False
433 return True
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)
441 if not new_actions:
442 self.report({'ERROR'}, "Unable to convert to pose assets")
443 return {'CANCELLED'}
445 self.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions))
446 return {'FINISHED'}
449 classes = (
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)