Source code for an.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 over ``skins``, which says what art *exists*. The
  ``viseme`` channel is Rhubarb's shape letter → an attachment on the ``mouth``
  slot. (Replaced ``viseme_map`` in 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']
"""

from __future__ import annotations

from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field, field_validator

from an.ir.assets import AssetSource
from an.ir.migrate import DocumentKind, register_kind, register_migration


CHARACTER_SCHEMA_VERSION = "0.3.0"

#: The descriptor is a schema-versioned document in its own right, with its own
#: version field. Registered here rather than in :mod:`an.ir.migrate` because
#: this module already imports from :mod:`an.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.
CHARACTER_DOCUMENT_KIND: DocumentKind = register_kind(
    DocumentKind(
        name="CharacterDescriptor",
        version_field="schema_version",
        current_version=CHARACTER_SCHEMA_VERSION,
    )
)

#: Rhubarb mouth shapes. A-F are mandatory in Rhubarb's basic set; G/H/X
#: are emitted when ``--extendedShapes GHX`` is on (Rhubarb's default).
#: We always ship all 9 so the renderer never has to fall back.
MOUTH_SHAPES: tuple[str, ...] = ("a", "b", "c", "d", "e", "f", "g", "h", "x")

#: Default Rhubarb-letter → mouth-attachment-name mapping. Uppercase keys
#: because Rhubarb emits A-X; lowercase attachment names by convention.
DEFAULT_VISEME_MAP: dict[str, str] = {s.upper(): f"mouth_{s}" for s in MOUTH_SHAPES}

#: The swap channel lip-sync drives. `viseme` is a conventional set name, not
#: a special case in control flow (an#87): the compiler projects EVERY
#: `asset_sets` channel onto the slots whose attachments its keys name, and
#: the runtime applies any projected channel the same way.
VISEME_CHANNEL: str = "viseme"

#: 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-derived `eye_l_open` spelling 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.
EYELID_CHANNEL: str = "eyelid"

#: Default eyelid-state → attachment-name mapping, shared by both eye slots.
DEFAULT_EYELID_MAP: dict[str, str] = {"OPEN": "open", "CLOSED": "closed"}


[docs] def default_asset_sets() -> dict[str, dict[str, str]]: """``{channel: {key: attachment_name}}`` for a freshly-built character.""" return { VISEME_CHANNEL: dict(DEFAULT_VISEME_MAP), EYELID_CHANNEL: dict(DEFAULT_EYELID_MAP), }
#: Required body parts. A character missing any of these can't be rendered #: as a full puppet; ``validate_character`` flags the gap. 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", ) #: Canonical character viewBox: 1024x1024 with feet near y≈980. All parts #: inherit this viewBox at export so PixiJS can use the SVG's intrinsic #: viewBox without a calibration step. DEFAULT_VIEW_BOX: tuple[int, int, int, int] = (0, 0, 1024, 1024) class _CharModel(BaseModel): """Common config: forward-compatible reads, strict writes.""" model_config = ConfigDict(extra="allow", populate_by_name=True)
[docs] class Bone(_CharModel): """A skeleton joint with a local transform relative to its parent. >>> b = Bone(name="head", parent="torso", x=0, y=-260, pivot="neck") >>> b.parent 'torso' """ name: str parent: Optional[str] = None x: float = 0.0 y: float = 0.0 rotation_deg: float = 0.0 scale_x: float = 1.0 scale_y: float = 1.0 #: Optional pivot name — must match a circle in the SVG ``skeleton`` group. pivot: Optional[str] = None
[docs] class Slot(_CharModel): """A draw-order slot bound to a bone, displaying one attachment at a time. >>> s = Slot(name="mouth", bone="head", draw_order=7, attachment="mouth_x") >>> s.attachment 'mouth_x' """ name: str bone: str draw_order: int = 0 #: Default attachment name; the active attachment can change at runtime #: via animation tracks targeting ``slot:<name>.attachment``. attachment: Optional[str] = None
[docs] class Attachment(_CharModel): """A drawable: an SVG path + anchor point (in 0..1 per-axis units). >>> a = Attachment(path="parts/head.svg", anchor=(0.5, 0.78)) >>> a.anchor (0.5, 0.78) """ path: str #: Anchor in 0..1 per-axis units (Pixi's Sprite.anchor convention). anchor: tuple[float, float] = (0.5, 0.5) #: Offset from the slot's bone, in view_box units. #: #: **This is where a part's position lives**, and it is the reference data #: model's answer, not an invention: DragonBones puts it in #: ``display.transform``, Spine in the region attachment's ``{x, y}``, and #: in both the *slot* carries no transform at all. It is what lets five face #: parts share one ``head`` bone and still land in different places — before #: this field they all stacked on the bone, because the descriptor had no #: way to say otherwise and the compiler used hardcoded literals instead. x: float = 0.0 y: float = 0.0 #: Optional explicit bounding box override in the part's local viewBox. width: Optional[float] = None height: Optional[float] = None
[docs] class Skin(_CharModel): """A named outfit/variant: maps slot → {attachment_name → Attachment}. >>> skin = Skin(name="default", slots={"mouth": {"mouth_a": Attachment(path="parts/mouth/mouth_a.svg")}}) >>> skin.slots["mouth"]["mouth_a"].path 'parts/mouth/mouth_a.svg' """ name: str = "default" slots: dict[str, dict[str, Attachment]] = Field(default_factory=dict)
_TrackType = Literal["sine", "step", "linear"]
[docs] class AnimationTrack(_CharModel): """A single channel inside an idle animation. The ``target`` is a path-string per the architecture pillar: - ``bone:<name>.<prop>`` for bone transforms (``x``, ``y``, ``rotation_deg``, ``scale_x``, ``scale_y``). - ``slot:<name>.attachment`` for swap animations (eyes blinking, mouth visemes). For ``type="sine"``: ``amplitude`` is the peak deviation; ``phase`` is in cycles (0..1). For ``type="step"`` / ``type="linear"``: ``frames`` is 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 """ target: str type: _TrackType = "sine" # sine fields amplitude: float = 0.0 phase: float = 0.0 # step / linear fields frames: list[tuple[float, Any]] = Field(default_factory=list) @field_validator("target") @classmethod def _check_target(cls, v: str) -> str: if not (v.startswith("bone:") or v.startswith("slot:")): raise ValueError(f"target must start with 'bone:' or 'slot:'; got {v!r}") return v
[docs] class IdleAnimation(_CharModel): """A named idle loop (e.g., breath, blink). >>> a = IdleAnimation(name="idle_breath", duration=4.0) >>> a.loop True """ name: str duration: float = 1.0 loop: bool = True tracks: list[AnimationTrack] = Field(default_factory=list)
[docs] class CharacterDescriptor(_CharModel): """The 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 """ schema_version: str = CHARACTER_SCHEMA_VERSION kind: Literal["CharacterDescriptor"] = "CharacterDescriptor" name: str display_name: Optional[str] = None view_box: tuple[int, int, int, int] = DEFAULT_VIEW_BOX #: Voice-store id or path used by the audio pipeline. Optional; the scene #: can override per shot. voice_ref: Optional[str] = None #: Optional source SVG (relative path) that the parts/ folder was #: extracted from. Useful for re-slicing. source_svg: Optional[str] = None bones: list[Bone] = Field(default_factory=list) slots: list[Slot] = Field(default_factory=list) skins: dict[str, Skin] = Field(default_factory=dict) #: ``{channel: {key: attachment_name}}`` — what a swap key SELECTS, layered #: over ``skins``, 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. Replaces ``viseme_map`` (schema 0.2.0). asset_sets: dict[str, dict[str, str]] = Field(default_factory=default_asset_sets) animations: dict[str, IdleAnimation] = Field(default_factory=dict) #: Where this character's art came from, and what its licence obliges. #: #: ``None`` means "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.ImageResult`` exactly, 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: AssetSource | None = None #: 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). ``False`` suppresses 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, and ``art_provenance`` reverts to pure #: provenance/licensing metadata. face_overlay: bool = True #: 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. ``None`` means #: the default binding derived from the slots the rig has #: (:func:`an.expression.binding.default_binding`). Additive: no schema bump, #: and a pre-Wave-6 descriptor reads back unchanged. expression_binding: Optional[list[dict[str, Any]]] = None #: 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-gaze` from 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. gaze_travel: Optional[dict[str, float]] = None #: 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 ``source`` now, typed, so they can be found. metadata: dict[str, Any] = Field(default_factory=dict)
[docs] def model_post_init(self, __context: Any) -> None: # If the caller didn't seed bones/slots/skins, fill in a sensible default # rig so a freshly-constructed CharacterDescriptor is immediately usable. if not self.bones: self.bones = list(_default_bones()) if not self.slots: self.slots = list(_default_slots()) if not self.skins: self.skins = {"default": _default_skin()} if not self.animations: # Resolved lazily to avoid a circular import with idle.py. from an.characters.idle import breath_animation, blink_animation self.animations = { "idle_breath": breath_animation(), "blink": blink_animation(), }
# ----------------------------------------------------------------------------- # Default rig builders # ----------------------------------------------------------------------------- #: Slot renames carried by the 0.1.0 -> 0.2.0 migration: a slot's name is now #: its scene-graph node name, so the four face slots take the names the scene #: already addressed them by. _SLOT_RENAMES_0_2_0: dict[str, str] = { "eye_l": "left_eye", "eye_r": "right_eye", "brow_l": "left_brow", "brow_r": "right_brow", } @register_migration(CHARACTER_DOCUMENT_KIND.name, "0.1.0", "0.2.0") def _character_0_1_0_to_0_2_0(doc: dict[str, Any]) -> dict[str, Any]: """`viseme_map` -> `asset_sets["viseme"]`, and slot names become node names. Both changes ride one migration because splitting them would break the descriptor schema twice and ship two migrations where one does. `viseme_map` is popped, not copied: leaving it would let a stale map sit beside the live one indefinitely, and every descriptor model sets `extra="allow"`, so nothing would ever complain. >>> out = _character_0_1_0_to_0_2_0( ... {"schema_version": "0.1.0", "viseme_map": {"A": "mouth_a"}, ... "slots": [{"name": "eye_l", "bone": "head"}]} ... ) >>> out["asset_sets"]["viseme"], "viseme_map" in out ({'A': 'mouth_a'}, False) >>> out["slots"][0]["name"] 'left_eye' """ viseme_map = doc.pop("viseme_map", None) if viseme_map is not None: doc.setdefault("asset_sets", {})[VISEME_CHANNEL] = viseme_map for slot in doc.get("slots") or (): if isinstance(slot, dict) and slot.get("name") in _SLOT_RENAMES_0_2_0: slot["name"] = _SLOT_RENAMES_0_2_0[slot["name"]] for skin in (doc.get("skins") or {}).values(): slots = skin.get("slots") if isinstance(skin, dict) else None if not isinstance(slots, dict): continue for old, new in _SLOT_RENAMES_0_2_0.items(): if old in slots: slots[new] = slots.pop(old) # Face offsets move from code into data. Before 0.2.0 the five face parts # had no way to say where they sat, so the compiler hardcoded four literal # pairs; a descriptor migrated from 0.1.0 therefore has the information # nowhere else. Seeding only when the attachment has not been given one # keeps a hand-authored offset authoritative. for skin in (doc.get("skins") or {}).values(): slots = skin.get("slots") if isinstance(skin, dict) else None if not isinstance(slots, dict): continue for slot_name, offset in FACE_OFFSETS.items(): for attachment in (slots.get(slot_name) or {}).values(): if not isinstance(attachment, dict): continue attachment.setdefault("x", offset[0]) attachment.setdefault("y", offset[1]) doc["schema_version"] = "0.2.0" return doc #: Eye attachment-name renames carried by 0.2.0 -> 0.3.0: both eye slots take #: the shared per-slot keys `open`/`closed` so ONE `eyelid` set can project #: onto both. Keyed per slot because the old names were per-side. _EYE_ATTACHMENT_RENAMES_0_3_0: dict[str, dict[str, str]] = { "left_eye": {"eye_l_open": "open", "eye_l_closed": "closed"}, "right_eye": {"eye_r_open": "open", "eye_r_closed": "closed"}, } #: `metadata.art_provenance` values that mean the face is baked into the head #: art. Consumed ONLY by the 0.3.0 migration below — live code reads the #: declared `face_overlay` field instead (an#87). `external_avatar` never had #: a writer; it is kept here so any hand-authored descriptor carrying it #: migrates the way the old special case treated it. _FACE_BAKED_PROVENANCES_0_3_0: tuple[str, ...] = ("dicebear", "external_avatar") @register_migration(CHARACTER_DOCUMENT_KIND.name, "0.2.0", "0.3.0") def _character_0_2_0_to_0_3_0(doc: dict[str, Any]) -> dict[str, Any]: """Four coherent changes, one migration (an#87). (a) ``face_overlay`` becomes a declared fact, derived once from the old ``metadata.art_provenance`` vendor-name check; (b) eye attachment names become the shared per-slot keys ``open``/``closed`` (paths unchanged); (c) ``asset_sets`` gains the ``eyelid`` channel; (d) stored idle-animation tracks are repaired — the 0.2.0 migration renamed slots in ``slots`` and ``skins`` but never touched ``animations``, so every stored descriptor carried stale ``slot:eye_l.attachment`` targets (latent only because nothing consumed the field; PlayAction resolution makes it live). >>> out = _character_0_2_0_to_0_3_0( ... {"schema_version": "0.2.0", ... "metadata": {"art_provenance": "dicebear"}, ... "skins": {"default": {"slots": {"left_eye": {"eye_l_open": {"path": "parts/eye_l_open.svg"}}}}}, ... "slots": [{"name": "left_eye", "bone": "head", "attachment": "eye_l_open"}], ... "animations": {"blink": {"name": "blink", "tracks": [ ... {"target": "slot:eye_l.attachment", "type": "step", ... "frames": [[0.0, "eye_l_open"], [0.05, "eye_l_closed"]]}]}}} ... ) >>> out["face_overlay"], out["schema_version"] (False, '0.3.0') >>> list(out["skins"]["default"]["slots"]["left_eye"]) ['open'] >>> out["slots"][0]["attachment"] 'open' >>> out["animations"]["blink"]["tracks"][0]["target"] 'slot:left_eye.attachment' >>> [f[1] for f in out["animations"]["blink"]["tracks"][0]["frames"]] ['open', 'closed'] >>> out["asset_sets"]["eyelid"] {'OPEN': 'open', 'CLOSED': 'closed'} """ # (a) the declared face fact, from the retired vendor-name check. provenance = (doc.get("metadata") or {}).get("art_provenance") doc.setdefault("face_overlay", provenance not in _FACE_BAKED_PROVENANCES_0_3_0) # (b) per-slot eye attachment keys, in skins and slot defaults. flat_renames = { old: new for per_slot in _EYE_ATTACHMENT_RENAMES_0_3_0.values() for old, new in per_slot.items() } for skin in (doc.get("skins") or {}).values(): slots = skin.get("slots") if isinstance(skin, dict) else None if not isinstance(slots, dict): continue for slot_name, renames in _EYE_ATTACHMENT_RENAMES_0_3_0.items(): attachments = slots.get(slot_name) if not isinstance(attachments, dict): continue for old, new in renames.items(): if old in attachments: attachments[new] = attachments.pop(old) for slot in doc.get("slots") or (): if isinstance(slot, dict) and slot.get("attachment") in flat_renames: slot["attachment"] = flat_renames[slot["attachment"]] # (c) the eyelid set, only where absent — a hand-authored one wins. asset_sets = doc.setdefault("asset_sets", {}) if isinstance(asset_sets, dict): asset_sets.setdefault(EYELID_CHANNEL, dict(DEFAULT_EYELID_MAP)) # (d) repair stored animation tracks: the 0.2.0 slot renames, applied to # the targets 0.2.0 missed, plus the (b) attachment renames in frames. for anim in (doc.get("animations") or {}).values(): tracks = anim.get("tracks") if isinstance(anim, dict) else None for track in tracks or (): if not isinstance(track, dict): continue target = track.get("target") if isinstance(target, str) and target.startswith("slot:"): rest = target[len("slot:") :] slot_name, _, prop = rest.partition(".") if slot_name in _SLOT_RENAMES_0_2_0: track["target"] = f"slot:{_SLOT_RENAMES_0_2_0[slot_name]}.{prop}" track["frames"] = [ [t, flat_renames.get(v, v) if isinstance(v, str) else v] for t, v in (track.get("frames") or ()) ] doc["schema_version"] = "0.3.0" return doc def _default_bones() -> list[Bone]: """The 7-bone default rig: root, torso, head, two arms, two legs. Coordinates assume a 1024x1024 viewBox with feet near y≈980. """ return [ Bone(name="root", parent=None, x=512, y=980, pivot="root"), Bone(name="torso", parent="root", x=0, y=-300, pivot="hip"), Bone(name="head", parent="torso", x=0, y=-260, pivot="neck"), Bone(name="arm_l", parent="torso", x=-90, y=-240, pivot="shoulder_l"), Bone(name="arm_r", parent="torso", x=90, y=-240, pivot="shoulder_r"), Bone(name="leg_l", parent="root", x=-50, y=-10, pivot="hip_l"), Bone(name="leg_r", parent="root", x=50, y=-10, pivot="hip_r"), ] #: Where each face part sits relative to the ``head`` bone, 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. FACE_OFFSETS: dict[str, tuple[float, float]] = { "left_eye": (-41.6, -17.8), "right_eye": (41.6, -17.8), "left_brow": (-41.6, -53.4), "right_brow": (41.6, -53.4), "mouth": (0.0, 41.6), } def _default_slots() -> list[Slot]: """The 11-slot default draw stack: legs behind, arms in front, face on top. **A slot's name IS its scene-graph node name.** The face slots read ``left_eye`` rather than ``eye_l`` for that reason and no other: node paths are the authoring surface (``scene.md`` targets ``charlie/left_eye:...``, and the doc-targeting test addresses them), so the alternative was a slot-to-node rename table — and a rename table is where a field quietly stops being carried. Attachment names are a *separate*, per-slot namespace: single-attachment slots keep the file-derived spelling (``brow_l``), while slots that one swap channel must drive **together** share key-like names — both eye slots carry ``open``/``closed`` (0.3.0) so the single ``eyelid`` set projects onto each. Paths keep the file spelling either way. """ return [ Slot(name="leg_l", bone="leg_l", draw_order=0, attachment="leg_l"), Slot(name="leg_r", bone="leg_r", draw_order=0, attachment="leg_r"), Slot(name="torso", bone="torso", draw_order=1, attachment="torso"), Slot(name="arm_l", bone="arm_l", draw_order=2, attachment="arm_l"), Slot(name="arm_r", bone="arm_r", draw_order=2, attachment="arm_r"), Slot(name="head", bone="head", draw_order=4, attachment="head"), Slot(name="left_eye", bone="head", draw_order=6, attachment="open"), Slot(name="right_eye", bone="head", draw_order=6, attachment="open"), Slot(name="mouth", bone="head", draw_order=7, attachment="mouth_x"), Slot(name="left_brow", bone="head", draw_order=8, attachment="brow_l"), Slot(name="right_brow", bone="head", draw_order=8, attachment="brow_r"), ]
[docs] def bones_from_pivots( pivots: Mapping[str, tuple[float, float]], *, bones: list[Bone] | None = None, ) -> list[Bone]: """Re-place a bone rig onto an illustrator's own joint coordinates. Each :class:`Bone` already declares the joint it stands for (``head`` -> ``neck``, ``arm_l`` -> ``shoulder_l``, ...), and :func:`~an.characters.svg_utils.extract_pivots` already returns those joints as ``{name: (cx, cy)}``. Nothing connected the two: `promote` computed 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 """ rig = [b.model_copy(deep=True) for b in (bones or _default_bones())] by_name = {b.name: b for b in rig} def absolute(bone: Bone) -> tuple[float, float]: x = y = 0.0 seen: set[str] = set() cursor: Bone | None = bone while cursor is not None and cursor.name not in seen: seen.add(cursor.name) x += cursor.x y += cursor.y cursor = by_name.get(cursor.parent) if cursor.parent else None return x, y for bone in rig: # declaration order: a parent is always placed first target = pivots.get(bone.pivot) if bone.pivot else None if target is None: continue parent = by_name.get(bone.parent) if bone.parent else None base = absolute(parent) if parent is not None else (0.0, 0.0) bone.x = target[0] - base[0] bone.y = target[1] - base[1] return rig
def _default_skin() -> Skin: """Default skin wiring slot names → attachment dicts → SVG paths. Paths are relative to the descriptor file. Slicing a real source SVG can overwrite/extend these; here we declare the canonical inventory so the descriptor is internally consistent even before parts exist on disk. """ slots: dict[str, dict[str, Attachment]] = {} # Single-attachment body slots for slot_name, anchor in ( # Anchors are stated relative to each slot's BONE. The torso's bone is # the hip, so the torso hangs UPWARD from it (anchor at its bottom # edge); the limbs' bones are shoulders and hips, so they hang downward # (anchor at their top edge). Before the compiler read any of this the # anchors were inert and the torso's read (0.5, 0.0) — which, once the # bone became the hip, drew the body below the waist and over the legs. ("torso", (0.5, 1.0)), ("head", (0.5, 0.78)), ("arm_l", (0.5, 0.0)), ("arm_r", (0.5, 0.0)), ("leg_l", (0.5, 0.0)), ("leg_r", (0.5, 0.0)), ): slots[slot_name] = { slot_name: Attachment(path=f"parts/{slot_name}.svg", anchor=anchor) } # Brows: slot name is the node name, attachment name is the file stem. for slot_name, attachment in (("left_brow", "brow_l"), ("right_brow", "brow_r")): x, y = FACE_OFFSETS[slot_name] slots[slot_name] = { attachment: Attachment( path=f"parts/{attachment}.svg", anchor=(0.5, 0.5), x=x, y=y ) } # Eye slots have two attachments. Their names are the shared per-slot # keys `open`/`closed` (NOT the file stems) so the one `eyelid` set can # project onto both slots; the paths keep the file spelling. for slot_name, stem in (("left_eye", "eye_l"), ("right_eye", "eye_r")): x, y = FACE_OFFSETS[slot_name] slots[slot_name] = { state: Attachment( path=f"parts/{stem}_{state}.svg", anchor=(0.5, 0.5), x=x, y=y ) for state in ("open", "closed") } # Mouth slot has 9 attachments (the viseme set). mouth_x, mouth_y = FACE_OFFSETS["mouth"] slots["mouth"] = { f"mouth_{s}": Attachment( path=f"parts/mouth/mouth_{s}.svg", anchor=(0.5, 0.5), x=mouth_x, y=mouth_y ) for s in MOUTH_SHAPES } return Skin(name="default", slots=slots)