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.
Files changed (255) hide show
  1. package/dist/__tests__/comics/art.test.d.ts +2 -0
  2. package/dist/__tests__/comics/art.test.d.ts.map +1 -0
  3. package/dist/__tests__/comics/art.test.js +96 -0
  4. package/dist/__tests__/comics/art.test.js.map +1 -0
  5. package/dist/__tests__/comics/build.test.d.ts +2 -0
  6. package/dist/__tests__/comics/build.test.d.ts.map +1 -0
  7. package/dist/__tests__/comics/build.test.js +215 -0
  8. package/dist/__tests__/comics/build.test.js.map +1 -0
  9. package/dist/__tests__/comics/canvas.test.d.ts +2 -0
  10. package/dist/__tests__/comics/canvas.test.d.ts.map +1 -0
  11. package/dist/__tests__/comics/canvas.test.js +161 -0
  12. package/dist/__tests__/comics/canvas.test.js.map +1 -0
  13. package/dist/__tests__/comics/config.test.d.ts +2 -0
  14. package/dist/__tests__/comics/config.test.d.ts.map +1 -0
  15. package/dist/__tests__/comics/config.test.js +97 -0
  16. package/dist/__tests__/comics/config.test.js.map +1 -0
  17. package/dist/__tests__/comics/geometry.test.d.ts +2 -0
  18. package/dist/__tests__/comics/geometry.test.d.ts.map +1 -0
  19. package/dist/__tests__/comics/geometry.test.js +118 -0
  20. package/dist/__tests__/comics/geometry.test.js.map +1 -0
  21. package/dist/__tests__/comics/html.test.d.ts +2 -0
  22. package/dist/__tests__/comics/html.test.d.ts.map +1 -0
  23. package/dist/__tests__/comics/html.test.js +253 -0
  24. package/dist/__tests__/comics/html.test.js.map +1 -0
  25. package/dist/__tests__/comics/lettering.test.d.ts +2 -0
  26. package/dist/__tests__/comics/lettering.test.d.ts.map +1 -0
  27. package/dist/__tests__/comics/lettering.test.js +241 -0
  28. package/dist/__tests__/comics/lettering.test.js.map +1 -0
  29. package/dist/__tests__/comics/script.test.d.ts +2 -0
  30. package/dist/__tests__/comics/script.test.d.ts.map +1 -0
  31. package/dist/__tests__/comics/script.test.js +147 -0
  32. package/dist/__tests__/comics/script.test.js.map +1 -0
  33. package/dist/__tests__/comics/split.test.d.ts +2 -0
  34. package/dist/__tests__/comics/split.test.d.ts.map +1 -0
  35. package/dist/__tests__/comics/split.test.js +120 -0
  36. package/dist/__tests__/comics/split.test.js.map +1 -0
  37. package/dist/__tests__/comics/spread.test.d.ts +2 -0
  38. package/dist/__tests__/comics/spread.test.d.ts.map +1 -0
  39. package/dist/__tests__/comics/spread.test.js +161 -0
  40. package/dist/__tests__/comics/spread.test.js.map +1 -0
  41. package/dist/__tests__/comics/strip.test.d.ts +2 -0
  42. package/dist/__tests__/comics/strip.test.d.ts.map +1 -0
  43. package/dist/__tests__/comics/strip.test.js +231 -0
  44. package/dist/__tests__/comics/strip.test.js.map +1 -0
  45. package/dist/__tests__/exports.test.js +89 -0
  46. package/dist/__tests__/exports.test.js.map +1 -1
  47. package/dist/__tests__/parse/containers.test.js +1 -1
  48. package/dist/__tests__/parse/containers.test.js.map +1 -1
  49. package/dist/canvas-backend/comic.d.ts +32 -0
  50. package/dist/canvas-backend/comic.d.ts.map +1 -0
  51. package/dist/canvas-backend/comic.js +216 -0
  52. package/dist/canvas-backend/comic.js.map +1 -0
  53. package/dist/canvas-backend/headerFooter.d.ts +4 -1
  54. package/dist/canvas-backend/headerFooter.d.ts.map +1 -1
  55. package/dist/canvas-backend/headerFooter.js +3 -1
  56. package/dist/canvas-backend/headerFooter.js.map +1 -1
  57. package/dist/canvas-backend/index.d.ts.map +1 -1
  58. package/dist/canvas-backend/index.js +7 -1
  59. package/dist/canvas-backend/index.js.map +1 -1
  60. package/dist/comics/art.d.ts +72 -0
  61. package/dist/comics/art.d.ts.map +1 -0
  62. package/dist/comics/art.js +154 -0
  63. package/dist/comics/art.js.map +1 -0
  64. package/dist/comics/fonts.d.ts +20 -0
  65. package/dist/comics/fonts.d.ts.map +1 -0
  66. package/dist/comics/fonts.js +42 -0
  67. package/dist/comics/fonts.js.map +1 -0
  68. package/dist/comics/geometry.d.ts +87 -0
  69. package/dist/comics/geometry.d.ts.map +1 -0
  70. package/dist/comics/geometry.js +272 -0
  71. package/dist/comics/geometry.js.map +1 -0
  72. package/dist/comics/index.d.ts +29 -0
  73. package/dist/comics/index.d.ts.map +1 -0
  74. package/dist/comics/index.js +18 -0
  75. package/dist/comics/index.js.map +1 -0
  76. package/dist/comics/layoutPage.d.ts +156 -0
  77. package/dist/comics/layoutPage.d.ts.map +1 -0
  78. package/dist/comics/layoutPage.js +698 -0
  79. package/dist/comics/layoutPage.js.map +1 -0
  80. package/dist/comics/lettering/__tests__/harness.test.d.ts +2 -0
  81. package/dist/comics/lettering/__tests__/harness.test.d.ts.map +1 -0
  82. package/dist/comics/lettering/__tests__/harness.test.js +273 -0
  83. package/dist/comics/lettering/__tests__/harness.test.js.map +1 -0
  84. package/dist/comics/lettering/__tests__/letterPanel.test.d.ts +2 -0
  85. package/dist/comics/lettering/__tests__/letterPanel.test.d.ts.map +1 -0
  86. package/dist/comics/lettering/__tests__/letterPanel.test.js +224 -0
  87. package/dist/comics/lettering/__tests__/letterPanel.test.js.map +1 -0
  88. package/dist/comics/lettering/__tests__/polish.test.d.ts +2 -0
  89. package/dist/comics/lettering/__tests__/polish.test.d.ts.map +1 -0
  90. package/dist/comics/lettering/__tests__/polish.test.js +357 -0
  91. package/dist/comics/lettering/__tests__/polish.test.js.map +1 -0
  92. package/dist/comics/lettering/__tests__/round2.test.d.ts +2 -0
  93. package/dist/comics/lettering/__tests__/round2.test.d.ts.map +1 -0
  94. package/dist/comics/lettering/__tests__/round2.test.js +289 -0
  95. package/dist/comics/lettering/__tests__/round2.test.js.map +1 -0
  96. package/dist/comics/lettering/__tests__/round3.test.d.ts +2 -0
  97. package/dist/comics/lettering/__tests__/round3.test.d.ts.map +1 -0
  98. package/dist/comics/lettering/__tests__/round3.test.js +240 -0
  99. package/dist/comics/lettering/__tests__/round3.test.js.map +1 -0
  100. package/dist/comics/lettering/__tests__/shapeText.test.d.ts +2 -0
  101. package/dist/comics/lettering/__tests__/shapeText.test.d.ts.map +1 -0
  102. package/dist/comics/lettering/__tests__/shapeText.test.js +98 -0
  103. package/dist/comics/lettering/__tests__/shapeText.test.js.map +1 -0
  104. package/dist/comics/lettering/__tests__/shapes.test.d.ts +2 -0
  105. package/dist/comics/lettering/__tests__/shapes.test.d.ts.map +1 -0
  106. package/dist/comics/lettering/__tests__/shapes.test.js +137 -0
  107. package/dist/comics/lettering/__tests__/shapes.test.js.map +1 -0
  108. package/dist/comics/lettering/__tests__/stubMeasure.d.ts +5 -0
  109. package/dist/comics/lettering/__tests__/stubMeasure.d.ts.map +1 -0
  110. package/dist/comics/lettering/__tests__/stubMeasure.js +47 -0
  111. package/dist/comics/lettering/__tests__/stubMeasure.js.map +1 -0
  112. package/dist/comics/lettering/__tests__/text.test.d.ts +2 -0
  113. package/dist/comics/lettering/__tests__/text.test.d.ts.map +1 -0
  114. package/dist/comics/lettering/__tests__/text.test.js +86 -0
  115. package/dist/comics/lettering/__tests__/text.test.js.map +1 -0
  116. package/dist/comics/lettering/geom.d.ts +78 -0
  117. package/dist/comics/lettering/geom.d.ts.map +1 -0
  118. package/dist/comics/lettering/geom.js +376 -0
  119. package/dist/comics/lettering/geom.js.map +1 -0
  120. package/dist/comics/lettering/index.d.ts +7 -0
  121. package/dist/comics/lettering/index.d.ts.map +1 -0
  122. package/dist/comics/lettering/index.js +8 -0
  123. package/dist/comics/lettering/index.js.map +1 -0
  124. package/dist/comics/lettering/letter.d.ts +7 -0
  125. package/dist/comics/lettering/letter.d.ts.map +1 -0
  126. package/dist/comics/lettering/letter.js +496 -0
  127. package/dist/comics/lettering/letter.js.map +1 -0
  128. package/dist/comics/lettering/placement.d.ts +133 -0
  129. package/dist/comics/lettering/placement.d.ts.map +1 -0
  130. package/dist/comics/lettering/placement.js +1095 -0
  131. package/dist/comics/lettering/placement.js.map +1 -0
  132. package/dist/comics/lettering/presets.d.ts +15 -0
  133. package/dist/comics/lettering/presets.d.ts.map +1 -0
  134. package/dist/comics/lettering/presets.js +61 -0
  135. package/dist/comics/lettering/presets.js.map +1 -0
  136. package/dist/comics/lettering/shape-text.d.ts +72 -0
  137. package/dist/comics/lettering/shape-text.d.ts.map +1 -0
  138. package/dist/comics/lettering/shape-text.js +550 -0
  139. package/dist/comics/lettering/shape-text.js.map +1 -0
  140. package/dist/comics/lettering/shapes.d.ts +83 -0
  141. package/dist/comics/lettering/shapes.d.ts.map +1 -0
  142. package/dist/comics/lettering/shapes.js +512 -0
  143. package/dist/comics/lettering/shapes.js.map +1 -0
  144. package/dist/comics/lettering/text.d.ts +79 -0
  145. package/dist/comics/lettering/text.d.ts.map +1 -0
  146. package/dist/comics/lettering/text.js +488 -0
  147. package/dist/comics/lettering/text.js.map +1 -0
  148. package/dist/comics/lettering/types.d.ts +279 -0
  149. package/dist/comics/lettering/types.d.ts.map +1 -0
  150. package/dist/comics/lettering/types.js +9 -0
  151. package/dist/comics/lettering/types.js.map +1 -0
  152. package/dist/comics/page.d.ts +37 -0
  153. package/dist/comics/page.d.ts.map +1 -0
  154. package/dist/comics/page.js +285 -0
  155. package/dist/comics/page.js.map +1 -0
  156. package/dist/comics/paint.d.ts +65 -0
  157. package/dist/comics/paint.d.ts.map +1 -0
  158. package/dist/comics/paint.js +219 -0
  159. package/dist/comics/paint.js.map +1 -0
  160. package/dist/comics/script.d.ts +42 -0
  161. package/dist/comics/script.d.ts.map +1 -0
  162. package/dist/comics/script.js +302 -0
  163. package/dist/comics/script.js.map +1 -0
  164. package/dist/comics/split.d.ts +135 -0
  165. package/dist/comics/split.d.ts.map +1 -0
  166. package/dist/comics/split.js +514 -0
  167. package/dist/comics/split.js.map +1 -0
  168. package/dist/comics/spread.d.ts +36 -0
  169. package/dist/comics/spread.d.ts.map +1 -0
  170. package/dist/comics/spread.js +172 -0
  171. package/dist/comics/spread.js.map +1 -0
  172. package/dist/comics/strip.d.ts +77 -0
  173. package/dist/comics/strip.d.ts.map +1 -0
  174. package/dist/comics/strip.js +89 -0
  175. package/dist/comics/strip.js.map +1 -0
  176. package/dist/comics/transform.d.ts +37 -0
  177. package/dist/comics/transform.d.ts.map +1 -0
  178. package/dist/comics/transform.js +161 -0
  179. package/dist/comics/transform.js.map +1 -0
  180. package/dist/comics/types.d.ts +143 -0
  181. package/dist/comics/types.d.ts.map +1 -0
  182. package/dist/comics/types.js +13 -0
  183. package/dist/comics/types.js.map +1 -0
  184. package/dist/comics/warnings.d.ts +14 -0
  185. package/dist/comics/warnings.d.ts.map +1 -0
  186. package/dist/comics/warnings.js +94 -0
  187. package/dist/comics/warnings.js.map +1 -0
  188. package/dist/configWarnings.d.ts.map +1 -1
  189. package/dist/configWarnings.js +42 -0
  190. package/dist/configWarnings.js.map +1 -1
  191. package/dist/defaults/comics.d.ts +51 -0
  192. package/dist/defaults/comics.d.ts.map +1 -0
  193. package/dist/defaults/comics.js +385 -0
  194. package/dist/defaults/comics.js.map +1 -0
  195. package/dist/defaults/index.d.ts +1 -0
  196. package/dist/defaults/index.d.ts.map +1 -1
  197. package/dist/defaults/index.js +9 -0
  198. package/dist/defaults/index.js.map +1 -1
  199. package/dist/design/layout.d.ts +13 -1
  200. package/dist/design/layout.d.ts.map +1 -1
  201. package/dist/design/layout.js +17 -0
  202. package/dist/design/layout.js.map +1 -1
  203. package/dist/html-backend.d.ts +3 -0
  204. package/dist/html-backend.d.ts.map +1 -1
  205. package/dist/html-backend.js +44 -8
  206. package/dist/html-backend.js.map +1 -1
  207. package/dist/htmlComic.d.ts +69 -0
  208. package/dist/htmlComic.d.ts.map +1 -0
  209. package/dist/htmlComic.js +179 -0
  210. package/dist/htmlComic.js.map +1 -0
  211. package/dist/index.d.ts +8 -2
  212. package/dist/index.d.ts.map +1 -1
  213. package/dist/index.js +4 -0
  214. package/dist/index.js.map +1 -1
  215. package/dist/parse/blockParser.d.ts.map +1 -1
  216. package/dist/parse/blockParser.js +25 -1
  217. package/dist/parse/blockParser.js.map +1 -1
  218. package/dist/parse/indexMarks.d.ts.map +1 -1
  219. package/dist/parse/indexMarks.js +3 -0
  220. package/dist/parse/indexMarks.js.map +1 -1
  221. package/dist/parse/types.d.ts +7 -1
  222. package/dist/parse/types.d.ts.map +1 -1
  223. package/dist/pipeline/build.d.ts.map +1 -1
  224. package/dist/pipeline/build.js +251 -9
  225. package/dist/pipeline/build.js.map +1 -1
  226. package/dist/pipeline/config.d.ts.map +1 -1
  227. package/dist/pipeline/config.js +4 -1
  228. package/dist/pipeline/config.js.map +1 -1
  229. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  230. package/dist/pipeline/contentWarnings.js +58 -0
  231. package/dist/pipeline/contentWarnings.js.map +1 -1
  232. package/dist/pipeline/floatSlots.js +1 -1
  233. package/dist/pipeline/floatSlots.js.map +1 -1
  234. package/dist/pipeline/headerFooter.d.ts +6 -2
  235. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  236. package/dist/pipeline/headerFooter.js +11 -3
  237. package/dist/pipeline/headerFooter.js.map +1 -1
  238. package/dist/pipeline/pageRoles.d.ts +1 -0
  239. package/dist/pipeline/pageRoles.d.ts.map +1 -1
  240. package/dist/pipeline/pageRoles.js +3 -0
  241. package/dist/pipeline/pageRoles.js.map +1 -1
  242. package/dist/pipeline/placement.d.ts +3 -3
  243. package/dist/pipeline/placement.d.ts.map +1 -1
  244. package/dist/pipeline/placement.js +4 -4
  245. package/dist/pipeline/placement.js.map +1 -1
  246. package/dist/types.d.ts +338 -1
  247. package/dist/types.d.ts.map +1 -1
  248. package/dist/vdt.d.ts +271 -2
  249. package/dist/vdt.d.ts.map +1 -1
  250. package/dist/vdt.js.map +1 -1
  251. package/dist/worker/fonts.d.ts +2 -1
  252. package/dist/worker/fonts.d.ts.map +1 -1
  253. package/dist/worker/fonts.js +11 -1
  254. package/dist/worker/fonts.js.map +1 -1
  255. 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