Source code for an.characters.idle

"""Idle animation factories: breath, blink, weight-shift.

Defaults are taken from production references (see research §6.3):

- 15 breaths/min ⇒ 4-second period.
- ±2 px torso vertical travel at a 1024-px-tall canonical character height.
- ±0.5° head rotation, phase-offset by 0.25 cycles from the chest.
- Blink closure ≈ 0.13 s; spontaneous blink gap 3-8 s (sampled per scene).

The functions return :class:`an.characters.IdleAnimation` instances ready
to drop into :attr:`CharacterDescriptor.animations`.

**Nothing here renders on its own.** Descriptor ``animations`` are seeded
by ``model_post_init`` and reach the screen ONLY through an authored ``play``
action (an#7: ``play("maya", "idle_breath")`` renders exactly what
:func:`breath_animation` returns — resolved by :mod:`an.characters.play`),
never automatically. The blink you see without one is compiled by
``an.adapters.cutout.compile._add_face_clips`` (an#88, via ``_blink_placements``) from a fixed
entity-name-phase schedule (period 4.0 s). ``random_blink_schedule`` has no
caller — it is the seeded alternative the compiled blink could adopt.

One documented number is not what a ``play`` shows: the "4-second period"
above is ``DEFAULT_BREATH_PERIOD_S``, but the seeded ``idle_breath`` also
carries the 6 s weight shift, and :func:`evaluate_track` divides by the
ANIMATION's duration — ``max(4, 6)`` — so every sine track in it runs a 6 s
cycle. A per-track period is the fix; until then, ``include_weight_shift=False``
gives the 4 s breath the numbers describe.

>>> a = breath_animation()
>>> a.name
'idle_breath'
>>> [t.target for t in a.tracks][:2]
['bone:torso.y', 'bone:head.rotation_deg']
>>> b = blink_animation()
>>> b.duration
0.18
"""

from __future__ import annotations

import math
import random

from an.characters.schema import AnimationTrack, IdleAnimation


# Canonical defaults — adjustable via keyword args.
DEFAULT_BREATH_PERIOD_S: float = 4.0
DEFAULT_BREATH_AMPLITUDE_PX: float = 2.0
DEFAULT_HEAD_TILT_DEG: float = 0.5
DEFAULT_WEIGHT_SHIFT_PERIOD_S: float = 6.0
DEFAULT_WEIGHT_SHIFT_AMPLITUDE_PX: float = 1.5
DEFAULT_BLINK_DURATION_S: float = 0.18
DEFAULT_BLINK_CLOSURE_S: float = 0.13


[docs] def breath_animation( *, period_s: float = DEFAULT_BREATH_PERIOD_S, amplitude_px: float = DEFAULT_BREATH_AMPLITUDE_PX, head_tilt_deg: float = DEFAULT_HEAD_TILT_DEG, include_weight_shift: bool = True, weight_shift_amplitude_px: float = DEFAULT_WEIGHT_SHIFT_AMPLITUDE_PX, weight_shift_period_s: float = DEFAULT_WEIGHT_SHIFT_PERIOD_S, name: str = "idle_breath", ) -> IdleAnimation: """Sine-wave breath on torso Y + head rotation; optional weight shift. The head tilt is phase-offset by 0.25 cycles to follow the chest with a natural lag. The optional weight shift is on a slower 6-second period to avoid a metronomic feel when both run at the same time. The animation's ``duration`` is the LCM-ish combined period: the longest sub-track period, so the overall loop closes cleanly. """ duration = period_s if include_weight_shift: duration = max(period_s, weight_shift_period_s) tracks = [ AnimationTrack( target="bone:torso.y", type="sine", amplitude=amplitude_px, phase=0.0, ), AnimationTrack( target="bone:head.rotation_deg", type="sine", amplitude=head_tilt_deg, phase=0.25, ), ] if include_weight_shift: tracks.append( AnimationTrack( target="bone:torso.x", type="sine", amplitude=weight_shift_amplitude_px, phase=0.5, ) ) return IdleAnimation(name=name, duration=duration, loop=True, tracks=tracks)
[docs] def evaluate_track(track: AnimationTrack, t: float, duration: float) -> object: """Evaluate a single animation track at time ``t``. For sine: ``amplitude * sin(2π * (t/duration + phase))``. For step: returns the value of the latest frame whose time ≤ ``t``. For linear: linear interpolation between bracketing frames. >>> tr = AnimationTrack(target='bone:torso.y', type='sine', amplitude=2.0) >>> round(evaluate_track(tr, 0.0, 4.0), 6) 0.0 >>> round(evaluate_track(tr, 1.0, 4.0), 6) 2.0 """ if track.type == "sine": u = (t / duration if duration > 0 else 0.0) + track.phase return float(track.amplitude) * math.sin(2.0 * math.pi * u) if track.type in ("step", "linear"): frames = list(track.frames) if not frames: return None if track.type == "step": current = frames[0][1] for ft, fv in frames: if t >= ft: current = fv else: break return current # linear for i in range(len(frames) - 1): a_t, a_v = frames[i] b_t, b_v = frames[i + 1] if a_t <= t <= b_t: span = b_t - a_t u = 0.0 if span <= 0 else (t - a_t) / span if isinstance(a_v, (int, float)) and isinstance(b_v, (int, float)): return a_v + (b_v - a_v) * u return a_v if u < 1.0 else b_v return frames[-1][1] raise ValueError(f"unsupported track type: {track.type!r}")