Browse Source

Add support for multiple animations per object.

I don't really want to talk about it. *Gulp*. Anyway, you define
multiple animations on either the animation modifier or the textures
panel. The UIs have all been unified. By default, you have an "(Entire
Animation)" that represents the old single animation. You may create
additional sub-animations over an arbitrary range of keyframes. Once
other animations are defined, the "(Entire Animation)" may be deleted.
However, if all subanimations are deleted, the "(Entire Animation)" will
resurect itself with its last known settings.

Behavior change: object animations (except for fixed cameras) now
REQUIRE an animation modifier be attached to export. It is now an error
to attach an animation modifier to any camera except for a fixed camera.
pull/282/head
Adam Johnson 3 years ago
parent
commit
b520bfedd1
Signed by: Hoikas
GPG Key ID: 0B6515D6FF6F271E
  1. 294
      korman/exporter/animation.py
  2. 5
      korman/exporter/camera.py
  3. 6
      korman/exporter/convert.py
  4. 166
      korman/exporter/material.py
  5. 119
      korman/nodes/node_messages.py
  6. 1
      korman/properties/__init__.py
  7. 162
      korman/properties/modifiers/anim.py
  8. 323
      korman/properties/prop_anim.py
  9. 5
      korman/properties/prop_camera.py
  10. 17
      korman/properties/prop_texture.py
  11. 1
      korman/ui/__init__.py
  12. 23
      korman/ui/modifiers/anim.py
  13. 72
      korman/ui/ui_anim.py
  14. 12
      korman/ui/ui_camera.py
  15. 52
      korman/ui/ui_texture.py

294
korman/exporter/animation.py

@ -14,15 +14,17 @@
# along with Korman. If not, see <http://www.gnu.org/licenses/>.
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]:

5
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

6
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)

166
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:

119
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

1
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 *

162
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):

323
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 <http://www.gnu.org/licenses/>.
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)

5
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"

17
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)

1
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 <http://www.gnu.org/licenses/>.
from .ui_anim import *
from .ui_camera import *
from .ui_image import *
from .ui_lamp import *

23
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()

72
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 <http://www.gnu.org/licenses/>.
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")

12
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):

52
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)

Loading…
Cancel
Save