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. 21
      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/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
import bpy import bpy
from collections import defaultdict from collections import defaultdict
import functools import functools
import itertools import itertools
import math import math
import mathutils import mathutils
from PyHSPlasma import *
from typing import * from typing import *
import weakref import weakref
from PyHSPlasma import *
from . import utils from . import utils
class AnimationConverter: class AnimationConverter:
@ -30,12 +32,13 @@ class AnimationConverter:
self._exporter = weakref.ref(exporter) self._exporter = weakref.ref(exporter)
self._bl_fps = bpy.context.scene.render.fps 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 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: if not bo.plasma_object.has_animation_data:
return return []
def fetch_animation_data(id_data): def fetch_animation_data(id_data):
if id_data is not None: if id_data is not None:
@ -44,8 +47,6 @@ class AnimationConverter:
return action, getattr(action, "fcurves", []) return action, getattr(action, "fcurves", [])
return None, [] 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) obj_action, obj_fcurves = fetch_animation_data(bo)
data_action, data_fcurves = fetch_animation_data(bo.data) 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. # things that aren't the typical position, rotation, scale animations.
applicators = [] applicators = []
if isinstance(bo.data, bpy.types.Camera): 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: 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: 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): if isinstance(bo.data, bpy.types.Lamp):
lamp = bo.data 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): 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): if isinstance(lamp, bpy.types.PointLamp):
applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp)) applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))
# Check to make sure we have some valid animation applicators before proceeding. return [i for i in applicators if i is not None]
if not any(applicators):
return def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
start: Optional[int], end: Optional[int]):
# There is a race condition in the client with animation loading. It expects for modifiers has_fov_anim = False
# 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):
if data_fcurves: if data_fcurves:
# The hard part about this crap is that FOV animations are not stored in ATC Animations # 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 # instead, FOV animation keyframes are held inside of the camera modifier. Cyan's solution
@ -130,33 +92,32 @@ class AnimationConverter:
degrees = math.degrees degrees = math.degrees
fov_fcurve.update() 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. # Well, now that we have multiple animations, we are using our fancier FCurve processing.
keyframes = fov_fcurve.keyframe_points # 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) num_keyframes = len(keyframes)
has_fov_anim = bool(num_keyframes) has_fov_anim = bool(num_keyframes)
i = 0 i = 0
while i < num_keyframes: 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 # So remember, these are messages. When we hit a keyframe, we're dispatching a message
# representing the NEXT desired FOV. # representing the NEXT desired FOV.
this_frame_time = this_keyframe.co[0] / fps this_keyframe = keyframes[i]
next_frame_num, next_frame_value = next_keyframe.co next_keyframe = keyframes[0] if i+1 == num_keyframes else keyframes[i+1]
next_frame_time = next_frame_num / fps
# This message is held on the camera modifier and sent to the animation... It calls # 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. # 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 = plEventCallbackMsg()
cb_msg.event = kTime cb_msg.event = kTime
cb_msg.eventTime = this_frame_time cb_msg.eventTime = this_keyframe.frame_time
cb_msg.index = i cb_msg.index = i
cb_msg.repeats = -1 cb_msg.repeats = -1
cb_msg.addReceiver(cam_key) cb_msg.addReceiver(cam_key)
anim_msg = plAnimCmdMsg() anim_msg = plAnimCmdMsg()
anim_msg.animName = "(Entire Animation)" anim_msg.animName = anim_name
anim_msg.time = this_frame_time anim_msg.time = this_keyframe.frame_time
anim_msg.sender = anim_key anim_msg.sender = anim_key
anim_msg.addReceiver(anim_key) anim_msg.addReceiver(anim_key)
anim_msg.addCallback(cb_msg) anim_msg.addCallback(cb_msg)
@ -166,30 +127,29 @@ class AnimationConverter:
# This is the message actually changes the FOV. Interestingly, it is sent at # 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 # 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. # parameters and passes them to the brain. Can't make this stuff up.
cam_msg = plCameraMsg() # Be sure to only export each instruction once.
cam_msg.addReceiver(cam_key) if not any((msg.config.accel == next_keyframe.frame_time for msg in camera.fovInstructions)):
cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True) cam_msg = plCameraMsg()
cam_config = cam_msg.config cam_msg.addReceiver(cam_key)
cam_config.accel = next_frame_time # Yassss... cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True)
cam_config.fovW = degrees(next_frame_value) cam_config = cam_msg.config
cam_config.fovH = degrees(next_frame_value * aspect) cam_config.accel = next_keyframe.frame_time # Yassss...
camera.addFOVInstruction(cam_msg) cam_config.fovW = degrees(next_keyframe.values[0])
cam_config.fovH = degrees(next_keyframe.values[0] * aspect)
camera.addFOVInstruction(cam_msg)
i += 1 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 # 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 # returned from here... At bare minimum, we'll need the applicator with an empty
# CompoundController. This should be sufficient to keep CWE from crashing... # 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) applicator = self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis,
camera = locals().get("camera", self._mgr.find_create_object(plCameraModifier, so=so)) allow_empty=has_fov_anim, start=start, end=end)
camera = self._mgr.find_create_object(plCameraModifier, so=so)
camera.animated = applicator is not None camera.animated = applicator is not None
return applicator 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: if not fcurves:
return None return None
@ -210,7 +170,9 @@ class AnimationConverter:
return map(lambda x: x * -1.0, color) return map(lambda x: x * -1.0, color)
else: else:
return color 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: if color_keyframes and lamp.use_specular:
channel = plPointControllerChannel() channel = plPointControllerChannel()
channel.controller = self._make_point3_controller(color_keyframes, color_bez) channel.controller = self._make_point3_controller(color_keyframes, color_bez)
@ -229,7 +191,9 @@ class AnimationConverter:
diffuse_channels = dict(color=3, energy=1) diffuse_channels = dict(color=3, energy=1)
diffuse_defaults = dict(color=lamp.color, energy=lamp.energy) diffuse_defaults = dict(color=lamp.color, energy=lamp.energy)
diffuse_fcurves = color_curves + [energy_curve,] 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: if not diffuse_keyframes:
return None return None
@ -241,7 +205,7 @@ class AnimationConverter:
applicator.channel = channel applicator.channel = channel
yield applicator 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: if not fcurves:
return None return None
@ -264,7 +228,8 @@ class AnimationConverter:
if distance_fcurve is not None: if distance_fcurve is not None:
channel = plScalarControllerChannel() channel = plScalarControllerChannel()
channel.controller = self.make_scalar_leaf_controller(distance_fcurve, 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 = plOmniCutoffApplicator()
applicator.channelName = name applicator.channelName = name
applicator.channel = channel applicator.channel = channel
@ -275,7 +240,8 @@ class AnimationConverter:
if energy_fcurve is not None: if energy_fcurve is not None:
report.warn("Constant attenuation cannot be animated in Plasma", ident=3) report.warn("Constant attenuation cannot be animated in Plasma", ident=3)
elif falloff == "INVERSE_LINEAR": 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: if keyframes:
channel = plScalarControllerChannel() channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False) channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@ -286,7 +252,8 @@ class AnimationConverter:
elif falloff == "INVERSE_SQUARE": elif falloff == "INVERSE_SQUARE":
if self._mgr.getVer() >= pvMoul: if self._mgr.getVer() >= pvMoul:
report.port("Lamp {} Falloff animations are only supported in Myst Online: Uru Live", falloff, indent=3) 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: if keyframes:
channel = plScalarControllerChannel() channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False) channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@ -299,7 +266,7 @@ class AnimationConverter:
else: else:
report.warn("Lamp Falloff '{}' animations are not supported", falloff, ident=3) 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: if not fcurves:
return None return None
@ -320,7 +287,7 @@ class AnimationConverter:
# so yes, we must convert the same animation data again and again. # 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 # 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. # 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: if controller is not None:
channel = plScalarControllerChannel() channel = plScalarControllerChannel()
channel.controller = controller channel.controller = controller
@ -331,7 +298,7 @@ class AnimationConverter:
sound.sound.name, indent=2) sound.sound.name, indent=2)
break break
def _convert_spot_lamp_animation(self, name, fcurves, lamp): def _convert_spot_lamp_animation(self, name, fcurves, lamp, start, end):
if not fcurves: if not fcurves:
return None return None
@ -343,7 +310,8 @@ class AnimationConverter:
# Spot Outer is just the size keyframes... # Spot Outer is just the size keyframes...
if size_fcurve is not None: if size_fcurve is not None:
channel = plScalarControllerChannel() 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 = plSpotOuterApplicator()
applicator.channelName = name applicator.channelName = name
applicator.channel = channel applicator.channel = channel
@ -359,7 +327,8 @@ class AnimationConverter:
inner_fcurves = [blend_fcurve, size_fcurve] inner_fcurves = [blend_fcurve, size_fcurve]
inner_channels = dict(spot_blend=1, spot_size=1) inner_channels = dict(spot_blend=1, spot_size=1)
inner_defaults = dict(spot_blend=lamp.spot_blend, spot_size=lamp.spot_size) 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: if keyframes:
channel = plScalarControllerChannel() channel = plScalarControllerChannel()
@ -369,8 +338,10 @@ class AnimationConverter:
applicator.channel = channel applicator.channel = channel
yield applicator yield applicator
def _convert_transform_animation(self, bo, fcurves, xform, allow_empty=False) -> Union[None, plMatrixChannelApplicator]: def _convert_transform_animation(self, bo, fcurves, xform, *, allow_empty: Optional[bool] = False,
tm = self.convert_transform_controller(fcurves, bo.rotation_mode, xform, allow_empty) 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: if tm is None and not allow_empty:
return None return None
@ -384,13 +355,16 @@ class AnimationConverter:
return applicator 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: if not fcurves and not allow_empty:
return None return None
pos = self.make_pos_controller(fcurves, "location", xform.to_translation()) pos = self.make_pos_controller(fcurves, "location", xform.to_translation(), start=start, end=end)
rot = self.make_rot_controller(fcurves, rotation_mode, xform) rot = self.make_rot_controller(fcurves, rotation_mode, xform, start=start, end=end)
scale = self.make_scale_controller(fcurves, "scale", xform.to_scale()) 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 pos is None and rot is None and scale is None:
if not allow_empty: if not allow_empty:
return None return None
@ -421,7 +395,47 @@ class AnimationConverter:
else: else:
return self.get_anigraph_keys(bo, so)[1] 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: def convert_matrix_keyframe(**kwargs) -> hsMatrix44:
pos = kwargs[pos_path] pos = kwargs[pos_path]
scale = kwargs[scale_path] scale = kwargs[scale_path]
@ -440,16 +454,20 @@ class AnimationConverter:
channels = { pos_path: 3, scale_path: 3 } channels = { pos_path: 3, scale_path: 3 }
default_values = { pos_path: pos_default, scale_path: scale_default } 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: if not keyframes:
return None return None
# Now we make the controller # Now we make the controller
return self._make_matrix44_controller(keyframes) 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] 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: if not keyframes:
return None return None
@ -458,7 +476,9 @@ class AnimationConverter:
ctrl = self._make_point3_controller(keyframes, bez_chans) ctrl = self._make_point3_controller(keyframes, bez_chans)
return ctrl 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"}: 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] rot_curves = [i for i in fcurves if i.data_path == "rotation_{}".format(rotation_mode.lower()) and i.keyframe_points]
if not rot_curves: if not rot_curves:
@ -477,7 +497,8 @@ class AnimationConverter:
# Just dropping bezier stuff on the floor because Plasma does not support it, and # 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 # I think that opting into quaternion keyframes is a good enough indication that
# you're OK with 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: if keyframes:
return self._make_quat_controller(keyframes) return self._make_quat_controller(keyframes)
else: else:
@ -497,7 +518,8 @@ class AnimationConverter:
return result[:] return result[:]
euler_convert = convert_euler_keyframe if rotation_mode != "XYZ" else convert 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: if keyframes:
# Once again, quaternion keyframes do not support bezier interpolation. Ideally, # Once again, quaternion keyframes do not support bezier interpolation. Ideally,
# we would just drop support for rotation beziers entirely to simplify all this # we would just drop support for rotation beziers entirely to simplify all this
@ -507,9 +529,12 @@ class AnimationConverter:
else: else:
return self._make_quat_controller(keyframes) 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] 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: if not keyframes:
return None return None
@ -517,8 +542,11 @@ class AnimationConverter:
ctrl = self._make_scale_value_controller(keyframes, bez_chans) ctrl = self._make_scale_value_controller(keyframes, bez_chans)
return ctrl return ctrl
def make_scalar_leaf_controller(self, fcurve, convert=None) -> Union[None, plLeafController]: def make_scalar_leaf_controller(self, fcurve: bpy.types.FCurve,
keyframes, bezier = self._process_fcurve(fcurve, convert) 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: if not keyframes:
return None return None
@ -647,7 +675,7 @@ class AnimationConverter:
ctrl.keys = (exported_frames, keyframe_type) ctrl.keys = (exported_frames, keyframe_type)
return ctrl 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 """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.""" equivalent, eg due to a convert function, then they are discarded."""
@ -667,7 +695,8 @@ class AnimationConverter:
return [] return []
return [keyframes_sorted[i] for i in filtered_indices] 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""" """Like _process_keyframes, but for one fcurve"""
# Adapt from incoming single item sequence to a single argument. # Adapt from incoming single item sequence to a single argument.
@ -676,9 +705,9 @@ class AnimationConverter:
else: else:
single_convert = None single_convert = None
# Can't proxy to _process_fcurves because it only supports linear interoplation. # 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 assert convert is not None
if isinstance(raw_values, Dict): if isinstance(raw_values, Dict):
values = convert(**raw_values) 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) assert len(values) == num_channels, "Converter returned {} values but expected {}".format(len(values), num_channels)
return values return values
def _process_fcurves(self, fcurves : Sequence, channels : Dict[str, int], result_channels : int, def _process_fcurves(self, fcurves: Sequence, channels: Dict[str, int], result_channels: int,
convert : Callable, defaults : Dict[str, Union[float, Sequence]]) -> Sequence: 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. """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 Like `_process_keyframes()`, except the converter function is mandatory, and each
Blender `data_path` must have a fixed number of channels. Blender `data_path` must have a fixed number of channels.
@ -714,9 +744,18 @@ class AnimationConverter:
fcurve.update() fcurve.update()
grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve 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 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 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]): 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) 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""" """Groups all FCurves for the same frame together"""
keyframe_data = type("KeyFrameData", (), {}) keyframe_data = type("KeyFrameData", (), {})
fps, pi = self._bl_fps, math.pi fps, pi = self._bl_fps, math.pi
keyframes, fcurve_keyframes = {}, defaultdict(dict) 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 } indexed_fcurves = { fcurve.array_index: fcurve for fcurve in fcurves if fcurve is not None }
for i, fcurve in indexed_fcurves.items(): for i, fcurve in indexed_fcurves.items():
fcurve.update() fcurve.update()
for fkey in fcurve.keyframe_points: for fkey in filter(framenum_filter, fcurve.keyframe_points):
fcurve_keyframes[fkey.co[0]][i] = fkey fcurve_keyframes[fkey.co[0]][i] = fkey
def iter_values(frame_num, fkeys) -> Generator[float, None, None]: def iter_values(frame_num, fkeys) -> Generator[float, None, None]:

