cutan.characters.schema¶
Character descriptor schema (Spine-shaped, Pydantic v2).
A character on disk lives at:
characters/<name>/
<name>.svg # optional canonical layered SVG
character.json # CharacterDescriptor as JSON
parts/
head.svg
torso.svg
arm_l.svg, arm_r.svg
leg_l.svg, leg_r.svg
eye_l_open.svg, eye_l_closed.svg, eye_r_open.svg, eye_r_closed.svg
brow_l.svg, brow_r.svg
mouth/mouth_a.svg … mouth_h.svg, mouth_x.svg
The descriptor borrows Spine’s separation of concerns:
bones — where things attach. Local transforms relative to a parent.
slots — what is drawn at each bone (one attachment active at a time).
skins — for each slot, the named attachments and their SVG paths.
asset_sets —
{channel: {key: attachment_name}}. What a swap key selects, layered overskins, which says what art exists. Thevisemechannel is Rhubarb’s shape letter → an attachment on themouthslot. (Replacedviseme_mapin schema 0.2.0.)animations — built-in idle loops (breath, blink) keyed by name.
A slot’s name is its scene-graph node name, which is why the face slots read
left_eye rather than eye_l; attachment names are a separate, per-slot
namespace — file-derived for single-attachment slots, and shared key-like names
(open/closed on both eye slots, 0.3.0) where one swap set must drive
several slots.
>>> char = CharacterDescriptor(name="maya")
>>> char.asset_sets["viseme"]["A"]
'mouth_a'
>>> char.asset_sets["viseme"]["X"]
'mouth_x'
>>> char.view_box
(0, 0, 1024, 1024)
>>> sorted(char.skins["default"].slots.keys())[:3]
['arm_l', 'arm_r', 'head']
Module Attributes
the bones' rest pose ( |
|
The descriptor is a schema-versioned document in its own right, with its own version field. |
|
Rhubarb mouth shapes. |
|
Default Rhubarb-letter → mouth-attachment-name mapping. |
|
The swap channel lip-sync drives. |
|
The swap channel blinks drive. |
|
Default eyelid-state → attachment-name mapping, shared by both eye slots. |
|
one KEY per drawn view, projected onto the slots whose art changes with the view (the factory draws the head and the torso), each slot carrying attachments NAMED after the keys. |
|
The views the factory draws, in turnaround order. |
|
its default attachments ARE this view. |
|
What joins a swap set's name to the view a variant of it serves: |
|
Required body parts. |
|
The transform properties a |
|
Hip to ground in the default rig, in view_box units. |
|
the head hangs above the neck, its lower ~fifth overlapping the collar. |
|
The head height the default face layout is drawn for, in view_box units — the pre-Wave-4 compiler's 96 px head at k = 345/1024. |
|
Where each face part sits relative to the |
Functions
|
Re-place a bone rig onto an illustrator's own joint coordinates. |
|
|
|
The name of |
|
|
Classes
|
A single channel inside an idle animation. |
|
The on-disk character schema. |
|
A named idle loop (e.g., breath, blink). |
|
How one slot is posed while a swap key is shown ( |
- class cutan.characters.schema.AnimationTrack(*, target, type='sine', amplitude=0.0, phase=0.0, frames=<factory>, **extra_data)[source]¶
Bases:
RigModelA single channel inside an idle animation.
The
targetis a path-string per the architecture pillar:bone:<name>.<prop>for bone transforms (x,y,rotation_deg,scale_x,scale_y).slot:<name>.attachmentfor swap animations (eyes blinking, mouth visemes).
For
type="sine":amplitudeis the peak deviation;phaseis in cycles (0..1). Fortype="step"/type="linear":framesis a list of[time_s, value]pairs evaluated in order.>>> t = AnimationTrack(target="bone:torso.y", type="sine", amplitude=2.0) >>> t.amplitude 2.0
- cutan.characters.schema.CHARACTER_DOCUMENT_KIND: DocumentKind = DocumentKind(name='CharacterDescriptor', version_field='schema_version', current_version='0.4.0')¶
The descriptor is a schema-versioned document in its own right, with its own version field. Registered here rather than in
an.ir.migratebecause this module already imports froman.ir.assets— registering from the other direction would close an import cycle, and because the package that owns a schema is the one that knows its version field.
- cutan.characters.schema.CHARACTER_SCHEMA_VERSION = '0.4.0'¶
the bones’ rest pose (
rotation_deg,scale_*) poses the built parts; the migration protects a 0.3.0 rig that carried one.- Type:
0.4.0 (an#339)
- class cutan.characters.schema.CharacterDescriptor(*, origin=None, rest_rotation=None, nesting=None, schema_version='0.4.0', kind='CharacterDescriptor', name, display_name=None, view_box=(0, 0, 1024, 1024), voice_ref=None, source_svg=None, bones=<factory>, slots=<factory>, skins=<factory>, asset_sets=<factory>, animations=<factory>, source=None, face_overlay=True, expression_binding=None, gaze_travel=None, colour_roles=<factory>, metadata=<factory>, swap_poses=<factory>, rest_view=None, gait=None, speech=None, occluded=<factory>, **extra_data)[source]¶
Bases:
RigDocumentThe on-disk character schema. Saved as
character.json.The descriptor is the SSOT for a character’s identity, body part inventory, pivot geometry, viseme map, and built-in idle behaviors. Binary art lives as SVG sidecars referenced by
Attachment.path(relative to the descriptor file).>>> c = CharacterDescriptor(name="maya") >>> c.schema_version == CHARACTER_SCHEMA_VERSION True >>> # all 9 mouths are wired into the default skin >>> sorted(c.skins["default"].slots["mouth"].keys()) == [ ... 'mouth_a', 'mouth_b', 'mouth_c', 'mouth_d', ... 'mouth_e', 'mouth_f', 'mouth_g', 'mouth_h', 'mouth_x', ... ] True >>> # round-trip >>> raw = c.model_dump_json() >>> back = CharacterDescriptor.model_validate_json(raw) >>> back.name == c.name True- asset_sets: dict[str, dict[str, str]]¶
{channel: {key: attachment_name}}— what a swap key SELECTS, layered overskins, which is the SSOT for what art EXISTS. The indirection is deliberate: a channel key is not an attachment name. Today’s viseme map happens to be one-to-one (9 keys, 9 attachments), but real mouth charts are many-to-one — ~10 drawings carrying ~40 phonemes — and collapsing the two namespaces makes the first shared drawing a schema change instead of a data change. Replacesviseme_map(schema 0.2.0).
- colour_roles: dict[str, dict[str, str]]¶
Which colour literal in which part plays which
StylePackrole —{part path: {"#rrggbb": role}}, e.g.{"parts/torso.svg": {"#a83249": "clothing"}}. Written by the factory, which KNOWS what it drew as skin or clothing; read by the compiler, which rewrites the tagged literals under a pack (palette swapping — seecutan.characters.colour_roles). Empty = untagged art (hand-drawn, DiceBear): a pack cannot reach it and the compiler says so, because the alternative is inferring a role from a pixel (an#99’s wrong-tone lid). Additive: no schema bump, and a descriptor without it reads back as untagged. Keys are normalised to lowercase#rrggbb; a role must be one a pack can set (an.styles.REACHABLE_ROLES).
- expression_binding: Optional[list[dict[str, Any]]]¶
How expression axes reach this rig (an#98), as a list of binding dicts —
{"axis", "slot", "property", "gain"[, "rig_scaled"]}for a transform channel,{"axis", "slot", "set_family"}for a swap set.Nonemeans the default binding derived from the slots the rig has (cutan.expression.binding.default_binding()). Additive: no schema bump, and a pre-Wave-6 descriptor reads back unchanged.
- face_overlay: bool¶
Whether this character’s face is drawn as separate overlay parts (eyes, brows, mouth as their own slots — the default) or baked into the head art (DiceBear / external avatars).
Falsesuppresses the face overlay slots at rig build AND the viseme/emotion channels at dialogue compile — a baked face has no overlay mouth to drive.This is a declared fact, replacing the old vendor-name check on
metadata.art_provenance(an#87): provenance says where art came from; this says what the art IS. The 0.2.0 → 0.3.0 migration derives it from the provenance string once, andart_provenancereverts to pure provenance/licensing metadata.
- gait: Optional[str]¶
This character’s default walk
gait(one ofGAITS, an#220); an author’sgaitarg overrides it.None= the locomotion chain:legswhen the rig affords a leg pair, elseglide(an#224). A robe figure whose leg slots are hem halves declares"hem"once, here, rather than on every walk. Omitted from the stored document when unset.
- gaze_travel: Optional[dict[str, float]]¶
How far a pupil may travel from its rest, in view-box units per axis (an#99): the sclera’s clearance minus the pupil’s radius, written by
an character add-gazefrom the parts it synthesized.None= the rig has no pupil layer (gaze is a no-op on it) or uses the default travel. The travel maps the gaze axes’ unit circle onto the sclera’s inner ellipse; the compiler clamps the summed (x, y) to 0.95 of that circle, which keeps the whole pupil disc inside the white at every angle (a per-axis box pokes out at the diagonal) — no runtime mask.
- metadata: dict[str, Any]¶
Free-form metadata (dicebear style/seed, etc.). Schema-evolution friendly: anything an external tool wants to record can land here.
This comment used to say “art license, etc.” — an invitation nothing ever took up. Rights live in
sourcenow, typed, so they can be found.
- model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}¶
Configuration for the model, should be a dictionary conforming to [
ConfigDict][pydantic.config.ConfigDict].
- model_post_init(_CharacterDescriptor__context)[source]¶
Override this method to perform additional initialization after
__init__andmodel_construct. This is useful if you want to do some validation that requires the entire model to be initialized.- Return type:
- occluded: dict[str, str]¶
The face features another drawing of this character covers, and what covers them —
{feature: what}, e.g.{"brows": "the cap hat at head_scale 0.3"}(an#252). A declared fact, written by whoever knows the geometry: the factory measures its hat against the brows’ acting range and records an overlap it could not seat away; an illustrator declares a helmet over the brows. Read by the character analyser: a covered feature is not afforded (brows→face.brows), so the methods needing it fall to their default, said byan character capabilities. Keys areOCCLUDABLE_FEATURES. Omitted from the stored document when empty.
- rest_view: Optional[str]¶
The view the DEFAULT art is drawn in (an#220) — a declared fact about the art, like
face_overlay.NonemeansDFLT_VIEW(front). A character carved from a profile (a silhouette film, a side- on figure) says"side", and everything that asks which view is in force before any turn —walkswinging its legs rather than lifting them — reads it instead of the author passingview: sideby hand. Omitted from the stored document when unset.
- source: AssetSource | None¶
Where this character’s art came from, and what its licence obliges.
Nonemeans “we made this” — not “unknown”. Anything acquired should carry one, because a licence defect is the only failure that reaches BACKWARDS through completed work: a video shipped with an unattributed CC BY asset cannot be un-shipped.Field names match
illustration.ImageResultexactly, so an adapter is a dict copy rather than a rename table — and a rename table is where a field quietly stops being carried. Pinned by test.
- source_svg: Optional[str]¶
Optional source SVG (relative path) that the parts/ folder was extracted from. Useful for re-slicing.
- speech: Union[str, dict[str, Any], None]¶
a method’s spelling (
mouth_chart,pulse), its id (speech.pose_only) or a choice with args and an optional version pin ({method: pulse, args: {strength: 0}}— a mime).None= the default chain: lip-sync when the character has a mouth chart, else a head pulse, recorded. Declaring it is also how a baked-face character renders under--strict-assets: a declared pulse is the request, not a fallback. Resolved (and refused when unknown) by the capability registry. Omitted from the stored document when unset.- Type:
How this character shows it is speaking (the speech aspect, an#248)
- swap_poses: dict[str, dict[str, dict[str, SlotPose]]]¶
{slot: SlotPose}}}`` (an#197). A
setof a swap set on the ENTITY itself ({kind: set, target: maya, property: view, value: side}) fans the key out to every slot the set projects onto AND poses the slots listed under that key; a slot listed under another key of the set returns to rest. That is how one key turns a whole character: the head and torso swap art, the far eye and arm hide, the mouth slides to the profile edge — while blinks, gaze and lip-sync keep running on what is visible (the face solver folds a pose into its own channels). Additive: no schema bump, and a descriptor without it reads back unposed.- Type:
How slots are POSED while a swap key shows — ``{set
- Type:
{key
- cutan.characters.schema.DEFAULT_EYELID_MAP: dict[str, str] = {'CLOSED': 'closed', 'OPEN': 'open'}¶
Default eyelid-state → attachment-name mapping, shared by both eye slots.
- cutan.characters.schema.DEFAULT_VISEME_MAP: dict[str, str] = {'A': 'mouth_a', 'B': 'mouth_b', 'C': 'mouth_c', 'D': 'mouth_d', 'E': 'mouth_e', 'F': 'mouth_f', 'G': 'mouth_g', 'H': 'mouth_h', 'X': 'mouth_x'}¶
Default Rhubarb-letter → mouth-attachment-name mapping. Uppercase keys because Rhubarb emits A-X; lowercase attachment names by convention.
- cutan.characters.schema.DFLT_VIEW: str = 'front'¶
its default attachments ARE this view. A descriptor whose art is drawn in another view says so in
rest_view.- Type:
The view a character shows at rest
- cutan.characters.schema.EYELID_CHANNEL: str = 'eyelid'¶
The swap channel blinks drive. One set serves BOTH eye slots because the eye slots share per-slot attachment names (
open/closed) — the 0.3.0 migration renamed them from the file-derivedeye_l_openspelling for exactly this: a set’s keys are looked up per slot, so slots that a single channel must drive together need attachment names in common.
- cutan.characters.schema.FACE_OFFSETS: dict[str, tuple[float, float]] = {'left_brow': (-41.6, -133.2), 'left_eye': (-41.6, -97.6), 'mouth': (0.0, -38.2), 'right_brow': (41.6, -133.2), 'right_eye': (41.6, -97.6)}¶
Where each face part sits relative to the
headbone, in view_box units.All five share one bone, so without a per-attachment offset they stack on it. These are the compiler’s four deleted hardcoded pairs converted at k = 345/1024 — i.e. the same picture, now expressed where an illustrator can change it. Those pairs were relative to the head’s CENTRE (the old compiler anchored the head at 0.5); the bone is the NECK, and the head hangs above it at
HEAD_ANCHOR, so each pair is lifted by the centre’s height above the neck. Unlifted, the mouth sat below the neck — on the torso (an#168).
- cutan.characters.schema.HEAD_ANCHOR: tuple[float, float] = (0.5, 0.78)¶
the head hangs above the neck, its lower ~fifth overlapping the collar.
- Type:
The head’s anchor on the neck bone
- class cutan.characters.schema.IdleAnimation(*, name, duration=1.0, loop=True, tracks=<factory>, **extra_data)[source]¶
Bases:
RigModelA named idle loop (e.g., breath, blink).
>>> a = IdleAnimation(name="idle_breath", duration=4.0) >>> a.loop True
- cutan.characters.schema.LEG_LENGTH: float = 300.0¶
Hip to ground in the default rig, in view_box units. The torso bone (the hip) and both leg bones sit this far above the root (the ground contact), so a leg drawn this long reaches the ground. The factory draws its legs to it.
- cutan.characters.schema.MOUTH_SHAPES: tuple[str, ...] = ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'x')¶
Rhubarb mouth shapes. A-F are mandatory in Rhubarb’s basic set; G/H/X are emitted when
--extendedShapes GHXis on (Rhubarb’s default). We always ship all 9 so the renderer never has to fall back.
- cutan.characters.schema.REFERENCE_HEAD_HEIGHT: float = 285.0¶
The head height the default face layout is drawn for, in view_box units — the pre-Wave-4 compiler’s 96 px head at k = 345/1024. The factory writes its head art at this height, so
FACE_OFFSETSlands on the face.
- cutan.characters.schema.REQUIRED_PARTS: tuple[str, ...] = ('head', 'torso', 'arm_l', 'arm_r', 'leg_l', 'leg_r', 'eye_l_open', 'eye_l_closed', 'eye_r_open', 'eye_r_closed', 'brow_l', 'brow_r')¶
Required body parts. A character missing any of these can’t be rendered as a full puppet;
validate_characterflags the gap.
- cutan.characters.schema.SLOT_POSE_OFFSETS: tuple[str, ...] = ('x', 'y')¶
The transform properties a
SlotPosesets, and whether each is an OFFSET added to the rest (in view_box units, so scaled by the rig), an ANGLE added to it (radians, never scaled) or a FACTOR on it.
- class cutan.characters.schema.SlotPose(*, x=0.0, y=0.0, scale_x=1.0, scale_y=1.0, alpha=1.0, rotation=0.0, **extra_data)[source]¶
Bases:
RigModelHow one slot is posed while a swap key is shown (
swap_poses, an#197).Relative to the slot’s REST, so one pose serves every placement:
x/yare added (view_box units, like an attachment offset),rotationis added too (radians, about the slot’s own pivot — how a profile splays its legs so both show),scale_x,scale_yandalphamultiply.alpha: 0is how a view HIDES a slot — the back view hides the face — which is a property of the view, never an author’s alpha hack on node paths guessed by trial.>>> SlotPose(alpha=0).alpha, SlotPose().x, SlotPose().rotation (0.0, 0.0, 0.0)
- cutan.characters.schema.VIEWS: tuple[str, ...] = ('front', 'three_quarter', 'side', 'back')¶
The views the factory draws, in turnaround order.
sideis a profile facing the viewer’s RIGHT at a positivescale_x; a negativescale_x(cutan.motion.turn(direction="left")) mirrors it to face left.
- cutan.characters.schema.VIEW_CHANNEL: str = 'view'¶
one KEY per drawn view, projected onto the slots whose art changes with the view (the factory draws the head and the torso), each slot carrying attachments NAMED after the keys. A conventional name, like
viseme— nothing in the compiler or the runtime reads it;cutan.motion.turnis the one writer that defaults to it.- Type:
The swap set a turnaround rides (an#197)
- cutan.characters.schema.VIEW_VARIANT_SEP: str = '@'¶
What joins a swap set’s name to the view a variant of it serves:
eyelid@sideis theeyelidset drawn for thesideview (an#220), the same separator the expression variants (viseme@happy, an#98) use.
- cutan.characters.schema.VISEME_CHANNEL: str = 'viseme'¶
The swap channel lip-sync drives.
visemeis a conventional set name, not a special case in control flow (an#87): the compiler projects EVERYasset_setschannel onto the slots whose attachments its keys name, and the runtime applies any projected channel the same way.
- cutan.characters.schema.bones_from_pivots(pivots, *, bones=None)[source]¶
Re-place a bone rig onto an illustrator’s own joint coordinates.
Each
Bonealready declares the joint it stands for (head->neck,arm_l->shoulder_l, …), andextract_pivots()already returns those joints as{name: (cx, cy)}. Nothing connected the two:promotecomputed the pivots and stored only their names, so the coordinates an artist drew were discarded and every character got the generic rig (an#75).Bones a drawing has no joint for keep their default placement, so a partial skeleton improves a rig rather than breaking it.
Positions are stored parent-relative, so an absolute joint is converted against its parent’s resolved absolute position — and parents are resolved first, which is why this walks in declaration order rather than by index.
>>> bones = bones_from_pivots({"neck": (500.0, 300.0), "root": (500.0, 900.0)}) >>> head = next(b for b in bones if b.name == "head") >>> root = next(b for b in bones if b.name == "root") >>> root.x, root.y (500.0, 900.0) >>> torso = next(b for b in bones if b.name == "torso") >>> round(head.y + torso.y + root.y) # absolute, back to the neck 300
- cutan.characters.schema.default_asset_sets()[source]¶
{channel: {key: attachment_name}}for a freshly-built character.
- cutan.characters.schema.view_variant_set(set_name, view)[source]¶
The name of
set_name’s variant forview(an#220).>>> view_variant_set("eyelid", "side") 'eyelid@side'- Return type:
- cutan.characters.schema.view_variant_sets(desc, *, view_set='view')[source]¶
{base set: {view: variant set name}}— every per-view face set the descriptor declares (an#220): a set named<base>@<view>where<base>is a declared set and<view>a key of itsviewset (or itsrest_view).viseme@happyis NOT one —happyis not a view — so the expression variants (an#98) and the view variants never collide.>>> d = CharacterDescriptor(name="v") >>> d.asset_sets["view"] = {"front": "front", "side": "side"} >>> d.asset_sets["eyelid@side"] = {"OPEN": "open_side", "CLOSED": "closed_side"} >>> d.asset_sets["viseme@happy"] = {"X": "mouth_x_happy"} >>> view_variant_sets(d) {'eyelid': {'side': 'eyelid@side'}}