← hex_grid · API reference · version 0.1.4
4 file(s), 739 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
SQRT3 const | 20 | — | 22 29 65 140 87 107 |
HEX_SIZE const | 21 | — | 22 29 30 65 66 153 … (7 total) |
HEX_LEN const | 22 | — | 34 36 |
hex_to_px fn | 27 | — | 151 7 15 20 29 30 … (15 total) |
hex_round fn | 39 | HXG-001 | 67 57 58 |
px_to_hex fn | 64 | HXG-001 | 16 20 51 52 |
hex_neighbor fn | 78 | HXG-002 | 177 27 42 45 60 80 … (17 total) |
hex_distance fn | 96 | — | 28 49 50 51 52 53 … (9 total) |
hex_neighbor_dir fn | 110 | HXG-006 | 43 45 61 88 123 137 … (9 total) |
hex_corner_offset fn | 139 | HXG-004 | 152 171 |
hex_corner_px fn | 150 | — | 71 |
hex_edge_corners fn | 160 | HXG-006 | 228 229 230 |
hex_canon_edge fn | 175 | HXG-003 | 59 62 117 124 146 238 … (7 total) |
GRID_SIZE const | 183 | — | 245 |
GRID_LEN const | 184 | — | none in this package |
cell_to_px fn | 187 | — | 84 89 183 184 |
px_to_cell fn | 192 | — | 85 89 |
cell_neighbor fn | 200 | HXG-004 | 95 98 103 165 |
cell_neighbor_dir fn | 211 | HXG-005 | 96 98 104 196 213 |
cell_distance fn | 225 | HXG-005 | 195 |
cell_corner_offset fn | 234 | HXG-004 | 244 174 |
cell_corner_px fn | 243 | — | none in this package |
cell_edge_corners fn | 249 | — | none in this package |
cell_canon_edge fn | 258 | — | 102 105 |
src/hex_grid.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_grid — the canonical HEX-GRID geometry + the square local basis for structures.
//
// THE CONVENTION (shared with moros — the single executable source of it):
// pointy-top hexes, odd-r offset coordinates, world scale L = sqrt(3) per hex step:
// x = sqrt(3) * (col + 0.5*(row & 1)); y = 1.5 * row
// Everything is a pure function over (q, r) lattice coords and world (x, y) floats;
// no structs, no state. Descriptive prefixed names — this is the technical layer
// (a friendlier drawing abstraction can sit above it).
//
// hex_* — the hex lattice itself (conversions, neighbors, metric, corners, edges)
// cell_* — a square ("90-degree") LOCAL basis for right-angled structures placed ON
// the hex world, oriented to one of its 12 directions (k * 30 degrees)
//
// Extracted from the crawler roguelike (its EXTRACTION.md documents the pipeline).
// ── The hex lattice ────────────────────────────────────────────────────────
pub const SQRT3 = 1.7320508075688772;
pub const HEX_SIZE = 1.0;
pub const HEX_LEN = 1.7320508075688772; // = SQRT3 * HEX_SIZE; one hex step
// Hex (col,row) -> world-plane centre (x,y). Pointy-top, ODD-R OFFSET (moros's
// convention): odd rows shifted half a hex right, so a rectangle of cells stays
// UPRIGHT (axial sheared them). Throughout the kernel (q,r) ARE offset (col,row).
pub fn hex_to_px(q: integer, r: integer) -> (float, float) {
par = r & 1;
hx = HEX_SIZE * (SQRT3 * (q as float) + SQRT3 / 2.0 * (par as float));
hy = HEX_SIZE * (1.5 * (r as float));
(hx, hy)
}
// Round fractional cube coords to the nearest hex; return AXIAL (q,r) — NOT the
// odd-r offset (col,row) the rest of this module takes. Both are
// `(integer, integer)`, so mixing them type-checks and lands on a neighbouring
// hex. Use `px_to_hex` unless you already hold axial coordinates.
// Example: @HXG-001
pub fn hex_round(qf: float, rf: float) -> (integer, integer) {
cx = qf;
cz = rf;
cy = -cx - cz;
rxf = round(cx);
ryf = round(cy);
rzf = round(cz);
dx = abs(rxf - cx);
dy = abs(ryf - cy);
dz = abs(rzf - cz);
if dx > dy && dx > dz {
rxf = -ryf - rzf;
} else if dy > dz {
ryf = -rxf - rzf;
} else {
rzf = -rxf - ryf;
}
(rxf as integer, rzf as integer)
}
// World-plane point (x,y) -> nearest hex (col,row). Axial-fractional inverse +
// cube round, then convert axial -> odd-r offset (col = q + (r - (r&1))/2).
// This is the conversion to reach for: it answers the OFFSET pair every other
// function here expects, where `hex_round` alone answers the axial one.
// Example: @HXG-001
pub fn px_to_hex(x: float, y: float) -> (integer, integer) {
qf = (SQRT3 / 3.0 * x - 1.0 / 3.0 * y) / HEX_SIZE;
rf = (2.0 / 3.0 * y) / HEX_SIZE;
(aq, ar) = hex_round(qf, rf);
col = aq + (ar - (ar & 1)) / 2;
(col, ar)
}
// The six neighbour directions (0=E, 1=SE, 2=SW, 3=W, 4=NW, 5=NE; r increases
// upward). ODD-R OFFSET, so the four diagonal deltas differ by row parity. The
// compass order matches the old axial one, so edge_corners/canon_edge are unchanged.
// Only E and W are parity-free: a hardcoded six-entry delta table is right on
// every even row and hands back the wrong NEIGHBOUR — not a bad cell — on odd ones.
// Example: @HXG-002, @HXG-004
pub fn hex_neighbor(q: integer, r: integer, dir: integer) -> (integer, integer) {
if (r & 1) == 0 {
if dir == 0 { return (q + 1, r); }
if dir == 1 { return (q, r - 1); }
if dir == 2 { return (q - 1, r - 1); }
if dir == 3 { return (q - 1, r); }
if dir == 4 { return (q - 1, r + 1); }
return (q, r + 1);
}
if dir == 0 { return (q + 1, r); }
if dir == 1 { return (q + 1, r - 1); }
if dir == 2 { return (q, r - 1); }
if dir == 3 { return (q - 1, r); }
if dir == 4 { return (q, r + 1); }
(q + 1, r + 1)
}
// Hex distance in steps — convert odd-r offset -> axial, then cube distance.
pub fn hex_distance(q1: integer, r1: integer, q2: integer, r2: integer) -> integer {
aq1 = q1 - (r1 - (r1 & 1)) / 2;
aq2 = q2 - (r2 - (r2 & 1)) / 2;
dq = aq1 - aq2;
dr = r1 - r2;
(abs(dq) + abs(dq + dr) + abs(dr)) / 2
}
// Which of the 6 neighbour directions leads from (q1,r1) to adjacent (q2,r2)?
// Inverse of hex_neighbor (odd-r offset, parity-dependent on r1); -1 if not adjacent.
// `-1` is an ordinary `integer`, not a null: it flows into every function that
// takes a direction, where `hex_neighbor` and `hex_edge_corners` absorb it as
// direction 5 and `hex_canon_edge` keeps it as an edge index. Check it here.
// Example: @HXG-006
pub fn hex_neighbor_dir(q1: integer, r1: integer, q2: integer, r2: integer) -> integer {
ndq = q2 - q1;
ndr = r2 - r1;
if (r1 & 1) == 0 {
if ndq == 1 && ndr == 0 { return 0; }
if ndq == 0 && ndr == -1 { return 1; }
if ndq == -1 && ndr == -1 { return 2; }
if ndq == -1 && ndr == 0 { return 3; }
if ndq == -1 && ndr == 1 { return 4; }
if ndq == 0 && ndr == 1 { return 5; }
return -1;
}
if ndq == 1 && ndr == 0 { return 0; }
if ndq == 1 && ndr == -1 { return 1; }
if ndq == 0 && ndr == -1 { return 2; }
if ndq == -1 && ndr == 0 { return 3; }
if ndq == 0 && ndr == 1 { return 4; }
if ndq == 1 && ndr == 1 { return 5; }
-1
}
// ── Edge walls (moros model): walls live on hex edges, stored on 3
// canonical edges per hex (dirs 0,1,2); dirs 3,4,5 belong to the neighbour. ──
// Corner offset of a pointy-top unit hex (circumradius 1), corner 0 = top,
// going counter-clockwise. Used to place edge endpoints and wall segments.
// +y is NORTH here. The cell_* family below uses +y SOUTH, with the same
// `(float, float)` return type — see the example before assuming either.
// Example: @HXG-004
pub fn hex_corner_offset(i: integer) -> (float, float) {
hw = SQRT3 / 2.0;
if i == 0 { return (0.0, 1.0); }
if i == 1 { return (0.0 - hw, 0.5); }
if i == 2 { return (0.0 - hw, 0.0 - 0.5); }
if i == 3 { return (0.0, 0.0 - 1.0); }
if i == 4 { return (hw, 0.0 - 0.5); }
(hw, 0.5)
}
// World-plane position of corner i of hex (q,r).
pub fn hex_corner_px(q: integer, r: integer, i: integer) -> (float, float) {
(ccx, ccy) = hex_to_px(q, r);
(cox, coy) = hex_corner_offset(i);
(ccx + cox * HEX_SIZE, ccy + coy * HEX_SIZE)
}
// The two corner indices bounding the edge in neighbour direction `dir`.
// Out-of-range directions fall through to the direction-5 arm rather than
// raising, which is how a -1 from `hex_neighbor_dir` becomes a real answer.
// Example: @HXG-006
pub fn hex_edge_corners(dir: integer) -> (integer, integer) {
if dir == 0 { return (4, 5); }
if dir == 1 { return (3, 4); }
if dir == 2 { return (2, 3); }
if dir == 3 { return (1, 2); }
if dir == 4 { return (0, 1); }
(5, 0)
}
// Resolve any of the 6 directions to the owning hex + canonical edge index
// (0,1,2). Dirs 0,1,2 are stored on this hex; 3,4,5 on the neighbour.
// Call this before you key a wall on anything. A wall keyed on the raw
// (q, r, dir) a caller happened to approach from becomes TWO half-walls that
// disagree — blocking from one side and open from the other.
// Example: @HXG-003, @HXG-006
pub fn hex_canon_edge(q: integer, r: integer, dir: integer) -> (integer, integer, integer) {
if dir < 3 { return (q, r, dir); }
(cnq, cnr) = hex_neighbor(q, r, dir);
(cnq, cnr, dir - 3)
}
// ── The square local basis (cell_*) ───────────────────────────────────────
pub const GRID_SIZE = 1.0;
pub const GRID_LEN = 1.0; // distance between adjacent cells = the distance-clock unit
// Cell (q,r) -> world centre.
pub fn cell_to_px(q: integer, r: integer) -> (float, float) {
(q as float, r as float)
}
// World point -> the cell containing it (cells centred on integers).
pub fn px_to_cell(x: float, y: float) -> (integer, integer) {
(round(x) as integer, round(y) as integer)
}
// 4 orthogonal directions: 0=E, 1=S, 2=W, 3=N. Note `r` increases SOUTH here,
// where in the hex_* family it increases NORTH — `hex_neighbor(q, r, 5)` and
// `cell_neighbor(q, r, 1)` return the same pair for opposite compass directions.
// Example: @HXG-004
pub fn cell_neighbor(q: integer, r: integer, dir: integer) -> (integer, integer) {
if dir == 0 { return (q + 1, r); }
if dir == 1 { return (q, r + 1); }
if dir == 2 { return (q - 1, r); }
(q, r - 1)
}
// Inverse of cell_neighbor; -1 if not orthogonally adjacent. This — not
// `cell_distance(a, b) == 1` — is the adjacency test: the metric is Chebyshev
// and answers 1 for diagonals no single step can reach.
// Example: @HXG-005, @HXG-006
pub fn cell_neighbor_dir(q1: integer, r1: integer, q2: integer, r2: integer) -> integer {
dq = q2 - q1;
dr = r2 - r1;
if dq == 1 && dr == 0 { return 0; }
if dq == 0 && dr == 1 { return 1; }
if dq == -1 && dr == 0 { return 2; }
if dq == 0 && dr == -1 { return 3; }
-1
}
// King-move (Chebyshev) distance — matches 8-directional movement, which is NOT
// the 4-directional move set `cell_neighbor` offers. So `== 1` admits diagonals
// and is not an adjacency test; use `cell_neighbor_dir(…) >= 0` for that.
// Example: @HXG-005
pub fn cell_distance(q1: integer, r1: integer, q2: integer, r2: integer) -> integer {
dq = abs(q1 - q2);
dr = abs(r1 - r2);
if dq > dr { dq } else { dr }
}
// Corner offsets of a unit cell (centre origin): 0=NW,1=NE,2=SE,3=SW (y down).
// +y is SOUTH here, the opposite of `hex_corner_offset` above.
// Example: @HXG-004
pub fn cell_corner_offset(i: integer) -> (float, float) {
if i == 0 { return (-0.5, -0.5); }
if i == 1 { return (0.5, -0.5); }
if i == 2 { return (0.5, 0.5); }
(-0.5, 0.5)
}
// One corner of a cell in pixels. The last step before drawing: everything above it is
// exact, and this is where a renderer's coordinates begin.
pub fn cell_corner_px(q: integer, r: integer, i: integer) -> (float, float) {
(cox, coy) = cell_corner_offset(i);
(q as float + cox * GRID_SIZE, r as float + coy * GRID_SIZE)
}
// The two corner indices bounding the edge in direction dir.
pub fn cell_edge_corners(dir: integer) -> (integer, integer) {
if dir == 0 { return (1, 2); } // E: NE-SE
if dir == 1 { return (2, 3); } // S: SE-SW
if dir == 2 { return (3, 0); } // W: SW-NW
(0, 1) // N: NW-NE
}
// Resolve any of the 4 directions to the owning cell + canonical edge (0=E,1=S);
// W/N delegate to the neighbour (each physical edge stored once).
pub fn cell_canon_edge(q: integer, r: integer, dir: integer) -> (integer, integer, integer) {
if dir == 0 { return (q, r, 0); } // E
if dir == 1 { return (q, r, 1); } // S
if dir == 2 { return (q - 1, r, 0); } // W == west neighbour's E
(q, r - 1, 1) // N == north neighbour's S
}
tests/01-hex-grid.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hexgrid: the hex lattice + the square local basis.
use hex_grid::*;
fn main() {
(x, y) = hex_grid::hex_to_px(3, 5);
assert(y == 7.5, "odd-r y: 1.5 * row");
}
fn test_hex_roundtrip() {
ok = true;
for r in 0..9 {
for q in 0..9 {
(x, y) = hex_grid::hex_to_px(q, r);
(q2, r2) = hex_grid::px_to_hex(x, y);
if q2 != q || r2 != r { ok = false; }
}
}
assert(ok, "hex_to_px / px_to_hex round-trip = identity");
}
fn test_hex_neighbors_ring() {
// all 6 neighbors are at lattice distance 1 and one hex step in world space
ok = true;
for d in 0..6 {
(nq, nr) = hex_grid::hex_neighbor(7, 6, d);
if hex_grid::hex_distance(7, 6, nq, nr) != 1 { ok = false; }
(ax, ay) = hex_grid::hex_to_px(7, 6);
(bx, by) = hex_grid::hex_to_px(nq, nr);
dx = bx - ax;
dy = by - ay;
step = sqrt(dx * dx + dy * dy);
if step < hex_grid::HEX_LEN - 0.001 || step > hex_grid::HEX_LEN + 0.001 { ok = false; }
}
assert(ok, "every neighbor: distance 1, one HEX_LEN step");
}
fn test_hex_neighbor_dir_inverse() {
ok = true;
for d in 0..6 {
(nq, nr) = hex_grid::hex_neighbor(4, 4, d);
if hex_grid::hex_neighbor_dir(4, 4, nq, nr) != d { ok = false; }
}
assert(ok, "hex_neighbor_dir inverts hex_neighbor");
}
fn test_hex_distance_metric() {
assert(hex_grid::hex_distance(0, 0, 0, 0) == 0, "identity");
assert(hex_grid::hex_distance(2, 3, 2, 3) == 0, "identity 2");
assert(hex_grid::hex_distance(0, 0, 3, 0) == 3, "straight row");
a = hex_grid::hex_distance(1, 1, 6, 5);
b = hex_grid::hex_distance(6, 5, 1, 1);
assert(a == b, "symmetric");
}
fn test_hex_canon_edge_shared() {
// an edge seen from both sides canonicalizes to the same id
(q1, r1, e1) = hex_grid::hex_canon_edge(5, 5, 0);
(nq, nr) = hex_grid::hex_neighbor(5, 5, 0);
back = hex_grid::hex_neighbor_dir(nq, nr, 5, 5);
(q2, r2, e2) = hex_grid::hex_canon_edge(nq, nr, back);
assert(q1 == q2 && r1 == r2 && e1 == e2, "shared edge canonicalizes identically");
}
fn test_hex_corners_hexagon() {
// 6 corners, all at HEX_SIZE from the centre
ok = true;
(cx, cy) = hex_grid::hex_to_px(2, 2);
for i in 0..6 {
(px, py) = hex_grid::hex_corner_px(2, 2, i);
dx = px - cx;
dy = py - cy;
d = sqrt(dx * dx + dy * dy);
if d < hex_grid::HEX_SIZE - 0.001 || d > hex_grid::HEX_SIZE + 0.001 { ok = false; }
}
assert(ok, "corners lie on the circumradius");
}
fn test_cell_roundtrip() {
ok = true;
for r in 0..6 {
for q in 0..6 {
(x, y) = hex_grid::cell_to_px(q, r);
(q2, r2) = hex_grid::px_to_cell(x, y);
if q2 != q || r2 != r { ok = false; }
}
}
assert(ok, "cell_to_px / px_to_cell round-trip = identity");
}
fn test_cell_neighbor_dir_inverse() {
ok = true;
for d in 0..4 {
(nq, nr) = hex_grid::cell_neighbor(3, 3, d);
if hex_grid::cell_neighbor_dir(3, 3, nq, nr) != d { ok = false; }
}
assert(ok, "cell_neighbor_dir inverts cell_neighbor");
}
fn test_cell_canon_edge_shared() {
(q1, r1, e1) = hex_grid::cell_canon_edge(2, 2, 1);
(nq, nr) = hex_grid::cell_neighbor(2, 2, 1);
back = hex_grid::cell_neighbor_dir(nq, nr, 2, 2);
(q2, r2, e2) = hex_grid::cell_canon_edge(nq, nr, back);
assert(q1 == q2 && r1 == r2 && e1 == e2, "shared cell edge canonicalizes identically");
}
tests/02-parity.loft
// THE CROSS-LANGUAGE PARITY GATE — moros#3.
//
// `hex_grid` owns the convention, and it is implemented twice: here, and in
// moros's `html/hex-lattice.js` for the browser world-map editor. Two
// implementations of one convention is the silent-failure shape this library
// exists to close — everything "works" until two components disagree about
// which edge is north, and nothing reports it.
//
// So both sides read ONE committed file. `fixtures/lattice.tsv` is not generated
// by either of them at test time; regenerating it to make a test pass is the one
// move that defeats the whole arrangement.
//
// WHY THE FIXTURE IS INTEGERS. The obvious fixture is sampled (col,row) → (x,y)
// floats, and it cannot be asserted exactly: the two sides reach the same centre
// by different expression trees, so they may differ in the last ulp while both
// being right. That forces a tolerance, and a tolerance is exactly where a real
// half-hex error hides. The doubled integer lattice has no such problem —
//
// k = 2*col + (row & 1) m = 3*row centre = (k*sqrt(3)/2, m/2)
//
// — a parity drift is a drift of 1 in k, and 1 is visible with no epsilon.
// The float step is checked separately below, where a tolerance is honest
// because the claim there is about SCALE, not about the lattice.
use hex_grid::*;
// Relative paths resolve from the TEST FILE's directory under `loft test`,
// not the package root — a doubled `tests/` is the classic way to get this wrong.
const FIXTURE = "fixtures/lattice.tsv";
struct Row { rw_col: integer, rw_row: integer, rw_k: integer, rw_m: integer }
fn load() -> vector<Row> {
out: vector<Row> = [];
f = file(FIXTURE);
for ln in lines(f) {
if len(ln) == 0 { continue; }
if (ln[0] ?? ' ') == '#' { continue; }
parts = split(ln, '\t');
if len(parts) < 4 { continue; }
out += [Row {
rw_col: (parts[0] ?? "") as integer ?? 0,
rw_row: (parts[1] ?? "") as integer ?? 0,
rw_k: (parts[2] ?? "") as integer ?? 0,
rw_m: (parts[3] ?? "") as integer ?? 0,
}];
}
out
}
// The fixture has to actually be there and actually have rows. A loader that
// silently returned nothing would make every assertion below vacuously true —
// the sweep would pass while measuring no cells at all.
fn test_the_fixture_is_present_and_covers_both_signs() {
rows = load();
assert(len(rows) == 63, "fixture has {len(rows)} rows, want 63 — has it been regenerated?");
neg_odd = 0;
pos_odd = 0;
for r in rows {
if r.rw_row < 0 && (r.rw_row & 1) == 1 { neg_odd += 1; }
if r.rw_row > 0 && (r.rw_row & 1) == 1 { pos_odd += 1; }
}
assert(neg_odd > 0, "no ODD NEGATIVE rows in the fixture — the one case that keeps breaking");
assert(pos_odd > 0, "no odd positive rows in the fixture");
}
// The lattice claim, exactly: every sampled cell lands on the integer point the
// fixture names. This is what the JavaScript side asserts too, against the same
// bytes.
fn test_every_fixture_cell_lands_on_its_integer_lattice_point() {
for r in load() {
k = 2 * r.rw_col + (r.rw_row & 1);
m = 3 * r.rw_row;
assert(k == r.rw_k, "cell {r.rw_col},{r.rw_row}: k is {k}, the fixture says {r.rw_k}");
assert(m == r.rw_m, "cell {r.rw_col},{r.rw_row}: m is {m}, the fixture says {r.rw_m}");
// and the lattice's own closure property, which a wrong parity shift breaks
assert((k - m) % 2 == 0, "cell {r.rw_col},{r.rw_row}: k={k} m={m} breaks k = m (mod 2)");
}
}
// `hex_to_px` is the function everything actually calls, so the fixture has to
// reach it and not just the formula restated beside it. The tolerance is honest
// here: the claim is that the published position is the lattice point times the
// scale, and the two sides multiply in a different order.
fn test_hex_to_px_agrees_with_the_fixture_lattice() {
for r in load() {
(x, y) = hex_to_px(r.rw_col, r.rw_row);
want_x = (r.rw_k as float) * SQRT3 / 2.0;
want_y = (r.rw_m as float) / 2.0;
assert(abs(x - want_x) < 0.000000001,
"cell {r.rw_col},{r.rw_row}: x is {x}, the fixture's lattice says {want_x}");
assert(abs(y - want_y) < 0.000000001,
"cell {r.rw_col},{r.rw_row}: y is {y}, the fixture's lattice says {want_y}");
}
}
// ⚠ THE CASE THE OTHER IMPLEMENTATION GOT WRONG. Stated on its own rather than
// left inside the sweep, because this is the specific bug the fixture was built
// to catch, and a reader should be able to find it by name.
fn test_odd_negative_rows_are_shifted_like_odd_positive_ones() {
for i in 0..4 {
odd_neg = 0 - (2 * i + 1);
odd_pos = 2 * i + 1;
(xn, _yn) = hex_to_px(0, odd_neg);
(xp, _yp) = hex_to_px(0, odd_pos);
assert(abs(xn - xp) < 0.000000001,
"row {odd_neg} sits at x={xn} but row {odd_pos} at x={xp} — the shift stopped below zero");
assert(abs(xn - SQRT3 / 2.0) < 0.000000001,
"row {odd_neg} is not shifted half a hex: x={xn}");
}
// and the even rows must NOT be shifted, or the test above passes on a
// lattice that shifts everything
for i in 0..4 {
(xe, _ye) = hex_to_px(0, 0 - (2 * i + 2));
assert(abs(xe) < 0.000000001, "even row {0 - (2 * i + 2)} was shifted to x={xe}");
}
}
tests/03-worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `hex_grid` (@PLN141). Each `test_*` below is a REAL,
// run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @HXG-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling. These are
// teaching examples, distinct from the correctness suite in `01-hex-grid.loft`
// and the cross-language parity gate in `02-parity.loft`.
//
// This library is pure geometry — no structs, no state — so every contract it
// has lives in what the NUMBERS mean, and the compiler sees none of it. Two
// coordinate systems, two lattices and six directions all travel as bare
// `integer`s through signatures that read the same:
//
// (integer, integer) an offset (col,row) — or an axial (q,r), and the
// two are different hexes with the same type
// (float, float) a world point — with +y NORTH in one half of this
// module and +y SOUTH in the other
// integer a direction 0..5 — or -1, meaning "there isn't one"
//
// Nothing below needs a fixture or a window; they are the cheapest tests in the
// repo and they pin the traps that cost the most to find.
use hex_grid::*;
// Float comparison for world coordinates: the library reaches a point through an
// expression tree a caller will not reproduce exactly, so an exact `==` on a
// derived coordinate is a test that fails for the wrong reason.
fn about(ab_a: float, ab_b: float) -> boolean {
d = ab_a - ab_b;
if d < 0.0 { d = 0.0 - d; }
d < 0.000001
}
// @HXG-001 — `hex_round` answers AXIAL coordinates; everything else in this
// module takes ODD-R OFFSET. Both are `(integer, integer)`, so handing one
// where the other is wanted type-checks perfectly and lands you on a DIFFERENT
// hex — an adjacent one, which is the worst kind of wrong because the result
// stays plausible for a long time. `px_to_hex` is the function that does the
// conversion for you; reach for `hex_round` only if you are converting axial
// coordinates you already hold.
fn test_hex_round_answers_axial_not_offset() {
// The centre of the hex at OFFSET (0, 3). Row 3 is odd, so it is shifted
// half a hex east: x = SQRT3/2, y = 1.5 * 3.
(hx, hy) = hex_grid::hex_to_px(0, 3);
assert(about(hx, 0.8660254037844386), "odd row is shifted half a hex east");
assert(about(hy, 4.5), "y = 1.5 * row");
// The right way in: `px_to_hex` answers the OFFSET cell, which is what
// `hex_neighbor`, `hex_distance` and `hex_to_px` all expect.
(oq, orr) = hex_grid::px_to_hex(hx, hy);
assert(oq == 0 && orr == 3, "px_to_hex answers offset (col,row)");
// `hex_round` is the axial half of that conversion, exposed on its own. Fed
// the same fractional coordinates px_to_hex computes internally, it answers
// (-1, 3) — the AXIAL name of that very same hex.
(aq, ar) = hex_grid::hex_round(0.0 - 1.0, 3.0);
assert(aq == -1 && ar == 3, "hex_round answers axial (q,r)");
// Same hex, two names — and nothing in the types says so. Read the axial
// pair as though it were offset and you are one hex west:
assert(hex_grid::hex_distance(oq, orr, aq, ar) == 1,
"read as offset, the axial answer is the WEST NEIGHBOUR, not this hex");
// Which is the error a caller actually makes: it is a real cell, it is next
// door, and every downstream call keeps working.
(bx, by) = hex_grid::hex_to_px(aq, ar);
assert(about(by, hy), "same row, so the mistake never shows up in y");
assert(bx < hx, "only x moved — by one full hex step");
}
// @HXG-002 — the four DIAGONAL neighbour deltas depend on row parity. In odd-r
// offset coordinates only due east and due west are parity-free; the other four
// shift by one column between even and odd rows. So the obvious optimisation —
// a hardcoded six-entry delta table — is correct on every even row and wrong on
// every odd one, and the failure is not a crash or an out-of-range cell: it
// hands back a DIFFERENT NEIGHBOUR of the same hex, one direction over.
fn test_neighbor_deltas_depend_on_row_parity() {
// Direction 1 is SE. From an even row it is (q, r-1) …
(ea, eb) = hex_grid::hex_neighbor(4, 4, 1);
assert(ea == 4 && eb == 3, "even row: SE is (q, r-1)");
// … and from an odd row it is (q+1, r-1).
(oa, ob) = hex_grid::hex_neighbor(4, 5, 1);
assert(oa == 5 && ob == 4, "odd row: SE is (q+1, r-1)");
// A caller who cached the even-row delta and applied it on row 5 lands on
// (4, 4). That cell is not garbage — it is this hex's SOUTH-WEST neighbour:
assert(hex_grid::hex_neighbor_dir(4, 5, 4, 4) == 2,
"the naive delta answers direction 2 (SW), not the 1 (SE) that was asked for");
assert(hex_grid::hex_distance(4, 5, 4, 4) == 1,
"still adjacent, so nothing downstream complains");
// Only east and west survive a hardcoded table, which is exactly why a
// half-written one passes its first tests.
ok = true;
for r in 0..8 {
(px, py) = hex_grid::hex_neighbor(3, r, 0);
if px != 4 || py != r { ok = false; }
(mx, my) = hex_grid::hex_neighbor(3, r, 3);
if mx != 2 || my != r { ok = false; }
}
assert(ok, "E and W are parity-free on every row; the other four are not");
}
// @HXG-003 — a wall lives on the EDGE BETWEEN two hexes, and this module stores
// it once. Each hex owns only directions 0, 1 and 2; a wall in direction 3, 4
// or 5 belongs to the neighbour. `hex_canon_edge` is what resolves any of the
// six to the one slot that holds it, and skipping it does not lose the wall —
// it makes ONE wall into TWO half-walls that disagree, so a character is
// blocked walking north and walks straight through going south.
//
// `hex_place`'s @HXP-001 is the same question one level UP — one seam between
// two adjacent STENCILS — and there the answer is that it should not be stored
// at all: cut the union once and the seam is interior, so nobody owns it.
fn test_one_wall_has_exactly_one_canonical_slot() {
// Direction 4 (NW) from (5,5) is stored on the NEIGHBOUR, as its edge 1.
(cq, cr, ce) = hex_grid::hex_canon_edge(5, 5, 4);
assert(cq == 5 && cr == 6 && ce == 1, "dir 4 is the neighbour's edge 1");
// The same physical wall, approached from the other side, resolves to the
// same three numbers — which is the whole point of the canonical form.
(nq, nr) = hex_grid::hex_neighbor(5, 5, 4);
back = hex_grid::hex_neighbor_dir(nq, nr, 5, 5);
(bq, br, be) = hex_grid::hex_canon_edge(nq, nr, back);
assert(bq == cq && br == cr && be == ce, "both sides name the same slot");
// Keyed on the raw (q, r, dir) instead, the two sides are two DIFFERENT keys.
assert(wall_key(5, 5, 4) == wall_key(nq, nr, back), "canonical keys agree");
assert(raw_key(5, 5, 4) != raw_key(nq, nr, back),
"raw keys do not — one wall, stored twice, half of it invisible");
// It holds for all six directions from any hex, which is the invariant to
// rely on rather than the dir < 3 arithmetic.
ok = true;
for d in 0..6 {
(aq, ar) = hex_grid::hex_neighbor(2, 3, d);
ad = hex_grid::hex_neighbor_dir(aq, ar, 2, 3);
if wall_key(2, 3, d) != wall_key(aq, ar, ad) { ok = false; }
}
assert(ok, "every direction from a hex agrees with its neighbour's view");
}
// The recommended idiom: never key a wall on the direction you happened to
// approach it from. Canonicalise first, and the key is the wall.
fn wall_key(wk_q: integer, wk_r: integer, wk_dir: integer) -> text {
(kq, kr, ke) = hex_grid::hex_canon_edge(wk_q, wk_r, wk_dir);
"{kq}:{kr}:{ke}"
}
// What the example above warns against — shown so the difference is readable.
fn raw_key(rk_q: integer, rk_r: integer, rk_dir: integer) -> text {
"{rk_q}:{rk_r}:{rk_dir}"
}
// @HXG-004 — the two halves of this module disagree about which way `r` goes,
// and they do it while returning the same types. In the HEX family `r`
// increases NORTH (`hex_neighbor(q, r, 5)` is NE and raises r); in the CELL
// family `r` increases SOUTH (`cell_neighbor(q, r, 1)` is S and raises r). Both
// raise world y. So a structure laid out in cell coordinates and placed on the
// hex world with the same mental picture comes out MIRRORED vertically, and
// every individual call was right.
fn test_the_two_families_disagree_on_which_way_r_goes() {
// Identical arithmetic, opposite compass.
(hq, hr) = hex_grid::hex_neighbor(4, 4, 5);
(sq, sr) = hex_grid::cell_neighbor(4, 4, 1);
assert(hq == sq && hr == sr, "both answer (4,5) — the same pair of integers");
// hex direction 5 is NE; cell direction 1 is S. Nothing in the result says which.
// The corner families carry the same split. Hex corner 0 is the TOP corner,
// at +y …
(tx, ty) = hex_grid::hex_corner_offset(0);
assert(about(tx, 0.0) && about(ty, 1.0), "hex corner 0 is the top, at +y");
// … while cell corner 0 is the NORTH-west corner, at -y.
(nx, ny) = hex_grid::cell_corner_offset(0);
assert(about(nx, -0.5) && about(ny, -0.5), "cell corner 0 is NW, at -y");
// Read together: in the hex half, north is +y; in the cell half, north is -y.
// A caller that picks one convention for the whole module is wrong for half
// of it, and a vertical mirror is the symptom.
(ya, yb) = hex_grid::hex_to_px(0, 1);
(za, zb) = hex_grid::hex_to_px(0, 0);
assert(yb > zb, "hex row 1 is NORTH of row 0, and has the larger y");
(wa, wb) = hex_grid::cell_to_px(0, 1);
(va, vb) = hex_grid::cell_to_px(0, 0);
assert(wb > vb, "cell row 1 is SOUTH of row 0, and also has the larger y");
}
// @HXG-005 — "distance 1" and "adjacent" are the SAME question in the hex family
// and DIFFERENT questions in the cell family. `cell_distance` is the Chebyshev
// (king-move) metric, so it answers 1 for a diagonal pair — but `cell_neighbor`
// offers only four orthogonal directions, so no single step gets you there. A
// caller who writes `cell_distance(a, b) == 1` as an adjacency test silently
// admits diagonals, which is how a wall gets walked around through its corner.
fn test_distance_one_is_not_adjacency_for_cells() {
assert(hex_grid::cell_distance(0, 0, 1, 1) == 1, "Chebyshev: a diagonal is 1 away");
assert(hex_grid::cell_neighbor_dir(0, 0, 1, 1) == -1, "…and is not a neighbour");
assert(cell_adjacent(0, 0, 1, 1) == false, "the predicate to use instead");
assert(cell_adjacent(0, 0, 1, 0), "orthogonal pairs still adjacent");
// In the hex family the two predicates coincide, because all six neighbours
// are at distance 1 and nothing else is — which is why the cell case surprises.
ok = true;
for d in 0..6 {
(aq, ar) = hex_grid::hex_neighbor(3, 3, d);
if hex_grid::hex_distance(3, 3, aq, ar) != 1 { ok = false; }
if hex_grid::hex_neighbor_dir(3, 3, aq, ar) < 0 { ok = false; }
}
assert(ok, "hex: distance 1 and adjacency agree");
}
// The predicate to reach for: ask the direction function, not the metric.
fn cell_adjacent(ca_q1: integer, ca_r1: integer, ca_q2: integer, ca_r2: integer) -> boolean {
hex_grid::cell_neighbor_dir(ca_q1, ca_r1, ca_q2, ca_r2) >= 0
}
// @HXG-006 — `-1` means "there is no such direction", and it is an `integer`, so
// it flows straight into every function that takes a direction. Nothing raises.
// `hex_edge_corners` and `hex_neighbor` both end in a fall-through arm, so -1
// silently BECOMES direction 5; `hex_canon_edge` takes its `dir < 3` branch and
// hands back an edge index of -1, which then indexes a wall table. Check the
// answer before you spend it — the check is one comparison and there is no
// null to remind you.
fn test_minus_one_is_not_null_and_it_indexes() {
bad = hex_grid::hex_neighbor_dir(0, 0, 5, 5);
assert(bad == -1, "distant hexes have no direction between them");
// -1 is not rejected anywhere; it is absorbed as direction 5.
(ma, mb) = hex_grid::hex_edge_corners(-1);
(fa, fb) = hex_grid::hex_edge_corners(5);
assert(ma == fa && mb == fb, "hex_edge_corners(-1) answers the direction-5 corners");
(na, nb) = hex_grid::hex_neighbor(4, 4, -1);
(va, vb) = hex_grid::hex_neighbor(4, 4, 5);
assert(na == va && nb == vb, "hex_neighbor(q,r,-1) answers the direction-5 neighbour");
// …except here, where it survives as an edge index of -1 rather than becoming 5.
// Two different wrong answers from one bad direction is what makes this hard to
// trace back: the wall is stored under an index no reader will ever ask for.
(ca, cb, ce) = hex_grid::hex_canon_edge(4, 4, -1);
assert(ca == 4 && cb == 4 && ce == -1, "hex_canon_edge keeps the -1 as an edge index");
// The guard, in full: one comparison, before the direction is used for anything.
assert(step_or_stay(4, 4, 0, 0) == "4:4", "no direction: stay put");
assert(step_or_stay(4, 4, 5, 4) == "5:4", "a real direction: take the step");
}
// The recommended idiom: turn the sentinel into a decision at the boundary,
// rather than letting it travel as a direction.
fn step_or_stay(ss_q: integer, ss_r: integer, ss_tq: integer, ss_tr: integer) -> text {
d = hex_grid::hex_neighbor_dir(ss_q, ss_r, ss_tq, ss_tr);
if d < 0 { return "{ss_q}:{ss_r}"; }
(nq, nr) = hex_grid::hex_neighbor(ss_q, ss_r, d);
"{nq}:{nr}"
}