5
korman/exporter/camera.py

@ -171,8 +171,9 @@ class CameraConverter:
return brain return brain
def _export_fixed_camera(self, so, bo, props): def _export_fixed_camera(self, so, bo, props):
if props.anim_enabled: anim_mod = bo.plasma_modifiers.animation
self._exporter().animation.convert_object_animations(bo, so) 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) brain = self._mgr.find_create_object(plCameraBrain1_Fixed, so=so)
self._convert_brain(so, bo, props, brain) self._convert_brain(so, bo, props, brain)
return brain return brain

6
korman/exporter/convert.py

@ -299,26 +299,22 @@ class Exporter:
def _export_camera_blobj(self, so, bo): def _export_camera_blobj(self, so, bo):
# Hey, guess what? Blender's camera data is utter crap! # 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 camera = bo.data.plasma_camera
self.camera.export_camera(so, bo, camera.camera_type, camera.settings, camera.transitions) self.camera.export_camera(so, bo, camera.camera_type, camera.settings, camera.transitions)
def _export_empty_blobj(self, so, bo): def _export_empty_blobj(self, so, bo):
self.animation.convert_object_animations(bo, so) pass
def _export_lamp_blobj(self, so, bo): def _export_lamp_blobj(self, so, bo):
self.animation.convert_object_animations(bo, so)
self.light.export_rtlight(so, bo) self.light.export_rtlight(so, bo)
def _export_mesh_blobj(self, so, bo): def _export_mesh_blobj(self, so, bo):
self.animation.convert_object_animations(bo, so)
if bo.data.materials: if bo.data.materials:
self.mesh.export_object(bo, so) self.mesh.export_object(bo, so)
else: else:
self.report.msg("No material(s) on the ObData, so no drawables", indent=1) self.report.msg("No material(s) on the ObData, so no drawables", indent=1)
def _export_font_blobj(self, so, bo): def _export_font_blobj(self, so, bo):
self.animation.convert_object_animations(bo, so)
with utils.temporary_mesh_object(bo) as meshObj: with utils.temporary_mesh_object(bo) as meshObj:
if bo.data.materials: if bo.data.materials:
self.mesh.export_object(meshObj, so) self.mesh.export_object(meshObj, so)

