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 and below the feet.

.. py:attribute:: ShotSpec.crop

module:

scenet.solve.camera

type:

~scenet.assets.contract.Landmark

.. py:attribute:: ShotSpec.headroom

module:

scenet.solve.camera

type:

float

.. py:attribute:: ShotSpec.footroom

module:

scenet.solve.camera

type:

float

.. py:method:: ShotSpec.init(crop, headroom, footroom=0.0)

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=None)

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.expression

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, expression, 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.backdrop#

.. py:module:: scenet.solve.backdrop

Resolving a setting into tonal masses: geometry, value, and draw order.

The solver still never sees artwork. A backdrop reaches it as the same kind of geometric contract everything else does – polygons, a value, an integer depth – and the emitter is left with nothing to decide.

Why masses rather than drawn geometry#

Crisp architecture needs a vanishing point, and this is deliberately a flat, orthographic compiler, so drawn buildings would fight the compiler’s own model. Soft tonal masses have no perspective to get wrong. That is the structural argument; the other is that this is simply how comics establish place.

Notan – the Japanese light/dark mass principle, which entered Western art teaching through Arthur Wesley Dow’s Composition (1899) – holds that place is read from the arrangement of masses rather than from rendered detail. Layered silhouette depth gives the arrangement: foreground near-black, each receding plane paler. And aerial perspective supplies the parametric rule for free – with distance, value contrast drops toward the atmosphere colour. Three numbers per plane, monotonic in depth. That is notation, not interpretation, which is what makes it belong in a compiler.

Shape grammars, as a formalism rather than a library#

Silhouette profiles are generated with the split/repeat/subdivide operations of CGA shape grammar (Mueller et al., Procedural Modeling of Buildings, SIGGRAPH 2006). It is reused as a formalism: there is no open-source Python implementation of CGA, and the reference one is commercial, inside Esri CityEngine. See docs/explanation/prior_art.md.

The discipline that comes with it is worth stating: how many of a thing there are is derived from the geometry – a wider span gets more bays – and only how big each one is comes from the seed. Random counts make a backdrop flicker between panels that ought to look related.

Determinism#

Every profile is seeded from the declared content and the panel size, through blake2b – never a clock, never hash(), which is salted per process and would agree with itself all day while disagreeing with tomorrow’s build. Every profile is also generated inside the frame by construction, so there is nothing to clip; shapely stays where it earns its place, in the balloon occlusion cost.

.. py:data:: ATMOSPHERE

module:

scenet.solve.backdrop

value:

4

the atmosphere itself.

type:

The rung beyond the farthest plane

.. py:data:: FALL_CONTRAST_THRESHOLD

module:

scenet.solve.backdrop

value:

0.5

Above this lightness – of the sky as the cloud leaves it, not of the bare sky – rain is drawn in ink rather than in the paper colour. Inkers flip the same way and for the same reason: a white streak over a noon sky is invisible and a black one over midnight is too. The choice is made here rather than in the emitter, because it is a decision about the panel.

Snow does not flip. Snow is white – a convention rather than a value on the depth ladder, and a black snowflake is not a thing any comic has ever drawn. The overcast veil above is what gives it something to read against.

.. py:data:: FOREGROUND_SPAN_KEEP

module:

scenet.solve.backdrop

value:

0.6

How much of its declared span a foreground mass keeps, held against its outer edge.

A foreground mass is a repoussoir – the doorway or the wall you are standing behind, which frames the panel. left reaches past the middle of the panel, and a foreground mass that wide stops framing the composition and starts burying it: the first contact sheet had alley hiding everything of the figure above the knees. A full-width foreground is left alone, because asking for one is asking for a silhouette.

.. py:data:: GROUND_START

module:

scenet.solve.backdrop

type:

dict[~scenet.ir.Plane, float]

value:

{Plane.FAR: 0.0, Plane.FOREGROUND: 0.68, Plane.MID: 0.16, Plane.NEAR: 0.4}

Where a ground-like mass begins below the horizon, as a fraction of the distance from the horizon to the bottom edge.

Ground, floor and water all run to the bottom edge, so a nearer one drawn from the horizon would bury every plane behind it – the near quayside would simply erase the water. Starting each one lower leaves the planes behind it showing as bands, and that stack of receding bands is the depth cue.

