Pose library: replace f-strings by format for I18n
[blender-addons.git] / pose_library / operators.py
blob73fca59de4b8b6de8f67125e2f8d1f67e0ae0c46
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 """
4 Pose Library - operators.
5 """
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
13 if _need_reload:
14 import importlib
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)
22 import bpy
23 from bpy.props import BoolProperty, StringProperty
24 from bpy.types import (
25 Action,
26 Context,
27 Event,
28 FileSelectEntry,
29 Object,
30 Operator,
32 from bpy_extras import asset_utils
33 from bpy.app.translations import pgettext_tip as tip_
36 class PoseAssetCreator:
37 @classmethod
38 def poll(cls, context: Context) -> bool:
39 return bool(
40 # There must be an object.
41 context.object
42 # It must be in pose mode with selected bones.
43 and context.object.mode == "POSE"
44 and context.object.pose
45 and context.selected_pose_bones_from_active_object
49 class LocalPoseAssetUser:
50 @classmethod
51 def poll(cls, context: Context) -> bool:
52 return bool(
53 isinstance(getattr(context, "id", None), Action)
54 and context.object
55 and context.object.mode == "POSE" # This condition may not be desired.
59 class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator):
60 bl_idname = "poselib.create_pose_asset"
61 bl_label = "Create Pose Asset"
62 bl_description = (
63 "Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
64 "The asset will be stored in the current blend file"
66 bl_options = {"REGISTER", "UNDO"}
68 pose_name: StringProperty(name="Pose Name") # type: ignore
69 activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
72 @classmethod
73 def poll(cls, context: Context) -> bool:
74 # Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
75 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
76 if not asset_browse_area:
77 # No asset browser is visible, so there also aren't any expectations
78 # that this asset will be visible.
79 return True
81 asset_space_params = asset_browser.params(asset_browse_area)
82 if asset_space_params.asset_library_ref != 'LOCAL':
83 cls.poll_message_set("Asset Browser must be set to the Current File library")
84 return False
86 return True
88 def execute(self, context: Context) -> Set[str]:
89 pose_name = self.pose_name or context.object.name
90 asset = pose_creation.create_pose_asset_from_context(context, pose_name)
91 if not asset:
92 self.report({"WARNING"}, "No keyframes were found for this pose")
93 return {"CANCELLED"}
95 if self.activate_new_action:
96 self._set_active_action(context, asset)
97 self._activate_asset_in_browser(context, asset)
98 return {'FINISHED'}
100 def _set_active_action(self, context: Context, asset: Action) -> None:
101 self._prevent_action_loss(context.object)
103 anim_data = context.object.animation_data_create()
104 context.window_manager.poselib_previous_action = anim_data.action
105 anim_data.action = asset
107 def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
108 """Activate the new asset in the appropriate Asset Browser.
110 This makes it possible to immediately check & edit the created pose asset.
113 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
114 if not asset_browse_area:
115 return
117 # After creating an asset, the window manager has to process the
118 # notifiers before editors should be manipulated.
119 pose_creation.assign_from_asset_browser(asset, asset_browse_area)
121 # Pass deferred=True, because we just created a new asset that isn't
122 # known to the Asset Browser space yet. That requires the processing of
123 # notifiers, which will only happen after this code has finished
124 # running.
125 asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
127 def _prevent_action_loss(self, object: Object) -> None:
128 """Mark the action with Fake User if necessary.
130 This is to prevent action loss when we reduce its reference counter by one.
133 if not object.animation_data:
134 return
136 action = object.animation_data.action
137 if not action:
138 return
140 if action.use_fake_user or action.users > 1:
141 # Removing one user won't GC it.
142 return
144 action.use_fake_user = True
145 self.report({'WARNING'}, tip_("Action %s marked Fake User to prevent loss") % action.name)
148 class POSELIB_OT_restore_previous_action(Operator):
149 bl_idname = "poselib.restore_previous_action"
150 bl_label = "Restore Previous Action"
151 bl_description = "Switch back to the previous Action, after creating a pose asset"
152 bl_options = {"REGISTER", "UNDO"}
154 @classmethod
155 def poll(cls, context: Context) -> bool:
156 return bool(
157 context.window_manager.poselib_previous_action
158 and context.object
159 and context.object.animation_data
160 and context.object.animation_data.action
161 and context.object.animation_data.action.asset_data is not None
164 def execute(self, context: Context) -> Set[str]:
165 # This is the Action that was just created with "Create Pose Asset".
166 # It has to be re-applied after switching to the previous action,
167 # to ensure the character keeps the same pose.
168 self.pose_action = context.object.animation_data.action
170 prev_action = context.window_manager.poselib_previous_action
171 context.object.animation_data.action = prev_action
172 context.window_manager.poselib_previous_action = None
174 # Wait a bit for the action assignment to be handled, before applying the pose.
175 wm = context.window_manager
176 self._timer = wm.event_timer_add(0.001, window=context.window)
177 wm.modal_handler_add(self)
179 return {'RUNNING_MODAL'}
181 def modal(self, context, event):
182 if event.type != 'TIMER':
183 return {'RUNNING_MODAL'}
185 wm = context.window_manager
186 wm.event_timer_remove(self._timer)
188 context.object.pose.apply_pose_from_action(self.pose_action)
189 return {'FINISHED'}
192 class ASSET_OT_assign_action(LocalPoseAssetUser, Operator):
193 bl_idname = "asset.assign_action"
194 bl_label = "Assign Action"
195 bl_description = "Set this pose Action as active Action on the active Object"
196 bl_options = {"REGISTER", "UNDO"}
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(
213 context, new_asset_name=context.object.name
215 if asset is None:
216 self.report({"WARNING"}, "No animation data found to create asset from")
217 return {"CANCELLED"}
219 filepath = self.save_datablock(asset)
221 context.window_manager.clipboard = "%s%s" % (
222 self.CLIPBOARD_ASSET_MARKER,
223 filepath,
225 asset_browser.tag_redraw(context.screen)
226 self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
228 # The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
229 asset.asset_clear()
230 asset.use_fake_user = False
232 # The asset can be removed from the main DB, as it was purely created to
233 # be stored to disk, and not to be used in this file.
234 if asset.users > 0:
235 # This should never happen, and indicates a bug in the code. Having a warning about it is nice,
236 # but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
237 self.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
239 bpy.data.actions.remove(asset)
240 return {"FINISHED"}
242 def save_datablock(self, action: Action) -> Path:
243 tempdir = Path(bpy.app.tempdir)
244 filepath = tempdir / "copied_asset.blend"
245 bpy.data.libraries.write(
246 str(filepath),
247 datablocks={action},
248 path_remap="NONE",
249 fake_user=True,
250 compress=True, # Single-datablock blend file, likely little need to diff.
252 return filepath
255 class POSELIB_OT_paste_asset(Operator):
256 bl_idname = "poselib.paste_asset"
257 bl_label = "Paste As New Asset"
258 bl_description = "Paste the Asset that was previously copied using Copy As Asset"
259 bl_options = {"REGISTER", "UNDO"}
261 @classmethod
262 def poll(cls, context: Context) -> bool:
263 if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
264 cls.poll_message_set("Current editor is not an asset browser")
265 return False
267 asset_lib_ref = context.space_data.params.asset_library_ref
268 if asset_lib_ref != 'LOCAL':
269 cls.poll_message_set("Asset Browser must be set to the Current File library")
270 return False
272 # Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
273 clipboard: str = context.window_manager.clipboard
274 if not clipboard:
275 cls.poll_message_set("Clipboard is empty")
276 return False
278 marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
279 if not clipboard.startswith(marker):
280 cls.poll_message_set("Clipboard does not contain an asset")
281 return False
283 return True
286 def execute(self, context: Context) -> Set[str]:
287 clipboard = context.window_manager.clipboard
288 marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
289 filepath = Path(clipboard[marker_len:])
291 assets = functions.load_assets_from(filepath)
292 if not assets:
293 self.report({"ERROR"}, "Did not find any assets on clipboard")
294 return {"CANCELLED"}
296 self.report({"INFO"}, tip_("Pasted %d assets") % len(assets))
298 bpy.ops.asset.library_refresh()
300 asset_browser_area = asset_browser.area_from_context(context)
301 if not asset_browser_area:
302 return {"FINISHED"}
304 # Assign same catalog as in asset browser.
305 catalog_id = asset_browser.active_catalog_id(asset_browser_area)
306 for asset in assets:
307 asset.asset_data.catalog_id = catalog_id
308 asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
310 return {"FINISHED"}
313 class PoseAssetUser:
314 @classmethod
315 def poll(cls, context: Context) -> bool:
316 if not (
317 context.object
318 and context.object.mode == "POSE" # This condition may not be desired.
319 and context.asset_library_ref
320 and context.asset_file_handle
322 return False
323 return context.asset_file_handle.id_type == 'ACTION'
325 def execute(self, context: Context) -> Set[str]:
326 asset: FileSelectEntry = context.asset_file_handle
327 if asset.local_id:
328 return self.use_pose(context, asset.local_id)
329 return self._load_and_use_pose(context)
331 def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
332 # Implement in subclass.
333 pass
335 def _load_and_use_pose(self, context: Context) -> Set[str]:
336 asset_library_ref = context.asset_library_ref
337 asset = context.asset_file_handle
338 asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
340 if not asset_lib_path:
341 self.report( # type: ignore
342 {"ERROR"},
343 # TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
344 tip_("Selected asset %s could not be located inside the asset library") % asset.name,
346 return {"CANCELLED"}
347 if asset.id_type != 'ACTION':
348 self.report( # type: ignore
349 {"ERROR"},
350 tip_("Selected asset %s is not an Action") % asset.name,
352 return {"CANCELLED"}
354 with bpy.types.BlendData.temp_data() as temp_data:
355 with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
356 data_to.actions = [asset.name]
358 action: Action = data_to.actions[0]
359 return self.use_pose(context, action)
362 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator):
363 bl_idname = "poselib.pose_asset_select_bones"
364 bl_label = "Select Bones"
365 bl_description = "Select those bones that are used in this pose"
366 bl_options = {"REGISTER", "UNDO"}
368 select: BoolProperty(name="Select", default=True) # type: ignore
369 flipped: BoolProperty(name="Flipped", default=False) # type: ignore
371 def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
372 arm_object: Object = context.object
373 pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
374 if self.select:
375 msg = tip_("Selected bones from %s") % pose_asset.name
376 else:
377 msg = tip_("Deselected bones from %s") % pose_asset.name
378 self.report({"INFO"}, msg)
379 return {"FINISHED"}
381 @classmethod
382 def description(
383 cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones'
384 ) -> str:
385 if properties.select:
386 return cls.bl_description
387 return cls.bl_description.replace("Select", "Deselect")
390 # This operator takes the Window Manager's `poselib_flipped` property, and
391 # passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
392 # possible to bind a key to the operator and still have it respect the global
393 # "Flip Pose" checkbox.
394 class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
395 bl_idname = "poselib.blend_pose_asset_for_keymap"
396 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
398 _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
399 bl_label = _rna.name
400 bl_description = _rna.description
401 del _rna
403 @classmethod
404 def poll(cls, context: Context) -> bool:
405 return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
407 def execute(self, context: Context) -> Set[str]:
408 flipped = context.window_manager.poselib_flipped
409 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
411 def invoke(self, context: Context, event: Event) -> Set[str]:
412 flipped = context.window_manager.poselib_flipped
413 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped)
416 # This operator takes the Window Manager's `poselib_flipped` property, and
417 # passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
418 # possible to bind a key to the operator and still have it respect the global
419 # "Flip Pose" checkbox.
420 class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
421 bl_idname = "poselib.apply_pose_asset_for_keymap"
422 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
424 _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
425 bl_label = _rna.name
426 bl_description = _rna.description
427 del _rna
429 @classmethod
430 def poll(cls, context: Context) -> bool:
431 if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
432 return False
433 return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
435 def execute(self, context: Context) -> Set[str]:
436 flipped = context.window_manager.poselib_flipped
437 return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
440 class POSELIB_OT_convert_old_poselib(Operator):
441 bl_idname = "poselib.convert_old_poselib"
442 bl_label = "Convert Legacy Pose Library"
443 bl_description = "Create a pose asset for each pose marker in the current action"
444 bl_options = {"REGISTER", "UNDO"}
446 @classmethod
447 def poll(cls, context: Context) -> bool:
448 action = context.object and context.object.animation_data and context.object.animation_data.action
449 if not action:
450 cls.poll_message_set("Active object has no Action")
451 return False
452 if not action.pose_markers:
453 cls.poll_message_set(tip_("Action %r is not a legacy pose library") % action.name)
454 return False
455 return True
457 def execute(self, context: Context) -> Set[str]:
458 from . import conversion
460 old_poselib = context.object.animation_data.action
461 new_actions = conversion.convert_old_poselib(old_poselib)
463 if not new_actions:
464 self.report({'ERROR'}, "Unable to convert to pose assets")
465 return {'CANCELLED'}
467 self.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions))
468 return {'FINISHED'}
471 class POSELIB_OT_convert_old_object_poselib(Operator):
472 bl_idname = "poselib.convert_old_object_poselib"
473 bl_label = "Convert Legacy Pose Library"
474 bl_description = "Create a pose asset for each pose marker in this legacy pose library data-block"
476 # Mark this one as "internal", as it converts `context.object.pose_library`
477 # instead of its current animation Action.
478 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
480 @classmethod
481 def poll(cls, context: Context) -> bool:
482 action = context.object and context.object.pose_library
483 if not action:
484 cls.poll_message_set("Active object has no pose library Action")
485 return False
486 if not action.pose_markers:
487 cls.poll_message_set(tip_("Action %r is not a legacy pose library") % action.name)
488 return False
489 return True
491 def execute(self, context: Context) -> Set[str]:
492 from . import conversion
494 old_poselib = context.object.pose_library
495 new_actions = conversion.convert_old_poselib(old_poselib)
497 if not new_actions:
498 self.report({'ERROR'}, "Unable to convert to pose assets")
499 return {'CANCELLED'}
501 self.report({'INFO'}, tip_("Converted %d poses to pose assets") % len(new_actions))
502 return {'FINISHED'}
505 classes = (
506 ASSET_OT_assign_action,
507 POSELIB_OT_apply_pose_asset_for_keymap,
508 POSELIB_OT_blend_pose_asset_for_keymap,
509 POSELIB_OT_convert_old_poselib,
510 POSELIB_OT_convert_old_object_poselib,
511 POSELIB_OT_copy_as_asset,
512 POSELIB_OT_create_pose_asset,
513 POSELIB_OT_paste_asset,
514 POSELIB_OT_pose_asset_select_bones,
515 POSELIB_OT_restore_previous_action,
518 register, unregister = bpy.utils.register_classes_factory(classes)