166
korman/exporter/material.py

@ -21,7 +21,7 @@ import functools
import itertools import itertools
import math import math
from pathlib import Path from pathlib import Path
from typing import Iterator, Optional, Union from typing import Dict, Iterator, Optional, Union
import weakref import weakref
from PyHSPlasma import * from PyHSPlasma import *
@ -514,7 +514,69 @@ class MaterialConverter:
return layer return layer
def _export_layer_animations(self, bo, bm, tex_slot, idx, base_layer) -> plLayer: 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): def harvest_fcurves(bl_id, collection, data_path=None):
if bl_id is None: if bl_id is None:
@ -543,66 +605,33 @@ class MaterialConverter:
harvest_fcurves(bm, fcurves, tex_slot.path_from_id()) harvest_fcurves(bm, fcurves, tex_slot.path_from_id())
harvest_fcurves(texture, fcurves) harvest_fcurves(texture, fcurves)
if not fcurves: # Take the FCurves and ram them through our converters, hopefully returning some valid
return base_layer # animation controllers.
controllers = {}
# 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
for attr, converter in self._animation_exporters.items(): 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 ctrl is not None:
if layer_animation is None: controllers[attr] = ctrl
layer_animation = self.get_texture_animation_key(bo, bm, texture).object return controllers
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
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 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): def translate_color(color_sequence):
# See things like get_material_preshade # See things like get_material_preshade
result = converter(bo, bm, mathutils.Color(color_sequence)) result = converter(bo, bm, mathutils.Color(color_sequence))
return result.red, result.green, result.blue 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 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 # Dumb function to intercept the opacity values and properly flag the base layer
def process_opacity(value): def process_opacity(value):
self._handle_layer_opacity(base_layer, value) self._handle_layer_opacity(base_layer, value)
@ -610,18 +639,20 @@ class MaterialConverter:
for i in fcurves: for i in fcurves:
if i.data_path == "plasma_layer.opacity": 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 ctrl
return None 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: if tex_slot is not None:
path = tex_slot.path_from_id() path = tex_slot.path_from_id()
pos_path = "{}.offset".format(path) pos_path = "{}.offset".format(path)
scale_path = "{}.scale".format(path) scale_path = "{}.scale".format(path)
# Plasma uses the controller to generate a matrix44... so we have to produce a leaf controller # 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 ctrl
return None return None
@ -1308,22 +1339,19 @@ class MaterialConverter:
color = bm.diffuse_color color = bm.diffuse_color
return utils.color(color) return utils.color(color)
def get_texture_animation_key(self, bo, bm, texture): def get_texture_animation_key(self, bo, bm, texture, anim_name: str) -> Iterator[plKey]:
"""Finds or creates the appropriate key for sending messages to an animated Texture""" """Finds the appropriate key for sending messages to an animated Texture"""
if not anim_name:
tex_name = texture.name if texture is not None else "AutoLayer" anim_name = "(Entire Animation)"
if bo.type == "LAMP":
assert bm is None for top_layer in filter(lambda x: isinstance(x.object, plLayerAnimationBase), self.get_layers(bo, bm, texture)):
bm_name = bo.name base_layer = top_layer.object.bottomOfStack
else: needle = top_layer
assert bm is not None while needle is not None:
bm_name = bm.name if needle.name == "{}_{}".format(base_layer.name, anim_name):
if texture is not None and not tex_name in bm.texture_slots: yield needle
raise ExportError("Texture '{}' not used in Material '{}'".format(bm_name, tex_name)) break
needle = needle.object.underLay
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 _handle_layer_opacity(self, layer: plLayerInterface, value: float): def _handle_layer_opacity(self, layer: plLayerInterface, value: float):
if value < 100: if value < 100:

