postext 1.19.3 → 1.20.1
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.
- package/dist/__tests__/comics/art.test.d.ts +2 -0
- package/dist/__tests__/comics/art.test.d.ts.map +1 -0
- package/dist/__tests__/comics/art.test.js +96 -0
- package/dist/__tests__/comics/art.test.js.map +1 -0
- package/dist/__tests__/comics/build.test.d.ts +2 -0
- package/dist/__tests__/comics/build.test.d.ts.map +1 -0
- package/dist/__tests__/comics/build.test.js +215 -0
- package/dist/__tests__/comics/build.test.js.map +1 -0
- package/dist/__tests__/comics/canvas.test.d.ts +2 -0
- package/dist/__tests__/comics/canvas.test.d.ts.map +1 -0
- package/dist/__tests__/comics/canvas.test.js +161 -0
- package/dist/__tests__/comics/canvas.test.js.map +1 -0
- package/dist/__tests__/comics/config.test.d.ts +2 -0
- package/dist/__tests__/comics/config.test.d.ts.map +1 -0
- package/dist/__tests__/comics/config.test.js +97 -0
- package/dist/__tests__/comics/config.test.js.map +1 -0
- package/dist/__tests__/comics/geometry.test.d.ts +2 -0
- package/dist/__tests__/comics/geometry.test.d.ts.map +1 -0
- package/dist/__tests__/comics/geometry.test.js +118 -0
- package/dist/__tests__/comics/geometry.test.js.map +1 -0
- package/dist/__tests__/comics/html.test.d.ts +2 -0
- package/dist/__tests__/comics/html.test.d.ts.map +1 -0
- package/dist/__tests__/comics/html.test.js +253 -0
- package/dist/__tests__/comics/html.test.js.map +1 -0
- package/dist/__tests__/comics/lettering.test.d.ts +2 -0
- package/dist/__tests__/comics/lettering.test.d.ts.map +1 -0
- package/dist/__tests__/comics/lettering.test.js +241 -0
- package/dist/__tests__/comics/lettering.test.js.map +1 -0
- package/dist/__tests__/comics/script.test.d.ts +2 -0
- package/dist/__tests__/comics/script.test.d.ts.map +1 -0
- package/dist/__tests__/comics/script.test.js +147 -0
- package/dist/__tests__/comics/script.test.js.map +1 -0
- package/dist/__tests__/comics/split.test.d.ts +2 -0
- package/dist/__tests__/comics/split.test.d.ts.map +1 -0
- package/dist/__tests__/comics/split.test.js +120 -0
- package/dist/__tests__/comics/split.test.js.map +1 -0
- package/dist/__tests__/comics/spread.test.d.ts +2 -0
- package/dist/__tests__/comics/spread.test.d.ts.map +1 -0
- package/dist/__tests__/comics/spread.test.js +161 -0
- package/dist/__tests__/comics/spread.test.js.map +1 -0
- package/dist/__tests__/comics/strip.test.d.ts +2 -0
- package/dist/__tests__/comics/strip.test.d.ts.map +1 -0
- package/dist/__tests__/comics/strip.test.js +231 -0
- package/dist/__tests__/comics/strip.test.js.map +1 -0
- package/dist/__tests__/exports.test.js +89 -0
- package/dist/__tests__/exports.test.js.map +1 -1
- package/dist/__tests__/parse/containers.test.js +1 -1
- package/dist/__tests__/parse/containers.test.js.map +1 -1
- package/dist/canvas-backend/comic.d.ts +32 -0
- package/dist/canvas-backend/comic.d.ts.map +1 -0
- package/dist/canvas-backend/comic.js +216 -0
- package/dist/canvas-backend/comic.js.map +1 -0
- package/dist/canvas-backend/headerFooter.d.ts +4 -1
- package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
- package/dist/canvas-backend/headerFooter.js +3 -1
- package/dist/canvas-backend/headerFooter.js.map +1 -1
- package/dist/canvas-backend/index.d.ts.map +1 -1
- package/dist/canvas-backend/index.js +7 -1
- package/dist/canvas-backend/index.js.map +1 -1
- package/dist/comics/art.d.ts +72 -0
- package/dist/comics/art.d.ts.map +1 -0
- package/dist/comics/art.js +154 -0
- package/dist/comics/art.js.map +1 -0
- package/dist/comics/fonts.d.ts +20 -0
- package/dist/comics/fonts.d.ts.map +1 -0
- package/dist/comics/fonts.js +42 -0
- package/dist/comics/fonts.js.map +1 -0
- package/dist/comics/geometry.d.ts +87 -0
- package/dist/comics/geometry.d.ts.map +1 -0
- package/dist/comics/geometry.js +272 -0
- package/dist/comics/geometry.js.map +1 -0
- package/dist/comics/index.d.ts +29 -0
- package/dist/comics/index.d.ts.map +1 -0
- package/dist/comics/index.js +18 -0
- package/dist/comics/index.js.map +1 -0
- package/dist/comics/layoutPage.d.ts +156 -0
- package/dist/comics/layoutPage.d.ts.map +1 -0
- package/dist/comics/layoutPage.js +698 -0
- package/dist/comics/layoutPage.js.map +1 -0
- package/dist/comics/lettering/__tests__/harness.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/harness.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/harness.test.js +273 -0
- package/dist/comics/lettering/__tests__/harness.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/letterPanel.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/letterPanel.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/letterPanel.test.js +224 -0
- package/dist/comics/lettering/__tests__/letterPanel.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/polish.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/polish.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/polish.test.js +357 -0
- package/dist/comics/lettering/__tests__/polish.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/round2.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/round2.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/round2.test.js +289 -0
- package/dist/comics/lettering/__tests__/round2.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/round3.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/round3.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/round3.test.js +240 -0
- package/dist/comics/lettering/__tests__/round3.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/shapeText.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/shapeText.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/shapeText.test.js +98 -0
- package/dist/comics/lettering/__tests__/shapeText.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/shapes.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/shapes.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/shapes.test.js +137 -0
- package/dist/comics/lettering/__tests__/shapes.test.js.map +1 -0
- package/dist/comics/lettering/__tests__/stubMeasure.d.ts +5 -0
- package/dist/comics/lettering/__tests__/stubMeasure.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/stubMeasure.js +47 -0
- package/dist/comics/lettering/__tests__/stubMeasure.js.map +1 -0
- package/dist/comics/lettering/__tests__/text.test.d.ts +2 -0
- package/dist/comics/lettering/__tests__/text.test.d.ts.map +1 -0
- package/dist/comics/lettering/__tests__/text.test.js +86 -0
- package/dist/comics/lettering/__tests__/text.test.js.map +1 -0
- package/dist/comics/lettering/geom.d.ts +78 -0
- package/dist/comics/lettering/geom.d.ts.map +1 -0
- package/dist/comics/lettering/geom.js +376 -0
- package/dist/comics/lettering/geom.js.map +1 -0
- package/dist/comics/lettering/index.d.ts +7 -0
- package/dist/comics/lettering/index.d.ts.map +1 -0
- package/dist/comics/lettering/index.js +8 -0
- package/dist/comics/lettering/index.js.map +1 -0
- package/dist/comics/lettering/letter.d.ts +7 -0
- package/dist/comics/lettering/letter.d.ts.map +1 -0
- package/dist/comics/lettering/letter.js +496 -0
- package/dist/comics/lettering/letter.js.map +1 -0
- package/dist/comics/lettering/placement.d.ts +133 -0
- package/dist/comics/lettering/placement.d.ts.map +1 -0
- package/dist/comics/lettering/placement.js +1095 -0
- package/dist/comics/lettering/placement.js.map +1 -0
- package/dist/comics/lettering/presets.d.ts +15 -0
- package/dist/comics/lettering/presets.d.ts.map +1 -0
- package/dist/comics/lettering/presets.js +61 -0
- package/dist/comics/lettering/presets.js.map +1 -0
- package/dist/comics/lettering/shape-text.d.ts +72 -0
- package/dist/comics/lettering/shape-text.d.ts.map +1 -0
- package/dist/comics/lettering/shape-text.js +550 -0
- package/dist/comics/lettering/shape-text.js.map +1 -0
- package/dist/comics/lettering/shapes.d.ts +83 -0
- package/dist/comics/lettering/shapes.d.ts.map +1 -0
- package/dist/comics/lettering/shapes.js +512 -0
- package/dist/comics/lettering/shapes.js.map +1 -0
- package/dist/comics/lettering/text.d.ts +79 -0
- package/dist/comics/lettering/text.d.ts.map +1 -0
- package/dist/comics/lettering/text.js +488 -0
- package/dist/comics/lettering/text.js.map +1 -0
- package/dist/comics/lettering/types.d.ts +279 -0
- package/dist/comics/lettering/types.d.ts.map +1 -0
- package/dist/comics/lettering/types.js +9 -0
- package/dist/comics/lettering/types.js.map +1 -0
- package/dist/comics/page.d.ts +37 -0
- package/dist/comics/page.d.ts.map +1 -0
- package/dist/comics/page.js +285 -0
- package/dist/comics/page.js.map +1 -0
- package/dist/comics/paint.d.ts +65 -0
- package/dist/comics/paint.d.ts.map +1 -0
- package/dist/comics/paint.js +219 -0
- package/dist/comics/paint.js.map +1 -0
- package/dist/comics/script.d.ts +42 -0
- package/dist/comics/script.d.ts.map +1 -0
- package/dist/comics/script.js +302 -0
- package/dist/comics/script.js.map +1 -0
- package/dist/comics/split.d.ts +135 -0
- package/dist/comics/split.d.ts.map +1 -0
- package/dist/comics/split.js +514 -0
- package/dist/comics/split.js.map +1 -0
- package/dist/comics/spread.d.ts +36 -0
- package/dist/comics/spread.d.ts.map +1 -0
- package/dist/comics/spread.js +172 -0
- package/dist/comics/spread.js.map +1 -0
- package/dist/comics/strip.d.ts +77 -0
- package/dist/comics/strip.d.ts.map +1 -0
- package/dist/comics/strip.js +89 -0
- package/dist/comics/strip.js.map +1 -0
- package/dist/comics/transform.d.ts +37 -0
- package/dist/comics/transform.d.ts.map +1 -0
- package/dist/comics/transform.js +161 -0
- package/dist/comics/transform.js.map +1 -0
- package/dist/comics/types.d.ts +143 -0
- package/dist/comics/types.d.ts.map +1 -0
- package/dist/comics/types.js +13 -0
- package/dist/comics/types.js.map +1 -0
- package/dist/comics/warnings.d.ts +14 -0
- package/dist/comics/warnings.d.ts.map +1 -0
- package/dist/comics/warnings.js +94 -0
- package/dist/comics/warnings.js.map +1 -0
- package/dist/configWarnings.d.ts.map +1 -1
- package/dist/configWarnings.js +42 -0
- package/dist/configWarnings.js.map +1 -1
- package/dist/defaults/comics.d.ts +51 -0
- package/dist/defaults/comics.d.ts.map +1 -0
- package/dist/defaults/comics.js +385 -0
- package/dist/defaults/comics.js.map +1 -0
- package/dist/defaults/index.d.ts +1 -0
- package/dist/defaults/index.d.ts.map +1 -1
- package/dist/defaults/index.js +9 -0
- package/dist/defaults/index.js.map +1 -1
- package/dist/design/layout.d.ts +13 -1
- package/dist/design/layout.d.ts.map +1 -1
- package/dist/design/layout.js +17 -0
- package/dist/design/layout.js.map +1 -1
- package/dist/html-backend.d.ts +3 -0
- package/dist/html-backend.d.ts.map +1 -1
- package/dist/html-backend.js +44 -8
- package/dist/html-backend.js.map +1 -1
- package/dist/htmlComic.d.ts +69 -0
- package/dist/htmlComic.d.ts.map +1 -0
- package/dist/htmlComic.js +179 -0
- package/dist/htmlComic.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/parse/blockParser.d.ts.map +1 -1
- package/dist/parse/blockParser.js +25 -1
- package/dist/parse/blockParser.js.map +1 -1
- package/dist/parse/indexMarks.d.ts.map +1 -1
- package/dist/parse/indexMarks.js +3 -0
- package/dist/parse/indexMarks.js.map +1 -1
- package/dist/parse/types.d.ts +7 -1
- package/dist/parse/types.d.ts.map +1 -1
- package/dist/pipeline/build.d.ts.map +1 -1
- package/dist/pipeline/build.js +251 -9
- package/dist/pipeline/build.js.map +1 -1
- package/dist/pipeline/config.d.ts.map +1 -1
- package/dist/pipeline/config.js +4 -1
- package/dist/pipeline/config.js.map +1 -1
- package/dist/pipeline/contentWarnings.d.ts.map +1 -1
- package/dist/pipeline/contentWarnings.js +58 -0
- package/dist/pipeline/contentWarnings.js.map +1 -1
- package/dist/pipeline/floatSlots.js +1 -1
- package/dist/pipeline/floatSlots.js.map +1 -1
- package/dist/pipeline/headerFooter.d.ts +6 -2
- package/dist/pipeline/headerFooter.d.ts.map +1 -1
- package/dist/pipeline/headerFooter.js +11 -3
- package/dist/pipeline/headerFooter.js.map +1 -1
- package/dist/pipeline/pageRoles.d.ts +1 -0
- package/dist/pipeline/pageRoles.d.ts.map +1 -1
- package/dist/pipeline/pageRoles.js +3 -0
- package/dist/pipeline/pageRoles.js.map +1 -1
- package/dist/pipeline/placement.d.ts +3 -3
- package/dist/pipeline/placement.d.ts.map +1 -1
- package/dist/pipeline/placement.js +4 -4
- package/dist/pipeline/placement.js.map +1 -1
- package/dist/types.d.ts +338 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vdt.d.ts +271 -2
- package/dist/vdt.d.ts.map +1 -1
- package/dist/vdt.js.map +1 -1
- package/dist/worker/fonts.d.ts +2 -1
- package/dist/worker/fonts.d.ts.map +1 -1
- package/dist/worker/fonts.js +11 -1
- package/dist/worker/fonts.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
// Where the balloons of one panel go (SPEC D3.3). Deterministic:
|
|
2
|
+
// captions pinned to a corner or an edge are butted first; the others are
|
|
3
|
+
// placed greedily in reading order, each at the best of a set of candidate
|
|
4
|
+
// centres (a grid over the panel and slots around its speaker), then a few
|
|
5
|
+
// passes move each one to its best candidate given all the others. The
|
|
6
|
+
// cost weighs hard faults (covering a face, an avoid zone, a speaker's
|
|
7
|
+
// mouth or another balloon; running out of the panel) far above the soft
|
|
8
|
+
// preferences (near its speaker, high in the panel, Kurlander's reading
|
|
9
|
+
// order mirrored for right-to-left pages, tails that do not cross). When a
|
|
10
|
+
// balloon cannot be placed cleanly the fallbacks are tried in order: break
|
|
11
|
+
// the border (when allowed), cover avoid zones (never faces), reshape the
|
|
12
|
+
// text block; then it is placed anyway and reported.
|
|
13
|
+
import { boundsOf, clipToRect, dist, insetConvex, nearestOnPolygon, norm, pointInConvex, pointInRect, rayExit, rectsOverlap, segmentHitsRect, segmentDistance, sub, add, scale, } from './geom';
|
|
14
|
+
import { tailLength } from './shapes';
|
|
15
|
+
const W = {
|
|
16
|
+
face: 60,
|
|
17
|
+
avoid: 25,
|
|
18
|
+
anchor: 30,
|
|
19
|
+
balloon: 60,
|
|
20
|
+
outside: 120,
|
|
21
|
+
order: 1.2,
|
|
22
|
+
orderBase: 5,
|
|
23
|
+
distance: 0.7,
|
|
24
|
+
far: 0.18,
|
|
25
|
+
below: 0.3,
|
|
26
|
+
height: 0.1,
|
|
27
|
+
start: 0.03,
|
|
28
|
+
cross: 15,
|
|
29
|
+
tailThrough: 20,
|
|
30
|
+
tangent: 0.6,
|
|
31
|
+
ownTail: 25,
|
|
32
|
+
otherFace: 15,
|
|
33
|
+
tailOverFigure: 5,
|
|
34
|
+
side: 3,
|
|
35
|
+
soft: 0.035,
|
|
36
|
+
longTail: 0.3,
|
|
37
|
+
};
|
|
38
|
+
/** The part of a panel lettering may use: its polygon, less what runs
|
|
39
|
+
* past the page's live area (a bleeding panel's art reaches the edge of
|
|
40
|
+
* the sheet; its lettering stays where the trim cannot cut it). */
|
|
41
|
+
export function livePolygon(panel) {
|
|
42
|
+
if (!panel.limit)
|
|
43
|
+
return panel.polygon.map((p) => ({ ...p }));
|
|
44
|
+
const clipped = clipToRect(panel.polygon, panel.limit);
|
|
45
|
+
return clipped.length >= 3 ? clipped : panel.polygon.map((p) => ({ ...p }));
|
|
46
|
+
}
|
|
47
|
+
function insetRect(r, d) {
|
|
48
|
+
const dx = Math.min(d, r.width / 2);
|
|
49
|
+
const dy = Math.min(d, r.height / 2);
|
|
50
|
+
return { x: r.x + dx, y: r.y + dy, width: r.width - 2 * dx, height: r.height - 2 * dy };
|
|
51
|
+
}
|
|
52
|
+
function sceneOf(panel) {
|
|
53
|
+
const live = livePolygon(panel);
|
|
54
|
+
let visible = insetConvex(live, panel.insetPx);
|
|
55
|
+
if (visible.length < 3)
|
|
56
|
+
visible = live;
|
|
57
|
+
const trim = panel.trim ?? panel.limit;
|
|
58
|
+
return {
|
|
59
|
+
panel,
|
|
60
|
+
visible,
|
|
61
|
+
visBox: boundsOf(visible),
|
|
62
|
+
faces: panel.anchors.filter((a) => a.face).map((a) => a.face),
|
|
63
|
+
avoid: [...(panel.avoid ?? [])],
|
|
64
|
+
soft: figureContacts(panel.softAvoid ?? [], panel.anchors),
|
|
65
|
+
mouths: panel.anchors.filter((a) => a.visible && a.id !== 'sfx').map((a) => a.mouth),
|
|
66
|
+
fwd: panel.direction === 'rtl' ? -1 : 1,
|
|
67
|
+
buttPoly: buttPolygon(panel),
|
|
68
|
+
neighbours: [...(panel.neighbours ?? [])],
|
|
69
|
+
...(panel.limit ? { limit: panel.limit } : {}),
|
|
70
|
+
...(trim ? { edge: insetRect(trim, panel.insetPx), edgeSfx: panel.sheet ?? trim } : {}),
|
|
71
|
+
bleeds: panel.limit !== undefined && panel.polygon.some((p) => !pointInRect(p, panel.limit, 0.5)),
|
|
72
|
+
foreign: [...(panel.foreign ?? [])],
|
|
73
|
+
keepOut: [...(panel.keepOut ?? [])],
|
|
74
|
+
live,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** How many times a balloon costs more over another character's figure
|
|
78
|
+
* than over its own speaker's (a letterer covers the speaker's shoulder
|
|
79
|
+
* before the one being spoken to). */
|
|
80
|
+
const OTHER_FIGURE = 4;
|
|
81
|
+
/** Soft zones with `contact` marked on the figures of characters whose
|
|
82
|
+
* faces all but touch (a hug, cheek to cheek): there the arms of one lie
|
|
83
|
+
* over the other, and no speaker's own figure is free to cover. Two
|
|
84
|
+
* figures merely standing side by side are not in contact. */
|
|
85
|
+
function figureContacts(zones, anchors) {
|
|
86
|
+
const faceOf = (id) => anchors.find((a) => a.id === id)?.face;
|
|
87
|
+
const touching = (a, b) => {
|
|
88
|
+
const gx = Math.max(a.x, b.x) - Math.min(a.x + a.width, b.x + b.width);
|
|
89
|
+
const gy = Math.max(a.y, b.y) - Math.min(a.y + a.height, b.y + b.height);
|
|
90
|
+
return gx <= 0.25 * Math.min(a.width, b.width) && gy <= 0.25 * Math.min(a.height, b.height);
|
|
91
|
+
};
|
|
92
|
+
return zones.map((z) => {
|
|
93
|
+
const f = z.owner !== undefined ? faceOf(z.owner) : undefined;
|
|
94
|
+
const contact = f !== undefined && anchors.some((a) => a.id !== z.owner && a.face !== undefined && touching(f, a.face));
|
|
95
|
+
return contact ? { ...z, contact: true } : { ...z };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function buttPolygon(panel) {
|
|
99
|
+
const half = (panel.borderPx ?? 0) / 2;
|
|
100
|
+
const live = livePolygon(panel);
|
|
101
|
+
if (half <= 0)
|
|
102
|
+
return live;
|
|
103
|
+
const poly = insetConvex(live, half);
|
|
104
|
+
return poly.length >= 3 ? poly : live;
|
|
105
|
+
}
|
|
106
|
+
const moved = (pts, at) => pts.map((p) => ({ x: p.x + at.x, y: p.y + at.y }));
|
|
107
|
+
const movedRect = (r, at) => ({ x: r.x + at.x, y: r.y + at.y, width: r.width, height: r.height });
|
|
108
|
+
/** Area of `rect` the samples (standing for `area` px²) fall in. */
|
|
109
|
+
function coveredArea(samples, rect, area) {
|
|
110
|
+
let n = 0;
|
|
111
|
+
for (const s of samples)
|
|
112
|
+
if (pointInRect(s, rect))
|
|
113
|
+
n++;
|
|
114
|
+
return (n / Math.max(1, samples.length)) * area;
|
|
115
|
+
}
|
|
116
|
+
/** Whether a face fills a good part of its panel (a close shot): its box
|
|
117
|
+
* then has corners of hair a balloon may cover (in a long shot they are a
|
|
118
|
+
* few pixels beside the face). */
|
|
119
|
+
function closeShot(face, vis) {
|
|
120
|
+
return face.width >= 0.35 * vis.width || face.height >= 0.35 * vis.height;
|
|
121
|
+
}
|
|
122
|
+
/** Area of a face's box the samples fall in, less its top corners: above
|
|
123
|
+
* its middle a face is the oval its box holds (the corners are hair or
|
|
124
|
+
* background); below it, the whole box (cheeks, jaw and chin). */
|
|
125
|
+
function coveredOval(samples, rect, area) {
|
|
126
|
+
const cx = rect.x + rect.width / 2;
|
|
127
|
+
const cy = rect.y + rect.height / 2;
|
|
128
|
+
const rx = rect.width / 2;
|
|
129
|
+
const ry = rect.height / 2;
|
|
130
|
+
let n = 0;
|
|
131
|
+
for (const s of samples) {
|
|
132
|
+
if (!pointInRect(s, rect))
|
|
133
|
+
continue;
|
|
134
|
+
if (s.y >= cy || ((s.x - cx) / rx) ** 2 + ((s.y - cy) / ry) ** 2 <= 1)
|
|
135
|
+
n++;
|
|
136
|
+
}
|
|
137
|
+
return (n / Math.max(1, samples.length)) * area;
|
|
138
|
+
}
|
|
139
|
+
function areaOf(v) {
|
|
140
|
+
// The bodies' boxes, as an ellipse fills them.
|
|
141
|
+
return v.bodies.reduce((s, b) => s + b.bbox.width * b.bbox.height * 0.8, 0);
|
|
142
|
+
}
|
|
143
|
+
/** Where a unit's tail goes for the cost: the target point, or none. */
|
|
144
|
+
function tailTargetOf(unit, scene, centre) {
|
|
145
|
+
const t = unit.target;
|
|
146
|
+
if (t.kind === 'point')
|
|
147
|
+
return t.point;
|
|
148
|
+
if (t.kind === 'offPanel')
|
|
149
|
+
return offPanelPoint(scene.panel, centre, t.toward, t.side);
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/** The border point an off-panel tail runs to: toward a cropped anchor,
|
|
153
|
+
* on a named side, else the nearest border point. */
|
|
154
|
+
export function offPanelPoint(panel, centre, toward, side) {
|
|
155
|
+
const poly = livePolygon(panel);
|
|
156
|
+
if (toward) {
|
|
157
|
+
const u = norm(sub(toward, centre));
|
|
158
|
+
const t = rayExit(centre, u, poly);
|
|
159
|
+
if (t !== undefined)
|
|
160
|
+
return add(centre, scale(u, t));
|
|
161
|
+
}
|
|
162
|
+
if (side) {
|
|
163
|
+
const b = panel.bbox;
|
|
164
|
+
const rtl = panel.direction === 'rtl';
|
|
165
|
+
const phys = side === 'start' ? (rtl ? 'right' : 'left') : side === 'end' ? (rtl ? 'left' : 'right') : side;
|
|
166
|
+
const probe = phys === 'top' ? { x: centre.x, y: b.y - 1e4 }
|
|
167
|
+
: phys === 'bottom' ? { x: centre.x, y: b.y + b.height + 1e4 }
|
|
168
|
+
: phys === 'left' ? { x: b.x - 1e4, y: centre.y } : { x: b.x + b.width + 1e4, y: centre.y };
|
|
169
|
+
const u = norm(sub(probe, centre));
|
|
170
|
+
const t = rayExit(centre, u, poly);
|
|
171
|
+
if (t !== undefined)
|
|
172
|
+
return add(centre, scale(u, t));
|
|
173
|
+
}
|
|
174
|
+
return nearestOnPolygon(centre, poly);
|
|
175
|
+
}
|
|
176
|
+
/** Cost of `unit` (variant `vi`) at `at`, given the other placed units. */
|
|
177
|
+
function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
178
|
+
const v = unit.variants[vi];
|
|
179
|
+
const em = unit.em;
|
|
180
|
+
const em2 = em * em;
|
|
181
|
+
const box = movedRect(v.bbox, at);
|
|
182
|
+
const samples = moved(v.samples, at);
|
|
183
|
+
const area = areaOf(v);
|
|
184
|
+
const reasons = new Set();
|
|
185
|
+
let hard = 0;
|
|
186
|
+
let soft = 0;
|
|
187
|
+
let order = 0;
|
|
188
|
+
let cover = 0;
|
|
189
|
+
let coverArt = 0;
|
|
190
|
+
const S = (k, x) => {
|
|
191
|
+
soft += x;
|
|
192
|
+
if (k === 'order')
|
|
193
|
+
order += x;
|
|
194
|
+
else if (k === 'coverAvoid' || k === 'coverArt')
|
|
195
|
+
cover += x;
|
|
196
|
+
if (k === 'coverArt')
|
|
197
|
+
coverArt += x;
|
|
198
|
+
if (terms)
|
|
199
|
+
terms[k] = (terms[k] ?? 0) + x;
|
|
200
|
+
};
|
|
201
|
+
if (v.cost)
|
|
202
|
+
S('arrangement', v.cost);
|
|
203
|
+
const H = (k, x) => {
|
|
204
|
+
hard += x;
|
|
205
|
+
if (terms)
|
|
206
|
+
terms[`!${k}`] = (terms[`!${k}`] ?? 0) + x;
|
|
207
|
+
};
|
|
208
|
+
// Faces (in a close shot less the top corners of their boxes, hair or
|
|
209
|
+
// background), avoid zones, mouths.
|
|
210
|
+
for (const f of scene.faces) {
|
|
211
|
+
if (!rectsOverlap(box, f))
|
|
212
|
+
continue;
|
|
213
|
+
const a = closeShot(f, scene.visBox) ? coveredOval(samples, f, area) : coveredArea(samples, f, area);
|
|
214
|
+
if (a > 0) {
|
|
215
|
+
H('face', (W.face * a) / em2 + W.face);
|
|
216
|
+
reasons.add('face');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const r of scene.avoid) {
|
|
220
|
+
if (!rectsOverlap(box, r))
|
|
221
|
+
continue;
|
|
222
|
+
const a = coveredArea(samples, r, area);
|
|
223
|
+
if (a > 0) {
|
|
224
|
+
// (What the art marks costs three times what a guard over a head
|
|
225
|
+
// does: a balloon touches the top of the hair before it covers a
|
|
226
|
+
// hand the artist asked to keep clear.)
|
|
227
|
+
if (level.coverAvoid)
|
|
228
|
+
S(r.guard ? 'coverAvoid' : 'coverArt', ((r.guard ? 2 : 6) * a) / em2);
|
|
229
|
+
else {
|
|
230
|
+
H('avoid', (W.avoid * a) / em2 + W.avoid);
|
|
231
|
+
reasons.add('avoid');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const r of scene.soft) {
|
|
236
|
+
if (!rectsOverlap(box, r))
|
|
237
|
+
continue;
|
|
238
|
+
const other = r.owner !== undefined && (r.contact === true || r.owner !== unit.speaker);
|
|
239
|
+
S(other ? 'otherFigure' : 'softAvoid', ((r.weight ?? 1) * (other ? OTHER_FIGURE : 1) * W.soft * coveredArea(samples, r, area)) / em2);
|
|
240
|
+
}
|
|
241
|
+
const bodiesHere = v.bodies.map((b) => moved(b.core, at));
|
|
242
|
+
for (const m of scene.mouths) {
|
|
243
|
+
if (!pointInRect(m, box, 0.4 * em))
|
|
244
|
+
continue;
|
|
245
|
+
if (bodiesHere.some((poly) => pointInConvex(m, poly) || dist(nearestOnPolygon(m, poly), m) < 0.4 * em)) {
|
|
246
|
+
H('anchor', W.anchor);
|
|
247
|
+
reasons.add('anchor');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Outside the panel. A unit that breaks its border on purpose (`break`,
|
|
251
|
+
// at the level that allows it; a pin, where its author put it) may run
|
|
252
|
+
// past the border and the gutter: a little cost, never a fault — but
|
|
253
|
+
// never off the sheet, and never into another panel's art unless it is
|
|
254
|
+
// a sound effect or a `break` (drawn sound and a balloon breaking into
|
|
255
|
+
// the next panel are both the letterer's call). Only a pin, drawn sound
|
|
256
|
+
// or the `break` of a panel that bleeds leaves the live area: a balloon
|
|
257
|
+
// left to the engine breaks into the gutter and the next panel, never
|
|
258
|
+
// into the margin.
|
|
259
|
+
const rim = moved(v.rim, at);
|
|
260
|
+
const intended = unit.pin !== undefined || (level.breakBorder && unit.breakBorder === true);
|
|
261
|
+
const edge = !intended ? scene.limit
|
|
262
|
+
: unit.kind === 'sfx' ? scene.edgeSfx ?? scene.limit
|
|
263
|
+
: unit.pin !== undefined || scene.bleeds ? scene.edge ?? scene.limit
|
|
264
|
+
: scene.limit ?? scene.edge;
|
|
265
|
+
let out = 0;
|
|
266
|
+
let intrude = 0;
|
|
267
|
+
let offPage = 0;
|
|
268
|
+
// A butted caption sits flush with the border itself.
|
|
269
|
+
const area0 = unit.butt && unit.position ? scene.buttPoly : scene.visible;
|
|
270
|
+
for (const p of rim) {
|
|
271
|
+
if (!pointInConvex(p, area0) && !(unit.butt && unit.position && dist(nearestOnPolygon(p, area0), p) < 1)) {
|
|
272
|
+
out++;
|
|
273
|
+
if (edge && !pointInRect(p, edge))
|
|
274
|
+
offPage++;
|
|
275
|
+
else if (scene.neighbours.some((r) => pointInRect(p, r)))
|
|
276
|
+
intrude++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const mayIntrude = intended && (unit.kind === 'sfx' || unit.breakBorder === true);
|
|
280
|
+
// Off the sheet is the worst; into another panel (its art, its
|
|
281
|
+
// lettering) a fault weighed against the others — a balloon poking into
|
|
282
|
+
// the next panel reads better than one over its speaker's face.
|
|
283
|
+
if (offPage > 0) {
|
|
284
|
+
H('offPage', (12 * W.outside * offPage) / rim.length + 200);
|
|
285
|
+
reasons.add('outside');
|
|
286
|
+
}
|
|
287
|
+
if (intrude > 0 && !mayIntrude) {
|
|
288
|
+
H('neighbour', (4 * W.outside * intrude) / rim.length + 40);
|
|
289
|
+
reasons.add('outside');
|
|
290
|
+
}
|
|
291
|
+
else if (intrude > 0)
|
|
292
|
+
S('neighbour', 4 + (8 * intrude) / rim.length);
|
|
293
|
+
if (intended) {
|
|
294
|
+
if (out > 0) {
|
|
295
|
+
const f = out / rim.length;
|
|
296
|
+
// Breaking the border is a matter of degree: a balloon may stick out
|
|
297
|
+
// of its panel, but one whose middle stands outside it reads as
|
|
298
|
+
// another panel's (drawn sound may; so may a balloon its author
|
|
299
|
+
// pinned). A pinned balloon (no `break`) slides back in when it can.
|
|
300
|
+
S('breakBorder', 3 * f + (unit.kind !== 'sfx' && !unit.breakBorder ? 6 : 0));
|
|
301
|
+
if (unit.kind !== 'sfx' && !unit.pin && !pointInConvex({ x: box.x + box.width / 2, y: box.y + box.height / 2 }, scene.live)) {
|
|
302
|
+
H('outside', W.outside * f + 10);
|
|
303
|
+
reasons.add('outside');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (out > 0) {
|
|
308
|
+
H('outside', (W.outside * out) / rim.length + 10);
|
|
309
|
+
reasons.add('outside');
|
|
310
|
+
}
|
|
311
|
+
// The fold of a spread: never, at any level, pinned or not.
|
|
312
|
+
for (const r of scene.keepOut) {
|
|
313
|
+
if (!rectsOverlap(box, r))
|
|
314
|
+
continue;
|
|
315
|
+
const ox = Math.min(box.x + box.width, r.x + r.width) - Math.max(box.x, r.x);
|
|
316
|
+
H('avoid', (W.avoid * 4 * ox) / em + 4 * W.avoid);
|
|
317
|
+
reasons.add('avoid');
|
|
318
|
+
}
|
|
319
|
+
// Balloons of the other panels (a balloon breaking into this one).
|
|
320
|
+
for (const r of scene.foreign) {
|
|
321
|
+
if (!rectsOverlap(box, r, 0.3 * em))
|
|
322
|
+
continue;
|
|
323
|
+
const a = coveredArea(samples, grow(r, 0.3 * em), area);
|
|
324
|
+
if (a > 0) {
|
|
325
|
+
H('balloon', (W.balloon * a) / em2 + W.balloon);
|
|
326
|
+
reasons.add('balloon');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Other balloons: overlap is a fault, a near miss (a tangent) a blemish.
|
|
330
|
+
const centre = at;
|
|
331
|
+
const target = tailTargetOf(unit, scene, centre);
|
|
332
|
+
for (const o of others) {
|
|
333
|
+
const ov = o.unit.variants[o.variant];
|
|
334
|
+
const obox = movedRect(ov.bbox, o.at);
|
|
335
|
+
if (rectsOverlap(box, obox, 0.5 * em)) {
|
|
336
|
+
const opolys = ov.bodies.map((b) => moved(b.core, o.at));
|
|
337
|
+
let inside = 0;
|
|
338
|
+
for (const s of samples)
|
|
339
|
+
if (opolys.some((poly) => pointInConvex(s, poly)))
|
|
340
|
+
inside++;
|
|
341
|
+
if (inside > 0) {
|
|
342
|
+
H('balloon', (W.balloon * (inside / samples.length) * area) / em2 + W.balloon);
|
|
343
|
+
reasons.add('balloon');
|
|
344
|
+
}
|
|
345
|
+
else
|
|
346
|
+
S('tangent', W.tangent);
|
|
347
|
+
}
|
|
348
|
+
// Reading order (Kurlander), for the two in their script order.
|
|
349
|
+
const [eBox, lBox] = o.unit.order < unit.order ? [obox, box] : [box, obox];
|
|
350
|
+
const forward = (lBox.x + lBox.width / 2 - (eBox.x + eBox.width / 2)) * scene.fwd < 0;
|
|
351
|
+
// `forward`: the earlier one lies forward (in reading direction) of the
|
|
352
|
+
// later one: the later may be no higher than its bottom; else no
|
|
353
|
+
// higher than its top.
|
|
354
|
+
const limit = forward ? eBox.y + eBox.height - 0.25 * em : eBox.y - 0.25 * em;
|
|
355
|
+
if (unit.kind !== 'sfx' && o.unit.kind !== 'sfx' && lBox.y < limit) {
|
|
356
|
+
const overlapX = Math.min(lBox.x + lBox.width, eBox.x + eBox.width) - Math.max(lBox.x, eBox.x);
|
|
357
|
+
// Side by side at one height reads in order when the earlier one
|
|
358
|
+
// is behind; a later one above an earlier one never does.
|
|
359
|
+
S('order', W.orderBase + (W.order * (limit - lBox.y)) / em + (overlapX > 0 ? 1 : 0));
|
|
360
|
+
}
|
|
361
|
+
// Tails that cross (as drawn: from the wall to the tip), tails
|
|
362
|
+
// through another balloon.
|
|
363
|
+
const otarget = tailTargetOf(o.unit, scene, o.at);
|
|
364
|
+
const mine = target ? tailSegment(unit, v, at, target) : undefined;
|
|
365
|
+
const theirs = otarget ? tailSegment(o.unit, ov, o.at, otarget) : undefined;
|
|
366
|
+
if (mine && theirs) {
|
|
367
|
+
// Crossing tails; tails that all but touch read as crossed too
|
|
368
|
+
// (unless they run to one speaker).
|
|
369
|
+
const gapTails = segmentDistance(mine[0], mine[1], theirs[0], theirs[1]);
|
|
370
|
+
if (gapTails === 0)
|
|
371
|
+
S('cross', W.cross);
|
|
372
|
+
else if (gapTails < 0.6 * em && dist(target, otarget) > em)
|
|
373
|
+
S('cross', 0.4 * W.cross);
|
|
374
|
+
}
|
|
375
|
+
if (mine && ov.bodies.some((b) => segmentHitsRect(mine[0], mine[1], shrink(movedRect(b.bbox, o.at), 0.12))))
|
|
376
|
+
S('tailThrough', W.tailThrough);
|
|
377
|
+
if (theirs && v.bodies.some((b) => segmentHitsRect(theirs[0], theirs[1], shrink(movedRect(b.bbox, at), 0.12))))
|
|
378
|
+
S('tailThrough', W.tailThrough);
|
|
379
|
+
}
|
|
380
|
+
// The tail leaves the first body clear of the group's other bodies, by
|
|
381
|
+
// more than its own half width: a tail grazing another body of its group
|
|
382
|
+
// leaves a notch between them that the doubled outline fills with ink.
|
|
383
|
+
if (target && v.bodies.length > 1) {
|
|
384
|
+
const edge = edgePoint(v, at, target);
|
|
385
|
+
for (const b of v.bodies.slice(1))
|
|
386
|
+
if (segmentHitsRect(edge, target, grow(movedRect(b.bbox, at), 0.8 * em)))
|
|
387
|
+
S('ownTail', W.ownTail);
|
|
388
|
+
}
|
|
389
|
+
// Near the speaker, a little above; tails not over faces.
|
|
390
|
+
if (target && unit.target.kind === 'point') {
|
|
391
|
+
const edge = edgePoint(v, at, target);
|
|
392
|
+
const gap = dist(edge, target);
|
|
393
|
+
const h = v.bodies[0].bbox.height;
|
|
394
|
+
// Room for a tail of a few ems: too close is worse than too far.
|
|
395
|
+
const ideal = Math.max(2 * em, 0.7 * h);
|
|
396
|
+
S('distance', gap < ideal ? (W.distance * (ideal - gap)) / em : (W.far * (gap - ideal)) / em);
|
|
397
|
+
if (gap < 0.7 * em)
|
|
398
|
+
S('tooClose', 3);
|
|
399
|
+
// A tail longer than a few ems reads as an arrow: the cost of a far
|
|
400
|
+
// balloon grows faster past six ems. (Over a close-up the balloon goes
|
|
401
|
+
// above the head and its tail runs down past the face: that one is
|
|
402
|
+
// long by necessity.)
|
|
403
|
+
if (gap > 6 * em)
|
|
404
|
+
S('longTail', (W.longTail * (gap - 6 * em)) / em);
|
|
405
|
+
if (centre.y > target.y)
|
|
406
|
+
S('below', (W.below * (centre.y - target.y)) / em);
|
|
407
|
+
// A tail over another character's face (or the top of the head)
|
|
408
|
+
// reads as theirs.
|
|
409
|
+
const tip = add(edge, scale(norm(sub(target, edge)), gap * 0.55));
|
|
410
|
+
for (const f of scene.faces) {
|
|
411
|
+
const head = { x: f.x, y: f.y - 0.3 * f.height, width: f.width, height: 1.3 * f.height };
|
|
412
|
+
if (!segmentHitsRect(edge, tip, shrink(head, 0.1)))
|
|
413
|
+
continue;
|
|
414
|
+
S('otherFace', pointInRect(target, head) ? 1 : W.otherFace);
|
|
415
|
+
}
|
|
416
|
+
// A tail drawn across another figure (the body under a marked face):
|
|
417
|
+
// the eye follows it through the wrong character.
|
|
418
|
+
const drawn = tailSegment(unit, v, at, target);
|
|
419
|
+
for (const a of scene.panel.anchors) {
|
|
420
|
+
if (!a.face || a.mouth === target || a.head === target)
|
|
421
|
+
continue;
|
|
422
|
+
const f = a.face;
|
|
423
|
+
const figure = { x: f.x - 0.2 * f.width, y: f.y + f.height, width: 1.4 * f.width, height: 1.6 * f.height };
|
|
424
|
+
if (segmentHitsRect(drawn[0], drawn[1], figure))
|
|
425
|
+
S('tailOverFigure', W.tailOverFigure);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else if (unit.target.kind === 'offPanel' && target) {
|
|
429
|
+
// An off-panel speaker's balloon sits by the border its voice comes
|
|
430
|
+
// from, with a short tail to it (the letterer's practice): a tail
|
|
431
|
+
// across the panel reads as an arrow, so the balloon moves instead.
|
|
432
|
+
const reach = dist(edgePoint(v, at, target), target);
|
|
433
|
+
S('side', (W.side * Math.max(0, reach - 1.2 * em) + 3 * W.side * Math.max(0, reach - 3 * em)) / em);
|
|
434
|
+
}
|
|
435
|
+
if (unit.kind === 'sfx' && unit.near)
|
|
436
|
+
S('sfxNear', (0.5 * dist(centre, unit.near)) / em);
|
|
437
|
+
// High in the panel; the first one in the top start corner. (A unit set
|
|
438
|
+
// at a corner or an edge has its place: a caption at the foot of a panel
|
|
439
|
+
// is not drawn to the top of it.)
|
|
440
|
+
if (unit.kind !== 'sfx' && !unit.position) {
|
|
441
|
+
const top = box.y - scene.visBox.y;
|
|
442
|
+
S('height', ((scene.heightBias ?? 1) * (unit.kind === 'note' ? W.height * Math.max(0, scene.visBox.y + scene.visBox.height - (box.y + box.height)) : W.height * top)) / em);
|
|
443
|
+
if (unit.order === firstOrder && unit.kind !== 'note') {
|
|
444
|
+
const fromStart = scene.fwd === 1 ? box.x - scene.visBox.x : scene.visBox.x + scene.visBox.width - (box.x + box.width);
|
|
445
|
+
S('start', (W.start * fromStart) / em);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { hard, soft, reasons, order, cover, coverArt };
|
|
449
|
+
}
|
|
450
|
+
/** A rectangle grown by `d` px on every side. */
|
|
451
|
+
function grow(r, d) {
|
|
452
|
+
return { x: r.x - d, y: r.y - d, width: r.width + 2 * d, height: r.height + 2 * d };
|
|
453
|
+
}
|
|
454
|
+
function shrink(r, f) {
|
|
455
|
+
return { x: r.x + r.width * f, y: r.y + r.height * f, width: r.width * (1 - 2 * f), height: r.height * (1 - 2 * f) };
|
|
456
|
+
}
|
|
457
|
+
/** A unit's tail as drawn, for the cost: from where it leaves the first
|
|
458
|
+
* body to its tip ({@link tailLength} of the gap). */
|
|
459
|
+
function tailSegment(unit, v, at, target) {
|
|
460
|
+
const edge = edgePoint(v, at, target);
|
|
461
|
+
const gap = dist(edge, target);
|
|
462
|
+
const len = Math.max(Math.min(gap, 0.8 * unit.em), tailLength(gap, unit.em, {}, unit.target.kind === 'offPanel'));
|
|
463
|
+
return [edge, add(edge, scale(norm(sub(target, edge)), len))];
|
|
464
|
+
}
|
|
465
|
+
/** Where the line from the first body's centre to `target` leaves it. */
|
|
466
|
+
function edgePoint(v, at, target) {
|
|
467
|
+
const b = v.bodies[0];
|
|
468
|
+
const c = { x: b.centre.x + at.x, y: b.centre.y + at.y };
|
|
469
|
+
const u = norm(sub(target, c));
|
|
470
|
+
const t = rayExit(c, u, moved(b.core, at));
|
|
471
|
+
return t === undefined ? c : add(c, scale(u, t));
|
|
472
|
+
}
|
|
473
|
+
/** Candidate anchor positions of a unit (deterministic order). */
|
|
474
|
+
function candidates(scene, unit, vi) {
|
|
475
|
+
const v = unit.variants[vi];
|
|
476
|
+
const out = [];
|
|
477
|
+
const seen = new Set();
|
|
478
|
+
const push = (p) => {
|
|
479
|
+
const k = `${Math.round(p.x * 2)},${Math.round(p.y * 2)}`;
|
|
480
|
+
if (seen.has(k))
|
|
481
|
+
return;
|
|
482
|
+
seen.add(k);
|
|
483
|
+
out.push(p);
|
|
484
|
+
};
|
|
485
|
+
const vb = scene.visBox;
|
|
486
|
+
// Clamp the unit's box into the visible box (a candidate hugging a side).
|
|
487
|
+
const clamp = (p) => {
|
|
488
|
+
const box = movedRect(v.bbox, p);
|
|
489
|
+
let dx = 0;
|
|
490
|
+
let dy = 0;
|
|
491
|
+
if (box.width <= vb.width) {
|
|
492
|
+
if (box.x < vb.x)
|
|
493
|
+
dx = vb.x - box.x;
|
|
494
|
+
else if (box.x + box.width > vb.x + vb.width)
|
|
495
|
+
dx = vb.x + vb.width - (box.x + box.width);
|
|
496
|
+
}
|
|
497
|
+
if (box.height <= vb.height) {
|
|
498
|
+
if (box.y < vb.y)
|
|
499
|
+
dy = vb.y - box.y;
|
|
500
|
+
else if (box.y + box.height > vb.y + vb.height)
|
|
501
|
+
dy = vb.y + vb.height - (box.y + box.height);
|
|
502
|
+
}
|
|
503
|
+
return { x: p.x + dx, y: p.y + dy };
|
|
504
|
+
};
|
|
505
|
+
// Anchor-relative offset: the unit box's centre to its anchor.
|
|
506
|
+
const off = { x: -(v.bbox.x + v.bbox.width / 2), y: -(v.bbox.y + v.bbox.height / 2) };
|
|
507
|
+
const at = (cx, cy) => ({ x: cx + off.x, y: cy + off.y });
|
|
508
|
+
const w = v.bbox.width;
|
|
509
|
+
const h = v.bbox.height;
|
|
510
|
+
const em = unit.em;
|
|
511
|
+
// Around the target (speaker or sound-effect point).
|
|
512
|
+
const target = unit.target.kind === 'point' ? unit.target.point : unit.near;
|
|
513
|
+
if (target) {
|
|
514
|
+
for (const k of [0.6, 1.2, 2, 3]) {
|
|
515
|
+
for (const fx of [0, -0.45, 0.45, -0.85, 0.85]) {
|
|
516
|
+
const p = at(target.x + fx * w, target.y - h / 2 - k * em - 0.4 * h);
|
|
517
|
+
push(p);
|
|
518
|
+
push(clamp(p));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (const side of [-1, 1]) {
|
|
522
|
+
for (const k of [0, 0.6, 1.2]) {
|
|
523
|
+
const p = at(target.x + side * (w / 2 + 1.5 * em), target.y - k * h);
|
|
524
|
+
push(p);
|
|
525
|
+
push(clamp(p));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Under the speaker (a last resort the cost weighs: in a cell with no
|
|
529
|
+
// room above the head, below it beats over the face).
|
|
530
|
+
if (unit.kind !== 'sfx') {
|
|
531
|
+
for (const k of [1.2, 2.4]) {
|
|
532
|
+
for (const fx of [0, -0.45, 0.45])
|
|
533
|
+
push(at(target.x + fx * w, target.y + h / 2 + k * em));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (unit.kind === 'sfx')
|
|
537
|
+
push(at(target.x, target.y));
|
|
538
|
+
}
|
|
539
|
+
// A grid over the visible box.
|
|
540
|
+
const step = Math.max(em, Math.min(vb.width, vb.height) / 12);
|
|
541
|
+
for (let y = vb.y + h / 2; y <= vb.y + vb.height - h / 2 + 1e-6; y += step) {
|
|
542
|
+
for (let x = vb.x + w / 2; x <= vb.x + vb.width - w / 2 + 1e-6; x += step)
|
|
543
|
+
push(at(x, y));
|
|
544
|
+
}
|
|
545
|
+
// The corners and edges, butted against the visible box.
|
|
546
|
+
for (const fy of [0, 0.5, 1]) {
|
|
547
|
+
for (const fx of [0, 0.5, 1])
|
|
548
|
+
push(clamp(at(vb.x + fx * vb.width + (0.5 - fx) * w, vb.y + fy * vb.height + (0.5 - fy) * h)));
|
|
549
|
+
}
|
|
550
|
+
if (out.length === 0)
|
|
551
|
+
push(at(vb.x + vb.width / 2, vb.y + vb.height / 2));
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
/** The anchor a corner or edge keyword puts a unit at: its box flush
|
|
555
|
+
* with the panel (`butt`) or with the inset area, at that corner. */
|
|
556
|
+
export function positionAnchor(scene, v, position, butt, along) {
|
|
557
|
+
const poly = butt ? scene.buttPoly : scene.visible;
|
|
558
|
+
const box = boundsOf(poly);
|
|
559
|
+
const startLeft = scene.fwd === 1;
|
|
560
|
+
// `along`: where on its edge (0 left, 1 right) instead of the keyword's.
|
|
561
|
+
const hx = along !== undefined ? along : position === 'top' || position === 'bottom' ? 0.5
|
|
562
|
+
: position.endsWith('start') ? (startLeft ? 0 : 1) : (startLeft ? 1 : 0);
|
|
563
|
+
const vy = position.startsWith('top') ? 0 : 1;
|
|
564
|
+
const bx = box.x + hx * (box.width - v.bbox.width);
|
|
565
|
+
const by = box.y + vy * (box.height - v.bbox.height);
|
|
566
|
+
// Move inward until the box's corners sit inside a slanted cell.
|
|
567
|
+
const inward = { x: hx === 0 ? 1 : hx === 1 ? -1 : 0, y: vy === 0 ? 1 : -1 };
|
|
568
|
+
let p = { x: bx - v.bbox.x, y: by - v.bbox.y };
|
|
569
|
+
for (let i = 0; i < 400; i++) {
|
|
570
|
+
const r = movedRect(v.bbox, p);
|
|
571
|
+
const corners = [{ x: r.x, y: r.y }, { x: r.x + r.width, y: r.y }, { x: r.x + r.width, y: r.y + r.height }, { x: r.x, y: r.y + r.height }];
|
|
572
|
+
if (corners.every((c) => pointInConvex(c, poly) || dist(nearestOnPolygon(c, poly), c) < 0.75))
|
|
573
|
+
break;
|
|
574
|
+
p = { x: p.x + inward.x, y: p.y + inward.y };
|
|
575
|
+
}
|
|
576
|
+
return p;
|
|
577
|
+
}
|
|
578
|
+
/** A corner or edge unit's anchors: its own corner first, then — a cost
|
|
579
|
+
* each, so taken only when its own covers a face, an avoid zone or a
|
|
580
|
+
* balloon — the other corner of that edge, the middle of it, the spots
|
|
581
|
+
* along it just clear of what it would cover (between two heads), and
|
|
582
|
+
* only then the corners of the opposite edge: a caption at the head of a
|
|
583
|
+
* panel (a strip's title) stays at its head when there is room there. */
|
|
584
|
+
function positionAnchors(scene, v, position, butt) {
|
|
585
|
+
const top = position.startsWith('top');
|
|
586
|
+
const across = position.endsWith('start') ? (top ? 'top-end' : 'bottom-end') : position.endsWith('end') ? (top ? 'top-start' : 'bottom-start') : (top ? 'top-start' : 'bottom-start');
|
|
587
|
+
const middle = top ? 'top' : 'bottom';
|
|
588
|
+
const flip = (q) => (q.startsWith('top') ? q.replace('top', 'bottom') : q.replace('bottom', 'top'));
|
|
589
|
+
const list = [[position, 0], [across, 2], [middle, 3], [flip(position), 5], [flip(across), 6]];
|
|
590
|
+
const seen = new Set();
|
|
591
|
+
const out = list.filter(([q]) => (seen.has(q) ? false : (seen.add(q), true))).map(([q, extra]) => ({ at: positionAnchor(scene, v, q, butt), extra }));
|
|
592
|
+
// Along the edge: the box butted against each side of every face or
|
|
593
|
+
// avoid zone in the edge's band, nearer its own corner first.
|
|
594
|
+
const own = positionAnchor(scene, v, position, butt);
|
|
595
|
+
const band = movedRect(v.bbox, own);
|
|
596
|
+
const span = boundsOf(butt ? scene.buttPoly : scene.visible);
|
|
597
|
+
const free = span.width - v.bbox.width;
|
|
598
|
+
if (free > 0) {
|
|
599
|
+
const ownX = band.x;
|
|
600
|
+
for (const r of [...scene.faces, ...scene.avoid]) {
|
|
601
|
+
if (r.y > band.y + band.height || r.y + r.height < band.y)
|
|
602
|
+
continue;
|
|
603
|
+
for (const x of [r.x + r.width + 1, r.x - v.bbox.width - 1]) {
|
|
604
|
+
const f = (x - span.x) / free;
|
|
605
|
+
if (f <= 0 || f >= 1)
|
|
606
|
+
continue;
|
|
607
|
+
out.push({ at: positionAnchor(scene, v, position, butt, f), extra: 3.5 + Math.abs(x - ownX) / span.width });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
const LEVELS = [
|
|
614
|
+
{ breakBorder: false, coverAvoid: false },
|
|
615
|
+
{ breakBorder: true, coverAvoid: false },
|
|
616
|
+
{ breakBorder: true, coverAvoid: true },
|
|
617
|
+
];
|
|
618
|
+
function better(a, b) {
|
|
619
|
+
if (a.hard > 0 || b.hard > 0)
|
|
620
|
+
return a.hard * 1000 + a.soft < b.hard * 1000 + b.soft - 1e-9;
|
|
621
|
+
return a.soft < b.soft - 1e-9;
|
|
622
|
+
}
|
|
623
|
+
/** Best spot for one unit given the others, over its variants and the
|
|
624
|
+
* fallback levels (in SPEC order); the first clean choice wins. */
|
|
625
|
+
function bestFor(scene, unit, others, firstOrder, fixed) {
|
|
626
|
+
let fallback;
|
|
627
|
+
const variants = fixed ? [fixed.variant] : unit.variants.map((_, i) => i);
|
|
628
|
+
const levels = fixed ? [fixed.level] : LEVELS.map((_, i) => i).filter((i) => i === 0 || unit.breakBorder || i === 2);
|
|
629
|
+
// Every arrangement of a joined group competes on its cost; a reshaped
|
|
630
|
+
// text block (wider or narrower than its best shape) pays a little for
|
|
631
|
+
// its poorer shape, and is tried before covering an avoid zone (a
|
|
632
|
+
// letterer reshapes the text before covering a hand).
|
|
633
|
+
const penalty = (vi) => (unit.variants[vi].reshaped ? RESHAPE_COST : 0);
|
|
634
|
+
for (const li of levels) {
|
|
635
|
+
const level = LEVELS[li];
|
|
636
|
+
let best;
|
|
637
|
+
for (const vi of variants) {
|
|
638
|
+
const cands = unit.pin
|
|
639
|
+
// (The pin as written wins unless it runs out of the panel.)
|
|
640
|
+
? pinAnchors(scene, unit, vi).map((at, i) => ({ at, extra: i === 0 ? 0 : 4 }))
|
|
641
|
+
: unit.position
|
|
642
|
+
? positionAnchors(scene, unit.variants[vi], unit.position, unit.butt === true)
|
|
643
|
+
: candidates(scene, unit, vi).map((at) => ({ at, extra: 0 }));
|
|
644
|
+
for (const { at, extra } of cands) {
|
|
645
|
+
const ev = evaluate(scene, unit, vi, at, others, level, firstOrder);
|
|
646
|
+
const score = ev.hard * 1000 + ev.soft + penalty(vi) + extra;
|
|
647
|
+
if (!best || score < best.score - 1e-9)
|
|
648
|
+
best = { variant: vi, level: li, at, ev, score };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!best)
|
|
652
|
+
continue;
|
|
653
|
+
const choice = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
|
|
654
|
+
if (choice.ev.hard === 0)
|
|
655
|
+
return fixed || li !== 0 ? choice : orderBeforeAvoid(scene, unit, others, firstOrder, best, penalty);
|
|
656
|
+
if (!fallback || better(choice.ev, fallback.ev))
|
|
657
|
+
fallback = choice;
|
|
658
|
+
}
|
|
659
|
+
return fallback;
|
|
660
|
+
}
|
|
661
|
+
/** What taking the last fallback (covering an avoid zone a little) to keep
|
|
662
|
+
* the reading order costs on top of the cover itself. */
|
|
663
|
+
const ORDER_OVER_AVOID = 4;
|
|
664
|
+
/** The most a balloon may cover of avoid zones (as their cost: 2 per em²
|
|
665
|
+
* of a guard over a head, 6 of a zone the art marks) to keep the reading
|
|
666
|
+
* order: the top of the hair over a face, the edge of a hat; of what the
|
|
667
|
+
* art marks, hardly a corner. */
|
|
668
|
+
const ORDER_COVER_MAX = 16;
|
|
669
|
+
const ORDER_COVER_ART_MAX = 6;
|
|
670
|
+
/** Whether a spot (its evaluation and cost terms) may stand in for one
|
|
671
|
+
* that reads out of order: clean, in order, covering no more of avoid
|
|
672
|
+
* zones than the caps, and its tail off the other faces (a tail across
|
|
673
|
+
* another character's face reads as theirs, worse than a line read
|
|
674
|
+
* late). */
|
|
675
|
+
function keepsOrder(ev, terms) {
|
|
676
|
+
return ev.hard === 0 && ev.order < W.orderBase && ev.cover <= ORDER_COVER_MAX && ev.coverArt <= ORDER_COVER_ART_MAX
|
|
677
|
+
&& (terms.otherFace ?? 0) < W.otherFace;
|
|
678
|
+
}
|
|
679
|
+
/** A clean choice that reads out of order (the balloon above the one it
|
|
680
|
+
* answers, or beside it on the wrong side) against the same unit covering
|
|
681
|
+
* a little of an avoid zone in reading order: a letterer lets a balloon
|
|
682
|
+
* touch the top of a head before setting a reply ahead of its line. */
|
|
683
|
+
function orderBeforeAvoid(scene, unit, others, firstOrder, best, penalty) {
|
|
684
|
+
const plain = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
|
|
685
|
+
if (best.ev.order < W.orderBase || unit.pin || unit.position)
|
|
686
|
+
return plain;
|
|
687
|
+
const level = LEVELS[2];
|
|
688
|
+
let alt;
|
|
689
|
+
for (let vi = 0; vi < unit.variants.length; vi++) {
|
|
690
|
+
for (const at of candidates(scene, unit, vi)) {
|
|
691
|
+
const terms = {};
|
|
692
|
+
const ev = evaluate(scene, unit, vi, at, others, level, firstOrder, terms);
|
|
693
|
+
if (!keepsOrder(ev, terms))
|
|
694
|
+
continue;
|
|
695
|
+
const score = ev.soft + penalty(vi) + ORDER_OVER_AVOID;
|
|
696
|
+
if (!alt || score < alt.score - 1e-9)
|
|
697
|
+
alt = { variant: vi, level: 2, at, ev, score };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return alt && alt.score < best.score - 1e-9 ? { variant: alt.variant, level: alt.level, at: alt.at, ev: alt.ev } : plain;
|
|
701
|
+
}
|
|
702
|
+
/** What choosing a reshaped text block costs (its poorer shape). */
|
|
703
|
+
const RESHAPE_COST = 1.5;
|
|
704
|
+
/** A pinned unit's anchors: on its pin, and moved the least that keeps
|
|
705
|
+
* its box inside the panel (a pinned label too long for the room left
|
|
706
|
+
* at its pin slides in rather than run out of the panel). */
|
|
707
|
+
function pinAnchors(scene, unit, vi) {
|
|
708
|
+
const p = pinAnchor(unit, vi);
|
|
709
|
+
const v = unit.variants[vi];
|
|
710
|
+
const box = movedRect(v.bbox, p);
|
|
711
|
+
// Drawn sound stays where its author put it, over borders and all; it
|
|
712
|
+
// only slides back onto the sheet. A pinned balloon slides into its
|
|
713
|
+
// panel when it would run out of it.
|
|
714
|
+
const vb = unit.kind === 'sfx' ? (scene.edgeSfx ?? scene.limit ?? scene.visBox) : scene.visBox;
|
|
715
|
+
const shift = (lo, size, min, max) => size > max - min ? (min + max) / 2 - (lo + size / 2) : lo < min ? min - lo : lo + size > max ? max - (lo + size) : 0;
|
|
716
|
+
const dx = shift(box.x, box.width, vb.x, vb.x + vb.width);
|
|
717
|
+
const dy = shift(box.y, box.height, vb.y, vb.y + vb.height);
|
|
718
|
+
const out = dx === 0 && dy === 0 ? [p] : [p, { x: p.x + dx, y: p.y + dy }];
|
|
719
|
+
// Off the fold of a spread, to the nearer side (then the other).
|
|
720
|
+
for (const q of [...out]) {
|
|
721
|
+
const b = movedRect(v.bbox, q);
|
|
722
|
+
for (const r of scene.keepOut) {
|
|
723
|
+
if (!rectsOverlap(b, r))
|
|
724
|
+
continue;
|
|
725
|
+
const left = r.x - (b.x + b.width) - 1;
|
|
726
|
+
const right = r.x + r.width - b.x + 1;
|
|
727
|
+
const sides = Math.abs(left) <= Math.abs(right) ? [left, right] : [right, left];
|
|
728
|
+
for (const d of sides)
|
|
729
|
+
out.push({ x: q.x + d, y: q.y });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
/** The anchor that puts a pinned unit's box centre on its pin; for a
|
|
735
|
+
* joined group, the centre of its first body's box (the pinned line's
|
|
736
|
+
* balloon: the lines joined to it follow wherever the group arranges
|
|
737
|
+
* them). */
|
|
738
|
+
function pinAnchor(unit, vi) {
|
|
739
|
+
const v = unit.variants[vi];
|
|
740
|
+
const box = v.bodies.length > 1 ? v.bodies[0].bbox : v.bbox;
|
|
741
|
+
return { x: unit.pin.x - (box.x + box.width / 2), y: unit.pin.y - (box.y + box.height / 2) };
|
|
742
|
+
}
|
|
743
|
+
function solve(scene, ordered, firstOrder, passes, mode = 'reading') {
|
|
744
|
+
const placed = [];
|
|
745
|
+
// Corner and edge captions first, then pinned units, then the rest in
|
|
746
|
+
// reading order (or the biggest first: in a crowded panel the balloon
|
|
747
|
+
// that needs the most room takes it first); a sound effect with no place
|
|
748
|
+
// of its own (no pin, no `sfx` point in the art) last: the dialogue
|
|
749
|
+
// takes the room it needs first, the sound fills what is left.
|
|
750
|
+
const rank = (u) => (u.position ? 0 : u.pin ? 1 : u.kind === 'sfx' && !u.near ? 3 : 2);
|
|
751
|
+
const size = (u) => u.variants[0].bbox.width * u.variants[0].bbox.height;
|
|
752
|
+
const standing = (u) => (u.target.kind === 'point' ? scene.fwd * u.target.point.x : Number.POSITIVE_INFINITY);
|
|
753
|
+
const sequence = [...ordered].sort((a, b) => rank(a) - rank(b)
|
|
754
|
+
|| (mode === 'big' ? size(b) - size(a) : mode === 'speaker' ? standing(a) - standing(b) : 0)
|
|
755
|
+
|| a.order - b.order);
|
|
756
|
+
for (const unit of sequence) {
|
|
757
|
+
const c = bestFor(scene, unit, placed, firstOrder);
|
|
758
|
+
placed.push({ unit, variant: c.variant, at: c.at, level: c.level, ev: c.ev });
|
|
759
|
+
}
|
|
760
|
+
// Local improvement: each free unit moves to its best spot given the
|
|
761
|
+
// others, keeping its variant and level.
|
|
762
|
+
return solveFrom(scene, placed, firstOrder, passes);
|
|
763
|
+
}
|
|
764
|
+
/** Improvement passes from an existing layout. */
|
|
765
|
+
function solveFrom(scene, start, firstOrder, passes) {
|
|
766
|
+
let placed = [...start];
|
|
767
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
768
|
+
let changed = false;
|
|
769
|
+
// Two balloons whose tails cross trade places (no single move can
|
|
770
|
+
// undo a crossing: each balloon alone is best where it is).
|
|
771
|
+
const swapped = swapCrossing(scene, placed, firstOrder);
|
|
772
|
+
if (swapped) {
|
|
773
|
+
placed = swapped;
|
|
774
|
+
changed = true;
|
|
775
|
+
}
|
|
776
|
+
for (let i = 0; i < placed.length; i++) {
|
|
777
|
+
const p = placed[i];
|
|
778
|
+
if (p.unit.pin || p.unit.position)
|
|
779
|
+
continue;
|
|
780
|
+
const others = placed.filter((_, j) => j !== i);
|
|
781
|
+
const now = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
|
|
782
|
+
const c = bestFor(scene, p.unit, others, firstOrder, { variant: p.variant, level: p.level });
|
|
783
|
+
if (better(c.ev, now) && (c.at.x !== p.at.x || c.at.y !== p.at.y)) {
|
|
784
|
+
placed[i] = { ...p, at: c.at, ev: c.ev };
|
|
785
|
+
changed = true;
|
|
786
|
+
}
|
|
787
|
+
else
|
|
788
|
+
placed[i] = { ...p, ev: now };
|
|
789
|
+
}
|
|
790
|
+
if (!changed)
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
return placed;
|
|
794
|
+
}
|
|
795
|
+
/** The layout with the first pair of free units whose tails cross
|
|
796
|
+
* swapped (each box centred where the other's was, then settled at its
|
|
797
|
+
* best spot), when that lowers the whole layout's cost; else undefined. */
|
|
798
|
+
function swapCrossing(scene, placed, firstOrder) {
|
|
799
|
+
const free = (p) => !p.unit.pin && !p.unit.position && p.unit.kind === 'balloon';
|
|
800
|
+
const centreOf = (p) => {
|
|
801
|
+
const b = p.unit.variants[p.variant].bbox;
|
|
802
|
+
return { x: p.at.x + b.x + b.width / 2, y: p.at.y + b.y + b.height / 2 };
|
|
803
|
+
};
|
|
804
|
+
const base = totalCost(scene, placed, firstOrder);
|
|
805
|
+
for (let i = 0; i < placed.length; i++) {
|
|
806
|
+
for (let j = i + 1; j < placed.length; j++) {
|
|
807
|
+
const a = placed[i];
|
|
808
|
+
const b = placed[j];
|
|
809
|
+
if (!free(a) || !free(b))
|
|
810
|
+
continue;
|
|
811
|
+
const ta = tailTargetOf(a.unit, scene, a.at);
|
|
812
|
+
const tb = tailTargetOf(b.unit, scene, b.at);
|
|
813
|
+
if (!ta || !tb)
|
|
814
|
+
continue;
|
|
815
|
+
const sa = tailSegment(a.unit, a.unit.variants[a.variant], a.at, ta);
|
|
816
|
+
const sb = tailSegment(b.unit, b.unit.variants[b.variant], b.at, tb);
|
|
817
|
+
const em = Math.min(a.unit.em, b.unit.em);
|
|
818
|
+
if (segmentDistance(sa[0], sa[1], sb[0], sb[1]) >= 0.6 * em || dist(ta, tb) <= em)
|
|
819
|
+
continue;
|
|
820
|
+
const ca = centreOf(a);
|
|
821
|
+
const cb = centreOf(b);
|
|
822
|
+
const moveTo = (p, c) => {
|
|
823
|
+
const bb = p.unit.variants[p.variant].bbox;
|
|
824
|
+
return { ...p, at: { x: c.x - (bb.x + bb.width / 2), y: c.y - (bb.y + bb.height / 2) } };
|
|
825
|
+
};
|
|
826
|
+
const raw = placed.map((p, k) => (k === i ? moveTo(a, cb) : k === j ? moveTo(b, ca) : p));
|
|
827
|
+
// Then each settled at its best spot given the other.
|
|
828
|
+
const settled = [...raw];
|
|
829
|
+
for (const k of [i, j]) {
|
|
830
|
+
const p = settled[k];
|
|
831
|
+
const others = settled.filter((_, m) => m !== k);
|
|
832
|
+
const c = bestFor(scene, p.unit, others, firstOrder, { variant: p.variant, level: p.level });
|
|
833
|
+
const now = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
|
|
834
|
+
if (better(c.ev, now))
|
|
835
|
+
settled[k] = { ...p, at: c.at, ev: c.ev };
|
|
836
|
+
}
|
|
837
|
+
// Or both taken out and placed again, each order in turn, the second
|
|
838
|
+
// seeing the first one's tail.
|
|
839
|
+
const replaced = (first, second) => {
|
|
840
|
+
const out = [...placed];
|
|
841
|
+
const rest = () => out.filter((_, m) => m !== i && m !== j);
|
|
842
|
+
const p1 = out[first];
|
|
843
|
+
const c1 = bestFor(scene, p1.unit, rest(), firstOrder);
|
|
844
|
+
out[first] = { ...p1, variant: c1.variant, level: c1.level, at: c1.at, ev: c1.ev };
|
|
845
|
+
const p2 = out[second];
|
|
846
|
+
const c2 = bestFor(scene, p2.unit, out.filter((_, m) => m !== second), firstOrder);
|
|
847
|
+
out[second] = { ...p2, variant: c2.variant, level: c2.level, at: c2.at, ev: c2.ev };
|
|
848
|
+
return out;
|
|
849
|
+
};
|
|
850
|
+
let trial;
|
|
851
|
+
let cost = base;
|
|
852
|
+
for (const t of [raw, settled, replaced(j, i), replaced(i, j)]) {
|
|
853
|
+
const c = totalCost(scene, t, firstOrder);
|
|
854
|
+
if (c < cost - 1e-6) {
|
|
855
|
+
cost = c;
|
|
856
|
+
trial = t;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (trial)
|
|
860
|
+
return trial;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
/** The cheapest spot for `unit` that reads in order with the others, over
|
|
866
|
+
* all its shapes and fallback levels (see {@link keepsOrder}; covering an
|
|
867
|
+
* avoid zone pays as much more as in {@link orderBeforeAvoid}); undefined
|
|
868
|
+
* when there is none. */
|
|
869
|
+
function inOrder(scene, unit, others, firstOrder) {
|
|
870
|
+
const levels = LEVELS.map((_, i) => i).filter((i) => i === 0 || unit.breakBorder || i === 2);
|
|
871
|
+
let best;
|
|
872
|
+
for (const li of levels) {
|
|
873
|
+
for (let vi = 0; vi < unit.variants.length; vi++) {
|
|
874
|
+
const penalty = unit.variants[vi].reshaped ? RESHAPE_COST : 0;
|
|
875
|
+
for (const at of candidates(scene, unit, vi)) {
|
|
876
|
+
const terms = {};
|
|
877
|
+
const ev = evaluate(scene, unit, vi, at, others, LEVELS[li], firstOrder, terms);
|
|
878
|
+
if (!keepsOrder(ev, terms))
|
|
879
|
+
continue;
|
|
880
|
+
const score = ev.soft + penalty + (ev.cover > 0 ? ORDER_OVER_AVOID : 0);
|
|
881
|
+
if (!best || score < best.score - 1e-9)
|
|
882
|
+
best = { variant: vi, level: li, at, ev, score };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return best && { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
|
|
887
|
+
}
|
|
888
|
+
/** The layout with one balloon that reads out of order placed again over
|
|
889
|
+
* all its shapes and fallbacks, the others where they stand, when that
|
|
890
|
+
* lowers the whole layout's cost; else undefined. The improvement passes
|
|
891
|
+
* keep a balloon's shape and level, and a pair placed again has the later
|
|
892
|
+
* balloon alone first, in the room the earlier one needs: neither moves
|
|
893
|
+
* the first line of a pair above the hair of its speaker, in a shape a
|
|
894
|
+
* line deeper, ahead of the reply that already stands below it. */
|
|
895
|
+
function reorder(scene, placed, firstOrder) {
|
|
896
|
+
const base = totalCost(scene, placed, firstOrder);
|
|
897
|
+
let best;
|
|
898
|
+
let cost = base;
|
|
899
|
+
for (let i = 0; i < placed.length; i++) {
|
|
900
|
+
const p = placed[i];
|
|
901
|
+
if (p.unit.pin || p.unit.position || p.unit.kind === 'sfx' || p.ev.order < W.orderBase)
|
|
902
|
+
continue;
|
|
903
|
+
const others = placed.filter((_, j) => j !== i);
|
|
904
|
+
const c = inOrder(scene, p.unit, others, firstOrder);
|
|
905
|
+
if (!c)
|
|
906
|
+
continue;
|
|
907
|
+
const out = placed.map((q, j) => (j === i ? { ...q, variant: c.variant, level: c.level, at: c.at, ev: c.ev } : q));
|
|
908
|
+
// (Covering an avoid zone to read in order pays what `bestFor` asks;
|
|
909
|
+
// the whole layout's cost counts the order on both balloons of a pair.)
|
|
910
|
+
const t = totalCost(scene, out, firstOrder) + (c.ev.cover > 0 ? ORDER_OVER_AVOID : 0);
|
|
911
|
+
if (t < cost - 1e-6) {
|
|
912
|
+
cost = t;
|
|
913
|
+
best = out;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return best;
|
|
917
|
+
}
|
|
918
|
+
/** The layout with one pair of free units taken out and placed again
|
|
919
|
+
* (each order in turn, the second seeing the first) when that lowers the
|
|
920
|
+
* whole layout's cost; else undefined. A single move cannot undo a pair
|
|
921
|
+
* that blocks itself: the first speaker's balloon in the corner the second
|
|
922
|
+
* needs, the second pushed onto the figures. */
|
|
923
|
+
function replacePair(scene, placed, firstOrder) {
|
|
924
|
+
const free = (p) => !p.unit.pin && !p.unit.position && p.unit.kind !== 'sfx';
|
|
925
|
+
const base = totalCost(scene, placed, firstOrder);
|
|
926
|
+
let best;
|
|
927
|
+
let cost = base;
|
|
928
|
+
for (let i = 0; i < placed.length; i++) {
|
|
929
|
+
for (let j = i + 1; j < placed.length; j++) {
|
|
930
|
+
if (!free(placed[i]) || !free(placed[j]))
|
|
931
|
+
continue;
|
|
932
|
+
for (const [first, second] of [[i, j], [j, i]]) {
|
|
933
|
+
const out = [...placed];
|
|
934
|
+
const p1 = out[first];
|
|
935
|
+
const c1 = bestFor(scene, p1.unit, out.filter((_, m) => m !== i && m !== j), firstOrder);
|
|
936
|
+
out[first] = { ...p1, variant: c1.variant, level: c1.level, at: c1.at, ev: c1.ev };
|
|
937
|
+
const p2 = out[second];
|
|
938
|
+
const c2 = bestFor(scene, p2.unit, out.filter((_, m) => m !== second), firstOrder);
|
|
939
|
+
out[second] = { ...p2, variant: c2.variant, level: c2.level, at: c2.at, ev: c2.ev };
|
|
940
|
+
const c = totalCost(scene, out, firstOrder);
|
|
941
|
+
if (c < cost - 1e-6) {
|
|
942
|
+
cost = c;
|
|
943
|
+
best = out;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return best;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* A placed layout with its crossed tails undone: pairs of balloons whose
|
|
952
|
+
* tails cross (or all but touch) trade places or are placed again, each
|
|
953
|
+
* seeing the other's tail, as long as the whole layout gets cheaper. What
|
|
954
|
+
* {@link placeUnits} runs between its improvement passes, on its own.
|
|
955
|
+
*/
|
|
956
|
+
export function uncrossTails(panel, placed) {
|
|
957
|
+
const scene = sceneOf(panel);
|
|
958
|
+
const ordered = [...placed].sort((a, b) => a.unit.order - b.unit.order);
|
|
959
|
+
const firstOrder = ordered.find((p) => p.unit.kind !== 'sfx' && p.unit.kind !== 'note')?.unit.order ?? Number.NaN;
|
|
960
|
+
let layout = placed.map((p) => {
|
|
961
|
+
const others = placed.filter((o) => o !== p);
|
|
962
|
+
return { ...p, level: 0, ev: evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[0], firstOrder) };
|
|
963
|
+
});
|
|
964
|
+
for (let k = 0; k < 4; k++) {
|
|
965
|
+
const next = swapCrossing(scene, layout, firstOrder);
|
|
966
|
+
if (!next)
|
|
967
|
+
break;
|
|
968
|
+
layout = next;
|
|
969
|
+
}
|
|
970
|
+
return layout.map(({ ev: _ev, level: _level, ...rest }) => rest);
|
|
971
|
+
}
|
|
972
|
+
/** How many pairs of tails of a layout cross (or all but touch). */
|
|
973
|
+
function crossings(scene, placed) {
|
|
974
|
+
let n = 0;
|
|
975
|
+
for (let i = 0; i < placed.length; i++) {
|
|
976
|
+
for (let j = i + 1; j < placed.length; j++) {
|
|
977
|
+
const a = placed[i];
|
|
978
|
+
const b = placed[j];
|
|
979
|
+
const ta = tailTargetOf(a.unit, scene, a.at);
|
|
980
|
+
const tb = tailTargetOf(b.unit, scene, b.at);
|
|
981
|
+
if (!ta || !tb || dist(ta, tb) <= Math.min(a.unit.em, b.unit.em))
|
|
982
|
+
continue;
|
|
983
|
+
const sa = tailSegment(a.unit, a.unit.variants[a.variant], a.at, ta);
|
|
984
|
+
const sb = tailSegment(b.unit, b.unit.variants[b.variant], b.at, tb);
|
|
985
|
+
if (segmentDistance(sa[0], sa[1], sb[0], sb[1]) < 0.6 * Math.min(a.unit.em, b.unit.em))
|
|
986
|
+
n++;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return n;
|
|
990
|
+
}
|
|
991
|
+
/** The whole layout's cost under the plain weights (hard faults first). */
|
|
992
|
+
function totalCost(scene, placed, firstOrder) {
|
|
993
|
+
let hard = 0;
|
|
994
|
+
let soft = 0;
|
|
995
|
+
for (let i = 0; i < placed.length; i++) {
|
|
996
|
+
const p = placed[i];
|
|
997
|
+
const ev = evaluate(scene, p.unit, p.variant, p.at, placed.filter((_, j) => j !== i), LEVELS[p.level], firstOrder);
|
|
998
|
+
hard += ev.hard;
|
|
999
|
+
soft += ev.soft;
|
|
1000
|
+
}
|
|
1001
|
+
return hard * 1000 + soft;
|
|
1002
|
+
}
|
|
1003
|
+
/** The search of {@link placeUnits}: greedy runs in a few orders and
|
|
1004
|
+
* height biases, then pairs placed again; the cheapest layout. */
|
|
1005
|
+
function search(scene, ordered, firstOrder, passes) {
|
|
1006
|
+
let placed;
|
|
1007
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
1008
|
+
const tries = (mode) => {
|
|
1009
|
+
for (const bias of HEIGHT_BIASES) {
|
|
1010
|
+
let run = solve({ ...scene, heightBias: bias }, ordered, firstOrder, passes, mode);
|
|
1011
|
+
// Settle under the plain weights.
|
|
1012
|
+
if (bias !== 1)
|
|
1013
|
+
run = solveFrom(scene, run, firstOrder, passes);
|
|
1014
|
+
const cost = totalCost(scene, run, firstOrder);
|
|
1015
|
+
if (cost < bestCost - 1e-9) {
|
|
1016
|
+
bestCost = cost;
|
|
1017
|
+
placed = run;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
tries('reading');
|
|
1022
|
+
// A layout with a fault left (a balloon out of the panel, over a face or
|
|
1023
|
+
// another balloon): the greedy order may have boxed the last balloons
|
|
1024
|
+
// in; try again placing the biggest first.
|
|
1025
|
+
if (bestCost >= 1000 && ordered.length > 1)
|
|
1026
|
+
tries('big');
|
|
1027
|
+
// Tails still crossing (speakers standing in the order opposite to the
|
|
1028
|
+
// one they speak in): try again from where the speakers stand, each
|
|
1029
|
+
// balloon by its own speaker first.
|
|
1030
|
+
if (ordered.length > 1 && crossings(scene, placed) > 0)
|
|
1031
|
+
tries('speaker');
|
|
1032
|
+
// Pairs placed again together, while that helps (a few rounds).
|
|
1033
|
+
for (let round = 0; round < 3 && ordered.length > 1; round++) {
|
|
1034
|
+
const next = replacePair(scene, placed, firstOrder);
|
|
1035
|
+
if (!next)
|
|
1036
|
+
break;
|
|
1037
|
+
placed = solveFrom(scene, next, firstOrder, passes);
|
|
1038
|
+
}
|
|
1039
|
+
// Balloons still read out of order placed again with every shape and
|
|
1040
|
+
// fallback open, the others where they stand.
|
|
1041
|
+
for (let round = 0; round < 2 && ordered.length > 1; round++) {
|
|
1042
|
+
const next = reorder(scene, placed, firstOrder);
|
|
1043
|
+
if (!next)
|
|
1044
|
+
break;
|
|
1045
|
+
placed = solveFrom(scene, next, firstOrder, passes);
|
|
1046
|
+
}
|
|
1047
|
+
return { placed: placed, cost: totalCost(scene, placed, firstOrder) };
|
|
1048
|
+
}
|
|
1049
|
+
/** Height biases the solver is run with: a greedy pass that lifts every
|
|
1050
|
+
* balloon higher first can leave room below for the later ones (the
|
|
1051
|
+
* Kurlander order often wants the earlier balloons high); the layout with
|
|
1052
|
+
* the lowest plain cost wins. */
|
|
1053
|
+
const HEIGHT_BIASES = [1, 3, 7];
|
|
1054
|
+
/** Place every unit of a panel (see the module comment). `extend`, when
|
|
1055
|
+
* given, adds spare variants to the units (see `letterPanelDetailed`):
|
|
1056
|
+
* a panel the first search leaves with a fault is searched again with
|
|
1057
|
+
* them, and the cheaper layout is kept. */
|
|
1058
|
+
export function placeUnits(panel, units, passes = 3, extend) {
|
|
1059
|
+
const scene = sceneOf(panel);
|
|
1060
|
+
const ordered = [...units].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1061
|
+
const firstOrder = ordered.find((u) => u.kind !== 'sfx' && u.kind !== 'note')?.order ?? Number.NaN;
|
|
1062
|
+
let { placed, cost } = search(scene, ordered, firstOrder, passes);
|
|
1063
|
+
if (cost >= 1000 && extend?.()) {
|
|
1064
|
+
const again = search(scene, ordered, firstOrder, passes);
|
|
1065
|
+
if (again.cost < cost - 1e-9)
|
|
1066
|
+
({ placed, cost } = again);
|
|
1067
|
+
}
|
|
1068
|
+
const final = placed;
|
|
1069
|
+
const diagnostics = [];
|
|
1070
|
+
for (let i = 0; i < final.length; i++) {
|
|
1071
|
+
const p = final[i];
|
|
1072
|
+
const others = final.filter((_, j) => j !== i);
|
|
1073
|
+
const ev = evaluate(scene, p.unit, p.variant, p.at, others, LEVELS[p.level], firstOrder);
|
|
1074
|
+
const fallbacks = [];
|
|
1075
|
+
if (p.level >= 1 && p.unit.breakBorder)
|
|
1076
|
+
fallbacks.push('breakBorder');
|
|
1077
|
+
if (p.level >= 2)
|
|
1078
|
+
fallbacks.push('coverAvoid');
|
|
1079
|
+
if (p.unit.variants[p.variant].reshaped)
|
|
1080
|
+
fallbacks.push('reshape');
|
|
1081
|
+
if (ev.hard > 0) {
|
|
1082
|
+
diagnostics.push({
|
|
1083
|
+
itemId: p.unit.id,
|
|
1084
|
+
panelIndex: panel.index,
|
|
1085
|
+
sourceStart: p.unit.sourceStart,
|
|
1086
|
+
sourceEnd: p.unit.sourceEnd,
|
|
1087
|
+
reasons: [...ev.reasons].sort(),
|
|
1088
|
+
fallbacks,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
final.sort((a, b) => a.unit.order - b.unit.order);
|
|
1093
|
+
return { placed: final.map(({ ev: _ev, ...rest }) => rest), diagnostics };
|
|
1094
|
+
}
|
|
1095
|
+
//# sourceMappingURL=placement.js.map
|