Skip to content

Commit 236fef1

Browse files
sp00ktoberclaude
andcommitted
feat(mhfu-port): sequences and clip continuity
sequence: the chain of moves linked by Move.after, its heads and step names. continuity: ranks a donor's clips by how well one's first pose continues another's last, by mean joint rotation angle with the body heading removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 394f2a2 commit 236fef1

4 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Which clip fits after which: how well clip B's first pose continues clip A's last.
4+
5+
A pose is each animated joint's local rotation as the engine's FK sees it (`fk`), with the
6+
body's heading about y taken out (YAW owns it, `travel`); the root's travel is location, which
7+
no rotation carries. Two poses are the mean angle between their joints, in degrees: smaller fits
8+
better. It ranks poses, not intent: a clip that hands to the next through a blend, or through a
9+
third clip, scores high.
10+
11+
A channel holds past its last key, so a clip's first and last poses are its first and last keys'
12+
values: ranking a moveset takes two poses per clip, once (`Ends`).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Collection, Mapping
18+
19+
import numpy as np
20+
from mhp_formats.anim import Clip
21+
from mhp_formats.skeleton import Skeleton
22+
23+
from . import build, fk, travel
24+
from .data import Data
25+
from .manifest import Manifest
26+
from .motion import SOURCE_BANK
27+
28+
GOOD, FAIR = 10.0, 20.0
29+
"""Degrees. A fifth of a donor's clip pairs lie within GOOD (clips that start and end on the same
30+
stance); the Zinogre's backflip parts meet at 15, FAIR; beyond it the poses do not meet."""
31+
32+
Scores = list[tuple[int, float]]
33+
34+
35+
def word(score: float) -> str:
36+
"""`good`, `fair` or `poor`."""
37+
return "good" if score <= GOOD else "fair" if score <= FAIR else "poor"
38+
39+
40+
class Ends:
41+
"""The first and last pose of each clip: `(clips, joints, 3, 3)` rotations, the joints being
42+
those some pose turns."""
43+
44+
def __init__(self, ids: tuple[int, ...], first: fk.Floats, last: fk.Floats) -> None:
45+
self.ids, self.first, self.last = ids, first, last
46+
47+
def scores(self, a: int) -> fk.Floats:
48+
"""Degrees from the end of clip `a` to the start of each clip, in `ids` order."""
49+
if a not in self.ids:
50+
raise KeyError(f"no clip {a}")
51+
if not self.first.shape[1]:
52+
return np.zeros(len(self.ids))
53+
trace = np.einsum("jik,njik->nj", self.last[self.ids.index(a)], self.first)
54+
angle: fk.Floats = np.degrees(np.arccos(np.clip((trace - 1) / 2, -1, 1)))
55+
mean: fk.Floats = angle.mean(axis=1)
56+
return mean
57+
58+
59+
def ends(
60+
clips: Mapping[int, Clip | fk.Curves],
61+
skeleton: Skeleton,
62+
record_of: Mapping[int, int] | None = None,
63+
) -> Ends:
64+
"""The end poses of `clips` (by id) on `skeleton`. `record_of` maps a donor's bones to its
65+
clips' tracks (`build.record_map`); without it, track i drives joint i."""
66+
rig = fk.Rig.from_skeleton(skeleton)
67+
ids = tuple(sorted(clips))
68+
rot, loc = [], []
69+
for i in ids:
70+
c = clips[i]
71+
curves = c if isinstance(c, fk.Curves) else fk.Curves(c, rig, record_of)
72+
r, t = curves.at(np.array([0.0, float(curves.last_frame)]))
73+
rot.append(r)
74+
loc.append(t)
75+
if not ids:
76+
return Ends(ids, np.zeros((0, 0, 3, 3)), np.zeros((0, 0, 3, 3)))
77+
rotations, locations = np.stack(rot), np.stack(loc)
78+
pose = fk.euler_xyz(rotations)
79+
body = np.flatnonzero(rig.parents == travel.root(skeleton))
80+
pose[..., body, :, :] = _unturned(rig.world(rotations, locations)[..., body, :3, :3])
81+
moving = np.flatnonzero(np.abs(pose - np.eye(3)).max(axis=(0, 1, 3, 4)) > 1e-9)
82+
return Ends(ids, pose[:, 0, moving], pose[:, 1, moving])
83+
84+
85+
def _unturned(body: fk.Floats) -> fk.Floats:
86+
"""`(..., body, 3, 3)` rotations with the heading about y they share taken out."""
87+
heading = np.arctan2(
88+
(body[..., 0, 2] - body[..., 2, 0]).sum(axis=-1),
89+
(body[..., 0, 0] + body[..., 2, 2]).sum(axis=-1),
90+
)
91+
c, s = np.cos(heading), np.sin(heading)
92+
back = np.zeros((*heading.shape, 3, 3)) # a turn of -heading about y
93+
back[..., 0, 0] = back[..., 2, 2] = c
94+
back[..., 0, 2], back[..., 2, 0], back[..., 1, 1] = -s, s, 1.0
95+
out: fk.Floats = back[..., None, :, :] @ body
96+
return out
97+
98+
99+
def donor_ends(m: Manifest, data: Data) -> Ends:
100+
"""The end poses of the manifest's donor moveset, on the donor's own skeleton."""
101+
d = build.donor(m, data)
102+
return ends(d.clips, d.skeleton, build.record_map(d, m.build))
103+
104+
105+
def fits_after(e: Ends, a: int, *, top: int | None = None) -> Scores:
106+
"""Every clip but `a` as `(id, degrees)`, best first: how far its first pose is from the
107+
last of `a`. Equal scores go nearest the id of `a` first. `top` keeps that many."""
108+
rows = [(i, float(s)) for i, s in zip(e.ids, e.scores(a), strict=True) if i != a]
109+
rows.sort(key=lambda r: (round(r[1], 3), abs(r[0] - a), r[0]))
110+
return rows if top is None else rows[:top]
111+
112+
113+
def neighbours(a: int, ids: Collection[int]) -> list[int]:
114+
"""`ids` but `a`, nearest first: its own stream by slot number, then the other streams by
115+
how far off they are."""
116+
stream, slot = divmod(a, SOURCE_BANK)
117+
118+
def apart(i: int) -> tuple[int, int, int]:
119+
return abs(i // SOURCE_BANK - stream), abs(i % SOURCE_BANK - slot), i
120+
121+
return sorted((i for i in ids if i != a), key=apart)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""Sequences: moves that run one after another through `Move.after`.
4+
5+
A sequence is a chain of moves, each handing to the next. Its head is a move no other move's
6+
`after` names; a ring of moves with no head is headed by its alphabetically first. A move that
7+
two chains reach belongs to the first head, in the manifest's order.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from .manifest import Manifest, ManifestError
13+
14+
15+
def chain(m: Manifest, head: str) -> list[str]:
16+
"""`head` and the moves it hands to in turn, up to the first repeat."""
17+
out: list[str] = []
18+
name: str | None = head
19+
while name is not None and name in m.moves and name not in out:
20+
out.append(name)
21+
name = m.moves[name].after
22+
return out
23+
24+
25+
def loops(m: Manifest, head: str) -> bool:
26+
"""Whether the chain from `head` ends by handing back to one of its own moves."""
27+
steps = chain(m, head)
28+
return bool(steps) and m.moves[steps[-1]].after is not None
29+
30+
31+
def heads(m: Manifest) -> list[str]:
32+
"""Each sequence's head, in the manifest's order."""
33+
named = {mv.after for mv in m.moves.values() if mv.after is not None}
34+
out = [n for n in m.moves if n not in named]
35+
reached = {s for h in out for s in chain(m, h)}
36+
for n in sorted(m.moves): # what is left is rings: every move in one is named by another
37+
if n not in reached:
38+
out.append(n)
39+
reached.update(chain(m, n))
40+
order = list(m.moves)
41+
return sorted(out, key=order.index)
42+
43+
44+
def head_of(m: Manifest, name: str) -> str:
45+
"""The head of the sequence `name` belongs to."""
46+
for h in heads(m):
47+
if name in chain(m, h):
48+
return h
49+
raise ManifestError(f"no move {name!r}")
50+
51+
52+
def step_name(m: Manifest, head: str) -> str:
53+
"""The lowest `<head>_<k>` from k = 2 that no move has."""
54+
k = 2
55+
while f"{head}_{k}" in m.moves:
56+
k += 1
57+
return f"{head}_{k}"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
import math
4+
from pathlib import Path
5+
6+
import pytest
7+
from mhfu_port import continuity, manifest
8+
from mhfu_port.data import Data
9+
from mhp_formats.anim import Channel, Clip, Keyframe, Track, quantize
10+
from mhp_formats.skeleton import Bone, Skeleton
11+
12+
PORTS = Path(__file__).parents[3] / "ports"
13+
ROT = (0x008, 0x010, 0x020)
14+
15+
16+
def skeleton() -> Skeleton:
17+
"""Joint 0 and the root 1 carry the travel; the body 2 and a limb 3 turn."""
18+
return Skeleton(
19+
[
20+
Bone(parent=-1, child=1),
21+
Bone(parent=0, child=2),
22+
Bone(parent=1, child=3, position=(0.0, 0.0, 50.0)),
23+
Bone(parent=2, position=(0.0, 30.0, 100.0)),
24+
]
25+
)
26+
27+
28+
def turn(axis: int, degrees: float) -> Channel:
29+
return Channel(ROT[axis], [Keyframe(quantize("rot", math.radians(degrees)), 0)])
30+
31+
32+
def held(channels: list[Channel], frames: int) -> Track:
33+
"""Each channel at its first key's value from frame 0 to `frames`."""
34+
return Track(
35+
[Channel(c.bit, [c.keyframes[0], c.keyframes[0]._replace(frame=frames)]) for c in channels]
36+
)
37+
38+
39+
def clip(body: list[Channel], limb: list[Channel], frames: int = 10) -> Clip:
40+
"""A clip whose joints 2 and 3 hold the given channels."""
41+
return Clip([Track(), Track(), held(body, frames), held(limb, frames)])
42+
43+
44+
def moveset(**poses: tuple[float, float]) -> dict[int, Clip]:
45+
"""Clip i holds the body at x degrees and the limb at y degrees about x."""
46+
return {int(k[1:]): clip([turn(0, b)], [turn(0, ll)]) for k, (b, ll) in poses.items()}
47+
48+
49+
def test_a_clip_fits_itself_best_and_a_rotated_one_worse():
50+
e = continuity.ends(moveset(c1=(0, 0), c2=(0, 0), c3=(0, 90), c4=(90, 90)), skeleton())
51+
got = dict(continuity.fits_after(e, 1))
52+
assert got[2] == pytest.approx(0.0) and got[3] == pytest.approx(45.0)
53+
assert got[4] == pytest.approx(90.0), "both joints turn 90 degrees"
54+
assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 4]
55+
assert continuity.fits_after(e, 1, top=1) == [(2, pytest.approx(0.0, abs=1e-6))]
56+
57+
58+
def test_equal_scores_go_nearest_first():
59+
e = continuity.ends(moveset(c1=(0, 0), c9=(0, 0), c3=(0, 0), c2=(0, 0)), skeleton())
60+
assert [i for i, _ in continuity.fits_after(e, 1)] == [2, 3, 9]
61+
62+
63+
def test_the_body_heading_is_not_a_difference():
64+
still = clip([turn(0, 30)], [turn(0, 10)])
65+
faced = clip([turn(0, 30), turn(1, 70)], [turn(0, 10)]) # the same pose, turned about y
66+
e = continuity.ends({1: still, 2: faced}, skeleton())
67+
assert dict(continuity.fits_after(e, 1))[2] < 1.0
68+
69+
70+
def test_the_end_poses_are_the_clips_ends():
71+
start = Track([Channel(ROT[0], [Keyframe(0, 0), Keyframe(quantize("rot", math.pi / 2), 8)])])
72+
swing = Clip([Track(), Track(), start, Track()])
73+
rest = clip([turn(0, 0)], [])
74+
e = continuity.ends({1: swing, 2: rest, 3: clip([turn(0, 90)], [])}, skeleton())
75+
after_swing, before_swing = dict(continuity.fits_after(e, 1)), dict(continuity.fits_after(e, 2))
76+
assert after_swing[3] == pytest.approx(0.0, abs=0.1), "the swing ends where 3 begins"
77+
assert before_swing[1] == pytest.approx(0.0), "and begins where the rest ends"
78+
79+
80+
def test_words():
81+
assert [continuity.word(x) for x in (0.0, 10.0, 15.0, 20.0, 31.0)] == [
82+
"good",
83+
"good",
84+
"fair",
85+
"fair",
86+
"poor",
87+
]
88+
89+
90+
def test_neighbours():
91+
ids = [101, 103, 104, 200, 201, 230, 231, 232, 5]
92+
assert continuity.neighbours(231, ids) == [230, 232, 201, 200, 104, 103, 101, 5]
93+
assert continuity.neighbours(103, [103, 101, 104, 5]) == [104, 101, 5]
94+
95+
96+
# ---- the Zinogre's donor (MHP3RD_DATA) ----
97+
98+
99+
@pytest.fixture(scope="module")
100+
def zinogre(data: Data) -> continuity.Ends:
101+
return continuity.donor_ends(manifest.load(PORTS / "zinogre.toml"), data)
102+
103+
104+
def rank(e: continuity.Ends, a: int, b: int) -> int:
105+
return [i for i, _ in continuity.fits_after(e, a)].index(b) + 1
106+
107+
108+
@pytest.mark.parametrize("a, b", [(218, 219), (219, 220), (221, 222), (222, 223)])
109+
def test_the_backflip_parts_chain(zinogre, a, b):
110+
assert rank(zinogre, a, b) <= 3
111+
112+
113+
def test_the_flinch_roll_parts_chain_crossed(zinogre):
114+
"""The donor's part 2s are crossed with its part 1s: 103 (_left_) ends where 108 (_right_)
115+
begins, 4 degrees off, and 107 is 42 off."""
116+
names = {103: "flinch_roll_left_part_1", 108: "flinch_roll_right_part_2"}
117+
assert rank(zinogre, 103, 108) <= 3 and rank(zinogre, 104, 107) <= 3, names
118+
assert dict(continuity.fits_after(zinogre, 103))[108] < continuity.GOOD
119+
120+
121+
def test_the_whole_moveset_ranks_fast(zinogre):
122+
import time
123+
124+
t = time.perf_counter()
125+
for a in zinogre.ids:
126+
continuity.fits_after(zinogre, a)
127+
assert time.perf_counter() - t < 1.0
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
from mhfu_port import manifest, sequence
4+
5+
HEAD = """
6+
[port]
7+
name = "z"
8+
host_species = 75
9+
pac = "z.bin"
10+
[source]
11+
model = 5339
12+
[clips.a]
13+
slot = 1
14+
"""
15+
16+
17+
def moves(**after: str | None) -> manifest.Manifest:
18+
"""Own moves in the given order, each `after` the move named (None: no `after`)."""
19+
body = "".join(
20+
f'[moves.{n}]\nclip = "a"\n' + (f'after = "{a}"\n' if a else "") for n, a in after.items()
21+
)
22+
return manifest.loads(HEAD + body)
23+
24+
25+
def test_chain():
26+
m = moves(c="d", a="b", b="c", d=None, solo=None)
27+
assert sequence.chain(m, "a") == ["a", "b", "c", "d"]
28+
assert sequence.chain(m, "c") == ["c", "d"]
29+
assert sequence.chain(m, "solo") == ["solo"]
30+
assert sequence.chain(m, "nope") == []
31+
32+
33+
def test_chain_stops_at_the_first_repeat():
34+
m = moves(h="a", a="b", b="c", c="a")
35+
assert sequence.chain(m, "h") == ["h", "a", "b", "c"]
36+
assert sequence.loops(m, "h")
37+
assert not sequence.loops(moves(a="b", b=None), "a")
38+
39+
40+
def test_heads():
41+
m = moves(c="d", a="b", b="c", d=None, solo=None)
42+
assert sequence.heads(m) == ["a", "solo"], "in manifest order"
43+
assert [sequence.head_of(m, n) for n in "abcd"] == ["a"] * 4
44+
assert sequence.head_of(m, "solo") == "solo"
45+
46+
47+
def test_a_ring_is_headed_by_its_first_name():
48+
m = moves(z="y", y="x", x="z", lone=None)
49+
assert sequence.heads(m) == ["x", "lone"] and sequence.head_of(m, "y") == "x"
50+
assert sequence.chain(m, "x") == ["x", "z", "y"] and sequence.loops(m, "x")
51+
52+
53+
def test_a_join_belongs_to_the_first_head():
54+
m = moves(p="join", q="join", join="end", end=None)
55+
assert sequence.heads(m) == ["p", "q"]
56+
assert sequence.head_of(m, "join") == "p" and sequence.head_of(m, "q") == "q"
57+
assert sequence.chain(m, "q") == ["q", "join", "end"]
58+
59+
60+
def test_step_name():
61+
m = moves(topple="topple_2", topple_2="topple_4", topple_4=None)
62+
assert sequence.step_name(m, "topple") == "topple_3"
63+
assert sequence.step_name(m, "fresh") == "fresh_2"

0 commit comments

Comments
 (0)