Fix T98039: Node Wrangler node preview no longer working
[blender-addons.git] / pose_library / operators.py
bloba1cccd2c4aeccaeee37ec9b6e51e8f8e430a2024
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
35 class PoseAssetCreator:
36 @classmethod
37 def poll(cls, context: Context) -> bool:
38 return bool(
39 # There must be an object.
40 context.object
41 # It must be in pose mode with selected bones.
42 and context.object.mode == "POSE"
43 and context.object.pose
44 and context.selected_pose_bones_from_active_object
48 class LocalPoseAssetUser:
49 @classmethod
50 def poll(cls, context: Context) -> bool:
51 return bool(
52 isinstance(getattr(context, "id", None), Action)
53 and context.object
54 and context.object.mode == "POSE" # This condition may not be desired.
58 class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator):
59 bl_idname = "poselib.create_pose_asset"
60 bl_label = "Create Pose Asset"
61 bl_description = (
62 "Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
63 "The asset will be stored in the current blend file"
65 bl_options = {"REGISTER", "UNDO"}
67 pose_name: StringProperty(name="Pose Name") # type: ignore
68 activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
71 @classmethod
72 def poll(cls, context: Context) -> bool:
73 # Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
74 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
75 if not asset_browse_area:
76 # No asset browser is visible, so there also aren't any expectations
77 # that this asset will be visible.
78 return True
80 asset_space_params = asset_browser.params(asset_browse_area)
81 if asset_space_params.asset_library_ref != 'LOCAL':
82 cls.poll_message_set("Asset Browser must be set to the Current File library")
83 return False
85 return True
87 def execute(self, context: Context) -> Set[str]:
88 pose_name = self.pose_name or context.object.name
89 asset = pose_creation.create_pose_asset_from_context(context, pose_name)
90 if not asset:
91 self.report({"WARNING"}, "No keyframes were found for this pose")
92 return {"CANCELLED"}
94 if self.activate_new_action:
95 self._set_active_action(context, asset)
96 self._activate_asset_in_browser(context, asset)
97 return {'FINISHED'}
99 def _set_active_action(self, context: Context, asset: Action) -> None:
100 self._prevent_action_loss(context.object)
102 anim_data = context.object.animation_data_create()
103 context.window_manager.poselib_previous_action = anim_data.action
104 anim_data.action = asset
106 def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
107 """Activate the new asset in the appropriate Asset Browser.
109 This makes it possible to immediately check & edit the created pose asset.
112 asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
113 if not asset_browse_area:
114 return
116 # After creating an asset, the window manager has to process the
117 # notifiers before editors should be manipulated.
118 pose_creation.assign_from_asset_browser(asset, asset_browse_area)
120 # Pass deferred=True, because we just created a new asset that isn't
121 # known to the Asset Browser space yet. That requires the processing of
122 # notifiers, which will only happen after this code has finished
123 # running.
124 asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
126 def _prevent_action_loss(self, object: Object) -> None:
127 """Mark the action with Fake User if necessary.
129 This is to prevent action loss when we reduce its reference counter by one.
132 if not object.animation_data:
133 return
135 action = object.animation_data.action
136 if not action:
137 return
139 if action.use_fake_user or action.users > 1:
140 # Removing one user won't GC it.
141 return
143 action.use_fake_user = True
144 self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
147 class POSELIB_OT_restore_previous_action(Operator):
148 bl_idname = "poselib.restore_previous_action"
149 bl_label = "Restore Previous Action"
150 bl_description = "Switch back to the previous Action, after creating a pose asset"
151 bl_options = {"REGISTER", "UNDO"}
153 @classmethod
154 def poll(cls, context: Context) -> bool:
155 return bool(
156 context.window_manager.poselib_previous_action
157 and context.object
158 and context.object.animation_data
159 and context.object.animation_data.action
160 and context.object.animation_data.action.asset_data is not None
163 def execute(self, context: Context) -> Set[str]:
164 # This is the Action that was just created with "Create Pose Asset".
165 # It has to be re-applied after switching to the previous action,
166 # to ensure the character keeps the same pose.
167 self.pose_action = context.object.animation_data.action
169 prev_action = context.window_manager.poselib_previous_action
170 context.object.animation_data.action = prev_action
171 context.window_manager.poselib_previous_action = None
173 # Wait a bit for the action assignment to be handled, before applying the pose.
174 wm = context.window_manager
175 self._timer = wm.event_timer_add(0.001, window=context.window)
176 wm.modal_handler_add(self)
178 return {'RUNNING_MODAL'}
180 def modal(self, context, event):
181 if event.type != 'TIMER':
182 return {'RUNNING_MODAL'}
184 wm = context.window_manager
185 wm.event_timer_remove(self._timer)
187 context.object.pose.apply_pose_from_action(self.pose_action)
188 return {'FINISHED'}
191 class ASSET_OT_assign_action(LocalPoseAssetUser, Operator):
192 bl_idname = "asset.assign_action"
193 bl_label = "Assign Action"
194 bl_description = "Set this pose Action as active Action on the active Object"
195 bl_options = {"REGISTER", "UNDO"}
197 def execute(self, context: Context) -> Set[str]:
198 context.object.animation_data_create().action = context.id
199 return {"FINISHED"}
202 class POSELIB_OT_copy_as_asset(PoseAssetCreator, Operator):
203 bl_idname = "poselib.copy_as_asset"
204 bl_label = "Copy Pose As Asset"
205 bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
206 bl_options = {"REGISTER"}
208 CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
210 def execute(self, context: Context) -> Set[str]:
211 asset = pose_creation.create_pose_asset_from_context(
212 context, new_asset_name=context.object.name
214 if asset is None:
215 self.report({"WARNING"}, "No animation data found to create asset from")
216 return {"CANCELLED"}
218 filepath = self.save_datablock(asset)
220 context.window_manager.clipboard = "%s%s" % (
221 self.CLIPBOARD_ASSET_MARKER,
222 filepath,
224 asset_browser.tag_redraw(context.screen)
225 self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
227 # The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
228 asset.asset_clear()
229 asset.use_fake_user = False
231 # The asset can be removed from the main DB, as it was purely created to
232 # be stored to disk, and not to be used in this file.
233 if asset.users > 0:
234 # This should never happen, and indicates a bug in the code. Having a warning about it is nice,
235 # but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
236 self.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
238 bpy.data.actions.remove(asset)
239 return {"FINISHED"}
241 def save_datablock(self, action: Action) -> Path:
242 tempdir = Path(bpy.app.tempdir)
243 filepath = tempdir / "copied_asset.blend"
244 bpy.data.libraries.write(
245 str(filepath),
246 datablocks={action},
247 path_remap="NONE",
248 fake_user=True,
249 compress=True, # Single-datablock blend file, likely little need to diff.
251 return filepath
254 class POSELIB_OT_paste_asset(Operator):
255 bl_idname = "poselib.paste_asset"
256 bl_label = "Paste As New Asset"
257 bl_description = "Paste the Asset that was previously copied using Copy As Asset"
258 bl_options = {"REGISTER", "UNDO"}
260 @classmethod
261 def poll(cls, context: Context) -> bool:
262 if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
263 cls.poll_message_set("Current editor is not an asset browser")
264 return False
266 asset_lib_ref = context.space_data.params.asset_library_ref
267 if asset_lib_ref != 'LOCAL':
268 cls.poll_message_set("Asset Browser must be set to the Current File library")
269 return False
271 # Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
272 clipboard: str = context.window_manager.clipboard
273 if not clipboard:
274 cls.poll_message_set("Clipboard is empty")
275 return False
277 marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
278 if not clipboard.startswith(marker):
279 cls.poll_message_set("Clipboard does not contain an asset")
280 return False
282 return True
285 def execute(self, context: Context) -> Set[str]:
286 clipboard = context.window_manager.clipboard
287 marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
288 filepath = Path(clipboard[marker_len:])
290 assets = functions.load_assets_from(filepath)
291 if not assets:
292 self.report({"ERROR"}, "Did not find any assets on clipboard")
293 return {"CANCELLED"}
295 self.report({"INFO"}, "Pasted %d assets" % len(assets))
297 bpy.ops.asset.library_refresh()
299 asset_browser_area = asset_browser.area_from_context(context)
300 if not asset_browser_area:
301 return {"FINISHED"}
303 # Assign same catalog as in asset browser.
304 catalog_id = asset_browser.active_catalog_id(asset_browser_area)
305 for asset in assets:
306 asset.asset_data.catalog_id = catalog_id
307 asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
309 return {"FINISHED"}
312 class PoseAssetUser:
313 @classmethod
314 def poll(cls, context: Context) -> bool:
315 if not (
316 context.object
317 and context.object.mode == "POSE" # This condition may not be desired.
318 and context.asset_library_ref
319 and context.asset_file_handle
321 return False
322 return context.asset_file_handle.id_type == 'ACTION'
324 def execute(self, context: Context) -> Set[str]:
325 asset: FileSelectEntry = context.asset_file_handle
326 if asset.local_id:
327 return self.use_pose(context, asset.local_id)
328 return self._load_and_use_pose(context)
330 def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
331 # Implement in subclass.
332 pass
334 def _load_and_use_pose(self, context: Context) -> Set[str]:
335 asset_library_ref = context.asset_library_ref
336 asset = context.asset_file_handle
337 asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
339 if not asset_lib_path:
340 self.report( # type: ignore
341 {"ERROR"},
342 # TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
343 f"Selected asset {asset.name} could not be located inside the asset library",
345 return {"CANCELLED"}
346 if asset.id_type != 'ACTION':
347 self.report( # type: ignore
348 {"ERROR"},
349 f"Selected asset {asset.name} is not an Action",
351 return {"CANCELLED"}
353 with bpy.types.BlendData.temp_data() as temp_data:
354 with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
355 data_to.actions = [asset.name]
357 action: Action = data_to.actions[0]
358 return self.use_pose(context, action)
361 class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator):
362 bl_idname = "poselib.pose_asset_select_bones"
363 bl_label = "Select Bones"
364 bl_description = "Select those bones that are used in this pose"
365 bl_options = {"REGISTER", "UNDO"}
367 select: BoolProperty(name="Select", default=True) # type: ignore
368 flipped: BoolProperty(name="Flipped", default=False) # type: ignore
370 def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
371 arm_object: Object = context.object
372 pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
373 verb = "Selected" if self.select else "Deselected"
374 self.report({"INFO"}, f"{verb} bones from {pose_asset.name}")
375 return {"FINISHED"}
377 @classmethod
378 def description(
379 cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones'
380 ) -> str:
381 if properties.select:
382 return cls.bl_description
383 return cls.bl_description.replace("Select", "Deselect")
386 # This operator takes the Window Manager's `poselib_flipped` property, and
387 # passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
388 # possible to bind a key to the operator and still have it respect the global
389 # "Flip Pose" checkbox.
390 class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
391 bl_idname = "poselib.blend_pose_asset_for_keymap"
392 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
394 _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
395 bl_label = _rna.name
396 bl_description = _rna.description
397 del _rna
399 @classmethod
400 def poll(cls, context: Context) -> bool:
401 return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
403 def execute(self, context: Context) -> Set[str]:
404 flipped = context.window_manager.poselib_flipped
405 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
407 def invoke(self, context: Context, event: Event) -> Set[str]:
408 flipped = context.window_manager.poselib_flipped
409 return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped)
412 # This operator takes the Window Manager's `poselib_flipped` property, and
413 # passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
414 # possible to bind a key to the operator and still have it respect the global
415 # "Flip Pose" checkbox.
416 class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
417 bl_idname = "poselib.apply_pose_asset_for_keymap"
418 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
420 _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
421 bl_label = _rna.name
422 bl_description = _rna.description
423 del _rna
425 @classmethod
426 def poll(cls, context: Context) -> bool:
427 if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
428 return False
429 return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
431 def execute(self, context: Context) -> Set[str]:
432 flipped = context.window_manager.poselib_flipped
433 return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
436 class POSELIB_OT_convert_old_poselib(Operator):
437 bl_idname = "poselib.convert_old_poselib"
438 bl_label = "Convert Legacy Pose Library"
439 bl_description = "Create a pose asset for each pose marker in the current action"
440 bl_options = {"REGISTER", "UNDO"}
442 @classmethod
443 def poll(cls, context: Context) -> bool:
444 action = context.object and context.object.animation_data and context.object.animation_data.action
445 if not action:
446 cls.poll_message_set("Active object has no Action")
447 return False
448 if not action.pose_markers:
449 cls.poll_message_set("Action %r is not a legacy pose library" % action.name)
450 return False
451 return True
453 def execute(self, context: Context) -> Set[str]:
454 from . import conversion
456 old_poselib = context.object.animation_data.action
457 new_actions = conversion.convert_old_poselib(old_poselib)
459 if not new_actions:
460 self.report({'ERROR'}, "Unable to convert to pose assets")
461 return {'CANCELLED'}
463 self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
464 return {'FINISHED'}
467 class POSELIB_OT_convert_old_object_poselib(Operator):
468 bl_idname = "poselib.convert_old_object_poselib"
469 bl_label = "Convert Legacy Pose Library"
470 bl_description = "Create a pose asset for each pose marker in this legacy pose library data-block"
472 # Mark this one as "internal", as it converts `context.object.pose_library`
473 # instead of its current animation Action.
474 bl_options = {"REGISTER", "UNDO", "INTERNAL"}
476 @classmethod
477 def poll(cls, context: Context) -> bool:
478 action = context.object and context.object.pose_library
479 if not action:
480 cls.poll_message_set("Active object has no pose library Action")
481 return False
482 if not action.pose_markers:
483 cls.poll_message_set("Action %r is not a legacy pose library" % action.name)
484 return False
485 return True
487 def execute(self, context: Context) -> Set[str]:
488 from . import conversion
490 old_poselib = context.object.pose_library
491 new_actions = conversion.convert_old_poselib(old_poselib)
493 if not new_actions:
494 self.report({'ERROR'}, "Unable to convert to pose assets")
495 return {'CANCELLED'}
497 self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
498 return {'FINISHED'}
501 classes = (
502 ASSET_OT_assign_action,
503 POSELIB_OT_apply_pose_asset_for_keymap,
504 POSELIB_OT_blend_pose_asset_for_keymap,
505 POSELIB_OT_convert_old_poselib,
506 POSELIB_OT_convert_old_object_poselib,
507 POSELIB_OT_copy_as_asset,
508 POSELIB_OT_create_pose_asset,
509 POSELIB_OT_paste_asset,
510 POSELIB_OT_pose_asset_select_bones,
511 POSELIB_OT_restore_previous_action,
514 register, unregister = bpy.utils.register_classes_factory(classes)