postext 1.20.0 → 1.20.2
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/comics/layoutPage.d.ts.map +1 -1
- package/dist/comics/layoutPage.js +4 -3
- package/dist/comics/layoutPage.js.map +1 -1
- 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/letter.d.ts.map +1 -1
- package/dist/comics/lettering/letter.js +167 -68
- package/dist/comics/lettering/letter.js.map +1 -1
- package/dist/comics/lettering/placement.d.ts +21 -5
- package/dist/comics/lettering/placement.d.ts.map +1 -1
- package/dist/comics/lettering/placement.js +235 -29
- package/dist/comics/lettering/placement.js.map +1 -1
- package/dist/comics/lettering/types.d.ts +15 -6
- package/dist/comics/lettering/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -61,18 +61,40 @@ function sceneOf(panel) {
|
|
|
61
61
|
visBox: boundsOf(visible),
|
|
62
62
|
faces: panel.anchors.filter((a) => a.face).map((a) => a.face),
|
|
63
63
|
avoid: [...(panel.avoid ?? [])],
|
|
64
|
-
soft:
|
|
64
|
+
soft: figureContacts(panel.softAvoid ?? [], panel.anchors),
|
|
65
65
|
mouths: panel.anchors.filter((a) => a.visible && a.id !== 'sfx').map((a) => a.mouth),
|
|
66
66
|
fwd: panel.direction === 'rtl' ? -1 : 1,
|
|
67
67
|
buttPoly: buttPolygon(panel),
|
|
68
68
|
neighbours: [...(panel.neighbours ?? [])],
|
|
69
69
|
...(panel.limit ? { limit: panel.limit } : {}),
|
|
70
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)),
|
|
71
72
|
foreign: [...(panel.foreign ?? [])],
|
|
72
73
|
keepOut: [...(panel.keepOut ?? [])],
|
|
73
74
|
live,
|
|
74
75
|
};
|
|
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
|
+
}
|
|
76
98
|
function buttPolygon(panel) {
|
|
77
99
|
const half = (panel.borderPx ?? 0) / 2;
|
|
78
100
|
const live = livePolygon(panel);
|
|
@@ -91,6 +113,29 @@ function coveredArea(samples, rect, area) {
|
|
|
91
113
|
n++;
|
|
92
114
|
return (n / Math.max(1, samples.length)) * area;
|
|
93
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
|
+
}
|
|
94
139
|
function areaOf(v) {
|
|
95
140
|
// The bodies' boxes, as an ellipse fills them.
|
|
96
141
|
return v.bodies.reduce((s, b) => s + b.bbox.width * b.bbox.height * 0.8, 0);
|
|
@@ -139,8 +184,17 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
139
184
|
const reasons = new Set();
|
|
140
185
|
let hard = 0;
|
|
141
186
|
let soft = 0;
|
|
187
|
+
let order = 0;
|
|
188
|
+
let cover = 0;
|
|
189
|
+
let coverArt = 0;
|
|
142
190
|
const S = (k, x) => {
|
|
143
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;
|
|
144
198
|
if (terms)
|
|
145
199
|
terms[k] = (terms[k] ?? 0) + x;
|
|
146
200
|
};
|
|
@@ -151,11 +205,12 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
151
205
|
if (terms)
|
|
152
206
|
terms[`!${k}`] = (terms[`!${k}`] ?? 0) + x;
|
|
153
207
|
};
|
|
154
|
-
// Faces
|
|
208
|
+
// Faces (in a close shot less the top corners of their boxes, hair or
|
|
209
|
+
// background), avoid zones, mouths.
|
|
155
210
|
for (const f of scene.faces) {
|
|
156
211
|
if (!rectsOverlap(box, f))
|
|
157
212
|
continue;
|
|
158
|
-
const a = coveredArea(samples, f, area);
|
|
213
|
+
const a = closeShot(f, scene.visBox) ? coveredOval(samples, f, area) : coveredArea(samples, f, area);
|
|
159
214
|
if (a > 0) {
|
|
160
215
|
H('face', (W.face * a) / em2 + W.face);
|
|
161
216
|
reasons.add('face');
|
|
@@ -166,8 +221,11 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
166
221
|
continue;
|
|
167
222
|
const a = coveredArea(samples, r, area);
|
|
168
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.)
|
|
169
227
|
if (level.coverAvoid)
|
|
170
|
-
S('coverAvoid', (2 * a) / em2);
|
|
228
|
+
S(r.guard ? 'coverAvoid' : 'coverArt', ((r.guard ? 2 : 6) * a) / em2);
|
|
171
229
|
else {
|
|
172
230
|
H('avoid', (W.avoid * a) / em2 + W.avoid);
|
|
173
231
|
reasons.add('avoid');
|
|
@@ -177,7 +235,8 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
177
235
|
for (const r of scene.soft) {
|
|
178
236
|
if (!rectsOverlap(box, r))
|
|
179
237
|
continue;
|
|
180
|
-
|
|
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);
|
|
181
240
|
}
|
|
182
241
|
const bodiesHere = v.bodies.map((b) => moved(b.core, at));
|
|
183
242
|
for (const m of scene.mouths) {
|
|
@@ -190,13 +249,19 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
190
249
|
}
|
|
191
250
|
// Outside the panel. A unit that breaks its border on purpose (`break`,
|
|
192
251
|
// at the level that allows it; a pin, where its author put it) may run
|
|
193
|
-
// past the border
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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.
|
|
197
259
|
const rim = moved(v.rim, at);
|
|
198
260
|
const intended = unit.pin !== undefined || (level.breakBorder && unit.breakBorder === true);
|
|
199
|
-
const edge = intended ?
|
|
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;
|
|
200
265
|
let out = 0;
|
|
201
266
|
let intrude = 0;
|
|
202
267
|
let offPage = 0;
|
|
@@ -369,8 +434,10 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
369
434
|
}
|
|
370
435
|
if (unit.kind === 'sfx' && unit.near)
|
|
371
436
|
S('sfxNear', (0.5 * dist(centre, unit.near)) / em);
|
|
372
|
-
// High in the panel; the first one in the top start corner.
|
|
373
|
-
|
|
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) {
|
|
374
441
|
const top = box.y - scene.visBox.y;
|
|
375
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);
|
|
376
443
|
if (unit.order === firstOrder && unit.kind !== 'note') {
|
|
@@ -378,7 +445,7 @@ function evaluate(scene, unit, vi, at, others, level, firstOrder, terms) {
|
|
|
378
445
|
S('start', (W.start * fromStart) / em);
|
|
379
446
|
}
|
|
380
447
|
}
|
|
381
|
-
return { hard, soft, reasons };
|
|
448
|
+
return { hard, soft, reasons, order, cover, coverArt };
|
|
382
449
|
}
|
|
383
450
|
/** A rectangle grown by `d` px on every side. */
|
|
384
451
|
function grow(r, d) {
|
|
@@ -486,11 +553,12 @@ function candidates(scene, unit, vi) {
|
|
|
486
553
|
}
|
|
487
554
|
/** The anchor a corner or edge keyword puts a unit at: its box flush
|
|
488
555
|
* with the panel (`butt`) or with the inset area, at that corner. */
|
|
489
|
-
export function positionAnchor(scene, v, position, butt) {
|
|
556
|
+
export function positionAnchor(scene, v, position, butt, along) {
|
|
490
557
|
const poly = butt ? scene.buttPoly : scene.visible;
|
|
491
558
|
const box = boundsOf(poly);
|
|
492
559
|
const startLeft = scene.fwd === 1;
|
|
493
|
-
|
|
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
|
|
494
562
|
: position.endsWith('start') ? (startLeft ? 0 : 1) : (startLeft ? 1 : 0);
|
|
495
563
|
const vy = position.startsWith('top') ? 0 : 1;
|
|
496
564
|
const bx = box.x + hx * (box.width - v.bbox.width);
|
|
@@ -509,8 +577,10 @@ export function positionAnchor(scene, v, position, butt) {
|
|
|
509
577
|
}
|
|
510
578
|
/** A corner or edge unit's anchors: its own corner first, then — a cost
|
|
511
579
|
* each, so taken only when its own covers a face, an avoid zone or a
|
|
512
|
-
* balloon — the other corner of that edge, the middle of it,
|
|
513
|
-
*
|
|
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. */
|
|
514
584
|
function positionAnchors(scene, v, position, butt) {
|
|
515
585
|
const top = position.startsWith('top');
|
|
516
586
|
const across = position.endsWith('start') ? (top ? 'top-end' : 'bottom-end') : position.endsWith('end') ? (top ? 'top-start' : 'bottom-start') : (top ? 'top-start' : 'bottom-start');
|
|
@@ -518,7 +588,27 @@ function positionAnchors(scene, v, position, butt) {
|
|
|
518
588
|
const flip = (q) => (q.startsWith('top') ? q.replace('top', 'bottom') : q.replace('bottom', 'top'));
|
|
519
589
|
const list = [[position, 0], [across, 2], [middle, 3], [flip(position), 5], [flip(across), 6]];
|
|
520
590
|
const seen = new Set();
|
|
521
|
-
|
|
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;
|
|
522
612
|
}
|
|
523
613
|
const LEVELS = [
|
|
524
614
|
{ breakBorder: false, coverAvoid: false },
|
|
@@ -562,12 +652,53 @@ function bestFor(scene, unit, others, firstOrder, fixed) {
|
|
|
562
652
|
continue;
|
|
563
653
|
const choice = { variant: best.variant, level: best.level, at: best.at, ev: best.ev };
|
|
564
654
|
if (choice.ev.hard === 0)
|
|
565
|
-
return choice;
|
|
655
|
+
return fixed || li !== 0 ? choice : orderBeforeAvoid(scene, unit, others, firstOrder, best, penalty);
|
|
566
656
|
if (!fallback || better(choice.ev, fallback.ev))
|
|
567
657
|
fallback = choice;
|
|
568
658
|
}
|
|
569
659
|
return fallback;
|
|
570
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
|
+
}
|
|
571
702
|
/** What choosing a reshaped text block costs (its poorer shape). */
|
|
572
703
|
const RESHAPE_COST = 1.5;
|
|
573
704
|
/** A pinned unit's anchors: on its pin, and moved the least that keeps
|
|
@@ -731,6 +862,59 @@ function swapCrossing(scene, placed, firstOrder) {
|
|
|
731
862
|
}
|
|
732
863
|
return undefined;
|
|
733
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
|
+
}
|
|
734
918
|
/** The layout with one pair of free units taken out and placed again
|
|
735
919
|
* (each order in turn, the second seeing the first) when that lowers the
|
|
736
920
|
* whole layout's cost; else undefined. A single move cannot undo a pair
|
|
@@ -816,16 +1000,9 @@ function totalCost(scene, placed, firstOrder) {
|
|
|
816
1000
|
}
|
|
817
1001
|
return hard * 1000 + soft;
|
|
818
1002
|
}
|
|
819
|
-
/**
|
|
820
|
-
*
|
|
821
|
-
|
|
822
|
-
* the lowest plain cost wins. */
|
|
823
|
-
const HEIGHT_BIASES = [1, 3, 7];
|
|
824
|
-
/** Place every unit of a panel (see the module comment). */
|
|
825
|
-
export function placeUnits(panel, units, passes = 3) {
|
|
826
|
-
const scene = sceneOf(panel);
|
|
827
|
-
const ordered = [...units].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
828
|
-
const firstOrder = ordered.find((u) => u.kind !== 'sfx' && u.kind !== 'note')?.order ?? Number.NaN;
|
|
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) {
|
|
829
1006
|
let placed;
|
|
830
1007
|
let bestCost = Number.POSITIVE_INFINITY;
|
|
831
1008
|
const tries = (mode) => {
|
|
@@ -859,6 +1036,35 @@ export function placeUnits(panel, units, passes = 3) {
|
|
|
859
1036
|
break;
|
|
860
1037
|
placed = solveFrom(scene, next, firstOrder, passes);
|
|
861
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
|
+
}
|
|
862
1068
|
const final = placed;
|
|
863
1069
|
const diagnostics = [];
|
|
864
1070
|
for (let i = 0; i < final.length; i++) {
|