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##"
-
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);
}
}