Add your own character#
A character in Scenet is not a picture. It is a skeleton plus parametric limbs, so a pose is a set of joint angles rather than a drawing — which avoids the combinatorial explosion of one image per pose per expression per facing direction.
That skeleton is declared in a *.puppet.yaml file. This page walks through one.
The file#
name: alice
units_per_head: 100
root: root
root_landmark: waist
landmarks:
head_top: 0
eyes: 40
chin: 100
shoulders: 132
chest: 200
waist: 330
mid_thigh: 470
knees: 560
feet: 750
joints:
root: {parent: null, offset: [0, 0]}
spine: {parent: root, offset: [0, -130]}
neck: {parent: spine, offset: [0, -68]}
head: {parent: neck, offset: [0, -82]}
shoulder_l: {parent: neck, offset: [-34, 6]}
elbow_l: {parent: shoulder_l, offset: [0, 95]}
wrist_l: {parent: elbow_l, offset: [0, 88]}
# ...and the mirror image on the right, plus hips, knees and ankles.
parts:
- {from: neck, to: root, width: 72}
- {from: shoulder_l, to: elbow_l, width: 26}
- {at: head, radius: 50}
anchors:
head_top: {joint: head, offset: [0, -50]}
eyes: {joint: head, offset: [0, -10]}
mouth: {joint: head, offset: [0, 25]}
feet: {joint: root, offset: [0, 420]}
face:
joint: head
radius: 62
gaze:
origin: eyes
poses:
standing_neutral: {elbow_l: -5, elbow_r: 5}
arms_crossed: {elbow_l: -25, wrist_l: -75, elbow_r: 25, wrist_r: 75}
Section by section#
units_per_head sets the scale. Everything else is in native units where one head is
100, and the camera converts between those and panel units. Height in head-heights is
derived, not declared:
from scenet import default_library
alice = default_library().get("alice")
assert alice.total_height == 750.0
assert alice.heads_tall == 7.5
landmarks are the crop lines a shot type names, measured downward from the top of the
head. All nine are required — not optional-with-defaults — because any shot type may crop
at any of them, so a puppet missing one is a puppet that cannot be framed at some
perfectly ordinary shot. head_top must be 0, and the rest must increase downward.
joints form a tree. A joint’s pose angle rotates the bone arriving at it and
everything below it, which is the formulation that makes posing read naturally: bending
elbow_l swings the upper arm and carries the forearm and hand with it.
parts are what actually gets drawn — a capsule between two joints, or a blob at one.
This is the only place appearance enters, and the solver never looks at it.
anchors are named attachment points. Two are load-bearing: mouth, where a balloon
tail aims, and eyes, where a gaze starts. Add whatever else you find useful.
face is the exclusion zone no balloon may cover. A head is not a circle, but for
layout purposes it is close enough and vastly cheaper — every “would this cover someone’s
face?” test becomes one distance comparison.
poses are named sets of joint angles. Joints you leave out keep their rest angle.
Loading it#
from pathlib import Path
from scenet import PuppetLibrary, compile_source, default_library, load_puppet
from scenet.assets.contract import DEFAULT_LIBRARY_PATH
# One file at a time...
alice = load_puppet(DEFAULT_LIBRARY_PATH / "alice.puppet.yaml")
assert alice.name == "alice"
# ...or a whole directory.
library = PuppetLibrary.from_directory(DEFAULT_LIBRARY_PATH)
assert library.names() == ("alice", "bob")
result = compile_source("cast: {a: {reference: alice}}", library=library)
assert result.core.actors[0].reference == "alice"
Your own directory works exactly the same way — point
from_directory at it. The
files must be named *.puppet.yaml, and the name: inside each one, not the filename, is
what a cast member’s reference matches.
Validation catches the mistakes you will actually make#
Puppet files are validated exhaustively at load time, because a puppet that is subtly wrong produces a figure that is subtly wrong with nothing to point at:
Mistake |
Caught as |
|---|---|
A landmark missing |
“is missing landmarks […]” |
Landmarks out of order |
“must increase downward from head_top to feet” |
|
“head_top must be 0, it is the origin” |
Root joint has a parent |
“root joint must have no parent” |
A joint naming a parent that does not exist |
“names unknown parent” |
A joint cycle |
“sits in a cycle” |
A part, anchor or pose naming a joint that does not exist |
“references unknown joint” |
|
“is not a declared anchor” |
Two files declaring the same |
“duplicate puppet name” |
The cycle and orphan checks are not pedantry. Forward kinematics accumulates each joint’s transform from its parent’s, so a cycle would not terminate and an orphan would leave a limb with no defined position.
import pydantic
import pytest
from scenet import PuppetSpec
with pytest.raises(pydantic.ValidationError, match="missing landmarks"):
PuppetSpec.model_validate(
{
"name": "incomplete",
"units_per_head": 100,
"landmarks": {"head_top": 0, "feet": 700},
"joints": {"root": {"parent": None, "offset": [0, 0]}},
"face": {"joint": "root", "radius": 10},
}
)
Make them different from each other#
The two shipped puppets have deliberately different proportions — 7.5 heads and taller. That is a testing decision as much as an artistic one: a bug in camera scaling cannot hide behind two figures that happen to be the same height, and neither can a bug in ground-sharing, which aligns feet rather than heads.
If you add characters, make them differ. Identical puppets are a blindfold.
What is not modelled#
No faces beyond a circle, no hands beyond a blob, no clothing, no expressions, no props. The asset contract is the geometric contract — enough for the solver to place things and for the emitter to draw a legible wireframe. Real artwork would slot in behind the same contract without the solver noticing, which is the reason the boundary is drawn where it is.