From 475cebcfefbd3d061f89c17dcd78afe4a1856cb7 Mon Sep 17 00:00:00 2001
From: @permadeath.com
Date: Thu, 20 Aug 2026 21:45:19 +0000
Subject: [PATCH] fix(candidates): trace the reachable set along hexsides, and say it hides
A convex hull over an enemy's `M` enclosed hexes the enemy cannot reach, which
is the opposite of the shape's one job.
`outline` now keeps the edges with no hex on the far side and chains them into
closed loops, so a region contains exactly what was scored and a split or holed
set draws as one. Terrain moves to a hex corner at reduced weight so the centred
stand marks read first, the key names both axes and both ends of each in words,
and the findings record that 22 of the 24 stands the defence list names at
minimax deal nothing at all.
---
crates/sds-core/examples/heatmap.rs | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------
crates/sds-core/src/heatmap.rs | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------
2 file(s) changed, 489 insertion(s)(+), 191 deletion(s)(-)
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);
}
}
--
tangled.sh