Solving#

Framing, placement, lettering and balloons. These modules are internal – they are documented because the decisions in them are the interesting part of the project, not because their signatures are promised to stay put.

scenet.solve.camera#

.. py:module:: scenet.solve.camera

Camera framing: shot type into scale and vertical placement.

The central rule, and the one most easily got wrong: a shot type names where the frame cuts the body, not what fraction of the panel a figure fills. Encoding the fraction instead bakes in one body and one pose, so a child and an adult would come out the same height. See docs/reference/shot_types.md, which is normative.

The second rule: one camera, one scale. A camera has a single focal length, so every actor at the same distance is scaled identically and a taller character is taller in frame. Scaling each actor to fit its own crop would silently erase height differences, which is precisely what a comic uses to characterise people.

.. py:class:: ShotSpec

module:

scenet.solve.camera

Bases: :py:class:object

A crop landmark and the empty space left above the head.

.. py:attribute:: ShotSpec.crop

module:

scenet.solve.camera

type:

~scenet.assets.contract.Landmark

.. py:attribute:: ShotSpec.headroom

module:

scenet.solve.camera

type:

float

.. py:method:: ShotSpec.init(crop, headroom)

module:

scenet.solve.camera

.. py:class:: CameraSolution

module:

scenet.solve.camera

Bases: :py:class:object

The camera’s verdict for a panel: one scale, shared by every actor.

.. py:attribute:: CameraSolution.scale

module:

scenet.solve.camera

type:

float

.. py:attribute:: CameraSolution.headroom

module:

scenet.solve.camera

type:

float

.. py:attribute:: CameraSolution.footroom

module:

scenet.solve.camera

type:

float

.. py:attribute:: CameraSolution.panel_height

module:

scenet.solve.camera

type:

float

.. py:attribute:: CameraSolution.shot

module:

scenet.solve.camera

type:

~scenet.ir.ShotType

.. py:attribute:: CameraSolution.reference

module:

scenet.solve.camera

type:

str

.. py:attribute:: CameraSolution.pullback

module:

scenet.solve.camera

type:

float

.. py:property:: CameraSolution.was_pulled_back

module:

scenet.solve.camera

type:

bool

Whether the camera had to retreat from the requested framing.

Surfaced to the user through

attr:

CompileResult.notes <scenet.pipeline.CompileResult.notes>. Retreating silently would leave a panel that quietly is not the shot that was asked for.

.. py:method:: CameraSolution.pulled_back_to(scale)

module:

scenet.solve.camera

Retreat the camera until the cast fits across the frame.

A shot type is a statement about vertical framing – where the frame cuts the body. When several actors cannot fit side by side at that scale, a real camera moves back: everyone gets smaller and more of the body comes into view. So the requested shot behaves as an upper bound on tightness rather than an exact contract, and the amount of retreat is recorded here so the result stays inspectable rather than mysterious.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.camera.CameraSolution``

.. py:property:: CameraSolution.head_top_y

module:

scenet.solve.camera

type:

float

Where the reference actor’s head-top lands.

.. py:method:: CameraSolution.root_y_framed(puppet)

module:

scenet.solve.camera

Place this puppet by its own head, as if framed alone.

Used for actors that share no ground line with anyone: each is composed independently within the frame.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

.. py:method:: CameraSolution.root_y_on_ground(puppet, ground_y)

module:

scenet.solve.camera

Place this puppet so its feet meet a given ground line.

This is what makes two characters of different heights stand together convincingly: feet align, heads do not.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

.. py:method:: CameraSolution.feet_below_root(puppet)

module:

scenet.solve.camera

How far below the root joint this puppet’s feet sit, at this camera scale.

type puppet:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.assets.contract.PuppetSpec``

param puppet:

The character being placed.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

returns:

Distance in panel units.

.. py:method:: CameraSolution.ground_y_of(puppet, root_y)

module:

scenet.solve.camera

The ground line a puppet stands on, given where its root joint is.

The inverse of

meth:

