From b520bfedd1ed2cfff4c3986717542e14d8a65c80 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 23 Aug 2021 12:27:39 -0400 Subject: [PATCH] Add support for multiple animations per object. I don't really want to talk about it. *Gulp*. Anyway, you define multiple animations on either the animation modifier or the textures panel. The UIs have all been unified. By default, you have an "(Entire Animation)" that represents the old single animation. You may create additional sub-animations over an arbitrary range of keyframes. Once other animations are defined, the "(Entire Animation)" may be deleted. However, if all subanimations are deleted, the "(Entire Animation)" will resurect itself with its last known settings. Behavior change: object animations (except for fixed cameras) now REQUIRE an animation modifier be attached to export. It is now an error to attach an animation modifier to any camera except for a fixed camera. --- korman/exporter/animation.py | 294 ++++++++++++++----------- korman/exporter/camera.py | 5 +- korman/exporter/convert.py | 6 +- korman/exporter/material.py | 166 ++++++++------ korman/nodes/node_messages.py | 119 ++++++---- korman/properties/__init__.py | 1 + korman/properties/modifiers/anim.py | 162 +++++++++----- korman/properties/prop_anim.py | 323 ++++++++++++++++++++++++++++ korman/properties/prop_camera.py | 5 + korman/properties/prop_texture.py | 17 +- korman/ui/__init__.py | 1 + korman/ui/modifiers/anim.py | 23 +- korman/ui/ui_anim.py | 72 +++++++ korman/ui/ui_camera.py | 12 +- korman/ui/ui_texture.py | 52 ++--- 15 files changed, 913 insertions(+), 345 deletions(-) create mode 100644 korman/properties/prop_anim.py create mode 100644 korman/ui/ui_anim.py 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)