Cleanup: quiet character escape warnings
[blender-addons.git] / pose_library / operators.py
blobe4e9c9af0b0b6bab107a6bd9d81e59809eec207f
1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 """
20 Pose Library - operators.
21 """
23 from pathlib import Path
24 from typing import Optional, Set
26 _need_reload = "functions" in locals()
27 from . import asset_browser, functions, pose_creation, pose_usage
29 if _need_reload:
30 import importlib
32 asset_browser = importlib.reload(asset_browser)
33 functions = importlib.reload(functions)
34 pose_creation = importlib.reload(pose_creation)
35 pose_usage = importlib.reload(pose_usage)
38 import bpy
39 from bpy.props import BoolProperty, StringProperty
40 from bpy.types import (
41 Action,
42 Context,
43 Event,
44 FileSelectEntry,
45 Object,
46 Operator,
48 from bpy_extras import asset_utils
51 class PoseAssetCreator:
52 @classmethod
53 def poll(cls, context: Context) -> bool:
54 return bool(
55 # There must be an object.
56 context.object
57 # It must be in pose mode with selected bones.
58 and context.object.mode == "POSE"
59 and context.object.pose
60 and context.selected_pose_bones_from_active_object
64 class LocalPoseAssetUser:
65 @classmethod
66 def poll(cls, context: Context) -> bool:
67 return bool(
68 isinstance(getattr(context, "id", None), Action)
69 and context.object
70 and context.object.mode == "POSE" # This condition may not be desired.
74 class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator):
75 bl_idname = "poselib.create_pose_asset"
76 bl_label = "Create Pose Asset"
77 bl_description = (
78 "Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
79 "The asset will be stored in the current blend file"
81 bl_options = {"REGISTER", "UNDO"}
83 pose_name: StringProperty(name="Pose Name") # type: ignore
84 activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
87 @classmethod
88 def poll(cls, context: Context) -> bool:
89 # Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
90 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
91 if not asset_browse_area:
92 # No asset browser is visible, so there also aren't any expectations
93 # that this asset will be visible.
94 return True
96 asset_space_params = asset_browser.params(asset_browse_area)
97 if asset_space_params.asset_library_ref != 'LOCAL':
98 cls.poll_message_set("Asset Browser must be set to the Current File library")
99 return False
101 return True
103 def execute(self, context: Context) -> Set[str]:
104 pose_name = self.pose_name or context.object.name
105 asset = pose_creation.create_pose_asset_from_context(context, pose_name)
106 if not asset:
107 self.report({"WARNING"}, "No keyframes were found for this pose")
108 return {"CANCELLED"}
110 if self.activate_new_action:
111 self._set_active_action(context, asset)
112 self._activate_asset_in_browser(context, asset)
113 return {'FINISHED'}
115 def _set_active_action(self, context: Context, asset: Action) -> None:
116 self._prevent_action_loss(context.object)
118 anim_data = context.object.animation_data_create()
119 context.window_manager.poselib_previous_action = anim_data.action
120 anim_data.action = asset
122 def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
123 """Activate the new asset in the appropriate Asset Browser.
125 This makes it possible to immediately check & edit the created pose asset.
128 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
129 if not asset_browse_area:
130 return
132 # After creating an asset, the window manager has to process the
133 # notifiers before editors should be manipulated.
134 pose_creation.assign_from_asset_browser(asset, asset_browse_area)
136 # Pass deferred=True, because we just created a new asset that isn't
137 # known to the Asset Browser space yet. That requires the processing of
138 # notifiers, which will only happen after this code has finished
139 # running.
140 asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
142 def _prevent_action_loss(self, object: Object) -> None:
143 """Mark the action with Fake User if necessary.
145 This is to prevent action loss when we reduce its reference counter by one.
148 if not object.animation_data:
149 return
151 action = object.animation_data.action
152 if not action:
153 return
155 if action.use_fake_user or action.users > 1:
156 # Removing one user won't GC it.
157 return
159 action.use_fake_user = True
160 self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
163 class POSELIB_OT_restore_previous_action(Operator):
164 bl_idname = "poselib.restore_previous_action"
165 bl_label = "Restore Previous Action"
166 bl_description = "Switch back to the previous Action, after creating a pose asset"
167 bl_options = {"REGISTER", "UNDO"}
169 @classmethod
170 def poll(cls, context: Context) -> bool:
171 return bool(
172 context.window_manager.poselib_previous_action
173 and context.object
174 and context.object.animation_data
175 and context.object.animation_data.action
176 and context.object.animation_data.action.asset_data is not None
179 def execute(self, context: Context) -> Set[str]:
180 # This is the Action that was just created with "Create Pose Asset".
181 # It has to be re-applied after switching to the previous action,
182 # to ensure the character keeps the same pose.
183 self.pose_action = context.object.animation_data.action
185 prev_action = context.window_manager.poselib_previous_action
186 context.object.animation_data.action = prev_action
187 context.window_manager.poselib_previous_action = None
189 # Wait a bit for the action assignment to be handled, before applying the pose.
190 wm = context.window_manager
191 self._timer = wm.event_timer_add(0.001, window=context.window)
192 wm.modal_handler_add(self)
194 return {'RUNNING_MODAL'}
196 def modal(self, context, event):
197 if event.type != 'TIMER':
198 return {'RUNNING_MODAL'}
200 wm = context.window_manager
201 wm.event_timer_remove(self._timer)
203 context.object.pose.apply_pose_from_action(self.pose_action)
204 return {'FINISHED'}
207 class ASSET_OT_assign_action(LocalPoseAssetUser, Operator):
208 bl_idname = "asset.assign_action"
209 bl_label = "Assign Action"
210 bl_description = "Set this pose Action as active Action on the active Object"
211 bl_options = {"REGISTER", "UNDO"}
213 def execute(self, context: Context) -> Set[str]:
214 context.object.animation_data_create().action = context.id
215 return {"FINISHED"}
218 class POSELIB_OT_copy_as_asset(PoseAssetCreator, Operator):
219 bl_idname = "poselib.copy_as_asset"
220 bl_label = "Copy Pose As Asset"
221 bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
222 bl_options = {"REGISTER"}
224 CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
226 def execute(self, context: Context) -> Set[str]:
227 asset = pose_creation.create_pose_asset_from_context(
228 context, new_asset_name=context.object.name
230 if asset is None:
231 self.report({"WARNING"}, "No animation data found to create asset from")
232 return {"CANCELLED"}
234 filepath = self.save_datablock(asset)
236 context.window_manager.clipboard = "%s%s" % (
237 self.CLIPBOARD_ASSET_MARKER,
238 filepath,
240 asset_browser.tag_redraw(context.screen)
241 self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
243 # The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
244 asset.asset_clear()
245 asset.use_fake_user = False
247 # The asset can be removed from the main DB, as it was purely created to
248 # be stored to disk, and not to be used in this file.
249 if asset.users > 0:
250 # This should never happen, and indicates a bug in the code. Having a warning about it is nice,
251 # but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
252 self.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
254 bpy.data.actions.remove(asset)
255 return {"FINISHED"}
257 def save_datablock(self, action: Action) -> Path:
258 tempdir = Path(bpy.app.tempdir)
259 filepath = tempdir / "copied_asset.blend"
260 bpy.data.libraries.write(
261 str(filepath),
262 datablocks={action},
263 path_remap="NONE",
264 fake_user=True,
265 compress=True, # Single-datablock blend file, likely little need to diff.
267 return filepath
270 class POSELIB_OT_paste_asset(Operator):
271 bl_idname = "poselib.paste_asset"
272 bl_label = "Paste As New Asset"
273 bl_description = "Paste the Asset that was previously copied using Copy As Asset"
274 bl_options = {"REGISTER", "UNDO"}
276 @classmethod
277 def poll(cls, context: Context) -> bool:
278 if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
279 cls.poll_message_set("Current editor is not an asset browser")
280 return False
282 asset_lib_ref = context.space_data.params.asset_library_ref
283 if asset_lib_ref != 'LOCAL':
284 cls.poll_message_set("Asset Browser must be set to the Current File library")
285 return False
287 # Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
288 clipboard: str = context.window_manager.clipboard
289 if not clipboard:
290 cls.poll_message_set("Clipboard is empty")
291 return False
293 marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
294 if not clipboard.startswith(marker):
295 cls.poll_message_set("Clipboard does not contain an asset")
296 return False
298 return True
301 def execute(self, context: Context) -> Set[str]:
302 clipboard = context.window_manager.clipboard
303 marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
304 filepath = Path(clipboard[marker_len:])
306 assets = functions.load_assets_from(filepath)
307 if not assets:
308 self.report({"ERROR"}, "Did not find any assets on clipboard")
309 return {"CANCELLED"}
311 self.report({"INFO"}, "Pasted %d assets" % len(assets))
313 bpy.ops.asset.library_refresh()
315 asset_browser_area = asset_browser.area_from_context(context)
316 if not asset_browser_area:
317 return {"FINISHED"}
319 # Assign same catalog as in asset browser.
320 catalog_id = asset_browser.active_catalog_id(asset_browser_area)
321 for asset in assets:
322 asset.asset_data.catalog_id = catalog_id
323 asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
325 return {"FINISHED"}
328 class PoseAssetUser:
329 @classmethod
330 def poll(cls, context: Context) -> bool:
331 if not (
332 context.object
333 and context.object.mode == "POSE" # This condition may not be desired.
334 and context.asset_library_ref
335 and context.asset_file_handle
337 return False
338 return context.asset_file_handle.id_type == 'ACTION'
340 def execute(self, context: Context) -> Set[str]:
341 asset: FileSelectEntry = context.asset_file_handle
342 if asset.local_id:
343 return self.use_pose(context, asset.local_id)
344 return self._load_and_use_pose(context)
346 def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
347 # Implement in subclass.
348 pass
350 def _load_and_use_pose(self, context: Context) -> Set[str]:
351 asset_library_ref = context.asset_library_ref
352 asset = context.asset_file_handle
353 asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
355 if not asset_lib_path:
356 self.report( # type: ignore
357 {"ERROR"},
358 # TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
359 f"Selected asset {asset.name} could not be located inside the asset library",
361 return {"CANCELLED"}
362 if asset.id_type != 'ACTION':
363 self.report( # type: ignore
364 {"ERROR"},
365 f"Selected asset {asset.name} is not an Action",
367 return {"CANCELLED"}
369 with bpy.types.BlendData.temp_data() as temp_data:
370 with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
371 data_to.actions = [asset.name]
373 action: Action = data_to.actions[0]
374 return self.use_pose(context, action)
377 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator):
378 bl_idname = "poselib.pose_asset_select_bones"
379 bl_label = "Select Bones"
380 bl_description = "Select those bones that are used in this pose"
381 bl_options = {"REGISTER", "UNDO"}
383 select: BoolProperty(name="Select", default=True) # type: ignore
384 flipped: BoolProperty(name="Flipped", default=False) # type: ignore
386 def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
387 arm_object: Object = context.object
388 pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
389 verb = "Selected" if self.select else "Deselected"
390 self.report({"INFO"}, f"{verb} bones from {pose_asset.name}")
391 return {"FINISHED"}
393 @classmethod
394 def description(
395 cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones'
396 ) -> str:
397 if properties.select:
398 return cls.bl_description
399 return cls.bl_description.replace("Select", "Deselect")
402 # This operator takes the Window Manager's `poselib_flipped` property, and
403 # passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
404 # possible to bind a key to the operator and still have it respect the global
405 # "Flip Pose" checkbox.
406 class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
407 bl_idname = "poselib.blend_pose_asset_for_keymap"
408 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
410 _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
411 bl_label = _rna.name
412 bl_description = _rna.description
413 del _rna
415 @classmethod
416 def poll(cls, context: Context) -> bool:
417 return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
419 def execute(self, context: Context) -> Set[str]:
420 flipped = context.window_manager.poselib_flipped
421 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
423 def invoke(self, context: Context, event: Event) -> Set[str]:
424 flipped = context.window_manager.poselib_flipped
425 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped)
428 # This operator takes the Window Manager's `poselib_flipped` property, and
429 # passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
430 # possible to bind a key to the operator and still have it respect the global
431 # "Flip Pose" checkbox.
432 class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
433 bl_idname = "poselib.apply_pose_asset_for_keymap"
434 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
436 _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
437 bl_label = _rna.name
438 bl_description = _rna.description
439 del _rna
441 @classmethod
442 def poll(cls, context: Context) -> bool:
443 return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
445 def execute(self, context: Context) -> Set[str]:
446 flipped = context.window_manager.poselib_flipped
447 return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
450 class POSELIB_OT_convert_old_poselib(Operator):
451 bl_idname = "poselib.convert_old_poselib"
452 bl_label = "Convert Old-Style Pose Library"
453 bl_description = "Create a pose asset for each pose marker in the current action"
454 bl_options = {"REGISTER", "UNDO"}
456 @classmethod
457 def poll(cls, context: Context) -> bool:
458 action = context.object and context.object.animation_data and context.object.animation_data.action
459 if not action:
460 cls.poll_message_set("Active object has no Action")
461 return False
462 if not action.pose_markers:
463 cls.poll_message_set("Action %r is not a old-style pose library" % action.name)
464 return False
465 return True
467 def execute(self, context: Context) -> Set[str]:
468 from . import conversion
470 old_poselib = context.object.animation_data.action
471 new_actions = conversion.convert_old_poselib(old_poselib)
473 if not new_actions:
474 self.report({'ERROR'}, "Unable to convert to pose assets")
475 return {'CANCELLED'}
477 self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
478 return {'FINISHED'}
481 classes = (
482 ASSET_OT_assign_action,
483 POSELIB_OT_apply_pose_asset_for_keymap,
484 POSELIB_OT_blend_pose_asset_for_keymap,
485 POSELIB_OT_convert_old_poselib,
486 POSELIB_OT_copy_as_asset,
487 POSELIB_OT_create_pose_asset,
488 POSELIB_OT_paste_asset,
489 POSELIB_OT_pose_asset_select_bones,
490 POSELIB_OT_restore_previous_action,
493 register, unregister = bpy.utils.register_classes_factory(classes)