119
korman/nodes/node_messages.py

@ -105,19 +105,27 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
("TEXTURE", "Texture", "Texture Action")], ("TEXTURE", "Texture", "Texture Action")],
default="OBJECT") default="OBJECT")
def _poll_material_textures(self, value): def _poll_texture(self, value):
if self.target_object is None: # must be a legal option... but is it a member of this material... or, if no material,
return False # any of the materials attached to the object?
if self.target_material is None: if self.target_material is not None:
return False return value.name in self.target_material.texture_slots
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):
def _poll_mesh_materials(self, value): if value in (slot.texture for slot in i.texture_slots if slot and slot.texture):
if self.target_object is None: return True
return False
if self.target_object.type != "MESH":
return False 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", target_object = PointerProperty(name="Object",
description="Target object", description="Target object",
@ -125,11 +133,11 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode,
target_material = PointerProperty(name="Material", target_material = PointerProperty(name="Material",
description="Target material", description="Target material",
type=bpy.types.Material, type=bpy.types.Material,
poll=_poll_mesh_materials) poll=_poll_material)
target_texture = PointerProperty(name="Texture", target_texture = PointerProperty(name="Texture",
description="Target texture", description="Target texture",
type=bpy.types.Texture, type=bpy.types.Texture,
poll=_poll_material_textures) poll=_poll_texture)
go_to = EnumProperty(name="Go To", go_to = EnumProperty(name="Go To",
description="Where should the animation start?", 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")], ("kStop", "Stop", "When the action is stopped by a message")],
default="kEnd") 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): def draw_buttons(self, context, layout):
layout.prop(self, "anim_type") 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": if self.anim_type != "OBJECT":
col = layout.column()
col.enabled = self.target_object is not None
col.prop(self, "target_material") 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, "target_texture")
col.prop(self, "anim_name")
layout.prop(self, "go_to") layout.prop(self, "go_to")
layout.prop(self, "action") 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 # We're either sending this off to an AGMasterMod or a LayerAnim
obj = self.target_object obj = self.target_object
if obj is None:
self.raise_error("target object must be specified")
if self.anim_type == "OBJECT": 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: if not obj.plasma_object.has_animation_data:
self.raise_error("invalid animation") self.raise_error("invalid animation")
target = exporter.animation.get_animation_key(obj) target = (exporter.animation.get_animation_key(obj),)
else: else:
material = self.target_material material = self.target_material
if material is None:
self.raise_error("target material must be specified")
texture = self.target_texture texture = self.target_texture
if texture is None: if obj is None and material is None and texture is None:
self.raise_error("target texture must be specified") 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) target = exporter.mesh.material.get_texture_animation_key(obj, material, texture, self.anim_name)
if target is None: target = [i for i in target if not isinstance(i.object, (plAgeGlobalAnim, plLayerSDLAnimation))]
raise RuntimeError() if not target:
if isinstance(target.object, plLayerSDLAnimation): self.raise_error("No controllable animations were found.")
self.raise_error("Cannot control an SDL Animation") for i in target:
msg.addReceiver(target) msg.addReceiver(i)
# Check the enum properties to see what commands we need to add # 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): 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) msg.setCmd(cmd, True)
# Easier part starts here??? # Easier part starts here???
fps = bpy.context.scene.render.fps msg.animName = self.anim_name
if self.action == "kPlayToPercent": if self.action == "kPlayToPercent":
msg.time = self.play_to_percent msg.time = self.play_to_percent
elif self.action == "kPlayToTime": 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... # Implicit s better than explicit, I guess...
if self.loop_begin != self.loop_end: if self.loop_begin != self.loop_end:
# NOTE: loop name is not used in the engine AFAICT # NOTE: loop name is not used in the engine AFAICT
msg.setCmd(plAnimCmdMsg.kSetLoopBegin, True) msg.setCmd(plAnimCmdMsg.kSetLoopBegin, True)
msg.setCmd(plAnimCmdMsg.kSetLoopEnd, True) msg.setCmd(plAnimCmdMsg.kSetLoopEnd, True)
msg.loopBegin = self.loop_begin / fps msg.loopBegin = exporter.animation.convert_frame_time(self.loop_begin)
msg.loopEnd = self.loop_end / fps msg.loopEnd = exporter.animation.convert_frame_time(self.loop_end)
# Whew, this was crazy # Whew, this was crazy
return msg return msg

