Browse Source

Merge pull request #282 from Hoikas/multi_animations

Multi animations
pull/285/head
Adam Johnson 3 years ago committed by GitHub
parent
commit
7cc5bf1924
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  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