an.expression.binding

How the axes reach a character: the binding and the mouth-set resolver (an#98).

Renderer-free, like an.characters.playan validate, an character validate and the cutout face solver all call the same functions here, so the three cannot disagree about whether an expression can resolve on a character.

  • A channel binding maps a numeric axis onto (slot, property, gain): the solver emits rest + Σ axis·gain on that slot’s node. The brow angle’s per-side sign lives in the gain — the two sides rotate in opposite screen directions for one axis sign.

  • A set binding maps a lid axis onto a slot’s swap set (eyelid); the solver reads a key off the ladder in an.expression.axes.

  • resolve_mouth_set is the ONE chain for “which mouth set does this line use”: viseme@<form> if declared and it covers the keys the line uses, else viseme with a warning naming the missing keys, else an ExpressionResolutionError (a speaking overlay face with no neutral mouth set).

>>> from an.characters.schema import CharacterDescriptor
>>> desc = CharacterDescriptor(name="m")
>>> sorted({b.axis for b in default_binding(desc)})
['brow_angle_l', 'brow_angle_r', 'brow_height_l', 'brow_height_r', 'lid_open_l', 'lid_open_r']
>>> resolve_mouth_set(desc, None, keys_used=["A", "X"])
'viseme'
>>> import warnings
>>> with warnings.catch_warnings(record=True) as w:
...     warnings.simplefilter("always")
...     resolve_mouth_set(desc, "happy", keys_used=["A", "X"])
'viseme'
>>> "viseme@happy" in str(w[0].message)
True
an.expression.binding.BROW_ANGLE_TRAVEL: float = 0.35

Brow rotation per unit of brow_angle_*, radians. Art direction.

an.expression.binding.BROW_HEIGHT_TRAVEL: float = 10.0

Brow travel per unit of brow_height_*, in the rig’s view-box units (scaled to scene pixels by the entity’s rig factor). Art direction; about the synthesized eye’s half-height.

class an.expression.binding.ChannelBinding(axis: str, slot: str, property: str, gain: float, rig_scaled: bool = False)[source]

A numeric axis driving one transform property of one slot’s node.

rig_scaled: bool = False

Whether the gain is a view-box length (scaled by the rig factor).

exception an.expression.binding.ExpressionResolutionError(who: str, problems: Iterable[str])[source]

An expression that cannot resolve on a character; problems says why.

an.expression.binding.GAZE_TRAVEL: float = 6.0

Pupil travel per unit of gaze_*, in view-box units — the default when a descriptor declares no travel of its own (add_gaze writes gaze_travel).

an.expression.binding.LID_SQUASH_GAIN: float = 0.5

On a rig whose eye squashes instead of swapping art, a lid offset scales the eye by this much per unit.

class an.expression.binding.SetBinding(axis: str, slot: str, set_family: str = 'eyelid')[source]

A lid axis driving one slot’s swap set through the ladder.

an.expression.binding.binding_for(desc: CharacterDescriptor) list[ChannelBinding | SetBinding][source]

The descriptor’s declared expression_binding (additive field), else the default.

A declared binding is a list of dicts in the two dataclasses’ shapes ({"axis", "slot", "property", "gain"[, "rig_scaled"]} or {"axis", "slot", "set_family"}). An unknown axis in it is an error.

an.expression.binding.declared_mouth_variants(desc: CharacterDescriptor) dict[str, str][source]

{form: set name} for every viseme@<form> set the descriptor declares.

>>> declared_mouth_variants(CharacterDescriptor(name="m"))
{}
an.expression.binding.default_binding(desc: CharacterDescriptor) list[ChannelBinding | SetBinding][source]

The binding the default rig implies, from the slots it actually has.

The brow angle’s screen sign: PixiJS rotation is clockwise-positive with y down, so on the LEFT brow (screen-left) a clockwise turn drops the inner end while on the RIGHT brow it lifts it — the axis says “+ = inner end up”, hence -travel on the left and +travel on the right.

an.expression.binding.expression_problems(desc: CharacterDescriptor | None, *, preset: str | None, axes: Collection[str] = (), who: str) list[str][source]

Every reason an expression cannot resolve on desc — empty means it can.

Shared by an validate (each becomes an error Finding) and the compiler (which raises ExpressionResolutionError with the same list).

>>> expression_problems(CharacterDescriptor(name="m"), preset="joyful", who="m")
["unknown expression preset 'joyful' (known: neutral, happy, sad, angry, surprised, afraid, disgusted, thinking, skeptical, amused)"]
>>> expression_problems(CharacterDescriptor(name="m", face_overlay=False), preset="happy", who="m")[0].startswith("'m' has its face baked")
True
an.expression.binding.preset_axes(preset: str | None, *, axes: Mapping[str, float] | None = None, intensity: float = 1.0) dict[str, float][source]

The numeric axis offsets an expression asks for: the preset’s, with axes layered over them, scaled by intensity and clamped. Only non-zero offsets are returned, so a neutral expression is {}.

An unknown preset or axis is a ValueError — validate reports it as an error, the compiler refuses it.

an.expression.binding.resolve_mouth_set(desc: CharacterDescriptor, preset: str | None, *, keys_used: Collection[str], who: str | None = None) str[source]

Which mouth set a line under preset uses — the one chain, shared.

viseme@<form> if the preset prefers a form the descriptor declares and that set covers keys_used; else viseme with a warning naming what was missing; else ExpressionResolutionError. A descriptor with no viseme set and no covering variant cannot speak at all — that is the error, not a fallback.

an.expression.binding.touches_gaze(axes: Collection[str]) bool[source]

Whether any of axes is a gaze axis (a no-op on a rig without pupils).

>>> touches_gaze(["gaze_x"]), touches_gaze(["brow_angle_l"])
(True, False)
an.expression.binding.variant_set_name(form: str) str[source]

The swap-set name for a mouth form (@ is a legal set-name character).

>>> variant_set_name("happy")
'viseme@happy'