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.
.. py:attribute:: ShotSpec.crop
- module:
scenet.solve.camera
- type:
~scenet.assets.contract.Landmark
.. py:attribute:: ShotSpec.headroom
- module:
scenet.solve.camera
- type:
float
.. py:method:: ShotSpec.init(crop, headroom)
- module:
scenet.solve.camera
.. py:class:: CameraSolution
- module:
scenet.solve.camera
Bases: :py:class:
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=0.0)
- module:
scenet.solve.camera
Resolve the camera against a reference actor.
Everything else in the panel inherits this scale. The reference is the actor the shot is composed on – by convention the first in the cast, which is the one the author wrote first and therefore the one the panel is about.
- rtype:
- sphinx_autodoc_typehints_type:
\:py\:class\:\~scenet.solve.camera.CameraSolution``
scenet.solve.staging#
.. py:module:: scenet.solve.staging
Horizontal placement, vertical grounding and draw order.
This is where Cassowary earns its place. The arithmetic is trivial – centring a figure on a third is one division – but the conflicts are not. Two actors both asked to stand centre must be pushed apart; a crowded panel must let figures bleed off the edge rather than overlap. Expressed as priorities, that resolves itself. Written by hand it becomes an ever-growing cascade of special cases.
Priorities used:
required actors never overlap, and keep their declared left-to-right order
strong actors stay inside the panel
weak actors sit on their requested anchor
Bounds are deliberately strong rather than required. Letting a figure bleed past the panel edge is ordinary comics practice, and far better than refusing to compile a crowded panel.
.. py:class:: Placement
- module:
scenet.solve.staging
Bases: :py:class:
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.x
- module:
scenet.solve.staging
- type:
float
.. py:attribute:: Placement.y
- module:
scenet.solve.staging
- type:
float
.. py:attribute:: Placement.scale
- module:
scenet.solve.staging
- type:
float
.. py:attribute:: Placement.facing_right
- module:
scenet.solve.staging
- type:
bool
.. py:attribute:: Placement.depth
- module:
scenet.solve.staging
- type:
int
.. py:property:: Placement.origin
- module:
scenet.solve.staging
- type:
~scenet.geom.Point
Where this actor’s root joint lands, as a point.
.. py:method:: Placement.init(actor_id, reference, pose, x, y, scale, facing_right, depth)
- module:
scenet.solve.staging
.. py:function:: horizontal_order(panel)
- module:
scenet.solve.staging
A total left-to-right order over the cast.
Declared
left_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.text#
.. py:module:: scenet.solve.text
Text measurement and line breaking.
Nothing downstream can proceed without this. A balloon’s size is a function of its text, wrapped at some measure, in a specific font – and placement, occlusion and reading order all depend on that size. So wrapping is decided here, during compilation, and the result is carried through Panel Core as explicit lines. The emitter never re-measures and therefore can never disagree with the solver.
Determinism demands the font be fixed. It arrives as a declared dependency rather than a system lookup precisely because “whatever font this machine happens to have” is the opposite of reproducible.
.. py:class:: TextBlock
- module:
scenet.solve.text
Bases: :py:class:
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
Balloon placement and tail routing.
Placement is a scored search over candidate positions rather than a sweep of a cost grid. Balloons belong in a small number of sensible places relative to their speaker – around the head, or tucked into a corner – so generating those directly is both cheaper than scanning cells and produces more natural results. This is the approach used in the cartographic label-placement literature, which is the same problem.
The constraint that naive implementations miss is reading order. A balloon may never sit above-and-left of the one before it in the script, because that makes the panel read in the wrong order. That is a correctness bug in a comic, not a cosmetic one, so it is enforced as a hard filter rather than scored.
.. py:class:: TailRoute
- module:
scenet.solve.balloons
Bases: :py:class:
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:function:: route_tail(balloon, mouth, obstacles, speaker_face=None)
- module:
scenet.solve.balloons
Route a tail from the balloon toward the speaker’s mouth.
Straight is right almost always, so it is tried first. Only when the direct line crosses another character’s face does the tail bend, and then via a single control point chosen from a handful of lateral offsets.
Grid pathfinding is deliberately not used here. A tail is a short tapered stroke, and A-star produces a jointed path that looks nothing like one drawn by hand.
- rtype:
- sphinx_autodoc_typehints_type:
\:py\:class\:\~scenet.solve.balloons.TailRoute``
.. py:function:: place_balloons(events, actors, panel, *, metrics=None, font_size=None)
- module:
scenet.solve.balloons
Place every balloon in script order.
Greedy rather than jointly optimised: each balloon is placed against those already down, which is exactly how reading order works – a balloon constrains its successor, never its predecessor. That makes the greedy pass the natural formulation rather than a compromise.
- rtype:
- sphinx_autodoc_typehints_type:
\:py\:class\:\tuple`\ \[:py:class:`~scenet.solve.balloons.PlacedBalloon`, :py:data:`…<Ellipsis>`]`