diff --git a/crates/sds-core/examples/heatmap.rs b/crates/sds-core/examples/heatmap.rs --- a/crates/sds-core/examples/heatmap.rs +++ b/crates/sds-core/examples/heatmap.rs @@ -17,7 +17,7 @@ //! carry the hue. The absolute values stay in the tooltips. //! //! Each board gets two rows: the scored hexes with each enemy's reachable set -//! drawn as a hull, and then the same three heatmaps with the top five walks +//! outlined along hexsides, and then the same three heatmaps with the top five walks //! the defence list would take drawn over them. //! //! Nothing here is tuned to make the picture look good. If a board comes out @@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; -use sds_core::heatmap::{convex_hull, HexCell, HexMap, Ranks, Scale, Shade}; +use sds_core::heatmap::{outline, Corner, HexCell, HexMap, Ranks, Scale, Shade}; use sds_core::hex::{translated, Stand}; use sds_core::pathfind::Search; use sds_core::stands::{score_stands, Params, Ranking, StandScore}; @@ -190,32 +190,35 @@ let at = Coord::new(x, y); let hex = scene.board.hex(at); let (cx, cy) = centre(at); - let below = cy + UNIT * 0.30; + // Tucked into the lower-left corner and drawn small. Terrain is + // background: the stand marks own the middle of the hex, and a + // centred mark must never have to compete with a glyph. + let (gx, below) = (cx - UNIT * 0.44, cy + UNIT * 0.46); if hex.impassable { - let r = UNIT * 0.26; + let r = UNIT * 0.17; svg.push_str(&stroked(&format!( "M{:.1},{:.1} L{:.1},{:.1} M{:.1},{:.1} L{:.1},{:.1}", - cx - r, + gx - r, below - r, - cx + r, + gx + r, below + r, - cx + r, + gx + r, below - r, - cx - r, + gx - r, below + r, ))); } else if hex.depth > 0 { // Two waves, so depth 1 and a hex that merely looks blue are // not the same mark. - let w = UNIT * 0.30; + let w = UNIT * 0.20; for row in 0..hex.depth.min(3) { - let y = below - UNIT * 0.13 + row as f32 * UNIT * 0.20; + let y = below - UNIT * 0.09 + row as f32 * UNIT * 0.14; svg.push_str(&stroked(&format!( "M{:.1},{:.1} q{:.1},{:.1} {:.1},0 t{:.1},0", - cx - w, + gx - w, y, w * 0.5, - -UNIT * 0.17, + -UNIT * 0.12, w, w, ))); @@ -223,10 +226,10 @@ } else if hex.terrain_mp >= 2 { // Heavy woods: two trees. Light woods: one. Same shape, so the // difference is a count rather than a colour. - svg.push_str(&tree(cx - UNIT * 0.20, below, UNIT * 0.24)); - svg.push_str(&tree(cx + UNIT * 0.20, below, UNIT * 0.24)); + svg.push_str(&tree(gx - UNIT * 0.15, below, UNIT * 0.17)); + svg.push_str(&tree(gx + UNIT * 0.15, below, UNIT * 0.17)); } else if hex.terrain_mp >= 1 { - svg.push_str(&tree(cx, below, UNIT * 0.26)); + svg.push_str(&tree(gx, below, UNIT * 0.19)); } if hex.level != 0 { // A plaque rather than a bare numeral: the fill under it is now @@ -248,49 +251,50 @@ } } -/// Each enemy's `M` as a tinted hull. +/// A corner-lattice point in pixels. /// -/// The hull of the **corners** of every hex the enemy can reach, not of their -/// centres, so the shape contains the hexes it is about rather than cutting -/// through the outer ring of them. Each successive hull is pulled a little -/// further in towards its own centroid, because three enemies four MP apart -/// produce hulls that share long stretches of edge and one drawn over another -/// reads as one enemy. -fn hulls(scene: &Scene, svg: &mut String) { +/// The lattice `sds_core::heatmap` traces outlines on is the same geometry +/// [`centre`] uses, with the horizontal axis in units of `1/sqrt(3)`. That is +/// why an outline lands exactly on the hexes it describes rather than near +/// them. +fn lattice(point: Corner) -> (f32, f32) { + ( + point.0 as f32 / 3.0_f32.sqrt() * UNIT + PAD, + point.1 as f32 * UNIT + PAD, + ) +} + +/// Each enemy's `M`, outlined along hexsides. +/// +/// The boundary of the union of the hexes that enemy can reach - not a hull +/// over them. A hull would enclose ground the enemy cannot stand on, and the +/// only job this shape has is showing exactly what was evaluated. The set can +/// be split or holed, so it is drawn as however many loops it takes, filled +/// `evenodd` so a hole stays empty. +fn reach(scene: &Scene, svg: &mut String) { for (index, foe) in scene.foes().iter().enumerate() { - let hexes: BTreeSet<(i32, i32)> = foe - .may_be - .iter() - .map(|p| (p.stand.hex.x, p.stand.hex.y)) - .collect(); - let mut points: Vec<(f32, f32)> = Vec::new(); - for (x, y) in &hexes { - let (cx, cy) = centre(Coord::new(*x, *y)); - points.extend(corner_points(cx, cy, RADIUS * UNIT * INSET)); - } - let hull = convex_hull(&points); - if hull.len() < 3 { + let hexes: Vec = foe.may_be.iter().map(|p| p.stand.hex).collect(); + let loops = outline(&hexes); + if loops.is_empty() { continue; } - let centroid = ( - hull.iter().map(|p| p.0).sum::() / hull.len() as f32, - hull.iter().map(|p| p.1).sum::() / hull.len() as f32, - ); - let pull = index as f32 * 2.2; - let drawn: Vec<(f32, f32)> = hull - .iter() - .map(|(x, y)| { - let (dx, dy) = (centroid.0 - x, centroid.1 - y); - let len = (dx * dx + dy * dy).sqrt().max(0.001); - (x + dx / len * pull, y + dy / len * pull) - }) - .collect(); + let mut d = String::new(); + for path in &loops { + for (at, point) in path.iter().enumerate() { + let (x, y) = lattice(*point); + let _ = write!(d, "{}{x:.1},{y:.1}", if at == 0 { "M" } else { "L" }); + d.push(' '); + } + d.push_str("Z "); + } + let unique: BTreeSet<(i32, i32)> = hexes.iter().map(|h| (h.x, h.y)).collect(); let _ = write!( svg, - r##"enemy {} can reach {} hexes on {THEIR_MP} MP"##, - points_list(&drawn), + r##"E{} can reach {} hexes on {THEIR_MP} MP, in {} region(s)"##, + d.trim_end(), index + 1, - hexes.len(), + unique.len(), + loops.len(), ); } } @@ -354,7 +358,7 @@ ground(panel.scene, &mut svg); heat_cells(panel, &mut svg); terrain(panel.scene, &mut svg); - hulls(panel.scene, &mut svg); + reach(panel.scene, &mut svg); for cell in panel.map.cells() { let (cx, cy) = centre(cell.hex); @@ -362,12 +366,7 @@ let on_offence = panel.offence.contains(&key); let on_defence = panel.defence.contains(&key); if on_offence || on_defence { - svg.push_str(&mark( - cx - UNIT * 0.42, - cy - UNIT * 0.30, - on_offence, - on_defence, - )); + svg.push_str(&mark(cx, cy - UNIT * 0.08, on_offence, on_defence)); } } @@ -378,8 +377,12 @@ /// The top-K marks: a triangle up for offence, down for defence, a diamond for /// a hex on both lists. +/// +/// Centred, solid and haloed, against terrain that is small, cornered and +/// faded. Three channels apart - shape, weight and place - because on a +/// hex this size any one of them alone is not enough. fn mark(cx: f32, cy: f32, offence: bool, defence: bool) -> String { - let r = UNIT * 0.26; + let r = UNIT * 0.34; let points = if offence && defence { format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", @@ -510,38 +513,91 @@ svg } -/// The two-axis key: lean across, total down. +/// The two-axis key. +/// +/// Written so it can be read on its own. Both axes are named for what they +/// mean rather than for the variable behind them, both ends of both are in +/// words, and the swatch grid carries ticks at round values instead of a +/// floating column of numbers. fn legend() -> String { + let (steps, rows) = (9usize, 5usize); + let (cell_w, cell_h) = (32.0_f32, 26.0_f32); + // Left gutter: a rotated axis name, then the tick column, then the grid. + let (left, top) = (150.0_f32, 34.0_f32); + let grid_w = cell_w * steps as f32; + let grid_h = cell_h * rows as f32; + let bottom = top + grid_h; + let width = left + grid_w + 16.0; + let height = bottom + 74.0; + let ticks = [1.0_f32, 0.75, 0.5, 0.25, 0.0]; + let mut out = String::new(); - out.push_str( - r##""##, + let _ = write!( + out, + r##""## ); - let (steps, rows) = (9, 5); for row in 0..rows { - // Brightest at the top, so the axis reads the way the caption does. - let total = 1.0 - row as f32 / (rows - 1) as f32; + // Brightest at the top, so the axis reads the way the words do. + let total = 1.0 - (row as f32 + 0.5) / rows as f32; for step in 0..steps { - let balance = 1.0 - step as f32 / (steps - 1) as f32; + let balance = 1.0 - (step as f32 + 0.5) / steps as f32; let _ = write!( out, - r##""##, + r##""##, shade_vars(Shade { total, balance }), - 48 + step * 30, - 20 + row * 22, + left + step as f32 * cell_w, + top + row as f32 * cell_h, ); } + } + + // Down the side: the name, rotated; the two ends in words above and below + // the column; round ticks between them. + let _ = write!( + out, + r##"how much happens here +a great deal +almost nothing"##, + top + grid_h / 2.0, + left - 46.0, + top + 3.5, + left - 46.0, + bottom + 3.5, + ); + for value in ticks { + let y = top + (1.0 - value) * grid_h; let _ = write!( out, - r##"{total:.2}"##, - 36 + row * 22 + r##" +{value:.2}"##, + left - 5.0, + left - 10.0, + y + 3.5, + ); + } + + // Across the bottom: ticks, then the two ends, then the name. + for value in ticks { + let x = left + (1.0 - value) * grid_w; + let _ = write!( + out, + r##" +{value:.2}"##, + bottom + 5.0, + bottom + 16.0, ); } let _ = write!( out, - r##"ranks high to deal -ranks high to take -total -lean across · total down · both are ranks"##, + r##"we deal more +we take more +the balance between dealing and taking +Both axes are ranks, not damage"##, + bottom + 34.0, + left + grid_w, + bottom + 34.0, + left + grid_w / 2.0, + bottom + 56.0, ); out.push_str(""); out @@ -735,9 +791,11 @@ body, r##"

