diff --git a/Content/Python/ue_workshop/animation_catalog.py b/Content/Python/ue_workshop/animation_catalog.py new file mode 100644 index 0000000..8e33b19 --- /dev/null +++ b/Content/Python/ue_workshop/animation_catalog.py @@ -0,0 +1,89 @@ +"""Maintain static editor enums as models and animations are imported.""" +import ast +import importlib +import math +from pathlib import Path +import sys +import tempfile + +from .paths import slug + +CATALOG = Path(__file__).with_name("animations.py") +HEADER = '''"""Automatically generated clip names and durations for VS Code. + +Updated by scene.skeletal_model() using imported UE animation assets. +This module works without Unreal. Do not edit durations manually. +""" +from .animation_clip import AnimationClip +''' + + +def _read_classes(text): + classes = {} + for node in ast.parse(text).body: + if not isinstance(node, ast.ClassDef): + continue + data = {} + for statement in node.body: + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + key, seconds = ast.literal_eval(statement.value) + data[key] = float(seconds) + classes[node.name] = data + return classes + + +def _class_source(name, clips): + lines = [f'class {name}(AnimationClip):', + f' """Clip names and durations: type {name}. for editor completion."""'] + members = set() + for key, seconds in sorted(clips.items()): + member = slug(key).upper() + if member in members: + raise ValueError(f"Powtorzona nazwa enuma: {name}.{member}") + members.add(member) + if not math.isfinite(seconds) or seconds <= 0: + raise ValueError(f"Nieprawidlowa dlugosc animacji {key}: {seconds}") + lines.extend([f' {member} = ({key!r}, {seconds!r})', + f' """{seconds:.3f} seconds at speed=1; clip: {key}."""']) + return '\n'.join(lines) + + +def _publish_class(source, filename): + # Refresh only this class, preserving other enum classes and existing imports. + # Names are validated and data are emitted with repr; source is generated here. + module = sys.modules.get(__package__ + ".animations") + if module is not None: + exec(compile(source, str(filename), "exec"), vars(module)) + importlib.invalidate_caches() + + +def update_catalog(name, clips, *, replace=True, output=None): + """Write NameAnimations. Partial selections merge; full discovery replaces it. + + Other models are preserved. Identical metadata does not rewrite the file. + clips maps actual playback keys to durations in seconds, without Unreal objects. + """ + name = slug(name) + class_name = name[0].upper() + name[1:] + "Animations" + path = CATALOG if output is None else Path(output) + old_text = path.read_text(encoding="utf-8") if path.is_file() else "" + classes = _read_classes(old_text) + data = {} if replace else dict(classes.get(class_name, {})) + data.update({key: float(seconds) for key, seconds in clips.items()}) + classes[class_name] = data + sources = {key: _class_source(key, value) for key, value in sorted(classes.items())} + text = HEADER + '\n\n' + '\n\n\n'.join(sources.values()) + '\n' + if text != old_text: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", + dir=path.parent, prefix=path.name + ".", suffix=".tmp", + delete=False) as temporary: + temporary.write(text) + temporary_path = Path(temporary.name) + try: + temporary_path.replace(path) + finally: + temporary_path.unlink(missing_ok=True) + if output is None: + _publish_class(sources[class_name], path) + return class_name diff --git a/Content/Python/ue_workshop/animation_clip.py b/Content/Python/ue_workshop/animation_clip.py new file mode 100644 index 0000000..6d670cb --- /dev/null +++ b/Content/Python/ue_workshop/animation_clip.py @@ -0,0 +1,33 @@ +"""Animation metadata usable in normal Python and VS Code without Unreal.""" +from enum import Enum +import math + + +class AnimationClip(Enum): + """A named clip with its exported duration. Subclasses list concrete clips.""" + + def __init__(self, key: str, seconds: float): + self._key = key + self._seconds = seconds + + @property + def key(self) -> str: + """Clip name used by shot.animation(), derived from the FBX filename.""" + return self._key + + @property + def seconds(self) -> float: + """Duration of one playback at speed=1, exported from Unreal.""" + return self._seconds + + def frames(self, fps: int = 24, speed: float = 1.0) -> int: + """Frames needed for one playback at this sequence FPS and speed.""" + if type(fps) is not int or fps < 1: + raise ValueError("fps musi byc dodatnia liczba calkowita.") + if isinstance(speed, bool) or not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0: + raise ValueError("speed musi byc dodatnia liczba.") + value = self.seconds * fps / speed + # Unreal stores durations as float32; avoid adding a whole extra frame + # for values such as 3.200000047683716 seconds at 30 FPS. + nearest = round(value) + return max(1, nearest if math.isclose(value, nearest, rel_tol=0, abs_tol=0.0001) else math.ceil(value)) diff --git a/Content/Python/ue_workshop/animations.py b/Content/Python/ue_workshop/animations.py new file mode 100644 index 0000000..452709f --- /dev/null +++ b/Content/Python/ue_workshop/animations.py @@ -0,0 +1,140 @@ +"""Automatically generated clip names and durations for VS Code. + +Updated by scene.skeletal_model() using imported UE animation assets. +This module works without Unreal. Do not edit durations manually. +""" +from .animation_clip import AnimationClip + + +class CreatureAnimations(AnimationClip): + """Clip names and durations: type CreatureAnimations. for editor completion.""" + LEFT_TURN_45 = ('left_turn_45', 1.3333333730697632) + """1.333 seconds at speed=1; clip: left_turn_45.""" + MUTANT_BREATHING_IDLE = ('mutant_breathing_idle', 4.0333333015441895) + """4.033 seconds at speed=1; clip: mutant_breathing_idle.""" + MUTANT_DYING = ('mutant_dying', 4.599999904632568) + """4.600 seconds at speed=1; clip: mutant_dying.""" + MUTANT_FLEXING_MUSCLES = ('mutant_flexing_muscles', 4.666666507720947) + """4.667 seconds at speed=1; clip: mutant_flexing_muscles.""" + MUTANT_IDLE = ('mutant_idle', 5.300000190734863) + """5.300 seconds at speed=1; clip: mutant_idle.""" + MUTANT_JUMPING = ('mutant_jumping', 3.1666667461395264) + """3.167 seconds at speed=1; clip: mutant_jumping.""" + MUTANT_LEFT_TURN_45 = ('mutant_left_turn_45', 1.3333333730697632) + """1.333 seconds at speed=1; clip: mutant_left_turn_45.""" + MUTANT_RIGHT_TURN_45 = ('mutant_right_turn_45', 1.9666666984558105) + """1.967 seconds at speed=1; clip: mutant_right_turn_45.""" + MUTANT_RIGHT_TURN_45_2 = ('mutant_right_turn_45_2', 1.3333333730697632) + """1.333 seconds at speed=1; clip: mutant_right_turn_45_2.""" + MUTANT_RUN = ('mutant_run', 0.8666666746139526) + """0.867 seconds at speed=1; clip: mutant_run.""" + MUTANT_SWIPING = ('mutant_swiping', 2.6666667461395264) + """2.667 seconds at speed=1; clip: mutant_swiping.""" + MUTANT_WALKING = ('mutant_walking', 1.4333332777023315) + """1.433 seconds at speed=1; clip: mutant_walking.""" + + +class FighterAnimations(AnimationClip): + """Clip names and durations: type FighterAnimations. for editor completion.""" + DRAW_A_GREAT_SWORD_1 = ('draw_a_great_sword_1', 0.5) + """0.500 seconds at speed=1; clip: draw_a_great_sword_1.""" + DRAW_A_GREAT_SWORD_2 = ('draw_a_great_sword_2', 0.800000011920929) + """0.800 seconds at speed=1; clip: draw_a_great_sword_2.""" + GREAT_SWORD_180_TURN = ('great_sword_180_turn', 0.800000011920929) + """0.800 seconds at speed=1; clip: great_sword_180_turn.""" + GREAT_SWORD_180_TURN_2 = ('great_sword_180_turn_2', 0.5666666626930237) + """0.567 seconds at speed=1; clip: great_sword_180_turn_2.""" + GREAT_SWORD_ATTACK = ('great_sword_attack', 1.2000000476837158) + """1.200 seconds at speed=1; clip: great_sword_attack.""" + GREAT_SWORD_BLOCKING = ('great_sword_blocking', 0.5) + """0.500 seconds at speed=1; clip: great_sword_blocking.""" + GREAT_SWORD_BLOCKING_2 = ('great_sword_blocking_2', 0.9666666388511658) + """0.967 seconds at speed=1; clip: great_sword_blocking_2.""" + GREAT_SWORD_BLOCKING_3 = ('great_sword_blocking_3', 0.5) + """0.500 seconds at speed=1; clip: great_sword_blocking_3.""" + GREAT_SWORD_CASTING = ('great_sword_casting', 4.800000190734863) + """4.800 seconds at speed=1; clip: great_sword_casting.""" + GREAT_SWORD_CROUCHING = ('great_sword_crouching', 0.6666666865348816) + """0.667 seconds at speed=1; clip: great_sword_crouching.""" + GREAT_SWORD_CROUCHING_2 = ('great_sword_crouching_2', 0.699999988079071) + """0.700 seconds at speed=1; clip: great_sword_crouching_2.""" + GREAT_SWORD_CROUCHING_3 = ('great_sword_crouching_3', 1.8333333730697632) + """1.833 seconds at speed=1; clip: great_sword_crouching_3.""" + GREAT_SWORD_CROUCHING_4 = ('great_sword_crouching_4', 0.36666667461395264) + """0.367 seconds at speed=1; clip: great_sword_crouching_4.""" + GREAT_SWORD_CROUCHING_5 = ('great_sword_crouching_5', 1.399999976158142) + """1.400 seconds at speed=1; clip: great_sword_crouching_5.""" + GREAT_SWORD_CROUCHING_6 = ('great_sword_crouching_6', 0.46666666865348816) + """0.467 seconds at speed=1; clip: great_sword_crouching_6.""" + GREAT_SWORD_HIGH_SPIN_ATTACK = ('great_sword_high_spin_attack', 1.8666666746139526) + """1.867 seconds at speed=1; clip: great_sword_high_spin_attack.""" + GREAT_SWORD_IDLE = ('great_sword_idle', 2.0) + """2.000 seconds at speed=1; clip: great_sword_idle.""" + GREAT_SWORD_IDLE_2 = ('great_sword_idle_2', 3.700000047683716) + """3.700 seconds at speed=1; clip: great_sword_idle_2.""" + GREAT_SWORD_IDLE_3 = ('great_sword_idle_3', 3.6666667461395264) + """3.667 seconds at speed=1; clip: great_sword_idle_3.""" + GREAT_SWORD_IDLE_4 = ('great_sword_idle_4', 3.7666666507720947) + """3.767 seconds at speed=1; clip: great_sword_idle_4.""" + GREAT_SWORD_IDLE_5 = ('great_sword_idle_5', 7.566666603088379) + """7.567 seconds at speed=1; clip: great_sword_idle_5.""" + GREAT_SWORD_IMPACT = ('great_sword_impact', 0.8666666746139526) + """0.867 seconds at speed=1; clip: great_sword_impact.""" + GREAT_SWORD_IMPACT_2 = ('great_sword_impact_2', 1.2000000476837158) + """1.200 seconds at speed=1; clip: great_sword_impact_2.""" + GREAT_SWORD_IMPACT_3 = ('great_sword_impact_3', 1.2666666507720947) + """1.267 seconds at speed=1; clip: great_sword_impact_3.""" + GREAT_SWORD_IMPACT_4 = ('great_sword_impact_4', 0.6666666865348816) + """0.667 seconds at speed=1; clip: great_sword_impact_4.""" + GREAT_SWORD_IMPACT_5 = ('great_sword_impact_5', 0.9666666388511658) + """0.967 seconds at speed=1; clip: great_sword_impact_5.""" + GREAT_SWORD_JUMP = ('great_sword_jump', 0.6333333253860474) + """0.633 seconds at speed=1; clip: great_sword_jump.""" + GREAT_SWORD_JUMP_2 = ('great_sword_jump_2', 0.8999999761581421) + """0.900 seconds at speed=1; clip: great_sword_jump_2.""" + GREAT_SWORD_JUMP_ATTACK = ('great_sword_jump_attack', 2.1666667461395264) + """2.167 seconds at speed=1; clip: great_sword_jump_attack.""" + GREAT_SWORD_KICK = ('great_sword_kick', 1.5) + """1.500 seconds at speed=1; clip: great_sword_kick.""" + GREAT_SWORD_KICK_2 = ('great_sword_kick_2', 1.7333333492279053) + """1.733 seconds at speed=1; clip: great_sword_kick_2.""" + GREAT_SWORD_POWER_UP = ('great_sword_power_up', 3.5) + """3.500 seconds at speed=1; clip: great_sword_power_up.""" + GREAT_SWORD_RUN = ('great_sword_run', 0.7333333492279053) + """0.733 seconds at speed=1; clip: great_sword_run.""" + GREAT_SWORD_RUN_2 = ('great_sword_run_2', 0.6000000238418579) + """0.600 seconds at speed=1; clip: great_sword_run_2.""" + GREAT_SWORD_SLASH = ('great_sword_slash', 1.2666666507720947) + """1.267 seconds at speed=1; clip: great_sword_slash.""" + GREAT_SWORD_SLASH_2 = ('great_sword_slash_2', 3.5333333015441895) + """3.533 seconds at speed=1; clip: great_sword_slash_2.""" + GREAT_SWORD_SLASH_3 = ('great_sword_slash_3', 1.8333333730697632) + """1.833 seconds at speed=1; clip: great_sword_slash_3.""" + GREAT_SWORD_SLASH_4 = ('great_sword_slash_4', 1.7999999523162842) + """1.800 seconds at speed=1; clip: great_sword_slash_4.""" + GREAT_SWORD_SLASH_5 = ('great_sword_slash_5', 1.4333332777023315) + """1.433 seconds at speed=1; clip: great_sword_slash_5.""" + GREAT_SWORD_SLIDE_ATTACK = ('great_sword_slide_attack', 2.133333444595337) + """2.133 seconds at speed=1; clip: great_sword_slide_attack.""" + GREAT_SWORD_STRAFE = ('great_sword_strafe', 1.100000023841858) + """1.100 seconds at speed=1; clip: great_sword_strafe.""" + GREAT_SWORD_STRAFE_2 = ('great_sword_strafe_2', 1.1666666269302368) + """1.167 seconds at speed=1; clip: great_sword_strafe_2.""" + GREAT_SWORD_STRAFE_3 = ('great_sword_strafe_3', 0.5666666626930237) + """0.567 seconds at speed=1; clip: great_sword_strafe_3.""" + GREAT_SWORD_STRAFE_4 = ('great_sword_strafe_4', 0.6333333253860474) + """0.633 seconds at speed=1; clip: great_sword_strafe_4.""" + GREAT_SWORD_TURN = ('great_sword_turn', 0.7333333492279053) + """0.733 seconds at speed=1; clip: great_sword_turn.""" + GREAT_SWORD_TURN_2 = ('great_sword_turn_2', 0.8333333134651184) + """0.833 seconds at speed=1; clip: great_sword_turn_2.""" + GREAT_SWORD_WALK = ('great_sword_walk', 1.3666666746139526) + """1.367 seconds at speed=1; clip: great_sword_walk.""" + GREAT_SWORD_WALK_2 = ('great_sword_walk_2', 1.2999999523162842) + """1.300 seconds at speed=1; clip: great_sword_walk_2.""" + SPELL_CAST = ('spell_cast', 1.1333333253860474) + """1.133 seconds at speed=1; clip: spell_cast.""" + TWO_HANDED_SWORD_DEATH = ('two_handed_sword_death', 2.4000000953674316) + """2.400 seconds at speed=1; clip: two_handed_sword_death.""" + TWO_HANDED_SWORD_DEATH_2 = ('two_handed_sword_death_2', 2.5999999046325684) + """2.600 seconds at speed=1; clip: two_handed_sword_death_2.""" diff --git a/Content/Python/ue_workshop/lighting.py b/Content/Python/ue_workshop/lighting.py index e1c80f9..dab1f39 100644 --- a/Content/Python/ue_workshop/lighting.py +++ b/Content/Python/ue_workshop/lighting.py @@ -3,21 +3,55 @@ import unreal from .camera import look_at +def _setup(actor, component_class, intensity, color): + light = actor.get_component_by_class(component_class) + light.set_mobility(unreal.ComponentMobility.MOVABLE) + light.set_editor_property("intensity", float(intensity)) + light.set_light_color(unreal.LinearColor(*color, 1.0)) + return light + + def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100), intensity=12000, color=(1, 1, 1), size=180): actor = scene.spawn(unreal.RectLight, name, location) - light = actor.get_component_by_class(unreal.RectLightComponent) - light.set_mobility(unreal.ComponentMobility.MOVABLE) - light.set_editor_property("intensity", float(intensity)) + light = _setup(actor, unreal.RectLightComponent, intensity, color) light.set_editor_property("source_width", float(size)) light.set_editor_property("source_height", float(size)) light.set_editor_property("attenuation_radius", 2000.0) - light.set_light_color(unreal.LinearColor(*color, 1.0)) + look_at(actor, target) + return actor + + +def point(scene, name="Point", location=(0, 0, 250), intensity=10000, color=(1, 1, 1)): + """Bulb shining in all directions.""" + actor = scene.spawn(unreal.PointLight, name, location) + light = _setup(actor, unreal.PointLightComponent, intensity, color) + light.set_editor_property("attenuation_radius", 2000.0) + return actor + + +def spot(scene, name="Spot", location=(200, -200, 400), target=(0, 0, 100), + intensity=20000, color=(1, 1, 1), angle=30): + """Cone of light; angle is measured in degrees from the cone axis.""" + actor = scene.spawn(unreal.SpotLight, name, location) + light = _setup(actor, unreal.SpotLightComponent, intensity, color) + light.set_editor_property("attenuation_radius", 2000.0) + light.set_editor_property("outer_cone_angle", float(angle)) + light.set_editor_property("inner_cone_angle", float(angle) * 0.7) + look_at(actor, target) + return actor + + +def sun(scene, name="Sun", location=(300, -300, 500), target=(0, 0, 0), + intensity=500, color=(1, 1, 1)): + """Parallel light for the whole scene; only the location-target direction matters.""" + actor = scene.spawn(unreal.DirectionalLight, name, location) + _setup(actor, unreal.DirectionalLightComponent, intensity, color) look_at(actor, target) return actor def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)): - return [area(scene, "Key", (200, -250, 350), color=key_color), + return [area(scene, "Key", (200, -500, 350), color=key_color), area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color), area(scene, "Rim", (-200, 0, 300), intensity=15000)] diff --git a/Content/Python/ue_workshop/root_motion.py b/Content/Python/ue_workshop/root_motion.py new file mode 100644 index 0000000..87f6b44 --- /dev/null +++ b/Content/Python/ue_workshop/root_motion.py @@ -0,0 +1,58 @@ +"""Transfer horizontal root motion to Sequencer actor keys without changing source clips.""" +import math +import hashlib + +import unreal + +from .paths import GENERATED + + +def prepare(clip, child, scene_name): + component = child.skeletal_mesh_component + bone = component.get_bone_name(0) + count = int(clip.get_editor_property("number_of_sampled_keys")) + duration = clip.get_play_length() + if count < 2 or duration <= 0: + raise ValueError("keep_end_position wymaga animacji z co najmniej dwiema probkami.") + poses = [unreal.AnimationLibrary.get_bone_pose_for_time( + clip, bone, duration * index / (count - 1), False) for index in range(count)] + first = poses[0].translation + # Extract only horizontal travel. Keep vertical motion (jumps/bobbing), + # every root rotation and scale, and all other bone tracks unchanged. + positions = [unreal.Vector(first.x, first.y, pose.translation.z) for pose in poses] + folder = f"{GENERATED}/{scene_name}/RootMotion" + identity = hashlib.sha1(clip.get_path_name().encode("utf-8")).hexdigest()[:8] + name = clip.get_name() + "_" + identity + "_InPlace" + path = folder + "/" + name + if unreal.EditorAssetLibrary.does_asset_exist(path): + # This path contains only our generated derivative for this scene. + if not unreal.EditorAssetLibrary.delete_asset(path): + raise RuntimeError("Nie mozna odswiezyc wygenerowanego klipu " + path) + copy = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(name, folder, clip) + if copy is None: + raise RuntimeError("Nie mozna utworzyc kopii animacji dla keep_end_position.") + controller = copy.get_editor_property("controller") + if not controller.set_bone_track_keys(bone, positions, [pose.rotation for pose in poses], + [pose.scale3d for pose in poses], should_transact=False): + raise RuntimeError("Nie mozna przeniesc ruchu kosci glownej do Transform.") + unreal.EditorAssetLibrary.save_loaded_asset(copy) + return copy, poses, duration + + +def displacement(poses, duration, elapsed, reverse=False): + """Interpolate horizontal root travel, accumulating complete playback loops.""" + elapsed = max(0.0, elapsed) + loops = math.floor(elapsed / duration) + time = elapsed - loops * duration + if reverse: + time = duration - time + index = min(time / duration * (len(poses) - 1), len(poses) - 1) + lower = int(index) + upper = min(lower + 1, len(poses) - 1) + alpha = index - lower + a, b = poses[lower].translation, poses[upper].translation + start, end = poses[0].translation, poses[-1].translation + if reverse: + start, end = end, start + return unreal.Vector(a.x + (b.x - a.x) * alpha - start.x + loops * (end.x - start.x), + a.y + (b.y - a.y) * alpha - start.y + loops * (end.y - start.y), 0) diff --git a/Content/Python/ue_workshop/scene.py b/Content/Python/ue_workshop/scene.py index 548d19a..34ffd34 100644 --- a/Content/Python/ue_workshop/scene.py +++ b/Content/Python/ue_workshop/scene.py @@ -111,6 +111,8 @@ class Scene: By default all animation FBXs beside the model are available by filename: shot.animation(actor, "great_sword_walk"). Optional animations={...} selects specific files; animations={} keeps only embedded animations. + Also writes NameAnimations with durations to ue_workshop.animations for + editor completion, including custom models (name="Ninja" -> NinjaAnimations). """ from .skeletal import import_model if height is not None and (not isinstance(height, (int, float)) or diff --git a/Content/Python/ue_workshop/sequence.py b/Content/Python/ue_workshop/sequence.py index 566bc2b..fd6c764 100644 --- a/Content/Python/ue_workshop/sequence.py +++ b/Content/Python/ue_workshop/sequence.py @@ -4,6 +4,16 @@ import math import unreal from .paths import GENERATED, slug +from .animation_clip import AnimationClip + + +def _key_mode(interpolation): + modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR, + "auto": unreal.MovieSceneKeyInterpolation.AUTO, + "constant": unreal.MovieSceneKeyInterpolation.CONSTANT} + if interpolation not in modes: + raise ValueError("Interpolacja: linear, auto lub constant.") + return modes[interpolation] class Sequence: @@ -28,7 +38,11 @@ class Sequence: self.asset.set_playback_end(frames) self._bindings = {} self._transforms = {} + self._light_tracks = {} self._animations = {} + self._root_motion = {} + self._root_motion_copies = {} + self._pose_holds = {} def bind(self, actor): key = actor.get_path_name() @@ -66,19 +80,41 @@ class Sequence: weight.add_key(unreal.FrameNumber(frame), value, interpolation=unreal.MovieSceneKeyInterpolation.LINEAR) - def animation(self, actor, clip=None, start=0, end=None, speed=1.0): + def animation(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None, + start=0, end=None, speed=1.0, keep_end_position=False, reverse=False, + keep_end_pose=False): """Play/loop an imported clip over [start, end) in sequence frames. - clip is an alias from scene.skeletal_model(animations=...), an AnimSequence, - or None for the only embedded animation. Transform keys still move the parent. + Pass a FighterAnimations/CreatureAnimations member, a filename key, an + AnimSequence, or None for the only available animation. With an enum and + no end, play once using its stored duration, sequence FPS and speed. + Strings/AnimSequence/None keep looping to the sequence end if end is omitted. + An explicit end sets the section range and can repeat the clip. Overlapping clips automatically crossfade over their entire shared range. Add clips in timeline order; at most two clips can overlap at a time. + keep_end_position transfers horizontal root travel into actor Transform + keys, keeping the final position for subsequent clips. Defaults to False. + reverse=True plays the clip backwards, including transferred root travel. + keep_end_pose=True retains the final pose with a frozen section after + playback and Sequencer Keep State. A later clip ends this pose hold. """ + if type(start) is not int: + raise ValueError("start musi byc numerem klatki (liczba calkowita).") + if isinstance(clip, AnimationClip): + if end is None: + end = start + clip.frames(self.fps, speed) + clip = clip.key end = self.frames if end is None else end if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames: raise ValueError(f"Zakres animacji: 0 <= start < end <= {self.frames}.") if not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0: raise ValueError("Predkosc animacji musi byc dodatnia.") + if type(keep_end_position) is not bool: + raise ValueError("keep_end_position musi byc True lub False.") + if type(reverse) is not bool: + raise ValueError("reverse musi byc True lub False.") + if type(keep_end_pose) is not bool: + raise ValueError("keep_end_pose musi byc True lub False.") entry = self.scene._skeletal_models.get(actor.get_path_name()) if entry is None: raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().") @@ -109,29 +145,97 @@ class Sequence: previous = overlaps[0] blend_frames = previous[1] - start track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack) + hold = self._pose_holds.pop(key, None) + if hold is not None: + hold_start, hold_section = hold + if start <= hold_start: + track.remove_section(hold_section) + else: + hold_section.set_range(hold_start, start) + if keep_end_position: + from .root_motion import prepare + source_key = clip.get_path_name() + if source_key not in self._root_motion_copies: + self._root_motion_copies[source_key] = prepare( + clip, child, self.scene.name + "/" + self.asset.get_name()) + clip, poses, duration = self._root_motion_copies[source_key] section = track.add_section() section.set_range(start, end) params = section.get_editor_property("params") params.set_editor_property("animation", clip) + params.set_editor_property("reverse", reverse) rate = unreal.MovieSceneTimeWarpVariant() rate.set_fixed_play_rate(float(speed)) params.set_editor_property("play_rate", rate) params.set_editor_property("force_custom_mode", True) section.set_editor_property("params", params) + # UE may rescale the range when play_rate changes. Restore the requested + # display-frame bounds after applying parameters (including speed). + section.set_range(start, end) + if keep_end_pose: + section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE) self._animation_blend(section, start, end, blend_frames, 0) if previous: self._animation_blend(previous[3], previous[0], previous[1], previous[4], blend_frames) sections.append((start, end, track, section, blend_frames)) + if keep_end_position: + self._keep_end_position(actor, child, start, end, speed, poses, duration, reverse) + if keep_end_pose and end < self.frames: + length = clip.get_play_length() + final_time = min((end - start) / self.fps * speed, length) + if reverse: + final_time = length - final_time + self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time)) return section + def _hold_pose(self, track, clip, start, time): + section = track.add_section() + params = section.get_editor_property("params") + params.set_editor_property("animation", clip) + rate = unreal.MovieSceneTimeWarpVariant() + rate.set_fixed_play_rate(0.0) + params.set_editor_property("play_rate", rate) + ticks = self.asset.get_tick_resolution() + tick_rate = ticks.numerator / ticks.denominator + # Stay just inside the asset end to avoid modulo wrapping to frame zero. + time = max(0.0, min(time, clip.get_play_length() - 1 / tick_rate)) + params.set_editor_property("first_loop_start_frame_offset", unreal.FrameNumber(round(time * tick_rate))) + params.set_editor_property("force_custom_mode", True) + section.set_editor_property("params", params) + section.set_range(start, self.frames) + section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE) + self._animation_blend(section, start, self.frames, 0, 0) + return section + + def _keep_end_position(self, actor, child, start, end, speed, poses, duration, reverse=False): + from .root_motion import displacement + key = actor.get_path_name() + if key not in self._root_motion: + self._root_motion[key] = { + "origin": actor.get_actor_location(), + "mesh_transform": child.get_actor_transform(), + "clips": [], + } + motion = self._root_motion[key] + motion["clips"].append((start, end, speed, poses, duration, reverse)) + mesh = motion["mesh_transform"] + zero = mesh.transform_location(unreal.Vector(0, 0, 0)) + # These are ordinary Transform keys. The final key holds the position + # after playback; subsequent animation calls do not reset it. + first = min(item[0] for item in motion["clips"]) + last = min(self.frames - 1, max(item[1] for item in motion["clips"])) + for frame in range(first, last + 1): + total = unreal.Vector(0, 0, 0) + for clip_start, clip_end, clip_speed, clip_poses, clip_duration, clip_reverse in motion["clips"]: + elapsed = min(max(frame - clip_start, 0), clip_end - clip_start) / self.fps * clip_speed + total += displacement(clip_poses, clip_duration, elapsed, reverse=clip_reverse) + world_delta = mesh.transform_location(total) - zero + self.transform_key(actor, frame, location=(motion["origin"] + world_delta).to_tuple()) + def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"): if not isinstance(frame, int) or not 0 <= frame < self.frames: raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") - modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR, - "auto": unreal.MovieSceneKeyInterpolation.AUTO, - "constant": unreal.MovieSceneKeyInterpolation.CONSTANT} - if interpolation not in modes: - raise ValueError("Interpolacja: linear, auto lub constant.") + mode = _key_mode(interpolation) key = actor.get_path_name() if key not in self._transforms: track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack) @@ -151,7 +255,88 @@ class Sequence: if len(values) != 3: raise ValueError("Transformacja wymaga trzech wartosci.") for channel, value in zip(channels[offset:offset + 3], values): - channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation]) + channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode) + + def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"): + """Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame.""" + if not isinstance(frame, int) or not 0 <= frame < self.frames: + raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") + mode = _key_mode(interpolation) + if color is not None and len(color) != 3: + raise ValueError("Kolor wymaga trzech wartosci.") + light = actor.get_component_by_class(unreal.LightComponent) + if light is None: + raise ValueError("light_key wymaga aktora swiatla, np. z lighting.spot().") + if color is not None: + channels = self._light_channels(light, unreal.MovieSceneColorTrack, "LightColor") + for channel, value in zip(channels, color): + channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode) + if intensity is not None: + channels = self._light_channels(light, unreal.MovieSceneFloatTrack, "Intensity") + channels[0].add_key(unreal.FrameNumber(frame), float(intensity), interpolation=mode) + + def _light_channels(self, light, track_class, name): + key = (light.get_path_name(), name) + if key not in self._light_tracks: + track = self.bind(light).add_track(track_class) + track.set_property_name_and_path(name, name) + section = track.add_section() + section.set_range(0, self.frames) + channels = section.get_all_channels() + if name == "LightColor": + channels[3].set_default(1.0) # Alpha is never keyed. + self._light_tracks[key] = channels + return self._light_tracks[key] + + def position(self, actor, frame: int) -> tuple[float, float, float]: + """Actor Transform position at a sequence frame, including keep_end_position. + + Evaluates Sequencer curves without moving the playhead. Does not include + motion of bones inside an animation that was not transferred to Transform. + """ + if type(frame) is not int or not 0 <= frame < self.frames: + raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.") + channels = self._transforms.get(actor.get_path_name()) + if channels is None: + return actor.get_actor_location().to_tuple() + interval = self.asset.make_range(frame, 1) + rate = unreal.FrameRate(self.fps, 1) + values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]] + if any(not axis for axis in values): + raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.") + return tuple(float(axis[0]) for axis in values) + + def move(self, actor, start: int, end: int, offset: tuple[float, float, float], + interpolation="auto") -> tuple[float, float, float]: + """Move by offset (world cm) from the actor's position at start. + + Replaces position keys in the range and shifts later position keys by + the resulting change, preserving later motion. Call after animation(). + Returns the target position. Rotation/scale keys are kept. + """ + if (type(start) is not int or type(end) is not int or + not 0 <= start < end < self.frames): + raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.") + if interpolation not in {"linear", "auto", "constant"}: + raise ValueError("Interpolacja: linear, auto lub constant.") + if len(offset) != 3 or any(not math.isfinite(value) for value in offset): + raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.") + origin = self.position(actor, start) + target = tuple(value + delta for value, delta in zip(origin, offset)) + channels = self._transforms.get(actor.get_path_name()) + if channels is not None: + previous_end = self.position(actor, end) + for axis, channel in enumerate(channels[:3]): + correction = target[axis] - previous_end[axis] + for key in list(channel.get_keys()): + time = key.get_time().frame_number.value + if start <= time <= end: + channel.remove_key(key) + elif time > end: + key.set_value(key.get_value() + correction) + self.transform_key(actor, start, location=origin, interpolation=interpolation) + self.transform_key(actor, end, location=target, interpolation=interpolation) + return target def save(self, open_editor=True): self.scene.save() diff --git a/Content/Python/ue_workshop/skeletal.py b/Content/Python/ue_workshop/skeletal.py index 564d74f..6c27cf6 100644 --- a/Content/Python/ue_workshop/skeletal.py +++ b/Content/Python/ue_workshop/skeletal.py @@ -6,6 +6,7 @@ import unreal from .paths import GENERATED, PROJECT, slug from .characters import _clips +from .animation_catalog import update_catalog @dataclass @@ -111,7 +112,8 @@ def import_model(filename, name="Character", animations=None, collection="Charac """ source = Path(filename).expanduser() source = (source if source.is_absolute() else PROJECT / source).resolve() - if animations is None: + discover_all = animations is None + if discover_all: animations = _clips(source) name = slug(name) destination = f"{GENERATED}/Skeletal/{name}" @@ -131,4 +133,6 @@ def import_model(filename, name="Character", animations=None, collection="Charac for alias, filename in (animations or {}).items(): clips[alias] = import_animation(filename, skeleton, name=slug(alias), destination=destination + "/Animations", reimport=reimport) + update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()}, + replace=discover_all) return SkeletalImport(meshes[0], clips) diff --git a/README.md b/README.md index 2770457..b726e06 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,33 @@ animację kości przez 120 klatek (5 s); krótszy klip zapętla się w swojej se Klucze `location`, `rotation` i `scale` mogą dodatkowo poruszać całym modelem. Przykład dwóch postaci z różnymi klipami: `examples/mixamo_characters.py`. +Do pisania scen w VS Code możesz używać enumów z gotowymi długościami — bez +szukania assetów ani uruchamiania komend w UE: + +```python +from ue_workshop.animations import FighterAnimations + +clip = FighterAnimations.GREAT_SWORD_ATTACK +clip.seconds # czas klipu w sekundach +clip.frames(shot.fps) # liczba klatek przy FPS sekwencji +shot.animation(character, clip, start=48) # bez end: jeden przebieg +``` + +Po wpisaniu `FighterAnimations.` edytor podpowiada nazwy. `CreatureAnimations` +udostępnia klipy stwora. [Dane offline, łączenie klipów i aktualizacja katalogu](docs/animation_catalog.md). + +Własne modele również dostają katalog automatycznie: pierwszy import +`scene.skeletal_model(path, name="Ninja")` zapisze `NinjaAnimations` w +`ue_workshop.animations`. Kolejne klipy i długości są aktualizowane przy imporcie, +bez osobnego polecenia generowania. `REIMPORT=False` nadal wykorzystuje gotowe assety. + +Gdy atak przesuwa postać, `shot.animation(character, clip, start=57, +keep_end_position=True)` automatycznie przenosi poziomy ruch do kluczy Transform +i zachowuje końcową pozycję na kolejne klipy. [Przykład slash_2](docs/animation_catalog.md). + +Po śmierci `shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150, +keep_end_pose=True)` utrzymuje leżącą pozę do końca sekwencji lub następnej animacji. + ```python character = scene.skeletal_model( PROJECT / "source_assets/character/creature/Maw J Laygo.fbx", diff --git a/docs/animation_catalog.md b/docs/animation_catalog.md new file mode 100644 index 0000000..026ec9c --- /dev/null +++ b/docs/animation_catalog.md @@ -0,0 +1,181 @@ +# Nazwy i długości animacji w VS Code + +W `ue_workshop.animations` są statycznie zapisane enumy `FighterAnimations` i +`CreatureAnimations`. Działają w zwykłym Pythonie, bez `unreal`. Po wpisaniu kropki +VS Code może podpowiedzieć wszystkie klipy. Opisy członków podają długość w sekundach. +Nazwy odpowiadają nazwom plików; nie ma skrótów typu `attack`. + +```python +from ue_workshop.animations import FighterAnimations, CreatureAnimations + +draw = FighterAnimations.DRAW_A_GREAT_SWORD_1 +attack = FighterAnimations.GREAT_SWORD_ATTACK + +draw.key # "draw_a_great_sword_1" +draw.seconds # 0.5 sekundy +draw.frames(24) # 12 klatek przy 24 FPS +attack.seconds # około 1.2 sekundy +attack.frames(24) # 29 klatek przy 24 FPS, zaokrąglone w górę +attack.frames(24, speed=2) + +# Bez end klip wybrany enumem gra raz, z automatycznie policzonym końcem. +shot.animation(character_fighter, draw, start=0) +next_start = draw.frames(shot.fps) - 6 +shot.animation(character_fighter, attack, start=next_start) +``` + +W tym przykładzie przejście trwa 6 wspólnych klatek. Przy zmianie klipu albo FPS +nie trzeba pamiętać jego długości. `speed` jest uwzględniane zarówno przez `frames`, +jak i przez automatyczny koniec w `shot.animation`. + +Podanie `end` nadal ustawia własny zakres i pozwala zapętlić klip: + +```python +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=0, end=120) +``` + +Dotychczasowe nazwy tekstowe nadal działają. Dla tekstu lub bez wskazania klipu +pominięte `end` zachowuje dotychczasowe znaczenie: gra do końca sekwencji. +Koniec sekcji musi mieścić się w `Sequence(..., frames=...)`. + +## Zachowanie pozycji po ataku + +Jeśli klip przesuwa postać do przodu, włącz opcję na tym klipie: + +```python +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_2, + start=57, keep_end_position=True) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_3, start=161) +``` + +`animation()` przenosi poziomy ruch kości głównej do zwykłych kluczy pozycji +aktora na ścieżce Transform. Ostatni klucz zachowuje osiągnięte przesunięcie, +więc następny atak zaczyna się w nowym miejscu. Klucze możesz edytować w Sequencerze. +Ruch pionowy i obroty kości pozostają w animacji. + +### Pozostawienie postaci na ziemi po śmierci + +```python +shot.animation(character_creature, CreatureAnimations.MUTANT_DYING, + start=150, keep_end_pose=True) +``` + +Enum bez `end` odtwarza klip raz. `keep_end_pose=True` dodaje po nim odcinek +z zamrożoną końcową pozą aż do końca sekwencji. Postać pozostaje na ziemi także +przy renderowaniu pojedynczej późniejszej klatki. Kolejna animacja tej samej +postaci kończy utrzymywanie pozy. Oryginalny klip nie jest zmieniany. + +`keep_end_pose` dotyczy ułożenia kości; `keep_end_position` dotyczy przesunięcia +aktora. Można włączyć obie opcje. Jeśli jawnie wydłużysz zakres przez `end`, +klip nadal może się zapętlać w tym zakresie — dla śmierci pozostaw pojedynczy przebieg. + +Aby uniknąć podwójnego przesuwania, sekcja korzysta z wygenerowanej kopii klipu +bez jego poziomego przesunięcia. Oryginalny asset animacji pozostaje bez zmian. +Opcja domyślnie jest wyłączona. Jeśli chcesz własne klucze pozycji, dodaj je po +wywołaniach `animation()` — zastąpią wygenerowane wartości na tych samych klatkach. + +### Ruch względem aktualnej pozycji + +Po `keep_end_position` nie musisz obliczać nowych współrzędnych ręcznie: + +```python +# Po wywołaniach shot.animation(...): przesunięcie o 50 cm w osi Y. +shot.move(character_creature, start=80, end=120, offset=(0, 50, 0)) +``` + +Helper odczyta pozycję aktora w klatce 80 z krzywych Sequencera, doda klucz +początkowy, a w klatce 120 klucz przesunięty o `offset`. To współrzędne świata +w centymetrach. Ostatnia klatka ruchu jest kluczem, więc musi być mniejsza od +`shot.frames`. Dodawaj ruch po animacjach. `move()` zastępuje klucze pozycji wewnątrz +odcinka, także wygenerowane przez `keep_end_position`. Późniejsze klucze pozycji są +przesuwane o wynikową różnicę, żeby zachować dalszy ruch. Klucze rotacji i skali zostają. + +### Cofanie podczas chodzenia + +`reverse=True` odtwarza klip od końca. W połączeniu z `keep_end_position=True` +odwraca także przesunięcie zapisane w klipie i zachowuje nową pozycję: + +```python +shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING, + start=80, end=120, reverse=True, keep_end_position=True) +``` + +Postać odtwarza kroki do tyłu bez zmiany kierunku, w którym patrzy. Jeśli chcesz +ustalić odległość cofnięcia samodzielnie, dopisz po animacjach: + +```python +shot.move(character_creature, start=80, end=120, offset=(0, -200, 0)) +``` + +To ustawia cofnięcie o 200 cm w osi Y; `reverse` odpowiada za kierunek odtwarzania +ruchu nóg, a `move` zastępuje trasę pozycjami wybranymi przez autora sceny. + +Do własnych obliczeń służy: + +```python +x, y, z = shot.position(character_creature, frame=80) +``` + +Odczyt uwzględnia klucze Transform, `keep_end_position` i interpolację. Nie przestawia +kursora w Sequencerze. Dotyczy pozycji aktora; ruchy samych kości wewnątrz klipu +bez `keep_end_position` nie zmieniają pozycji jego aktora. Odczyt wykonuje się w UE +podczas uruchamiania skryptu, więc VS Code nie musi znać liczbowych współrzędnych. + +Listę z danymi też można odczytać bez UE: + +```python +for clip in FighterAnimations: + print(clip.key, clip.seconds, clip.frames(24)) +``` + +## Własny model studenta — generowanie automatyczne + +Każde `scene.skeletal_model(...)` automatycznie zapisuje nazwy i długości jego +zaimportowanych klipów do `Content/Python/ue_workshop/animations.py`. Dotyczy to +także modeli pobranych przez studentów, FBX z animacją w środku i `.blend`. + +Umieść model i jego animacje FBX w osobnym folderze, np. `source_assets/ninja`. +Przy pierwszym uruchomieniu w UE: + +```python +character = scene.skeletal_model( + PROJECT / "source_assets/ninja/model.fbx", + name="Ninja", + reimport=False, +) +``` + +Powstanie `NinjaAnimations` — nazwa enuma wynika z `name`. VS Code zobaczy nowy +statyczny kod i będzie mógł go podpowiadać. W kolejnej edycji skryptu możesz użyć: + +```python +from ue_workshop.animations import NinjaAnimations + +# Jeśli w folderze był walking.fbx: +clip = NinjaAnimations.WALKING +clip.seconds +clip.frames(shot.fps) +shot.animation(character, clip, start=0) +``` + +Pierwszy import w UE jest potrzebny do odczytania rzeczywistych długości. +Późniejsze pisanie sceny korzysta z zapisanego katalogu i nie wymaga komend w UE. +Różnym modelom nadaj różne `name` — to również nazwa ich assetów w projekcie. + +Po dodaniu FBX do folderu uruchom swój skrypt ponownie: brakujący klip zostanie +zaimportowany i dopisany do enuma. Po zmianie istniejącego klipu użyj `REIMPORT=True`; +nowa długość zapisze się automatycznie. Nie trzeba uruchamiać osobnego generatora. +`REIMPORT=False` nadal używa istniejących assetów. Nie ma sprawdzania dat źródeł. + +Katalogi innych modeli pozostają w pliku. Jawny wybór `animations={...}` aktualizuje +tylko wybrane wpisy, zachowując inne znane klipy tej postaci. Automatyczny odczyt +całego folderu odświeża pełną listę. Jeśli dane się nie zmieniły, plik nie jest +zapisywany ponownie. + +Opcjonalna ręczna aktualizacja dołączonego fightera i creature: + +```text +python tools/run_workshop.py tools/update_animation_catalog.py +``` + +To narzędzie również zachowuje katalogi własnych modeli studentów. diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index e19554e..6aaa048 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -7,6 +7,33 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma Własny skrypt: `scene.skeletal_model(..., reimport=True)` lub `scene.model(..., reimport=True)`. Nie ma automatycznego wykrywania zmian źródeł. +Enumy i długości bez uruchamiania UE: + +```python +from ue_workshop.animations import FighterAnimations +clip = FighterAnimations.GREAT_SWORD_ATTACK +clip.seconds +clip.frames(shot.fps, speed=1.0) +shot.animation(character, clip, start=48) # jeden przebieg; end obliczony z długości +``` + +Jawne `end` nadal pozwala zapętlić klip. `scene.skeletal_model(path, name="Ninja")` +automatycznie wygeneruje `NinjaAnimations` po pierwszym imporcie w UE, także dla +własnych plików studentów. [Katalog offline i jego odświeżanie](animation_catalog.md). + +`shot.animation(character, clip, start=57, keep_end_position=True)` zapisuje poziomy +ruch z klipu jako klucze Transform; końcowy klucz utrzymuje pozycję po ataku. +Źródłowy klip pozostaje bez zmian. Opcja dotyczy ruchu kości głównej. + +`shot.position(character, frame=80)` odczytuje pozycję aktora z krzywych sekwencji. +`shot.move(character, start=80, end=120, offset=(0, 50, 0))` dodaje klucze ruchu +o 50 cm względem pozycji w klatce 80, także po `keep_end_position`. +`move` zastępuje klucze pozycji w tym odcinku i odpowiednio przesuwa późniejsze. +`shot.animation(character, clip, reverse=True, keep_end_position=True, start=80, end=120)` +odtwarza kroki i przeniesiony ruch do tyłu. Odległość można nadpisać przez `shot.move`. +`shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150, keep_end_pose=True)` +odtwarza śmierć raz i zachowuje końcową pozę na dalszą część sekwencji. + `camera.create(..., fov=60)` ustawia poziomy kąt widzenia w stopniach: większy FOV daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywowi 45 mm na sensorze o szerokości 36 mm. @@ -24,6 +51,9 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo | Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` | | Światła studyjne | `lights = lighting.studio(scene)` | | Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` | +| Żarówka | `lamp = lighting.point(scene, location=(0, 0, 250), intensity=10000, color=(1, 0.5, 0.2))` | +| Reflektor | `spot = lighting.spot(scene, location=(200, -200, 400), target=(0, 0, 100), angle=30)` | +| Słońce (liczy się tylko kierunek) | `sun = lighting.sun(scene, location=(300, -300, 500), target=(0, 0, 0), intensity=500)` | | Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6)` | | Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` | | Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` | @@ -31,6 +61,8 @@ daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywo | Klucz pozycji | `shot.transform_key(actor, 0, location=(0, 0, 0))` | | Klucz rotacji | `shot.transform_key(actor, 47, rotation=(0, 0, 360))` | | Klucz skali | `shot.transform_key(actor, 24, scale=(1.2, 1.2, 1.2), interpolation="auto")` | +| Klucz koloru światła | `shot.light_key(spot, 0, color=(1, 0, 0))` | +| Klucz jasności światła | `shot.light_key(spot, 47, intensity=40000, interpolation="auto")` | | Zapis + otwarcie Sequencera | `shot.save()` | | Własny renderer | `renderer = Renderer(width=1920, height=1080, samples=8, warmup=32, mp4=True)` | | Preset z własnymi wartościami | `renderer = Renderer.from_preset("final", samples=8)` | diff --git a/docs/validation.md b/docs/validation.md index c2451c0..cc84cee 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -100,3 +100,76 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA nakładania; stykające się klipy nie dostają fade. Sprawdzone łańcuchy trzech klipów oraz odrzucanie niejednoznacznych zakresów i dodawania w odwrotnej kolejności. - Parametr `blend_in` usunięty z API i przykładów. Zakresy scen pozostają takie same. + +## Katalog enumów i długości dostępnych offline + +- 34 testy poza UE: statyczne enumy obejmują wszystkie pliki źródłowe, importują + się bez `unreal`, udostępniają sekundy i przeliczenie klatek z FPS i prędkością. +- Sprawdzone zaokrąglanie czasu float32 bez dodawania zbędnej klatki, automatyczny + koniec dla enuma, jawny koniec oraz odrzucenie sekcji dłuższej niż sekwencja. +- `tools/smoke_animation_catalog.py` w UE: porównane długości wszystkich 63 klipów + z rzeczywistym `get_play_length()`, odtworzone enumy, w tym `speed=2`, oraz render + klatki ze środka automatycznego przejścia. Katalog pochodzi z eksportu assetów UE. +- Po przypisaniu parametrów sekcji przywracany jest zakres podany w Pythonie, + ponieważ edytor może przeskalować go przy zmianie prędkości. + +## Automatyczne katalogi własnych modeli + +- 41 testów poza UE: generowanie nowej klasy, metadane bez `unreal`, zachowanie + innych modeli, aktualizacja podzbioru, usuniętych klipów i niezmienionych danych. +- Sprawdzone odrzucanie kolizji nazw i nieprawidłowych czasów bez naruszenia pliku + oraz dodanie nowej klasy do już załadowanego modułu. +- `tools/smoke_automatic_catalog.py` zaliczony w UE: pierwszy import pod nazwą + StudentCatalogSmoke, automatyczny zapis dwóch klipów, odczyt wygenerowanego enuma + i użycie go do animacji z automatycznym końcem. Katalog testowy zapisano w build. +- Zwykły hook odświeżył także pełne katalogi Fighter i Creature z istniejących assetów. + +## keep_end_position + +- 45 testów poza UE: dodatkowo pozioma interpolacja ruchu, sumowanie pętli, + włączenie przeniesienia do Transform i ponowne użycie jednej kopii klipu. +- `tools/smoke_keep_end_position.py` zaliczony w UE: slash_2 od klatki 57 przy + 30 FPS, wejście slash_3 w klatce 161. Zapisane rzeczywiste klucze rodzica; + po klatce 163 aktor zachowuje około 354 cm przesunięcia dla modelu wysokości 200 cm. +- Sprawdzone ciągłe położenie bioder przy przejściu, brak podwojenia przesunięcia, + niezmieniona źródłowa animacja, ponowne wykonanie i render klatki po ataku. +- Przenoszony jest poziomy ruch pierwszej kości szkieletu (Hips w tym modelu). + Pionowe ruchy i obroty kości zostają w klipie; kopie są w wygenerowanym folderze RootMotion. + +## Odczyt pozycji i ruch względny + +- 49 testów poza UE: odczyt istniejących kanałów pozycji, pozycja startowa bez + ścieżki, dodanie względnego offsetu oraz walidacja bez tworzenia częściowych kluczy. +- `tools/smoke_relative_move.py` zaliczony w UE: creature wysokości 300 cm, + start `(0, -850, 0)`, MUTANT_RUN z keep_end_position, swiping i walking. +- Pozycja w klatce 80 odczytana z natywnych krzywych Sequencera. Ruch 80–120 + zwiększył Y dokładnie o 50 cm, zachował punkt początkowy i pozycję po klatce 120. + Sprawdzono też interpolowaną pozycję w środku ruchu. + +## Cofanie i zastępowanie trasy + +- 53 testy poza UE: odwrócone odtwarzanie, cofanie ruchu po nierównomiernej krzywej, + sumowanie pętli do tyłu, usuwanie kluczy wewnątrz move i przesuwanie późniejszych. +- `tools/smoke_reverse_walk.py` zaliczony w UE: run → swiping → walking z reverse, + naturalny ruch wstecz oraz zastąpienie trasy cofnięciem o 200 cm. +- Sprawdzone pozycje początkowa, środkowa i końcowa, brak wewnętrznych kluczy + z poprzedniej trasy i zachowanie względnego ruchu późniejszego klucza. + +## Zachowanie końcowej pozy + +- 55 testów poza UE: natywne Keep State, zamrożony odcinek końcowej pozy oraz + skracanie tego odcinka, gdy pojawi się następny klip tej samej postaci. +- `tools/smoke_keep_end_pose.py`: śmierć mutanta od klatki 0 przy 30 FPS, render + klatki 299 po zakończeniu klipu. Wizualnie sprawdzono, że mutant leży na ziemi. +- Samo Keep State dawało w takim renderze pozę referencyjną; dodatkowy odcinek + z prędkością 0 i przesunięciem czasu do końca klipu utrzymuje właściwą pozę. + +## Rodzaje świateł i klucze światła + +- 60 testów poza UE: klucze koloru i jasności, ponowne użycie tracków oraz + odrzucanie błędnych argumentów bez tworzenia tracka. +- `tools/smoke_lights.py` w UE: point, spot i sun w jednej scenie, render klatek + 0, 24 i 47. Wizualnie sprawdzono zmianę koloru reflektora z czerwonego na niebieski + oraz wzrost jasności reflektora i żarówki. +- Domyślne `intensity=500` słońca dobrano do stałej ekspozycji `camera.create`; + przy `intensity=1` słońce nie było widoczne na renderze. diff --git a/exercises/advanced_example.py b/exercises/advanced_example.py index 5c1f44e..8318604 100644 --- a/exercises/advanced_example.py +++ b/exercises/advanced_example.py @@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules: if _render_module is not None and _render_module.is_rendering(): raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.") # Najpierw zaleznosci, potem moduly, ktore z nich korzystaja. - for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets", - "_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"): + for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets", + "_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"): _module = sys.modules.get("ue_workshop." + _name) if _module is not None: importlib.reload(_module) @@ -19,32 +19,53 @@ if "ue_workshop" in sys.modules: from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials from ue_workshop.paths import PROJECT from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL - - -# POSTACIE +from ue_workshop.animations import FighterAnimations, CreatureAnimations # 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore. scene = Scene("MyShot") floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11)) -character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200) -#character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300) +character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200, location=(30, -40, 0.16), rotation=(0,0,180)) +character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0,-850,0)) scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor) -lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0)) -cam = camera.create(scene, location=(450, -550, 300), fov=40) + +lights = [ + lighting.area(scene, "Key", (200, -500, 350), color=(1.0, 0.75, 0.5)), + lighting.area(scene, "Fill", (100, 300, 250), intensity=7000, color=(0.3, 0.6, 1.0)), + lighting.area(scene, "Rim", (-200, 0, 300), intensity=15000) +] + +cam = camera.create(scene, location=(200, -300, 200), target=(0, 0, 100), fov=90) # 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119. -shot = Sequence(scene, fps=24, frames=120) +shot = Sequence(scene, fps=30, frames=260) + +# Fighter +shot.animation(character_fighter, FighterAnimations.DRAW_A_GREAT_SWORD_2, start=0) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=22, end=55) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING, start=48) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING_2, start=61) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH, start=80) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_JUMP_ATTACK, start=117, keep_end_position=True) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_STRAFE, start=180, keep_end_position=True) +shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_RUN_2, start=207, end=259, keep_end_position=True) +shot.transform_key(character_fighter,58, location=(30, -40, 0.16), interpolation="auto") +shot.transform_key(character_fighter,64, location=(30, 20, 0.16), interpolation="auto") +shot.move(character_fighter, start=134, end=146, offset=(0, -170, 0), interpolation="auto") + +# Creature +shot.animation(character_creature, CreatureAnimations.MUTANT_RUN, start=0, end=60, keep_end_position=True) +shot.animation(character_creature, CreatureAnimations.MUTANT_SWIPING, start=20) +shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING, start=95, end=160, keep_end_position=True, reverse=True) +shot.animation(character_creature, CreatureAnimations.MUTANT_DYING, start=130, end=259, keep_end_pose=True) + +# Camera shot.camera(cam) +r = cam.get_actor_rotation() +shot.transform_key(cam, 0, rotation=(r.roll, r.pitch, r.yaw)) +shot.transform_key(cam, 10, location=(200, -300, 200), interpolation="auto") +shot.transform_key(cam, 75, location=(450, -550, 300), rotation=(0, -15, 150), interpolation="auto") -shot.animation(character_fighter, "draw_a_great_sword_1", start=0, end=60) - - - -# shot.animation(character_fighter, "great_sword_idle", start=0, end=60) -# shot.animation(character_fighter, "great_sword_attack", start=48, end=120) -# shot.transform_key(character_fighter, 70, location=(0, 0, 0), interpolation="auto") -# shot.transform_key(character_fighter, 100, location=(0, 200, 0), interpolation="auto") shot.save() # 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True. diff --git a/exercises/my_shot.py b/exercises/my_shot.py index c4c1b58..78cbd1a 100644 --- a/exercises/my_shot.py +++ b/exercises/my_shot.py @@ -9,8 +9,8 @@ if "ue_workshop" in sys.modules: if _render_module is not None and _render_module.is_rendering(): raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.") # Najpierw zaleznosci, potem moduly, ktore z nich korzystaja. - for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets", - "_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"): + for _name in ("paths", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets", + "_gltf", "blender", "characters", "skeletal", "root_motion", "scene", "sequence", "render"): _module = sys.modules.get("ue_workshop." + _name) if _module is not None: importlib.reload(_module) diff --git a/exercises/my_shot_headless.py b/exercises/my_shot_headless.py index 13dd8c2..cc05d4d 100644 --- a/exercises/my_shot_headless.py +++ b/exercises/my_shot_headless.py @@ -13,7 +13,7 @@ def main(): raise RuntimeError("Ten plik uruchom z terminala zwyklym Pythonem, poza edytorem UE.") # --render zapewnia film takze wtedy, gdy w my_shot.py jest RENDER=False. # Argumenty launchera, np. --editor i --timeout, mozna dopisac w terminalu. - script = Path(__file__).with_name("my_shot.py") + script = Path(__file__).with_name("advanced_example.py") return run_workshop([str(script), "--render", *sys.argv[1:]]) diff --git a/tests/test_animation_catalog.py b/tests/test_animation_catalog.py new file mode 100644 index 0000000..78f862e --- /dev/null +++ b/tests/test_animation_catalog.py @@ -0,0 +1,45 @@ +"""Offline, statically declared enums and timing metadata for editor use.""" +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python")) +from ue_workshop.animation_clip import AnimationClip +from ue_workshop.animations import FighterAnimations, CreatureAnimations +from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL, _clips + + +class TimingClip(AnimationClip): + WALK = ("walk", 2.0) + FLOAT_DURATION = ("float_duration", 3.200000047683716) + FRACTIONAL = ("fractional", 2.1) + + +class CatalogTests(unittest.TestCase): + def test_catalog_covers_all_source_files_without_duplicate_keys(self): + for catalog, model in ((FighterAnimations, FIGHTER_MODEL), (CreatureAnimations, CREATURE_MODEL)): + with self.subTest(model=model): + keys = [clip.key for clip in catalog] + self.assertEqual(set(keys), set(_clips(model))) + self.assertEqual(len(keys), len(set(keys))) + self.assertTrue(all(clip.seconds > 0 for clip in catalog)) + + def test_metadata_is_available_without_loading_unreal(self): + self.assertNotIn("unreal", sys.modules) + self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.key, "draw_a_great_sword_1") + self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.seconds, 0.5) + self.assertEqual(FighterAnimations.DRAW_A_GREAT_SWORD_1.frames(24), 12) + + def test_frames_respect_fps_speed_and_fractional_end(self): + self.assertEqual(TimingClip.WALK.frames(24), 48) + self.assertEqual(TimingClip.WALK.frames(30, speed=2), 30) + self.assertEqual(TimingClip.FRACTIONAL.frames(24), 51) + self.assertEqual(TimingClip.FLOAT_DURATION.frames(30), 96) + + def test_invalid_fps_and_speed_are_rejected(self): + for fps in (0, -1, True, 24.5): + with self.subTest(fps=fps), self.assertRaises(ValueError): + TimingClip.WALK.frames(fps) + for speed in (0, -1, True, float("nan"), float("inf")): + with self.subTest(speed=speed), self.assertRaises(ValueError): + TimingClip.WALK.frames(24, speed) diff --git a/tests/test_automatic_catalog.py b/tests/test_automatic_catalog.py new file mode 100644 index 0000000..c2bbfd7 --- /dev/null +++ b/tests/test_automatic_catalog.py @@ -0,0 +1,77 @@ +"""Automatic catalogs preserve other models, partial selections and offline imports.""" +from pathlib import Path +import sys +import tempfile +from types import ModuleType +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python")) +from ue_workshop import animation_catalog +from ue_workshop.animation_clip import AnimationClip + + +class AutomaticCatalogTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.output = Path(self.folder.name) / "animations.py" + + def load(self): + namespace = {"__name__": "ue_workshop._catalog_test", "__package__": "ue_workshop"} + exec(compile(self.output.read_text(encoding="utf-8"), str(self.output), "exec"), namespace) + return namespace + + def update(self, name, clips, **options): + return animation_catalog.update_catalog(name, clips, output=self.output, **options) + + def test_new_model_produces_enum_with_durations_without_unreal(self): + self.assertEqual(self.update("StudentHero", {"walking": 2.0}), "StudentHeroAnimations") + clip = self.load()["StudentHeroAnimations"].WALKING + self.assertEqual(clip.key, "walking") + self.assertEqual(clip.seconds, 2) + self.assertEqual(clip.frames(24), 48) + self.assertNotIn("unreal", sys.modules) + + def test_full_update_preserves_other_models_and_removes_deleted_clips(self): + self.update("Fighter", {"walk": 1.0, "attack": 2.0}) + self.update("StudentHero", {"jump": 3.0}) + self.update("Fighter", {"walk": 1.5}) + namespace = self.load() + self.assertEqual([clip.key for clip in namespace["FighterAnimations"]], ["walk"]) + self.assertEqual(namespace["StudentHeroAnimations"].JUMP.seconds, 3.0) + + def test_partial_selection_updates_duration_without_erasing_other_clips(self): + self.update("Hero", {"walk": 1.0, "attack": 2.0}) + self.update("Hero", {"walk": 1.5}, replace=False) + catalog = self.load()["HeroAnimations"] + self.assertEqual(catalog.WALK.seconds, 1.5) + self.assertEqual(catalog.ATTACK.seconds, 2.0) + + def test_unchanged_metadata_does_not_rewrite_file(self): + self.update("Hero", {"walk": 1.0}) + with patch.object(animation_catalog.tempfile, "NamedTemporaryFile", side_effect=AssertionError("rewritten")): + self.update("Hero", {"walk": 1.0}) + + def test_empty_model_is_valid(self): + self.update("Hero", {}) + self.assertEqual(list(self.load()["HeroAnimations"]), []) + + def test_invalid_metadata_does_not_damage_existing_catalog(self): + self.update("Hero", {"walk": 1.0}) + before = self.output.read_bytes() + for data in ({"walk": 0}, {"walk": float("nan")}, {"walk": 1.0, "WALK": 2.0}): + with self.subTest(data=data), self.assertRaises(ValueError): + self.update("Hero", data) + self.assertEqual(self.output.read_bytes(), before) + + def test_publish_adds_class_to_loaded_module_without_replacing_other_enums(self): + module = ModuleType("ue_workshop.animations") + module.AnimationClip = AnimationClip + module.ExistingAnimations = object() + original = module.ExistingAnimations + source = animation_catalog._class_source("StudentHeroAnimations", {"walk": 2.0}) + with patch.dict(sys.modules, {"ue_workshop.animations": module}): + animation_catalog._publish_class(source, self.output) + self.assertEqual(module.StudentHeroAnimations.WALK.frames(24), 48) + self.assertIs(module.ExistingAnimations, original) diff --git a/tests/test_clip_discovery.py b/tests/test_clip_discovery.py index a0ff71c..c75c194 100644 --- a/tests/test_clip_discovery.py +++ b/tests/test_clip_discovery.py @@ -19,6 +19,9 @@ class Clip: def get_name(self): return "EmbeddedTake" + def get_play_length(self): + return 1.5 + class DiscoveryTests(unittest.TestCase): def setUp(self): @@ -40,6 +43,7 @@ class DiscoveryTests(unittest.TestCase): self.cache = patch.object(self.module, "_cached", return_value=[Mesh()]).start() self.importer = patch.object(self.module, "_import", return_value=[Mesh()]).start() self.animation = patch.object(self.module, "import_animation", side_effect=lambda *a, **kw: Clip()).start() + self.catalog = patch.object(self.module, "update_catalog").start() self.addCleanup(patch.stopall) def test_discovery_excludes_model_and_derives_names_without_aliases(self): @@ -55,6 +59,8 @@ class DiscoveryTests(unittest.TestCase): self.assertEqual(self.animation.call_count, 2) for call in self.animation.call_args_list: self.assertFalse(call.kwargs["reimport"]) + self.catalog.assert_called_once_with("Hero", { + "great_sword_attack_2": 1.5, "great_sword_walk": 1.5}, replace=True) def test_empty_dictionary_disables_discovery_and_keeps_embedded_name(self): self.cache.return_value = [Mesh(), Clip()] @@ -67,6 +73,7 @@ class DiscoveryTests(unittest.TestCase): self.assertEqual(set(result.animations), {"selected"}) self.animation.assert_called_once() self.assertEqual(self.animation.call_args.args[0], self.walk) + self.catalog.assert_called_once_with("Hero", {"selected": 1.5}, replace=False) def test_reimport_true_reimports_model_and_discovered_clips(self): self.module.import_model(self.model, name="Hero", reimport=True) diff --git a/tests/test_light_keys.py b/tests/test_light_keys.py new file mode 100644 index 0000000..19f17e5 --- /dev/null +++ b/tests/test_light_keys.py @@ -0,0 +1,58 @@ +"""Validate light keys without an Unreal installation.""" +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest.mock import MagicMock, patch + + +class LightKeyTests(unittest.TestCase): + def setUp(self): + self.unreal = MagicMock() + self.unreal.FrameNumber.side_effect = lambda frame: frame + path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py" + spec = importlib.util.spec_from_file_location("ue_workshop._light_key_test", path) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {"unreal": self.unreal}): + spec.loader.exec_module(module) + self.shot = module.Sequence.__new__(module.Sequence) + self.shot.frames = 48 + self.shot._light_tracks = {} + self.shot.bind = MagicMock() + self.track = self.shot.bind.return_value.add_track.return_value + self.channels = [MagicMock() for _ in range(4)] + self.track.add_section.return_value.get_all_channels.return_value = self.channels + self.actor = MagicMock() + self.light = self.actor.get_component_by_class.return_value + self.light.get_path_name.return_value = "Light" + + def test_color_keys_rgb_and_leaves_alpha_at_default(self): + self.shot.light_key(self.actor, 10, color=(1, 0.5, 0)) + self.shot.bind.assert_called_once_with(self.light) + self.track.set_property_name_and_path.assert_called_once_with("LightColor", "LightColor") + self.assertEqual(self.channels[1].add_key.call_args.args, (10, 0.5)) + self.channels[3].set_default.assert_called_once_with(1.0) + self.channels[3].add_key.assert_not_called() + + def test_later_keys_reuse_the_track(self): + self.shot.light_key(self.actor, 0, intensity=0) + self.shot.light_key(self.actor, 47, intensity=5000) + self.track.add_section.assert_called_once() + self.track.set_property_name_and_path.assert_called_once_with("Intensity", "Intensity") + self.assertEqual(self.channels[0].add_key.call_count, 2) + + def test_color_and_intensity_use_separate_tracks(self): + self.shot.light_key(self.actor, 0, color=(1, 1, 1), intensity=100) + self.assertEqual(self.track.add_section.call_count, 2) + + def test_invalid_arguments_create_no_track(self): + for args in [dict(frame=48, color=(1, 1, 1)), dict(frame=0, color=(1, 1)), + dict(frame=0, intensity=1, interpolation="smooth")]: + with self.assertRaises(ValueError): + self.shot.light_key(self.actor, **args) + self.shot.bind.assert_not_called() + + def test_actor_without_light_is_rejected(self): + self.actor.get_component_by_class.return_value = None + with self.assertRaises(ValueError): + self.shot.light_key(self.actor, 0, intensity=1) diff --git a/tests/test_root_motion.py b/tests/test_root_motion.py new file mode 100644 index 0000000..58b9934 --- /dev/null +++ b/tests/test_root_motion.py @@ -0,0 +1,46 @@ +"""Horizontal root travel, interpolation and accumulated playback loops.""" +import importlib.util +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + + +class Vector: + def __init__(self, x, y, z): + self.x, self.y, self.z = x, y, z + + +class RootMotionTests(unittest.TestCase): + def setUp(self): + unreal = MagicMock() + unreal.Vector = Vector + path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/root_motion.py" + spec = importlib.util.spec_from_file_location("ue_workshop._root_motion_test", path) + self.module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {"unreal": unreal}): + spec.loader.exec_module(self.module) + self.poses = [SimpleNamespace(translation=Vector(0, 0, 100)), + SimpleNamespace(translation=Vector(0, 50, 110)), + SimpleNamespace(translation=Vector(0, 100, 100))] + + def test_horizontal_interpolation_keeps_vertical_motion_in_clip(self): + value = self.module.displacement(self.poses, 2.0, 0.5) + self.assertEqual((value.x, value.y, value.z), (0, 25, 0)) + + def test_end_and_repeated_playback_accumulate_instead_of_reset(self): + self.assertEqual(self.module.displacement(self.poses, 2.0, 2).y, 100) + self.assertEqual(self.module.displacement(self.poses, 2.0, 5).y, 250) + + def test_before_start_has_no_displacement(self): + self.assertEqual(self.module.displacement(self.poses, 2.0, -1).y, 0) + + def test_reverse_accumulates_backwards_without_initial_jump(self): + self.assertEqual(self.module.displacement(self.poses, 2.0, 0, reverse=True).y, 0) + self.assertEqual(self.module.displacement(self.poses, 2.0, 1, reverse=True).y, -50) + self.assertEqual(self.module.displacement(self.poses, 2.0, 5, reverse=True).y, -250) + + def test_reverse_samples_the_end_of_a_nonuniform_trajectory(self): + self.poses[1].translation.y = 20 + self.assertEqual(self.module.displacement(self.poses, 2.0, 0.5, reverse=True).y, -40) diff --git a/tests/test_skeletal_sequence.py b/tests/test_skeletal_sequence.py index f6fc24f..712df3d 100644 --- a/tests/test_skeletal_sequence.py +++ b/tests/test_skeletal_sequence.py @@ -7,6 +7,11 @@ import unittest from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python")) +from ue_workshop.animation_clip import AnimationClip + + +class KnownClip(AnimationClip): + WALK = ("walk", 2.0) class Clip: @@ -17,6 +22,12 @@ class Clip: assert name == "skeleton" return self.skeleton + def get_path_name(self): + return "OriginalClip" + + def get_play_length(self): + return 2.0 + class AnimationTests(unittest.TestCase): def setUp(self): @@ -35,6 +46,10 @@ class AnimationTests(unittest.TestCase): self.shot.asset = MagicMock() self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1) self.shot._animations = {} + self.shot._transforms = {} + self.shot._root_motion = {} + self.shot._root_motion_copies = {} + self.shot._pose_holds = {} self.shot.bind = MagicMock() self.actor = MagicMock() self.actor.get_path_name.return_value = "Parent" @@ -44,7 +59,7 @@ class AnimationTests(unittest.TestCase): self.clip = Clip(self.skeleton) self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip}) self.imported.mesh.get_editor_property.return_value = self.skeleton - self.shot.scene = SimpleNamespace(_skeletal_models={"Parent": (self.child, self.imported)}) + self.shot.scene = SimpleNamespace(name="TestScene", _skeletal_models={"Parent": (self.child, self.imported)}) def test_adjacent_clips_share_child_track_and_set_speed(self): self.shot.animation(self.actor, "walk", start=0, end=48) @@ -55,6 +70,24 @@ class AnimationTests(unittest.TestCase): track.add_section.return_value.set_range.assert_called_with(48, 120) self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8) + def test_enum_plays_once_when_end_is_omitted(self): + section = self.shot.animation(self.actor, KnownClip.WALK, start=10) + section.set_range.assert_called_with(10, 58) + section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", self.clip) + + def test_enum_automatic_end_respects_speed(self): + section = self.shot.animation(self.actor, KnownClip.WALK, start=10, speed=2) + section.set_range.assert_called_with(10, 34) + + def test_explicit_end_overrides_enum_duration(self): + section = self.shot.animation(self.actor, KnownClip.WALK, start=0, end=120) + section.set_range.assert_called_with(0, 120) + + def test_enum_cannot_exceed_sequence_length(self): + with self.assertRaises(ValueError): + self.shot.animation(self.actor, KnownClip.WALK, start=100) + self.shot.bind.assert_not_called() + def test_nested_clip_is_rejected_without_creating_section(self): self.shot.animation(self.actor, "walk", start=0, end=48) with self.assertRaises(ValueError): @@ -64,11 +97,62 @@ class AnimationTests(unittest.TestCase): def test_invalid_ranges_and_speeds_do_not_create_track(self): for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48}, {"start": True}, {"speed": 0}, {"speed": float("nan")}, - {"speed": float("inf")}): + {"speed": float("inf")}, {"keep_end_position": 1}, {"reverse": 1}, + {"keep_end_pose": 1}): with self.subTest(options=options), self.assertRaises(ValueError): self.shot.animation(self.actor, "walk", **options) self.shot.bind.assert_not_called() + def test_reverse_is_set_on_native_animation_parameters(self): + section = self.shot.animation(self.actor, "walk", start=0, end=60, reverse=True) + section.get_editor_property.return_value.set_editor_property.assert_any_call("reverse", True) + + def test_keep_end_pose_uses_native_keep_state(self): + main, hold = MagicMock(), MagicMock() + self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold] + section = self.shot.animation(self.actor, KnownClip.WALK, start=10, keep_end_pose=True) + section.set_completion_mode.assert_called_once_with(self.unreal.MovieSceneCompletionMode.KEEP_STATE) + section.set_range.assert_called_with(10, 58) + hold.set_range.assert_called_with(58, 120) + self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.0) + + def test_later_clip_truncates_frozen_pose(self): + main, hold, next_section = MagicMock(), MagicMock(), MagicMock() + self.shot.bind.return_value.add_track.return_value.add_section.side_effect = [main, hold, next_section] + self.shot.animation(self.actor, KnownClip.WALK, start=0, keep_end_pose=True) + self.shot.animation(self.actor, KnownClip.WALK, start=60) + hold.set_range.assert_called_with(48, 60) + + def test_relative_move_replaces_baked_keys_and_offsets_future_motion(self): + axes = [MagicMock() for _ in range(3)] + future_keys = [] + for axis in axes: + interior, future = MagicMock(), MagicMock() + interior.get_time.return_value.frame_number.value = 90 + future.get_time.return_value.frame_number.value = 150 + future.get_value.return_value = 100 + axis.get_keys.return_value = [interior, future] + future_keys.append(future) + self.shot._transforms["Parent"] = axes + self.shot.position = MagicMock(side_effect=[(0, 300, 0), (0, 350, 0)]) + self.shot.transform_key = MagicMock() + self.assertEqual(self.shot.move(self.actor, 80, 110, offset=(0, -200, 0)), (0, 100, 0)) + for axis in axes: + axis.remove_key.assert_called_once_with(axis.get_keys.return_value[0]) + future_keys[1].set_value.assert_called_once_with(-150) + + def test_keep_end_position_uses_copy_and_adds_transform_motion(self): + copy = Clip(self.skeleton) + prepare = MagicMock(return_value=(copy, ["poses"], 2.0)) + self.shot._keep_end_position = MagicMock() + with patch.dict(sys.modules, {"ue_workshop.root_motion": SimpleNamespace(prepare=prepare)}): + section = self.shot.animation(self.actor, "walk", start=0, end=48, keep_end_position=True) + self.shot.animation(self.actor, "walk", start=60, end=108, keep_end_position=True) + prepare.assert_called_once() + self.assertIs(self.imported.animations["walk"], self.clip) + section.get_editor_property.return_value.set_editor_property.assert_any_call("animation", copy) + self.assertEqual(self.shot._keep_end_position.call_count, 2) + def test_crossfade_sets_complementary_weight_curves(self): track = self.shot.bind.return_value.add_track.return_value first, second = MagicMock(), MagicMock() @@ -143,3 +227,33 @@ class AnimationTests(unittest.TestCase): with self.assertRaises(ValueError): self.shot.animation(self.actor, "walk") self.shot.bind.assert_not_called() + + def test_position_uses_existing_sequencer_curves(self): + axes = [MagicMock(), MagicMock(), MagicMock()] + for channel, value in zip(axes, (10, 250, 3)): + channel.evaluate_keys.return_value = [value] + self.shot._transforms["Parent"] = axes + self.assertEqual(self.shot.position(self.actor, 80), (10, 250, 3)) + self.shot.asset.make_range.assert_called_once_with(80, 1) + + def test_position_without_track_uses_spawn_position(self): + self.actor.get_actor_location.return_value.to_tuple.return_value = (30, -850, 0) + self.assertEqual(self.shot.position(self.actor, 80), (30, -850, 0)) + + def test_relative_move_anchors_existing_position_and_adds_offset(self): + self.shot.position = MagicMock(return_value=(10, 250, 3)) + self.shot.transform_key = MagicMock() + target = self.shot.move(self.actor, start=80, end=110, offset=(0, 50, 0)) + self.assertEqual(target, (10, 300, 3)) + self.shot.position.assert_called_once_with(self.actor, 80) + self.shot.transform_key.assert_any_call(self.actor, 80, location=(10, 250, 3), interpolation="auto") + self.shot.transform_key.assert_any_call(self.actor, 110, location=(10, 300, 3), interpolation="auto") + + def test_invalid_relative_move_does_not_write_keys(self): + self.shot.transform_key = MagicMock() + for options in ({"start": 80, "end": 80}, {"start": 80, "end": 120}, + {"start": 80, "end": 110, "offset": (0, float("nan"), 0)}): + options.setdefault("offset", (0, 50, 0)) + with self.subTest(options=options), self.assertRaises(ValueError): + self.shot.move(self.actor, **options) + self.shot.transform_key.assert_not_called() diff --git a/tools/smoke_animation_catalog.py b/tools/smoke_animation_catalog.py new file mode 100644 index 0000000..740d995 --- /dev/null +++ b/tools/smoke_animation_catalog.py @@ -0,0 +1,34 @@ +"""Validate offline catalog timings against UE and play enum clips with automatic ends.""" +import math + +from ue_workshop import Scene, Sequence, Renderer, camera, lighting +from ue_workshop.animations import FighterAnimations, CreatureAnimations +from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL +from ue_workshop.skeletal import import_model + +for model, name, catalog in ((FIGHTER_MODEL, "Fighter", FighterAnimations), + (CREATURE_MODEL, "Creature", CreatureAnimations)): + imported = import_model(model, name=name, reimport=False) + for clip in catalog: + assert math.isclose(clip.seconds, imported.animations[clip.key].get_play_length(), + rel_tol=0, abs_tol=0.000001), clip.key + +scene = Scene("CatalogSmoke") +fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", rotation=(0, 0, 180)) +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2)) +lighting.studio(scene) +cam = camera.create(scene, location=(450, -550, 300), fov=45) +shot = Sequence(scene, fps=24, frames=120) +shot.camera(cam) +draw = FighterAnimations.DRAW_A_GREAT_SWORD_1 +attack = FighterAnimations.GREAT_SWORD_ATTACK +first = shot.animation(fighter, draw, start=0) +second_start = draw.frames(shot.fps) - 6 +second = shot.animation(fighter, attack, start=second_start) +fast = shot.animation(fighter, attack, start=60, speed=2) +assert first.get_end_frame() == draw.frames(shot.fps) +assert second.get_end_frame() == second_start + attack.frames(shot.fps) +assert fast.get_end_frame() == 60 + attack.frames(shot.fps, speed=2) +assert second.get_editor_property("params").animation.get_name() == attack.key +shot.save(open_editor=False) +renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False) diff --git a/tools/smoke_automatic_catalog.py b/tools/smoke_automatic_catalog.py new file mode 100644 index 0000000..c17268d --- /dev/null +++ b/tools/smoke_automatic_catalog.py @@ -0,0 +1,32 @@ +"""Import a model under a student name and verify its enum is generated automatically.""" +from unittest.mock import patch + +from ue_workshop import Scene, Sequence +from ue_workshop import animation_catalog, skeletal +from ue_workshop.characters import FIGHTER_MODEL, _clips +from ue_workshop.paths import PROJECT + +scene = Scene("AutomaticCatalogSmoke") +clips = _clips(FIGHTER_MODEL) +output = PROJECT / "build/AutomaticCatalogSmoke/animations.py" +update = animation_catalog.update_catalog +with patch.object(skeletal, "update_catalog", side_effect=lambda name, data, **options: + update(name, data, output=output, **options)): + actor = scene.skeletal_model( + FIGHTER_MODEL, name="StudentCatalogSmoke", reimport=False, + animations={key: clips[key] for key in ("great_sword_idle", "great_sword_attack")}) + +namespace = {"__name__": "ue_workshop._generated_smoke", "__package__": "ue_workshop"} +exec(compile(output.read_text(encoding="utf-8"), str(output), "exec"), namespace) +catalog = namespace["StudentCatalogSmokeAnimations"] +assert len(catalog) == 2 +attack = catalog.GREAT_SWORD_ATTACK +assert attack.seconds > 0 +shot = Sequence(scene, frames=120) +section = shot.animation(actor, attack, start=0) +assert section.get_end_frame() == attack.frames(shot.fps) +shot.save(open_editor=False) + +# Check the normal hook updates the real existing catalog and preserves models. +skeletal.import_model(FIGHTER_MODEL, name="Fighter", reimport=False) +skeletal.import_model(PROJECT / "source_assets/character/creature/Maw J Laygo.fbx", name="Creature", reimport=False) diff --git a/tools/smoke_keep_end_pose.py b/tools/smoke_keep_end_pose.py new file mode 100644 index 0000000..08af6ac --- /dev/null +++ b/tools/smoke_keep_end_pose.py @@ -0,0 +1,31 @@ +"""Check native pose retention and render a frame after the dying clip.""" + +import unreal + +from ue_workshop import Scene, Sequence, Renderer, camera, lighting +from ue_workshop.animations import CreatureAnimations as C +from ue_workshop.characters import CREATURE_MODEL + +scene = Scene("KeepPoseSmoke") +actor = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300) +child, imported = scene._skeletal_models[actor.get_path_name()] +component = child.skeletal_mesh_component +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2)) +lighting.studio(scene) +cam = camera.create(scene, location=(500, -650, 400), target=(0, 0, 80), fov=60) +shot = Sequence(scene, fps=30, frames=300) +shot.camera(cam) +section = shot.animation(actor, C.MUTANT_DYING, start=0, keep_end_pose=True) +assert section.get_completion_mode() == unreal.MovieSceneCompletionMode.KEEP_STATE +shot.save(open_editor=False) +clip = imported.animations[C.MUTANT_DYING.key] +changed = False +for bone in unreal.AnimationLibrary.get_animation_track_names(clip): + first = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, 0, False) + last = unreal.AnimationLibrary.get_bone_pose_for_time(clip, bone, clip.get_play_length(), False) + if not first.rotation.equals(last.rotation, 0.001): + changed = True + break +assert changed, "Dying source has no pose change" +assert section.get_end_frame() < 299 +renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False) diff --git a/tools/smoke_keep_end_position.py b/tools/smoke_keep_end_position.py new file mode 100644 index 0000000..96b7402 --- /dev/null +++ b/tools/smoke_keep_end_position.py @@ -0,0 +1,51 @@ +"""Verify moving slash root travel becomes persistent parent Transform keys.""" +import json +import math + +import unreal + +from ue_workshop import Scene, Sequence, Renderer, camera, lighting +from ue_workshop.animations import FighterAnimations as F +from ue_workshop.characters import FIGHTER_MODEL +from ue_workshop.paths import PROJECT + +scene = Scene("KeepEndSmoke") +actor = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200) +child, imported = scene._skeletal_models[actor.get_path_name()] +original = imported.animations[F.GREAT_SWORD_SLASH_2.key] +bone = child.skeletal_mesh_component.get_bone_name(0) +before = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False) +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2)) +lighting.studio(scene) +cam = camera.create(scene, location=(650, -650, 450), target=(0, 150, 100), fov=55) +shot = Sequence(scene, fps=30, frames=300) +shot.camera(cam) +section = shot.animation(actor, F.GREAT_SWORD_SLASH_2, start=57, keep_end_position=True) +shot.animation(actor, F.GREAT_SWORD_SLASH_3, start=161) +copy = section.get_editor_property("params").animation +assert copy != original +end = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, copy.get_play_length(), False) +start = unreal.AnimationLibrary.get_bone_pose_for_time(copy, bone, 0, False) +assert abs(end.translation.y - start.translation.y) < 0.001 +after = unreal.AnimationLibrary.get_bone_pose_for_time(original, bone, original.get_play_length(), False) +assert abs(after.translation.y - before.translation.y) < 0.001 +channels = shot._transforms[actor.get_path_name()] +last = [channel.get_keys()[-1].get_value() for channel in channels[:3]] +assert math.hypot(last[0], last[1]) > 100, last +assert channels[0].get_keys()[-1].get_time().frame_number.value == section.get_end_frame() +shot.save(open_editor=False) +unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(shot.asset) +positions = {} +hips_positions = {} +for frame in (57, 160, 161, 162, 163, 164, 190): + unreal.LevelSequenceEditorBlueprintLibrary.set_current_time(frame) + unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence() + positions[frame] = actor.get_actor_location().to_tuple() + hips_positions[frame] = child.skeletal_mesh_component.get_socket_location(bone).to_tuple() +assert math.dist(positions[163], positions[190]) < 0.01, positions +assert math.dist(positions[162], positions[163]) < 20, positions +assert math.dist(hips_positions[162], hips_positions[163]) < 30, hips_positions +assert abs((hips_positions[160][1] - hips_positions[57][1]) - + (positions[160][1] - positions[57][1])) < 1, hips_positions +(PROJECT / "Saved/Workshop/keep_end_position.json").write_text(json.dumps(positions, indent=2), encoding="utf-8") +renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False) diff --git a/tools/smoke_lights.py b/tools/smoke_lights.py new file mode 100644 index 0000000..40fc636 --- /dev/null +++ b/tools/smoke_lights.py @@ -0,0 +1,23 @@ +"""Check every light type and render keyed color and intensity (frames 0 and 47).""" +from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials + +scene = Scene("LightsSmoke") +floor = materials.color("LightsFloor", rgb=(0.5, 0.5, 0.5)) +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor) +scene.primitive("Sphere", name="Ball", location=(0, 0, 50), material=floor) +sun = lighting.sun(scene, color=(1, 0.9, 0.8)) +point = lighting.point(scene, location=(-300, 200, 200), color=(0, 1, 0)) +spot = lighting.spot(scene, location=(0, -300, 400), target=(0, 0, 0)) +cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 50)) +shot = Sequence(scene, fps=24, frames=48) +shot.camera(cam) +shot.light_key(spot, 0, color=(1, 0, 0), intensity=5000) +shot.light_key(spot, 47, color=(0, 0, 1), intensity=40000, interpolation="auto") +shot.light_key(point, 0, intensity=0) +shot.light_key(point, 47, intensity=20000) +shot.light_key(sun, 0, intensity=0) +shot.light_key(sun, 24, intensity=500) +shot.light_key(sun, 25, intensity=0, interpolation="constant") +shot.save(open_editor=False) +assert len(shot.asset.get_bindings()) >= 2 +renderer = Renderer(width=640, height=360, samples=1, warmup=8, mp4=False) diff --git a/tools/smoke_relative_move.py b/tools/smoke_relative_move.py new file mode 100644 index 0000000..2c9ae32 --- /dev/null +++ b/tools/smoke_relative_move.py @@ -0,0 +1,24 @@ +"""Verify position queries and relative movement after kept Mixamo root travel.""" +import math + +from ue_workshop import Scene, Sequence +from ue_workshop.animations import CreatureAnimations as C +from ue_workshop.characters import CREATURE_MODEL + +scene = Scene("RelativeMoveSmoke") +creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0)) +shot = Sequence(scene, fps=30, frames=180) +shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True) +shot.animation(creature, C.MUTANT_SWIPING, start=20) +shot.animation(creature, C.MUTANT_WALKING, start=80, end=120) +before = shot.position(creature, 80) +assert math.dist(before, (0, -850, 0)) > 100, before +target = shot.move(creature, start=80, end=120, offset=(0, 50, 0), interpolation="auto") +assert math.dist(shot.position(creature, 80), before) < 0.001 +assert math.dist(shot.position(creature, 120), target) < 0.001 +assert abs(target[1] - before[1] - 50) < 0.001 +assert math.dist(shot.position(creature, 179), target) < 0.001 +middle = shot.position(creature, 100) +assert before[1] < middle[1] < target[1] +shot.save(open_editor=False) +print("Relative movement from", before, "to", target) diff --git a/tools/smoke_reverse_walk.py b/tools/smoke_reverse_walk.py new file mode 100644 index 0000000..20943be --- /dev/null +++ b/tools/smoke_reverse_walk.py @@ -0,0 +1,30 @@ +"""Reverse a walking clip and replace baked travel with an explicit retreat.""" +import math + +from ue_workshop import Scene, Sequence +from ue_workshop.animations import CreatureAnimations as C +from ue_workshop.characters import CREATURE_MODEL + +scene = Scene("ReverseWalkingSmoke") +creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0, -850, 0)) +shot = Sequence(scene, fps=30, frames=180) +shot.animation(creature, C.MUTANT_RUN, start=0, end=60, keep_end_position=True) +shot.animation(creature, C.MUTANT_SWIPING, start=20) +walk = shot.animation(creature, C.MUTANT_WALKING, start=80, end=120, + keep_end_position=True, reverse=True) +assert walk.get_editor_property("params").get_editor_property("reverse") +before = shot.position(creature, 80) +natural_end = shot.position(creature, 120) +assert natural_end[1] < before[1] - 10, (before, natural_end) +# A later authored key should retain its relative movement after replacement. +shot.transform_key(creature, 150, location=(natural_end[0], natural_end[1] + 30, natural_end[2])) +target = shot.move(creature, start=80, end=120, offset=(0, -200, 0)) +assert math.dist(shot.position(creature, 80), before) < 0.001 +assert math.dist(shot.position(creature, 120), target) < 0.001 +assert abs(target[1] - before[1] + 200) < 0.001 +middle = shot.position(creature, 100) +assert target[1] < middle[1] < before[1], middle +assert abs(shot.position(creature, 150)[1] - target[1] - 30) < 0.001 +channels = shot._transforms[creature.get_path_name()] +assert all(not 80 < key.get_time().frame_number.value < 120 for key in channels[1].get_keys()) +shot.save(open_editor=False) diff --git a/tools/update_animation_catalog.py b/tools/update_animation_catalog.py new file mode 100644 index 0000000..e549349 --- /dev/null +++ b/tools/update_animation_catalog.py @@ -0,0 +1,12 @@ +"""Optional refresh of bundled enums; other models are preserved. Imports update them automatically.""" +from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL +from ue_workshop.skeletal import import_model + +counts = {} +for model, name in ( + (FIGHTER_MODEL, "Fighter"), + (CREATURE_MODEL, "Creature"), +): + imported = import_model(model, name=name, reimport=False) + counts[name + "Animations"] = len(imported.animations) +print("Exported animation catalog:", counts)