Cleanup: strip trailing space, remove BOM
[blender-addons.git] / pose_library / operators.py
blobc8c2c0700e467f2c86f40533fbd0dc874107b89d
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,
50 class PoseAssetCreator:
51 @classmethod
52 def poll(cls, context: Context) -> bool:
53 return bool(
54 # There must be an object.
55 context.object
56 # It must be in pose mode with selected bones.
57 and context.object.mode == "POSE"
58 and context.object.pose
59 and context.selected_pose_bones_from_active_object
63 class LocalPoseAssetUser:
64 @classmethod
65 def poll(cls, context: Context) -> bool:
66 return bool(
67 isinstance(getattr(context, "id", None), Action)
68 and context.object
69 and context.object.mode == "POSE" # This condition may not be desired.
73 class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator):
74 bl_idname = "poselib.create_pose_asset"
75 bl_label = "Create Pose Asset"
76 bl_description = (
77 "Create a new Action that contains the pose of the selected bones, and mark it as Asset"
79 bl_options = {"REGISTER", "UNDO"}
81 pose_name: StringProperty(name="Pose Name") # type: ignore
82 activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
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_for_category(
110 context.screen, "ANIMATION"
112 if not asset_browse_area:
113 return
115 # After creating an asset, the window manager has to process the
116 # notifiers before editors should be manipulated.
117 pose_creation.assign_tags_from_asset_browser(asset, asset_browse_area)
119 # Pass deferred=True, because we just created a new asset that isn't
120 # known to the Asset Browser space yet. That requires the processing of
121 # notifiers, which will only happen after this code has finished
122 # running.
123 asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
125 def _prevent_action_loss(self, object: Object) -> None:
126 """Mark the action with Fake User if necessary.
128 This is to prevent action loss when we reduce its reference counter by one.
131 if not object.animation_data:
132 return
134 action = object.animation_data.action
135 if not action:
136 return
138 if action.use_fake_user or action.users > 1:
139 # Removing one user won't GC it.
140 return
142 action.use_fake_user = True
143 self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
146 class POSELIB_OT_restore_previous_action(Operator):
147 bl_idname = "poselib.restore_previous_action"
148 bl_label = "Restore Previous Action"
149 bl_description = "Switch back to the previous Action, after creating a pose asset"
150 bl_options = {"REGISTER", "UNDO"}
152 @classmethod
153 def poll(cls, context: Context) -> bool:
154 return bool(
155 context.window_manager.poselib_previous_action
156 and context.object
157 and context.object.animation_data
158 and context.object.animation_data.action
159 and context.object.animation_data.action.asset_data is not None
162 def execute(self, context: Context) -> Set[str]:
163 # This is the Action that was just created with "Create Pose Asset".
164 # It has to be re-applied after switching to the previous action,
165 # to ensure the character keeps the same pose.
166 self.pose_action = context.object.animation_data.action
168 prev_action = context.window_manager.poselib_previous_action
169 context.object.animation_data.action = prev_action
170 context.window_manager.poselib_previous_action = None
172 # Wait a bit for the action assignment to be handled, before applying the pose.
173 wm = context.window_manager
174 self._timer = wm.event_timer_add(0.001, window=context.window)
175 wm.modal_handler_add(self)
177 return {'RUNNING_MODAL'}
179 def modal(self, context, event):
180 if event.type != 'TIMER':
181 return {'RUNNING_MODAL'}
183 wm = context.window_manager
184 wm.event_timer_remove(self._timer)
186 context.object.pose.apply_pose_from_action(self.pose_action)
187 return {'FINISHED'}
190 class ASSET_OT_assign_action(LocalPoseAssetUser, Operator):
191 bl_idname = "asset.assign_action"
192 bl_label = "Assign Action"
193 bl_description = "Set this pose Action as active Action on the active Object"
194 bl_options = {"REGISTER", "UNDO"}
196 def execute(self, context: Context) -> Set[str]:
197 context.object.animation_data_create().action = context.id
198 return {"FINISHED"}
201 class POSELIB_OT_copy_as_asset(PoseAssetCreator, Operator):
202 bl_idname = "poselib.copy_as_asset"
203 bl_label = "Copy Pose As Asset"
204 bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
205 bl_options = {"REGISTER"}
207 CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
209 def execute(self, context: Context) -> Set[str]:
210 asset = pose_creation.create_pose_asset_from_context(
211 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 functions.asset_clear(context, asset)
220 if asset.users > 0:
221 self.report({"ERROR"}, "Unexpected non-null user count for the asset")
222 return {"FINISHED"}
224 bpy.data.actions.remove(asset)
226 context.window_manager.clipboard = "%s%s" % (
227 self.CLIPBOARD_ASSET_MARKER,
228 filepath,
231 asset_browser.tag_redraw(context.screen)
232 self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
233 return {"FINISHED"}
235 def save_datablock(self, action: Action) -> Path:
236 tempdir = Path(bpy.app.tempdir)
237 filepath = tempdir / "copied_asset.blend"
238 bpy.data.libraries.write(
239 str(filepath),
240 datablocks={action},
241 path_remap="NONE",
242 fake_user=True,
243 compress=True, # Single-datablock blend file, likely little need to diff.
245 return filepath
248 class POSELIB_OT_paste_asset(Operator):
249 bl_idname = "poselib.paste_asset"
250 bl_label = "Paste As New Asset"
251 bl_description = "Paste the Asset that was previously copied using Copy As Asset"
252 bl_options = {"REGISTER", "UNDO"}
254 @classmethod
255 def poll(cls, context: Context) -> bool:
256 clipboard: str = context.window_manager.clipboard
257 if not clipboard:
258 return False
259 marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
260 return clipboard.startswith(marker)
262 def execute(self, context: Context) -> Set[str]:
263 clipboard = context.window_manager.clipboard
264 marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
265 filepath = Path(clipboard[marker_len:])
267 assets = functions.load_assets_from(filepath)
268 if not assets:
269 self.report({"ERROR"}, "Did not find any assets on clipboard")
270 return {"CANCELLED"}
272 self.report({"INFO"}, "Pasted %d assets" % len(assets))
274 bpy.ops.file.refresh()
275 asset_browser_area = asset_browser.area_from_context(context, 'ANIMATIONS')
276 if asset_browser_area:
277 asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
279 return {"FINISHED"}
282 class PoseAssetUser:
283 @classmethod
284 def poll(cls, context: Context) -> bool:
285 if not (
286 context.object
287 and context.object.mode == "POSE" # This condition may not be desired.
288 and context.asset_library_ref
289 and context.asset_file_handle
291 return False
292 return context.asset_file_handle.id_type == 'ACTION'
294 def execute(self, context: Context) -> Set[str]:
295 asset: FileSelectEntry = context.asset_file_handle
296 if asset.local_id:
297 return self.use_pose(context, asset.local_id)
298 return self._load_and_use_pose(context)
300 def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
301 # Implement in subclass.
302 pass
304 def _load_and_use_pose(self, context: Context) -> Set[str]:
305 asset_library_ref = context.asset_library_ref
306 asset = context.asset_file_handle
307 asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
309 if not asset_lib_path:
310 self.report( # type: ignore
311 {"ERROR"},
312 # TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
313 f"Selected asset {asset.name} could not be located inside the asset library",
315 return {"CANCELLED"}
316 if asset.id_type != 'ACTION':
317 self.report( # type: ignore
318 {"ERROR"},
319 f"Selected asset {asset.name} is not an Action",
321 return {"CANCELLED"}
323 with bpy.types.BlendData.temp_data() as temp_data:
324 with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
325 data_to.actions = [asset.name]
327 action: Action = data_to.actions[0]
328 return self.use_pose(context, action)
331 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator):
332 bl_idname = "poselib.pose_asset_select_bones"
333 bl_label = "Select Bones"
334 bl_description = "Select those bones that are used in this pose"
335 bl_options = {"REGISTER", "UNDO"}
337 select: BoolProperty(name="Select", default=True) # type: ignore
338 flipped: BoolProperty(name="Flipped", default=False) # type: ignore
340 def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
341 arm_object: Object = context.object
342 pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
343 verb = "Selected" if self.select else "Deselected"
344 self.report({"INFO"}, f"{verb} bones from {pose_asset.name}")
345 return {"FINISHED"}
347 @classmethod
348 def description(
349 cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones'
350 ) -> str:
351 if properties.select:
352 return cls.bl_description
353 return cls.bl_description.replace("Select", "Deselect")
356 class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
357 bl_idname = "poselib.blend_pose_asset_for_keymap"
358 bl_options = {"REGISTER", "UNDO"}
360 _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
361 bl_label = _rna.name
362 bl_description = _rna.description
363 del _rna
365 @classmethod
366 def poll(cls, context: Context) -> bool:
367 return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
369 def execute(self, context: Context) -> Set[str]:
370 flipped = context.window_manager.poselib_flipped
371 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
373 def invoke(self, context: Context, event: Event) -> Set[str]:
374 flipped = context.window_manager.poselib_flipped
375 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped)
378 class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
379 bl_idname = "poselib.apply_pose_asset_for_keymap"
380 bl_options = {"REGISTER", "UNDO"}
382 _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
383 bl_label = _rna.name
384 bl_description = _rna.description
385 del _rna
387 @classmethod
388 def poll(cls, context: Context) -> bool:
389 return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
391 def execute(self, context: Context) -> Set[str]:
392 flipped = context.window_manager.poselib_flipped
393 return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
396 class POSELIB_OT_convert_old_poselib(Operator):
397 bl_idname = "poselib.convert_old_poselib"
398 bl_label = "Convert Old-Style Pose Library"
399 bl_description = "Create a pose asset for each pose marker in the current action"
400 bl_options = {"REGISTER", "UNDO"}
402 @classmethod
403 def poll(cls, context: Context) -> bool:
404 action = context.object and context.object.animation_data and context.object.animation_data.action
405 if not action:
406 cls.poll_message_set("Active object has no Action")
407 return False
408 if not action.pose_markers:
409 cls.poll_message_set("Action %r is not a old-style pose library" % action.name)
410 return False
411 return True
413 def execute(self, context: Context) -> Set[str]:
414 from . import conversion
416 old_poselib = context.object.animation_data.action
417 new_actions = conversion.convert_old_poselib(old_poselib)
419 if not new_actions:
420 self.report({'ERROR'}, "Unable to convert to pose assets")
421 return {'CANCELLED'}
423 self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
424 return {'FINISHED'}
427 classes = (
428 ASSET_OT_assign_action,
429 POSELIB_OT_apply_pose_asset_for_keymap,
430 POSELIB_OT_blend_pose_asset_for_keymap,
431 POSELIB_OT_convert_old_poselib,
432 POSELIB_OT_copy_as_asset,
433 POSELIB_OT_create_pose_asset,
434 POSELIB_OT_paste_asset,
435 POSELIB_OT_pose_asset_select_bones,
436 POSELIB_OT_restore_previous_action,
439 register, unregister = bpy.utils.register_classes_factory(classes)