{}

{} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction · {} values in each rank pool

Scored, with each enemy’s reach

-

The hulls are the three enemies’ M: every hex each of them can be standing in when we -arrive, on {THEIR_MP} MP from where they are now. Every scored hex on the panel was scored against every -position inside all three.

+

The outlined regions are the three enemies’ M: every hex each of them can be standing +in when we arrive, on {THEIR_MP} MP from where they are now. Each outline runs along hexsides and +contains exactly the hexes that were evaluated - not a hull over them, which would have claimed ground +the enemy cannot reach. Every scored hex on the panel was scored against every position inside all +three.

"##, escape(&scene.name), scene.stands().len(), @@ -888,7 +946,7 @@
M
Every position an enemy could be in when we get there. We choose L. We do not choose M, so each of our stands is scored not against one enemy position but against all of -them. Drawn on every panel as a tinted hull, one per enemy.
+them. Drawn on every panel as a tinted region outlined along hexsides, one per enemy.
N
Which enemy. There are three, which is the smallest number that makes the next part interesting: against a single enemy the two aggregations below are the same number.
@@ -982,6 +1040,9 @@ impassable

A numeral in a small box is the hex’s level. Terrain used to be the hex fill; it is an overlay now so the fill can carry the whole heat scale.

+

Terrain sits small and faded in the lower-left corner of a hex. The stand marks sit +whole and centred. Terrain is background and the marks are the answer, so they are kept apart on shape, +on weight and on where in the hex they sit rather than on any one of the three.