1
korman/properties/__init__.py

@ -15,6 +15,7 @@
import bpy import bpy
from .prop_anim import *
from .prop_camera import * from .prop_camera import *
from .prop_image import * from .prop_image import *
from .prop_lamp import * from .prop_lamp import *

162
korman/properties/modifiers/anim.py

@ -15,9 +15,13 @@
import bpy import bpy
from bpy.props import * from bpy.props import *
from typing import Iterable, Iterator, Optional
from PyHSPlasma import * from PyHSPlasma import *
from .base import PlasmaModifierProperties from .base import PlasmaModifierProperties
from ..prop_anim import PlasmaAnimationCollection
from ...exporter import ExportError, utils from ...exporter import ExportError, utils
from ... import idprops 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 # we will not use this action for any animation logic. that must be stored on the Object
# datablock for simplicity's sake. # datablock for simplicity's sake.
return None 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): class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
@ -47,65 +59,101 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
bl_description = "Object animation" bl_description = "Object animation"
bl_icon = "ACTION" bl_icon = "ACTION"
auto_start = BoolProperty(name="Auto Start", subanimations = PointerProperty(type=PlasmaAnimationCollection)
description="Automatically start this animation on link-in",
default=True) def pre_export(self, exporter, bo):
loop = BoolProperty(name="Loop Anim", # We want to run the animation converting early in the process because of dependencies on
description="Loop the animation", # the animation data being available. Especially in the camera exporter.
default=True) so = exporter.mgr.find_create_object(plSceneObject, bl=bo)
self.convert_object_animations(exporter, bo, so, self.subanimations)
initial_marker = StringProperty(name="Start Marker",
description="Marker indicating the default start point") def convert_object_animations(self, exporter, bo, so, anims: Optional[Iterable] = None):
loop_start = StringProperty(name="Loop Start", if not anims:
description="Marker indicating where the default loop begins") anims = [self.subanimations.entire_animation]
loop_end = StringProperty(name="Loop End", aganims = list(self._export_ag_anims(exporter, bo, so, anims))
description="Marker indicating where the default loop ends")
obj_sdl_anim = StringProperty(name="SDL Animation", # Defer creation of the private animation until after the converter has been executed.
description="Name of the SDL variable to use for this animation", # Just because we have some FCurves doesn't mean they will produce anything particularly
options=set()) # 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 applicators = converter.convert_object_animations(bo, so, anim_name, start=start, end=end)
def anim_type(self): if not applicators:
return plAgeGlobalAnim if self.enabled and self.obj_sdl_anim else plATCAnim exporter.report.warn("Animation '{}' generated no applicators. Nothing will be exported.",
anim_name, indent=2)
continue
def export(self, exporter, bo, so): pClass = plAgeGlobalAnim if anim.sdl_var else plATCAnim
action = self.blender_action aganim = exporter.mgr.find_create_object(pClass, bl=bo, so=so, name="{}_{}".format(bo.name, anim_name))
anim_mod = bo.plasma_modifiers.animation aganim.name = anim_name
aganim.start, aganim.end = converter.get_frame_time_range(*applicators, so=so)
# Do not create the private animation here. The animation converter itself does this for i in applicators:
# before we reach this point. If it does not create an animation, then we might create an aganim.addApplicator(i)
# empty animation that crashes Uru.
atcanim = exporter.mgr.find_object(anim_mod.anim_type, so=so) if isinstance(aganim, plATCAnim):
if atcanim is None: aganim.autoStart = anim.auto_start
return aganim.loop = anim.loop
if not isinstance(atcanim, plAgeGlobalAnim): if action is not None:
atcanim.autoStart = self.auto_start markers = action.pose_markers
atcanim.loop = self.loop initial_marker = markers.get(anim.initial_marker)
if initial_marker is not None:
# Simple start and loop info for ATC aganim.initial = converter.convert_frame_time(initial_marker.frame)
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)
else: else:
atcanim.loopStart = atcanim.start aganim.initial = -1.0
loop_end = markers.get(self.loop_end) if anim.loop:
if loop_end is not None: loop_start = markers.get(anim.loop_start)
atcanim.loopEnd = _convert_frame_time(loop_end.frame) if loop_start is not None:
else: aganim.loopStart = converter.convert_frame_time(loop_start.frame)
atcanim.loopEnd = atcanim.end else:
else: aganim.loopStart = aganim.start
if self.loop: loop_end = markers.get(anim.loop_end)
atcanim.loopStart = atcanim.start if loop_end is not None:
atcanim.loopEnd = atcanim.end 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): 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="", description="",
options=set()) options=set())
active_transition_index = IntProperty(options={"HIDDEN"}) 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 bpy.props import *
from .. import idprops from .. import idprops
from .prop_anim import PlasmaAnimationCollection
class EnvMapVisRegion(idprops.IDPropObjectMixin, bpy.types.PropertyGroup): class EnvMapVisRegion(idprops.IDPropObjectMixin, bpy.types.PropertyGroup):
enabled = BoolProperty(default=True) enabled = BoolProperty(default=True)
@ -56,16 +57,6 @@ class PlasmaLayer(bpy.types.PropertyGroup):
type=EnvMapVisRegion) type=EnvMapVisRegion)
active_region_index = IntProperty(options={"HIDDEN"}) 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", is_detail_map = BoolProperty(name="Detail Fade",
description="Texture fades out as distance from the camera increases", description="Texture fades out as distance from the camera increases",
default=False, default=False,
@ -108,3 +99,9 @@ class PlasmaLayer(bpy.types.PropertyGroup):
("1024", "1024x1024", "")], ("1024", "1024x1024", "")],
default="1024", default="1024",
options=set()) 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 # You should have received a copy of the GNU General Public License
# along with Korman. If not, see <http://www.gnu.org/licenses/>. # along with Korman. If not, see <http://www.gnu.org/licenses/>.
from .ui_anim import *
from .ui_camera import * from .ui_camera import *
from .ui_image import * from .ui_image import *
from .ui_lamp import * from .ui_lamp import *