root_y_on_ground <scenet.solve.camera.CameraSolution.root_y_on_ground>, and how ground_shared_with gets its target: take one actor’s ground line, then place the other so their feet meet it.

type puppet:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.assets.contract.PuppetSpec``

param puppet:

The character.

type root_y:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

param root_y:

Where its root joint sits vertically.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

returns:

The y coordinate of its feet.

.. py:method:: CameraSolution.init(scale, headroom, footroom, panel_height, shot, reference, pullback=1.0)

module:

scenet.solve.camera

.. py:function:: headroom_for(shot, angle)

module:

scenet.solve.camera

Empty space to leave above the head, as a fraction of panel height.

A shot type has two halves and they use different units. The crop landmark is anatomical – the waist, the chest, the shoulders – which is what stops a shot type baking in one body and one pose. The headroom is a plain fraction of panel height, because it is about composition within the frame rather than about anatomy. See docs/reference/shot_types.md, which is normative.

Angle changes headroom rather than perspective. This compiler is orthographic, so a tilted camera cannot foreshorten anything – but the amount of air above the head is the compositional cue readers actually take from an angle, and it is one that survives being drawn flat.

A low camera looks up and the subject looms, so headroom tightens; a high camera looks down and it opens out.

type shot:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.ir.ShotType``

param shot:

The requested framing.

type angle:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.ir.CameraAngle``

param angle:

The camera height.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

returns:

Headroom as a fraction of panel height, never below MINIMUM_ANGLE_HEADROOM for a tilted camera – so that extreme_close_up, whose base headroom is zero, still shifts under an angle instead of staying flush against the top edge.

.. admonition:: Example

from scenet import CameraAngle, ShotType from scenet.solve.camera import headroom_for [ … headroom_for(ShotType.MEDIUM_SHOT, angle) … for angle in (CameraAngle.LOW, CameraAngle.EYE_LEVEL, CameraAngle.HIGH) … ] [0.05, 0.1, 0.16000000000000003]

.. py:function:: visible_height(puppet, shot)

module:

scenet.solve.camera

Native height of the portion of the body the frame will show.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

.. py:function:: solve_camera(reference, *, shot, angle, panel_height, footroom=0.0)

module:

scenet.solve.camera

Resolve the camera against a reference actor.

Everything else in the panel inherits this scale. The reference is the actor the shot is composed on – by convention the first in the cast, which is the one the author wrote first and therefore the one the panel is about.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.camera.CameraSolution``

scenet.solve.staging#

.. py:module:: scenet.solve.staging

Horizontal placement, vertical grounding and draw order.

This is where Cassowary earns its place. The arithmetic is trivial – centring a figure on a third is one division – but the conflicts are not. Two actors both asked to stand centre must be pushed apart; a crowded panel must let figures bleed off the edge rather than overlap. Expressed as priorities, that resolves itself. Written by hand it becomes an ever-growing cascade of special cases.

Priorities used:

 required  actors never overlap, and keep their declared left-to-right order
 strong    actors stay inside the panel
 weak      actors sit on their requested anchor

Bounds are deliberately strong rather than required. Letting a figure bleed past the panel edge is ordinary comics practice, and far better than refusing to compile a crowded panel.

.. py:class:: Placement

module:

scenet.solve.staging

Bases: :py:class:object

Where one actor’s root joint ends up, and how it is drawn.

.. py:attribute:: Placement.actor_id

module:

scenet.solve.staging

type:

str

.. py:attribute:: Placement.reference

module:

scenet.solve.staging

type:

str

.. py:attribute:: Placement.pose

module:

scenet.solve.staging

type:

str

.. py:attribute:: Placement.x

module:

scenet.solve.staging

type:

float

.. py:attribute:: Placement.y

module:

scenet.solve.staging

type:

float

.. py:attribute:: Placement.scale

module:

scenet.solve.staging

type:

float

.. py:attribute:: Placement.facing_right

module:

scenet.solve.staging

type:

bool

.. py:attribute:: Placement.depth

module:

scenet.solve.staging

type:

int

.. py:property:: Placement.origin