Every mark on this page is an SVG shape. No emoji, no icon font, and the page fetches nothing.

marks

@@ -994,15 +1055,98 @@

where we start · E1 · E2 · E3

-

Each enemy’s hull is the same tint as its start hex. The hulls overlap heavily, so each one is -drawn a little further inside the last; hover one for its hex count.

-

The hulls are drawn faint on purpose. They are context - what was evaluated - and not -data.

+

Each enemy’s reachable set is outlined in the tint of its own start hex, and follows hexsides +exactly. The three overlap heavily and share long stretches of edge, so each also carries its own dash; +hover one for its hex count and how many separate regions it is in.

+

The outlines are drawn faint on purpose. They are context - what was evaluated - and not +data. A set can be split by impassable ground or wrap around a hex it cannot enter, and both are drawn +as they are rather than smoothed over.

-

The hulls and the five walks are on separate rows rather than one panel. Together on a -single map they were unreadable: three overlapping outlines and five overlapping routes over a -coloured field is too many line weights at once. The heatmap under both rows is the same one.

+

The reach outlines and the five walks are on separate rows rather than one panel. +Together on a single map they were unreadable: three overlapping outlines and five overlapping routes +over a coloured field is too many line weights at once. The heatmap under both rows is the same one.

"##, + ) +} + +/// What the defence list is actually rewarding, counted rather than guessed. +/// +/// The picks on the west and south of `open` are not keeping range and are not +/// protecting a rear arc: they deal nothing at all, which means no line to any +/// enemy from any facing. Written down because a reader who is not told will +/// read a row of dark hexes behind a treeline as the bot having found clever +/// cover, and it has not - it has found the map's blind spots. +/// +/// Every number here is computed, including the sentence that summarises them. +/// A verdict written by hand would be a verdict that could drift away from the +/// table under it. +/// +/// This is a gap in the model, not a fault in the render, and it is not fixed +/// here. +fn hiding(scenes: &[Scene], rankings: &[Vec]) -> String { + let mut rows = String::new(); + // Blind stands per regime, summed over the boards, so the summary can say + // which end of the exponent range does this rather than assume. + let mut blind_by_regime = [0usize; REGIMES.len()]; + let mut listed_total = 0usize; + for (scene, row) in scenes.iter().zip(rankings.iter()) { + for (at, (regime, ranking)) in REGIMES.iter().zip(row.iter()).enumerate() { + let blind = ranking + .best_defence() + .filter(|score| score.offence.expected_damage <= 0.0) + .count(); + let listed = ranking.best_defence().count(); + blind_by_regime[at] += blind; + if at == 0 { + listed_total += listed; + } + let top = ranking.best_defence().next(); + let _ = write!( + rows, + r##"{}{}{}{:.2}{blind}/{listed}"##, + escape(&scene.name), + chip(regime), + top.map(|s| format!( + "({}, {}) f{}", + s.reach.stand.hex.x, s.reach.stand.hex.y, s.reach.stand.facing + )) + .unwrap_or_else(|| "none".to_string()), + top.map(|s| s.offence.expected_damage).unwrap_or(0.0), + ); + } + } + let worst = blind_by_regime[0]; + let mean = blind_by_regime[REGIMES.len() - 1]; + let verdict = if worst > mean { + "It is the low exponents that do this. A hex with no line has a worst case \ + of zero incoming, and no hex that can be shot at can beat zero, so a minimax ranking puts \ + blind hexes first by construction. The mean averages instead, and a blind hex is then \ + merely good rather than unbeatable" + } else if worst == mean { + "The exponent does not change how much of this happens" + } else { + "It is the high exponents that do this, which is the opposite of what the \ + operator would suggest and is worth looking into" + }; + format!( + r##"