The exception is the farthest ground-like mass in a panel, which meets the horizon itself: there is nothing behind it to reveal, and a strip of bare paper along the horizon reads as a mistake. See _ground_start.

.. py:data:: PLANE_SCALE

module:

scenet.solve.backdrop

type:

dict[~scenet.ir.Plane, float]

value:

{Plane.FAR: 0.55, Plane.FOREGROUND: 1.45, Plane.MID: 0.8, Plane.NEAR: 1.0}

How much larger the same mass is drawn nearer the reader. Size perspective alongside aerial perspective: a near hill is not merely darker than a far one, it is bigger.

.. py:data:: TONE_INDEX

module:

scenet.solve.backdrop

type:

dict[~scenet.ir.Plane, int]

value:

{Plane.FAR: 3, Plane.FOREGROUND: 0, Plane.MID: 2, Plane.NEAR: 1}

Which rung of the ladder each plane takes. Front to back, so the foreground is the darkest and the far plane the palest of the four.

.. py:class:: ResolvedAtmosphere

module:

scenet.solve.backdrop

Bases: :py:class:object

What the air is doing, resolved.

.. attribute:: time

When the panel happens.

.. attribute:: weather

What is falling, if anything.

.. attribute:: tone

The atmosphere’s own value at this hour.

.. attribute:: veil

The turbulence layer – fog, or cloud for rain and snow.

.. attribute:: streaks

Rain, as (start, end) pairs all at one angle.

.. attribute:: flecks

Snow, as discs.

.. attribute:: streak_width

Stroke width for a streak, in panel units.

.. attribute:: fall_tone

What rain and snow are drawn in, chosen against the atmosphere so they stay visible at every hour.

.. py:attribute:: ResolvedAtmosphere.time

module:

scenet.solve.backdrop

type:

~scenet.ir.TimeOfDay

.. py:attribute:: ResolvedAtmosphere.weather

module:

scenet.solve.backdrop

type:

~scenet.ir.Weather

.. py:attribute:: ResolvedAtmosphere.tone

module:

scenet.solve.backdrop

type:

str

.. py:attribute:: ResolvedAtmosphere.veil

module:

scenet.solve.backdrop

type:

~scenet.solve.backdrop.ResolvedVeil | None

.. py:attribute:: ResolvedAtmosphere.streaks

module:

scenet.solve.backdrop

type:

tuple[tuple[~scenet.geom.Point, ~scenet.geom.Point], …]

.. py:attribute:: ResolvedAtmosphere.flecks

module:

scenet.solve.backdrop

type:

tuple[~scenet.geom.Circle, …]

.. py:attribute:: ResolvedAtmosphere.streak_width

module:

scenet.solve.backdrop

type:

float

.. py:attribute:: ResolvedAtmosphere.fall_tone

module:

scenet.solve.backdrop

type:

str

.. py:method:: ResolvedAtmosphere.init(time, weather, tone, veil=None, streaks=(), flecks=(), streak_width=0.0, fall_tone=’’)

module:

scenet.solve.backdrop

.. py:class:: ResolvedBackdrop

module:

scenet.solve.backdrop

Bases: :py:class:object

Everything behind, around and in front of the cast.

.. attribute:: horizon

Where the ground meets what is behind it, in panel units.

.. attribute:: masses

The tonal masses, back to front.

.. attribute:: atmosphere

The air, or None when the weather is clear.

.. attribute:: seed

What every profile in here was generated from.

.. py:attribute:: ResolvedBackdrop.horizon

module:

scenet.solve.backdrop

type:

float

.. py:attribute:: ResolvedBackdrop.seed

module:

scenet.solve.backdrop

type:

int

.. py:attribute:: ResolvedBackdrop.masses

module:

scenet.solve.backdrop

type:

tuple[~scenet.solve.backdrop.ResolvedMass, …]

.. py:attribute:: ResolvedBackdrop.atmosphere

module:

scenet.solve.backdrop

type:

~scenet.solve.backdrop.ResolvedAtmosphere | None

.. py:method:: ResolvedBackdrop.occluders()

module:

scenet.solve.backdrop

The masses a balloon should prefer not to sit on, with their planes.

Masses are not exclusions. Balloons sit over backgrounds routinely; that is the point of having a background. This feeds a soft cost instead.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\tuple`\ \[:py:class:`tuple`\ \[:py:class:`tuple`\ \[:py:class:`~scenet.geom.Point`, :py:data:`…<Ellipsis>`], :py:class:`~scenet.ir.Plane`], :py:data:`…<Ellipsis>`]`