module:

scenet.solve.staging

type:

~scenet.geom.Point

Where this actor’s root joint lands, as a point.

.. py:method:: Placement.init(actor_id, reference, pose, x, y, scale, facing_right, depth)

module:

scenet.solve.staging

.. py:function:: horizontal_order(panel)

module:

scenet.solve.staging

A total left-to-right order over the cast.

Declared left_of relations are honoured; everything else is broken by anchor position and then by actor id. The tiebreak matters more than it looks: the solver needs a total order to write non-overlap constraints against, and it must be the same total order on every run or the output stops being deterministic.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\tuple`\ \[:py:class:`str`, :py:data:`…<Ellipsis>`]`

.. py:function:: depth_order(panel)

module:

scenet.solve.staging

Painter’s order from in_front_of / behind relations.

Depth is the longest chain of actors behind a given one, so anything not mentioned stays at zero and the common case adds no noise to the output.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\dict`\ \[:py:class:`str`, :py:class:`int`]`

.. py:function:: solve_staging(panel, library, camera=None)

module:

scenet.solve.staging

Resolve every actor’s position, scale, facing and draw order.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\tuple`\ \[:py:class:`tuple`\ \[:py:class:`~scenet.solve.staging.Placement`, :py:data:`…<Ellipsis>`], :py:class:`~scenet.solve.camera.CameraSolution`]`

scenet.solve.text#

.. py:module:: scenet.solve.text

Text measurement and line breaking.

Nothing downstream can proceed without this. A balloon’s size is a function of its text, wrapped at some measure, in a specific font – and placement, occlusion and reading order all depend on that size. So wrapping is decided here, during compilation, and the result is carried through Panel Core as explicit lines. The emitter never re-measures and therefore can never disagree with the solver.

Determinism demands the font be fixed. It arrives as a declared dependency rather than a system lookup precisely because “whatever font this machine happens to have” is the opposite of reproducible.

.. py:class:: TextBlock

module:

scenet.solve.text

Bases: :py:class:object

Wrapped text, measured.

.. py:attribute:: TextBlock.lines

module:

scenet.solve.text

type:

tuple[str, …]

.. py:attribute:: TextBlock.width

module:

scenet.solve.text

type:

float

.. py:attribute:: TextBlock.height

module:

scenet.solve.text

type:

float

.. py:attribute:: TextBlock.font_size

module:

scenet.solve.text

type:

float

.. py:attribute:: TextBlock.line_height

module:

scenet.solve.text

type:

float

.. py:attribute:: TextBlock.line_widths

module:

scenet.solve.text

type:

tuple[float, …]

.. py:property:: TextBlock.aspect

module:

scenet.solve.text

type:

float

Width divided by height, or 0.0 for an empty block.

The quantity the line breaker optimises. Lettering convention wants a balloon wider than it is tall – see TARGET_ASPECT.

.. py:property:: TextBlock.raggedness

module:

scenet.solve.text

type:

float

How unbalanced the lines are, from 0 (equal) to nearly 1 (one line tiny).

.. py:method:: TextBlock.init(lines, width, height, font_size, line_height, line_widths=())

module:

scenet.solve.text

.. py:class:: FontMetrics

module:

scenet.solve.text

Bases: :py:class:object

Advance widths read straight from the font’s own tables.

Kerning is deliberately ignored. Reading kern/GPOS would tighten measurement slightly, but SVG renderers do not agree on whether to apply it, and a measurement the renderer will not reproduce is worse than a slightly generous one. Erring wide means balloons are never too small for their text.

.. py:method:: FontMetrics.init(path=PosixPath(‘/home/runner/work/scenet/scenet/.venv/lib/python3.14/site-packages/font_source_sans_pro/files/SourceSansPro-Regular.ttf’))

module:

scenet.solve.text

Open a font and read the tables needed for measurement.

type path:

:sphinx_autodoc_typehints_type:\:py\:class\:\~pathlib.Path``

param path:

