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:
objectA 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:
objectThe 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 howground_shared_withgets 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_HEADROOMfor a tilted camera – so thatextreme_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:
objectWhere 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_ofrelations 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/behindrelations.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.
leftreaches past the middle of the panel, and a foreground mass that wide stops framing the composition and starts burying it: the first contact sheet hadalleyhiding 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:
objectWhat 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:
objectEverything 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:
objectOne 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
#rrggbbfill... 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:
objectThe 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. Seedocs/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
lightnessreturns, 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
#rrggbbneutral 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
Lis the cube root of the linear value, which is the whole conversion – and that linear value is exactly the relative luminancecontrast_ratioweighs, 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
#rrggbbneutral 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.
blake2brather thanhash(), 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:
skyis the atmosphere. It is at infinite distance whatever plane it was filed under, so it always takes the last rung.windowis 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
#rrggbbneutral 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:
objectWrapped 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.0for 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:
objectAdvance widths read straight from the font’s own tables.
Kerning is deliberately ignored. Reading
kern/GPOSwould 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
headtable.
.. 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-textexists.- 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_INKis 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_toneis what holds it, andtests/test_setting.pyis 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
toneexists. 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:
objectThe pointer from balloon to mouth.
A straight tail is correct almost always.
controlis 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:
objectOne 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:
objectOne 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
fillso the text reads on the box it sits in... attribute:: speaker
Who is talking, for a
spokencaption. 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:
objectEverything 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
#rrggbbthe 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.