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