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
blinknever closed once a second set that sorted beforeeyelidalso namedopen. 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.slotis the primary slot whose node carries the bone, orNonefor the entity container (bone:root).propertyis the RUNTIME name; values arerest + deviation * unit(times the rig’s pixel factor whenrig_scaled).
- an.characters.play.HEAD_BONE = 'head'
The bone whose primary slot’s nested slots are the FACE — what
face_overlay=falsesuppresses.
- exception an.characters.play.PlayResolutionError(animation: str, problems: list[str])[source]
A
playthat cannot resolve;problemslists 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>.attachmenttrack: 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;Nonewhen 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, orNone.
- 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 ofresolve_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
headand 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
animationofdescinto renderer-ready tracks, or raisePlayResolutionErrorlisting every problem found.art_exists(rel_path)answers whether a skin attachment’s art is on disk; passNonewhen 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.ceilrather thanround: withround, 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
slotnests under, orNonewhen 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()