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