diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index 5a9630c..81afce9 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -14,15 +14,17 @@
# along with Korman. If not, see .
import bpy
+
from collections import defaultdict
import functools
import itertools
import math
import mathutils
-from PyHSPlasma import *
from typing import *
import weakref
+from PyHSPlasma import *
+
from . import utils
class AnimationConverter:
@@ -30,12 +32,13 @@ class AnimationConverter:
self._exporter = weakref.ref(exporter)
self._bl_fps = bpy.context.scene.render.fps
- def _convert_frame_time(self, frame_num : int) -> float:
+ def convert_frame_time(self, frame_num: int) -> float:
return frame_num / self._bl_fps
- def convert_object_animations(self, bo, so) -> None:
+ def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
+ start: Optional[int] = None, end: Optional[int] = None) -> Iterable[plAGApplicator]:
if not bo.plasma_object.has_animation_data:
- return
+ return []
def fetch_animation_data(id_data):
if id_data is not None:
@@ -44,8 +47,6 @@ class AnimationConverter:
return action, getattr(action, "fcurves", [])
return None, []
- # TODO: At some point, we should consider supporting NLA stuff.
- # But for now, this seems sufficient.
obj_action, obj_fcurves = fetch_animation_data(bo)
data_action, data_fcurves = fetch_animation_data(bo.data)
@@ -55,63 +56,24 @@ class AnimationConverter:
# things that aren't the typical position, rotation, scale animations.
applicators = []
if isinstance(bo.data, bpy.types.Camera):
- applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves))
+ applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
else:
- applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis))
+ applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis, start=start, end=end))
if bo.plasma_modifiers.soundemit.enabled:
- applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit))
+ applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
if isinstance(bo.data, bpy.types.Lamp):
lamp = bo.data
- applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp))
+ applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
if isinstance(lamp, bpy.types.SpotLamp):
- applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp))
+ applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
if isinstance(lamp, bpy.types.PointLamp):
- applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp))
-
- # Check to make sure we have some valid animation applicators before proceeding.
- if not any(applicators):
- return
-
- # There is a race condition in the client with animation loading. It expects for modifiers
- # to be listed on the SceneObject in a specific order. D'OH! So, always use these funcs.
- agmod, agmaster = self.get_anigraph_objects(bo, so)
- anim_mod = bo.plasma_modifiers.animation
- atcanim = self._mgr.find_create_object(anim_mod.anim_type, so=so)
-
- # Add the animation data to the ATC
- for i in applicators:
- if i is not None:
- atcanim.addApplicator(i)
- agmod.channelName = bo.name
- agmaster.addPrivateAnim(atcanim.key)
-
- # This was previously part of the Animation Modifier, however, there can be lots of animations
- # Therefore we move it here.
- def get_ranges(*args, **kwargs):
- index = kwargs.get("index", 0)
- for i in args:
- if i is not None:
- yield i.frame_range[index]
- atcanim.name = "(Entire Animation)"
- sdl_name = anim_mod.obj_sdl_anim
- atcanim.start = self._convert_frame_time(min(get_ranges(obj_action, data_action, index=0)))
- atcanim.end = self._convert_frame_time(max(get_ranges(obj_action, data_action, index=1)))
- if isinstance(atcanim, plAgeGlobalAnim):
- atcanim.globalVarName = anim_mod.obj_sdl_anim
- if isinstance(atcanim, plATCAnim):
- # Marker points
- if obj_action is not None:
- for marker in obj_action.pose_markers:
- atcanim.setMarker(marker.name, self._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
-
- def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves):
+ applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
+
+ return [i for i in applicators if i is not None]
+
+ def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
+ start: Optional[int], end: Optional[int]):
+ has_fov_anim = False
if data_fcurves:
# The hard part about this crap is that FOV animations are not stored in ATC Animations
# instead, FOV animation keyframes are held inside of the camera modifier. Cyan's solution
@@ -130,33 +92,32 @@ class AnimationConverter:
degrees = math.degrees
fov_fcurve.update()
- # Seeing as how we're transforming the data entirely, we'll just use the fcurve itself
- # instead of our other animation helpers. But ugh does this mess look like sloppy C.
- keyframes = fov_fcurve.keyframe_points
+
+ # Well, now that we have multiple animations, we are using our fancier FCurve processing.
+ # Unfortunately, the code still looks like sin. What can you do?
+ keyframes, _ = self._process_fcurve(fov_fcurve, start=start, end=end)
num_keyframes = len(keyframes)
+
has_fov_anim = bool(num_keyframes)
i = 0
while i < num_keyframes:
- this_keyframe = keyframes[i]
- next_keyframe = keyframes[0] if i+1 == num_keyframes else keyframes[i+1]
-
# So remember, these are messages. When we hit a keyframe, we're dispatching a message
# representing the NEXT desired FOV.
- this_frame_time = this_keyframe.co[0] / fps
- next_frame_num, next_frame_value = next_keyframe.co
- next_frame_time = next_frame_num / fps
+ this_keyframe = keyframes[i]
+ next_keyframe = keyframes[0] if i+1 == num_keyframes else keyframes[i+1]
# This message is held on the camera modifier and sent to the animation... It calls
# back when the animation reaches the keyframe time, causing the FOV message to be sent.
+ # This should be exported per-animation because it will be specific to each ATC.
cb_msg = plEventCallbackMsg()
cb_msg.event = kTime
- cb_msg.eventTime = this_frame_time
+ cb_msg.eventTime = this_keyframe.frame_time
cb_msg.index = i
cb_msg.repeats = -1
cb_msg.addReceiver(cam_key)
anim_msg = plAnimCmdMsg()
- anim_msg.animName = "(Entire Animation)"
- anim_msg.time = this_frame_time
+ anim_msg.animName = anim_name
+ anim_msg.time = this_keyframe.frame_time
anim_msg.sender = anim_key
anim_msg.addReceiver(anim_key)
anim_msg.addCallback(cb_msg)
@@ -166,30 +127,29 @@ class AnimationConverter:
# This is the message actually changes the FOV. Interestingly, it is sent at
# export-time and while playing the game, the camera modifier just steals its
# parameters and passes them to the brain. Can't make this stuff up.
- cam_msg = plCameraMsg()
- cam_msg.addReceiver(cam_key)
- cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True)
- cam_config = cam_msg.config
- cam_config.accel = next_frame_time # Yassss...
- cam_config.fovW = degrees(next_frame_value)
- cam_config.fovH = degrees(next_frame_value * aspect)
- camera.addFOVInstruction(cam_msg)
+ # Be sure to only export each instruction once.
+ if not any((msg.config.accel == next_keyframe.frame_time for msg in camera.fovInstructions)):
+ cam_msg = plCameraMsg()
+ cam_msg.addReceiver(cam_key)
+ cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True)
+ cam_config = cam_msg.config
+ cam_config.accel = next_keyframe.frame_time # Yassss...
+ cam_config.fovW = degrees(next_keyframe.values[0])
+ cam_config.fovH = degrees(next_keyframe.values[0] * aspect)
+ camera.addFOVInstruction(cam_msg)
i += 1
- else:
- has_fov_anim = False
- else:
- has_fov_anim = False
# If we exported any FOV animation at all, then we need to ensure there is an applicator
# returned from here... At bare minimum, we'll need the applicator with an empty
# CompoundController. This should be sufficient to keep CWE from crashing...
- applicator = self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis, allow_empty=has_fov_anim)
- camera = locals().get("camera", self._mgr.find_create_object(plCameraModifier, so=so))
+ applicator = self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis,
+ allow_empty=has_fov_anim, start=start, end=end)
+ camera = self._mgr.find_create_object(plCameraModifier, so=so)
camera.animated = applicator is not None
return applicator
- def _convert_lamp_color_animation(self, name, fcurves, lamp):
+ def _convert_lamp_color_animation(self, name, fcurves, lamp, start, end):
if not fcurves:
return None
@@ -210,7 +170,9 @@ class AnimationConverter:
return map(lambda x: x * -1.0, color)
else:
return color
- color_keyframes, color_bez = self._process_keyframes(color_curves, 3, lamp.color, convert_specular_animation)
+ color_keyframes, color_bez = self._process_keyframes(color_curves, 3, lamp.color,
+ convert=convert_specular_animation,
+ start=start, end=end)
if color_keyframes and lamp.use_specular:
channel = plPointControllerChannel()
channel.controller = self._make_point3_controller(color_keyframes, color_bez)
@@ -229,7 +191,9 @@ class AnimationConverter:
diffuse_channels = dict(color=3, energy=1)
diffuse_defaults = dict(color=lamp.color, energy=lamp.energy)
diffuse_fcurves = color_curves + [energy_curve,]
- diffuse_keyframes = self._process_fcurves(diffuse_fcurves, diffuse_channels, 3, convert_diffuse_animation, diffuse_defaults)
+ diffuse_keyframes = self._process_fcurves(diffuse_fcurves, diffuse_channels, 3,
+ convert_diffuse_animation, diffuse_defaults,
+ start=start, end=end)
if not diffuse_keyframes:
return None
@@ -241,7 +205,7 @@ class AnimationConverter:
applicator.channel = channel
yield applicator
- def _convert_omni_lamp_animation(self, name, fcurves, lamp):
+ def _convert_omni_lamp_animation(self, name, fcurves, lamp, start, end):
if not fcurves:
return None
@@ -264,7 +228,8 @@ class AnimationConverter:
if distance_fcurve is not None:
channel = plScalarControllerChannel()
channel.controller = self.make_scalar_leaf_controller(distance_fcurve,
- lambda x: x if lamp.use_sphere else x * 2)
+ lambda x: x if lamp.use_sphere else x * 2,
+ start=start, end=end)
applicator = plOmniCutoffApplicator()
applicator.channelName = name
applicator.channel = channel
@@ -275,7 +240,8 @@ class AnimationConverter:
if energy_fcurve is not None:
report.warn("Constant attenuation cannot be animated in Plasma", ident=3)
elif falloff == "INVERSE_LINEAR":
- keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten, omni_defaults)
+ keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten,
+ omni_defaults, start=start, end=end)
if keyframes:
channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@@ -286,7 +252,8 @@ class AnimationConverter:
elif falloff == "INVERSE_SQUARE":
if self._mgr.getVer() >= pvMoul:
report.port("Lamp {} Falloff animations are only supported in Myst Online: Uru Live", falloff, indent=3)
- keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten, omni_defaults)
+ keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten,
+ omni_defaults, start=start, end=end)
if keyframes:
channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@@ -299,7 +266,7 @@ class AnimationConverter:
else:
report.warn("Lamp Falloff '{}' animations are not supported", falloff, ident=3)
- def _convert_sound_volume_animation(self, name, fcurves, soundemit):
+ def _convert_sound_volume_animation(self, name, fcurves, soundemit, start, end):
if not fcurves:
return None
@@ -320,7 +287,7 @@ class AnimationConverter:
# so yes, we must convert the same animation data again and again.
# To make matters worse, the way that these keyframes are stored can cause
# the animation to evaluate to a no-op. Be ready for that.
- controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume)
+ controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume, start=start, end=end)
if controller is not None:
channel = plScalarControllerChannel()
channel.controller = controller
@@ -331,7 +298,7 @@ class AnimationConverter:
sound.sound.name, indent=2)
break
- def _convert_spot_lamp_animation(self, name, fcurves, lamp):
+ def _convert_spot_lamp_animation(self, name, fcurves, lamp, start, end):
if not fcurves:
return None
@@ -343,7 +310,8 @@ class AnimationConverter:
# Spot Outer is just the size keyframes...
if size_fcurve is not None:
channel = plScalarControllerChannel()
- channel.controller = self.make_scalar_leaf_controller(size_fcurve, lambda x: math.degrees(x))
+ channel.controller = self.make_scalar_leaf_controller(size_fcurve, lambda x: math.degrees(x),
+ start=start, end=end)
applicator = plSpotOuterApplicator()
applicator.channelName = name
applicator.channel = channel
@@ -359,7 +327,8 @@ class AnimationConverter:
inner_fcurves = [blend_fcurve, size_fcurve]
inner_channels = dict(spot_blend=1, spot_size=1)
inner_defaults = dict(spot_blend=lamp.spot_blend, spot_size=lamp.spot_size)
- keyframes = self._process_fcurves(inner_fcurves, inner_channels, 1, convert_spot_inner, inner_defaults)
+ keyframes = self._process_fcurves(inner_fcurves, inner_channels, 1, convert_spot_inner,
+ inner_defaults, start=start, end=end)
if keyframes:
channel = plScalarControllerChannel()
@@ -369,8 +338,10 @@ class AnimationConverter:
applicator.channel = channel
yield applicator
- def _convert_transform_animation(self, bo, fcurves, xform, allow_empty=False) -> Union[None, plMatrixChannelApplicator]:
- tm = self.convert_transform_controller(fcurves, bo.rotation_mode, xform, allow_empty)
+ def _convert_transform_animation(self, bo, fcurves, xform, *, allow_empty: Optional[bool] = False,
+ start: Optional[int] = None, end: Optional[int] = None) -> Optional[plMatrixChannelApplicator]:
+ tm = self.convert_transform_controller(fcurves, bo.rotation_mode, xform, allow_empty=allow_empty,
+ start=start, end=end)
if tm is None and not allow_empty:
return None
@@ -384,13 +355,16 @@ class AnimationConverter:
return applicator
- def convert_transform_controller(self, fcurves, rotation_mode: str, xform, allow_empty=False) -> Union[None, plCompoundController]:
+ def convert_transform_controller(self, fcurves, rotation_mode: str, xform, *,
+ allow_empty: Optional[bool] = False,
+ start: Optional[int] = None,
+ end: Optional[int] = None) -> Union[None, plCompoundController]:
if not fcurves and not allow_empty:
return None
- pos = self.make_pos_controller(fcurves, "location", xform.to_translation())
- rot = self.make_rot_controller(fcurves, rotation_mode, xform)
- scale = self.make_scale_controller(fcurves, "scale", xform.to_scale())
+ pos = self.make_pos_controller(fcurves, "location", xform.to_translation(), start=start, end=end)
+ rot = self.make_rot_controller(fcurves, rotation_mode, xform, start=start, end=end)
+ scale = self.make_scale_controller(fcurves, "scale", xform.to_scale(), start=start, end=end)
if pos is None and rot is None and scale is None:
if not allow_empty:
return None
@@ -421,7 +395,47 @@ class AnimationConverter:
else:
return self.get_anigraph_keys(bo, so)[1]
- def make_matrix44_controller(self, fcurves, pos_path : str, scale_path : str, pos_default, scale_default) -> Union[None, plLeafController]:
+ def get_frame_time_range(self, *anims: Iterable[Union[plAGApplicator, plController]],
+ so: Optional[plSceneObject] = None, name: Optional[str] = None) -> Tuple[int, int]:
+ """Determines the range of frame numbers in an exported animation."""
+ def iter_frame_times():
+ nonlocal name
+ for anim in anims:
+ if isinstance(anim, plAGApplicator):
+ anim = anim.channel.controller
+ if anim is None:
+ # Maybe a camera FOV thing, or something.
+ continue
+
+ def iter_leaves(ctrl: Optional[plController]) -> Iterator[plLeafController]:
+ if ctrl is None:
+ return
+ elif isinstance(ctrl, plCompoundController):
+ yield from iter_leaves(ctrl.X)
+ yield from iter_leaves(ctrl.Y)
+ yield from iter_leaves(ctrl.Z)
+ elif isinstance(ctrl, plLeafController):
+ yield ctrl
+ else:
+ raise ValueError(ctrl)
+
+ yield from (key.frameTime for leaf in iter_leaves(anim) for key in leaf.keys[0])
+
+ # Special case: camera animations are over on the plCameraModifier. Grr.
+ if so is not None:
+ camera = self._mgr.find_object(plCameraModifier, so=so)
+ if camera is not None:
+ if not name:
+ name = "(Entire Animation)"
+ yield from (msg.time for msg, _ in camera.messageQueue if isinstance(msg, plAnimCmdMsg) and msg.animName == name)
+
+ try:
+ return min(iter_frame_times()), max(iter_frame_times())
+ except ValueError:
+ return 0.0, 0.0
+
+ def make_matrix44_controller(self, fcurves, pos_path: str, scale_path: str, pos_default, scale_default,
+ *, start: Optional[int] = None, end: Optional[int] = None) -> Optional[plLeafController]:
def convert_matrix_keyframe(**kwargs) -> hsMatrix44:
pos = kwargs[pos_path]
scale = kwargs[scale_path]
@@ -440,16 +454,20 @@ class AnimationConverter:
channels = { pos_path: 3, scale_path: 3 }
default_values = { pos_path: pos_default, scale_path: scale_default }
- keyframes = self._process_fcurves(fcurves, channels, 1, convert_matrix_keyframe, default_values)
+ keyframes = self._process_fcurves(fcurves, channels, 1, convert_matrix_keyframe,
+ default_values, start=start, end=end)
if not keyframes:
return None
# Now we make the controller
return self._make_matrix44_controller(keyframes)
- def make_pos_controller(self, fcurves, data_path: str, default_xform, convert=None) -> Union[None, plLeafController]:
+ def make_pos_controller(self, fcurves, data_path: str, default_xform,
+ convert: Optional[Callable] = None, *, start: Optional[int] = None,
+ end: Optional[int] = None) -> Optional[plLeafController]:
pos_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
- keyframes, bez_chans = self._process_keyframes(pos_curves, 3, default_xform, convert)
+ keyframes, bez_chans = self._process_keyframes(pos_curves, 3, default_xform, convert,
+ start=start, end=end)
if not keyframes:
return None
@@ -458,7 +476,9 @@ class AnimationConverter:
ctrl = self._make_point3_controller(keyframes, bez_chans)
return ctrl
- def make_rot_controller(self, fcurves, rotation_mode: str, default_xform, convert=None) -> Union[None, plCompoundController, plLeafController]:
+ def make_rot_controller(self, fcurves, rotation_mode: str, default_xform,
+ convert: Optional[Callable] = None, *, start: Optional[int] = None,
+ end: Optional[int] = None) -> Union[None, plCompoundController, plLeafController]:
if rotation_mode in {"AXIS_ANGLE", "QUATERNION"}:
rot_curves = [i for i in fcurves if i.data_path == "rotation_{}".format(rotation_mode.lower()) and i.keyframe_points]
if not rot_curves:
@@ -477,7 +497,8 @@ class AnimationConverter:
# Just dropping bezier stuff on the floor because Plasma does not support it, and
# I think that opting into quaternion keyframes is a good enough indication that
# you're OK with that.
- keyframes, bez_chans = self._process_keyframes(rot_curves, 4, default_xform, convert)
+ keyframes, bez_chans = self._process_keyframes(rot_curves, 4, default_xform, convert,
+ start=start, end=end)
if keyframes:
return self._make_quat_controller(keyframes)
else:
@@ -497,7 +518,8 @@ class AnimationConverter:
return result[:]
euler_convert = convert_euler_keyframe if rotation_mode != "XYZ" else convert
- keyframes, bez_chans = self._process_keyframes(rot_curves, 3, default_xform.to_euler(rotation_mode), euler_convert)
+ keyframes, bez_chans = self._process_keyframes(rot_curves, 3, default_xform.to_euler(rotation_mode),
+ euler_convert, start=start, end=end)
if keyframes:
# Once again, quaternion keyframes do not support bezier interpolation. Ideally,
# we would just drop support for rotation beziers entirely to simplify all this
@@ -507,9 +529,12 @@ class AnimationConverter:
else:
return self._make_quat_controller(keyframes)
- def make_scale_controller(self, fcurves, data_path: str, default_xform, convert=None) -> plLeafController:
+ def make_scale_controller(self, fcurves, data_path: str, default_xform,
+ convert: Optional[Callable] = None, *, start: Optional[int] = None,
+ end: Optional[int] = None) -> Optional[plLeafController]:
scale_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
- keyframes, bez_chans = self._process_keyframes(scale_curves, 3, default_xform, convert)
+ keyframes, bez_chans = self._process_keyframes(scale_curves, 3, default_xform, convert,
+ start=start, end=end)
if not keyframes:
return None
@@ -517,8 +542,11 @@ class AnimationConverter:
ctrl = self._make_scale_value_controller(keyframes, bez_chans)
return ctrl
- def make_scalar_leaf_controller(self, fcurve, convert=None) -> Union[None, plLeafController]:
- keyframes, bezier = self._process_fcurve(fcurve, convert)
+ def make_scalar_leaf_controller(self, fcurve: bpy.types.FCurve,
+ convert: Optional[Callable] = None, *,
+ start: Optional[int] = None,
+ end: Optional[int] = None) -> Optional[plLeafController]:
+ keyframes, bezier = self._process_fcurve(fcurve, convert, start=start, end=end)
if not keyframes:
return None
@@ -647,7 +675,7 @@ class AnimationConverter:
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _sort_and_dedupe_keyframes(self, keyframes : Dict) -> Sequence:
+ def _sort_and_dedupe_keyframes(self, keyframes: Dict) -> Sequence:
"""Takes in the final, unsorted keyframe sequence and sorts it. If all keyframes are
equivalent, eg due to a convert function, then they are discarded."""
@@ -667,7 +695,8 @@ class AnimationConverter:
return []
return [keyframes_sorted[i] for i in filtered_indices]
- def _process_fcurve(self, fcurve, convert=None) -> Tuple[Sequence, AbstractSet]:
+ def _process_fcurve(self, fcurve: bpy.types.FCurve, convert: Optional[Callable] = None, *,
+ start: Optional[int] = None, end: Optional[int] = None) -> Tuple[Sequence, AbstractSet]:
"""Like _process_keyframes, but for one fcurve"""
# Adapt from incoming single item sequence to a single argument.
@@ -676,9 +705,9 @@ class AnimationConverter:
else:
single_convert = None
# Can't proxy to _process_fcurves because it only supports linear interoplation.
- return self._process_keyframes([fcurve], 1, [0.0], single_convert)
+ return self._process_keyframes([fcurve], 1, [0.0], single_convert, start=start, end=end)
- def _santize_converted_values(self, num_channels : int, raw_values : Union[Dict, Sequence], convert : Callable):
+ def _santize_converted_values(self, num_channels: int, raw_values: Union[Dict, Sequence], convert: Callable):
assert convert is not None
if isinstance(raw_values, Dict):
values = convert(**raw_values)
@@ -696,8 +725,9 @@ class AnimationConverter:
assert len(values) == num_channels, "Converter returned {} values but expected {}".format(len(values), num_channels)
return values
- def _process_fcurves(self, fcurves : Sequence, channels : Dict[str, int], result_channels : int,
- convert : Callable, defaults : Dict[str, Union[float, Sequence]]) -> Sequence:
+ def _process_fcurves(self, fcurves: Sequence, channels: Dict[str, int], result_channels: int,
+ convert: Callable, defaults: Dict[str, Union[float, Sequence]], *,
+ start: Optional[int] = None, end: Optional[int] = None) -> Sequence:
"""This consumes a sequence of Blender FCurves that map to a single Plasma controller.
Like `_process_keyframes()`, except the converter function is mandatory, and each
Blender `data_path` must have a fixed number of channels.
@@ -714,9 +744,18 @@ class AnimationConverter:
fcurve.update()
grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve
- fcurve_keyframes = defaultdict(functools.partial(defaultdict, dict))
+ if start is not None and end is not None:
+ framenum_filter = lambda x: x.co[0] >= start and x.co[0] <= end
+ elif start is not None and end is None:
+ framenum_filter = lambda x: x.co[0] >= start
+ elif start is None and end is not None:
+ framenum_filter = lambda x: x.co[0] <= end
+ else:
+ framenum_filter = lambda x: True
+
+ fcurve_keyframes = defaultdict(lambda: defaultdict(dict))
for fcurve in (i for i in fcurves if i is not None):
- for fkey in fcurve.keyframe_points:
+ for fkey in filter(framenum_filter, fcurve.keyframe_points):
fcurve_keyframes[fkey.co[0]][fcurve.data_path][fcurve.array_index] = fkey
def iter_channel_values(frame_num : int, fcurves : Dict, fkeys : Dict, num_channels : int, defaults : Union[float, Sequence]):
@@ -756,17 +795,28 @@ class AnimationConverter:
return self._sort_and_dedupe_keyframes(keyframes)
- def _process_keyframes(self, fcurves, num_channels : int, default_values : Sequence, convert=None) -> Tuple[Sequence, AbstractSet]:
+ def _process_keyframes(self, fcurves, num_channels: int, default_values: Sequence,
+ convert: Optional[Callable] = None, *, start: Optional[int] = None,
+ end: Optional[int] = None) -> Tuple[Sequence, AbstractSet]:
"""Groups all FCurves for the same frame together"""
keyframe_data = type("KeyFrameData", (), {})
fps, pi = self._bl_fps, math.pi
keyframes, fcurve_keyframes = {}, defaultdict(dict)
+ if start is not None and end is not None:
+ framenum_filter = lambda x: x.co[0] >= start and x.co[0] <= end
+ elif start is not None and end is None:
+ framenum_filter = lambda x: x.co[0] >= start
+ elif start is None and end is not None:
+ framenum_filter = lambda x: x.co[0] <= end
+ else:
+ framenum_filter = lambda x: True
+
indexed_fcurves = { fcurve.array_index: fcurve for fcurve in fcurves if fcurve is not None }
for i, fcurve in indexed_fcurves.items():
fcurve.update()
- for fkey in fcurve.keyframe_points:
+ for fkey in filter(framenum_filter, fcurve.keyframe_points):
fcurve_keyframes[fkey.co[0]][i] = fkey
def iter_values(frame_num, fkeys) -> Generator[float, None, None]:
diff --git a/korman/exporter/camera.py b/korman/exporter/camera.py
index cfbf18b..c942fde 100644
--- a/korman/exporter/camera.py
+++ b/korman/exporter/camera.py
@@ -171,8 +171,9 @@ class CameraConverter:
return brain
def _export_fixed_camera(self, so, bo, props):
- if props.anim_enabled:
- self._exporter().animation.convert_object_animations(bo, so)
+ anim_mod = bo.plasma_modifiers.animation
+ if props.anim_enabled and not anim_mod.enabled and bo.plasma_object.has_animation_data:
+ anim_mod.convert_object_animations(self._exporter(), bo, so)
brain = self._mgr.find_create_object(plCameraBrain1_Fixed, so=so)
self._convert_brain(so, bo, props, brain)
return brain
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index 20212fe..afba199 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -299,26 +299,22 @@ class Exporter:
def _export_camera_blobj(self, so, bo):
# Hey, guess what? Blender's camera data is utter crap!
- # NOTE: Animation export is dependent on camera type, so we'll do that later.
camera = bo.data.plasma_camera
self.camera.export_camera(so, bo, camera.camera_type, camera.settings, camera.transitions)
def _export_empty_blobj(self, so, bo):
- self.animation.convert_object_animations(bo, so)
+ pass
def _export_lamp_blobj(self, so, bo):
- self.animation.convert_object_animations(bo, so)
self.light.export_rtlight(so, bo)
def _export_mesh_blobj(self, so, bo):
- self.animation.convert_object_animations(bo, so)
if bo.data.materials:
self.mesh.export_object(bo, so)
else:
self.report.msg("No material(s) on the ObData, so no drawables", indent=1)
def _export_font_blobj(self, so, bo):
- self.animation.convert_object_animations(bo, so)
with utils.temporary_mesh_object(bo) as meshObj:
if bo.data.materials:
self.mesh.export_object(meshObj, so)
diff --git a/korman/exporter/material.py b/korman/exporter/material.py
index a343b6b..f931b78 100644
--- a/korman/exporter/material.py
+++ b/korman/exporter/material.py
@@ -21,7 +21,7 @@ import functools
import itertools
import math
from pathlib import Path
-from typing import Iterator, Optional, Union
+from typing import Dict, Iterator, Optional, Union
import weakref
from PyHSPlasma import *
@@ -514,7 +514,69 @@ class MaterialConverter:
return layer
def _export_layer_animations(self, bo, bm, tex_slot, idx, base_layer) -> plLayer:
- """Exports animations on this texture and chains the Plasma layers as needed"""
+ top_layer = base_layer
+ converter = self._exporter().animation
+ texture = tex_slot.texture if tex_slot is not None else None
+
+ def attach_layer(pClass: type, anim_name: str, controllers: Dict[str, plController]):
+ nonlocal top_layer
+ name = "{}_{}".format(base_layer.key.name, anim_name)
+ layer_animation = self._mgr.find_create_object(pClass, bl=bo, name=name)
+
+ # A word: in my testing, saving the Layer SDL to a server can result in issues where
+ # the animation get stuck in a state that no longer matches the animation you've
+ # created, and the result is an irrecoverable mess. Meaning, the animation plays
+ # whenever and however it wants, regardless of your fancy logic nodes. At some point,
+ # we may (TODO) want to pass these animations through the PlasmaNet thingo and apply
+ # the synch flags it thinks we need. For now, just exclude everything.
+ layer_animation.synchFlags |= plSynchedObject.kExcludeAllPersistentState
+
+ for attr, ctrl in controllers.items():
+ setattr(layer_animation, attr, ctrl)
+ layer_animation.underLay = top_layer.key
+ top_layer = layer_animation
+
+ if texture is not None:
+ layer_props = texture.plasma_layer
+ for anim in layer_props.subanimations:
+ if not anim.is_entire_animation:
+ start, end = anim.start, anim.end
+ else:
+ start, end = None, None
+ controllers = self._export_layer_controllers(bo, bm, tex_slot, idx, base_layer,
+ start=start, end=end)
+ if not controllers:
+ continue
+
+ pClass = plLayerSDLAnimation if anim.sdl_var else plLayerAnimation
+ attach_layer(pClass, anim.animation_name, controllers)
+ atc = top_layer.timeConvert
+ atc.begin, atc.end = converter.get_frame_time_range(*controllers.values())
+ atc.loopBegin, atc.loopEnd = atc.begin, atc.end
+ if not anim.auto_start:
+ atc.flags |= plAnimTimeConvert.kStopped
+ if anim.loop:
+ atc.flags |= plAnimTimeConvert.kLoop
+ if isinstance(top_layer, plLayerSDLAnimation):
+ top_layer.varName = layer_props.sdl_var
+ else:
+ # Crappy automatic entire layer animation. Loop it by default.
+ controllers = self._export_layer_controllers(bo, bm, tex_slot, idx, base_layer)
+ if controllers:
+ attach_layer(plLayerAnimation, "(Entire Animation)", controllers)
+ atc = top_layer.timeConvert
+ atc.flags |= plAnimTimeConvert.kLoop
+ atc.begin, atc.end = converter.get_frame_time_range(*controllers.values())
+ atc.loopBegin = atc.begin
+ atc.loopEnd = atc.end
+
+ return top_layer
+
+
+ def _export_layer_controllers(self, bo: bpy.types.Object, bm: bpy.types.Material, tex_slot,
+ idx: int, base_layer, *, start: Optional[int] = None,
+ end: Optional[int] = None) -> Dict[str, plController]:
+ """Convert animations on this material/texture combo in the requested range to Plasma controllers"""
def harvest_fcurves(bl_id, collection, data_path=None):
if bl_id is None:
@@ -543,66 +605,33 @@ class MaterialConverter:
harvest_fcurves(bm, fcurves, tex_slot.path_from_id())
harvest_fcurves(texture, fcurves)
- if not fcurves:
- return base_layer
-
- # Okay, so we have some FCurves. We'll loop through our known layer animation converters
- # and chain this biotch up as best we can.
- layer_animation = None
+ # Take the FCurves and ram them through our converters, hopefully returning some valid
+ # animation controllers.
+ controllers = {}
for attr, converter in self._animation_exporters.items():
- ctrl = converter(bo, bm, tex_slot, base_layer, fcurves)
+ ctrl = converter(bo, bm, tex_slot, base_layer, fcurves, start=start, end=end)
if ctrl is not None:
- if layer_animation is None:
- layer_animation = self.get_texture_animation_key(bo, bm, texture).object
- setattr(layer_animation, attr, ctrl)
-
- # Alrighty, if we exported any controllers, layer_animation is a plLayerAnimation. We need to do
- # the common schtuff now.
- if layer_animation is not None:
- layer_animation.underLay = base_layer.key
-
- fps = bpy.context.scene.render.fps
- atc = layer_animation.timeConvert
-
- # Since we are harvesting from the material action but are exporting to a layer, the
- # action's range is relatively useless. We'll figure our own. Reminder: the blender
- # documentation is wrong -- FCurve.range() returns a sequence of frame numbers, not times.
- atc.begin = min((fcurve.range()[0] for fcurve in fcurves)) * (30.0 / fps) / fps
- atc.end = max((fcurve.range()[1] for fcurve in fcurves)) * (30.0 / fps) / fps
-
- if tex_slot is not None:
- layer_props = tex_slot.texture.plasma_layer
- if not layer_props.anim_auto_start:
- atc.flags |= plAnimTimeConvert.kStopped
- if layer_props.anim_loop:
- atc.flags |= plAnimTimeConvert.kLoop
- atc.loopBegin = atc.begin
- atc.loopEnd = atc.end
- if layer_props.anim_sdl_var:
- layer_animation.varName = layer_props.anim_sdl_var
- else:
- # Hmm... I wonder what we should do here? A reasonable default might be to just
- # run the stupid thing in a loop.
- atc.flags |= plAnimTimeConvert.kLoop
- atc.loopBegin = atc.begin
- atc.loopEnd = atc.end
- return layer_animation
-
- # Well, we had some FCurves but they were garbage... Too bad.
- return base_layer
+ controllers[attr] = ctrl
+ return controllers
- def _export_layer_diffuse_animation(self, bo, bm, tex_slot, base_layer, fcurves, converter):
+ def _export_layer_diffuse_animation(self, bo, bm, tex_slot, base_layer, fcurves, *, start, end, converter):
assert converter is not None
+ # If there's no material, then this is simply impossible.
+ if bm is None:
+ return None
+
def translate_color(color_sequence):
# See things like get_material_preshade
result = converter(bo, bm, mathutils.Color(color_sequence))
return result.red, result.green, result.blue
- ctrl = self._exporter().animation.make_pos_controller(fcurves, "diffuse_color", bm.diffuse_color, translate_color)
+ ctrl = self._exporter().animation.make_pos_controller(fcurves, "diffuse_color",
+ bm.diffuse_color, translate_color,
+ start=start, end=end)
return ctrl
- def _export_layer_opacity_animation(self, bo, bm, tex_slot, base_layer, fcurves):
+ def _export_layer_opacity_animation(self, bo, bm, tex_slot, base_layer, fcurves, *, start, end):
# Dumb function to intercept the opacity values and properly flag the base layer
def process_opacity(value):
self._handle_layer_opacity(base_layer, value)
@@ -610,18 +639,20 @@ class MaterialConverter:
for i in fcurves:
if i.data_path == "plasma_layer.opacity":
- ctrl = self._exporter().animation.make_scalar_leaf_controller(i, process_opacity)
+ ctrl = self._exporter().animation.make_scalar_leaf_controller(i, process_opacity, start=start, end=end)
return ctrl
return None
- def _export_layer_transform_animation(self, bo, bm, tex_slot, base_layer, fcurves):
+ def _export_layer_transform_animation(self, bo, bm, tex_slot, base_layer, fcurves, *, start, end):
if tex_slot is not None:
path = tex_slot.path_from_id()
pos_path = "{}.offset".format(path)
scale_path = "{}.scale".format(path)
# Plasma uses the controller to generate a matrix44... so we have to produce a leaf controller
- ctrl = self._exporter().animation.make_matrix44_controller(fcurves, pos_path, scale_path, tex_slot.offset, tex_slot.scale)
+ ctrl = self._exporter().animation.make_matrix44_controller(fcurves, pos_path, scale_path,
+ tex_slot.offset, tex_slot.scale,
+ start=start, end=end)
return ctrl
return None
@@ -1308,22 +1339,19 @@ class MaterialConverter:
color = bm.diffuse_color
return utils.color(color)
- def get_texture_animation_key(self, bo, bm, texture):
- """Finds or creates the appropriate key for sending messages to an animated Texture"""
-
- tex_name = texture.name if texture is not None else "AutoLayer"
- if bo.type == "LAMP":
- assert bm is None
- bm_name = bo.name
- else:
- assert bm is not None
- bm_name = bm.name
- if texture is not None and not tex_name in bm.texture_slots:
- raise ExportError("Texture '{}' not used in Material '{}'".format(bm_name, tex_name))
-
- name = "{}_{}_LayerAnim".format(bm_name, tex_name)
- pClass = plLayerSDLAnimation if texture is not None and texture.plasma_layer.anim_sdl_var else plLayerAnimation
- return self._mgr.find_create_key(pClass, bl=bo, name=name)
+ def get_texture_animation_key(self, bo, bm, texture, anim_name: str) -> Iterator[plKey]:
+ """Finds the appropriate key for sending messages to an animated Texture"""
+ if not anim_name:
+ anim_name = "(Entire Animation)"
+
+ for top_layer in filter(lambda x: isinstance(x.object, plLayerAnimationBase), self.get_layers(bo, bm, texture)):
+ base_layer = top_layer.object.bottomOfStack
+ needle = top_layer
+ while needle is not None:
+ if needle.name == "{}_{}".format(base_layer.name, anim_name):
+ yield needle
+ break
+ needle = needle.object.underLay
def _handle_layer_opacity(self, layer: plLayerInterface, value: float):
if value < 100:
diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py
index f7c8b86..a42196c 100644
--- a/korman/nodes/node_messages.py
+++ b/korman/nodes/node_messages.py
@@ -105,19 +105,27 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
("TEXTURE", "Texture", "Texture Action")],
default="OBJECT")
- def _poll_material_textures(self, value):
- if self.target_object is None:
- return False
- if self.target_material is None:
- return False
- return value.name in self.target_material.texture_slots
-
- def _poll_mesh_materials(self, value):
- if self.target_object is None:
- return False
- if self.target_object.type != "MESH":
+ def _poll_texture(self, value):
+ # must be a legal option... but is it a member of this material... or, if no material,
+ # any of the materials attached to the object?
+ if self.target_material is not None:
+ return value.name in self.target_material.texture_slots
+ elif self.target_object is not None:
+ for i in (slot.material for slot in self.target_object.material_slots if slot and slot.material):
+ if value in (slot.texture for slot in i.texture_slots if slot and slot.texture):
+ return True
return False
- return value.name in self.target_object.data.materials
+ else:
+ return True
+
+ def _poll_material(self, value):
+ # Don't filter materials by texture - this would (potentially) result in surprising UX
+ # in that you would have to clear the texture selection before being able to select
+ # certain materials.
+ if self.target_object is not None:
+ object_materials = (slot.material for slot in self.target_object.material_slots if slot and slot.material)
+ return value in object_materials
+ return True
target_object = PointerProperty(name="Object",
description="Target object",
@@ -125,11 +133,11 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
target_material = PointerProperty(name="Material",
description="Target material",
type=bpy.types.Material,
- poll=_poll_mesh_materials)
+ poll=_poll_material)
target_texture = PointerProperty(name="Texture",
description="Target texture",
type=bpy.types.Texture,
- poll=_poll_material_textures)
+ poll=_poll_texture)
go_to = EnumProperty(name="Go To",
description="Where should the animation start?",
@@ -189,18 +197,55 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
("kStop", "Stop", "When the action is stopped by a message")],
default="kEnd")
+ # Blender memory workaround
+ _ENTIRE_ANIMATION = "(Entire Animation)"
+ def _get_anim_names(self, context):
+ if self.anim_type == "OBJECT":
+ items = [(anim.animation_name, anim.animation_name, "")
+ for anim in self.target_object.plasma_modifiers.animation.subanimations]
+ elif self.anim_type == "TEXTURE":
+ if self.target_texture is not None:
+ items = [(anim.animation_name, anim.animation_name, "")
+ for anim in self.target_texture.plasma_layer.subanimations]
+ elif self.target_material is not None or self.target_object is not None:
+ if self.target_material is None:
+ materials = (i.material for i in self.target_object.material_slots if i and i.material)
+ else:
+ materials = (self.target_material,)
+ layer_props = (i.texture.plasma_layer for mat in materials for i in mat.texture_slots if i and i.texture)
+ all_anims = frozenset((anim.animation_name for i in layer_props for anim in i.subanimations))
+ items = [(i, i, "") for i in all_anims]
+ else:
+ items = [(PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, "")]
+ else:
+ raise RuntimeError()
+
+ # We always want "(Entire Animation)", if it exists, to be the first item.
+ entire = items.index((PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, ""))
+ if entire not in (-1, 0):
+ items.pop(entire)
+ items.insert(0, (PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, ""))
+
+ return items
+
+ anim_name = EnumProperty(name="Animation",
+ description="Name of the animation to control",
+ items=_get_anim_names,
+ options=set())
+
def draw_buttons(self, context, layout):
layout.prop(self, "anim_type")
- layout.prop(self, "target_object")
+ col = layout.column()
+ if self.anim_type == "OBJECT":
+ col.alert = self.target_object is None
+ else:
+ col.alert = not any((self.target_object, self.target_material, self.target_texture))
+ col.prop(self, "target_object")
if self.anim_type != "OBJECT":
- col = layout.column()
- col.enabled = self.target_object is not None
col.prop(self, "target_material")
-
- col = layout.column()
- col.enabled = self.target_object is not None and self.target_material is not None
col.prop(self, "target_texture")
+ col.prop(self, "anim_name")
layout.prop(self, "go_to")
layout.prop(self, "action")
@@ -238,26 +283,24 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
# We're either sending this off to an AGMasterMod or a LayerAnim
obj = self.target_object
- if obj is None:
- self.raise_error("target object must be specified")
if self.anim_type == "OBJECT":
+ if obj is None:
+ self.raise_error("target object must be specified")
if not obj.plasma_object.has_animation_data:
self.raise_error("invalid animation")
- target = exporter.animation.get_animation_key(obj)
+ target = (exporter.animation.get_animation_key(obj),)
else:
material = self.target_material
- if material is None:
- self.raise_error("target material must be specified")
texture = self.target_texture
- if texture is None:
- self.raise_error("target texture must be specified")
- target = exporter.mesh.material.get_texture_animation_key(obj, material, texture)
-
- if target is None:
- raise RuntimeError()
- if isinstance(target.object, plLayerSDLAnimation):
- self.raise_error("Cannot control an SDL Animation")
- msg.addReceiver(target)
+ if obj is None and material is None and texture is None:
+ self.raise_error("At least one of: target object, material, texture MUST be specified")
+ target = exporter.mesh.material.get_texture_animation_key(obj, material, texture, self.anim_name)
+
+ target = [i for i in target if not isinstance(i.object, (plAgeGlobalAnim, plLayerSDLAnimation))]
+ if not target:
+ self.raise_error("No controllable animations were found.")
+ for i in target:
+ msg.addReceiver(i)
# Check the enum properties to see what commands we need to add
for prop in (self.go_to, self.action, self.play_direction, self.looping):
@@ -266,19 +309,19 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
msg.setCmd(cmd, True)
# Easier part starts here???
- fps = bpy.context.scene.render.fps
+ msg.animName = self.anim_name
if self.action == "kPlayToPercent":
msg.time = self.play_to_percent
elif self.action == "kPlayToTime":
- msg.time = self.play_to_frame / fps
+ msg.time = exporter.animation.convert_frame_time(self.play_to_frame)
# Implicit s better than explicit, I guess...
if self.loop_begin != self.loop_end:
# NOTE: loop name is not used in the engine AFAICT
msg.setCmd(plAnimCmdMsg.kSetLoopBegin, True)
msg.setCmd(plAnimCmdMsg.kSetLoopEnd, True)
- msg.loopBegin = self.loop_begin / fps
- msg.loopEnd = self.loop_end / fps
+ msg.loopBegin = exporter.animation.convert_frame_time(self.loop_begin)
+ msg.loopEnd = exporter.animation.convert_frame_time(self.loop_end)
# Whew, this was crazy
return msg
diff --git a/korman/properties/__init__.py b/korman/properties/__init__.py
index fa3b5e4..9980638 100644
--- a/korman/properties/__init__.py
+++ b/korman/properties/__init__.py
@@ -15,6 +15,7 @@
import bpy
+from .prop_anim import *
from .prop_camera import *
from .prop_image import *
from .prop_lamp import *
diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py
index fba352a..63b8572 100644
--- a/korman/properties/modifiers/anim.py
+++ b/korman/properties/modifiers/anim.py
@@ -15,9 +15,13 @@
import bpy
from bpy.props import *
+
+from typing import Iterable, Iterator, Optional
+
from PyHSPlasma import *
from .base import PlasmaModifierProperties
+from ..prop_anim import PlasmaAnimationCollection
from ...exporter import ExportError, utils
from ... import idprops
@@ -36,7 +40,15 @@ class ActionModifier:
# we will not use this action for any animation logic. that must be stored on the Object
# datablock for simplicity's sake.
return None
- raise ExportError("Object '{}' is not animated".format(bo.name))
+ raise ExportError("'{}': Object has an animation modifier but is not animated".format(bo.name))
+
+ def sanity_check(self) -> None:
+ if not self.id_data.plasma_object.has_animation_data:
+ raise ExportError("'{}': Has an animation modifier but no animation data.", self.id_data.name)
+
+ if self.id_data.type == "CAMERA":
+ if not self.id_data.data.plasma_camera.allow_animations:
+ raise ExportError("'{}': Animation modifiers are not allowed on this camera type.", self.id_data.name)
class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
@@ -47,65 +59,101 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
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")
- obj_sdl_anim = StringProperty(name="SDL Animation",
- description="Name of the SDL variable to use for this animation",
- options=set())
+ subanimations = PointerProperty(type=PlasmaAnimationCollection)
+
+ def pre_export(self, exporter, bo):
+ # We want to run the animation converting early in the process because of dependencies on
+ # the animation data being available. Especially in the camera exporter.
+ so = exporter.mgr.find_create_object(plSceneObject, bl=bo)
+ self.convert_object_animations(exporter, bo, so, self.subanimations)
+
+ def convert_object_animations(self, exporter, bo, so, anims: Optional[Iterable] = None):
+ if not anims:
+ anims = [self.subanimations.entire_animation]
+ aganims = list(self._export_ag_anims(exporter, bo, so, anims))
+
+ # Defer creation of the private animation until after the converter has been executed.
+ # Just because we have some FCurves doesn't mean they will produce anything particularly
+ # useful. Some versions of Uru will crash if we feed it an empty animation, so yeah.
+ if aganims:
+ agmod, agmaster = exporter.animation.get_anigraph_objects(bo, so)
+ agmod.channelName = self.id_data.name
+ for i in aganims:
+ agmaster.addPrivateAnim(i.key)
+
+ def _export_ag_anims(self, exporter, bo, so, anims: Iterable) -> Iterator[plAGAnim]:
+ action = self.blender_action
+ converter = exporter.animation
+
+ for anim in anims:
+ assert anim is not None, "Animation should not be None!"
+ anim_name = anim.animation_name
+
+ # If this is the entire animation, the range that anim.start and anim.end will return
+ # is the range of all of the keyframes. Of course, we don't nesecarily convert every
+ # keyframe, so we will defer figuring out the range until the conversion is complete.
+ if not anim.is_entire_animation:
+ start, end = anim.start, anim.end
+ start, end = min((start, end)), max((start, end))
+ else:
+ start, end = None, None
- @property
- def anim_type(self):
- return plAgeGlobalAnim if self.enabled and self.obj_sdl_anim else plATCAnim
+ applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end)
+ if not applicators:
+ exporter.report.warn("Animation '{}' generated no applicators. Nothing will be exported.",
+ anim_name, indent=2)
+ continue
- def export(self, exporter, bo, so):
- action = self.blender_action
- anim_mod = bo.plasma_modifiers.animation
-
- # Do not create the private animation here. The animation converter itself does this
- # before we reach this point. If it does not create an animation, then we might create an
- # empty animation that crashes Uru.
- atcanim = exporter.mgr.find_object(anim_mod.anim_type, so=so)
- if atcanim is None:
- return
-
- if not isinstance(atcanim, plAgeGlobalAnim):
- atcanim.autoStart = self.auto_start
- atcanim.loop = self.loop
-
- # Simple start and loop info for ATC
- if action is not None:
- markers = action.pose_markers
- initial_marker = markers.get(self.initial_marker)
- if initial_marker is not None:
- atcanim.initial = _convert_frame_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)
+ pClass = plAgeGlobalAnim if anim.sdl_var else plATCAnim
+ aganim = exporter.mgr.find_create_object(pClass, bl=bo, so=so, name="{}_{}".format(bo.name, anim_name))
+ aganim.name = anim_name
+ aganim.start, aganim.end = converter.get_frame_time_range(*applicators, so=so)
+ for i in applicators:
+ aganim.addApplicator(i)
+
+ if isinstance(aganim, plATCAnim):
+ aganim.autoStart = anim.auto_start
+ aganim.loop = anim.loop
+
+ if action is not None:
+ markers = action.pose_markers
+ initial_marker = markers.get(anim.initial_marker)
+ if initial_marker is not None:
+ aganim.initial = converter.convert_frame_time(initial_marker.frame)
else:
- atcanim.loopStart = atcanim.start
- loop_end = markers.get(self.loop_end)
- if loop_end is not None:
- atcanim.loopEnd = _convert_frame_time(loop_end.frame)
- else:
- atcanim.loopEnd = atcanim.end
- else:
- if self.loop:
- atcanim.loopStart = atcanim.start
- atcanim.loopEnd = atcanim.end
+ aganim.initial = -1.0
+ if anim.loop:
+ loop_start = markers.get(anim.loop_start)
+ if loop_start is not None:
+ aganim.loopStart = converter.convert_frame_time(loop_start.frame)
+ else:
+ aganim.loopStart = aganim.start
+ loop_end = markers.get(anim.loop_end)
+ if loop_end is not None:
+ aganim.loopEnd = converter.convert_frame_time(loop_end.frame)
+ else:
+ aganim.loopEnd = aganim.end
+ else:
+ if anim.loop:
+ aganim.loopStart = aganim.start
+ aganim.loopEnd = aganim.end
+
+ # Fixme? Not sure if we really need to expose this...
+ aganim.easeInMin = 1.0
+ aganim.easeInMax = 1.0
+ aganim.easeInLength = 1.0
+ aganim.easeOutMin = 1.0
+ aganim.easeOutMax = 1.0
+ aganim.easeOutLength = 1.0
+
+ if isinstance(aganim, plAgeGlobalAnim):
+ aganim.globalVarName = anim.sdl_var
+
+ yield aganim
+
+ @classmethod
+ def register(cls):
+ PlasmaAnimationCollection.register_entire_animation(bpy.types.Object, cls)
class AnimGroupObject(idprops.IDPropObjectMixin, bpy.types.PropertyGroup):
diff --git a/korman/properties/prop_anim.py b/korman/properties/prop_anim.py
new file mode 100644
index 0000000..47fb593
--- /dev/null
+++ b/korman/properties/prop_anim.py
@@ -0,0 +1,323 @@
+# 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 copy import deepcopy
+import functools
+import itertools
+from typing import Iterable, Iterator
+
+class PlasmaAnimation(bpy.types.PropertyGroup):
+ ENTIRE_ANIMATION = "(Entire Animation)"
+
+ def _get_animation_name(self):
+ if self.is_entire_animation:
+ return self.ENTIRE_ANIMATION
+ else:
+ return self.animation_name_value
+
+ def _set_animation_name(self, value):
+ if not self.is_entire_animation:
+ self.animation_name_value = value
+
+ _PROPERTIES = {
+ "animation_name": {
+ "type": StringProperty,
+ "property": {
+ "name": "Animation Name",
+ "description": "Name of this (sub-)animation",
+ "get": _get_animation_name,
+ "set": _set_animation_name,
+ },
+ },
+ "start": {
+ "type": IntProperty,
+ "property": {
+ "name": "Start",
+ "description": "The first frame of this (sub-)animation",
+ "soft_min": 0,
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.begin",
+ bpy.types.Texture: "plasma_layer.anim_begin",
+ },
+ },
+ "end": {
+ "type": IntProperty,
+ "property": {
+ "name": "End",
+ "description": "The last frame of this (sub-)animation",
+ "soft_min": 0,
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.end",
+ bpy.types.Texture: "plasma_layer.anim_end",
+ },
+ },
+ "auto_start": {
+ "type": BoolProperty,
+ "property": {
+ "name": "Auto Start",
+ "description": "Automatically start this animation on link-in",
+ "default": True,
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.auto_start",
+ bpy.types.Texture: "plasma_layer.anim_auto_start",
+ },
+ },
+ "loop": {
+ "type": BoolProperty,
+ "property": {
+ "name": "Loop Anim",
+ "description": "Loop the animation",
+ "default": True,
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.loop",
+ bpy.types.Texture: "plasma_layer.anim_loop",
+ },
+ },
+ "initial_marker": {
+ "type": StringProperty,
+ "property": {
+ "name": "Start Marker",
+ "description": "Marker indicating the default start point",
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.initial_marker",
+ bpy.types.Texture: "plasma_layer.anim_initial_marker",
+ }
+ },
+ "loop_start": {
+ "type": StringProperty,
+ "property": {
+ "name": "Loop Start",
+ "description": "Marker indicating where the default loop begins",
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.loop_start",
+ bpy.types.Texture: "plasma_layer.anim_loop_start",
+ },
+ },
+ "loop_end": {
+ "type": StringProperty,
+ "property": {
+ "name": "Loop End",
+ "description": "Marker indicating where the default loop ends",
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.loop_end",
+ bpy.types.Texture: "plasma_layer.anim_loop_end",
+ },
+ },
+ "sdl_var": {
+ "type": StringProperty,
+ "property": {
+ "name": "SDL Variable",
+ "description": "Name of the SDL variable to use to control the playback of this animation",
+ },
+ "entire_animation": {
+ bpy.types.Object: "plasma_modifiers.animation.obj_sdl_anim",
+ bpy.types.Texture: "plasma_layer.anim_sdl_var",
+ },
+ },
+ }
+
+ @classmethod
+ def iter_frame_numbers(cls, id_data) -> Iterator[int]:
+ # It would be nice if this could use self.iter_fcurves, but the property that uses this
+ # is not actually of type PlasmaAnimation. Meaning that self is some other object (great).
+ fcurves = itertools.chain.from_iterable((id.animation_data.action.fcurves
+ for id in cls._iter_my_ids(id_data)
+ if id.animation_data and id.animation_data.action))
+ frame_numbers = (keyframe.co[0] for fcurve in fcurves for keyframe in fcurve.keyframe_points)
+ yield from frame_numbers
+
+ @classmethod
+ def _iter_my_ids(cls, id_data: bpy.types.ID) -> Iterator[bpy.types.ID]:
+ yield id_data
+ if isinstance(id_data, bpy.types.Object):
+ if id_data.data is not None:
+ yield id_data.data
+ elif isinstance(id_data, bpy.types.Texture):
+ material = getattr(bpy.context, "material", None)
+ if material is not None and material in id_data.users_material:
+ yield material
+
+ def _get_entire_start(self) -> int:
+ try:
+ return min(PlasmaAnimation.iter_frame_numbers(self.id_data))
+ except ValueError:
+ return 0
+
+ def _get_entire_end(self) -> int:
+ try:
+ return max(PlasmaAnimation.iter_frame_numbers(self.id_data))
+ except ValueError:
+ return 0
+
+ def _set_dummy(self, value: int) -> None:
+ pass
+
+ _ENTIRE_ANIMATION_PROPERTIES = {
+ "start": {
+ "get": _get_entire_start,
+ "set": _set_dummy,
+ },
+ "end": {
+ "get": _get_entire_end,
+ "set": _set_dummy,
+ },
+ }
+
+ @classmethod
+ def _get_from_class_lut(cls, id_data, lut):
+ # This is needed so that things like bpy.types.ImageTexture can map to bpy.types.Texture.
+ # Note that only one level of bases is attempted. Right now, that is sufficient for our
+ # use case and what Blender does, but beware in the future.
+ for i in itertools.chain((id_data.__class__,), id_data.__class__.__bases__):
+ value = lut.get(i)
+ if value is not None:
+ return value
+
+ @classmethod
+ def _make_prop_getter(cls, prop_name: str, lut, default=None):
+ def proc(self):
+ if self.is_entire_animation:
+ attr_path = cls._get_from_class_lut(self.id_data, lut)
+ if attr_path is not None:
+ prop_delim = attr_path.rfind('.')
+ prop_group = self.id_data.path_resolve(attr_path[:prop_delim])
+ return getattr(prop_group, attr_path[prop_delim+1:])
+ else:
+ return default
+ else:
+ return getattr(self, "{}_value".format(prop_name))
+ return proc
+
+ @classmethod
+ def _make_prop_setter(cls, prop_name: str, lut):
+ def proc(self, value):
+ if self.is_entire_animation:
+ attr_path = cls._get_from_class_lut(self.id_data, lut)
+ if attr_path is not None:
+ prop_delim = attr_path.rfind('.')
+ prop_group = self.id_data.path_resolve(attr_path[:prop_delim])
+ setattr(prop_group, attr_path[prop_delim+1:], value)
+ else:
+ setattr(self, "{}_value".format(prop_name), value)
+ return proc
+
+ @classmethod
+ def register(cls):
+ # Register accessor and storage properties on this property group - we need these to be
+ # separate because the old style single animation per-ID settings should map to the new
+ # "(Entire Animation)" sub-animation. This will allow us to "trivially" allow downgrading
+ # to previous Korman versions without losing data.
+ for prop_name, definitions in cls._PROPERTIES.items():
+ props, kwargs = definitions["property"], {}
+ if "options" not in props:
+ kwargs["options"] = set()
+
+ value_kwargs = deepcopy(kwargs)
+ value_kwargs["options"].add("HIDDEN")
+ value_props = { key: value for key, value in props.items() if key not in {"get", "set", "update"} }
+ setattr(cls, "{}_value".format(prop_name), definitions["type"](**value_props, **value_kwargs))
+
+ needs_accessors = "get" not in props and "set" not in props
+ if needs_accessors:
+ # We have to use these weirdo wrappers because Blender only accepts function objects
+ # for its property callbacks, not arbitrary callables eg lambdas, functools.partials.
+ kwargs["get"] = cls._make_prop_getter(prop_name, definitions["entire_animation"], props.get("default"))
+ kwargs["set"] = cls._make_prop_setter(prop_name, definitions["entire_animation"])
+ setattr(cls, prop_name, definitions["type"](**props, **kwargs))
+
+ @classmethod
+ def register_entire_animation(cls, id_type, rna_type):
+ """Registers all of the properties for the old style single animation per ID animations onto
+ the property group given by `rna_type`. These were previously directly registered but are
+ now abstracted away to serve as the backing store for the new "entire animation" method."""
+ for prop_name, definitions in cls._PROPERTIES.items():
+ lut = definitions.get("entire_animation", {})
+ path_from_id = lut.get(id_type)
+ if path_from_id:
+ attr_name = path_from_id[path_from_id.rfind('.')+1:]
+ kwargs = deepcopy(definitions["property"])
+ kwargs.update(cls._ENTIRE_ANIMATION_PROPERTIES.get(prop_name, {}))
+ setattr(rna_type, attr_name, definitions["type"](**kwargs))
+
+ is_entire_animation = BoolProperty(default=False, options={"HIDDEN"})
+
+
+class PlasmaAnimationCollection(bpy.types.PropertyGroup):
+ """The magical turdfest!"""
+
+ def _get_active_index(self) -> int:
+ # Remember: this is bound to an impostor object by Blender's rna system
+ PlasmaAnimationCollection._ensure_default_animation(self)
+ return self.active_animation_index_value
+
+ def _set_active_index(self, value: int) -> None:
+ self.active_animation_index_value = value
+
+ active_animation_index = IntProperty(get=_get_active_index, set=_set_active_index,
+ options={"HIDDEN"})
+ active_animation_index_value = IntProperty(options={"HIDDEN"})
+
+ # Animations backing store--don't use this except to display the list in Blender's UI.
+ animation_collection = CollectionProperty(type=PlasmaAnimation)
+
+ def _get_hack(self):
+ if not any((i.is_entire_animation for i in self.animation_collection)):
+ entire_animation = self.animation_collection.add()
+ entire_animation.is_entire_animation = True
+ return True
+
+ def _set_hack(self, value):
+ raise RuntimeError("Don't set this.")
+
+ # Blender locks properties to a read-only state during the UI draw phase. This is a problem
+ # because we may need to initialize a default animation (or the entire animation) when we
+ # want to observe it in the UI. That restriction is dropped, however, when RNA poperties are
+ # being observed or set. So, this will allow us to initialize the entire animation in the
+ # UI phase at the penalty of potentially having to loop through the animation collection twice.
+ request_entire_animation = BoolProperty(get=_get_hack, set=_set_hack, options={"HIDDEN"})
+
+ @property
+ def animations(self) -> Iterable[PlasmaAnimation]:
+ self._ensure_default_animation()
+ return self.animation_collection
+
+ def __iter__(self) -> Iterator[PlasmaAnimation]:
+ return iter(self.animations)
+
+ def _ensure_default_animation(self) -> None:
+ if not bool(self.animation_collection):
+ assert self.request_entire_animation
+
+ @property
+ def entire_animation(self) -> PlasmaAnimation:
+ assert self.request_entire_animation
+ return next((i for i in self.animation_collection if i.is_entire_animation))
+
+ @classmethod
+ def register_entire_animation(cls, id_type, rna_type):
+ # Forward helper so we can get away with only importing this klass
+ PlasmaAnimation.register_entire_animation(id_type, rna_type)
diff --git a/korman/properties/prop_camera.py b/korman/properties/prop_camera.py
index 41724fb..5d46fc7 100644
--- a/korman/properties/prop_camera.py
+++ b/korman/properties/prop_camera.py
@@ -253,3 +253,8 @@ class PlasmaCamera(bpy.types.PropertyGroup):
description="",
options=set())
active_transition_index = IntProperty(options={"HIDDEN"})
+
+ @property
+ def allow_animations(self) -> bool:
+ """Check to see if animations are allowed on this camera"""
+ return self.camera_type == "fixed"
diff --git a/korman/properties/prop_texture.py b/korman/properties/prop_texture.py
index 7ae8312..78d83ad 100644
--- a/korman/properties/prop_texture.py
+++ b/korman/properties/prop_texture.py
@@ -17,6 +17,7 @@ import bpy
from bpy.props import *
from .. import idprops
+from .prop_anim import PlasmaAnimationCollection
class EnvMapVisRegion(idprops.IDPropObjectMixin, bpy.types.PropertyGroup):
enabled = BoolProperty(default=True)
@@ -56,16 +57,6 @@ class PlasmaLayer(bpy.types.PropertyGroup):
type=EnvMapVisRegion)
active_region_index = IntProperty(options={"HIDDEN"})
- anim_auto_start = BoolProperty(name="Auto Start",
- description="Automatically start layer animation",
- default=True)
- anim_loop = BoolProperty(name="Loop",
- description="Loop layer animation",
- default=True)
- anim_sdl_var = StringProperty(name="SDL Variable",
- description="Name of the SDL Variable to use for this animation",
- options=set())
-
is_detail_map = BoolProperty(name="Detail Fade",
description="Texture fades out as distance from the camera increases",
default=False,
@@ -108,3 +99,9 @@ class PlasmaLayer(bpy.types.PropertyGroup):
("1024", "1024x1024", "")],
default="1024",
options=set())
+
+ subanimations = PointerProperty(type=PlasmaAnimationCollection)
+
+ @classmethod
+ def register(cls):
+ PlasmaAnimationCollection.register_entire_animation(bpy.types.Texture, cls)
diff --git a/korman/ui/__init__.py b/korman/ui/__init__.py
index 5f0ddca..d5a2ac6 100644
--- a/korman/ui/__init__.py
+++ b/korman/ui/__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 .ui_anim import *
from .ui_camera import *
from .ui_image import *
from .ui_lamp import *
diff --git a/korman/ui/modifiers/anim.py b/korman/ui/modifiers/anim.py
index 614ff07..f781d35 100644
--- a/korman/ui/modifiers/anim.py
+++ b/korman/ui/modifiers/anim.py
@@ -16,6 +16,7 @@
import bpy
from .. import ui_list
+from .. import ui_anim
def _check_for_anim(layout, modifier):
try:
@@ -31,21 +32,13 @@ def animation(modifier, layout, context):
if action is None:
return
- split = layout.split()
- col = split.column()
- col.prop(modifier, "auto_start")
- col = split.column()
- col.prop(modifier, "loop")
-
- if action:
- layout.prop_search(modifier, "initial_marker", action, "pose_markers", icon="PMARKER")
- col = layout.column()
- col.enabled = modifier.loop and not modifier.obj_sdl_anim
- col.prop_search(modifier, "loop_start", action, "pose_markers", icon="PMARKER")
- col.prop_search(modifier, "loop_end", action, "pose_markers", icon="PMARKER")
- layout.separator()
- layout.prop(modifier, "obj_sdl_anim")
-
+ if modifier.id_data.type == "CAMERA":
+ if not modifier.id_data.data.plasma_camera.allow_animations:
+ layout.label("Animation modifiers are not allowed on this camera type!", icon="ERROR")
+ return
+
+ ui_anim.draw_multi_animation(layout, "object", modifier, "subanimations")
+
def animation_filter(modifier, layout, context):
split = layout.split()
diff --git a/korman/ui/ui_anim.py b/korman/ui/ui_anim.py
new file mode 100644
index 0000000..3741854
--- /dev/null
+++ b/korman/ui/ui_anim.py
@@ -0,0 +1,72 @@
+# 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 . import ui_list
+
+class AnimListUI(bpy.types.UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
+ layout.label(item.animation_name, icon="ANIM")
+
+
+def draw_multi_animation(layout, context_attr, prop_base, anims_collection_name, *, use_box=False, **kwargs):
+ # Yeah, I know this looks weird, but it lets us pretend that the PlasmaAnimationCollection
+ # is a first class collection property. Fancy.
+ anims = getattr(prop_base, anims_collection_name)
+ kwargs.setdefault("rows", 2)
+ ui_list.draw_list(layout, "AnimListUI", context_attr, anims,
+ "animation_collection", "active_animation_index",
+ name_prop="animation_name", name_prefix="Animation",
+ **kwargs)
+ try:
+ anim = anims.animation_collection[anims.active_animation_index]
+ except IndexError:
+ pass
+ else:
+ sub = layout.box() if use_box else layout
+ draw_single_animation(sub, anim)
+
+def draw_single_animation(layout, anim):
+ row = layout.row()
+ row.enabled = not anim.is_entire_animation
+ row.prop(anim, "animation_name", text="Name", icon="ANIM")
+
+ split = layout.split()
+ col = split.column()
+ col.label("Playback Settings:")
+ col.prop(anim, "auto_start")
+ col.prop(anim, "loop")
+ col = split.column(align=True)
+ col.label("Animation Range:")
+ col.enabled = not anim.is_entire_animation
+ # Not alerting on exceeding the range of the keyframes - that may be intentional.
+ col.alert = anim.start >= anim.end
+ col.prop(anim, "start")
+ col.prop(anim, "end")
+
+ # Only doing this crap for object animations, FTS on material animations.
+ if isinstance(anim.id_data, bpy.types.Object):
+ action = getattr(anim.id_data.animation_data, "action", None)
+ if action:
+ layout.separator()
+ layout.prop_search(anim, "initial_marker", action, "pose_markers", icon="PMARKER")
+ col = layout.column()
+ col.active = anim.loop and not anim.sdl_var
+ col.prop_search(anim, "loop_start", action, "pose_markers", icon="PMARKER")
+ col.prop_search(anim, "loop_end", action, "pose_markers", icon="PMARKER")
+
+ layout.separator()
+ layout.prop(anim, "sdl_var")
diff --git a/korman/ui/ui_camera.py b/korman/ui/ui_camera.py
index c8d3192..ca30deb 100644
--- a/korman/ui/ui_camera.py
+++ b/korman/ui/ui_camera.py
@@ -204,19 +204,25 @@ class PlasmaCameraAnimationPanel(CameraButtonsPanel, bpy.types.Panel):
split = layout.split()
col = split.column()
col.label("Animation:")
- col.active = props.anim_enabled and any(helpers.fetch_fcurves(context.object))
+ anim_enabled = props.anim_enabled or context.object.plasma_modifiers.animation.enabled
+ col.active = anim_enabled and context.object.plasma_object.has_animation_data
col.prop(props, "start_on_push")
col.prop(props, "stop_on_pop")
col.prop(props, "reset_on_pop")
col = split.column()
col.active = camera.camera_type == "rail"
+ invalid = camera.camera_type == "rail" and not context.object.plasma_object.has_transform_animation
+ col.alert = invalid
col.label("Rail:")
col.prop(props, "rail_pos", text="")
+ if invalid:
+ col.label("Rail cameras must have a transformation animation!", icon="ERROR")
def draw_header(self, context):
- self.layout.active = any(helpers.fetch_fcurves(context.object))
- self.layout.prop(context.camera.plasma_camera.settings, "anim_enabled", text="")
+ self.layout.active = context.object.plasma_object.has_animation_data
+ if not context.object.plasma_modifiers.animation.enabled:
+ self.layout.prop(context.camera.plasma_camera.settings, "anim_enabled", text="")
class PlasmaCameraViewPanel(CameraButtonsPanel, bpy.types.Panel):
diff --git a/korman/ui/ui_texture.py b/korman/ui/ui_texture.py
index 3bcd387..a346d47 100644
--- a/korman/ui/ui_texture.py
+++ b/korman/ui/ui_texture.py
@@ -16,6 +16,7 @@
import bpy
from . import ui_list
+from . import ui_anim
class TextureButtonsPanel:
bl_space_type = "PROPERTIES"
@@ -70,35 +71,23 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):
split = layout.split()
col = split.column()
- sub = col.column()
- sub.label("Animation:")
- sub.active = self._has_animation_data(context) and not use_stencil
- sub.prop(layer_props, "anim_auto_start")
- sub.prop(layer_props, "anim_loop")
- sub.separator()
- sub.label("SDL Animation:")
- sub.prop(layer_props, "anim_sdl_var", text="")
- # Yes, two separator.
- col.separator()
- col.separator()
- sub = col.column()
- sub.active = texture.type == "IMAGE" and texture.image is None
- sub.prop_menu_enum(layer_props, "dynatext_resolution", text="Dynamic Text Size")
-
- col = split.column()
- col.label("Miscellaneous:")
- col.active = not use_stencil
- col.prop(layer_props, "opacity", text="Opacity")
- col.separator()
-
- col = col.column()
- col.enabled = True
col.label("Z Depth:")
col.prop(layer_props, "alpha_halo")
col.prop(layer_props, "skip_depth_write")
col.prop(layer_props, "skip_depth_test")
col.prop(layer_props, "z_bias")
+ col = split.column()
+ col.label("Miscellaneous:")
+ sub = col.column()
+ sub.active = not use_stencil
+ sub.prop(layer_props, "opacity", text="Opacity")
+ sub.separator()
+ sub = col.column()
+ sub.active = texture.type == "IMAGE" and texture.image is None
+ sub.prop_menu_enum(layer_props, "dynatext_resolution", text="Dynamic Text Size")
+
+ layout.separator()
split = layout.split()
col = split.column()
detail_map_candidate = texture.type == "IMAGE" and texture.use_mipmap
@@ -114,7 +103,18 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):
col.prop(layer_props, "detail_opacity_start")
col.prop(layer_props, "detail_opacity_stop")
- def _has_animation_data(self, context):
+
+class PlasmaLayerAnimationPanel(TextureButtonsPanel, bpy.types.Panel):
+ bl_label = "Plasma Layer Animations"
+
+ @classmethod
+ def poll(cls, context):
+ if super().poll(context):
+ return cls._has_animation_data(context)
+ return False
+
+ @classmethod
+ def _has_animation_data(cls, context):
tex = getattr(context, "texture", None)
if tex is not None:
if tex.animation_data is not None:
@@ -126,3 +126,7 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):
return True
return False
+
+ def draw(self, context):
+ ui_anim.draw_multi_animation(self.layout, "texture", context.texture.plasma_layer,
+ "subanimations", use_box=True)