.. py:method:: ResolvedBackdrop.init(horizon, seed, masses=(), atmosphere=None)

module:

scenet.solve.backdrop

.. py:class:: ResolvedMass

module:

scenet.solve.backdrop

Bases: :py:class:object

One tonal mass, resolved to a numeric polygon.

.. attribute:: id

Stable identifier, m0, m1, … back to front.

.. attribute:: kind

What it is made of, kept so a Core document stays readable.

.. attribute:: plane

How far back it sits.

.. attribute:: depth

Its place in the painter’s order, shared with the actors.

.. attribute:: tone

The #rrggbb fill.

.. attribute:: polygon

The silhouette, in panel coordinates.

.. py:attribute:: ResolvedMass.id

module:

scenet.solve.backdrop

type:

str

.. py:attribute:: ResolvedMass.kind

module:

scenet.solve.backdrop

type:

~scenet.ir.MassKind

.. py:attribute:: ResolvedMass.plane

module:

scenet.solve.backdrop

type:

~scenet.ir.Plane

.. py:attribute:: ResolvedMass.depth

module:

scenet.solve.backdrop

type:

int

.. py:attribute:: ResolvedMass.tone

module:

scenet.solve.backdrop

type:

str

.. py:attribute:: ResolvedMass.polygon

module:

scenet.solve.backdrop

type:

tuple[~scenet.geom.Point, …]

.. py:method:: ResolvedMass.init(id, kind, plane, depth, tone, polygon)

module:

scenet.solve.backdrop

.. py:class:: ResolvedVeil

module:

scenet.solve.backdrop

Bases: :py:class:object

The turbulence layer, as parameters rather than as pixels.

SVG has Perlin noise built in through feTurbulence, and the specification includes reference code, so a fixed seed is reproducible by definition – the emitted text is identical. Browsers agree only approximately on what to paint from it, which is fine and is why the determinism contract is on the SVG text and has never been on pixels. See docs/reference/language.md.

.. py:attribute:: ResolvedVeil.tone

module:

scenet.solve.backdrop

type:

str

.. py:attribute:: ResolvedVeil.opacity

module:

scenet.solve.backdrop

type:

float

.. py:attribute:: ResolvedVeil.frequency

module:

scenet.solve.backdrop

type:

float

.. py:attribute:: ResolvedVeil.octaves

module:

scenet.solve.backdrop

type:

int

.. py:attribute:: ResolvedVeil.seed

module:

scenet.solve.backdrop

type:

int

.. py:method:: ResolvedVeil.init(tone, opacity, frequency, octaves, seed)

module:

scenet.solve.backdrop

.. py:function:: contrast_ratio(one, other)

module:

scenet.solve.backdrop

How far apart two neutral values read, on the WCAG scale.

Measured in relative luminance, not in the OKLab lightness lightness returns, and the two sitting in one file is deliberate rather than an oversight. The ladder is spaced in OKLab because that predicts even perceived steps, which is what makes four planes recede evenly. Legibility is checked here because WCAG is where the published threshold comes from, and a floor is only worth stating if it is the number the standard states.

type one:
sphinx_autodoc_typehints_type:

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

param one:

A #rrggbb neutral grey.

type other:
sphinx_autodoc_typehints_type:

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

param other:

The value to compare it against.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

A ratio in 1.0 .. 21.0, symmetric in its arguments.

.. admonition:: Example

