an.characters.play

Resolve a play against a character descriptor — the renderer-free half (an#7).

A PlayAction names a descriptor animation. Its tracks speak the DESCRIPTOR’s vocabulary — bones, slots, attachment names, view-box units, degrees — while the renderer’s channels speak the SCENE’s: node paths, swap-set keys, scene pixels, radians. This module does everything on the descriptor side of that line and knows no renderer, so that an validate and the cutout compiler share ONE verdict on whether a play can resolve. The compiler used to decide alone, and validate passed plays that compile then refused — four measured cases: an unknown bone property, a bone with no slot of its own, a frame naming art that is not on disk, and a slot suppressed by face_overlay=false (an#7 review).

Every rule mirrors a rig-builder fact, and the builder imports the shared helpers rather than restating them, so the two cannot drift:

  • A bone track animates the node of the bone’s primary slot — the slot named like the bone (primary_slot_per_bone()); bone:root.* animates the entity container. A bone with no primary slot is a resolution error that says so: the old message (“no node of that name was built”) named the symptom and left the rule for the author to guess.

  • A slot track resolves to exactly one swap set: the set whose keys name every frame’s attachment. Resolving frame-by-frame used to split a track across two channels; the runtime applies a pose’s properties in name order, so blink never closed once a second set that sorted before eyelid also named open. Two candidates is an error naming both.

  • Art is consulted when the caller can consult it (art_exists): a frame whose attachment is declared but not on disk is reported as exactly that, not as “no set resolves it”.

>>> from an.characters.schema import CharacterDescriptor
>>> desc = CharacterDescriptor(name="maya")
>>> resolved = resolve_play(desc, "blink")
>>> [(t.slot, t.set_name) for t in resolved.tracks]
[('left_eye', 'eyelid'), ('right_eye', 'eyelid')]
>>> play_problems(desc, "walk")
["the descriptor declares no animation 'walk' (it has: ['blink', 'idle_breath'])"]
an.characters.play.BONE_TRACK_PROPERTIES: dict[str, tuple[str, float]] = {'rotation_deg': ('rotation', 0.017453292519943295), 'scale_x': ('scale_x', 1.0), 'scale_y': ('scale_y', 1.0), 'x': ('x', 1.0), 'y': ('y', 1.0)}

Descriptor bone-track properties → (runtime property, unit factor). The descriptor speaks degrees for rotation; the runtime is radians.

class an.characters.play.BoneTrack(track: AnimationTrack, slot: str | None, property: str, unit: float, rig_scaled: bool)[source]

A resolved bone:<name>.<prop> track.

slot is the primary slot whose node carries the bone, or None for the entity container (bone:root). property is the RUNTIME name; values are rest + deviation * unit (times the rig’s pixel factor when rig_scaled).

an.characters.play.HEAD_BONE = 'head'

The bone whose primary slot’s nested slots are the FACE — what face_overlay=false suppresses.

exception an.characters.play.PlayResolutionError(animation: str, problems: list[str])[source]

A play that cannot resolve; problems lists every reason found.

an.characters.play.RIG_SCALED_PROPERTIES: frozenset[str] = frozenset({'x', 'y'})

Bone-track properties whose values are view-box LENGTHS, so a renderer scales them by the rig’s view-box → scene-pixel factor. Scales and angles are dimensionless.

an.characters.play.ROOT_BONE = 'root'

a track on it animates the entity’s container node rather than any slot.

Type:

The bone that stands for the whole rig

class an.characters.play.ResolvedPlay(animation: 'IdleAnimation', tracks: 'tuple[ResolvedTrack, ...]')[source]
class an.characters.play.SlotTrack(track: AnimationTrack, slot: str, set_name: str, frames: tuple[tuple[float, str], ...])[source]

A resolved slot:<name>.attachment track: one set, frames as KEYS.

an.characters.play.active_skin(desc: CharacterDescriptor) Skin[source]

The skin the rig draws: default, else the first declared, else empty.

an.characters.play.art_exists_for(characters_store: Mapping, ref: str) Callable[[str], bool] | None[source]

rel_path -> is the art on disk, for a character in a filesystem store; None when the store has no root to look under (a dict, a fake) — a store that can answer nothing must assume presence, not absence, exactly as the rig builder’s part probe does.

an.characters.play.drawn_attachment(desc: CharacterDescriptor, skin: Skin, slot: Slot) tuple[str, Attachment] | None[source]

The (name, attachment) a slot draws by default, or None.

an.characters.play.play_problems(desc: CharacterDescriptor, animation: str, *, art_exists: Callable[[str], bool] | None = None) list[str][source]

Every reason play(<entity>, animation) cannot resolve — empty when it can. The validate-facing spelling of resolve_play().

an.characters.play.primary_slot_per_bone(desc: CharacterDescriptor) dict[str, str][source]

{bone name: the slot that IS that bone}, when one exists.

Used for node nesting, which is deliberately not the bone hierarchy. The rigs here are flat by design — arms are siblings of the torso, not children (CLAUDE.md pillar 4) — so bone parentage decides position only. A slot nests under the primary slot of its bone when it is not that slot itself, which is what puts eyes and mouth under head and leaves every limb a direct child of the entity.

>>> primary_slot_per_bone(CharacterDescriptor(name="m"))["head"]
'head'
an.characters.play.resolve_play(desc: CharacterDescriptor, animation: str, *, art_exists: Callable[[str], bool] | None = None) ResolvedPlay[source]

Resolve animation of desc into renderer-ready tracks, or raise PlayResolutionError listing every problem found.

art_exists(rel_path) answers whether a skin attachment’s art is on disk; pass None when the caller cannot know, and every declared attachment is assumed present (the rig builder’s own rule for a store without a filesystem root).

an.characters.play.sampled_deviations(track: AnimationTrack, duration: float, fps: int) list[tuple[float, float]][source]

(time, deviation) pairs for a sine bone track at the frame rate — an.characters.idle.evaluate_track()’s formula, sampled, so the descriptor’s own evaluator stays the one definition of a sine track.

an.characters.play.sine_sample_times(duration: float, fps: int) list[float][source]

Frame-rate sample times for a sine track, ALWAYS ending at duration.

ceil rather than round: with round, a 0.18 s track at 24 fps got samples up to 0.1667 s and then held that value to the clip end, so the cycle-closing sample (equal to the first) was never emitted and the clip wrapped with a jump (an#7 review).

>>> sine_sample_times(0.19, 24)[-2:]
[0.16666666666666666, 0.19]
>>> len(sine_sample_times(6.0, 24))
145
an.characters.play.slot_node_path(desc: CharacterDescriptor, slot_name: str) str[source]

The node path of a slot RELATIVE to its entity (head/left_eye, torso) — the rig builder’s nesting rule, stated once.

>>> slot_node_path(CharacterDescriptor(name="m"), "left_eye")
'head/left_eye'
>>> slot_node_path(CharacterDescriptor(name="m"), "torso")
'torso'
an.characters.play.slot_parent(desc: CharacterDescriptor, slot: Slot) str | None[source]

The slot slot nests under, or None when it is a direct child.

an.characters.play.suppressed_slots(desc: CharacterDescriptor) frozenset[str][source]

Slots the rig builder never builds: with the face baked into the head art (face_overlay=false), every slot nested under the HEAD BONE’s primary slot — keyed on the bone, not on a slot named “head”.

>>> sorted(suppressed_slots(CharacterDescriptor(name="m", face_overlay=False)))
['left_brow', 'left_eye', 'mouth', 'right_brow', 'right_eye']
>>> suppressed_slots(CharacterDescriptor(name="m"))
frozenset()