A TrueType or OpenType file. Defaults to the font that ships as an ordinary dependency of this package – never a system font lookup, because determinism requires the same metrics everywhere.

raises ValueError:

The font has no usable Unicode character map, so no text could be measured against it at all.

.. py:property:: FontMetrics.units_per_em

module:

scenet.solve.text

type:

float

The font’s design grid size, from its head table.

.. py:method:: FontMetrics.advance(character)

module:

scenet.solve.text

Advance width of one character, in em units.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

.. py:method:: FontMetrics.measure(text, font_size)

module:

scenet.solve.text

Width of a string set at a given size.

type text:
sphinx_autodoc_typehints_type:

\:py\:class\:\str``

param text:

The string to measure. Not wrapped; measured as one run.

type font_size:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

param font_size:

Type size in panel units.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

returns:

Width in panel units. Slightly generous, since kerning is ignored – which errs toward balloons a shade too large rather than text that overflows.

.. admonition:: Example

from scenet.solve.text import load_metrics metrics = load_metrics() metrics.measure(“mm”, 100) > metrics.measure(“ii”, 100) True

.. py:method:: FontMetrics.line_height(font_size)

module:

scenet.solve.text

Baseline-to-baseline distance for a given type size.

type font_size:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

param font_size:

Type size in panel units.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\float``

returns:

font_size * LINE_HEIGHT_FACTOR. A fixed multiple rather than the font’s own ascent-plus-descent, because comics lettering is set to a chosen leading rather than to whatever the typeface suggests.

.. py:method:: FontMetrics.glyph_outlines(text)

module:

scenet.solve.text

Each character’s outline as SVG path data, with its advance in em units.

Converting lettering to outlines rather than emitting <text> is what makes the output genuinely self-contained: no font to embed, no font to be missing, and the rendered shapes are by construction the ones that were measured. The cost is that the text is no longer selectable, which is why --live-text exists.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\list`\ \[:py:class:`tuple`\ \[:py:class:`str`, :py:class:`float`]]`

.. py:function:: load_metrics(path=None)

module:

scenet.solve.text

Cached metrics. Parsing a 300 KB font per balloon would be absurd.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.text.FontMetrics``

.. py:function:: wrap_to_width(text, metrics, font_size, measure)

module:

scenet.solve.text

Greedy word wrap at a given measure.

Greedy rather than Knuth-Plass: balloons hold a handful of words, where the optimal-fit algorithm’s advantage vanishes, and greedy is trivially deterministic.

A single word longer than the measure is left to overflow rather than being hyphenated or broken. Breaking a word mid-way in comic lettering looks like a mistake, and the balloon widening to fit is the correct outcome.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\list`\ \[:py:class:`str`]`

.. py:function:: candidate_measures(words, metrics, font_size)

module:

scenet.solve.text

Every line measure at which the wrapping can change.

A line is always some contiguous run of words, so the widths of all such runs are exactly the measures worth trying. Anything between two of them produces the same break points as the lower one.

The obvious shortcut – dividing total width by the desired line count – looks equivalent and is not. For “You forgot your umbrella!” it never proposes the measure that fits “You forgot your”, so the good two-line break is unreachable and the search settles for a ragged three-line block instead. Balloons hold a few dozen words at most, so enumerating runs costs nothing.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\list`\ \[:py:class:`float`]`

.. py:function:: layout_text(text, *, font_size, metrics=None, target_aspect=2.0)

module:

scenet.solve.text

Wrap text into the best-shaped block for a balloon.

Scored on how close the block comes to the target aspect ratio, plus a penalty per line. The penalty is what stops a three-word phrase being split: purely on aspect, breaking “I know.” into two lines scores marginally better than leaving it alone, which is not something any letterer would do.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.text.TextBlock``

.. py:function:: balloon_size(block, padding_factor=0.55)

module:

scenet.solve.text

Outer dimensions of a balloon holding this text block.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\tuple`\ \[:py:class:`float`, :py:class:`float`]`

scenet.solve.balloons#

.. py:module:: scenet.solve.balloons

Balloon placement and tail routing.