from scenet.solve.backdrop import contrast_ratio round(contrast_ratio(“#000000”, “#ffffff”), 1) 21.0

.. py:function:: depth_for(plane, *, frontmost_actor)

module:

scenet.solve.backdrop

Painter’s order for a plane, against the cast that is already placed.

type plane:
sphinx_autodoc_typehints_type:

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

param plane:

How far back the mass sits.

type frontmost_actor:
sphinx_autodoc_typehints_type:

\:py\:class\:\int``

param frontmost_actor:

The largest depth any actor in this panel was given.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\int``

returns:

A depth for the existing (depth, id) sort. Backdrop planes are negative, so they land behind every actor; the foreground takes one above the frontmost, so it draws over the cast and still under the lettering.

.. py:function:: lightness(tone)

module:

scenet.solve.backdrop

The OKLab lightness a neutral grey sits at.

The inverse of how the ladder was built, so that monotonicity is checkable rather than asserted. For a neutral, OKLab’s matrices cancel and L is the cube root of the linear value, which is the whole conversion – and that linear value is exactly the relative luminance contrast_ratio weighs, so the two share one linearisation rather than each carrying its own copy of the sRGB transfer curve.

type tone:
sphinx_autodoc_typehints_type:

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

param tone:

A #rrggbb neutral grey.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

Lightness in 0.0 .. 1.0.

.. admonition:: Example

from scenet.solve.backdrop import lightness round(lightness(“#ffffff”), 3) 1.0

.. py:function:: seed_for(setting, width, height)

module:

scenet.solve.backdrop

A stable seed for one panel’s backdrop.

Derived from the declared content and the panel size, so the same source produces the same silhouettes forever – and two panels that differ get different ones.

blake2b rather than hash(), deliberately and load-bearingly: hash() is salted per process, so a seed derived from it agrees with itself all day and disagrees with tomorrow’s build. That is exactly the failure a golden-file test cannot see.

type setting:
sphinx_autodoc_typehints_type:

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

param setting:

The declared setting.

type width:
sphinx_autodoc_typehints_type:

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

param width:

Panel width in panel units.

type height:
sphinx_autodoc_typehints_type:

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

param height:

Panel height in panel units.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\int``

returns:

A 32-bit seed.

.. py:function:: solve_backdrop(setting, panel, *, frontmost_actor=0)

module:

scenet.solve.backdrop

Resolve a declared setting into masses, tones and atmosphere.

type setting:
sphinx_autodoc_typehints_type:

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

param setting:

The declared setting.

type panel:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.geom.BBox``

param panel:

The panel rectangle. Not the margined frame: a backdrop bleeds to the edge, exactly as artwork does, and only lettering is kept inside a margin.

type frontmost_actor:
sphinx_autodoc_typehints_type:

\:py\:class\:\int``

param frontmost_actor:

The largest depth any actor was given, so a foreground mass can be placed in front of the whole cast.

rtype:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.backdrop.ResolvedBackdrop` | :py:obj:`None``

returns:

The resolved backdrop, or None when there is nothing to draw – which is what every panel written before this block existed gets.

.. admonition:: Example

from scenet.geom import BBox from scenet.ir import SettingSpec from scenet.solve.backdrop import solve_backdrop solve_backdrop(SettingSpec(), BBox(0.0, 0.0, 800.0, 600.0)) is None True

.. py:function:: tone_for(kind, plane, time)

module:

scenet.solve.backdrop

The value a mass is filled with.

Value comes from the plane and from nothing else, which is what keeps the notan reading literal: masses at one distance read as one mass, and the arrangement is what carries the place. Two kinds sit off their own plane’s rung, and both stay on the ladder rather than beside it:

  • sky is the atmosphere. It is at infinite distance whatever plane it was filed under, so it always takes the last rung.

  • window is a hole showing a more distant plane, so it takes the rung one step farther back than the wall it is cut into.

type kind:
sphinx_autodoc_typehints_type:

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

param kind:

What the mass is made of.

type plane:
sphinx_autodoc_typehints_type:

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

param plane:

How far back it sits.

type time:
sphinx_autodoc_typehints_type:

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

param time:

When the panel happens, which selects the ladder.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

A #rrggbb neutral grey.

.. admonition:: Example

from scenet.ir import MassKind, Plane, TimeOfDay from scenet.solve.backdrop import tone_for tone_for(MassKind.SKY, Plane.FAR, TimeOfDay.NIGHT) ‘#4d4d4d’

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:data:: ITALIC_FONT_PATH

module:

scenet.solve.text

value:

PosixPath(‘/home/runner/work/scenet/scenet/.venv/lib/python3.14/site-packages/font_source_sans_pro/files/SourceSansPro-It.ttf’)

The italic face of the same family, for the caption kinds letterers set in italic. A real font file rather than a skew transform: a synthetic oblique measures as the roman face and draws as neither, which is exactly the disagreement between measurement and rendering this module exists to prevent. It costs nothing to use – it ships in the same declared dependency.

.. 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

Placing everything in a panel that carries words: balloons, captions, and tails.

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 box 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.

Captions go through this machinery rather than a parallel one. The placement principles the lettering references give for floating text – keep off the important figures, preserve the space of the art, keep the reading order flowing – are the ones already implemented here. What differs is where a caption wants to be: a balloon mildly dislikes hugging the panel edge, and a caption is looking for exactly that.

So the two are placed in one pass in script order, sharing the list of boxes already down. Placing every caption first would be simpler and wrong: a caption written after the dialogue would then impose reading order on balloons that precede it.

.. py:data:: CAPTION_INK

module:

scenet.solve.balloons

value:

‘#111111’

What lettering is drawn in, and its opposite. CAPTION_INK is the emitter’s stroke colour, restated here because the choice between the two is made in the solver.

.. py:data:: LETTERING_FLOOR

module:

scenet.solve.balloons

value:

4.5

WCAG AA for body text. This is the contrast a reader actually gets, because the box is opaque and the text sits on the fill whatever is behind it. letter_tone is what holds it, and tests/test_setting.py is what proves every tone in the palette does.

type:

The floor a caption’s own lettering must clear against its fill

.. py:data:: SEPARATION_FLOOR

module:

scenet.solve.balloons

value:

3.0

WCAG AA for large text, which is the right comparison for a filled rectangle rather than for type.

Not every tone clears this on every rung, and none is required to. White on a noon sky is 1.16:1, which is the whole reason tone exists. What the palette owes the author is an escape from every background the compiler can produce – at least one tone above this floor for every rung of every row – and that is what is tested.

type:

The floor at which a box separates from the plane behind it

.. 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:class:: PlacedCaption

module:

scenet.solve.balloons

Bases: :py:class:object

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

.. attribute:: id

Stable identifier, c0, c1, … in the order the captions appear.

.. attribute:: order

Position in the panel’s reading order, which captions share with balloons – so a caption between two lines of dialogue takes the number between theirs.

.. attribute:: kind

What the box is doing, which decides how it is set.

.. attribute:: box

Where it ended up.

.. attribute:: block

The text, already quoted where the kind calls for it, broken into lines and measured against the face it will be drawn in.

.. attribute:: fill

The value the box is filled with, resolved from the declared tone.

.. attribute:: ink

The value its lettering is drawn in, chosen against fill so the text reads on the box it sits in.

.. attribute:: speaker

Who is talking, for a spoken caption. They are off panel, so this is not an actor id and resolves to nobody in the cast.

There is no tail, which is the reason this is not a fifth BalloonKind.

.. py:attribute:: PlacedCaption.id

module:

scenet.solve.balloons

type:

str

.. py:attribute:: PlacedCaption.order

module:

scenet.solve.balloons

type:

int

.. py:attribute:: PlacedCaption.kind

module:

scenet.solve.balloons

type:

~scenet.ir.CaptionKind

.. py:attribute:: PlacedCaption.box

module:

scenet.solve.balloons

type:

~scenet.geom.BBox

.. py:attribute:: PlacedCaption.block

module:

scenet.solve.balloons

type:

~scenet.solve.text.TextBlock

.. py:attribute:: PlacedCaption.fill

module:

scenet.solve.balloons

type:

str

.. py:attribute:: PlacedCaption.ink

module:

scenet.solve.balloons

type:

str

.. py:attribute:: PlacedCaption.speaker

module:

scenet.solve.balloons

type:

str | None

.. py:method:: PlacedCaption.init(id, order, kind, box, block, fill=’#ffffff’, ink=’#111111’, speaker=None)

module:

scenet.solve.balloons

.. py:class:: ScriptLayout

module:

scenet.solve.balloons

Bases: :py:class:object

Everything in a panel that carries words, placed.

Two tuples rather than one merged sequence: they reduce to two different Core types, and the thing that orders them – order – is on both.

.. py:attribute:: ScriptLayout.captions

module:

scenet.solve.balloons

type:

tuple[~scenet.solve.balloons.PlacedCaption, …]

.. py:attribute:: ScriptLayout.balloons

module:

scenet.solve.balloons

type:

tuple[~scenet.solve.balloons.PlacedBalloon, …]

.. py:method:: ScriptLayout.init(captions=(), balloons=())

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:: letter_tone(fill)

module:

scenet.solve.balloons

What a caption filled with this value is lettered in.

Ink over a pale box, paper over a dark one, chosen by contrast rather than by a threshold on the tone – the same rule and the same reason as falling rain in solve/backdrop.py, where a white streak is invisible against noon and a black one against midnight. Inkers flip the same way.

Resolved here rather than in the emitter because which mark reads is a fact about the panel, not a rendering preference: the emitter must not be able to draw a box in a value the solver did not choose.

type fill:
sphinx_autodoc_typehints_type:

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

param fill:

The #rrggbb the box is filled with.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

The value the lettering is drawn in.

.. admonition:: Example

from scenet.solve.balloons import letter_tone letter_tone(“#ffffff”), letter_tone(“#090909”) (‘#111111’, ‘#ffffff’)

.. py:function:: caption_text(events, index)

module:

scenet.solve.balloons

The text of a caption, with quotation marks where the kind calls for them.

Blambot’s rule for a run of spoken captions: an opening quote on each, a closing quote only on the last. Consecutive boxes are one continuous line of off-panel speech, and closing each of them would read as four separate interruptions.

Applied here, before measurement, and carried through Panel Core as part of the resolved lines. Adding the marks in the emitter would make the text wider than the box that was drawn for it.

type events:
sphinx_autodoc_typehints_type:

\:py\:class\:\~collections.abc.Sequence`\ \[:py:class:`~scenet.ir.SayEvent` | :py:class:`~scenet.ir.CaptionEvent`]`

param events:

The whole script, because whether this caption closes depends on what follows it.

type index:
sphinx_autodoc_typehints_type:

\:py\:class\:\int``

param index:

Which entry to render.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

The text to letter.

.. admonition:: Example

from scenet.ir import CaptionEvent, CaptionKind from scenet.solve.balloons import caption_text run = ( … CaptionEvent(text=”Get down!”, kind=CaptionKind.SPOKEN), … CaptionEvent(text=”All of you!”, kind=CaptionKind.SPOKEN), … ) caption_text(run, 0).endswith(”””) False caption_text(run, 1).endswith(”””) True

.. py:function:: place_script(events, actors, panel, *, metrics=None, italic_metrics=None, font_size=None, backdrop=None, emanata=None)

module:

scenet.solve.balloons

Place every balloon and caption, in script order.

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

One pass over the whole script, not captions and then balloons. The two share the list of boxes already placed, so a caption written between two lines of dialogue is read between them.

type events:
sphinx_autodoc_typehints_type:

\:py\:class\:\~collections.abc.Sequence`\ \[:py:class:`~scenet.ir.SayEvent` | :py:class:`~scenet.ir.CaptionEvent`]`

param events:

The panel’s script, in reading order.

type actors:
sphinx_autodoc_typehints_type:

\:py\:class\:\dict`\ \[:py:class:`str`, :py:class:`~scenet.assets.kinematics.ResolvedPuppet`]`

param actors:

Resolved puppets, keyed by actor id.

type panel:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.geom.BBox``

param panel:

The rectangle to compose within, margins already applied.

type metrics:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.text.FontMetrics` | :py:obj:`None``

param metrics:

Font to measure roman lettering against.

type italic_metrics:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.text.FontMetrics` | :py:obj:`None``

param italic_metrics:

Font to measure italic captions against. Defaults to the italic face of the same family.

type font_size:
sphinx_autodoc_typehints_type:

\:py\:class\:\float` | :py:obj:`None``

param font_size:

Override for dialogue size, in panel units.

type backdrop:
sphinx_autodoc_typehints_type:

\:py\:class\:\~scenet.solve.backdrop.ResolvedBackdrop` | :py:obj:`None``

param backdrop:

The resolved setting, if the panel has one. Its masses are a soft cost, never an exclusion: a balloon over a sky is the ordinary case.

type emanata:
sphinx_autodoc_typehints_type:

\:py\:class\:\~collections.abc.Mapping`\ \[:py:class:`str`, :py:class:`~collections.abc.Sequence`\ \[:py:class:`~collections.abc.Sequence`\ \[:py:class:`~scenet.geom.Point`]]] | :py:obj:`None``

param emanata:

Actor id to the zones of the marks drawn around them. Also a soft cost, and a heavier one than a body, but never an exclusion.

rtype:
sphinx_autodoc_typehints_type:

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

returns:

Everything that carries words, placed.

raises BalloonPlacementError:

Some box had no legal position anywhere in the panel.