The defence list rewards hiding

+

The stands the defence list puts first sit on the far west and south of open. They +are not there to keep range and not there to protect a rear arc. They deal 0.00, which +means no line to any enemy from any facing, and they sit directly behind the woods belt at +x = 0..2, y = 7..11 from enemies at (5, 6), (9, 6) and +(12, 7). The driver is line-of-sight blockage and nothing else.

+
+{rows}
boardregimetop defence standdamage it dealstop {TOP_K} that deal nothing
+

Across all three boards, {worst} of the {listed_total} stands the defence list names at minimax deal +nothing at all, against {mean} at the mean. {verdict}.

+

“Least damage taken” with no objective term is maximised by +standing where nothing can happen. That is a missing piece of the model rather than a fault in +the picture, and it is left in view here rather than fixed: a reader should be told the bot is hiding, +not left to infer that it has found clever cover.

+

Where a hex has no line to anything, every facing scores the same and the tie-break +picks one arbitrarily. The reported facing at such a stand carries no information - f4 on +open points its rear at the enemies - and nobody should read meaning into it.

+"## ) } @@ -1091,7 +1235,7 @@ picture stops being informative. It stays in because leaving it out would be tuning the page. And the offence list barely moves with the exponent, while the defence list does - which fits, since cover limits the worst case and the worst case is what a low exponent is looking at.

-

The rank pools are small, and a small pool makes a coarse ramp:

+{}

The rank pools are small, and a small pool makes a coarse ramp:

    {}

Absolute maxima across all nine maps: {:.1} dealt and {:.1} taken. Both appear in every tooltip. Neither is used for a colour.

@@ -1102,13 +1246,15 @@ MegaMek. No MegaMek and no match are involved in drawing this: it is the estimator run over a fixture. Us at (4, 13); enemies at (5, 6), (9, 6) and (12, 7). The unit and its guns are a fixture choice, picked so that all five volley -outputs carry information, and are not a model change. The hulls are computed here with a monotone -chain in sds_core::heatmap::convex_hull, not with MegaMek’s -ConvexBoardArea: it is a drawing aid, and Princess’s geometry is not a dependency -this repository takes for one.

