@toonstrip/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Panel sequencing: the responsive column-count-from-available-width policy.
3
+ *
4
+ * Every panel renders at a fixed native size and never stretches above it —
5
+ * a figure that composed differently at every viewport width would be an
6
+ * authoring instability. Column count reflows instead: a width that cannot
7
+ * hold another native panel earns one anyway if the row can shrink to within
8
+ * {@link MIN_PANEL_SCALE} of native, and a viewport narrower than one native
9
+ * panel fits the single column down to whatever width is available.
10
+ */
11
+ /** Native panel size — the art is ~400x400, so this is the 1:1 point. */
12
+ export declare const UNIT: {
13
+ width: number;
14
+ height: number;
15
+ };
16
+ /** The column ceiling on a wide viewport — a decision, not a fit result. */
17
+ export declare const MAX_COLUMNS = 3;
18
+ /**
19
+ * Shrink tolerance: the floor a panel may shrink to *in order to earn
20
+ * another column*. Not a floor on a lone panel — a viewport too narrow for
21
+ * even one native panel gets a straight fit-down (see {@link layoutStrip}),
22
+ * which can go below this.
23
+ */
24
+ export declare const MIN_PANEL_SCALE = 0.85;
25
+ export interface StripLayout {
26
+ /** Panels per row. Always at least 1. */
27
+ columns: number;
28
+ /** CSS-pixel width of one panel. */
29
+ panelWidth: number;
30
+ /** CSS-pixel height of one panel, at the native aspect. */
31
+ panelHeight: number;
32
+ }
33
+ export interface LayoutOptions {
34
+ /** Space between panels, both axes. */
35
+ gutter?: number;
36
+ /** Ceiling on columns; defaults to {@link MAX_COLUMNS}. */
37
+ maxColumns?: number;
38
+ }
39
+ /**
40
+ * How many panels fit across `available` pixels, fit against
41
+ * {@link MIN_PANEL_SCALE} of the native unit width rather than the unit
42
+ * itself — so a width that cannot hold two *native* panels but can hold two
43
+ * shrunk to >= 0.85 native still earns the two-up layout. {@link layoutStrip}
44
+ * fills in the actual share width afterward.
45
+ */
46
+ export declare function columnsFor(available: number, gutter: number, maxColumns?: number): number;
47
+ /**
48
+ * The strip's geometry at a given container width. The panel width resolves
49
+ * to one of three cases:
50
+ *
51
+ * - **Native.** `share >= UNIT.width`: the panel sits at native and the row
52
+ * is centred in its leftover. The common case above one column's worth of
53
+ * space.
54
+ * - **Shrink-to-earn-a-column.** `0.85*UNIT.width <= share < UNIT.width`:
55
+ * arises because {@link columnsFor} fit the extra column against
56
+ * {@link MIN_PANEL_SCALE}; panels shrink to the share so both fit.
57
+ * - **Fit-down.** One column narrower than a native panel: the lone panel
58
+ * scales down to fit rather than overflow. The only case that may drop
59
+ * below 0.85.
60
+ */
61
+ export declare function layoutStrip(available: number, options?: LayoutOptions): StripLayout;
62
+ /**
63
+ * Which cell a panel occupies. Kept as its own function because it is the
64
+ * half of the policy that says *reading order is row-major* — the strip's
65
+ * DOM order and the grid's flow have to agree with it or a resize would
66
+ * renumber the comic.
67
+ */
68
+ export declare function cellFor(index: number, columns: number): {
69
+ row: number;
70
+ column: number;
71
+ };
package/dist/layout.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Panel sequencing: the responsive column-count-from-available-width policy.
3
+ *
4
+ * Every panel renders at a fixed native size and never stretches above it —
5
+ * a figure that composed differently at every viewport width would be an
6
+ * authoring instability. Column count reflows instead: a width that cannot
7
+ * hold another native panel earns one anyway if the row can shrink to within
8
+ * {@link MIN_PANEL_SCALE} of native, and a viewport narrower than one native
9
+ * panel fits the single column down to whatever width is available.
10
+ */
11
+ /** Native panel size — the art is ~400x400, so this is the 1:1 point. */
12
+ export const UNIT = { width: 400, height: 340 };
13
+ /** The column ceiling on a wide viewport — a decision, not a fit result. */
14
+ export const MAX_COLUMNS = 3;
15
+ /**
16
+ * Shrink tolerance: the floor a panel may shrink to *in order to earn
17
+ * another column*. Not a floor on a lone panel — a viewport too narrow for
18
+ * even one native panel gets a straight fit-down (see {@link layoutStrip}),
19
+ * which can go below this.
20
+ */
21
+ export const MIN_PANEL_SCALE = 0.85;
22
+ /**
23
+ * How many panels fit across `available` pixels, fit against
24
+ * {@link MIN_PANEL_SCALE} of the native unit width rather than the unit
25
+ * itself — so a width that cannot hold two *native* panels but can hold two
26
+ * shrunk to >= 0.85 native still earns the two-up layout. {@link layoutStrip}
27
+ * fills in the actual share width afterward.
28
+ */
29
+ export function columnsFor(available, gutter, maxColumns = MAX_COLUMNS) {
30
+ const minWidth = UNIT.width * MIN_PANEL_SCALE;
31
+ const fit = Math.floor((available + gutter) / (minWidth + gutter));
32
+ return Math.max(1, Math.min(fit, maxColumns));
33
+ }
34
+ /**
35
+ * The strip's geometry at a given container width. The panel width resolves
36
+ * to one of three cases:
37
+ *
38
+ * - **Native.** `share >= UNIT.width`: the panel sits at native and the row
39
+ * is centred in its leftover. The common case above one column's worth of
40
+ * space.
41
+ * - **Shrink-to-earn-a-column.** `0.85*UNIT.width <= share < UNIT.width`:
42
+ * arises because {@link columnsFor} fit the extra column against
43
+ * {@link MIN_PANEL_SCALE}; panels shrink to the share so both fit.
44
+ * - **Fit-down.** One column narrower than a native panel: the lone panel
45
+ * scales down to fit rather than overflow. The only case that may drop
46
+ * below 0.85.
47
+ */
48
+ export function layoutStrip(available, options = {}) {
49
+ const gutter = options.gutter ?? 10;
50
+ // A container that has not been measured yet reports 0; fall back to the
51
+ // unit rather than dividing a row into a zero-width panel.
52
+ const width = available > 0 ? available : UNIT.width;
53
+ const columns = columnsFor(width, gutter, options.maxColumns);
54
+ const share = (width - (columns - 1) * gutter) / columns;
55
+ // Native is the ceiling — never a free stretch.
56
+ const panelWidth = Math.max(1, Math.min(share, UNIT.width));
57
+ return {
58
+ columns,
59
+ panelWidth,
60
+ panelHeight: Math.round((panelWidth / UNIT.width) * UNIT.height),
61
+ };
62
+ }
63
+ /**
64
+ * Which cell a panel occupies. Kept as its own function because it is the
65
+ * half of the policy that says *reading order is row-major* — the strip's
66
+ * DOM order and the grid's flow have to agree with it or a resize would
67
+ * renumber the comic.
68
+ */
69
+ export function cellFor(index, columns) {
70
+ return { row: Math.floor(index / columns), column: index % columns };
71
+ }
package/dist/pose.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pose selection: mapping a requested emotion onto the poses a character
3
+ * owns. Selection is nearest-neighbour on the emotion wheel, which is why a
4
+ * character never needs a pose for every emotion — a request resolves to
5
+ * whatever the pack actually shipped.
6
+ *
7
+ * Generic over `T extends Emotion` rather than fixed to a pack-format record:
8
+ * selection only ever reads an angle and an intensity, never a byte offset or
9
+ * a palette, so any manifest entry that carries `{ angle, intensity }` can be
10
+ * passed straight through.
11
+ */
12
+ import { type Emotion, type EmotionOption } from "./emotion.js";
13
+ /** Normalize into (-PI, PI]. */
14
+ export declare function normalizeAngle(value: number): number;
15
+ export declare function subtractAngles(a: number, b: number): number;
16
+ /**
17
+ * Nearest pose by angle, then by intensity. Used for faces, which always
18
+ * resolve to something: a character with one face still has to render.
19
+ */
20
+ export declare function nearestByAngle<T extends Emotion>(records: readonly T[], want: Emotion): T | undefined;
21
+ /**
22
+ * Nearest torso or body: a gated search — a candidate must fall inside the
23
+ * wheel segment, or be a resting neutral, to be considered at all. A torso
24
+ * can therefore fail to match, and the caller falls back to neutral.
25
+ *
26
+ * `start` implements a round-robin: the scan begins after the pose used
27
+ * last, so a repeated identical emotion cycles through whatever variants
28
+ * exist instead of freezing on one drawing.
29
+ */
30
+ export declare function nearestGated<T extends Emotion>(records: readonly T[], want: Emotion, start?: number): T | undefined;
31
+ export declare function firstNeutral<T extends Emotion>(records: readonly T[]): T | undefined;
32
+ /**
33
+ * Resolve one emotion to a face *or* a torso. An emotion on the wheel is a
34
+ * facial expression and resolves against faces; a gesture (wave, shrug, ...)
35
+ * is a body posture and resolves against torsos by exact match, since no
36
+ * angular metric applies to a sentinel. One request therefore constrains one
37
+ * half of the figure and leaves the other free — a character can wave while
38
+ * looking sad.
39
+ */
40
+ export declare function headAndTorsoFor<T extends Emotion>(faces: readonly T[], torsos: readonly T[], want: Emotion): {
41
+ face?: T;
42
+ torso?: T;
43
+ };
44
+ /**
45
+ * Resolve a *set* of weighted candidate emotions to a face and a torso.
46
+ *
47
+ * Options are consumed highest-priority first; the first that yields a face
48
+ * fixes the face, the first that yields a torso fixes the torso, and the
49
+ * scan stops once both are filled. Anything still unfilled falls back to
50
+ * neutral. This is how a gesture (which only ever fills a torso) and a
51
+ * facial emotion (which only ever fills a face) combine into one figure from
52
+ * one caller-supplied option set.
53
+ */
54
+ export declare function selectComplexPose<T extends Emotion>(faces: readonly T[], torsos: readonly T[], options: readonly EmotionOption[]): {
55
+ face?: T;
56
+ torso?: T;
57
+ };
58
+ /** Resolve a single emotion to a face and a torso — the common path. */
59
+ export declare function selectPose<T extends Emotion>(faces: readonly T[], torsos: readonly T[], want: Emotion, lastTorso?: number): {
60
+ face?: T;
61
+ torso?: T;
62
+ };
63
+ /** Simple characters have one image per pose; the same gated search applies. */
64
+ export declare function selectBody<T extends Emotion>(bodies: readonly T[], want: Emotion, lastBody?: number): T | undefined;
package/dist/pose.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Pose selection: mapping a requested emotion onto the poses a character
3
+ * owns. Selection is nearest-neighbour on the emotion wheel, which is why a
4
+ * character never needs a pose for every emotion — a request resolves to
5
+ * whatever the pack actually shipped.
6
+ *
7
+ * Generic over `T extends Emotion` rather than fixed to a pack-format record:
8
+ * selection only ever reads an angle and an intensity, never a byte offset or
9
+ * a palette, so any manifest entry that carries `{ angle, intensity }` can be
10
+ * passed straight through.
11
+ */
12
+ import { NEMOTIONS, isGesture } from "./emotion.js";
13
+ /** Normalize into (-PI, PI]. */
14
+ export function normalizeAngle(value) {
15
+ if (value > -Math.PI && value <= Math.PI)
16
+ return value;
17
+ let t = value / (2 * Math.PI);
18
+ t = (t - Math.trunc(t)) * 2 * Math.PI;
19
+ if (t > Math.PI)
20
+ return t - 2 * Math.PI;
21
+ if (t <= -Math.PI)
22
+ return t + 2 * Math.PI;
23
+ return t;
24
+ }
25
+ export function subtractAngles(a, b) {
26
+ return normalizeAngle(a - b);
27
+ }
28
+ /**
29
+ * How far a pose may sit from the request and still be considered: half a
30
+ * wheel segment in each direction, so the eight segments tile the circle.
31
+ */
32
+ const ANGLE_TOLERANCE = Math.PI / NEMOTIONS;
33
+ const isNeutral = (rec) => rec.angle === 0 && rec.intensity === 0;
34
+ /**
35
+ * Nearest pose by angle, then by intensity. Used for faces, which always
36
+ * resolve to something: a character with one face still has to render.
37
+ */
38
+ export function nearestByAngle(records, want) {
39
+ let best;
40
+ let bestAngle = 3 * Math.PI;
41
+ let bestIntensity = 2.0;
42
+ for (const rec of records) {
43
+ const angle = Math.abs(subtractAngles(rec.angle, want.angle));
44
+ if (angle > bestAngle)
45
+ continue;
46
+ const intensity = Math.abs(want.intensity - rec.intensity);
47
+ if (angle === bestAngle && intensity >= bestIntensity)
48
+ continue;
49
+ bestAngle = angle;
50
+ bestIntensity = intensity;
51
+ best = rec;
52
+ }
53
+ return best;
54
+ }
55
+ /**
56
+ * Nearest torso or body: a gated search — a candidate must fall inside the
57
+ * wheel segment, or be a resting neutral, to be considered at all. A torso
58
+ * can therefore fail to match, and the caller falls back to neutral.
59
+ *
60
+ * `start` implements a round-robin: the scan begins after the pose used
61
+ * last, so a repeated identical emotion cycles through whatever variants
62
+ * exist instead of freezing on one drawing.
63
+ */
64
+ export function nearestGated(records, want, start = 0) {
65
+ let best;
66
+ let bestIntensity = 2.0;
67
+ const n = records.length;
68
+ for (let i = 0; i < n; i++) {
69
+ const rec = records[(start + i) % n];
70
+ // Gestures are sentinels above 2*PI; the angular metric cannot reach them.
71
+ if (isGesture(rec.angle))
72
+ continue;
73
+ const angle = Math.abs(subtractAngles(rec.angle, want.angle));
74
+ const firstNeutralMatch = isNeutral(rec) && best === undefined;
75
+ if (angle >= ANGLE_TOLERANCE && !firstNeutralMatch)
76
+ continue;
77
+ // A resting neutral standing in for a felt emotion is a weak match, but a
78
+ // legal one — rank it behind anything that actually matched.
79
+ const intensity = firstNeutralMatch && want.intensity > 0
80
+ ? 1.5
81
+ : Math.abs(want.intensity - rec.intensity);
82
+ if (intensity >= bestIntensity)
83
+ continue;
84
+ bestIntensity = intensity;
85
+ best = rec;
86
+ }
87
+ return best;
88
+ }
89
+ export function firstNeutral(records) {
90
+ return records.find(isNeutral) ?? records[0];
91
+ }
92
+ /**
93
+ * Resolve one emotion to a face *or* a torso. An emotion on the wheel is a
94
+ * facial expression and resolves against faces; a gesture (wave, shrug, ...)
95
+ * is a body posture and resolves against torsos by exact match, since no
96
+ * angular metric applies to a sentinel. One request therefore constrains one
97
+ * half of the figure and leaves the other free — a character can wave while
98
+ * looking sad.
99
+ */
100
+ export function headAndTorsoFor(faces, torsos, want) {
101
+ if (!isGesture(want.angle)) {
102
+ const face = nearestByAngle(faces, want);
103
+ return face ? { face } : {};
104
+ }
105
+ const torso = torsos.find((rec) => rec.angle === want.angle);
106
+ return torso ? { torso } : {};
107
+ }
108
+ /**
109
+ * Resolve a *set* of weighted candidate emotions to a face and a torso.
110
+ *
111
+ * Options are consumed highest-priority first; the first that yields a face
112
+ * fixes the face, the first that yields a torso fixes the torso, and the
113
+ * scan stops once both are filled. Anything still unfilled falls back to
114
+ * neutral. This is how a gesture (which only ever fills a torso) and a
115
+ * facial emotion (which only ever fills a face) combine into one figure from
116
+ * one caller-supplied option set.
117
+ */
118
+ export function selectComplexPose(faces, torsos, options) {
119
+ const remaining = options.map((o) => ({ ...o }));
120
+ let face;
121
+ let torso;
122
+ for (;;) {
123
+ let best;
124
+ for (const option of remaining) {
125
+ if (option.priority > (best?.priority ?? 0))
126
+ best = option;
127
+ }
128
+ if (!best)
129
+ break;
130
+ best.priority = 0;
131
+ const found = headAndTorsoFor(faces, torsos, best);
132
+ face ??= found.face;
133
+ torso ??= found.torso;
134
+ if (face && torso)
135
+ break;
136
+ }
137
+ face ??= firstNeutral(faces);
138
+ torso ??= firstNeutral(torsos);
139
+ const out = {};
140
+ if (face)
141
+ out.face = face;
142
+ if (torso)
143
+ out.torso = torso;
144
+ return out;
145
+ }
146
+ /** Resolve a single emotion to a face and a torso — the common path. */
147
+ export function selectPose(faces, torsos, want, lastTorso = -1) {
148
+ const face = nearestByAngle(faces, want);
149
+ const torso = nearestGated(torsos, want, (lastTorso + 1) % Math.max(1, torsos.length))
150
+ ?? firstNeutral(torsos);
151
+ const out = {};
152
+ if (face)
153
+ out.face = face;
154
+ if (torso)
155
+ out.torso = torso;
156
+ return out;
157
+ }
158
+ /** Simple characters have one image per pose; the same gated search applies. */
159
+ export function selectBody(bodies, want, lastBody = -1) {
160
+ if (isGesture(want.angle)) {
161
+ return bodies.find((rec) => rec.angle === want.angle) ?? firstNeutral(bodies);
162
+ }
163
+ return nearestGated(bodies, want, (lastBody + 1) % Math.max(1, bodies.length))
164
+ ?? firstNeutral(bodies);
165
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * A panel, drawn. The renderer is a pure function of a {@link Panel} plus the
3
+ * cast: it reads no external state, which is what makes it testable in Node
4
+ * without a caller-side game loop or session — a shot arrives as a name
5
+ * (`establishing`, `close-up`) chosen by the caller, and this file decides
6
+ * what that shot looks like at the current panel size.
7
+ */
8
+ import type { Body, Line, Panel } from "@toonstrip/schema";
9
+ import { type Cast, type Figure, type PoseCycle } from "./figure.js";
10
+ import { type BalloonBox } from "./balloon.js";
11
+ import { type Rect } from "./balloon-layout.js";
12
+ /** The bodiless caption channel — a line with no speaking body. */
13
+ export declare const CAPTION = "caption";
14
+ /** A camera preset, as the numbers a render needs. */
15
+ type Shot = {
16
+ /** How much of the backdrop is visible behind the figures. */
17
+ backdrop: number;
18
+ } & (
19
+ /** Figure height as a fraction of panel height — the ordinary case. */
20
+ {
21
+ fill: number;
22
+ /** Vertical framing: 1 shows the whole figure, lower crops toward the head. */
23
+ reveal: number;
24
+ headFill?: never;
25
+ headTop?: never;
26
+ }
27
+ /**
28
+ * *Head* height as a fraction of panel height, for a rung tight enough
29
+ * that the head is the shot. Not interchangeable with `fill`, which scales
30
+ * by body height — a family of characters with different head-to-body
31
+ * ratios would get a different shot per character from `fill` alone.
32
+ */
33
+ | {
34
+ headFill: number;
35
+ /** Where the head-top sits, as a fraction of panel height. */
36
+ headTop: number;
37
+ fill?: never;
38
+ reveal?: never;
39
+ });
40
+ /**
41
+ * When a panel carries a speech or thought balloon, no figure may grow wider
42
+ * than this fraction of the panel — a speech balloon straddles the head from
43
+ * above, so it needs to be wider than the head to sit over it and still
44
+ * read. Left uncapped on a panel with no such balloon (a `reaction` beat,
45
+ * whose whole point is a face that fills the frame).
46
+ */
47
+ export declare const MAX_FIGURE_WIDTH = 0.55;
48
+ /**
49
+ * When a panel carries a speech or thought balloon, no head may grow taller
50
+ * than this fraction of the panel — `fill` scales a figure by its *body*
51
+ * height, but what a reader reads as "how close is this" is head size, and
52
+ * heads vary as a fraction of figure height across a cast. This is a
53
+ * per-figure clamp on top of `fill`, binding hardest at `close-up`.
54
+ */
55
+ export declare const MAX_HEAD_HEIGHT = 0.38;
56
+ /**
57
+ * Reserved band at the top of the panel for a straddling speech balloon and
58
+ * its tail. A speech balloon sits above its speaker and drops a near-
59
+ * vertical tail, which only works if the head is far enough down to leave
60
+ * room — so when such a balloon is present, a figure is pushed down until
61
+ * its head-top clears this fraction of the panel height.
62
+ */
63
+ export declare const BALLOON_HEADROOM = 0.44;
64
+ /** A laid-out body: its speaker id, figure, and placement in panel space. */
65
+ export interface PlacedBody {
66
+ speaker: string;
67
+ figure: Figure;
68
+ x: number;
69
+ y: number;
70
+ scale: number;
71
+ mirror: boolean;
72
+ }
73
+ export interface RenderOptions {
74
+ /**
75
+ * Per-character pose cycles. Owned by the caller so that re-rendering the
76
+ * same panel (a resize, a re-paint) is idempotent, while a *new* panel
77
+ * advances the cycle. Without this split, a resize would silently redraw
78
+ * every panel with different bodies.
79
+ */
80
+ cycles?: Map<string, PoseCycle>;
81
+ /**
82
+ * Somewhere to record what this render placed. Purely an output: the
83
+ * renderer reads nothing from it and draws the same pixels whether or not
84
+ * it is passed. Useful for a visual-diff gate: the geometry that produced
85
+ * a change says *what* moved, which a pixel hash alone cannot.
86
+ */
87
+ trace?: PanelTrace;
88
+ }
89
+ /** What a render placed, in panel space. Filled in by {@link RenderOptions.trace}. */
90
+ export interface PanelTrace {
91
+ bodies: Array<{
92
+ speaker: string;
93
+ x: number;
94
+ y: number;
95
+ scale: number;
96
+ mirror: boolean;
97
+ }>;
98
+ balloons: Array<{
99
+ speaker: string;
100
+ box: BalloonBox;
101
+ /** How the text broke — the count is enough to spot a re-wrap. */
102
+ lines: number;
103
+ /** Where the tail points, or `null` for a balloon that grows none. */
104
+ tail: {
105
+ x: number;
106
+ y: number;
107
+ } | null;
108
+ }>;
109
+ /** True when the balloon driver refused the panel and the stack fallback drew it. */
110
+ stacked: boolean;
111
+ }
112
+ export declare function renderPanel(ctx: CanvasRenderingContext2D, panel: Panel, cast: Cast, width: number, height: number, options?: RenderOptions): void;
113
+ /** A body earns the stage if requested, or if it speaks this panel. */
114
+ export declare function isSpeaker(body: Body, speakers: readonly Line[]): boolean;
115
+ /**
116
+ * Stage order and facing. Order is stable by insertion (no talk-to graph
117
+ * exists to score a better one). Facing is scored: a body is charged for
118
+ * each neighbour it does not face and each neighbour that does not face it;
119
+ * reduced over a fixed row this means each body faces its **larger side** —
120
+ * the leftmost faces right, the rightmost faces left, and the row turns
121
+ * inward. An exact tie (the middle of an odd row) keeps the rightward default.
122
+ */
123
+ export declare function orderBodies<T>(bodies: readonly T[]): Array<{
124
+ body: T;
125
+ flip: boolean;
126
+ }>;
127
+ /**
128
+ * Reserve the top band for a straddling speech balloon, and keep the head
129
+ * that sits under it small enough to leave a body in frame.
130
+ *
131
+ * 1. Push down to the band: a figure whose head-top is above
132
+ * {@link BALLOON_HEADROOM} is moved down until it clears. A figure
133
+ * already below the band is left alone.
134
+ * 2. Cap the head: with the head-top pinned, head size is the whole of "how
135
+ * close are we", and `shot.fill` scales by *body* height — a poor proxy
136
+ * across a cast whose head-to-body ratio varies. See {@link MAX_HEAD_HEIGHT}.
137
+ *
138
+ * Only figures the band actually pinned are capped, and they shrink about
139
+ * their own centre so the balloon column stays over them.
140
+ */
141
+ export declare function applyHeadroom(placed: PlacedBody[], height: number): void;
142
+ /**
143
+ * Place N bodies across the panel floor. Each body is scaled to the
144
+ * camera's fill height, normalized to a common target height across the
145
+ * cast. Widths are summed; if the row overruns the panel the whole set is
146
+ * reduced by one factor. Bodies are then spread with equal margins,
147
+ * including the two edges.
148
+ */
149
+ export declare function layoutAvatars(bodies: ReadonlyArray<{
150
+ body: Body;
151
+ figure: Figure;
152
+ }>, width: number, height: number, shot: Shot, widthCap?: number): PlacedBody[];
153
+ /**
154
+ * Who is on stage in this panel, and posed how. Each figure's pose comes
155
+ * from its **own** body's emotion, so N participants pose independently,
156
+ * never sharing one panel emotion. Unknown character ids (no sheet, no
157
+ * manifest entry) are skipped.
158
+ *
159
+ * Exported because it is the step that advances `cycles`, and it takes no
160
+ * width — a caller that needs a panel's round-robin advanced without
161
+ * drawing it (a virtualized strip that stages every panel but paints only
162
+ * the ones on screen) can call this and get exactly what {@link renderPanel}
163
+ * would have consumed. Calling it mutates `cycles`; pass a copy to avoid that.
164
+ */
165
+ export declare function selectFigures(panel: Panel, cast: Cast, cycles: Map<string, PoseCycle>): Array<{
166
+ body: Body;
167
+ figure: Figure;
168
+ }>;
169
+ export declare function fit(figure: Figure, height: number, fill: number, widthCap?: number, headFill?: number): number;
170
+ /** The band balloons may occupy, from the panel's top down to {@link BALLOON_HEADROOM}. */
171
+ export declare function balloonRect(width: number, height: number, titled?: boolean): Rect;
172
+ /** The inset from the panel border. */
173
+ export declare const BALLOON_MARGIN = 10;
174
+ /** The location title's height, and the type it is set in. Not a balloon: no tail, no draw order. */
175
+ export declare const TITLE_HEIGHT = 22;
176
+ /** Draw the location title, clipped to the panel's width. */
177
+ export declare function drawLocationTitle(ctx: CanvasRenderingContext2D, title: string, width: number): void;
178
+ /**
179
+ * Slide a box horizontally until it no longer overlaps any face box, moving
180
+ * it toward whichever side needs less travel and still fits the panel. When
181
+ * the box is too wide to clear at all, it is left where it was and only
182
+ * nudged back inside the panel edges.
183
+ */
184
+ export declare function clearOfFaces(x: number, y: number, w: number, h: number, bulge: number, faces: ReadonlyArray<{
185
+ left: number;
186
+ right: number;
187
+ top: number;
188
+ bottom: number;
189
+ }>, width: number): number;
190
+ export {};