21
korman/ui/modifiers/anim.py

@ -16,6 +16,7 @@
import bpy import bpy
from .. import ui_list from .. import ui_list
from .. import ui_anim
def _check_for_anim(layout, modifier): def _check_for_anim(layout, modifier):
try: try:
@ -31,20 +32,12 @@ def animation(modifier, layout, context):
if action is None: if action is None:
return return
split = layout.split() if modifier.id_data.type == "CAMERA":
col = split.column() if not modifier.id_data.data.plasma_camera.allow_animations:
col.prop(modifier, "auto_start") layout.label("Animation modifiers are not allowed on this camera type!", icon="ERROR")
col = split.column() return
col.prop(modifier, "loop")
ui_anim.draw_multi_animation(layout, "object", modifier, "subanimations")
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")
def animation_filter(modifier, layout, context): def animation_filter(modifier, layout, context):
split = layout.split() 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() split = layout.split()
col = split.column() col = split.column()
col.label("Animation:") 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, "start_on_push")
col.prop(props, "stop_on_pop") col.prop(props, "stop_on_pop")
col.prop(props, "reset_on_pop") col.prop(props, "reset_on_pop")
col = split.column() col = split.column()
col.active = camera.camera_type == "rail" 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.label("Rail:")
col.prop(props, "rail_pos", text="") col.prop(props, "rail_pos", text="")
if invalid:
col.label("Rail cameras must have a transformation animation!", icon="ERROR")
def draw_header(self, context): def draw_header(self, context):
self.layout.active = any(helpers.fetch_fcurves(context.object)) self.layout.active = context.object.plasma_object.has_animation_data
self.layout.prop(context.camera.plasma_camera.settings, "anim_enabled", text="") 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): class PlasmaCameraViewPanel(CameraButtonsPanel, bpy.types.Panel):

52
korman/ui/ui_texture.py

@ -16,6 +16,7 @@
import bpy import bpy
from . import ui_list from . import ui_list
from . import ui_anim
class TextureButtonsPanel: class TextureButtonsPanel:
bl_space_type = "PROPERTIES" bl_space_type = "PROPERTIES"
@ -70,35 +71,23 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):
split = layout.split() split = layout.split()
col = split.column() 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.label("Z Depth:")
col.prop(layer_props, "alpha_halo") col.prop(layer_props, "alpha_halo")
col.prop(layer_props, "skip_depth_write") col.prop(layer_props, "skip_depth_write")
col.prop(layer_props, "skip_depth_test") col.prop(layer_props, "skip_depth_test")
col.prop(layer_props, "z_bias") 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() split = layout.split()
col = split.column() col = split.column()
detail_map_candidate = texture.type == "IMAGE" and texture.use_mipmap 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_start")
col.prop(layer_props, "detail_opacity_stop") 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) tex = getattr(context, "texture", None)
if tex is not None: if tex is not None:
if tex.animation_data is not None: if tex.animation_data is not None:
@ -126,3 +126,7 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel):
return True return True
return False 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