+outputs carry information, and are not a model change. The reach outlines are traced here by +sds_core::heatmap::outline, which keeps the edges of a set of hexes that have no hex on +the far side and chains them into closed loops. Not MegaMek’s ConvexBoardArea: that +class is convex because it encodes Princess’s opinion about where a force should be, which is a +different thing from a description of what a unit can reach.

Hover any scored hex for its numbers. Nothing on this page is tuned to make the picture look good.

"##, + hiding(scenes, rankings), spreads.concat(), scale.deal_max, scale.take_max, @@ -1304,10 +1450,14 @@ .r-mean .dot, .sw.r-mean { background: var(--r-mean); } .keybox { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; - padding: 16px 18px; box-shadow: var(--shadow); max-width: 420px; } -svg.key { width: 100%; max-width: 380px; height: auto; display: block; } -.key text.axis { fill: var(--ink-faint); font-size: 8.5px; + padding: 16px 18px; box-shadow: var(--shadow); max-width: 510px; } +svg.key { width: 100%; max-width: 470px; height: auto; display: block; } +.key text { font-family: ui-sans-serif, system-ui, sans-serif; } +.key .axis-name { fill: var(--ink); font-size: 11px; font-weight: 650; } +.key .axis-end { fill: var(--ink-soft); font-size: 10px; } +.key .tick-label { fill: var(--ink-faint); font-size: 9px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.key .tick { stroke: var(--ink-faint); stroke-width: 1; } .legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 22px 34px; background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 18px 20px; @@ -1346,25 +1496,36 @@ /* A different edge from the ground's, so a hex we can stop in where nothing happens is still visibly a hex we can stop in. */ .map .cell { stroke: var(--cell-edge); stroke-width: 0.9; stroke-opacity: 0.55; } -.map .mark { fill: var(--mark); stroke: var(--mark-edge); stroke-width: 0.8; } +/* The answer, so it reads first: full-strength ink and a halo that cuts it + out of whatever colour the hex is. */ +.map .mark { fill: var(--mark); stroke: var(--mark-edge); stroke-width: 1.5; + paint-order: stroke; stroke-linejoin: round; } -.glyph { paint-order: stroke; fill: var(--glyph); stroke: var(--glyph-halo); stroke-width: 1.6; +/* Background information: it must be legible and must not be the first thing + seen, so it is drawn small, in a corner, and at well under full strength. */ +.glyph { paint-order: stroke; fill: var(--glyph); stroke: var(--glyph-halo); stroke-width: 1.2; stroke-linejoin: round; stroke-linecap: round; } .glyph-halo, .glyph-line { fill: none; stroke-linecap: round; stroke-linejoin: round; } -.glyph-halo { stroke: var(--glyph-halo); stroke-width: 3.4; } -.glyph-line { stroke: var(--glyph); stroke-width: 1.5; } +.glyph-halo { stroke: var(--glyph-halo); stroke-width: 2.6; } +.glyph-line { stroke: var(--glyph); stroke-width: 1.2; } +.map .glyph, .map .glyph-halo, .map .glyph-line { opacity: 0.62; } .map .lvlbox { fill: var(--glyph-halo); stroke: var(--glyph); stroke-width: 0.7; opacity: 0.92; } .map .lvl { fill: var(--glyph); font-size: 8px; text-anchor: middle; font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -/* Context, not data: faint enough to sit under the hex colours. */ -.map .hull { stroke-width: 1.3; stroke-dasharray: 5 4; fill-opacity: 0.10; stroke-opacity: 0.62; } +/* Context, not data: faint enough to sit under the hex colours. A dash per + enemy as well as a tint, because three reachable sets share long stretches + of hexside and a coincident edge would otherwise read as one enemy. */ +.map .reach { stroke-width: 1.4; fill-opacity: 0.10; stroke-opacity: 0.66; } +.map .reach.f0 { stroke-dasharray: 6 4; } +.map .reach.f1 { stroke-dasharray: 2 3; stroke-dashoffset: 3; } +.map .reach.f2 { stroke-dasharray: 9 4; stroke-dashoffset: 6; } .map .start { stroke-width: 2.4; } /* Their start hexes are solid in their own tint, ours is an outline: the - hulls belong to them, and each one matches its owner's hex. */ -.map .hull.f0, .map .start.f0 { fill: var(--foe-0); stroke: var(--foe-0); } -.map .hull.f1, .map .start.f1 { fill: var(--foe-1); stroke: var(--foe-1); } -.map .hull.f2, .map .start.f2 { fill: var(--foe-2); stroke: var(--foe-2); } + reachable sets belong to them, and each matches its owner's hex. */ +.map .reach.f0, .map .start.f0 { fill: var(--foe-0); stroke: var(--foe-0); } +.map .reach.f1, .map .start.f1 { fill: var(--foe-1); stroke: var(--foe-1); } +.map .reach.f2, .map .start.f2 { fill: var(--foe-2); stroke: var(--foe-2); } .map .start.us { fill: none; stroke: var(--us); stroke-dasharray: 4 3; } .map .who { font-size: 9.5px; font-weight: 700; text-anchor: middle; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs --- a/crates/sds-core/src/heatmap.rs +++ b/crates/sds-core/src/heatmap.rs @@ -11,7 +11,7 @@ //! we take at it are different questions, and folding them into one scalar //! would bury an exchange rate in the picture. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::stands::Ranking; use crate::wire::Coord; @@ -233,46 +233,121 @@ } } -/// The convex hull of a set of points, in a consistent winding. +/// A point on the lattice hex corners land on. /// -/// Monotone chain, and ours rather than MegaMek's `ConvexBoardArea`: this is a -/// drawing aid for showing which hexes an enemy's `M` covers, and taking a -/// dependency on Princess's geometry to draw a shape would be the wrong trade. +/// Corners are exact integers if the horizontal axis is measured in units of +/// `1/sqrt(3)` of a hex width: hex `(x, y)` has its centre at +/// `(3x + 2, 2y + (x & 1) + 1)` and each of its six corners one or two units +/// from that. Integers are the whole point - an edge shared by two hexes is +/// then the *same* pair of points seen from both sides, and that is what makes +/// the union of a set of hexes traceable at all. +pub type Corner = (i32, i32); + +/// Corner offsets from a hex centre, anticlockwise from due east, in the +/// lattice above. A flat-topped hex: corners 0 and 3 are its left and right +/// points, and the edges `1->2` and `4->5` are its flat bottom and top. +const CORNER_U: [i32; 6] = [2, 1, -1, -2, -1, 1]; +const CORNER_V: [i32; 6] = [0, 1, 1, 0, -1, -1]; + +/// Where a hex's centre sits on the corner lattice. +pub fn hex_centre(hex: Coord) -> Corner { + (3 * hex.x + 2, 2 * hex.y + (hex.x & 1) + 1) +} + +/// One of a hex's six corners, counted from due east. +pub fn hex_corner(hex: Coord, k: usize) -> Corner { + let (u, v) = hex_centre(hex); + (u + CORNER_U[k % 6], v + CORNER_V[k % 6]) +} + +/// The boundary of the union of a set of hexes, as closed loops of corners. /// -/// Fewer than three points come back unchanged, and collinear points are -/// dropped: a hull that kept them would draw the same outline with more -/// vertices. -pub fn convex_hull(points: &[(f32, f32)]) -> Vec<(f32, f32)> { - let mut sorted: Vec<(f32, f32)> = points.to_vec(); - sorted.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); - sorted.dedup(); - if sorted.len() < 3 { - return sorted; - } - let cross = |o: (f32, f32), a: (f32, f32), b: (f32, f32)| { - (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0) - }; - let mut hull: Vec<(f32, f32)> = Vec::with_capacity(sorted.len() * 2); - for pass in 0..2 { - // Two vertices of this pass's own chain, and never fewer than two - // overall: the first pass starts on an empty hull. - let lower = (hull.len() + 1).max(2); - let run: Box> = if pass == 0 { - Box::new(sorted.iter()) - } else { - Box::new(sorted.iter().rev()) - }; - for point in run { - while hull.len() >= lower - && cross(hull[hull.len() - 2], hull[hull.len() - 1], *point) <= 0.0 - { - hull.pop(); - } - hull.push(*point); +/// This is what draws an enemy's `M`, and it follows hexsides exactly: the +/// region it encloses is precisely the hexes it was given. A convex hull over +/// the same corners would have been smaller to compute and a lie to look at - +/// it swallows hexes the enemy cannot reach, which defeats the one job the +/// shape has, which is showing what was evaluated. +/// +/// Ours rather than MegaMek's `ConvexBoardArea` for the reason that class is +/// convex in the first place: it is Princess's opinion about where a force +/// should be, not a description of a reachable set. +/// +/// **More than one loop is normal.** A reachable set can be split in two by +/// impassable ground, and it can surround a hex it cannot enter. An outer +/// boundary and a hole wind in opposite directions, so filling the loops with +/// `evenodd` - or with `nonzero` - leaves the hole empty either way. +/// +/// Every edge of every hex is emitted in one consistent winding; an edge that +/// appears twice has a hex on both sides and is interior; what is left is the +/// boundary, chained end to end. +pub fn outline(hexes: &[Coord]) -> Vec> { + let unique: BTreeSet<(i32, i32)> = hexes.iter().map(|hex| (hex.x, hex.y)).collect(); + let mut directed: Vec<(Corner, Corner)> = Vec::with_capacity(unique.len() * 6); + for (x, y) in &unique { + let hex = Coord::new(*x, *y); + for k in 0..6 { + directed.push((hex_corner(hex, k), hex_corner(hex, k + 1))); } } - hull.pop(); - hull + let undirected = |a: Corner, b: Corner| if a <= b { (a, b) } else { (b, a) }; + let mut shared: BTreeMap<(Corner, Corner), usize> = BTreeMap::new(); + for (a, b) in &directed { + *shared.entry(undirected(*a, *b)).or_insert(0) += 1; + } + let mut leaving: BTreeMap> = BTreeMap::new(); + for (a, b) in directed { + if shared[&undirected(a, b)] == 1 { + leaving.entry(a).or_default().push(b); + } + } + + let mut loops: Vec> = Vec::new(); + loop { + leaving.retain(|_, out| !out.is_empty()); + let Some(start) = leaving.keys().next().copied() else { + break; + }; + let mut path = vec![start]; + let mut at = start; + // Two loops can meet at a single corner, so a vertex may have two edges + // leaving it. Taking either one still consumes every edge into some + // closed loop, which is all a fill needs. + while let Some(next) = leaving.get_mut(&at).and_then(|out| out.pop()) { + if next == start { + break; + } + path.push(next); + at = next; + } + loops.push(path); + } + loops.sort(); + loops +} + +/// Whether a point on the corner lattice is inside a traced outline. +/// +/// Even-odd crossing count, so a hole reads as outside. Half-open on the +/// vertical, which is what makes a ray through a corner - and this lattice puts +/// two corners of every hex on its own centre's row - count once rather than +/// twice or not at all. +pub fn outline_contains(loops: &[Vec], point: Corner) -> bool { + let mut inside = false; + for path in loops { + for at in 0..path.len() { + let (u1, v1) = path[at]; + let (u2, v2) = path[(at + 1) % path.len()]; + if (v1 > point.1) == (v2 > point.1) { + continue; + } + let span = (v2 - v1) as f64; + let cut = u1 as f64 + (point.1 - v1) as f64 / span * (u2 - u1) as f64; + if cut > point.0 as f64 { + inside = !inside; + } + } + } + inside } #[cfg(test)] @@ -486,59 +561,121 @@ } } - /// A square comes back as its four corners, and the point inside is gone. + /// Every hex of the set is inside its own outline, and nothing beside it + /// is. This is the whole claim the shape makes. #[test] - fn a_hull_keeps_the_corners_and_drops_the_inside() { - let hull = convex_hull(&[ - (0.0, 0.0), - (4.0, 0.0), - (4.0, 4.0), - (0.0, 4.0), - (2.0, 2.0), - (1.0, 3.0), - ]); - assert_eq!(hull.len(), 4); - for corner in [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)] { - assert!(hull.contains(&corner), "{corner:?} missing from {hull:?}"); + fn an_outline_holds_exactly_the_hexes_it_was_given() { + // Deliberately ragged: a straight-edged region would pass with a + // convex hull too, and the point is that this one would not. + let hexes: Vec = [ + (2, 2), + (3, 2), + (4, 2), + (2, 3), + (3, 3), + (2, 4), + (6, 5), + (6, 6), + ] + .iter() + .map(|(x, y)| Coord::new(*x, *y)) + .collect(); + let loops = outline(&hexes); + for hex in &hexes { + assert!( + outline_contains(&loops, hex_centre(*hex)), + "{hex:?} should be inside its own outline" + ); } - } - - /// Collinear points do not become vertices, and a degenerate set comes - /// back as itself rather than as an empty polygon. - #[test] - fn a_hull_handles_lines_and_single_points() { - let line = convex_hull(&[(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (3.0, 3.0)]); - assert_eq!(line, vec![(0.0, 0.0), (3.0, 3.0)]); - assert_eq!(convex_hull(&[(2.0, 5.0)]), vec![(2.0, 5.0)]); - assert_eq!(convex_hull(&[]), Vec::new()); - let one_hex = convex_hull(&[(1.0, 1.0), (1.0, 1.0), (1.0, 1.0)]); - assert_eq!(one_hex, vec![(1.0, 1.0)]); - } - - /// The hull does not depend on the order the points arrived in, which is - /// the same promise the cell ordering makes. - #[test] - fn a_hull_does_not_depend_on_the_input_order() { - let points = vec![ - (3.0, 1.0), - (0.0, 0.0), - (2.0, 5.0), - (5.0, 2.0), - (1.0, 4.0), - (2.5, 2.5), - ]; - let forward = convex_hull(&points); - let backward = convex_hull(&points.iter().rev().copied().collect::>()); - assert_eq!(forward, backward); - // Every input point is inside or on the hull it came from. - let edges: Vec<((f32, f32), (f32, f32))> = (0..forward.len()) - .map(|at| (forward[at], forward[(at + 1) % forward.len()])) - .collect(); - for point in &points { - for (a, b) in &edges { - let side = (b.0 - a.0) * (point.1 - a.1) - (b.1 - a.1) * (point.0 - a.0); - assert!(side >= -1e-4, "{point:?} outside edge {a:?}->{b:?}"); + for x in 0..9 { + for y in 0..9 { + let hex = Coord::new(x, y); + if hexes.contains(&hex) { + continue; + } + assert!( + !outline_contains(&loops, hex_centre(hex)), + "{hex:?} is not in the set and must be outside the outline" + ); } } + } + + /// The case a convex hull cannot represent at all: a ring with a hex + /// missing from the middle comes out as two loops, and the middle is + /// outside. + #[test] + fn a_hole_traces_a_second_loop() { + let middle = Coord::new(3, 3); + let ring: Vec = crate::hex::neighbours(middle).into_iter().collect(); + let loops = outline(&ring); + assert_eq!(loops.len(), 2, "an outer boundary and a hole: {loops:?}"); + assert!(!outline_contains(&loops, hex_centre(middle))); + for hex in &ring { + assert!(outline_contains(&loops, hex_centre(*hex))); + } + } + + /// Two hexes that do not touch are two regions, not one. + #[test] + fn a_split_set_traces_a_loop_each() { + let apart = [Coord::new(1, 1), Coord::new(8, 8)]; + let loops = outline(&apart); + assert_eq!(loops.len(), 2); + for hex in &apart { + assert!(outline_contains(&loops, hex_centre(*hex))); + } + assert!(!outline_contains(&loops, hex_centre(Coord::new(4, 4)))); + } + + /// One hex is its own six corners, and an empty set draws nothing. + #[test] + fn an_outline_handles_the_small_cases() { + let one = outline(&[Coord::new(2, 2)]); + assert_eq!(one.len(), 1); + assert_eq!(one[0].len(), 6); + let corners: BTreeSet = one[0].iter().copied().collect(); + for k in 0..6 { + assert!(corners.contains(&hex_corner(Coord::new(2, 2), k))); + } + assert!(outline(&[]).is_empty()); + } + + /// Adjacent hexes share an edge exactly, so the seam between them is + /// interior and does not survive into the boundary. + #[test] + fn neighbours_share_an_edge_and_lose_it() { + let middle = Coord::new(4, 4); + for neighbour in crate::hex::neighbours(middle) { + let mine: BTreeSet = (0..6).map(|k| hex_corner(middle, k)).collect(); + let theirs: BTreeSet = (0..6).map(|k| hex_corner(neighbour, k)).collect(); + assert_eq!( + mine.intersection(&theirs).count(), + 2, + "{middle:?} and {neighbour:?} must share exactly one edge" + ); + // Two hexes side by side trace one loop of ten corners, not two of + // six: the shared edge is gone from both. + let joined = outline(&[middle, neighbour]); + assert_eq!(joined.len(), 1); + assert_eq!(joined[0].len(), 10); + } + } + + /// The same set always traces the same loops, whatever order it arrived + /// in - the promise the cell ordering makes, kept by the outline too. + #[test] + fn an_outline_does_not_depend_on_the_input_order() { + let hexes: Vec = [(1, 1), (2, 1), (2, 2), (1, 2), (3, 1)] + .iter() + .map(|(x, y)| Coord::new(*x, *y)) + .collect(); + let forward = outline(&hexes); + let backward = outline(&hexes.iter().rev().copied().collect::>()); + assert_eq!(forward, backward); + // And a repeated hex is not a second hex. + let mut doubled = hexes.clone(); + doubled.extend(hexes.iter().copied()); + assert_eq!(outline(&doubled), forward); } }