an.stage.rig#
The rig model the stage draws: bones, slots, skins and attachments.
A rig is the stage’s own idea (a skeleton of bones, draw-ordered slots bound to
them, and skins mapping slots to drawable attachments); props use it as well as
characters. It lives in an.stage so the stage compiler can build a rig
without importing any genre; a genre’s descriptor (cutan’s
CharacterDescriptor) is composed from these types.
Two layers live here:
the model:
Bone,Slot,Attachment,Skin, andRigDocument, the base of every document that IS arig (
PropDescriptor,CharacterDescriptor), which carries the fields about the rig as a whole (its declaredRigDocument.origin);the builder:
build_rig_subtree(), the ONE function that turns a rig document into a scene subtree for props and characters alike (an#108), with the helpers a genre needs to call it (part_probe(),raster_digest(),art_src(),bone_positions(),rig_origin()). Public since an#338: before it,cutanreached thebuilder through private names of the stage compiler, which a second genre could not have done. The compiler re-exports the old private spellings.
Module Attributes
1024x1024 with feet near y≈980. |
|
The fields |
|
|
|
The nesting modes ( |
|
The capability a nested chain affords (ADR 0002 decision 1's name, registered by the core in |
|
Scene-graph pixels spanned by a descriptor's full |
|
The fit policy every compiled sprite carries. |
|
The |
|
The same, for props. |
Functions
|
Path used inside the runtime dir, relative to |
|
The box a part draws in, in view_box units: the declared size wins, the art's aspect is kept (an#220). |
|
The DEFAULT point in view_box space that the entity's placement refers to, when the rig declares no |
|
Absolute |
Whether any bone of a (raw or model) rig document has a non-zero |
|
|
Build the scene subtree for a rig (a prop or a character), from its descriptor. |
Where a nested chain asks the STAGE for a draw order it cannot give (an#340, an#403). |
|
|
How each container of a nested chain must order what it holds (an#403). |
|
A rest pose the stage cannot apply in a nested chain (an#340). |
|
The rig's DECLARED origin as two floats, or |
|
The |
|
The node paths of a rig's slots, relative to its entity, in DECLARED draw order: the paint list the stage uses when a nested chain interleaves with an unrelated part (an#430), which no sort of a container's own items can give ( |
|
The builder guard (an#339): whether |
|
What each container holds in the builder's own order: the slot's visual first, then its children by their own |
|
|
|
Drop every unset |
|
A probe answering |
|
|
The migration step every rig kind runs onto the version that applies the rest pose (an#339). |
|
|
|
|
Register the protective rest-pose step for one rig kind (an#339). |
|
What to say when the MIGRATION, on this read, kept a rig's pose unapplied (an#407). |
|
The node transform fields a bone's rest pose sets (an#339): its |
|
What a rig's structure affords, derived from the rig model (an#340). |
|
The point of the rig, in view_box units, that lands at the entity's placement. |
|
What is wrong with a rig's declared origin, as warnings (an#338). |
|
What is structurally wrong with a rig's bones and slots (an#340). |
|
Warnings about a rig's rest pose (an#339), on a model or a raw document. |
|
|
|
|
Classes
|
A drawable: an SVG path + anchor point (in 0..1 per-axis units). |
|
A skeleton joint with a local transform relative to its parent. |
|
The base of every document that IS a rig: |
|
Common config: forward-compatible reads, strict writes. |
|
A named outfit/variant: maps slot → {attachment_name → Attachment}. |
|
A draw-order slot bound to a bone, displaying one attachment at a time. |
Exceptions
A rig's bone rest pose was NOT applied, because the document may predate it. |
|
A rig cannot be built as declared (a cycle, or a chain the stage cannot draw). |
- class an.stage.rig.Attachment(**data)[source]#
Bases:
RigModelA 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)
- model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#
Configuration for the model, should be a dictionary conforming to [
ConfigDict][pydantic.config.ConfigDict].
- source: AssetSource | None#
Where THIS part’s art came from, when it is not the descriptor’s
source— a character composed from several clips, or a carved head on a CC0 body, credits each (an#220).None= the descriptor’ssourcecovers it.an creditslists every one; an all-rights- reserved part makes the render NOT PUBLISHABLE like any other. Omitted from the stored document when unset.
- width: float | None#
The size the part draws at, in view_box units — the rig’s units, the ones
x/yand the bones use (an#220). **A declared size wins** over the art’s own extent, asPlane.sizedoes for plates. Unset, the art’s own extent is the size: an SVG’swidth/height(else its viewBox), a raster’s PIXEL count — so a PNG carved at one pixel per unit needs nothing, and one carved at any other scale declares its size here instead of being resampled. The aspect is the art’s, always (an#74): with ONE of the two declared the other follows the art’s aspect; with both, the art is contained in the box (uniformly scaled to fit, never stretched) andan character validatesays when the two aspects disagree. Seeattachment_box().
- x: float#
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 oneheadbone 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.
- class an.stage.rig.Bone(**data)[source]#
Bases:
RigModelA 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'
- an.stage.rig.CHARACTER_ART_PREFIX: str = 'characters/'#
The
assets.texturessrcprefix a rig’s art is addressed under, which is also the mall store that resolves it (render.ASSET_SRC_PREFIX_TO_STORE). A parameter rather than a literal because the rig builder is the same code for a character and for a prop, and the store is the ONLY thing that differs about where their art lives. Two hardcoded copies of"characters/"— thesrcbuilder and the probe’s own — reached three call sites, and that is what made “a prop is a rig too” read as a rewrite instead of an argument (an#108).
- an.stage.rig.CONTAIN_FIT: str = 'contain'#
The fit policy every compiled sprite carries. Named rather than inlined so the one place that decides “the art keeps its shape” is greppable.
- an.stage.rig.DEFAULT_VIEW_BOX: tuple[int, int, int, int] = (0, 0, 1024, 1024)#
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.
- Type:
Canonical character viewBox
- an.stage.rig.NESTINGS: tuple[str, ...] = ('flat', 'bones')#
The nesting modes (
RigDocument.nesting); unset means the first.
- an.stage.rig.PROP_ART_PREFIX: str = 'props/'#
The same, for props. Both are keys of
render.ASSET_SRC_PREFIX_TO_STORE, which is what decides where the staging step copies the art from.
- an.stage.rig.REST_POSE_SINCE: dict[str, str] = {'PropDescriptor': '0.2.0'}#
{rig document kind: the version from which its bones' rest pose is applied}, filled byregister_rest_pose_migration()(an#339). The builder guard reads it to recognise a document older than that version.
- an.stage.rig.RIG_DOCUMENT_OPTIONAL_FIELDS: tuple[str, ...] = ('origin', 'rest_rotation', 'nesting')#
The fields
RigDocumentadds, each written out of the stored document when unset (omit_unset_rig_fields()), so every descriptor that never set one reads back, and hashes, as it did before the field existed.
- an.stage.rig.RIG_HIERARCHY: str = 'rig.hierarchy'#
The capability a nested chain affords (ADR 0002 decision 1’s name, registered by the core in
an.capabilities.subjects).
- exception an.stage.rig.RestPoseWarning[source]#
Bases:
UserWarningA rig’s bone rest pose was NOT applied, because the document may predate it.
- class an.stage.rig.RigDocument(**data)[source]#
Bases:
RigModelThe base of every document that IS a rig:
PropDescriptor,CharacterDescriptor.Holds what is true of the rig as a whole, not of one bone or slot (which is why it is not
RigModel, the base ofBoneand the others as well). Every field here is omitted from the stored document when unset.>>> RigDocument(origin=(512, 1010)).origin (512.0, 1010.0) >>> RigDocument().model_dump() {} >>> RigDocument(origin=(float("inf"), 0)) Traceback (most recent call last): ... pydantic_core._pydantic_core.ValidationError: 1 validation error for RigDocument ...
- model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#
Configuration for the model, should be a dictionary conforming to [
ConfigDict][pydantic.config.ConfigDict].
- nesting: Literal['flat', 'bones'] | None#
a slot nests under its OWN bone’s primary slot only, so limbs are siblings of the torso (the rigs’ long-standing shape).
"bones": a slot nests under the primary slot of the nearest ancestor bone that has one, to any depth, so a forearm turns with its upper arm and a sword with its hand (forward kinematics).slot_parent_chain()is the rule.- Type:
How slots nest (an#340). Unset or
"flat"
- origin: tuple[Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[AllowInfNan(allow_inf_nan=False)])], Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[AllowInfNan(allow_inf_nan=False)])]] | None#
The point of the art that the entity’s placement (
stage.at) refers to, in view_box units (an#338). Unset, the rig is placed by the centre of its bones’ extent (bone_extent_centre()), which is what every rig did before the field existed; a prop declares its foot (a tripod’s, a figurine’s stand) so it stands where it is put whatever its extent.
- rest_rotation: bool | None#
Whether the bones’
rotation_deg/scale_x/scale_ypose the built parts (an#339). Unset (orTrue) they do: the rest pose is the bones’.Falseis written ONLY by the migration onto a document from before that rule whose bones carry a rotation or scale, because such a rig was drawn with the pose already in its pixels (the fields were ignored) and applying them now would pose it twice (protect_legacy_rest_pose()).
- exception an.stage.rig.RigError[source]#
Bases:
ValueErrorA rig cannot be built as declared (a cycle, or a chain the stage cannot draw).
- class an.stage.rig.RigModel(**data)[source]#
Bases:
BaseModelCommon config: forward-compatible reads, strict writes.
- an.stage.rig.SCENE_PX_PER_VIEW_BOX: float = 345.0#
Scene-graph pixels spanned by a descriptor’s full
view_boxheight.The single number that maps descriptor space to scene space. One uniform factor
k = SCENE_PX_PER_VIEW_BOX / view_box_heightscales bone positions and part extents alike — uniform by construction, so the compiler cannot violate the invariant that aspect ratio is intrinsic to the art (an#74).345 is a calibration, not a preference. It is what reproduces the framing the seven deleted
_SVG_*_SIZEconstants hand-tuned: at k = 345/1024 = 0.3369,saturated-rig’s own art gives torso 107.8x129.4 against the old 110x130, legs 37.7x118.6 against 38x120. The constants were an approximation of exactly this product, which is the evidence that the rig should have been driving it all along.
- class an.stage.rig.Skin(**data)[source]#
Bases:
RigModelA 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'
- class an.stage.rig.Slot(**data)[source]#
Bases:
RigModelA 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'
- an.stage.rig.art_src(ref, rel_path, *, art_prefix='characters/')[source]#
Path used inside the runtime dir, relative to
index.html.- Return type:
>>> art_src("maya", "parts/head.svg") 'characters/maya/parts/head.svg' >>> art_src("lamp", "parts/body.svg", art_prefix="props/") 'props/lamp/parts/body.svg'
- an.stage.rig.attachment_box(width, height, art)[source]#
The box a part draws in, in view_box units: the declared size wins, the art’s aspect is kept (an#220).
>>> attachment_box(None, None, (40, 20)) # the art's own extent (40.0, 20.0) >>> attachment_box(120, None, (40, 20)) # width declared: height follows (120.0, 60.0) >>> attachment_box(None, 30, (40, 20)) (60.0, 30.0) >>> attachment_box(120, 120, (40, 20)) # both: contained, never stretched (120.0, 60.0) >>> attachment_box(120, 90, None) # unmeasurable art: the box as declared (120.0, 90.0) >>> attachment_box(120, None, None) is None # nothing to take the aspect from True
- an.stage.rig.bone_extent_centre(bones)[source]#
The DEFAULT point in view_box space that the entity’s placement refers to, when the rig declares no
RigDocument.origin(rig_origin()).>>> bone_extent_centre({"root": (512.0, 980.0), "head": (512.0, 420.0)}) (512.0, 700.0)
The centre of the rig’s bone extent, not the root bone. The scene root positions a character on x only and leaves y at 0, so this point is what lands at the frame’s vertical centre — and a rig whose root is its ground contact (the default puts it at the feet, y=980) would therefore hang its whole body above the placement point, head off-frame.
Centring on the extent makes framing independent of where an author chose to put the root, which is a rigging decision and should not be a framing one. On the default rig it lands at y=700, within 20 units of the torso bone — i.e. it reproduces the convention the deleted
torso_y = 0.0literal encoded, without hardcoding a bone name.
- an.stage.rig.bone_positions(desc)[source]#
Absolute
(x, y)per bone, in view_box units.Bone transforms are parent-relative, so a bone’s position is the sum along its parent chain. A cycle or a dangling parent stops the walk rather than looping — a malformed rig is #78’s business, not this function’s.
- an.stage.rig.bones_carry_a_rest_pose(doc)[source]#
Whether any bone of a (raw or model) rig document has a non-zero
rotation_degor a non-unitscale_x/scale_y.- Return type:
>>> bones_carry_a_rest_pose({"bones": [{"name": "a"}, {"name": "b", "rotation_deg": 22}]}) True >>> bones_carry_a_rest_pose({"bones": [{"name": "a", "scale_x": 1.0}]}) False
- an.stage.rig.build_rig_subtree(entity, desc_data, *, textures, probe=None, resolutions=None, art_prefix='characters/', descriptor_model, document_kind, texture_srcs=None, digest=None, skip_slots=None)[source]#
Build the scene subtree for a rig (a prop or a character), from its descriptor.
Slots nest by
slot_parent_chain()(nesting: flatorbones, an#340). Inbonesmode a nested part is placed relative to its parent BONE, without inheriting the parent’s attachment offset, and its bone’s rest pose composes through the chain.skip_slotsare slots the GENRE says not to build (the cut-out genre’s baked face:cutan.characters.play.suppressed_slots); a skipped slot’s nested parts are not built either.Noneis the legacy rule for a genre that predates the argument (an.genres.API_LEVEL< 5): withface_overlayfalse, the slots nested under theheadbone’s primary slot. It is the one place the core still names a bone, and it goes when no genre needs it.A part may be SVG or raster (PNG/JPEG/WebP, an#211): the probe measures either, and
digest(src)— a content digest for raster art,Nonefor SVG — is appended to a raster texture’s alias so the texture is addressed by its bytes.Every swap key keeps its own geometry (an#211). A swap re-textures the sprite, and the box, anchor and offset were the DEFAULT attachment’s, so a key drawn on a different canvas was fitted into the wrong box — a closed mouth on a thin canvas squashed every open mouth to a fraction of a pixel. A key whose box, anchor or offset differs from the drawn attachment’s is listed in
VisualJSON.asset_geometryand the runtime applies it with the texture; a rig whose keys share a canvas emits nothing new.texture_srcsmaps a part path to thesrcits texture loads from instead of the stored file — a style pack’s recoloured art (_recoloured_texture_srcs()). Such a texture’s alias carries a digest of its content, so a different recolour is a different texture (the runtime’s loader ignores a re-added alias on hot reload, an#155). The part is still probed and sized from the stored file, whose geometry is the same.Every part’s position comes from a bone, every part’s extent from its own art, and both are scaled by one uniform factor. Nothing here is a module constant: the seven
_SVG_*_SIZEvalues and the four y-offset literals this replaced are gone, and guttingbones/slots/skins/view_boxnow changes the output — which it provably did not before (an#73).A slot whose art is not on disk is recorded in
resolutionsas a fallback, which makes it audible by default and fatal understrict_assets— the same treatment a missing character already got (an#33), now reaching inside the descriptor to the individual part (an#76). It is recorded rather than raised here because the decision belongs to one place, and that place is_raise_or_warn_on_asset_fallbacks().probe(src) -> (exists, size)answers whether a part’s art is on disk and what size it rasterises at. Existence decides whether a texture is declared at all. The sprite’s box is the attachment’s declaredwidth/heightwhen it has them — a declared size wins, with the art’s aspect kept (an.characters.schema.attachment_box(), an#220) — else the art’s own extent (a raster’s pixel count); failing both, the runtime’scontainfit draws the art at its natural shape — never stretched to a fabricated box.- Return type:
- an.stage.rig.chain_draw_order_problems(desc)[source]#
Where a nested chain asks the STAGE for a draw order it cannot give (an#340, an#403).
The stage sorts the items of each container (
chain_paint_order()), so a part nested under one drawn LATER is fine (a far arm behind its torso). What it cannot do is interleave two containers: a slot’s subtree is always painted together, so an unrelated part ordered between a chain’s members (a leg between the arms and the head) cannot be honoured. Refused by the stage compiler; the asset validators only warn. Nothing inflatnesting.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", ... bones=[NS(name="arm", parent=None), NS(name="hand", parent="arm"), NS(name="cord", parent=None)], ... slots=[NS(name="arm", bone="arm", draw_order=1), NS(name="cord", bone="cord", draw_order=2), ... NS(name="hand", bone="hand", draw_order=3)]) >>> chain_draw_order_problems(rig)[0].startswith("the stage paints each chain's parts together") True
- an.stage.rig.chain_paint_order(desc, *, built=None)[source]#
How each container of a nested chain must order what it holds (an#403).
The stage draws a container’s items in order: its slot’s own visual and its child slots’ containers (
Noneis the entity’s container, which holds the root slots and no visual of its own). Sorting those siblings (PixiJS’ssortableChildrenon azIndex) gives any order in which every slot’s SUBTREE is painted contiguously, a far arm behind the torso it nests under included. Each item spans the draw orders of its subtree; the items are sorted, stably, by that(lowest, highest)span, starting from the builder’s own order (natural_paint_order()), so equal draw orders keep their tree order and a chain already painted in declared order is not reordered.builtlimits it to the slots the builder drew.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", ... bones=[NS(name="torso", parent=None), NS(name="arm", parent="torso")], ... slots=[NS(name="torso", bone="torso", draw_order=2), NS(name="arm", bone="arm", draw_order=1)]) >>> chain_paint_order(rig) {None: ['torso'], 'torso': ['arm', 'torso']}
- an.stage.rig.chain_pose_problems(desc)[source]#
A rest pose the stage cannot apply in a nested chain (an#340).
A node exists only for a bone that carries a slot. A bone with NO slot between a part and the part it nests under has no node to rotate or scale, so its
rotation_deg/scale_*would be lost (itsx/yare kept: positions sum along the chain). Refused at compile, like the draw order; give the bone a slot or move its pose to the bone below it.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", slots=[NS(name="arm", bone="arm"), NS(name="hand", bone="hand")], ... bones=[NS(name="arm", parent=None), NS(name="elbow", parent="arm", rotation_deg=-70), ... NS(name="hand", parent="elbow")]) >>> chain_pose_problems(rig)[0].startswith("bone 'elbow' carries no slot") True
- an.stage.rig.declared_origin(desc)[source]#
The rig’s DECLARED origin as two floats, or
Nonewhen it declares none.descis a model or a raw document (a mapping, asan validatereads it); a descriptor model that predatesRigDocument, whereoriginis anextrakey (a list), is read the same way.>>> from types import SimpleNamespace as NS >>> declared_origin(NS(origin=[512, 1010])), declared_origin(NS()) ((512.0, 1010.0), None) >>> declared_origin({"origin": [0, 5]}) (0.0, 5.0)
- an.stage.rig.drawn_attachment(desc, skin, slot)[source]#
The
(name, attachment)a slot draws by default, orNone.- Return type:
tuple[str,Attachment] |None
- an.stage.rig.global_paint_order(desc, *, built=None)[source]#
The node paths of a rig’s slots, relative to its entity, in DECLARED draw order: the paint list the stage uses when a nested chain interleaves with an unrelated part (an#430), which no sort of a container’s own items can give (
chain_draw_order_problems()). Spine’s global slot order: each part follows its bone’s world transform, and the list says who paints over whom.builtlimits it to the slots the builder drew.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", ... bones=[NS(name="arm", parent=None), NS(name="hand", parent="arm"), NS(name="cord", parent=None)], ... slots=[NS(name="arm", bone="arm", draw_order=1), NS(name="cord", bone="cord", draw_order=2), ... NS(name="hand", bone="hand", draw_order=3)]) >>> global_paint_order(rig) ['arm', 'cord', 'arm/hand']
- an.stage.rig.legacy_rest_pose_unknown(raw, kind, *, since)[source]#
The builder guard (an#339): whether
rawmay be a pre-rest-pose document that its migration could not see.DocumentKind.version_ofreads a document with no version field as CURRENT, so the protective migration never runs on it. A document like that whose bones carry a pose, and which says nothing aboutrest_rotation, is built flat (as it would have been) and warned about. A version older thansinceis caught too, should a read skip the migration.- Return type:
>>> k = DocumentKind("Demo", "schema_version", "0.2.0") >>> legacy_rest_pose_unknown({"bones": [{"name": "a", "rotation_deg": 9}]}, k, since="0.2.0") True >>> legacy_rest_pose_unknown({"schema_version": "0.2.0", "bones": [{"name": "a", "rotation_deg": 9}]}, k, since="0.2.0") False
- an.stage.rig.natural_paint_order(order, keys)[source]#
What each container holds in the builder’s own order: the slot’s visual first, then its children by their own
(draw_order, name).- Return type:
- an.stage.rig.nesting_of(desc)[source]#
"flat"or"bones"(an#340); unset is flat.- Return type:
>>> nesting_of({"nesting": "bones"}), nesting_of({}) ('bones', 'flat')
- an.stage.rig.omit_unset_rig_fields(data)[source]#
Drop every unset
RigDocumentfield from a dumped document, in place.A subclass that declares its own
model_serializerREPLACES the base’s (pydantic keeps one per model), so it must call this on its output, or an unsetoriginreaches every stored descriptor asnull.- Return type:
>>> omit_unset_rig_fields({"name": "lamp", "origin": None}) {'name': 'lamp'} >>> omit_unset_rig_fields({"origin": [512.0, 1010.0]}) {'origin': [512.0, 1010.0]}
- an.stage.rig.part_probe(characters_store, *, art_prefix='characters/')[source]#
A probe answering
(art exists, the size it rasterises at)for a part.Two questions, deliberately not one. Whether the art is there decides whether the compiler declares a texture for it; whether it can be measured decides only whether the sprite’s box comes from the art or from the runtime’s fit. Collapsing them is a real bug and it was here: a degenerate
<svg/>is unmeasurable but present, and treating that as absent made the part vanish from the scene silently — trading an#79’s hang for exactly the invisible-art failure #76 exists to stop.Returns
Nonewhen the store has no filesystem root — not a probe that answers “absent” — because a store that can answer nothing must drop no parts rather than all of them.Size is read from the SVG root’s
width/height, falling back to the viewBox extent as a browser does — or, for PNG/JPEG/WebP art, from the image header (an#211): a header parse, not a render, either way. Before an#211 a PNG was parsed AS SVG here and the compile died on an XML error.
- an.stage.rig.primary_slot_per_bone(desc)[source]#
{bone name: the slot that IS that bone}, when one exists.The anchor of node nesting (
slot_parent_chain()). In the defaultflatnesting the rigs are flat — 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 own bone when it is not that slot itself, which puts eyes and mouth underheadand leaves every limb a direct child of the entity.nesting: bones(an#340) follows the bone hierarchy to the nearest ancestor’s primary slot instead.>>> from types import SimpleNamespace as NS >>> primary_slot_per_bone(NS(slots=[NS(name="head", bone="head"), NS(name="mouth", bone="head")]))["head"] 'head'
- an.stage.rig.protect_legacy_rest_pose(doc)[source]#
The migration step every rig kind runs onto the version that applies the rest pose (an#339).
Before it, a bone’s
rotation_degand scale reached no node, so a rig whose bones carry one was drawn with that pose in its pixels. Such a document getsrest_rotation: falseand keeps its picture; a document whose bones carry none gets nothing (and every document on the maintainer’s machine was of that kind when this shipped). Returnsdoc, edited in place; the caller sets the version.>>> protect_legacy_rest_pose({"bones": [{"name": "leg", "rotation_deg": -22}]})["rest_rotation"] False >>> "rest_rotation" in protect_legacy_rest_pose({"bones": [{"name": "root"}]}) False >>> protect_legacy_rest_pose({"bones": [{"name": "leg", "rotation_deg": 5}], "rest_rotation": True})["rest_rotation"] True
- an.stage.rig.raster_digest(store, *, art_prefix='characters/')[source]#
digest(src): a short content digest for RASTER art, elseNone.A raster texture is addressed by its bytes (an#211): the digest goes into the texture’s alias, so a re-carved part is a different texture — the runtime’s loader ignores a re-added alias on hot reload (an#155) — and a different compiled contract, whose hash then covers the pixels drawn. SVG art keeps its plain alias, which is what keeps every existing document byte-identical.
- an.stage.rig.register_rest_pose_migration(kind, from_version, to_version)[source]#
Register the protective rest-pose step for one rig kind (an#339).
One call from the module that owns the kind’s schema (
an.stage.propsforPropDescriptor,cutanforCharacterDescriptor): it registersprotect_legacy_rest_pose()as that kind’sfrom -> tomigration and recordstoas the version the builder applies the rest pose from.- Return type:
>>> register_rest_pose_migration("DemoRig", "1.0", "2.0") >>> REST_POSE_SINCE["DemoRig"] '2.0' >>> from an.ir.migrate import MIGRATIONS >>> MIGRATIONS[("DemoRig", "1.0", "2.0")]({"version": "1.0", "bones": []}) {'version': '1.0', 'bones': []}
- an.stage.rig.rest_pose_protection(raw, migrated, *, kind='rig document')[source]#
What to say when the MIGRATION, on this read, kept a rig’s pose unapplied (an#407).
The protective step writes
rest_rotation: falseonto a document from before the rest pose whose bones carry one, guessing the pose is already in its pixels. An author who added a rotation to such a file is the other case, and nothing else would tell them why the part did not move. A flag the AUTHOR wrote (present in the stored document) is a choice, and silent.>>> old = {"schema_version": "0.1.0", "bones": [{"name": "leg", "rotation_deg": 20}]} >>> rest_pose_protection(old, {**old, "rest_rotation": False}).startswith("its bones carry") True >>> rest_pose_protection({**old, "rest_rotation": False}, {**old, "rest_rotation": False}) is None True
- an.stage.rig.rest_transform(bone)[source]#
The node transform fields a bone’s rest pose sets (an#339): its
rotation_degin radians and its scales,-0.0normalised to0.0.Every rest reader composes on the built transform (
playdeviations, swap poses, the face solver, presets, a from-less tween, the runtime’s load), so this is the whole change. An authoredrotationtween stays ABSOLUTE:to: 0straightens a part whose rest is splayed.>>> rest_transform(Bone(name="leg", rotation_deg=-22))["rotation"] -0.3839724354387525 >>> rest_transform(Bone(name="leg", rotation_deg=-0.0)) {'rotation': 0.0, 'scale_x': 1.0, 'scale_y': 1.0}
- an.stage.rig.rig_affordances(desc)[source]#
What a rig’s structure affords, derived from the rig model (an#340).
rig.hierarchywhen the rig nests inbonesmode and some chain links two different bones:keysare every slot in such a chain (sorig.hierarchy:forearm_lasks for a forearm in a chain),countthe deepest chain’s number of BONES (rig.hierarchy>=3is “a shoulder, an elbow and a hand”),chainsevery root-to-leaf chain of slots. Parts on one bone (a head with its face) add no depth, and a flat rig affords nothing here: that nesting is a drawing convention, not a joint. The core registers it as thepropanalyser; a genre’s analyser composes it for its own kinds.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", bones=[NS(name="arm", parent=None), NS(name="hand", parent="arm")], ... slots=[NS(name="arm", bone="arm"), NS(name="hand", bone="hand"), NS(name="sword", bone="hand")]) >>> rig_affordances(rig) {'rig.hierarchy': {'keys': ['arm', 'hand', 'sword'], 'count': 2, 'chains': [['arm', 'hand', 'sword']]}}
- an.stage.rig.rig_origin(desc)[source]#
The point of the rig, in view_box units, that lands at the entity’s placement.
The ONE rule (an#338): the declared
RigDocument.originwhen the rig has one, else the centre of its bones’ extent (bone_extent_centre()).build_rig_subtree()places every part relative to it, and a genre that reports where a rig’s art reaches from its stage point (cutan’sstage_extent) reads it too, so the two agree by construction.>>> from types import SimpleNamespace as NS >>> bones = [NS(name="root", parent=None, x=512, y=980), NS(name="top", parent="root", x=0, y=-560)] >>> rig_origin(NS(bones=bones)) (512.0, 700.0) >>> rig_origin(NS(bones=bones, origin=(512, 980))) (512.0, 980.0)
- an.stage.rig.rig_origin_problems(desc)[source]#
What is wrong with a rig’s declared origin, as warnings (an#338).
Nothing when the rig declares none. A non-finite origin (possible only on a document read without
RigDocument, which refuses one) places the whole rig nowhere; one outside theview_boxis legal (a hanging sign’s hook can sit above its art) but is far more often a unit slip (scene pixels written where view_box units belong), so it is said, not refused.>>> from types import SimpleNamespace as NS >>> rig_origin_problems(NS(origin=(512, 1010), view_box=(0, 0, 1024, 1024))) [] >>> rig_origin_problems(NS(origin=(512, 2000), view_box=(0, 0, 1024, 1024)))[0].startswith("origin (512.0, 2000.0) lies outside") True >>> rig_origin_problems(NS(origin=(float("nan"), 0), view_box=(0, 0, 1024, 1024)))[0][:30] 'origin (nan, 0.0) is not finit'
- an.stage.rig.rig_problems(desc)[source]#
What is structurally wrong with a rig’s bones and slots (an#340).
A bone whose
parentnames no bone; a cycle in the bone graph (the one form a closed linkage can take in a model where each bone names one parent: forward kinematics only, so it is refused); a slot whosebonenames no bone (it used to land at the origin, silently). Shared by the asset validators (an character validate,an.stage.props.validate_prop()).>>> from types import SimpleNamespace as NS >>> rig_problems(NS(bones=[NS(name="a", parent="b"), NS(name="b", parent="a")], ... slots=[NS(name="s", bone="nope")])) ['bones form a cycle (a closed linkage): a -> b -> a; a rig is a tree (forward kinematics only)', "slot 's' is bound to bone 'nope', which the rig does not declare"]
- an.stage.rig.rig_rest_problems(desc)[source]#
Warnings about a rig’s rest pose (an#339), on a model or a raw document.
A part turns about its NODE’s origin, which is the bone plus the drawn attachment’s
x/yoffset. A bone with a rest rotation whose part is offset therefore turns that part about a point that is not the joint, the usual way a splayed leg ends up detached from its hip. The way to turn a part about its joint isx: 0, y: 0on the attachment and the art’sanchorat the joint. Nothing is said about a rig that keeps its legacy pose (rest_rotation: false), since its bones pose nothing.>>> doc = {"bones": [{"name": "leg", "rotation_deg": 20}], ... "slots": [{"name": "leg", "bone": "leg"}], ... "skins": {"default": {"slots": {"leg": {"leg": {"path": "p.svg", "y": 40}}}}}} >>> rig_rest_problems(doc)[0].startswith("bone 'leg' rests at 20") True >>> doc["skins"]["default"]["slots"]["leg"]["leg"]["y"] = 0 >>> rig_rest_problems(doc) []
- an.stage.rig.slot_node_paths(desc)[source]#
{slot: its node path relative to the entity}(torso/arm/hand), byslot_parent_chain(); a slot caught in a cycle maps to its own name.>>> from types import SimpleNamespace as NS >>> rig = NS(nesting="bones", bones=[NS(name="a", parent=None), NS(name="b", parent="a")], ... slots=[NS(name="a", bone="a"), NS(name="b", bone="b")]) >>> slot_node_paths(rig) {'a': 'a', 'b': 'a/b'}
- an.stage.rig.slot_parent_chain(desc)[source]#
{slot: the slot it nests under, or None}: THE nesting rule (an#340).The builder and a genre’s part paths (
cutan’splay.slot_parent,slot_node_path) all read it, so aplayor a preset addresses the node the builder made.flat(unset): a slot nests under its own bone’s primary slot (the slot named like the bone) when it is not that slot; everything else is a child of the entity. Arms are siblings of the torso.bones: the same, and a slot that IS its bone’s primary (or whose bone has none) nests under the primary slot of the nearest ANCESTOR bone that has one, to any depth. A bone cycle stops the walk;rig_problemsnames it and the builder refuses it.
>>> from types import SimpleNamespace as NS >>> rig = NS(bones=[NS(name="arm", parent="torso"), NS(name="hand", parent="arm"), ... NS(name="torso", parent=None)], ... slots=[NS(name="torso", bone="torso"), NS(name="arm", bone="arm"), ... NS(name="hand", bone="hand"), NS(name="sword", bone="hand")]) >>> slot_parent_chain(rig) {'torso': None, 'arm': None, 'hand': None, 'sword': 'hand'} >>> rig.nesting = "bones" >>> slot_parent_chain(rig) {'torso': None, 'arm': 'torso', 'hand': 'arm', 'sword': 'hand'}