diff --git a/korman/exporter/__init__.py b/korman/exporter/__init__.py
index 8623197..3eb954d 100644
--- a/korman/exporter/__init__.py
+++ b/korman/exporter/__init__.py
@@ -19,3 +19,4 @@ from PyHSPlasma import *
from .convert import *
from .explosions import *
+from . import utils
diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
new file mode 100644
index 0000000..bdf3781
--- /dev/null
+++ b/korman/exporter/animation.py
@@ -0,0 +1,261 @@
+# This file is part of Korman.
+#
+# Korman is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Korman is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Korman. If not, see .
+
+import bpy
+import math
+import mathutils
+from PyHSPlasma import *
+import weakref
+
+from . import utils
+
+class AnimationConverter:
+ def __init__(self, exporter):
+ self._exporter = weakref.ref(exporter)
+ self._bl_fps = bpy.context.scene.render.fps
+
+ def convert_action2tm(self, action, default_xform):
+ """Converts a Blender Action to a plCompoundController."""
+ fcurves = action.fcurves
+ if not fcurves:
+ return None
+
+ # NOTE: plCompoundController is from Myst 5 and was backported to MOUL.
+ # Worry not however... libHSPlasma will do the conversion for us.
+ tm = plCompoundController()
+ tm.X = self.make_pos_controller(fcurves, default_xform)
+ tm.Y = self.make_rot_controller(fcurves, default_xform)
+ tm.Z = self.make_scale_controller(fcurves, default_xform)
+ return tm
+
+ def make_pos_controller(self, fcurves, default_xform):
+ pos_curves = (i for i in fcurves if i.data_path == "location" and i.keyframe_points)
+ keyframes, bez_chans = self._process_keyframes(pos_curves)
+ if not keyframes:
+ return None
+
+ # At one point, I had some... insanity here to try to crush bezier channels and hand off to
+ # blah blah blah... As it turns out, point3 keyframe's tangents are vector3s :)
+ ctrl = self._make_point3_controller(keyframes, bez_chans, default_xform.to_translation())
+ return ctrl
+
+ def make_rot_controller(self, fcurves, default_xform):
+ # TODO: support rotation_quaternion
+ rot_curves = (i for i in fcurves if i.data_path == "rotation_euler" and i.keyframe_points)
+ keyframes, bez_chans = self._process_keyframes(rot_curves)
+ if not keyframes:
+ return None
+
+ # Ugh. Unfortunately, it appears Blender's default interpolation is bezier. So who knows if
+ # many users will actually see the benefit here? Makes me sad.
+ if bez_chans:
+ ctrl = self._make_scalar_controller(keyframes, bez_chans, default_xform.to_euler("XYZ"))
+ else:
+ ctrl = self._make_quat_controller(keyframes, default_xform.to_euler("XYZ"))
+ return ctrl
+
+ def make_scale_controller(self, fcurves, default_xform):
+ scale_curves = (i for i in fcurves if i.data_path == "scale" and i.keyframe_points)
+ keyframes, bez_chans = self._process_keyframes(scale_curves)
+ if not keyframes:
+ return None
+
+ # There is no such thing as a compound scale controller... in Plasma, anyway.
+ ctrl = self._make_scale_value_controller(keyframes, bez_chans, default_xform)
+ return ctrl
+
+ def _make_point3_controller(self, keyframes, bezier, default_xform):
+ ctrl = plLeafController()
+ subctrls = ("X", "Y", "Z")
+ keyframe_type = hsKeyFrame.kBezPoint3KeyFrame if bezier else hsKeyFrame.kPoint3KeyFrame
+ exported_frames = []
+ last_xform = [default_xform[0], default_xform[1], default_xform[2]]
+
+ for keyframe in keyframes:
+ exported = hsPoint3Key()
+ exported.frame = keyframe.frame_num
+ exported.frameTime = keyframe.frame_time
+ exported.type = keyframe_type
+
+ in_tan = hsVector3()
+ out_tan = hsVector3()
+ value = hsVector3()
+ for i, subctrl in enumerate(subctrls):
+ fkey = keyframe.values.get(i, None)
+ if fkey is not None:
+ v = fkey.co[1]
+ last_xform[i] = v
+ setattr(value, subctrl, v)
+ setattr(in_tan, subctrl, keyframe.in_tans[i])
+ setattr(out_tan, subctrl, keyframe.out_tans[i])
+ else:
+ setattr(value, subctrl, last_xform[i])
+ setattr(in_tan, subctrl, 0.0)
+ setattr(out_tan, subctrl, 0.0)
+ exported.inTan = in_tan
+ exported.outTan = out_tan
+ exported.value = value
+ exported_frames.append(exported)
+ ctrl.keys = (exported_frames, keyframe_type)
+ return ctrl
+
+ def _make_quat_controller(self, keyframes, default_xform):
+ ctrl = plLeafController()
+ keyframe_type = hsKeyFrame.kQuatKeyFrame
+ exported_frames = []
+ last_xform = [default_xform[0], default_xform[1], default_xform[2]]
+
+ for keyframe in keyframes:
+ exported = hsQuatKey()
+ exported.frame = keyframe.frame_num
+ exported.frameTime = keyframe.frame_time
+ exported.type = keyframe_type
+ # NOTE: quat keyframes don't do bezier nonsense
+
+ value = mathutils.Euler(last_xform, default_xform.order)
+ for i in range(3):
+ fkey = keyframe.values.get(i, None)
+ if fkey is not None:
+ v = fkey.co[1]
+ last_xform[i] = v
+ value[i] = v
+ quat = value.to_quaternion()
+ exported.value = utils.quaternion(quat)
+ exported_frames.append(exported)
+ ctrl.keys = (exported_frames, keyframe_type)
+ return ctrl
+
+ def _make_scalar_controller(self, keyframes, bez_chans, default_xform):
+ ctrl = plCompoundController()
+ subctrls = ("X", "Y", "Z")
+ for i in subctrls:
+ setattr(ctrl, i, plLeafController())
+ exported_frames = ([], [], [])
+
+ for keyframe in keyframes:
+ for i, subctrl in enumerate(subctrls):
+ fkey = keyframe.values.get(i, None)
+ if fkey is not None:
+ keyframe_type = hsKeyFrame.kBezScalarKeyFrame if i in bez_chans else hsKeyFrame.kScalarKeyFrame
+ exported = hsScalarKey()
+ exported.frame = keyframe.frame_num
+ exported.frameTime = keyframe.frame_time
+ exported.inTan = keyframe.in_tans[i]
+ exported.outTan = keyframe.out_tans[i]
+ exported.type = keyframe_type
+ exported.value = fkey.co[1]
+ exported_frames[i].append(exported)
+ for i, subctrl in enumerate(subctrls):
+ my_keyframes = exported_frames[i]
+
+ # ensure this controller has at least ONE keyframe
+ if not my_keyframes:
+ hack_frame = hsScalarKey()
+ hack_frame.frame = 0
+ hack_frame.frameTime = 0.0
+ hack_frame.type = hsKeyFrame.kScalarKeyFrame
+ hack_frame.value = default_xform[i]
+ my_keyframes.append(hack_frame)
+ getattr(ctrl, subctrl).keys = (my_keyframes, my_keyframes[0].type)
+ return ctrl
+
+ def _make_scale_value_controller(self, keyframes, bez_chans, default_xform):
+ subctrls = ("X", "Y", "Z")
+ keyframe_type = hsKeyFrame.kBezScaleKeyFrame if bez_chans else hsKeyFrame.kScaleKeyFrame
+ exported_frames = []
+
+ _scale = default_xform.to_scale()
+ last_xform = [_scale[0], _scale[1], _scale[2]]
+ unit_quat = default_xform.to_quaternion()
+ unit_quat.normalize()
+ unit_quat = utils.quaternion(unit_quat)
+
+ for keyframe in keyframes:
+ exported = hsScaleKey()
+ exported.frame = keyframe.frame_num
+ exported.frameTime = keyframe.frame_time
+ exported.type = keyframe_type
+
+ in_tan = hsVector3()
+ out_tan = hsVector3()
+ value = hsVector3()
+ for i, subctrl in enumerate(subctrls):
+ fkey = keyframe.values.get(i, None)
+ if fkey is not None:
+ v = fkey.co[1]
+ last_xform[i] = v
+ setattr(value, subctrl, v)
+ setattr(in_tan, subctrl, keyframe.in_tans[i])
+ setattr(out_tan, subctrl, keyframe.out_tans[i])
+ else:
+ setattr(value, subctrl, last_xform[i])
+ setattr(in_tan, subctrl, 0.0)
+ setattr(out_tan, subctrl, 0.0)
+ exported.inTan = in_tan
+ exported.outTan = out_tan
+ exported.value = (value, unit_quat)
+ exported_frames.append(exported)
+
+ ctrl = plLeafController()
+ ctrl.keys = (exported_frames, keyframe_type)
+ return ctrl
+
+ def _process_keyframes(self, fcurves):
+ """Groups all FCurves for the same frame together"""
+ keyframe_data = type("KeyFrameData", (), {})
+ fps = self._bl_fps
+ pi = math.pi
+
+ keyframes = {}
+ bez_chans = set()
+ for fcurve in fcurves:
+ fcurve.update()
+ for fkey in fcurve.keyframe_points:
+ frame_num, value = fkey.co
+ if fps == 30.0:
+ # hope you don't have a frame 29.9 and frame 30.0...
+ frame_num = int(frame_num)
+ else:
+ frame_num = int(frame_num * (30.0 / fps))
+ keyframe = keyframes.get(frame_num, None)
+ if keyframe is None:
+ keyframe = keyframe_data()
+ keyframe.frame_num = frame_num
+ keyframe.frame_time = frame_num / fps
+ keyframe.in_tans = {}
+ keyframe.out_tans = {}
+ keyframe.values = {}
+ keyframes[frame_num] = keyframe
+ idx = fcurve.array_index
+ keyframe.values[idx] = fkey
+
+ # Calculate the bezier interpolation nonsense
+ if fkey.interpolation == "BEZIER":
+ og_frame = fkey.co[0]
+ keyframe.in_tans[idx] = -(value - fkey.handle_left[1]) / (og_frame - fkey.handle_left[0]) / fps / (2 * pi)
+ keyframe.out_tans[idx] = (value - fkey.handle_right[1]) / (og_frame - fkey.handle_right[0]) / fps / (2 * pi)
+ else:
+ keyframe.in_tans[idx] = 0.0
+ keyframe.out_tans[idx] = 0.0
+ if keyframe.in_tans[idx] != 0.0 or keyframe.out_tans[idx] != 0.0:
+ bez_chans.add(idx)
+
+ # Return the keyframes in a sequence sorted by frame number
+ final_keyframes = [keyframes[i] for i in sorted(keyframes)]
+ return (final_keyframes, bez_chans)
+
+ @property
+ def _mgr(self):
+ return self._exporter().mgr
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index 5a907d0..94e8f96 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -18,6 +18,7 @@ import os.path
from PyHSPlasma import *
import time
+from . import animation
from . import explosions
from . import logger
from . import manager
@@ -47,6 +48,7 @@ class Exporter:
self.report = logger.ExportAnalysis()
self.physics = physics.PhysicsConverter(self)
self.light = rtlight.LightConverter(self)
+ self.animation = animation.AnimationConverter(self)
self.sumfile = sumfile.SumFile()
# Step 1: Create the age info and the pages
diff --git a/korman/operators/op_modifier.py b/korman/operators/op_modifier.py
index 6c4e882..bd774f7 100644
--- a/korman/operators/op_modifier.py
+++ b/korman/operators/op_modifier.py
@@ -148,3 +148,44 @@ class ModifierLogicWizOperator(ModifierOperator, bpy.types.Operator):
end = time.process_time()
print("\nLogicWiz finished in {:.2f} seconds".format(end-start))
return {"FINISHED"}
+
+
+class ModifierCollectionAddOperator(ModifierOperator, bpy.types.Operator):
+ bl_idname = "object.plasma_modifier_collection_add"
+ bl_label = "Add Item"
+ bl_description = "Adds an item to the collection"
+
+ modifier = StringProperty(name="Modifier", description="Attribute name of the Plasma Modifier")
+ collection = StringProperty(name="Collection", description="Attribute name of the collection property")
+ name_prefix = StringProperty(name="Name Prefix", description="Prefix for autogenerated item names", default="Item")
+ name_prop = StringProperty(name="Name Property", description="Attribute name of the item name property")
+
+ def execute(self, context):
+ obj = context.active_object
+ mod = getattr(obj.plasma_modifiers, self.modifier)
+ collection = getattr(mod, self.collection)
+ idx = len(collection)
+ collection.add()
+ if self.name_prop:
+ setattr(collection[idx], self.name_prop, "{} {}".format(self.name_prefix, idx+1))
+ return {"FINISHED"}
+
+
+class ModifierCollectionRemoveOperator(ModifierOperator, bpy.types.Operator):
+ bl_idname = "object.plasma_modifier_collection_remove"
+ bl_label = "Remove Item"
+ bl_description = "Removes an item from the collection"
+
+ modifier = StringProperty(name="Modifier", description="Attribute name of the Plasma Modifier")
+ collection = StringProperty(name="Collection", description="Attribute name of the collection property")
+ index = IntProperty(name="Index", description="Item index to remove")
+
+ def execute(self, context):
+ obj = context.active_object
+ mod = getattr(obj.plasma_modifiers, self.modifier)
+ collection = getattr(mod, self.collection)
+ if len(collection) > self.index:
+ collection.remove(self.index)
+ return {"FINISHED"}
+ else:
+ return {"CANCELLED"}
diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py
index 25897be..aa3c739 100644
--- a/korman/properties/modifiers/__init__.py
+++ b/korman/properties/modifiers/__init__.py
@@ -17,6 +17,7 @@ import bpy
import inspect
from .base import PlasmaModifierProperties
+from .anim import *
from .avatar import *
from .logic import *
from .physics import *
diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py
new file mode 100644
index 0000000..8475085
--- /dev/null
+++ b/korman/properties/modifiers/anim.py
@@ -0,0 +1,175 @@
+# This file is part of Korman.
+#
+# Korman is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Korman is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Korman. If not, see .
+
+import bpy
+from bpy.props import *
+from PyHSPlasma import *
+
+from .base import PlasmaModifierProperties
+from ...exporter import ExportError, utils
+
+def _convert_frame_time(frame_num):
+ fps = bpy.context.scene.render.fps
+ return frame_num / fps
+
+def _get_blender_action(bo):
+ if bo.animation_data is None or bo.animation_data.action is None:
+ raise ExportError("Object '{}' has no Action to export".format(bo.name))
+ return bo.animation_data.action
+
+class PlasmaAnimationModifier(PlasmaModifierProperties):
+ pl_id = "animation"
+
+ bl_category = "Animation"
+ bl_label = "Animation"
+ bl_description = "Object animation"
+ bl_icon = "ACTION"
+
+ auto_start = BoolProperty(name="Auto Start",
+ description="Automatically start this animation on link-in",
+ default=True)
+ loop = BoolProperty(name="Loop Anim",
+ description="Loop the animation",
+ default=True)
+
+ initial_marker = StringProperty(name="Start Marker",
+ description="Marker indicating the default start point")
+ loop_start = StringProperty(name="Loop Start",
+ description="Marker indicating where the default loop begins")
+ loop_end = StringProperty(name="Loop End",
+ description="Marker indicating where the default loop ends")
+
+ def created(self, obj):
+ self.display_name = "{}_(Entire Animation)".format(obj.name)
+
+ @property
+ def requires_actor(self):
+ return True
+
+ def export(self, exporter, bo, so):
+ action = _get_blender_action(bo)
+ markers = action.pose_markers
+
+ atcanim = exporter.mgr.find_create_key(plATCAnim, so=so, name=self.display_name).object
+ atcanim.autoStart = self.auto_start
+ atcanim.loop = self.loop
+ atcanim.name = "(Entire Animation)"
+ atcanim.start = 0.0
+ atcanim.end = _convert_frame_time(action.frame_range[1])
+
+ # Simple start and loop info
+ initial_marker = markers.get(self.initial_marker)
+ if initial_marker is not None:
+ atcanim.initial = _convert_fame_time(initial_marker.frame)
+ else:
+ atcanim.initial = -1.0
+ if self.loop:
+ loop_start = markers.get(self.loop_start)
+ if loop_start is not None:
+ atcanim.loopStart = _convert_frame_time(loop_start.frame)
+ else:
+ atcanim.loopStart = 0.0
+ loop_end = markers.get(self.loop_end)
+ if loop_end is not None:
+ atcanim.loopEnd = _convert_frame_time(loop_end.frame)
+ else:
+ atcanim.loopEnd = _convert_frame_time(action.frame_range[1])
+
+ # Marker points
+ for marker in markers:
+ atcanim.setMarker(marker.name, _convert_frame_time(marker.frame))
+
+ # Fixme? Not sure if we really need to expose this...
+ atcanim.easeInMin = 1.0
+ atcanim.easeInMax = 1.0
+ atcanim.easeInLength = 1.0
+ atcanim.easeOutMin = 1.0
+ atcanim.easeOutMax = 1.0
+ atcanim.easeOutLength = 1.0
+
+ # Now for the animation data. We're mostly just going to hand this off to the controller code
+ matrix = bo.matrix_basis
+ applicator = plMatrixChannelApplicator()
+ applicator.enabled = True
+ applicator.channelName = bo.name
+ channel = plMatrixControllerChannel()
+ channel.controller = exporter.animation.convert_action2tm(action, matrix)
+ applicator.channel = channel
+ atcanim.addApplicator(applicator)
+
+ # Decompose the matrix into the 90s-era 3ds max affine parts sillyness
+ # All that's missing now is something like "(c) 1998 HeadSpin" oh wait...
+ affine = hsAffineParts()
+ affine.T = utils.vector3(matrix.to_translation())
+ affine.K = utils.vector3(matrix.to_scale())
+ affine.F = -1.0 if matrix.determinant() < 0.0 else 1.0
+ rot = matrix.to_quaternion()
+ affine.Q = utils.quaternion(rot)
+ rot.normalize()
+ affine.U = utils.quaternion(rot)
+ channel.affine = affine
+
+ # We need both an AGModifier and an AGMasterMod
+ # TODO: grouped animations (eg one door, two objects)
+ agmod = exporter.mgr.add_object(plAGModifier, so=so, name=self.display_name)
+ agmod.channelName = bo.name
+ agmaster = exporter.mgr.add_object(plAGMasterMod, so=so, name=self.display_name)
+ agmaster.addPrivateAnim(atcanim.key)
+
+
+class LoopMarker(bpy.types.PropertyGroup):
+ loop_name = StringProperty(name="Loop Name",
+ description="Name of this loop")
+ loop_start = StringProperty(name="Loop Start",
+ description="Marker name from whence the loop begins")
+ loop_end = StringProperty(name="Loop End",
+ description="Marker name from whence the loop ends")
+
+
+class PlasmaAnimationLoopModifier(PlasmaModifierProperties):
+ pl_id = "animation_loop"
+
+ bl_category = "Animation"
+ bl_label = "Loop Markers"
+ bl_description = "Animation loop settings"
+ bl_icon = "PMARKER_SEL"
+
+ loops = CollectionProperty(name="Loops",
+ description="Loop points within the animation",
+ type=LoopMarker)
+ active_loop_index = IntProperty(options={"HIDDEN"})
+
+ def created(self, obj):
+ # who cares, this modifier creates no Keys...
+ self.display_name = "AnimLoops"
+
+ def export(self, exporter, bo, so):
+ action = _get_blender_action(bo)
+ markers = action.pose_markers
+
+ key_name = bo.plasma_modifiers.animation.display_name
+ atcanim = exporter.mgr.find_create_key(plATCAnim, so=so, name=key_name).object
+ for loop in self.loops:
+ start = markers.get(loop.loop_start)
+ end = markers.get(loop.loop_end)
+ if start is None:
+ exporter.report.warn("Animation '{}' Loop '{}': Marker '{}' not found. This loop will not be exported".format(
+ action.name, loop.loop_name, loop.loop_start), indent=2)
+ if end is None:
+ exporter.report.warn("Animation '{}' Loop '{}': Marker '{}' not found. This loop will not be exported".format(
+ action.name, loop.loop_name, loop.loop_end), indent=2)
+ if start is None or end is None:
+ continue
+ atcanim.setLoop(loop.loop_name, _convert_frame_time(start.frame), _convert_frame_time(end.frame))
diff --git a/korman/ui/modifiers/__init__.py b/korman/ui/modifiers/__init__.py
index 7dcf614..0a838df 100644
--- a/korman/ui/modifiers/__init__.py
+++ b/korman/ui/modifiers/__init__.py
@@ -13,6 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Korman. If not, see .
+from .anim import *
from .avatar import *
from .logic import *
from .physics import *
diff --git a/korman/ui/modifiers/anim.py b/korman/ui/modifiers/anim.py
new file mode 100644
index 0000000..198eed4
--- /dev/null
+++ b/korman/ui/modifiers/anim.py
@@ -0,0 +1,70 @@
+# This file is part of Korman.
+#
+# Korman is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Korman is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Korman. If not, see .
+
+import bpy
+
+def _check_for_anim(layout, context):
+ if context.object.animation_data is None or context.object.animation_data.action is None:
+ layout.label("Object has no animation data", icon="ERROR")
+ return False
+ return True
+
+def animation(modifier, layout, context):
+ if not _check_for_anim(layout, context):
+ return
+ action = context.object.animation_data.action
+
+ split = layout.split()
+ col = split.column()
+ col.prop(modifier, "auto_start")
+ col = split.column()
+ col.prop(modifier, "loop")
+
+ layout.prop_search(modifier, "initial_marker", action, "pose_markers", icon="PMARKER")
+ col = layout.column()
+ col.enabled = modifier.loop
+ col.prop_search(modifier, "loop_start", action, "pose_markers", icon="PMARKER")
+ col.prop_search(modifier, "loop_end", action, "pose_markers", icon="PMARKER")
+
+
+class LoopListUI(bpy.types.UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
+ layout.prop(item, "loop_name", emboss=False, text="", icon="PMARKER_ACT")
+
+
+def animation_loop(modifier, layout, context):
+ if not _check_for_anim(layout, context):
+ return
+
+ row = layout.row()
+ row.template_list("LoopListUI", "loops", modifier, "loops", modifier, "active_loop_index",
+ rows=2, maxrows=3)
+ col = row.column(align=True)
+ op = col.operator("object.plasma_modifier_collection_add", icon="ZOOMIN", text="")
+ op.modifier = modifier.pl_id
+ op.collection = "loops"
+ op.name_prefix = "Loop"
+ op.name_prop = "loop_name"
+ op = col.operator("object.plasma_modifier_collection_remove", icon="ZOOMOUT", text="")
+ op.modifier = modifier.pl_id
+ op.collection = "loops"
+ op.index = modifier.active_loop_index
+
+ # Modify the loop points
+ if modifier.loops:
+ action = context.object.animation_data.action
+ loop = modifier.loops[modifier.active_loop_index]
+ layout.prop_search(loop, "loop_start", action, "pose_markers", icon="PMARKER")
+ layout.prop_search(loop, "loop_end", action, "pose_markers", icon="PMARKER")