"""The cut-out genre's motion presets: moves that name a rig's parts or swap its views.
``nod``, ``point``, ``turn``, ``walk``, ``waddle`` and ``speech_pulse`` moved
here from :mod:`an.motion` (an#322): each names a part of a cut-out rig
(``<entity>/head``, the arm and leg nodes) or swaps a view, so they belong to
the genre, not to the core. The rig-free moves on the entity container
(``pop_in``, ``hop``, ``shake``, ``slide_in``/``slide_out``,
``squash_stretch``, ``crawl``) stay in :mod:`an.motion`, together with the
helpers every preset is built from (``rest``, landing ``set``s,
:func:`~an.motion.stage_poses`, :func:`~an.motion.as_leaves`). ``an.motion``
keeps live aliases at the old names (the 14-day shim rule in this repository's
``CLAUDE.md``).
Like the core's, each preset EXPANDS to ordinary ``tween`` (and, for ``turn``,
``set``) actions; nothing downstream learns a preset exists. :data:`PRESETS`
is the table a ``play`` resolves a name in (:mod:`cutan.characters.play`): the
core's presets and this module's, by name. Importing this module is what puts
the genre's presets into it.
>>> sorted(RIG_PRESETS), set(RIG_PRESETS) <= set(PRESETS)
(['nod', 'point', 'speech_pulse', 'turn', 'waddle', 'walk'], True)
>>> [(f.action.target, round(f.action.to_value, 2)) for f in _tweens(nod("charlie", count=1))]
[('charlie/head', 0.18), ('charlie/head', 0.0)]
"""
from __future__ import annotations
import math
from collections.abc import Callable, Mapping
from typing import Any
from an.base import EasingSpec, PathStr, Seconds
from an.ir.compose import delay, duration_of, flatten, parallel, sequence, set_, tween
from an.ir.schema import Action, Shot
from an.motion import (
DFLT_IN_EASING,
DFLT_OSCILLATION_EASING,
DFLT_OUT_EASING,
HOME_PRESETS,
OVERSHOOT,
PRESET_VERSIONS as CORE_PRESET_VERSIONS,
PRESETS as CORE_PRESETS,
Rest,
_alternating,
_positive,
_rest,
_settled,
_side_sign,
_through,
_tweens,
stage_poses,
)
# -----------------------------------------------------------------------------
# Defaults
# -----------------------------------------------------------------------------
DFLT_NOD_PART: str = "head"
DFLT_NOD_ANGLE: float = 0.18 # radians
DFLT_NOD_DURATION: Seconds = 0.5
DFLT_NOD_COUNT: int = 2
#: Negative is counter-clockwise on screen: an arm hanging from a shoulder
#: swings its hand to the VIEWER'S RIGHT — outward for the procedural rig's
#: ``right_arm``. A descriptor rig's ``arm_r`` hangs on the viewer's left, so
#: point it outward with a positive angle.
DFLT_POINT_ANGLE: float = -1.3 # radians
DFLT_POINT_RAISE: Seconds = 0.25
DFLT_POINT_HOLD: Seconds = 0.6
DFLT_WADDLE_STEPS: int = 4
DFLT_WADDLE_STEP_DURATION: Seconds = 0.3
DFLT_WADDLE_ANGLE: float = 0.1 # radians
DFLT_WADDLE_LIFT: float = 6.0 # scene px
DFLT_TURN_DURATION: Seconds = 0.3
DFLT_WALK_STEP_S: Seconds = 0.4
#: Steps a walk takes when neither ``steps`` nor ``distance`` sets them: a walk
#: to an ABSOLUTE ``to_x`` cannot count its steps from where it starts, because
#: a ``sequence`` must know a play's length before anything is placed.
DFLT_WALK_STEPS: int = 6
DFLT_WALK_STEP_LENGTH: float = 80.0 # scene px per step, for ``distance``
DFLT_WALK_STRIDE: float = 0.35 # radians a leg swings either side (side view)
DFLT_WALK_LIFT: float = 10.0 # scene px a stepping leg rises (front view)
DFLT_WALK_BOB: float = 6.0 # scene px the body travels per step (its whole bob)
DFLT_WALK_ARM_SWING: float = 0.3 # radians
DFLT_WALK_ROCK: float = (
0.06 # radians, the body's side-to-side sway (rock, waddle, hem)
)
#: Radians each hem half tilts about its hip (the two as mirror images: the
#: hem opens and closes) in a ``hem`` gait seen from the front (an#220) — what read on a carved robe figure, where lifting
#: one half by ``lift`` px barely showed.
DFLT_WALK_HEM_TILT: float = 0.24
#: Every channel of a walk — a limb's and the body's (cutan#15) — ends with a
#: constant tween this long at its end value instead of a settling ``set``: it
#: lands the value exactly (a held tween END is evaluated at its own end, which
#: float drift cannot put a grid step early), and unlike a ``set`` — whose hold
#: outranks a view's pose channel, and holds again when a longer authored tween
#: on the property ends — it lets later motion carry on.
WALK_LANDING_S: Seconds = 1e-3
#: Leg and arm node names a walk looks for, in order: the rig contract's
#: (descriptor rigs, ``an character new``), then a ``parts`` rig's (the
#: built-in placeholder draws arms and no legs: it glides).
WALK_LEG_NAMES: tuple[tuple[str, str], ...] = (
("leg_l", "leg_r"),
("left_leg", "right_leg"),
)
WALK_ARM_NAMES: tuple[tuple[str, str], ...] = (
("arm_l", "arm_r"),
("left_arm", "right_arm"),
)
#: Views whose legs SWING about the hip (the character seen from the side);
#: every other view (``front``, ``back``, none) steps them up and down.
WALK_SWING_VIEWS: frozenset[str] = frozenset({"side", "three_quarter"})
#: A speech pulse (ADR 0002's requirement-free speech, an#248): the part that
#: pulses on each syllable — ``head`` lifts like a hinged jaw (the cut-out
#: "head flap"); ``""`` pulses the whole body.
DFLT_PULSE_PART: str = "head"
#: How far the part stretches on a syllable, as a fraction of its rest
#: ``scale_y``. Masters who mime set 0 (study_the_masters §4, Reiniger).
DFLT_PULSE_STRENGTH: float = 0.06
DFLT_PULSE_ATTACK_S: Seconds = 0.06
DFLT_PULSE_RELEASE_S: Seconds = 0.1
#: How high a ``hop`` gait jumps on each step (scene px at drawn scale 1).
DFLT_WALK_HOP_HEIGHT: float = 18.0
#: How far a ``glide`` leans into its travel (radians).
DFLT_WALK_LEAN: float = 0.04
#: The share of a ``profile`` walk's ``bob`` the body SINKS below contact height
#: after a contact (the "down" pose); it rises the rest above it before the
#: next (the "up" pose), so ``bob`` is its whole travel between two contacts.
PROFILE_SINK_SHARE: float = 1 / 3
#: The gaits ``walk`` knows (``gait=``, an#220, an#224): persisted in character
#: descriptors and ``play`` args (:data:`cutan.characters.schema.GAITS`), so a
#: spelling is never renamed. Each is a locomotion method
#: (:mod:`cutan.characters.methods`); ``misc/docs/locomotion_gaits.md`` is the
#: classification behind them.
GAITS: tuple[str, ...] = (
"legs",
"hem",
"rock",
"profile",
"shuffle",
"waddle",
"hop",
"bounce",
"glide",
)
#: The gaits that move a leg pair — and need one (the ``limbs.legs`` capability).
LEGGED_GAITS: frozenset[str] = frozenset({"legs", "hem", "profile", "shuffle"})
#: The gait of a figure with no leg pair: the locomotion chain's last link.
DFLT_LEGLESS_GAIT: str = "glide"
#: Every walk parameter a gait reads, with the shared default.
WALK_PARAM_DEFAULTS: dict[str, float] = {
"step_s": DFLT_WALK_STEP_S,
"step_length": DFLT_WALK_STEP_LENGTH,
"stride": DFLT_WALK_STRIDE,
"lift": DFLT_WALK_LIFT,
"bob": DFLT_WALK_BOB,
"arm_swing": DFLT_WALK_ARM_SWING,
"rock": DFLT_WALK_ROCK,
"hem_tilt": DFLT_WALK_HEM_TILT,
"hop_height": DFLT_WALK_HOP_HEIGHT,
"lean": DFLT_WALK_LEAN,
}
#: The lengths that scale with the figure's drawn scale (the rest are angles,
#: times, or a leg's lift in the figure's own frame).
SCALED_GAIT_PARAMS: frozenset[str] = frozenset({"step_length", "bob", "hop_height"})
#: Each gait's departures from :data:`WALK_PARAM_DEFAULTS`. The first three are
#: the pre-an#224 gaits and keep the shared defaults exactly (no pixel moves).
GAIT_DEFAULTS: dict[str, dict[str, float]] = {
"legs": {},
"hem": {},
"rock": {},
# Williams' four poses: a longer stride, the body sinking after contact.
"profile": {"stride": 0.45, "arm_swing": 0.35},
# Feet barely leave the ground: short, quick steps, no bob to speak of.
"shuffle": {
"step_s": 0.3,
"step_length": 40.0,
"stride": 0.12,
"lift": 3.0,
"bob": 1.0,
"arm_swing": 0.1,
},
# From foot to foot: a big rock, a small lift.
"waddle": {"rock": 0.12, "lift": 5.0, "bob": 4.0, "arm_swing": 0.15},
"hop": {"arm_swing": 0.0},
# South Park: a pronounced bob while sliding, the feet flicking.
"bounce": {"bob": 8.0, "lift": 4.0, "stride": 0.1, "arm_swing": 0.15},
# A slide with a gentle bob and a lean into the move; no limb moves.
"glide": {"bob": 1.5, "arm_swing": 0.0},
}
#: The swap set a turn swaps: the factory's turnaround (an#197).
DFLT_TURN_SET: str = "view"
DFLT_TURN_TO: str = "back"
# -----------------------------------------------------------------------------
# Presets
# -----------------------------------------------------------------------------
[docs]
def nod(
target: PathStr,
*,
part: str = DFLT_NOD_PART,
angle: float = DFLT_NOD_ANGLE,
duration: Seconds = DFLT_NOD_DURATION,
count: int = DFLT_NOD_COUNT,
rest: Rest | None = None,
) -> Action:
"""Dip the head ``count`` times (a rotation of ``<target>/<part>``).
In a front-facing 2D cut-out a nod reads as a small head rotation about
its pivot; ``rest`` is the HEAD's rest, not the entity's.
>>> [(f.action.target, round(f.action.to_value, 2)) for f in _tweens(nod("charlie", count=1))]
[('charlie/head', 0.18), ('charlie/head', 0.0)]
"""
if count < 1:
raise ValueError(f"nod needs a count of at least 1, got {count}")
_positive(duration=duration)
path = f"{target}/{part}" if part else target
r0 = _rest(rest, "rotation")
values = [r0] + [r0 + angle, r0] * count
n = len(values) - 1
return _through(
path,
"rotation",
values,
durations=[duration / n] * n,
easings=_alternating(n, DFLT_OUT_EASING, DFLT_IN_EASING),
)
[docs]
def point(
target: PathStr,
*,
angle: float = DFLT_POINT_ANGLE,
raise_duration: Seconds = DFLT_POINT_RAISE,
hold: Seconds = DFLT_POINT_HOLD,
easing: EasingSpec = OVERSHOOT,
rest: Rest | None = None,
) -> Action:
"""Swing an arm out to point, hold it, and lower it again.
``target`` is the ARM node — ``"charlie/right_arm"`` on the procedural
rig, ``"maya/arm_r"`` on a descriptor rig (and there, since that arm hangs
on the viewer's left, pass a positive ``angle`` to point outward).
>>> [(f.start, f.action.to_value) for f in _tweens(point("charlie/right_arm", hold=0.5))]
[(0.0, -1.3), (0.75, 0.0)]
"""
_positive(raise_duration=raise_duration)
if hold < 0:
raise ValueError(f"hold must not be negative, got {hold!r}")
r0 = _rest(rest, "rotation")
return _settled(
target,
"rotation",
r0,
tween(
target,
"rotation",
to=r0 + angle,
duration=raise_duration,
from_=r0,
easing=easing,
),
delay(hold),
tween(
target,
"rotation",
to=r0,
duration=raise_duration,
from_=r0 + angle,
easing=DFLT_OSCILLATION_EASING,
),
)
[docs]
def waddle(
target: PathStr,
*,
steps: int = DFLT_WADDLE_STEPS,
step_duration: Seconds = DFLT_WADDLE_STEP_DURATION,
angle: float = DFLT_WADDLE_ANGLE,
lift: float = DFLT_WADDLE_LIFT,
travel: float = 0.0,
rest: Rest | None = None,
) -> Action:
"""A walk cycle for a rig with no legs to animate: rock and bob per step.
Each step rocks the body to alternate sides by ``angle`` and bobs it up by
``lift``; ``angle=0`` is a plain bob. ``travel`` (scene px, signed)
carries the body sideways over the whole walk — the one ``x`` move here,
so it is the one that needs ``rest`` in a multi-character shot.
>>> w = _tweens(waddle("charlie", steps=2, travel=100))
>>> sorted({f.action.property for f in w})
['rotation', 'x', 'y']
>>> max(f.end for f in w)
0.6
"""
if steps < 1:
raise ValueError(f"waddle needs at least one step, got {steps}")
_positive(step_duration=step_duration)
r0, y0 = _rest(rest, "rotation"), _rest(rest, "y")
half = step_duration / 2
rock = [r0]
bob = [y0]
for i in range(steps):
rock += [r0 + (angle if i % 2 == 0 else -angle), r0]
bob += [y0 - lift, y0]
n = 2 * steps
easings = _alternating(n, DFLT_OUT_EASING, DFLT_IN_EASING)
moves = [
_through(target, "rotation", rock, durations=[half] * n, easings=easings),
_through(target, "y", bob, durations=[half] * n, easings=easings),
]
if travel:
x0 = _rest(rest, "x")
moves.append(
_settled(
target,
"x",
x0 + travel,
tween(
target,
"x",
to=x0 + travel,
duration=steps * step_duration,
from_=x0,
easing="linear",
),
)
)
return parallel(*moves)
def _limb_pair(
names: tuple[str, str] | None,
candidates: tuple[tuple[str, str], ...],
parts: Mapping[str, Rest] | None,
) -> tuple[str, str] | None:
"""The two limb nodes a walk moves: ``names`` when given (``()``: none),
else the first candidate pair the rig builds (``parts``), else — with no
rig to look at — the rig contract's names."""
if names is not None:
if len(names) not in (0, 2):
raise ValueError(f"give two limb names (or none), got {names!r}")
return tuple(names) if names else None # type: ignore[return-value]
if parts is None:
return candidates[0]
# By SLOT name, the path's last segment: in `nesting: bones` (an#340) a leg
# is built under its hip (`torso/left_leg`), and the walk moves that node.
by_slot: dict[str, str] = {}
for path in parts:
by_slot.setdefault(path.rsplit("/", 1)[-1], path)
return next(
(
(by_slot[pair[0]], by_slot[pair[1]])
for pair in candidates
if all(n in by_slot for n in pair)
),
None,
)
[docs]
def gait_params(
gait: str, *, scale: float = 1.0, **given: float | None
) -> dict[str, float]:
"""The parameters a ``gait`` walks with: ``given`` where not ``None``, else its default.
A default length (:data:`SCALED_GAIT_PARAMS`: the step, the body's bob, a
hop) is stated for a figure at drawn scale 1 and multiplied by ``scale``
(the figure's drawn scale: its stage scale, an#224); an explicit value is
scene px as given. Angles and a leg's ``lift`` (in the figure's own frame,
already scaled with it) never scale.
>>> gait_params("legs")["step_length"], gait_params("legs", scale=2.0)["step_length"]
(80.0, 160.0)
>>> gait_params("shuffle", scale=2.0, step_length=50.0)["step_length"]
50.0
"""
if gait not in GAITS:
raise ValueError(f"gait must be one of {list(GAITS)}, got {gait!r}")
out: dict[str, float] = {}
for name, base in WALK_PARAM_DEFAULTS.items():
value = given.get(name)
if value is None:
value = GAIT_DEFAULTS[gait].get(name, base)
if name in SCALED_GAIT_PARAMS:
value = value * scale
out[name] = float(value)
return out
[docs]
def walk(
target: PathStr,
*,
to_x: float | None = None,
distance: float | None = None,
direction: str | None = None,
steps: int | None = None,
step_s: Seconds | None = None,
step_length: float | None = None,
stride: float | None = None,
lift: float | None = None,
bob: float | None = None,
arm_swing: float | None = None,
rock: float | None = None,
hem_tilt: float | None = None,
hop_height: float | None = None,
lean: float | None = None,
view: str | None = None,
gait: str | None = None,
legs: tuple[str, str] | None = None,
arms: tuple[str, str] | None = None,
parts: Mapping[str, Rest] | None = None,
rest: Rest | None = None,
scale: float | None = None,
) -> Action:
"""Walk: the body travels on ``x`` while the gait moves it — legs that
alternate, a hop, a bounce, a glide (an#214, an#224).
**Where to.** ``to_x`` (absolute scene x) or ``distance`` (signed px; with
``direction`` ``"left"``/``"right"`` its sign is the direction's), or
neither to walk on the spot. The walk starts where the entity IS —
played by name, ``rest`` is its pose at the play's start (an#212), so
``set x -800`` then ``walk to_x: -100`` walks in from off-screen.
**How many steps.** ``steps``, else ``|distance| / step_length``, else
:data:`DFLT_WALK_STEPS` — never counted from the start position, so the
walk's length (``steps × step_s``) is known before it is placed and a
``sequence`` waits for exactly that long.
**Gait** (``gait``, one of :data:`GAITS`; the locomotion methods of
:mod:`cutan.characters.methods`, classified in
``misc/docs/locomotion_gaits.md``). Each has its own defaults
(:data:`GAIT_DEFAULTS`); a parameter passed overrides them.
- ``legs``: in a view in :data:`WALK_SWING_VIEWS` (``side``,
``three_quarter``) each leg swings ``stride`` radians either side of its
rest about the hip, the two in opposition; in any other view the
stepping leg rises ``lift`` px and sets down; the body bobs ``bob`` once
per step and the arms swing ``arm_swing`` against the legs.
- ``profile``: the four-pose cycle of a profile walk (contact, down,
passing, up): the legs swing whatever the view, the body sinks after
each contact (a third of ``bob``) and rises before the next (two
thirds): ``bob`` is its whole travel between two contacts.
- ``shuffle``: ``legs`` with the feet barely leaving the ground — short,
quick steps, no bob to speak of.
- ``hem``: a robe whose leg slots are the two halves of its hem: facing
the camera the halves TILT by ``hem_tilt`` radians as mirror images (the
hem opens and closes) while the body sways by ``rock`` and bobs; in a
side or three-quarter view they swing like legs by ``stride`` and the
sway is dropped.
- ``waddle``: the body rocks ``rock`` from foot to foot and bobs; legs, if
any, lift in turn.
- ``hop``: the body jumps ``hop_height`` px on every step.
- ``bounce``: the body bobs ``bob`` on every step while it slides; legs,
if any, flick (a South Park walk).
- ``glide``: the body slides, leaning ``lean`` radians into the move and
bobbing ``bob`` gently; no limb moves.
- ``rock``: no leg moves; the body rocks ``rock`` and bobs.
A sway (``rock``) follows the figure's facing (the sign of ``rest``'s
``scale_x``, cutan#20): with legs, it leans onto the standing foot. An
amplitude set to 0 writes no channel at all (cutan#14), and every channel
— the body's included — lands with a :data:`WALK_LANDING_S` constant
tween rather than a settling ``set`` (cutan#15), so an authored move on
the same property that outlasts the walk carries on where it is.
Unset: ``legs`` when the rig builds a leg pair, else :data:`DFLT_LEGLESS_GAIT`
— the locomotion chain's default (played by name, the compiler resolves the
gait on the capability registry first: the author's, else the
descriptor's, else the chain). A legged gait on a rig with no leg pair
walks the legless default too.
**Size.** ``step_length``, ``bob`` and ``hop_height`` default to lengths
for a figure at drawn scale 1, multiplied by ``scale``, the figure's drawn
scale — its STAGE scale (``stage.scale``, the rig as built), which the
compiler fills in when the walk is played by name — so a character staged
at ``scale: 2`` strides twice as far (an#224). Pass them to set scene px
outright. Called from Python with no ``scale``, the ``scale_y`` of
``rest`` stands in; that is the pose at the play's start, which a
``pop_in`` running under the walk holds at 0 (cutan#13) — refused, naming
the scale — so the compiler never relies on it.
**Legs and arms.** ``legs``/``arms`` name the two limb nodes; by default
the first pair in :data:`WALK_LEG_NAMES` / :data:`WALK_ARM_NAMES` that the
rig builds (``parts``: the entity's built parts with their pose at the
start, filled in by the compiler). Played by name, ``view`` is the one in
force on the timeline at the play's start (else the descriptor's
``rest_view``, an#220). Limbs land on their rest with a
:data:`WALK_LANDING_S` constant tween, not a settling ``set``: a ``set``'s
hold would outrank the view's pose channel and keep a profile's splay
after a later turn to the front.
The walk does not turn the character: in a side view, face the way it
walks first (``turn``, ``direction``) — the classic walk-off is ``turn``
then ``walk``.
>>> w = walk("bob", distance=160, steps=2, step_s=0.5)
>>> sorted({(f.action.target, f.action.property) for f in _tweens(w)})
[('bob', 'x'), ('bob', 'y'), ('bob/arm_l', 'rotation'), ('bob/arm_r', 'rotation'), ('bob/leg_l', 'y'), ('bob/leg_r', 'y')]
>>> round(max(f.end for f in flatten(w)), 6), {f.action.to_value for f in _tweens(w) if f.action.property == "x"}
(1.0, {160.0})
>>> sorted({f.action.property for f in _tweens(walk("bob", distance=80, view="side"))
... if f.action.target == "bob/leg_l"})
['rotation']
>>> sorted({(f.action.target, f.action.property) for f in _tweens(walk("blob", steps=2, legs=(), arms=()))})
[('blob', 'y')]
>>> sorted({(f.action.target, f.action.property) for f in _tweens(walk("al", steps=2, gait="hem"))
... if f.action.target in ("al", "al/leg_l")})
[('al', 'rotation'), ('al', 'y'), ('al/leg_l', 'rotation')]
>>> [round(f.action.to_value, 1) for f in _tweens(walk("k", steps=2, gait="hop", legs=(), arms=()))]
[-18.0, 0.0, -18.0, 0.0, 0.0]
>>> {f.action.to_value for f in _tweens(walk("k", distance=160, rest={"scale_y": 2.0}, legs=()))
... if f.action.property == "x"}
{160.0}
>>> round(duration_of(walk("k", distance=160, scale=2.0, legs=())), 6), round(duration_of(walk("k", distance=160, legs=())), 6)
(0.4, 0.8)
"""
if gait is not None and gait not in GAITS:
raise ValueError(f"gait must be one of {list(GAITS)}, got {gait!r}")
if to_x is not None and distance is not None:
raise ValueError("give to_x (absolute) or distance (relative), not both")
if direction is not None:
sign = _side_sign(direction)
if distance is None:
raise ValueError("direction goes with distance (to_x is already a place)")
distance = sign * abs(distance)
leg_pair = _limb_pair(legs, WALK_LEG_NAMES, parts)
arm_pair = _limb_pair(arms, WALK_ARM_NAMES, parts)
if gait is None:
gait = "legs" if leg_pair is not None else DFLT_LEGLESS_GAIT
elif gait in LEGGED_GAITS and leg_pair is None:
gait = DFLT_LEGLESS_GAIT
if gait not in LEGGED_GAITS | {"waddle", "bounce"}:
leg_pair = None # this gait moves no leg
given_scale = scale is not None
if scale is None:
scale = abs(_rest(rest, "scale_y"))
if not (math.isfinite(scale) and scale > 0):
raise ValueError(
f"the figure's drawn scale is {scale!r}"
+ ("" if given_scale else " (its scale_y at the play's start)")
+ ": a walk needs a finite, positive scale (its stage scale)"
)
if step_length is not None:
_positive(step_length=step_length)
p = gait_params(
gait,
scale=abs(scale),
step_s=step_s,
step_length=step_length,
stride=stride,
lift=lift,
bob=bob,
arm_swing=arm_swing,
rock=rock,
hem_tilt=hem_tilt,
hop_height=hop_height,
lean=lean,
)
step_s = p["step_s"]
_positive(step_s=step_s)
if step_s <= 4 * WALK_LANDING_S:
raise ValueError(
f"step_s must be longer than {4 * WALK_LANDING_S}s, got {step_s!r}"
)
if steps is None:
steps = (
max(1, round(abs(distance) / p["step_length"]))
if distance is not None
else DFLT_WALK_STEPS
)
if steps < 1:
raise ValueError(f"walk needs at least one step, got {steps}")
x0, y0 = _rest(rest, "x"), _rest(rest, "y")
x1 = to_x if to_x is not None else x0 + (distance or 0.0)
half = step_s / 2
n = 2 * steps
up_down = _alternating(n, DFLT_OUT_EASING, DFLT_IN_EASING)
moves: list[Action] = []
def limb(name: str) -> str:
return f"{target}/{name}"
def part_rest(name: str) -> Rest | None:
return (parts or {}).get(name)
def unsettled(
path: str, prop: str, values: list[float], durations, easings
) -> Action:
# The landing takes its time out of the last segment: the walk's length
# is exactly `steps × step_s`, which `play_extent` promised. The body's
# channels land this way too (cutan#15): a settling `set` would hold
# again when a longer authored tween on the property ends, snapping the
# body back to where the walk left it.
durations = [*durations[:-1], durations[-1] - WALK_LANDING_S]
return sequence(
*(
tween(path, prop, to=b, duration=d, from_=a, easing=e)
for a, b, d, e in zip(values, values[1:], durations, easings)
),
tween(
path,
prop,
to=values[-1],
duration=WALK_LANDING_S,
from_=values[-1],
easing="linear",
),
)
def swing(path: str, r0: float, amount: float, phase: float) -> Action:
# Extremes at every contact (a step boundary), the rest at both ends. A
# one-step walk has no interior contact: its one extreme is mid-step
# (cutan#16: `[r0, r0]` moved nothing while the body travelled).
if steps == 1:
values = [r0, r0 + phase * amount, r0]
durations = [half, half]
else:
values = (
[r0]
+ [r0 + phase * amount * (-1) ** k for k in range(steps - 1)]
+ [r0]
)
durations = [step_s] * steps
return unsettled(
path,
"rotation",
values,
durations,
[DFLT_OSCILLATION_EASING] * len(durations),
)
def step_lift(path: str, ly: float, amount: float, phase: float) -> Action:
# This leg steps on every other step; the other one stands.
values, durations, easings = [ly], [], []
for i in range(steps):
if (i % 2 == 0) == (phase > 0):
values += [ly - amount, ly]
durations += [half, half]
easings += [DFLT_OUT_EASING, DFLT_IN_EASING]
else:
values += [ly]
durations += [step_s]
easings += ["linear"]
return unsettled(path, "y", values, durations, easings)
def body_bob(height: float) -> Action:
# Highest mid-step (the passing pose), at ``y0`` at every contact. A
# one-step walk whose legs SWING has its one contact mid-step, so its
# bob runs twice in the step, at twice the rate — as its swing does
# (review of cutan#16, F5).
if steps == 1 and swinging and leg_pair is not None:
values = [y0, y0 - height, y0, y0 - height, y0]
return unsettled(
target,
"y",
values,
[half / 2] * 4,
_alternating(4, DFLT_OUT_EASING, DFLT_IN_EASING),
)
values = [y0]
for _ in range(steps):
values += [y0 - height, y0]
return unsettled(target, "y", values, [half] * n, up_down)
def body_cycle(height: float) -> Action:
# Williams' four poses between two CONTACTS (a step boundary, the legs
# at their widest): contact → down (lowest, a quarter step in) →
# passing (the legs cross, mid-step) → up (highest, three quarters in)
# → contact. `height` is the body's whole travel between two contacts:
# it sinks a third of it and rises two thirds. The walk starts and ends
# STANDING (the legs together, a passing pose), so the first step gets
# the half of the cycle before a contact (up) and the last the half
# after one (down); a one-step walk's contact is mid-step, its up and
# down a quarter step either side of it, at twice the interior rate —
# as its one-step swing is (cutan#18, cutan#16).
q = step_s / 4
up = y0 - (1 - PROFILE_SINK_SHARE) * height
down = y0 + PROFILE_SINK_SHARE * height
values: list[float] = [y0]
durations: list[Seconds] = []
easings: list[EasingSpec] = []
if steps == 1:
values += [up, y0, down, y0]
durations += [3 * q / 2, q / 2, q / 2, 3 * q / 2]
easings += [DFLT_OUT_EASING, DFLT_IN_EASING] * 2
else:
for i in range(steps):
if i == 0:
values += [up, y0]
durations += [3 * q, q]
easings += [DFLT_OUT_EASING, DFLT_IN_EASING]
elif i == steps - 1:
values += [down, y0]
durations += [q, 3 * q]
easings += [DFLT_OUT_EASING, DFLT_IN_EASING]
else:
values += [down, up, y0]
durations += [q, 2 * q, q]
easings += [
DFLT_OUT_EASING,
DFLT_OSCILLATION_EASING,
DFLT_IN_EASING,
]
return unsettled(target, "y", values, durations, easings)
def body_rock(amount: float) -> Action:
# Onto the standing foot: a positive rotation (clockwise on screen)
# leans the body over the viewer's right while `leg_l` — on the
# viewer's left on every rig — lifts on the even steps. The body's
# rotation is applied AFTER its mirror, the legs move WITH it, so a
# figure facing left (rest `scale_x` < 0) leans the other way (cutan#20).
r0 = _rest(rest, "rotation")
facing = -1.0 if _rest(rest, "scale_x") < 0 else 1.0
rock_values = [r0] + [
v for i in range(steps) for v in (r0 + facing * amount * (-1) ** i, r0)
]
return unsettled(target, "rotation", rock_values, [half] * n, up_down)
def body_lean(amount: float) -> Action:
r0 = _rest(rest, "rotation")
tilt = r0 + (amount if x1 > x0 else -amount)
ramp = min(step_s, steps * step_s / 4)
hold = steps * step_s - 2 * ramp
values, durations, easings = [r0, tilt], [ramp], [DFLT_OUT_EASING]
if hold > 0:
values.append(tilt)
durations.append(hold)
easings.append("linear")
values.append(r0)
durations.append(ramp)
easings.append(DFLT_IN_EASING)
return unsettled(target, "rotation", values, durations, easings)
swinging = view in WALK_SWING_VIEWS or gait == "profile"
hem = gait == "hem" and not swinging
# A zero amplitude writes NO channel (cutan#14): a constant tween would
# still outrank an authored move on the property for the walk's length.
# The travel.
if x1 != x0:
moves.append(unsettled(target, "x", [x0, x1], [steps * step_s], ["linear"]))
# The body's rise and fall.
rise = p["hop_height"] if gait == "hop" else p["bob"]
if rise:
moves.append(body_cycle(rise) if gait == "profile" else body_bob(rise))
# The legs.
if leg_pair is not None:
for phase, name in zip((1.0, -1.0), leg_pair):
pose = part_rest(name)
if gait != "waddle" and (swinging or hem):
amount = p["hem_tilt"] if hem else p["stride"]
if amount:
moves.append(
swing(limb(name), _rest(pose, "rotation"), amount, phase)
)
elif p["lift"]:
moves.append(step_lift(limb(name), _rest(pose, "y"), p["lift"], phase))
# The body's sway.
if (hem or gait in ("rock", "waddle")) and p["rock"]:
moves.append(body_rock(p["rock"]))
elif gait == "glide" and p["lean"] and x1 != x0:
moves.append(body_lean(p["lean"]))
# The arms, against the legs: the leg_l phase is +1, so arm_l's is -1.
if arm_pair is not None and p["arm_swing"]:
for phase, name in zip((-1.0, 1.0), arm_pair):
moves.append(
swing(
limb(name),
_rest(part_rest(name), "rotation"),
p["arm_swing"],
phase,
)
)
if not moves:
# Nothing to move (on the spot, every amplitude 0): the walk still
# takes its time, as `play_extent` promised a `sequence`.
moves.append(delay(steps * step_s))
return parallel(*moves)
_FACINGS: tuple[str, ...] = ("right", "left")
def _facing_sign(name: str, value: str) -> float:
if value not in _FACINGS:
raise ValueError(f"{name} must be one of {list(_FACINGS)}, got {value!r}")
return -1.0 if value == "left" else 1.0
[docs]
def turn(
target: PathStr,
*,
to: str = DFLT_TURN_TO,
direction: str = "right",
from_direction: str | None = None,
duration: Seconds = DFLT_TURN_DURATION,
view_set: str = DFLT_TURN_SET,
rest: Rest | None = None,
step_hz: float | None = None,
) -> Action:
"""Turn a character to the view ``to`` — the classic cut-out turn (an#197).
``scale_x`` squashes to 0 (the character edge-on), the view swaps at that
midpoint, and ``scale_x`` opens again to the rest scale — mirrored when
``direction="left"``: a ``side`` view is drawn facing the viewer's right,
so ``direction`` is which way the character FACES after the turn.
``from_direction`` is which way it faced before — by default the sign of
the rest ``scale_x`` (a character staged mirrored faces left). Called from
Python the preset cannot see an EARLIER turn, so turning back from a
left-facing profile is ``turn(to="front", from_direction="left")``; PLAYED
by name (``{kind: play, animation: turn}``) the compiler fills it in from
the timeline before it (:func:`cutan.characters.play.resolve_turns`, an#203).
``to`` is a key of the character's ``view`` set — ``front``, ``back``,
``side`` or ``three_quarter`` on a factory character
(``an character new --offline``); the swap is a ``set`` on the ENTITY,
which the compiler fans out to the head and torso and which poses the face
(the back hides it, the profile keeps one eye). ``rest`` is the entity's:
its ``scale_x`` magnitude is where the turn opens to.
``step_hz`` is the shot's stepped timing (the compiler fills it in when a
turn is played by name). A turn whose halves are shorter than one step
cannot show its squash: its stepped frames would sample the edge-on scale,
so the character vanishes for a frame (an#273, South Park on twos). Such a
turn is the style's HARD SWAP instead: at its midpoint the view changes and
``scale_x`` takes the new facing, with no squash.
>>> def lands(a): # a tween's end value, a set's value
... return a.to_value if a.kind == "tween" else a.value
>>> [(round(f.start, 2), f.action.property, lands(f.action))
... for f in flatten(turn("ned", to="side", direction="left"))]
[(0.0, 'scale_x', 0.0), (0.15, 'view', 'side'), (0.15, 'scale_x', -1.0), (0.3, 'scale_x', -1.0)]
>>> [(round(f.start, 3), f.action.property, lands(f.action)) # on twos at 24 fps
... for f in flatten(turn("ned", to="side", duration=0.04, step_hz=12))]
[(0.02, 'view', 'side'), (0.02, 'scale_x', 1.0)]
"""
_positive(duration=duration)
if not isinstance(to, str) or not to:
raise ValueError(
f"to must name a view (a key of the {view_set!r} set), got {to!r}"
)
rest_sx = _rest(rest, "scale_x")
s0 = abs(rest_sx)
if from_direction is None:
from_direction = "left" if rest_sx < 0 else "right"
before = _facing_sign("from_direction", from_direction) * s0
after = _facing_sign("direction", direction) * s0
half = duration / 2
if step_hz is not None and step_hz > 0 and half < 1.0 / float(step_hz):
return sequence(
delay(half),
parallel(set_(target, view_set, to), set_(target, "scale_x", after)),
)
return sequence(
tween(
target,
"scale_x",
to=0.0,
duration=half,
from_=before,
easing=DFLT_IN_EASING,
),
parallel(
set_(target, view_set, to),
_settled(
target,
"scale_x",
after,
tween(
target,
"scale_x",
to=after,
duration=half,
from_=0.0,
easing=DFLT_OUT_EASING,
),
),
),
)
[docs]
def face_toward(
shot: Shot,
who: str,
other: str,
*,
view: str = "side",
from_direction: str | None = None,
duration: Seconds = DFLT_TURN_DURATION,
mall: Mapping[str, Mapping] | None = None,
) -> Action:
""":func:`turn` ``who`` to ``view``, facing ``other`` — the direction read
off the stage, so a profile looks at the other character wherever the
layout put them.
(These examples build character entities, the cut-out genre's; they are not run here.)
>>> from an.ir.schema import AssetRef # doctest: +SKIP
>>> two = Shot(id="s", entities=[ # doctest: +SKIP
... AssetRef(kind="character", id=n, store="characters", ref=n) for n in ("a", "b")])
>>> [f.action.to_value for f in _tweens(face_toward(two, "b", "a"))] # doctest: +SKIP
[0.0, -1.0]
"""
poses = stage_poses(shot, mall=mall)
for name in (who, other):
if name not in poses:
raise KeyError(f"no entity {name!r} in the shot; built: {sorted(poses)}")
direction = "right" if poses[other]["x"] >= poses[who]["x"] else "left"
return turn(
who,
to=view,
direction=direction,
from_direction=from_direction,
duration=duration,
rest=poses[who],
)
[docs]
def speech_pulse(
target: PathStr,
*,
beats: tuple[Seconds, ...] = (0.0,),
strength: float = DFLT_PULSE_STRENGTH,
part: str = DFLT_PULSE_PART,
attack: Seconds = DFLT_PULSE_ATTACK_S,
release: Seconds = DFLT_PULSE_RELEASE_S,
rest: Rest | None = None,
) -> Action:
"""Pulse a part on each syllable: speech carried without a mouth (an#248).
The requirement-free last link of the speech aspect (ADR 0002 decision 5,
method ``speech.pose_only``): a character whose face is baked into its art
(``face_overlay: false``) still reads as speaking. On each time in ``beats``
(seconds from the start) ``<target>/<part>`` stretches its ``scale_y`` by
``strength`` over ``attack`` and settles over ``release``; ``part=""``
pulses the whole body. A beat that would start before the previous pulse
settles is skipped, so the pulse never stacks. ``strength=0`` is a mime.
``rest`` is the PART's rest, as for :func:`nod`; it lands with a constant
tween rather than a settling ``set``, so played once per syllable (as the
speech aspect does) each pulse rides whatever the head is doing.
>>> [(round(f.start, 2), f.action.to_value) for f in _tweens(speech_pulse("al", beats=(0.0, 0.3)))]
[(0.0, 1.06), (0.06, 1.0), (0.3, 1.06), (0.36, 1.0), (0.46, 1.0)]
"""
_positive(attack=attack, release=release)
if not beats:
raise ValueError("speech_pulse needs at least one beat")
path = f"{target}/{part}" if part else target
s0 = _rest(rest, "scale_y")
peak = s0 * (1.0 + strength)
moves: list[Action] = []
t = 0.0
for beat in sorted(float(b) for b in beats):
if beat < t:
continue
if beat > t:
moves.append(delay(beat - t))
moves += [
tween(
path,
"scale_y",
to=peak,
duration=attack,
from_=s0,
easing=DFLT_OUT_EASING,
),
tween(
path,
"scale_y",
to=s0,
duration=release,
from_=peak,
easing=DFLT_IN_EASING,
),
]
t = beat + attack + release
# Lands with a constant tween, not a settling `set` (as a walk's limbs do):
# a `set` would hold `s0` and freeze an authored head-scale tween running
# under the pulse; a tween ends, and the authored one carries on.
moves.append(
tween(
path, "scale_y", to=s0, duration=WALK_LANDING_S, from_=s0, easing="linear"
)
)
return sequence(*moves)
#: The genre's presets, by name.
RIG_PRESETS: dict[str, Callable[..., Action]] = {
f.__name__: f for f in (nod, point, waddle, turn, walk, speech_pulse)
}
#: Each genre preset's vocabulary version (ADR 0003 decision 2): bump one in the
#: SAME change that makes it expand differently for the same args, so every shot
#: that plays it re-renders visibly instead of silently.
RIG_PRESET_VERSIONS: dict[str, str] = {
**{name: "1" for name in RIG_PRESETS},
# 2 (an#224): the gaits, a legless figure glides instead of rocking, and
# the default lengths scale with the figure's drawn scale.
# 3 (cutan#13): the drawn scale is the stage scale, not the pose at the
# play's start (a figure resized by an authored move strides as drawn).
# turn 2 (an#273): a turn shorter than a step is a hard swap, not a squash
# that vanishes on the stepped frame between.
"turn": "2",
# walk 4 (cutan#14-#16, #18, #20): a one-step walk swings its limbs; the profile
# cycle is phased to the contacts and `bob` is its whole travel; the sway
# mirrors with the figure; a zero amplitude writes no channel; the body
# lands with the landing tween, not a settling set.
"walk": "4",
}
#: Every preset a ``play`` can name — the core's and the genre's — the one table
#: the skill, the demos, the vocabulary and the ``play`` fallback
#: (:func:`cutan.characters.play.play_source`, an#166) read. A genre preset wins a
#: name the core also has.
PRESETS: dict[str, Callable[..., Action]] = {**CORE_PRESETS, **RIG_PRESETS}
#: :data:`PRESETS`' vocabulary versions.
PRESET_VERSIONS: dict[str, str] = {**CORE_PRESET_VERSIONS, **RIG_PRESET_VERSIONS}
__all__ = [
"CORE_PRESETS",
"DFLT_TURN_SET",
"DFLT_LEGLESS_GAIT",
"GAITS",
"GAIT_DEFAULTS",
"HOME_PRESETS",
"LEGGED_GAITS",
"PRESETS",
"PRESET_VERSIONS",
"RIG_PRESETS",
"WALK_ARM_NAMES",
"WALK_LANDING_S",
"WALK_LEG_NAMES",
"face_toward",
"gait_params",
"nod",
"point",
"speech_pulse",
"turn",
"waddle",
"walk",
]