Placement is a scored search over candidate positions rather than a sweep of a cost grid. Balloons belong in a small number of sensible places relative to their speaker – around the head, or tucked into a corner – so generating those directly is both cheaper than scanning cells and produces more natural results. This is the approach used in the cartographic label-placement literature, which is the same problem.

The constraint that naive implementations miss is reading order. A balloon may never sit above-and-left of the one before it in the script, because that makes the panel read in the wrong order. That is a correctness bug in a comic, not a cosmetic one, so it is enforced as a hard filter rather than scored.

.. py:class:: TailRoute

module:

scenet.solve.balloons

Bases: :py:class:object

The pointer from balloon to mouth.

A straight tail is correct almost always. control is set only when the direct route was obstructed and the tail had to bend around something.

.. py:attribute:: TailRoute.start

module:

scenet.solve.balloons

type:

~scenet.geom.Point

.. py:attribute:: TailRoute.end

module:

scenet.solve.balloons

type:

~scenet.geom.Point

.. py:attribute:: TailRoute.control

module:

scenet.solve.balloons

type:

~scenet.geom.Point | None

.. py:property:: TailRoute.is_curved

module:

scenet.solve.balloons

type:

bool

Whether this tail had to bend around a face.

Reported in :attr:CompileResult.notes <scenet.pipeline.CompileResult.notes>, since a curved tail is a sign the panel is crowded enough to be worth a second look.

.. py:method:: TailRoute.init(start, end, control=None)

module:

scenet.solve.balloons

.. py:class:: PlacedBalloon

module:

scenet.solve.balloons

Bases: :py:class:object

One balloon after placement, before it is reduced to a Core document.

.. attribute:: id

Stable identifier, b0, b1, … in script order.

.. attribute:: speaker

Actor id of whoever is talking.

.. attribute:: order

Position in reading order, counting from zero.

.. attribute:: kind

Which sort of balloon to draw.

.. attribute:: box

Where it ended up.

.. attribute:: block

The text, already broken into lines and measured.

.. attribute:: tail

The route from balloon to mouth.

.. py:attribute:: PlacedBalloon.id

module:

scenet.solve.balloons

type:

str

.. py:attribute:: PlacedBalloon.speaker

module:

scenet.solve.balloons

type:

str

.. py:attribute:: PlacedBalloon.order

module:

scenet.solve.balloons

type:

int

.. py:attribute:: PlacedBalloon.kind

module:

scenet.solve.balloons

type:

~scenet.ir.BalloonKind

.. py:attribute:: PlacedBalloon.box

module:

scenet.solve.balloons

type:

~scenet.geom.BBox

.. py:attribute:: PlacedBalloon.block

module:

scenet.solve.balloons

type:

~scenet.solve.text.TextBlock

.. py:attribute:: PlacedBalloon.tail

module:

scenet.solve.balloons

type:

~scenet.solve.balloons.TailRoute

.. py:method:: PlacedBalloon.init(id, speaker, order, kind, box, block, tail)

module:

scenet.solve.balloons

.. py:function:: route_tail(balloon, mouth, obstacles, speaker_face=None)

module:

scenet.solve.balloons

Route a tail from the balloon toward the speaker’s mouth.

Straight is right almost always, so it is tried first. Only when the direct line crosses another character’s face does the tail bend, and then via a single control point chosen from a handful of lateral offsets.

Grid pathfinding is deliberately not used here. A tail is a short tapered stroke, and A-star produces a jointed path that looks nothing like one drawn by hand.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.balloons.TailRoute``

.. py:function:: place_balloons(events, actors, panel, *, metrics=None, font_size=None)

module:

scenet.solve.balloons

Place every balloon in script order.

Greedy rather than jointly optimised: each balloon is placed against those already down, which is exactly how reading order works – a balloon constrains its successor, never its predecessor. That makes the greedy pass the natural formulation rather than a compromise.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\tuple`\ \[:py:class:`~scenet.solve.balloons.PlacedBalloon`, :py:data:`…<Ellipsis>`]`