hex_field source

← hex_field · API reference · version 0.1.7

9 file(s), 3343 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.

ItemDefinedWorked exampleCall sites
HexSet struct58—77 81 85 91 93 95 … (35 total)
VecMap struct68—429 545 549 570 577 591 … (7 total)
hexset_chunk fn77—86 859 894 1095 1189 1245 … (32 total)
hexset_new fn85—318 366 388 12 22 34 … (8 total)
hexset_q0 fn91—736 749 772 785 795 805 … (21 total)
hexset_r0 fn93—736 749 773 785 795 805 … (21 total)
hexset_w fn95—749 759 1046 1047 1058 1068 … (17 total)
hexset_h fn97—749 760 1046 1047 1058 1068 … (16 total)
hexset_get fn108—413 416 439 445 785 1074 … (26 total)
hexset_set fn116—321 333 374 399 912 1114 … (34 total)
hexset_count fn127—14 25 37 72 39 118 … (50 total)
lattice_k fn133—370 391 440 446 1006 1021 … (34 total)
lattice_m fn135—371 392 441 446 1007 1021 … (34 total)
nb_q fn138—416 443 830 1139 1220 1328 … (21 total)
nb_r fn150—416 444 830 1140 1221 1329 … (21 total)
Heights struct168—178 181 184 192 198 717 … (19 total)
heights_new fn178—860 895 1096 1190 1245 25 … (30 total)
height_set fn192—921 1115 1203 1272 1409 29 … (12 total)
height_get fn198—795 1115 1203 1272 45 33 … (12 total)
Labels struct212—222 225 228 236 242 718 … (19 total)
labels_new fn222—861 896 1097 1191 1245 26 … (31 total)
label_set fn236—930 1116 1204 1273 1410 30 … (16 total)
label_get fn242—805 1116 1204 1273 47 31 … (13 total)
hex_at fn253HXL-006223
corner_k fn282—449 451
corner_m fn293—450 452
form_hexdisk fn317—188 189 190
hexdisk_into fn330—13 23 35 69 49
hex_dist fn339—321 333
form_circle fn365HXL-005192 193 194 197 198 199
form_octagon fn387HXL-005202 203
wall_count fn409—none in this package
trace fn429HXL-00124 36 70 15 65 89 … (11 total)
shoelace2 fn549—572 579 95 96 99 100
outline_count fn570HXL-003629 130 137 140 163
shoelace_total fn577HXL-002612 25 72 15 101 102 … (12 total)
validate fn591HXL-00437 39 129 136 164 174
HXF_MAGIC const685—770 872 102 135 147 161 … (14 total)
HXF_SCHEMA const686—771 874 103 147 161 96 … (12 total)
HXF_HEADER const687—869 891
TAG_OCCU const689—779 907 111 165 100 99 … (9 total)
TAG_HGHT const690—791 916
TAG_LABL const691—801 925
TAG_EDGE const692—820 934 102
TAG_LAYR const699—840 942 101
HXF_OK const703—858 36 65 87 116 127 … (18 total)
HXF_BAD_MAGIC const704—872 362
HXF_BAD_SCHEMA const705—874 139 367
HXF_BAD_RESERVED const706—881 152 372
HXF_BAD_SECTION const707—908 917 926 936 946 169 … (9 total)
HXF_TRUNCATED const708—869 902 905 185 192 394
HXF_MISSING_OCCU const709—964 406
HXF_BAD_EXTENT const710—890 891 446 452
HexDoc struct714—856 857
doc_write fn732—34 63 66 85 178 45 … (7 total)
doc_write_edges fn744HXL-012736 67 82 136 486 499
doc_write_all fn755—748 77
doc_read fn856HXL-01135 64 86 115 127 138 … (33 total)
ROT_OK const987—1048 1059 141 145
ROT_NOT_MULTIPLE const988—1248 131 134
lattice_to_cell fn992—1015 1021 1270 1296 1332 1333 … (15 total)
lattice_rot60 fn999—1011 33
cell_rot fn1005—1075 1113 1149 1150 49 52 … (10 total)
cell_mirror fn1020—1173 1201 1227 1228 74 75
Stencil struct1027—1043 1044 1055 1056 1066 1165 … (19 total)
stencil_from fn1043—1244 12 244 40 277
stencil_with_edges fn1054—1094 1188 184 15 254
stencil_rotate fn1066HXL-0071192 1224 1251 62 196 342 … (12 total)
stencil_mirror fn1165HXL-007208 212 340 266
stencil_rotate_deg fn1242—130 133 140 144
stencil_stamp fn1257—1285 1348 89 103 104 105 … (14 total)
stencil_stamp_layers fn1281—1313 67
stencil_stamp_all fn1309—300 327
stencil_stamp_edges fn1344—54
stencil_unstamp fn1394—1425
stencil_unstamp_layers fn1421—1456
stencil_unstamp_all fn1452—304 328
EdgeSet struct1521—719 745 756 1031 1054 1310 … (27 total)
EDGE_MAT_MAX const1538—1531 1598 328 332
edgeset_new fn1541HXL-008736 862 897 1046 1103 1192 … (17 total)
edge_key fn1554HXL-00819 20 297 298
edge_set_mat fn1590HXL-0091334 1369 1475 1652 181 14 … (15 total)
edgeset_refused fn1606HXL-009330 336 342
edge_mat fn1610—830 1141 1222 1330 1365 1472 … (22 total)
edge_set_surf fn1631—1651
edge_surf fn1642—1142 1223 261
edge_set_both fn1649—1151 1230 253
edgeset_surf_count fn1657—255 262
edgeset_equal fn1667—263 267 271 502
edgeset_digest fn1682—none in this package
edgeset_bytes fn1697—none in this package
edgeset_q0 fn1700—none in this package
edgeset_r0 fn1702—none in this package
edgeset_w fn1704—483
edgeset_h fn1706—none in this package
edgeset_count_all fn1712—193 198 199 210 211 302 … (15 total)
edgeset_count fn1722—30 35 38 39 55 71 … (13 total)
Layers struct1753—720 756 1032 1282 1310 1422 … (22 total)
layers_new fn1764—749 863 898 1047 1058 1107 … (12 total)
layer_count fn1772—766 837 1108 1117 1197 1205 … (15 total)
layer_name fn1774—838 1108 1197 1290 1298 1437
layers_q0 fn1776—none in this package
layers_r0 fn1778—none in this package
layers_w fn1780—none in this package
layers_h fn1782—none in this package
layer_index fn1786—1797 1815 1822 82 83
layer_add fn1796—948 1108 1197 1290 185 8 … (11 total)
layer_set fn1814—1299 1438 186 11 12 18 … (9 total)
layer_get fn1821—1299 202 314 13 14 15 … (15 total)
layer_get_at fn1828—846 1118 1206
layer_set_at fn1835—952 1118 1206

src/hex_field.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_field — hex cell sets -> an EXACT integer vector map.
//
// A consumer's Python oracle checks the traced output against golden JSON byte-for-byte,
// which is possible because the whole representation is INTEGER:
//
//   every hex centre AND corner lies on the lattice  x = k*sqrt(3)/2 , y = m/2
//   with centre(q,r) = (2q + (r&1), 3r) and corners at (0,+-2), (+-1,+-1).
//
// So there is no float anywhere in the geometry, no epsilon compare, and the area
// round-trip is exact integer arithmetic:
//
//   sum of the integer shoelace over all loops  ==  12 * (number of hexes)
//
// A single hex shoelaces to exactly 12; holes contribute negative.  Cell count and traced
// outline therefore cannot disagree — that is what makes the emitted vector map safe to
// hand to a 2D renderer or a mesh routine.
//
// Storage follows the survival guide: a flat pre-allocated vector indexed by a packed
// key (no tuple-keyed sets, no capture-append-reassign), and loops are held as flat
// coordinate arrays plus a start index, not vector<vector<>>.
//
// ── Using it ─────────────────────────────────────────────────────────────────
//
// Four steps, and every routine below serves one of them: make a chunk, fill cells,
// trace, read the loops.
//
//   s = hexset_chunk(-2, -2, 5, 5);   // 1. a bounded window of hex coords
//   hexdisk_into(s, 0, 0, 1);         // 2. fill it — a disc of radius 1 at (0,0)
//   hexset_set(s, 0, 0, false);       //    …and punch the middle out
//
//   v = trace(s);                     // 3. -> VecMap: closed integer loops
//   for i in 0..v.vm_loops {          // 4. loop i is [vm_start[i], vm_start[i+1])
//     for j in (v.vm_start[i] ?? 0)..(v.vm_start[i + 1] ?? 0) {
//       // (v.vm_k[j], v.vm_m[j]) is a lattice point: x = k*sqrt(3)/2, y = m/2
//     }
//   }
//
// The output is what a renderer or a mesher wants — closed outlines, holes included,
// with no float to disagree about.  Scale is applied by the CONSUMER at the last
// moment (see the README), so the same map serves a minimap and a mesh.
//
// The rest of the surface is the same four steps at more resolutions: `Heights` and
// `Labels` attach a value per cell, `EdgeSet` and `Layers` attach one per edge, and
// the `hexset_*`/`edgeset_*`/`layers_*` readers are how a consumer asks a chunk what
// window it covers without knowing how it is stored.
//
// Worked examples: `tests/07-worked-examples.loft` (@HXL-001 … @HXL-005) — start at
// @HXL-001, which writes down a single hex's six lattice points in full.

// Occupancy over a BOUNDED CHUNK of hex coords: q in [hs_q0 .. hs_q0+hs_w),
// r in [hs_r0 .. hs_r0+hs_h).  Deliberately a chunk, not a global graph — every
// routine here is O(chunk) and never walks an unbounded neighbourhood, so the same
// code serves a 32x32 world chunk (plan #2) as serves a one-off tower window.
// Anything spanning chunks is traced per chunk (with a halo) and stitched; nothing
// here assumes the form is centred, or that the world is reachable.
pub struct HexSet {
  hs_q0:    integer,
  hs_r0:    integer,
  hs_w:     integer,
  hs_h:     integer,
  hs_cells: vector<boolean>,
  hs_count: integer,
}

// A vector map: loops concatenated, loop i occupying [vm_start[i], vm_start[i+1]).
pub struct VecMap {
  pub vm_k:     vector<integer>,
  pub vm_m:     vector<integer>,
  pub vm_start: vector<integer>,
  pub vm_loops: integer,
}

// A chunk anchored at (q0,r0), w x h cells.  THE primitive — everything else builds
// on it, so no routine can accidentally depend on a global frame.
pub fn hexset_chunk(q0: integer, r0: integer, w: integer, h: integer) -> HexSet {
  cells: vector<boolean> = [];
  n = w * h;
  for _ in 0..n { cells += [false]; }
  HexSet { hs_q0: q0, hs_r0: r0, hs_w: w, hs_h: h, hs_cells: cells, hs_count: 0 }
}

// Convenience: a window centred on the origin, radius `w`.
pub fn hexset_new(w: integer) -> HexSet {
  hexset_chunk(0 - w, 0 - w, 2 * w + 1, 2 * w + 1)
}

// Step 1's window, read back.  A consumer that did not build the chunk asks it where it
// starts and how far it runs, rather than carrying those four numbers alongside it.
pub fn hexset_q0(s: HexSet) -> integer { s.hs_q0 }
// The window's first `r`.
pub fn hexset_r0(s: HexSet) -> integer { s.hs_r0 }
// How many `q` columns the window spans.
pub fn hexset_w(s: HexSet) -> integer { s.hs_w }
// How many `r` rows the window spans.
pub fn hexset_h(s: HexSet) -> integer { s.hs_h }

fn hs_index(s: HexSet, q: integer, r: integer) -> integer {
  dq = q - s.hs_q0;
  dr = r - s.hs_r0;
  if dq < 0 || dq >= s.hs_w || dr < 0 || dr >= s.hs_h { return -1; }
  dr * s.hs_w + dq
}

// Step 2 read: is this cell filled?  Outside the window is `false`, not an error — a
// caller may probe a neighbour that lies past the edge without guarding every call.
pub fn hexset_get(s: HexSet, q: integer, r: integer) -> boolean {
  i = hs_index(s, q, r);
  if i < 0 { return false; }
  s.hs_cells[i] ?? false
}

// Step 2 write: fill or clear one cell.  Clearing an interior cell is how a hole is made,
// and `trace` will emit it as its own negative loop.  Outside the window it does nothing.
pub fn hexset_set(s: HexSet, q: integer, r: integer, on: boolean) {
  i = hs_index(s, q, r);
  if i < 0 { return; }
  had = s.hs_cells[i] ?? false;
  s.hs_cells[i] = on;
  if on && !had { s.hs_count = s.hs_count + 1; }
  if !on && had { s.hs_count = s.hs_count - 1; }
}

// How many cells are filled.  The cheap half of the area round-trip: `12 * hexset_count`
// is what the traced outline must shoelace to, so this is the number a check compares.
pub fn hexset_count(s: HexSet) -> integer { s.hs_count }

// ── lattice + adjacency (integers only) ──────────────────────────────────────

// Hex (q,r) -> exact integer lattice centre.  Matches hex_grid::hex_to_px scaled by
// (sqrt(3)/2, 1/2) — verified against the library before porting.
pub fn lattice_k(q: integer, r: integer) -> integer { 2 * q + (r & 1) }
// The lattice `m` of hex (q,r) — the y half of the pair `lattice_k` gives the x of.
pub fn lattice_m(_q: integer, r: integer) -> integer { 3 * r }

// Neighbour (q,r) in direction d, odd-r offset.  Same SET as hex_grid::hex_neighbor.
pub fn nb_q(q: integer, r: integer, d: integer) -> integer {
  odd = r & 1;
  if d == 0 { return q + 1; }
  if d == 1 { return q - 1; }
  if d == 2 { if odd == 1 { return q + 1; } return q; }
  if d == 3 { if odd == 1 { return q; } return q - 1; }
  if d == 4 { if odd == 1 { return q + 1; } return q; }
  if odd == 1 { return q; }
  q - 1
}

// The neighbour's `r`, the companion of `nb_q`.  Walk both to step across the field.
pub fn nb_r(_q: integer, r: integer, d: integer) -> integer {
  if d == 0 { return r; }
  if d == 1 { return r; }
  if d == 2 { return r - 1; }
  if d == 3 { return r - 1; }
  if d == 4 { return r + 1; }
  r + 1
}

// ── HEIGHTS ─────────────────────────────────────────────────────────────────
//
// A per-CELL height, chunk-indexed exactly like a HexSet.  Everything before stairs got by
// with height on the material (how tall a wall is); a staircase is the first thing that
// needs the GROUND itself to have a z, because each step is a different floor.
//
// It is deliberately a separate structure rather than a field on HexSet: most of the world
// is flat and should not pay for a height it never reads, and a height field is derived
// (from a way, a contour, a terrace) exactly as the edge field is.
pub struct Heights {
  hz_q0: integer,
  hz_r0: integer,
  hz_w:  integer,
  hz_h:  integer,
  hz_z:  vector<float>,
}

// A height per cell over the same window shape a HexSet uses, so the two index alike.
// Attach one when the field needs elevation as well as occupancy; every cell starts 0.0.
pub fn heights_new(q0: integer, r0: integer, w: integer, h: integer) -> Heights {
  z: vector<float> = [];
  for _ in 0..w * h { z += [0.0]; }
  Heights { hz_q0: q0, hz_r0: r0, hz_w: w, hz_h: h, hz_z: z }
}

fn hz_index(f: Heights, q: integer, r: integer) -> integer {
  dq = q - f.hz_q0;
  dr = r - f.hz_r0;
  if dq < 0 || dr < 0 || dq >= f.hz_w || dr >= f.hz_h { return -1; }
  dr * f.hz_w + dq
}

// Set one cell's height.  Outside the window it does nothing, matching `hexset_set`.
pub fn height_set(f: Heights, q: integer, r: integer, z: float) {
  i = hz_index(f, q, r);
  if i >= 0 { f.hz_z[i] = z; }
}

// One cell's height, or 0.0 outside the window — probe a neighbour without guarding.
pub fn height_get(f: Heights, q: integer, r: integer) -> float {
  i = hz_index(f, q, r);
  if i < 0 { return 0.0; }
  f.hz_z[i] ?? 0.0
}

// ── LABELS ──────────────────────────────────────────────────────────────────
//
// A per-CELL integer, chunk-indexed exactly like Heights.  Heights answers "how high is
// this cell"; Labels answers "whose is it".  Plan #9 (canopy-first trees) needs the second
// because a canopy is a PARTITION under competition rather than a stamped footprint — the
// assignment is the output, not a tie-break.
//
// -1 means unowned, which is a real state: a cell no crown reaches belongs to nobody.
pub struct Labels {
  lb_q0: integer,
  lb_r0: integer,
  lb_w:  integer,
  lb_h:  integer,
  lb_v:  vector<integer>,
}

// An integer label per cell over a HexSet-shaped window: region ids, materials, owners.
// Unset cells read -1, which is why -1 is not a usable label.
pub fn labels_new(q0: integer, r0: integer, w: integer, h: integer) -> Labels {
  v: vector<integer> = [];
  for _ in 0..w * h { v += [-1]; }
  Labels { lb_q0: q0, lb_r0: r0, lb_w: w, lb_h: h, lb_v: v }
}

fn lb_index(f: Labels, q: integer, r: integer) -> integer {
  dq = q - f.lb_q0;
  dr = r - f.lb_r0;
  if dq < 0 || dr < 0 || dq >= f.lb_w || dr >= f.lb_h { return -1; }
  dr * f.lb_w + dq
}

// Label one cell.  Outside the window it does nothing.
pub fn label_set(f: Labels, q: integer, r: integer, v: integer) {
  i = lb_index(f, q, r);
  if i >= 0 { f.lb_v[i] = v; }
}

// One cell's label, or -1 when unset or outside the window.
pub fn label_get(f: Labels, q: integer, r: integer) -> integer {
  i = lb_index(f, q, r);
  if i < 0 { return -1; }
  f.lb_v[i] ?? -1
}

// World point -> the cell that contains it.  The exact inverse of the centre map
// (lattice_k / lattice_m), by the standard axial-fractional + cube-round route.  Gated two
// ways in sighttest: it must round-trip every cell of a chunk, and it must agree cell for
// cell with hex_grid::px_to_hex, which is the canonical convention this whole plan follows.
// Example: @HXL-006
pub fn hex_at(px: float, py: float) -> (integer, integer) {
  qf = 0.5773502691896258 * px - 0.3333333333333333 * py;
  rf = 0.6666666666666666 * py;
  cx = qf;
  cz = rf;
  cy = 0.0 - cx - cz;
  rx = round(cx);
  ry = round(cy);
  rz = round(cz);
  dx = abs(rx - cx);
  dy = abs(ry - cy);
  dz = abs(rz - cz);
  if dx > dy && dx > dz {
    rx = 0.0 - ry - rz;
  } else if dy > dz {
    ry = 0.0 - rx - rz;
  } else {
    rz = 0.0 - rx - ry;
  }
  aq = rx as integer;
  ar = rz as integer;
  (aq + ((ar - (ar & 1)) / 2 ?? 0), ar)
}

// Corner i of a cell, as an offset from its lattice centre.  Ordered so consecutive
// corners bound one edge; index matches the Python CORNER table exactly.
// CANONICAL corner order — this is hex_grid::hex_corner_offset / worldmesh.loft's
// hex_lat_offset (the watertight instanced floor depends on it), in exact lattice
// units.  Do NOT invent another ordering: downstream code reads corner i by index.
pub fn corner_k(i: integer) -> integer {
  if i == 0 { return 0; }
  if i == 1 { return -1; }
  if i == 2 { return -1; }
  if i == 3 { return 0; }
  if i == 4 { return 1; }
  1
}

// Corner i's lattice `m` offset from the centre — the companion of `corner_k`.  Together
// they are the six offsets every traced vertex is built from, in the order `trace` emits.
pub fn corner_m(i: integer) -> integer {
  if i == 0 { return 2; }
  if i == 1 { return 1; }
  if i == 2 { return -1; }
  if i == 3 { return -2; }
  if i == 4 { return -1; }
  1
}

// Which corner pair (i, i+1) is the edge shared with the neighbour at lattice delta?
fn edge_of(dk: integer, dm: integer) -> integer {
  if dk == -1 && dm == 3 { return 0; }
  if dk == -2 && dm == 0 { return 1; }
  if dk == -1 && dm == -3 { return 2; }
  if dk == 1 && dm == -3 { return 3; }
  if dk == 2 && dm == 0 { return 4; }
  if dk == 1 && dm == 3 { return 5; }
  -1
}

// ── candidate placement routines ─────────────────────────────────────────────

// Steps 1 and 2 in one call: a window of radius `w` holding a disc of radius `n`.  The
// quickest way to get something traceable when the shape itself is not the point.
pub fn form_hexdisk(w: integer, n: integer) -> HexSet {
  s = hexset_new(w);
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      if hex_dist(0, 0, q, r) <= n { hexset_set(s, q, r, true); }
    }
  }
  s
}

// Fill a hex-distance disk centred anywhere INTO an existing chunk.  Cells outside the
// chunk are simply not written — the caller decides the halo.  This is the chunk-local
// form of form_hexdisk and the one a world chunk actually uses.
pub fn hexdisk_into(s: HexSet, cq: integer, cr: integer, n: integer) {
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      if hex_dist(cq, cr, q, r) <= n { hexset_set(s, q, r, true); }
    }
  }
}

// Hex distance on odd-r offset, via cube coords.
pub fn hex_dist(q1: integer, r1: integer, q2: integer, r2: integer) -> integer {
  x1 = q1 - ((r1 - (r1 & 1)) / 2 ?? 0);
  z1 = r1;
  y1 = 0 - x1 - z1;
  x2 = q2 - ((r2 - (r2 & 1)) / 2 ?? 0);
  z2 = r2;
  y2 = 0 - x2 - z2;
  dx = x1 - x2; if dx < 0 { dx = 0 - dx; }
  dy = y1 - y2; if dy < 0 { dy = 0 - dy; }
  dz = z1 - z2; if dz < 0 { dz = 0 - dz; }
  m = dx;
  if dy > m { m = dy; }
  if dz > m { m = dz; }
  m
}

// Euclidean disk: the hex CENTRE must lie within `radius` world units of the origin.
// Compared in squared lattice units to keep it integer until the final test.
//
// ⚠ `radius` IS NOT IN HEX STEPS.  A world unit here is the one the lattice defines —
// x = k·√3/2, y = m/2 — in which a hex has CIRCUMRADIUS 1 and two neighbouring centres
// are √3 ≈ 1.732 apart.  So `form_circle(w, 3.0)` is 13 cells while `form_hexdisk(w, 3)`
// is 37: the same 3, two units, a factor of √3 between them.  To ask for n hex steps,
// pass `n * 1.7320508075688772` — `form_circle(w, 2·√3)` and `form_hexdisk(w, 2)` are
// the same 19 cells.
// Example: @HXL-005
pub fn form_circle(w: integer, radius: float) -> HexSet {
  s = hexset_new(w);
  lim = radius * radius;
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      k = lattice_k(q, r);
      m = lattice_m(q, r);
      x = (k as float) * 0.8660254037844386;
      y = (m as float) * 0.5;
      if x * x + y * y <= lim + 0.000000001 { hexset_set(s, q, r, true); }
    }
  }
  s
}

// Regular octagon, apothem `a`, one flat side on the world x-axis (canonical
// orientation — an octagon is 8-fold symmetric and the grid is 6-fold, so the
// orientation is FROZEN, not free).
//
// `apothem` is in the same lattice world units as `form_circle`'s radius — hex
// circumradius 1, so a hex STEP is √3 — not in hex steps.
// Example: @HXL-005
pub fn form_octagon(w: integer, apothem: float) -> HexSet {
  s = hexset_new(w);
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      x = (lattice_k(q, r) as float) * 0.8660254037844386;
      y = (lattice_m(q, r) as float) * 0.5;
      if x < 0.0 { x = 0.0 - x; }
      if y < 0.0 { y = 0.0 - y; }
      d = x;
      if y > d { d = y; }
      e = (x + y) * 0.7071067811865476;
      if e > d { d = e; }
      if d <= apothem + 0.000000001 { hexset_set(s, q, r, true); }
    }
  }
  s
}

// ── the tracer: cells -> exact integer loops ─────────────────────────────────

// How many filled cells touch an empty one or the window edge — the form's perimeter in
// cells.  Useful before tracing, when a cost or a bound depends on the boundary size.
pub fn wall_count(s: HexSet) -> integer {
  n = 0;
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      if hexset_get(s, q, r) {
        edge = false;
        for d in 0..6 {
          if !hexset_get(s, nb_q(q, r, d), nb_r(q, r, d)) { edge = true; }
        }
        if edge { n = n + 1; }
      }
    }
  }
  n
}

// Trace the boundary into ordered closed loops.  Each boundary edge is emitted directed
// corner_i -> corner_i+1, which gives every loop a consistent orientation (outer one
// way, enclosed holes the other) — what a fill rule and a triangulator both need.
// Example: @HXL-001
pub fn trace(s: HexSet) -> VecMap {
  // collect directed edges as parallel arrays
  ak: vector<integer> = [];
  am: vector<integer> = [];
  bk: vector<integer> = [];
  bm: vector<integer> = [];
  used: vector<boolean> = [];
  ne = 0;
  for r in s.hs_r0..s.hs_r0 + s.hs_h {
    for q in s.hs_q0..s.hs_q0 + s.hs_w {
      if hexset_get(s, q, r) {
        k0 = lattice_k(q, r);
        m0 = lattice_m(q, r);
        for d in 0..6 {
          nq = nb_q(q, r, d);
          nr = nb_r(q, r, d);
          if !hexset_get(s, nq, nr) {
            i = edge_of(lattice_k(nq, nr) - k0, lattice_m(nq, nr) - m0);
            if i >= 0 {
              j = i + 1; if j > 5 { j = 0; }
              ak += [k0 + corner_k(i)];
              am += [m0 + corner_m(i)];
              bk += [k0 + corner_k(j)];
              bm += [m0 + corner_m(j)];
              used += [false];
              ne = ne + 1;
            }
          }
        }
      }
    }
  }

  // TERMINATION.  The walk below is unambiguous only because every boundary vertex has
  // exactly ONE outgoing edge — a real property of hex grids (each vertex touches 3
  // MUTUALLY ADJACENT cells, so a boundary cannot pinch at a vertex the way it can on a
  // square grid).  Relying on that silently means a bad input HANGS instead of failing,
  // so the walk carries an explicit budget: every edge is consumed at most once, so no
  // more than `ne` steps can ever be legitimate.
  vk: vector<integer> = [];
  vm: vector<integer> = [];
  st: vector<integer> = [];
  nloops = 0;
  done = 0;
  budget = ne + 1;
  while done < ne {
    // find an unused edge to start a loop
    seed = -1;
    for e in 0..ne { if seed < 0 && !(used[e] ?? true) { seed = e; } }
    if seed < 0 { done = ne; }
    if seed >= 0 {
      st += [len(vk)];
      nloops = nloops + 1;
      sk = ak[seed] ?? 0;
      sm = am[seed] ?? 0;
      ck = sk;
      cm = sm;
      going = true;
      while going {
        budget = budget - 1;
        if budget < 0 { going = false; done = ne; }
        // find the unused edge leaving (ck,cm)
        pick = -1;
        for e in 0..ne {
          if pick < 0 && !(used[e] ?? true) && (ak[e] ?? 0) == ck && (am[e] ?? 0) == cm {
            pick = e;
          }
        }
        if pick < 0 { going = false; }
        if pick >= 0 && budget >= 0 {
          used[pick] = true;
          done = done + 1;
          vk += [ck];
          vm += [cm];
          ck = bk[pick] ?? 0;
          cm = bm[pick] ?? 0;
          if ck == sk && cm == sm { going = false; }
        }
      }
    }
  }
  st += [len(vk)];
  // CANONICAL START: rotate each loop to begin at its lexicographically smallest
  // vertex.  A traced cycle is the same shape whichever edge seeded it, so the raw
  // start is an artefact of iteration order and two correct implementations disagree
  // on it.  Canonicalising makes the vector map UNIQUE — which is what lets the golden
  // JSON act as a language-neutral contract.  (Found by this port disagreeing with the
  // python oracle on the start vertex while every point and the area matched.)
  for li in 0..nloops {
    a = st[li] ?? 0;
    b = st[li + 1] ?? 0;
    n = b - a;
    if n > 1 {
      best = 0;
      for cj in 1..n {
        bk2 = vk[a + best] ?? 0;
        bm2 = vm[a + best] ?? 0;
        jk = vk[a + cj] ?? 0;
        jm = vm[a + cj] ?? 0;
        if jk < bk2 || (jk == bk2 && jm < bm2) { best = cj; }
      }
      if best > 0 {
        rk: vector<integer> = [];
        rm: vector<integer> = [];
        for rj in 0..n {
          idx = best + rj; if idx >= n { idx = idx - n; }
          rk += [vk[a + idx] ?? 0];
          rm += [vm[a + idx] ?? 0];
        }
        for wj in 0..n {
          vk[a + wj] = rk[wj] ?? 0;
          vm[a + wj] = rm[wj] ?? 0;
        }
      }
    }
  }
  VecMap { vm_k: vk, vm_m: vm, vm_start: st, vm_loops: nloops }
}

// Twice the signed area of loop i, in integer lattice units.  Exact.
pub fn shoelace2(v: VecMap, i: integer) -> integer {
  a = v.vm_start[i] ?? 0;
  b = v.vm_start[i + 1] ?? 0;
  n = b - a;
  s = 0;
  for j in 0..n {
    j2 = j + 1; if j2 >= n { j2 = 0; }
    k1 = v.vm_k[a + j] ?? 0;
    m1 = v.vm_m[a + j] ?? 0;
    k2 = v.vm_k[a + j2] ?? 0;
    m2 = v.vm_m[a + j2] ?? 0;
    s = s + k1 * m2 - k2 * m1;
  }
  s
}

// How many loops enclose area — one per connected component of the cell set.  Exposed
// because `validate` deliberately no longer insists on exactly one (see there): a caller
// that knows its form is a single piece can still assert `outline_count(v) == 1`, and a
// caller tracing a world chunk can ask how many pieces landed in it.
// Example: @HXL-003
pub fn outline_count(v: VecMap) -> integer {
  n = 0;
  for i in 0..v.vm_loops { if shoelace2(v, i) > 0 { n = n + 1; } }
  n
}

// Example: @HXL-002
pub fn shoelace_total(v: VecMap) -> integer {
  t = 0;
  for i in 0..v.vm_loops { t = t + shoelace2(v, i); }
  t
}

// ── the validator — what must hold before a renderer/mesh may consume it ─────

// Returns 0 when the vector map is safe to consume; otherwise a failure code:
//   1 no loops   2 degenerate loop   3 segment is not a hex edge
//   4 area round-trip failed         5 no outer loop at all
//   6 a vertex repeats (the outline touches itself)
// Example: @HXL-003
// Example: @HXL-004
pub fn validate(v: VecMap, cells: integer) -> integer {
  if v.vm_loops <= 0 { return 1; }
  for i in 0..v.vm_loops {
    a = v.vm_start[i] ?? 0;
    b = v.vm_start[i + 1] ?? 0;
    n = b - a;
    if n < 3 { return 2; }
    for j in 0..n {
      j2 = j + 1; if j2 >= n { j2 = 0; }
      dk = (v.vm_k[a + j2] ?? 0) - (v.vm_k[a + j] ?? 0);
      dm = (v.vm_m[a + j2] ?? 0) - (v.vm_m[a + j] ?? 0);
      ok = false;
      if dk == 0 && dm == 2 { ok = true; }
      if dk == 0 && dm == -2 { ok = true; }
      if dk == 1 && dm == 1 { ok = true; }
      if dk == 1 && dm == -1 { ok = true; }
      if dk == -1 && dm == 1 { ok = true; }
      if dk == -1 && dm == -1 { ok = true; }
      if !ok { return 3; }
    }
  }
  if shoelace_total(v) != 12 * cells { return 4; }
  // AT LEAST one outer loop — not exactly one.
  //
  // This read `outer != 1`, which is a property of a CONNECTED form, and this package
  // traces BOUNDED CHUNKS on purpose: two buildings in one 32x32 window, or one form the
  // chunk edge cuts in half, are the ordinary case and not an error.  Every such map was
  // refused with code 5 while `trace` had produced it correctly and the area round-trip
  // agreed exactly — two disjoint hexes give two loops summing to 12 x 2, and were called
  // invalid.  A map with no positive loop at all is still nonsense, and that is what is
  // left here.
  //
  // What the old rule caught as a side effect, and this does not: a hole wound the SAME
  // way as its outer loop, compensated by an extra outer loop, so the areas still sum.
  // Deciding that needs each loop's NESTING, not a count of signs — `trace` cannot emit
  // it (a boundary edge is directed from the cell side, so the winding is not a free
  // choice), so it can only arrive in a map built somewhere else.  A caller that KNOWS
  // its form is connected keeps the stronger property with `outline_count(v) == 1`.
  outer = outline_count(v);
  if outer < 1 { return 5; }
  // NO REPEATED VERTEX, across the whole map rather than within a loop: a point visited
  // twice is a pinch, and a triangulator cannot resolve one.
  //
  // This has to be CHECKED rather than assumed precisely because `trace` cannot produce
  // it — a hex vertex touches three MUTUALLY ADJACENT cells, so a boundary never pinches,
  // and every map this package traces is simple.  The maps that reach `validate` from a
  // file, or from a consumer's own builder, are the ones that can: two hexes emitted as
  // two circuits joined at their shared corners give twelve legal hex-edge segments whose
  // shoelace is exactly 12 x 2, so every other check here passes it.
  nv = v.vm_start[v.vm_loops] ?? 0;
  for i in 0..nv {
    for j in (i + 1)..nv {
      if (v.vm_k[i] ?? 0) == (v.vm_k[j] ?? 0) && (v.vm_m[i] ?? 0) == (v.vm_m[j] ?? 0) {
        return 6;
      }
    }
  }
  0
}

// ═══════════════════════════════════════════════════════════════════════
// hex_doc — the interchange format for a hex field: a world, or a stencil.
//
// The SHARED artifact.  A field written by one consumer must load in another
// bit-for-bit, so `load(save(x)) == x` is not a private test — it is the
// interface, and both sides gate it.
//
//     offset  size  field           type  notes
//     -------------------------------------------------------------------
//     0       4     magic           i32   'HXF1'
//     4       4     schema_version  i32   1
//     8       4     q0              i32   extent origin
//     12      4     r0              i32
//     16      4     w               i32   extent size, cells
//     20      4     h               i32
//     24      4     section_count   i32
//     28      4     reserved        i32   must be 0
//
//     then section_count sections, each:
//     0       4     tag             i32   'OCCU' | 'HGHT' | 'LABL' | …
//     4       4     byte_len        i32   payload size
//     8       byte_len              payload
//
// SECTIONS, not a flags word.  A reader that meets a tag it does not know
// SKIPS it by byte_len and keeps going, so a field written by a newer consumer
// still loads in an older one.  A flags word cannot do that: an unknown bit
// means an array of unknown length, which is unskippable, so the only correct
// response would be to refuse the whole file.  That difference is testable, and
// hex_doctest tests it.
//
// Explicit dimensions are load-bearing.  A format that infers its extent from
// its payload cannot detect truncation; this one checks every section's length
// against w·h and refuses a mismatch.

pub const HXF_MAGIC   = 0x31465848;   // 'HXF1' little-endian
pub const HXF_SCHEMA  = 1;
pub const HXF_HEADER  = 32;

pub const TAG_OCCU = 0x5543434F;      // 'OCCU'
pub const TAG_HGHT = 0x54484748;      // 'HGHT'
pub const TAG_LABL = 0x4C42414C;      // 'LABL'
pub const TAG_EDGE = 0x45474445;      // 'EDGE' — the wall layer
// 'LAYR' — one NAMED integer layer.  Payload: name_len i32, the name's bytes,
// then w*h i32 values.  One section per layer, so a file carries as many as the
// author made and a reader that wants none skips them all by their lengths.
// TAG_LABL predates this and is the unnamed primary layer; it stays because the
// committed fixtures are written with it, and because "the material" needing no
// name is the common case.
pub const TAG_LAYR = 0x5259414C;

// Refusal codes.  0 = loaded.  Every one of these is a REFUSAL, never a
// partial parse: a half-read field is worse than no field.
pub const HXF_OK            = 0;
pub const HXF_BAD_MAGIC     = 1;
pub const HXF_BAD_SCHEMA    = 2;
pub const HXF_BAD_RESERVED  = 3;
pub const HXF_BAD_SECTION   = 4;   // section length disagrees with w·h
pub const HXF_TRUNCATED     = 5;
pub const HXF_MISSING_OCCU  = 6;
pub const HXF_BAD_EXTENT    = 7;   // w or h impossible for a file this size

// What a load produced.  `doc_code` is the refusal code; the field members are
// meaningful only when it is HXF_OK.
pub struct HexDoc {
  pub doc_code:    integer,
  pub doc_cells:   HexSet,
  pub doc_heights: Heights,
  pub doc_labels:  Labels,
  pub doc_edges:   EdgeSet,
  pub doc_layers:  Layers,
  pub doc_has_h:   boolean,
  pub doc_has_l:   boolean,
  pub doc_has_e:   boolean,
  pub doc_skipped: integer,   // unknown sections skipped — the forward-compat count
}

// ── writing ──────────────────────────────────────────────────────────────

// Write a field.  `heights` / `labels` are optional: pass has_h/has_l false to
// leave the section out entirely rather than writing a zero-filled one, so a
// stencil that only carries occupancy costs only occupancy.
pub fn doc_write(
  path: text, s: HexSet, hz: Heights, lb: Labels,
  has_h: boolean, has_l: boolean
) {
  doc_write_edges(path, s, hz, lb, edgeset_new(hexset_q0(s), hexset_r0(s), 1, 1),
                  has_h, has_l, false);
}

// As doc_write, plus the wall layer.  The EDGE payload is the halo grid — gw*gh*3
// slots — because an edge on the chunk boundary belongs to a cell outside it, and
// dropping those would silently open the walls of every stamped building's rim.
// Example: @HXL-012
pub fn doc_write_edges(
  path: text, s: HexSet, hz: Heights, lb: Labels, eg: EdgeSet,
  has_h: boolean, has_l: boolean, has_e: boolean
) {
  doc_write_all(path, s, hz, lb, eg,
                layers_new(hexset_q0(s), hexset_r0(s), hexset_w(s), hexset_h(s)),
                has_h, has_l, has_e);
}

// The full form: cells, optional heights/labels/edges, and any number of NAMED
// integer layers.
pub fn doc_write_all(
  path: text, s: HexSet, hz: Heights, lb: Labels, eg: EdgeSet, ly: Layers,
  has_h: boolean, has_l: boolean, has_e: boolean
) {
  w = hexset_w(s);
  h = hexset_h(s);
  n = w * h;
  sections = 1;
  if has_h { sections = sections + 1; }
  if has_l { sections = sections + 1; }
  if has_e { sections = sections + 1; }
  sections = sections + layer_count(ly);

  f = file(path);
  f#format = LittleEndian;
  f += (HXF_MAGIC as i32);
  f += (HXF_SCHEMA as i32);
  f += (hexset_q0(s) as i32);
  f += (hexset_r0(s) as i32);
  f += (w as i32);
  f += (h as i32);
  f += (sections as i32);
  f += (0 as i32);

  f += (TAG_OCCU as i32);
  f += (n as i32);
  for q in 0..w {
    for r in 0..h {
      // `on` is 0 or 1 by construction, but the cast is checked anyway —
      // a narrowing cast that "cannot fail" is exactly the kind that does.
      on = if hexset_get(s, hexset_q0(s) + q, hexset_r0(s) + r) { 1 } else { 0 };
      f += ((on as u8?) ?? 0 as u8);
    }
  }

  if has_h {
    f += (TAG_HGHT as i32);
    f += ((n * 8) as i32);
    for q in 0..w {
      for r in 0..h {
        f += height_get(hz, hexset_q0(s) + q, hexset_r0(s) + r);
      }
    }
  }

  if has_l {
    f += (TAG_LABL as i32);
    f += ((n * 4) as i32);
    for q in 0..w {
      for r in 0..h {
        f += (label_get(lb, hexset_q0(s) + q, hexset_r0(s) + r) as i32);
      }
    }
  }

  if has_e {
    // Written through `edge_mat`, keyed by the two cells an edge lies between, rather
    // than by copying `eg`'s vector.  The section's LENGTH comes from `s`, so an EdgeSet
    // built at a different extent has a different stride, and a raw copy then lands every
    // material on the wrong edge — a file that loads cleanly and describes other
    // geometry.  Keying by cells makes the bytes independent of how the source layer is
    // laid out, and byte-identical to the old ones whenever the extents agree (which the
    // committed `walled.hxf` fixture is the gate for).
    gw = w + 2;
    gh = h + 2;
    f += (TAG_EDGE as i32);
    f += ((gw * gh * 3) as i32);
    for dr in 0..gh {
      for dq in 0..gw {
        cq = hexset_q0(s) + dq - 1;
        cr = hexset_r0(s) + dr - 1;
        for sl in 0..3 {
          d = 0;
          if sl == 1 { d = 2; }
          if sl == 2 { d = 3; }
          mv = edge_mat(eg, cq, cr, nb_q(cq, cr, d), nb_r(cq, cr, d));
          f += ((mv as u8?) ?? 0);
        }
      }
    }
  }

  for li in 0..layer_count(ly) {
    nm = layer_name(ly, li);
    nlen = size(nm);
    f += (TAG_LAYR as i32);
    f += ((4 + nlen + n * 4) as i32);
    f += (nlen as i32);
    f += nm;
    for q in 0..w {
      for r in 0..h {
        f += (layer_get_at(ly, li, hexset_q0(s) + q, hexset_r0(s) + r) as i32);
      }
    }
  }
}

// ── reading ──────────────────────────────────────────────────────────────

// Example: @HXL-010
// Example: @HXL-011
pub fn doc_read(path: text) -> HexDoc {
  d = HexDoc {
    doc_code: HXF_OK,
    doc_cells: hexset_chunk(0, 0, 1, 1),
    doc_heights: heights_new(0, 0, 1, 1),
    doc_labels: labels_new(0, 0, 1, 1),
    doc_edges: edgeset_new(0, 0, 1, 1),
    doc_layers: layers_new(0, 0, 1, 1),
    doc_has_h: false, doc_has_l: false, doc_has_e: false, doc_skipped: 0,
  };
  f = file(path);
  f#format = LittleEndian;
  total = f#size;
  if total < HXF_HEADER { d.doc_code = HXF_TRUNCATED; return d; }

  magic = f#read as i32;
  if magic != HXF_MAGIC { d.doc_code = HXF_BAD_MAGIC; return d; }
  schema = f#read as i32;
  if schema != HXF_SCHEMA { d.doc_code = HXF_BAD_SCHEMA; return d; }
  q0 = f#read as i32;
  r0 = f#read as i32;
  w  = f#read as i32;
  h  = f#read as i32;
  nsec = f#read as i32;
  rsv = f#read as i32;
  if rsv != 0 { d.doc_code = HXF_BAD_RESERVED; return d; }

  // THE EXTENT IS CHECKED AGAINST THE FILE, not taken on trust.  It is the pair of
  // fields that decides how much this reader allocates, and it was the only part of the
  // header nothing verified: a 32-byte file claiming 4000x4000 built sixteen million
  // cells — plus heights, labels, edges and layers over the same extent — and only then
  // reported a missing section.  OCCU is mandatory and costs one byte per cell, so a
  // file can never describe more cells than it has bytes; that is the bound, and it
  // needs no second pass to apply.
  if w < 0 || h < 0 { d.doc_code = HXF_BAD_EXTENT; return d; }
  if w * h > total - HXF_HEADER { d.doc_code = HXF_BAD_EXTENT; return d; }

  n = w * h;
  d.doc_cells   = hexset_chunk(q0, r0, w, h);
  d.doc_heights = heights_new(q0, r0, w, h);
  d.doc_labels  = labels_new(q0, r0, w, h);
  d.doc_edges   = edgeset_new(q0, r0, w, h);
  d.doc_layers  = layers_new(q0, r0, w, h);
  seen_occu = false;

  for _ in 0..nsec {
    if f#next + 8 > total { d.doc_code = HXF_TRUNCATED; return d; }
    tag = f#read as i32;
    blen = f#read as i32;
    if f#next + blen > total { d.doc_code = HXF_TRUNCATED; return d; }

    if tag == TAG_OCCU {
      if blen != n { d.doc_code = HXF_BAD_SECTION; return d; }
      for q in 0..w {
        for r in 0..h {
          v = f#read as u8;
          hexset_set(d.doc_cells, q0 + q, r0 + r, v != 0);
        }
      }
      seen_occu = true;
    } else if tag == TAG_HGHT {
      if blen != n * 8 { d.doc_code = HXF_BAD_SECTION; return d; }
      for q in 0..w {
        for r in 0..h {
          hv = f#read as float;
          height_set(d.doc_heights, q0 + q, r0 + r, hv);
        }
      }
      d.doc_has_h = true;
    } else if tag == TAG_LABL {
      if blen != n * 4 { d.doc_code = HXF_BAD_SECTION; return d; }
      for q in 0..w {
        for r in 0..h {
          lv = f#read as i32;
          label_set(d.doc_labels, q0 + q, r0 + r, lv);
        }
      }
      d.doc_has_l = true;
    } else if tag == TAG_EDGE {
      egn = (w + 2) * (h + 2) * 3;
      if blen != egn { d.doc_code = HXF_BAD_SECTION; return d; }
      for i in 0..egn {
        ev = f#read as u8;
        d.doc_edges.eg_mat[i] = ev;
      }
      d.doc_has_e = true;
    } else if tag == TAG_LAYR {
      nlen = f#read as i32;
      // the declared section length must account for the name AND the values —
      // a name length that disagrees with it is a corrupt file, not a short read
      if blen != 4 + nlen + n * 4 { d.doc_code = HXF_BAD_SECTION; return d; }
      nm = f#read(nlen) as text;
      li = layer_add(d.doc_layers, nm);
      for q in 0..w {
        for r in 0..h {
          lv = f#read as i32;
          layer_set_at(d.doc_layers, li, q0 + q, r0 + r, lv);
        }
      }
    } else {
      // Unknown section: skip it by its declared length.  This is the whole
      // reason sections carry a length, and why a newer writer does not break
      // an older reader.
      f#next = f#next + blen;
      d.doc_skipped = d.doc_skipped + 1;
    }
  }

  if !seen_occu { d.doc_code = HXF_MISSING_OCCU; return d; }
  d
}

// ═══════════════════════════════════════════════════════════════════════
// stencils — a reusable piece of authored field, stamped at an orientation.
//
// A stencil is a SMALL FIELD, not a bitmap: the same structures a world holds,
// at a smaller extent.  So stamping is MERGING TWO FIELDS, and needs no new
// conflict rule.
//
// Rotation is why this is cheap.  On the exact-integer lattice a 60° turn is an
// integer map — k' = (k − m)/2, m' = (3k + m)/2 — and both halves divide evenly
// for every cell centre, because centres satisfy k ≡ m (mod 2).  So a stencil
// rotates with NO resampling and NO drift: a house stamped at 300° is the same
// house as at 0°, cell for cell, not a filtered approximation of it.  Reflection
// (k → −k) is exact too, giving 12 orientations.
//
// Translation happens in LATTICE space, never in (q, r).  Adding (dq, dr) to an
// odd-r offset coordinate is NOT a translation — it changes the row stagger, so
// a stencil stamped on an odd row would come out sheared.  Adding lattice
// vectors is exact, and closed: k₁≡m₁ and k₂≡m₂ give k₁+k₂ ≡ m₁+m₂.

pub const ROT_OK           = 0;
pub const ROT_NOT_MULTIPLE = 1;

// Inverse of (lattice_k, lattice_m).  m is always 3r and k − (r&1) always even,
// so both divisions are exact rather than truncating.
pub fn lattice_to_cell(k: integer, m: integer) -> (integer, integer) {
  lr = (m / 3) ?? 0;
  lq = ((k - (lr & 1)) / 2) ?? 0;
  (lq, lr)
}

// One 60° step counter-clockwise about the lattice origin.
pub fn lattice_rot60(k: integer, m: integer) -> (integer, integer) {
  (((k - m) / 2) ?? 0, ((3 * k + m) / 2) ?? 0)
}

// Rotate a cell n × 60° about cell (0,0).  n may be any integer; six steps are
// the identity, so it wraps.
pub fn cell_rot(q: integer, r: integer, n: integer) -> (integer, integer) {
  rk = lattice_k(q, r);
  rm = lattice_m(q, r);
  steps = n % 6;
  if steps < 0 { steps = steps + 6; }
  for _ in 0..steps {
    (nk, nm) = lattice_rot60(rk, rm);
    rk = nk;
    rm = nm;
  }
  lattice_to_cell(rk, rm)
}

// Reflect a cell in the vertical axis (k → −k).  With the six rotations this
// gives the 12 orientations handed content needs — an L-shaped house, a stair.
pub fn cell_mirror(q: integer, r: integer) -> (integer, integer) {
  lattice_to_cell(0 - lattice_k(q, r), lattice_m(q, r))
}

// A stencil: a set of cells with optional heights, labels and edges (`sn_has_*` say which
// are present) and its named layers.  `stencil_from` builds one; `stencil_rotate` and
// `stencil_mirror` transform it.
pub struct Stencil {
  pub sn_cells:   HexSet,
  pub sn_heights: Heights,
  pub sn_labels:  Labels,
  pub sn_edges:   EdgeSet,
  pub sn_layers:  Layers,
  sn_has_h:   boolean,
  sn_has_l:   boolean,
  sn_has_e:   boolean,
  pub sn_code:    integer,
}

// Build the field FIRST, then construct the stencil: these take their arguments
// by value, so cells set on the HexSet afterwards do not reach the stencil.  The
// failure is quiet — the stencil reports the right layer count and extent, and
// only the contents are missing.
pub fn stencil_from(s: HexSet, hz: Heights, lb: Labels, has_h: boolean, has_l: boolean) -> Stencil {
  Stencil {
    sn_cells: s, sn_heights: hz, sn_labels: lb,
    sn_edges: edgeset_new(hexset_q0(s), hexset_r0(s), hexset_w(s), hexset_h(s)),
    sn_layers: layers_new(hexset_q0(s), hexset_r0(s), hexset_w(s), hexset_h(s)),
    sn_has_h: has_h, sn_has_l: has_l, sn_has_e: false, sn_code: ROT_OK,
  }
}

// A stencil that carries walls.  Separate constructor so the common case — a
// footprint with no walls — does not allocate an edge layer it never reads.
pub fn stencil_with_edges(s: HexSet, hz: Heights, lb: Labels, eg: EdgeSet,
                          has_h: boolean, has_l: boolean) -> Stencil {
  Stencil {
    sn_cells: s, sn_heights: hz, sn_labels: lb, sn_edges: eg,
    sn_layers: layers_new(hexset_q0(s), hexset_r0(s), hexset_w(s), hexset_h(s)),
    sn_has_h: has_h, sn_has_l: has_l, sn_has_e: true, sn_code: ROT_OK,
  }
}

// Rotate a whole stencil n × 60° about its own (0,0), re-fitting the extent to
// whatever the rotated cells need.
// Example: @HXL-007
pub fn stencil_rotate(st: Stencil, n: integer) -> Stencil {
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  // pass 1 — where do the occupied cells land?
  seen = false;
  mnq = 0; mxq = 0; mnr = 0; mxr = 0;
  for iq in 0..w {
    for ir in 0..h {
      if hexset_get(st.sn_cells, q0 + iq, r0 + ir) {
        (tq, tr) = cell_rot(q0 + iq, r0 + ir, n);
        if !seen { mnq = tq; mxq = tq; mnr = tr; mxr = tr; seen = true; }
        if tq < mnq { mnq = tq; }
        if tq > mxq { mxq = tq; }
        if tr < mnr { mnr = tr; }
        if tr > mxr { mxr = tr; }
      }
    }
  }
  // NO EARLY `return st`.  A stencil with no occupied cells has no bounding box to fit, so
  // it comes back unturned at its own extent — expressed as a zero-turn copy rather than by
  // handing the PARAMETER back, because a function whose return paths disagree about
  // ownership (parameter on one, freshly built on the other) leaks the fresh one, one store
  // per call, on both backends: loft#982.  The leak fires on the path that IS taken, so the
  // guard had to go even though rotating an empty stencil is the rare case.
  turns = n;
  if !seen { mnq = q0; mnr = r0; mxq = q0 + w - 1; mxr = r0 + h - 1; turns = 0; }
  nw = mxq - mnq + 1;
  nh = mxr - mnr + 1;
  out = stencil_with_edges(
    hexset_chunk(mnq, mnr, nw, nh),
    heights_new(mnq, mnr, nw, nh),
    labels_new(mnq, mnr, nw, nh),
    // The CELL extent, not a pre-haloed one: `edgeset_new` adds the one-cell halo
    // itself (`gw = w + 2`), which is exactly the reach a rim edge needs — its owner
    // is at most one cell outside.  Passing `(mnq - 1, nw + 2)` here halos it twice,
    // and `stencil_from` does not, so two stencils holding the same walls would
    // disagree on `eg_gw` and `edgeset_equal` could never compare them.
    edgeset_new(mnq, mnr, nw, nh),
    st.sn_has_h, st.sn_has_l
  );
  out.sn_has_e = st.sn_has_e;
  out.sn_layers = layers_new(mnq, mnr, nw, nh);
  for li in 0..layer_count(st.sn_layers) { layer_add(out.sn_layers, layer_name(st.sn_layers, li)); }
  // pass 2 — carry the payload across
  for iq in 0..w {
    for ir in 0..h {
      if hexset_get(st.sn_cells, q0 + iq, r0 + ir) {
        (tq, tr) = cell_rot(q0 + iq, r0 + ir, turns);
        hexset_set(out.sn_cells, tq, tr, true);
        if st.sn_has_h { height_set(out.sn_heights, tq, tr, height_get(st.sn_heights, q0 + iq, r0 + ir)); }
        if st.sn_has_l { label_set(out.sn_labels, tq, tr, label_get(st.sn_labels, q0 + iq, r0 + ir)); }
        for li in 0..layer_count(st.sn_layers) {
          layer_set_at(out.sn_layers, li, tq, tr, layer_get_at(st.sn_layers, li, q0 + iq, r0 + ir));
        }
      }
    }
  }
  // Edges rotate because their key is the doubled midpoint of two centres, and
  // the midpoint of two rotated centres is the rotation of the midpoint.  So
  // rotating both endpoints and re-keying is exact — no resampling, no drift.
  if st.sn_has_e {
    // Walk the HALO grid, not the cell extent.  An edge is stored against one of
    // its two cells, and for a rim edge that owner is OUTSIDE the extent — so a
    // loop over the extent silently drops it.  A stencil must never lose data,
    // so this iterates every stored slot rather than every occupied cell.
    for iq in -1..(w + 1) {
      for ir in -1..(h + 1) {
        cq = q0 + iq;
        cr = r0 + ir;
        for sl in 0..3 {
          d = 0;
          if sl == 1 { d = 2; }
          if sl == 2 { d = 3; }
          nq = nb_q(cq, cr, d);
          nr = nb_r(cq, cr, d);
          mv = edge_mat(st.sn_edges, cq, cr, nq, nr);
          sv = edge_surf(st.sn_edges, cq, cr, nq, nr);
          // BOTH slots, because a rotation is a transform of this stencil in memory —
          // the surface ids still point into the same table they did before the turn.
          // Carrying only the material made six turns lose the geometry half while the
          // cells came back exactly, so `edgeset_equal` (which compares both, on purpose)
          // could never confirm the identity the exact lattice exists to give.
          if mv != 0 || sv != 0 {
            (aq, ar) = cell_rot(cq, cr, turns);
            (bq, br) = cell_rot(nq, nr, turns);
            edge_set_both(out.sn_edges, aq, ar, bq, br, sv, mv);
          }
        }
      }
    }
  }
  out
}

// Reflect a whole stencil (k -> -k).  With the six rotations this is the other
// half of the TWELVE orientations: six placements, and the remaining six reached
// by flipping.  Handed content — an L-shaped house, a staircase — needs it, and
// it is exact for the same reason the rotation is.
// Example: @HXL-007
pub fn stencil_mirror(st: Stencil) -> Stencil {
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  seen = false;
  mnq = 0; mxq = 0; mnr = 0; mxr = 0;
  for iq in 0..w {
    for ir in 0..h {
      if hexset_get(st.sn_cells, q0 + iq, r0 + ir) {
        (tq, tr) = cell_mirror(q0 + iq, r0 + ir);
        if !seen { mnq = tq; mxq = tq; mnr = tr; mxr = tr; seen = true; }
        if tq < mnq { mnq = tq; }
        if tq > mxq { mxq = tq; }
        if tr < mnr { mnr = tr; }
        if tr > mxr { mxr = tr; }
      }
    }
  }
  // No early `return st` — see stencil_rotate, and loft#982.  With no cells to place there
  // is nothing to reflect, so the extent stays and `flip` turns the reflection off.
  flip = seen;
  if !seen { mnq = q0; mnr = r0; mxq = q0 + w - 1; mxr = r0 + h - 1; }
  nw = mxq - mnq + 1;
  nh = mxr - mnr + 1;
  out = stencil_with_edges(
    hexset_chunk(mnq, mnr, nw, nh),
    heights_new(mnq, mnr, nw, nh),
    labels_new(mnq, mnr, nw, nh),
    edgeset_new(mnq, mnr, nw, nh),      // the cell extent — see stencil_rotate
    st.sn_has_h, st.sn_has_l
  );
  out.sn_has_e = st.sn_has_e;
  out.sn_layers = layers_new(mnq, mnr, nw, nh);
  for li in 0..layer_count(st.sn_layers) { layer_add(out.sn_layers, layer_name(st.sn_layers, li)); }
  for iq in 0..w {
    for ir in 0..h {
      if hexset_get(st.sn_cells, q0 + iq, r0 + ir) {
        (tq, tr) = cell_mirror(q0 + iq, r0 + ir);
        hexset_set(out.sn_cells, tq, tr, true);
        if st.sn_has_h { height_set(out.sn_heights, tq, tr, height_get(st.sn_heights, q0 + iq, r0 + ir)); }
        if st.sn_has_l { label_set(out.sn_labels, tq, tr, label_get(st.sn_labels, q0 + iq, r0 + ir)); }
        for li in 0..layer_count(st.sn_layers) {
          layer_set_at(out.sn_layers, li, tq, tr, layer_get_at(st.sn_layers, li, q0 + iq, r0 + ir));
        }
      }
    }
  }
  if st.sn_has_e {
    for iq in -1..(w + 1) {
      for ir in -1..(h + 1) {
        cq = q0 + iq;
        cr = r0 + ir;
        for sl in 0..3 {
          d = 0;
          if sl == 1 { d = 2; }
          if sl == 2 { d = 3; }
          nq = nb_q(cq, cr, d);
          nr = nb_r(cq, cr, d);
          mv = edge_mat(st.sn_edges, cq, cr, nq, nr);
          sv = edge_surf(st.sn_edges, cq, cr, nq, nr);
          if mv != 0 || sv != 0 {                       // both slots — see stencil_rotate
            aq = cq; ar = cr; bq = nq; br = nr;
            if flip {
              (aq, ar) = cell_mirror(cq, cr);
              (bq, br) = cell_mirror(nq, nr);
            }
            edge_set_both(out.sn_edges, aq, ar, bq, br, sv, mv);
          }
        }
      }
    }
  }
  out
}

// Degrees, refused rather than rounded when they are not a multiple of 60.
// A rotation that silently rounds is how an exact lattice quietly becomes an
// approximate one, so this returns an EMPTY stencil carrying ROT_NOT_MULTIPLE.
pub fn stencil_rotate_deg(st: Stencil, deg: integer) -> Stencil {
  if deg % 60 != 0 {
    bad = stencil_from(
      hexset_chunk(0, 0, 1, 1), heights_new(0, 0, 1, 1), labels_new(0, 0, 1, 1),
      false, false
    );
    bad.sn_code = ROT_NOT_MULTIPLE;
    return bad;
  }
  stencil_rotate(st, (deg / 60) ?? 0)
}

// Stamp `st` so its (0,0) lands on target cell (aq, ar).  Occupied stencil
// cells win; everything else in the target is untouched, which is what makes
// two stamps at different places order-free.
pub fn stencil_stamp(
  dst: HexSet, dst_h: Heights, dst_l: Labels,
  st: Stencil, aq: integer, ar: integer
) {
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  for iq in 0..w {
    for ir in 0..h {
      sq = q0 + iq;
      sr = r0 + ir;
      if hexset_get(st.sn_cells, sq, sr) {
        (tq, tr) = lattice_to_cell(lattice_k(sq, sr) + dk, lattice_m(sq, sr) + dm);
        hexset_set(dst, tq, tr, true);
        if st.sn_has_h { height_set(dst_h, tq, tr, height_get(st.sn_heights, sq, sr)); }
        if st.sn_has_l { label_set(dst_l, tq, tr, label_get(st.sn_labels, sq, sr)); }
      }
    }
  }
}

// Stamp the named layers too.  Separate from stencil_stamp so a consumer that
// uses no layers pays nothing for them.
pub fn stencil_stamp_layers(
  dst: HexSet, dst_h: Heights, dst_l: Labels, dst_y: Layers,
  st: Stencil, aq: integer, ar: integer
) {
  stencil_stamp(dst, dst_h, dst_l, st, aq, ar);
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  for li in 0..layer_count(st.sn_layers) { layer_add(dst_y, layer_name(st.sn_layers, li)); }
  for iq in 0..w {
    for ir in 0..h {
      sq = q0 + iq;
      sr = r0 + ir;
      if hexset_get(st.sn_cells, sq, sr) {
        (tq, tr) = lattice_to_cell(lattice_k(sq, sr) + dk, lattice_m(sq, sr) + dm);
        for li in 0..layer_count(st.sn_layers) {
          nm = layer_name(st.sn_layers, li);
          layer_set(dst_y, nm, tq, tr, layer_get(st.sn_layers, nm, sq, sr));
        }
      }
    }
  }
}

// Everything at once: cells, heights, labels, named layers and walls.  Exists so
// a consumer does not stamp cells three times by calling the three narrower
// forms, and so there is ONE place that knows the full payload.
pub fn stencil_stamp_all(
  dst: HexSet, dst_h: Heights, dst_l: Labels, dst_y: Layers, dst_e: EdgeSet,
  st: Stencil, aq: integer, ar: integer
) {
  stencil_stamp_layers(dst, dst_h, dst_l, dst_y, st, aq, ar);
  if !st.sn_has_e { return; }
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  // the halo, for the same reason as stencil_rotate
  for iq in -1..(w + 1) {
    for ir in -1..(h + 1) {
      cq = q0 + iq;
      cr = r0 + ir;
      for sl in 0..3 {
        d = 0;
        if sl == 1 { d = 2; }
        if sl == 2 { d = 3; }
        nq = nb_q(cq, cr, d);
        nr = nb_r(cq, cr, d);
        mv = edge_mat(st.sn_edges, cq, cr, nq, nr);
        if mv != 0 {
          (tq, tr) = lattice_to_cell(lattice_k(cq, cr) + dk, lattice_m(cq, cr) + dm);
          (uq, ur) = lattice_to_cell(lattice_k(nq, nr) + dk, lattice_m(nq, nr) + dm);
          edge_set_mat(dst_e, tq, tr, uq, ur, mv);
        }
      }
    }
  }
}

// Stamp including the wall layer.  Edges translate the same way cells do — in
// lattice space — so a wall stamped on an odd row stays on the edge it was
// authored on.
pub fn stencil_stamp_edges(
  dst: HexSet, dst_h: Heights, dst_l: Labels, dst_e: EdgeSet,
  st: Stencil, aq: integer, ar: integer
) {
  stencil_stamp(dst, dst_h, dst_l, st, aq, ar);
  if !st.sn_has_e { return; }
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  // the halo, for the same reason as stencil_rotate
  for iq in -1..(w + 1) {
    for ir in -1..(h + 1) {
      cq = q0 + iq;
      cr = r0 + ir;
      for sl in 0..3 {
        d = 0;
        if sl == 1 { d = 2; }
        if sl == 2 { d = 3; }
        nq = nb_q(cq, cr, d);
        nr = nb_r(cq, cr, d);
        mv = edge_mat(st.sn_edges, cq, cr, nq, nr);
        if mv != 0 {
          (tq, tr) = lattice_to_cell(lattice_k(cq, cr) + dk, lattice_m(cq, cr) + dm);
          (uq, ur) = lattice_to_cell(lattice_k(nq, nr) + dk, lattice_m(nq, nr) + dm);
          edge_set_mat(dst_e, tq, tr, uq, ur, mv);
        }
      }
    }
  }
}

// ── un-stamping ──────────────────────────────────────────────────────────
//
// The inverse of a stamp: it clears exactly the slots the stamp WROTE, back to
// the values a freshly-built field holds — cell false, height 0.0, label -1,
// layer 0, edge material 0.
//
// ⚠ THIS RESTORES THE TARGET ONLY WHERE THE TARGET WAS PRISTINE.  A stamp is
// last-writer-wins (see `stencil_stamp`), so it DESTROYS whatever payload sat
// under its footprint; nothing here can bring that back, because the previous
// value was never kept.  "Stamp then un-stamp is the identity" is therefore a
// claim about placing on FREE ground — which is the case the editor needs, a
// placement made and then taken back.  Un-stamping onto ground another stencil
// already occupied leaves that other stencil's cells cleared too, and that is
// the honest behaviour rather than a bug: the two stencils are indistinguishable
// once merged, which is what merging into one field MEANS.  A consumer that
// needs true undo over occupied ground must snapshot, not un-stamp.

// Clear the cells, heights and labels a `stencil_stamp` wrote.
pub fn stencil_unstamp(
  dst: HexSet, dst_h: Heights, dst_l: Labels,
  st: Stencil, aq: integer, ar: integer
) {
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  for iq in 0..w {
    for ir in 0..h {
      sq = q0 + iq;
      sr = r0 + ir;
      if hexset_get(st.sn_cells, sq, sr) {
        (tq, tr) = lattice_to_cell(lattice_k(sq, sr) + dk, lattice_m(sq, sr) + dm);
        hexset_set(dst, tq, tr, false);
        if st.sn_has_h { height_set(dst_h, tq, tr, 0.0); }
        if st.sn_has_l { label_set(dst_l, tq, tr, -1); }
      }
    }
  }
}

// Clear the named layers too — the inverse of `stencil_stamp_layers`.
//
// The layer NAMES the stamp added to the target stay: a name is a property of
// the field, not of one placement, and a second placement of the same part
// would only add it back.  Only the VALUES under this footprint are cleared.
pub fn stencil_unstamp_layers(
  dst: HexSet, dst_h: Heights, dst_l: Labels, dst_y: Layers,
  st: Stencil, aq: integer, ar: integer
) {
  stencil_unstamp(dst, dst_h, dst_l, st, aq, ar);
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  for iq in 0..w {
    for ir in 0..h {
      sq = q0 + iq;
      sr = r0 + ir;
      if hexset_get(st.sn_cells, sq, sr) {
        (tq, tr) = lattice_to_cell(lattice_k(sq, sr) + dk, lattice_m(sq, sr) + dm);
        for li in 0..layer_count(st.sn_layers) {
          nm = layer_name(st.sn_layers, li);
          layer_set(dst_y, nm, tq, tr, 0);
        }
      }
    }
  }
}

// Everything at once — the inverse of `stencil_stamp_all`, and the form a
// consumer should use, so one place knows the full payload in BOTH directions.
//
// Edges walk the same halo the stamp did, and clear only the slots the stencil
// itself carried a material for.  Iterating the occupied cells instead would
// leave the rim walls behind — the same blindness that once turned 18 edge
// slots into 10 with every cell count still agreeing.
pub fn stencil_unstamp_all(
  dst: HexSet, dst_h: Heights, dst_l: Labels, dst_y: Layers, dst_e: EdgeSet,
  st: Stencil, aq: integer, ar: integer
) {
  stencil_unstamp_layers(dst, dst_h, dst_l, dst_y, st, aq, ar);
  if !st.sn_has_e { return; }
  dk = lattice_k(aq, ar);
  dm = lattice_m(aq, ar);
  q0 = hexset_q0(st.sn_cells); r0 = hexset_r0(st.sn_cells);
  w  = hexset_w(st.sn_cells);  h  = hexset_h(st.sn_cells);
  for iq in -1..(w + 1) {
    for ir in -1..(h + 1) {
      cq = q0 + iq;
      cr = r0 + ir;
      for sl in 0..3 {
        d = 0;
        if sl == 1 { d = 2; }
        if sl == 2 { d = 3; }
        nq = nb_q(cq, cr, d);
        nr = nb_r(cq, cr, d);
        if edge_mat(st.sn_edges, cq, cr, nq, nr) != 0 {
          (tq, tr) = lattice_to_cell(lattice_k(cq, cr) + dk, lattice_m(cq, cr) + dm);
          (uq, ur) = lattice_to_cell(lattice_k(nq, nr) + dk, lattice_m(nq, nr) + dm);
          edge_set_mat(dst_e, tq, tr, uq, ur, 0);
        }
      }
    }
  }
}

// ═══════════════════════════════════════════════════════════════════════
// edges — the wall layer.
//
// The canonical key, direction canon, halo and type widths are crawler's edge
// layer's.  That layer also carries a SURFACE id (physics geometry — straights
// and arcs); this holds only the material, which is what authored content and a
// stencil need, and `hex_edge` adds the surface on top.
//
// The canonical key is the DOUBLED MIDPOINT of the two cell centres:
//   edge_k = lattice_k(a) + lattice_k(b),  edge_m = lattice_m(a) + lattice_m(b)
// Symmetric in a and b, so an edge has one identity regardless of which side
// names it.  It is also why rotation is free: the midpoint of two rotated
// centres IS the rotation of the midpoint, so an edge rotates exactly when its
// endpoints do.
//
// Opposite direction pairs are {0,1} {2,5} {3,4}, so the canonical set is
// {0,2,3} — exactly one of an edge's two cells sees it that way, which is what
// makes storage one slot per edge rather than two.
//
// TYPE WIDTHS are crawler's finding, adopted whole (their LOFT-HANDOFF H3):
// u16/i16 cannot be index-assigned at all, and u32 loses the write SILENTLY
// through a struct parameter.  u8 and i32 are safe. The material is u8 because
// that is the widest safe SMALL type, not because 255 materials is a design.

// TWO SLOTS, TWO OWNERS.  `eg_mat` is the PHYSICS of an edge — what it is made of — and
// is authored: a stencil carries materials, a document round-trips them.  `eg_surf` is the
// GEOMETRY — WHICH exact surface blocks here — and is derived at build time.
//
// The surface slot exists because a hex edge has only 6 possible normals, and measured
// against the true wall normal across 24 headings, using the edge's own normal is wrong by
// up to 90 degrees: a bounce that sends you sideways.  So the edge stores an id and the
// surface carries the exact geometry.  That makes the grid a SPATIAL INDEX — it says THAT
// you hit something and WHICH thing — while the normal stays exact and mesh-free.
//
// `eg_surf` is DELIBERATELY NOT SERIALIZED by the document format, and that is not an
// omission to fix later: a surface id points into a `Surfaces` table this library does not
// own, so writing ids to a file would persist dangling references.  Materials are ids into
// a closed enumeration of mechanisms and travel fine; surfaces are not.  The TAG_EDGE
// section stays exactly `(w+2)*(h+2)*3` material bytes, so existing documents keep loading.
pub struct EdgeSet {
  eg_q0:  integer,
  eg_r0:  integer,
  eg_w:   integer,
  eg_h:   integer,
  eg_gw:  integer,          // grid width  = w + 2, a one-cell halo
  eg_gh:  integer,
  eg_mat: vector<u8>,       // material (PHYSICS); 0 = open       — authored, serialized
  eg_surf: vector<i32>,     // surface id (GEOMETRY); 0 = passable — derived, not serialized
  eg_count: integer,        // blocked edges, maintained O(1) rather than counted by walking
  eg_refused: integer,      // writes rejected for a material outside 0..EDGE_MAT_MAX
}

// The whole domain of a material id, because the slot is a u8.  Named rather than
// left implicit: a caller allocating material ids needs to know where they stop, and
// the failure at the far end is not a wrong material but a MISSING WALL — see
// `edge_set_mat`.
pub const EDGE_MAT_MAX = 255;

// Example: @HXL-008
pub fn edgeset_new(q0: integer, r0: integer, w: integer, h: integer) -> EdgeSet {
  gw = w + 2;
  gh = h + 2;
  m: vector<u8> = [];
  sf: vector<i32> = [];
  n = gw * gh * 3;
  for _ in 0..n { m += [(0 as u8?) ?? 0]; sf += [(0 as i32?) ?? 0]; }
  EdgeSet { eg_q0: q0, eg_r0: r0, eg_w: w, eg_h: h, eg_gw: gw, eg_gh: gh,
            eg_mat: m, eg_surf: sf, eg_count: 0, eg_refused: 0 }
}

// The doubled midpoint — an edge's identity, independent of which cell names it.
// Example: @HXL-008
pub fn edge_key(qa: integer, ra: integer, qb: integer, rb: integer) -> (integer, integer) {
  (lattice_k(qa, ra) + lattice_k(qb, rb), lattice_m(qa, ra) + lattice_m(qb, rb))
}

fn eg_dir_from(qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
  d = -1;
  for i in 0..6 {
    if nb_q(qa, ra, i) == qb && nb_r(qa, ra, i) == rb { d = i; }
  }
  d
}

// Storage slot for the edge between two adjacent cells, or -1.
fn eg_index(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
  d = eg_dir_from(qa, ra, qb, rb);
  if d < 0 { return -1; }
  cq = qa; cr = ra; slot = 0;
  if d == 2 { slot = 1; }
  if d == 3 { slot = 2; }
  if d == 1 { cq = qb; cr = rb; slot = 0; }
  if d == 5 { cq = qb; cr = rb; slot = 1; }
  if d == 4 { cq = qb; cr = rb; slot = 2; }
  dq = cq - e.eg_q0 + 1;
  dr = cr - e.eg_r0 + 1;
  if dq < 0 || dq >= e.eg_gw || dr < 0 || dr >= e.eg_gh { return -1; }
  (dr * e.eg_gw + dq) * 3 + slot
}

// The storage slot for an edge, or -1.  PUBLIC because a consumer building its
// own accessors on this layer cannot address storage it cannot index — crawler's
// apply_features and its junction arbitration write by index.  Exposing the slot
// is cheaper than exporting a get/set pair for every access pattern they have.
// Unconditional write — the last writer wins.  The write POLICY is deliberately the
// caller's: first-writer-wins is `if edge_mat(...) == 0 { edge_set_mat(...) }` at the call
// site, where the reader can see which rule is in force.
// Example: @HXL-009
pub fn edge_set_mat(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer, mat: integer) {
  i = eg_index(e, qa, ra, qb, rb);
  if i < 0 { return; }
  // OUT OF DOMAIN IS REFUSED, NOT NARROWED.  The slot is a u8, and narrowing past it
  // with a `?? 0` default would write the one value that means NO WALL —
  // `edge_set_mat(e, …, 300)` would delete the wall it was setting.  The slot keeps
  // what it held and the refusal is counted, because a wall that is not there is not
  // something a caller can see.
  if mat < 0 || mat > EDGE_MAT_MAX { e.eg_refused = e.eg_refused + 1; return; }
  e.eg_mat[i] = (mat as u8?) ?? 0;
}

// Writes this layer refused because the material was outside 0..EDGE_MAT_MAX.  Non-zero
// means a wall a caller asked for is absent — check it wherever material ids are derived
// rather than written as literals.
// Example: @HXL-009
pub fn edgeset_refused(e: EdgeSet) -> integer { e.eg_refused }

// The material on the edge between two neighbouring cells, or 0 when there is none.
// Edges carry what cells cannot: a wall belongs BETWEEN two hexes, not inside one.
pub fn edge_mat(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
  i = eg_index(e, qa, ra, qb, rb);
  if i < 0 { return 0; }
  (e.eg_mat[i] ?? 0) as integer
}

// ── THE SURFACE SLOT ────────────────────────────────────────────────────────
//
// WHY THESE ARE ACCESSORS AND `eg_index` STAYS PRIVATE.  The obvious alternative is to
// publish the index so a consumer can write `e.eg_surf[i]` in its own loops, which is what
// crawler's collision layer does today over its own copy of this storage.  Publishing it
// would freeze `(cell, canonical direction, slot)` into the contract, and with two
// consumers on this library that layout could then never change.  The cost of accessors is
// one extra index computation per write in a build-time loop; the cost of the alternative
// is permanent.  Correctness of the seam outranks a small constant here.

// Write the surface blocking this edge, maintaining the blocked count exactly.
// Unconditional — the WRITE POLICY belongs to the caller, because there is more than one:
// crawler's collision layer wants first-writer-wins for plain construction and
// nearest-surface arbitration where two parts meet.  A policy baked in here would silently
// decide which surface owns a junction, which is a physics question, not a storage one.
pub fn edge_set_surf(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer,
                     surf: integer) {
  i = eg_index(e, qa, ra, qb, rb);
  if i < 0 { return; }
  was = (e.eg_surf[i] ?? 0) as integer;
  if was == 0 && surf != 0 { e.eg_count = e.eg_count + 1; }
  if was != 0 && surf == 0 { e.eg_count = e.eg_count - 1; }
  e.eg_surf[i] = (surf as i32?) ?? 0;
}

// The surface on that same edge — the finish, where `edge_mat` is the substance.
pub fn edge_surf(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
  i = eg_index(e, qa, ra, qb, rb);
  if i < 0 { return 0; }
  (e.eg_surf[i] ?? 0) as integer
}

// Both halves of an edge in one call, so a caller cannot set geometry and forget physics.
pub fn edge_set_both(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer,
                     surf: integer, mat: integer) {
  edge_set_surf(e, qa, ra, qb, rb, surf);
  edge_set_mat(e, qa, ra, qb, rb, mat);
}

// Blocked edges, O(1).  `edgeset_count` walks the grid and counts MATERIALS; this is the
// derived-geometry count and is maintained on write, so it is free to call every frame.
pub fn edgeset_surf_count(e: EdgeSet) -> integer { e.eg_count }

// Exact element-wise equality over BOTH slots and the whole grid, halo included.
//
// Exists because the obvious consumer test — "is the cached layer identical to a freshly
// derived one?" — otherwise has to read the vectors directly, which would make the field
// layout contract.  Compares the halo too: `edgeset_count` walks only in-chunk cells, so a
// corruption in the 74-of-75 halo slots of a small layer round-trips faithfully and no
// assertion sees it.  That is not hypothetical here — it is how a negative control in this
// package failed to fail.
pub fn edgeset_equal(a: EdgeSet, b: EdgeSet) -> boolean {
  if a.eg_gw != b.eg_gw || a.eg_gh != b.eg_gh { return false; }
  n = a.eg_gw * a.eg_gh * 3;
  same = true;
  for i in 0..n {
    if ((a.eg_mat[i] ?? 0) as integer) != ((b.eg_mat[i] ?? 0) as integer) { same = false; }
    if ((a.eg_surf[i] ?? 0) as integer) != ((b.eg_surf[i] ?? 0) as integer) { same = false; }
  }
  same
}

// An order-sensitive checksum of the whole layer — the cheap "did anything change at all?"
// for a test that operates on a layer and asserts it was left alone.  Use `edgeset_equal`
// when the answer must be exact; a digest can collide, and for a correctness gate that
// matters.
pub fn edgeset_digest(e: EdgeSet) -> integer {
  h = 1469598103;
  n = e.eg_gw * e.eg_gh * 3;
  for i in 0..n {
    h = (h * 31 + ((e.eg_mat[i] ?? 0) as integer)) % 1000000007 ?? 0;
    h = (h * 31 + ((e.eg_surf[i] ?? 0) as integer)) % 1000000007 ?? 0;
  }
  h
}

// Actual bytes held, so a consumer can GATE the footprint instead of reasoning about it.
// An edge layer is a per-region cache in at least one consumer, which makes its size a
// real cost; `size` reports what is truly allocated, so an assertion on it is what stops
// the structure quietly growing back.  Exposed as a function rather than leaving consumers
// to read the vectors, which would make the field layout contract.
pub fn edgeset_bytes(e: EdgeSet) -> integer { size(e.eg_mat) + size(e.eg_surf) }

// The edge layer's window, read back, exactly as `hexset_q0` reads a cell layer's.
pub fn edgeset_q0(e: EdgeSet) -> integer { e.eg_q0 }
// The edge layer's first `r`.
pub fn edgeset_r0(e: EdgeSet) -> integer { e.eg_r0 }
// How many `q` columns the edge layer spans.
pub fn edgeset_w(e: EdgeSet) -> integer { e.eg_w }
// How many `r` rows the edge layer spans.
pub fn edgeset_h(e: EdgeSet) -> integer { e.eg_h }

// Every non-zero slot in the layer, INCLUDING the halo.  edgeset_count walks
// only in-chunk cells, which leaves the halo — 74 of a 3x3 layer's 75 slots —
// unverified: a corruption there round-trips faithfully and no assertion sees
// it.  Found by a negative control that failed to fail.
pub fn edgeset_count_all(e: EdgeSet) -> integer {
  n = 0;
  for i in 0..(e.eg_gw * e.eg_gh * 3) {
    if (e.eg_mat[i] ?? 0) != 0 { n = n + 1; }
  }
  n
}

// How many edges carry a material.  Walks the canonical slots only, so each
// edge is counted once however many cells touch it.
pub fn edgeset_count(e: EdgeSet) -> integer {
  n = 0;
  for iq in 0..e.eg_w {
    for ir in 0..e.eg_h {
      cq = e.eg_q0 + iq;
      cr = e.eg_r0 + ir;
      for s in 0..3 {
        d = 0;
        if s == 1 { d = 2; }
        if s == 2 { d = 3; }
        if edge_mat(e, cq, cr, nb_q(cq, cr, d), nb_r(cq, cr, d)) != 0 { n = n + 1; }
      }
    }
  }
  n
}

// ═══════════════════════════════════════════════════════════════════════
// named integer layers.
//
// `Labels` is one integer per cell.  A consumer generally needs several — moros
// carries a material, an item and that item's rotation — and which ones exist is
// CONTENT, not mechanism.  So the library holds "a named integer field over the
// chunk" and the consumer names them.
//
// One flat vector rather than a vector of Labels: layer i, cell (q,r) lives at
// i*w*h + r*w + q.  Adding a layer appends w*h slots, so a stencil with one layer
// costs one layer's worth of storage.

// Named integer layers over a `ly_w × ly_h` window anchored at `(ly_q0, ly_r0)`: the layer
// names, and every value in one flat vector, layer i and cell (q,r) at i*w*h + r*w + q.
pub struct Layers {
  ly_q0:    integer,
  ly_r0:    integer,
  ly_w:     integer,
  ly_h:     integer,
  ly_names: vector<text>,
  ly_vals:  vector<integer>,
}

// Several named integer layers over one window, added as they are needed.  Reach for it
// instead of many `Labels` when the set of layers is the consumer's to decide.
pub fn layers_new(q0: integer, r0: integer, w: integer, h: integer) -> Layers {
  ns: vector<text> = [];
  vs: vector<integer> = [];
  Layers { ly_q0: q0, ly_r0: r0, ly_w: w, ly_h: h, ly_names: ns, ly_vals: vs }
}

// How many layers exist, and the name of layer i — enough to walk them without knowing
// which names a consumer chose.
pub fn layer_count(ls: Layers) -> integer { len(ls.ly_names) }
// Layer i's name, or "" — walk `0..layer_count` to list them.
pub fn layer_name(ls: Layers, i: integer) -> text { ls.ly_names[i] ?? "" }
// The layer stack's window, read back, as `hexset_q0` reads a cell layer's.
pub fn layers_q0(ls: Layers) -> integer { ls.ly_q0 }
// The layer stack's first `r`.
pub fn layers_r0(ls: Layers) -> integer { ls.ly_r0 }
// How many `q` columns the layer stack spans.
pub fn layers_w(ls: Layers) -> integer { ls.ly_w }
// How many `r` rows the layer stack spans.
pub fn layers_h(ls: Layers) -> integer { ls.ly_h }

// Index of a named layer, or -1.  Names are the consumer's vocabulary; the
// library never interprets them.
pub fn layer_index(ls: Layers, name: text) -> integer {
  found = -1;
  for i in 0..len(ls.ly_names) {
    if (ls.ly_names[i] ?? "") == name { found = i; }
  }
  found
}

// Add a layer, zero-filled.  Adding one that exists is a no-op, so a consumer
// can call it defensively without checking first.
pub fn layer_add(ls: Layers, name: text) -> integer {
  had = layer_index(ls, name);
  if had >= 0 { return had; }
  ls.ly_names += [name];
  n = ls.ly_w * ls.ly_h;
  for _ in 0..n { ls.ly_vals += [0]; }
  len(ls.ly_names) - 1
}

fn ly_slot(ls: Layers, li: integer, q: integer, r: integer) -> integer {
  dq = q - ls.ly_q0;
  dr = r - ls.ly_r0;
  if li < 0 || dq < 0 || dq >= ls.ly_w || dr < 0 || dr >= ls.ly_h { return -1; }
  li * ls.ly_w * ls.ly_h + dr * ls.ly_w + dq
}

// Write one cell of a named layer.  An unknown name does nothing rather than creating a
// layer, so a typo loses the write instead of silently growing the stack.
pub fn layer_set(ls: Layers, name: text, q: integer, r: integer, v: integer) {
  i = ly_slot(ls, layer_index(ls, name), q, r);
  if i < 0 { return; }
  ls.ly_vals[i] = v;
}

// Read one cell of a named layer; 0 when the name or the cell is not there.
pub fn layer_get(ls: Layers, name: text, q: integer, r: integer) -> integer {
  i = ly_slot(ls, layer_index(ls, name), q, r);
  if i < 0 { return 0; }
  ls.ly_vals[i] ?? 0
}

// By index, for the writer and for iterating without knowing the names.
pub fn layer_get_at(ls: Layers, li: integer, q: integer, r: integer) -> integer {
  i = ly_slot(ls, li, q, r);
  if i < 0 { return 0; }
  ls.ly_vals[i] ?? 0
}

// Write by layer INDEX, for a loop that resolved the name once with `layer_index`.
pub fn layer_set_at(ls: Layers, li: integer, q: integer, r: integer, v: integer) {
  i = ly_slot(ls, li, q, r);
  if i < 0 { return; }
  ls.ly_vals[i] = v;
}

tests/01-hex-field.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_field: the exact-integer contract, asserted standalone.
//
// These are the CONTRACT tests — the guarantees a consumer (the game, the in-world
// editor) is entitled to rely on. They are deliberately self-contained: crawler keeps
// its own golden-JSON diff against the Python oracle, because that is a CONSUMER's
// check of this package, not this package's check of itself.
use hex_field::*;

fn main() {
  s = hexset_new(16);
  hexdisk_into(s, 8, 8, 2);
  assert(hexset_count(s) == 19, "hexdisk N=2 is 19 cells");
}

// I-ROUNDTRIP: outline and cell set can never disagree.
//   sum of the integer shoelace over all loops == 12 * (number of hexes)
fn test_area_roundtrip_is_exact() {
  ok = true;
  for n in 0..5 {
    s = hexset_new(32);
    hexdisk_into(s, 16, 16, n);
    v = trace(s);
    if shoelace_total(v) != 12 * hexset_count(s) { ok = false; }
  }
  assert(ok, "shoelace total == 12 * cell count, for every hexdisk radius");
}

// The validator must pass every traced form it produced itself.
fn test_validator_accepts_traced_forms() {
  ok = true;
  for n in 1..5 {
    s = hexset_new(32);
    hexdisk_into(s, 16, 16, n);
    v = trace(s);
    if validate(v, hexset_count(s)) != 0 { ok = false; }
  }
  assert(ok, "validate() == 0 on forms this package traced");
}

// Vertices are INTEGER lattice points — the property the whole design rests on.
// Cell centres satisfy k == m (mod 2); no float, no epsilon, no drift.
fn test_lattice_is_integral() {
  ok = true;
  for r in 0..12 {
    for q in 0..12 {
      k = lattice_k(q, r);
      m = lattice_m(q, r);
      if ((k - m) % 2) != 0 { ok = false; }
    }
  }
  assert(ok, "every cell centre satisfies k == m (mod 2)");
}

// Occupancy round-trips, and out-of-chunk reads are false rather than a crash.
fn test_hexset_setget_and_bounds() {
  s = hexset_new(8);
  hexset_set(s, 3, 4, true);
  assert(hexset_get(s, 3, 4), "set then get");
  assert(!hexset_get(s, 4, 4), "an untouched cell is empty");
  assert(!hexset_get(s, 99, 99), "out of chunk reads false, not a crash");
}

// NEGATIVE CONTROL: the area invariant must FAIL when the set is corrupted by one
// cell without re-tracing. A gate that cannot go red is not a gate.
fn test_negative_control_detects_one_cell() {
  s = hexset_new(32);
  hexdisk_into(s, 16, 16, 3);
  v = trace(s);
  hexset_set(s, 16, 16 + 9, true);          // a detached cell, outline NOT re-traced
  assert(shoelace_total(v) != 12 * hexset_count(s),
         "one added cell must break the area round-trip");
}

tests/02-hex-doc.loft

// The document format's contract.  `load(save(x)) == x` is the interface
// between consumers, so it is gated here, in the package, not in either game.
use hex_field::*;

// Clears any leftover before naming the path: `file()` APPENDS to an existing file,
// so a run that fails before its `delete` would poison the next one's header.
fn tmp(name: text) -> text {
  p = "hexdoc_{name}.bin";
  delete(p);
  p
}

fn build(w: integer, h: integer) -> HexSet {
  s = hexset_chunk(2, 3, w, h);
  for q in 0..w {
    for r in 0..h {
      if (q + r) % 3 != 0 { hexset_set(s, 2 + q, 3 + r, true); }
    }
  }
  s
}

fn test_round_trip_is_identity() {
  s = build(5, 4);
  hz = heights_new(2, 3, 5, 4);
  lb = labels_new(2, 3, 5, 4);
  for q in 0..5 {
    for r in 0..4 {
      height_set(hz, 2 + q, 3 + r, (q * 10 + r) as float + 0.5);
      label_set(lb, 2 + q, 3 + r, q * 7 + r);
    }
  }
  p = tmp("rt");
  doc_write(p, s, hz, lb, true, true);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "load refused with {d.doc_code}");
  assert(d.doc_has_h, "heights section missing");
  assert(d.doc_has_l, "labels section missing");
  assert(hexset_count(d.doc_cells) == hexset_count(s), "cell count changed");
  for q in 0..5 {
    for r in 0..4 {
      assert(hexset_get(d.doc_cells, 2 + q, 3 + r) == hexset_get(s, 2 + q, 3 + r),
             "occupancy differs at {q},{r}");
      // fractional heights survive: the format carries f64, so 12.5 is NOT 12
      assert(height_get(d.doc_heights, 2 + q, 3 + r) == height_get(hz, 2 + q, 3 + r),
             "height differs at {q},{r}");
      assert(label_get(d.doc_labels, 2 + q, 3 + r) == label_get(lb, 2 + q, 3 + r),
             "label differs at {q},{r}");
    }
  }
  delete(p);
}

// Writing what was read must produce the SAME BYTES.  Structural equality
// hides field-ordering and default-value drift, which is how two consumers
// stop agreeing without either noticing.
fn test_rewrite_is_byte_identical() {
  s = build(4, 4);
  hz = heights_new(2, 3, 4, 4);
  lb = labels_new(2, 3, 4, 4);
  for q in 0..4 { for r in 0..4 { height_set(hz, 2 + q, 3 + r, 1.25); label_set(lb, 2 + q, 3 + r, 9); } }
  a = tmp("b1"); b = tmp("b2");
  doc_write(a, s, hz, lb, true, true);
  d = doc_read(a);
  assert(d.doc_code == HXF_OK, "first load refused {d.doc_code}");
  doc_write(b, d.doc_cells, d.doc_heights, d.doc_labels, d.doc_has_h, d.doc_has_l);
  fa = file(a); fa#format = LittleEndian;
  fb = file(b); fb#format = LittleEndian;
  na = fa#size; nb = fb#size;
  assert(na == nb, "sizes differ: {na} vs {nb}");
  for i in 0..na {
    x = fa#read as u8;
    y = fb#read as u8;
    assert(x == y, "byte {i} differs: {x} vs {y}");
  }
  delete(a); delete(b);
}

// An occupancy-only field must not carry empty height/label sections.
fn test_absent_sections_are_absent() {
  s = build(3, 3);
  hz = heights_new(2, 3, 3, 3);
  lb = labels_new(2, 3, 3, 3);
  p = tmp("occ");
  doc_write(p, s, hz, lb, false, false);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "refused {d.doc_code}");
  assert(!d.doc_has_h, "heights present when not written");
  assert(!d.doc_has_l, "labels present when not written");
  f = file(p); f#format = LittleEndian;
  assert(f#size == 32 + 8 + 9, "occupancy-only file is {f#size} bytes, want 49");
  delete(p);
}

// THE reason sections carry a length: a field written by a newer consumer,
// with a section this reader has never heard of, still loads.
fn test_unknown_section_is_skipped() {
  p = tmp("fwd");
  {
    f = file(p);
    f#format = LittleEndian;
    f += (HXF_MAGIC as i32);
    f += (HXF_SCHEMA as i32);
    f += (0 as i32); f += (0 as i32);      // q0, r0
    f += (2 as i32); f += (2 as i32);      // w, h
    f += (2 as i32);                        // two sections
    f += (0 as i32);                        // reserved
    f += (0x5A5A5A5A as i32);               // a tag from the future
    f += (6 as i32);
    for _ in 0..6 { f += (0xEE as u8); }
    f += (TAG_OCCU as i32);
    f += (4 as i32);
    for _ in 0..4 { f += (1 as u8); }
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "a future section made the load fail: {d.doc_code}");
  assert(d.doc_skipped == 1, "expected 1 skipped section, got {d.doc_skipped}");
  assert(hexset_count(d.doc_cells) == 4, "occupancy after the unknown section was lost");
  delete(p);
}

// ── refusals: each must fail LOUDLY, never parse half a field ────────────

fn test_refuses_bad_magic() {
  p = tmp("magic");
  { f = file(p); f#format = LittleEndian; f += ((0xDEADBEEF as i32?) ?? 0 as i32); }
  assert(doc_read(p).doc_code != HXF_OK, "bad magic accepted");
  delete(p);
}

fn test_refuses_future_schema() {
  p = tmp("schema");
  {
    f = file(p); f#format = LittleEndian;
    f += (HXF_MAGIC as i32); f += (99 as i32);
    for _ in 0..6 { f += (0 as i32); }
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_BAD_SCHEMA, "future schema gave {d.doc_code}");
  delete(p);
}

fn test_refuses_nonzero_reserved() {
  p = tmp("rsv");
  {
    f = file(p); f#format = LittleEndian;
    f += (HXF_MAGIC as i32); f += (HXF_SCHEMA as i32);
    f += (0 as i32); f += (0 as i32); f += (2 as i32); f += (2 as i32);
    f += (1 as i32); f += (7 as i32);
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_BAD_RESERVED, "non-zero reserved gave {d.doc_code}");
  delete(p);
}

// Explicit dimensions exist to catch exactly this.
fn test_refuses_section_length_disagreeing_with_dimensions() {
  p = tmp("dims");
  {
    f = file(p); f#format = LittleEndian;
    f += (HXF_MAGIC as i32); f += (HXF_SCHEMA as i32);
    f += (0 as i32); f += (0 as i32);
    f += (4 as i32); f += (4 as i32);      // claims 16 cells
    f += (1 as i32); f += (0 as i32);
    f += (TAG_OCCU as i32); f += (9 as i32);   // but ships 9 bytes
    for _ in 0..9 { f += (1 as u8); }
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_BAD_SECTION, "dimension mismatch gave {d.doc_code}");
  delete(p);
}

fn test_refuses_truncated_file() {
  s = build(4, 4);
  hz = heights_new(2, 3, 4, 4);
  lb = labels_new(2, 3, 4, 4);
  full = tmp("full");
  doc_write(full, s, hz, lb, true, true);
  ff = file(full); ff#format = LittleEndian;
  keep = ff#size - 20;
  cut = tmp("cut");
  { g = file(cut); g#format = LittleEndian;
    for _ in 0..keep { b = ff#read as u8; g += (b as u8); } }
  d = doc_read(cut);
  assert(d.doc_code == HXF_TRUNCATED, "truncated file gave {d.doc_code}");
  delete(full); delete(cut);
}

fn test_refuses_header_only_stub() {
  p = tmp("stub");
  { f = file(p); f#format = LittleEndian; f += (HXF_MAGIC as i32); }
  assert(doc_read(p).doc_code == HXF_TRUNCATED, "8-byte stub accepted");
  delete(p);
}

tests/03-fixture.loft

// The interface gate.  `tests/fixtures/canonical.hxf` is ONE committed file that
// both consumers read.  If either side's writer or reader drifts, one of them
// stops agreeing with a file neither of them can quietly regenerate.
//
// Its values are chosen to exercise the format's decisions, not to look tidy:
// a NEGATIVE extent origin, labels past u8 (301), and heights that are both
// negative and fractional (-3.5) — the three things moros#1 measured the dense
// model could not express.
use hex_field::*;

// Relative paths resolve from the TEST FILEs directory under `loft test`,
// not the package root — a doubled `tests/` is the classic way to get this wrong.
const FIXTURE = "fixtures/canonical.hxf";

fn test_canonical_fixture_loads() {
  d = doc_read(FIXTURE);
  assert(d.doc_code == HXF_OK, "the committed fixture no longer loads: code {d.doc_code}");
  assert(hexset_q0(d.doc_cells) == -2, "q0 is {hexset_q0(d.doc_cells)}, want -2");
  assert(hexset_r0(d.doc_cells) == -1, "r0 is {hexset_r0(d.doc_cells)}, want -1");
  assert(hexset_w(d.doc_cells) == 4, "w is {hexset_w(d.doc_cells)}");
  assert(hexset_h(d.doc_cells) == 3, "h is {hexset_h(d.doc_cells)}");
  assert(hexset_count(d.doc_cells) == 8, "cells is {hexset_count(d.doc_cells)}, want 8");
  assert(d.doc_has_h, "heights section lost");
  assert(d.doc_has_l, "labels section lost");
}

fn test_canonical_fixture_values_survive() {
  d = doc_read(FIXTURE);
  assert(d.doc_code == HXF_OK, "fixture refused {d.doc_code}");
  // a label past u8 — the format is i32 on purpose
  assert(label_get(d.doc_labels, 1, -1) == 301, "label at (1,-1) is {label_get(d.doc_labels, 1, -1)}, want 301");
  // a height that is negative AND fractional — the format is f64 on purpose
  assert(height_get(d.doc_heights, -2, -1) == 0.0 - 3.5,
         "height at (-2,-1) is {height_get(d.doc_heights, -2, -1)}, want -3.5");
  assert(height_get(d.doc_heights, 1, 0) == 9.5,
         "height at (1,0) is {height_get(d.doc_heights, 1, 0)}, want 9.5");
}

// Read it, write it back, and the bytes must match the committed file.  This is
// what stops a writer from drifting while its own reader drifts with it.
fn test_rewriting_the_fixture_reproduces_it() {
  d = doc_read(FIXTURE);
  assert(d.doc_code == HXF_OK, "fixture refused {d.doc_code}");
  tmp = "fixture_rewrite.bin";
  doc_write(tmp, d.doc_cells, d.doc_heights, d.doc_labels, d.doc_has_h, d.doc_has_l);
  fa = file(FIXTURE); fa#format = LittleEndian;
  fb = file(tmp);     fb#format = LittleEndian;
  na = fa#size; nb = fb#size;
  assert(na == nb, "rewrite is {nb} bytes, committed fixture is {na}");
  for i in 0..na {
    x = fa#read as u8;
    y = fb#read as u8;
    assert(x == y, "byte {i} differs: {x} vs {y} — the writer has drifted from the fixture");
  }
  delete(tmp);
}

tests/04-stencil.loft

// The stencil contract.  Every assertion here is exact — no tolerance appears in
// this file, because the whole claim is that the lattice makes rotation exact.
use hex_field::*;

// An L-shape: handed, so a mirror is distinguishable from a rotation.
fn ell() -> Stencil {
  s  = hexset_chunk(0, 0, 3, 3);
  hz = heights_new(0, 0, 3, 3);
  lb = labels_new(0, 0, 3, 3);
  for r in 0..3 { hexset_set(s, 0, r, true); label_set(lb, 0, r, 10 + r); height_set(hz, 0, r, r as float + 0.5); }
  for q in 1..3 { hexset_set(s, q, 0, true); label_set(lb, q, 0, 20 + q); height_set(hz, q, 0, q as float + 0.5); }
  stencil_from(s, hz, lb, true, true)
}

fn shoelace_of(s: HexSet) -> integer { shoelace_total(trace(s)) }

// ── the load-bearing invariant ───────────────────────────────────────────

// SIX 60° STEPS ARE THE IDENTITY — asserted at the lattice level, because
// cell_rot reduces n modulo 6, so "rotate by 6" short-circuits to doing nothing
// and would pass no matter how wrong the rotation was.  A control that perturbed
// lattice_rot60 by (+1,+1) left an earlier version of this test green; composing
// the primitive six times is what actually pins it.
fn test_six_lattice_steps_are_the_identity() {
  for qi in 0..9 {
    for ri in 0..9 {
      q = qi - 4;
      r = ri - 4;
      k0 = lattice_k(q, r);
      m0 = lattice_m(q, r);
      k = k0; m = m0;
      for s in 0..6 {
        (nk, nm) = lattice_rot60(k, m);
        k = nk; m = nm;
        // every intermediate image must still be a legal cell centre
        assert(m % 3 == 0, "step {s} from {q},{r}: m={m} is not a multiple of 3");
        assert((k - m) % 2 == 0, "step {s} from {q},{r}: k={k} m={m} breaks k = m (mod 2)");
      }
      assert(k == k0 && m == m0,
             "six steps from cell {q},{r}: ({k},{m}) != ({k0},{m0})");
    }
  }
}

// And each single step must actually MOVE a non-origin cell — otherwise the
// identity above is satisfied by a rotation that does nothing at all.
fn test_a_single_step_is_not_a_no_op() {
  for n in 1..6 {
    (rq, rr) = cell_rot(2, 1, n);
    assert(rq != 2 || rr != 1, "rotating (2,1) by {n} steps left it in place");
  }
  (bq, br) = cell_rot(2, 1, 6);
  assert(bq == 2 && br == 1, "rotating (2,1) by 6 steps did not return it");
}

fn test_rotation_preserves_count_and_area() {
  st = ell();
  n0 = hexset_count(st.sn_cells);
  a0 = shoelace_of(st.sn_cells);
  assert(a0 == 12 * n0, "the source itself breaks the area identity");
  for step in 1..6 {
    rot = stencil_rotate(st, step);
    n = hexset_count(rot.sn_cells);
    a = shoelace_of(rot.sn_cells);
    assert(n == n0, "step {step}: cell count {n}, want {n0}");
    assert(a == a0, "step {step}: shoelace {a}, want {a0}");
    assert(a == 12 * n, "step {step}: area identity broken");
  }
}

fn test_mirror_is_exact_and_self_inverse() {
  for q in 0..5 {
    for r in 0..5 {
      (mq, mr) = cell_mirror(q - 2, r - 2);
      (bq, br) = cell_mirror(mq, mr);
      assert(bq == q - 2 && br == r - 2, "mirror is not self-inverse at {q-2},{r-2}");
    }
  }
}

// ── stamping ─────────────────────────────────────────────────────────────

fn test_stamp_lands_where_asked_on_an_odd_row() {
  // An ODD anchor row is the case that a naive (q,r) translation shears.
  st = ell();
  dst  = hexset_chunk(-5, -5, 20, 20);
  dh   = heights_new(-5, -5, 20, 20);
  dl   = labels_new(-5, -5, 20, 20);
  stencil_stamp(dst, dh, dl, st, 4, 3);
  assert(hexset_count(dst) == hexset_count(st.sn_cells),
         "stamped {hexset_count(dst)} cells, stencil has {hexset_count(st.sn_cells)}");
  // the stamped shape must still trace to the same area
  assert(shoelace_of(dst) == 12 * hexset_count(dst), "stamped shape breaks the area identity");
}

// ⚠ AT DIFFERENT PLACES — and the last line is what says so.  The OVERLAPPING
// case is a different claim with a different answer, and it is
// `test_overlapping_stamps_are_order_free_only_in_occupancy` below.
fn test_two_stamps_at_different_places_are_order_free() {
  st = ell();
  a1 = hexset_chunk(-8, -8, 30, 30); h1 = heights_new(-8, -8, 30, 30); l1 = labels_new(-8, -8, 30, 30);
  a2 = hexset_chunk(-8, -8, 30, 30); h2 = heights_new(-8, -8, 30, 30); l2 = labels_new(-8, -8, 30, 30);
  stencil_stamp(a1, h1, l1, st, 1, 1);
  stencil_stamp(a1, h1, l1, st, 6, 5);
  stencil_stamp(a2, h2, l2, st, 6, 5);
  stencil_stamp(a2, h2, l2, st, 1, 1);
  for q in 0..30 {
    for r in 0..30 {
      assert(hexset_get(a1, -8 + q, -8 + r) == hexset_get(a2, -8 + q, -8 + r),
             "stamp order changed cell {q},{r}");
    }
  }
  assert(hexset_count(a1) == 2 * hexset_count(st.sn_cells), "the two stamps overlapped");
}

fn test_stamp_leaves_the_rest_of_the_target_alone() {
  st = ell();
  dst = hexset_chunk(0, 0, 20, 20); dh = heights_new(0, 0, 20, 20); dl = labels_new(0, 0, 20, 20);
  hexset_set(dst, 15, 15, true);          // a pre-existing cell far away
  label_set(dl, 15, 15, 999);
  stencil_stamp(dst, dh, dl, st, 2, 2);
  assert(hexset_get(dst, 15, 15), "an unrelated cell was cleared by a stamp");
  assert(label_get(dl, 15, 15) == 999, "an unrelated label was overwritten");
}

// ── the negative control the issue asks for ──────────────────────────────

fn test_rotation_by_a_non_multiple_of_60_is_refused() {
  st = ell();
  bad = stencil_rotate_deg(st, 45);
  assert(bad.sn_code == ROT_NOT_MULTIPLE, "45° was accepted, code {bad.sn_code}");
  assert(hexset_count(bad.sn_cells) == 0, "a refused rotation still produced cells");
  bad2 = stencil_rotate_deg(st, 1);
  assert(bad2.sn_code == ROT_NOT_MULTIPLE, "1° was accepted");
}

fn test_rotation_by_multiples_of_60_is_accepted() {
  st = ell();
  for i in 0..7 {
    ok = stencil_rotate_deg(st, i * 60);
    assert(ok.sn_code == ROT_OK, "{i * 60}° was refused");
    assert(hexset_count(ok.sn_cells) == hexset_count(st.sn_cells), "{i * 60}° lost cells");
  }
  neg = stencil_rotate_deg(st, 0 - 120);
  assert(neg.sn_code == ROT_OK, "-120° was refused");
  assert(hexset_count(neg.sn_cells) == hexset_count(st.sn_cells), "-120° lost cells");
}

// ── a stencil must never lose data ───────────────────────────────────────
//
// Not "the cells survive" — EVERYTHING survives.  A stencil is cells plus
// labels, heights, edge materials and named layers, and an orientation change
// is a relabelling of the lattice, so nothing has anywhere to go.
//
// This is stated over edgeset_count_all, which walks the halo too: rim edges are
// stored against a cell OUTSIDE the extent, so an implementation that iterates
// occupied cells drops them and every in-chunk count still looks plausible.
// That is exactly what happened — 18 slots became 10 and the cell count never
// moved.

fn walled_ring() -> Stencil {
  c  = hexset_chunk(-1, -1, 3, 3);
  hz = heights_new(-1, -1, 3, 3);
  lb = labels_new(-1, -1, 3, 3);
  eg = edgeset_new(-1, -1, 3, 3);
  hexset_set(c, 0, 0, true);
  label_set(lb, 0, 0, 2);
  for d in 0..6 {
    rq = nb_q(0, 0, d);
    rr = nb_r(0, 0, d);
    hexset_set(c, rq, rr, true);
    label_set(lb, rq, rr, 1);
    height_set(hz, rq, rr, 3.5);
  }
  for d in 0..6 {
    rq = nb_q(0, 0, d);
    rr = nb_r(0, 0, d);
    for e2 in 0..6 {
      oq = nb_q(rq, rr, e2);
      orr = nb_r(rq, rr, e2);
      if !hexset_get(c, oq, orr) { edge_set_mat(eg, rq, rr, oq, orr, 1); }
    }
  }
  st = stencil_with_edges(c, hz, lb, eg, true, true);
  layer_add(st.sn_layers, "item");
  layer_set(st.sn_layers, "item", 0, 0, 44);
  st
}

fn test_rotation_loses_nothing_at_all() {
  st = walled_ring();
  n0 = hexset_count(st.sn_cells);
  e0 = edgeset_count_all(st.sn_edges);
  assert(e0 > 0, "the fixture has no walls");
  for n in 1..6 {
    rot = stencil_rotate(st, n);
    assert(hexset_count(rot.sn_cells) == n0, "turn {n}: cells {hexset_count(rot.sn_cells)}, want {n0}");
    assert(edgeset_count_all(rot.sn_edges) == e0,
           "turn {n}: {edgeset_count_all(rot.sn_edges)} edge slots, want {e0} — a rotation lost walls");
    assert(layer_count(rot.sn_layers) == 1, "turn {n}: lost the named layer");
    (tq, tr) = cell_rot(0, 0, n);
    assert(layer_get(rot.sn_layers, "item", tq, tr) == 44, "turn {n}: lost the item value");
  }
}

fn test_mirror_loses_nothing_and_is_self_inverse() {
  st = walled_ring();
  m1 = stencil_mirror(st);
  assert(hexset_count(m1.sn_cells) == hexset_count(st.sn_cells), "mirror lost cells");
  assert(edgeset_count_all(m1.sn_edges) == edgeset_count_all(st.sn_edges),
         "mirror lost walls: {edgeset_count_all(m1.sn_edges)} vs {edgeset_count_all(st.sn_edges)}");
  m2 = stencil_mirror(m1);
  assert(hexset_count(m2.sn_cells) == hexset_count(st.sn_cells), "mirroring twice lost cells");
  for q in 0..3 {
    for r in 0..3 {
      assert(hexset_get(m2.sn_cells, q - 1, r - 1) == hexset_get(st.sn_cells, q - 1, r - 1),
             "mirroring twice is not the identity at {q-1},{r-1}");
    }
  }
}

// ── what OVERLAP actually does ───────────────────────────────────────────
//
// ⚠ THE DESIGN SENTENCE WAS WRONG, AND MEASURING IT IS HOW WE KNOW.  moros#5
// promised "two stencils overlapping at the same level arbitrate
// deterministically AND ORDER-FREELY — stamping A then B equals B then A".
// Half of that is true.  Stamped both ways with different payloads on the
// shared cells, the occupancy agreed everywhere and SIX labels and SIX heights
// did not: a stamp is last-writer-wins, so the payload carries the order.
//
// Order-freedom for the payload was never the right rule anyway — it would make
// "place this on top of that" impossible, which is the one thing an editor's
// stamp is for.  What survives is the pair below, and both halves are gated,
// because a mechanism that quietly became order-DEPENDENT in its occupancy
// would break stamping into a shared field and no count would notice.

fn overlap_pair(lab: integer, hgt: float) -> Stencil {
  s  = hexset_chunk(0, 0, 3, 3);
  hz = heights_new(0, 0, 3, 3);
  lb = labels_new(0, 0, 3, 3);
  for q in 0..3 { for r in 0..3 {
    hexset_set(s, q, r, true); label_set(lb, q, r, lab); height_set(hz, q, r, hgt);
  } }
  stencil_from(s, hz, lb, true, true)
}

fn test_overlapping_stamps_are_order_free_only_in_occupancy() {
  a = overlap_pair(11, 1.5);
  b = overlap_pair(22, 9.5);
  c1 = hexset_chunk(-8,-8,30,30); h1 = heights_new(-8,-8,30,30); l1 = labels_new(-8,-8,30,30);
  c2 = hexset_chunk(-8,-8,30,30); h2 = heights_new(-8,-8,30,30); l2 = labels_new(-8,-8,30,30);
  stencil_stamp(c1, h1, l1, a, 0, 0);
  stencil_stamp(c1, h1, l1, b, 1, 0);
  stencil_stamp(c2, h2, l2, b, 1, 0);
  stencil_stamp(c2, h2, l2, a, 0, 0);

  // the two anchors are one apart, so the 3x3 blobs genuinely share cells —
  // without this the test degrades into the disjoint case above and proves
  // nothing about overlap at all
  assert(hexset_count(c1) < 2 * 9, "the fixtures did not overlap: {hexset_count(c1)} cells");

  shared = 0;
  for qi in 0..30 { for ri in 0..30 {
    q = qi - 8; r = ri - 8;
    assert(hexset_get(c1,q,r) == hexset_get(c2,q,r), "occupancy depended on stamp order at {q},{r}");
    if hexset_get(c1,q,r) && label_get(l1,q,r) != label_get(l2,q,r) { shared += 1; }
  } }
  assert(hexset_count(c1) == hexset_count(c2), "overlap changed the cell count with the order");
  // and the payload DOES carry the order — the refuted half, pinned so that a
  // future arbitration rule cannot land silently
  assert(shared > 0, "labels no longer depend on stamp order — the last-writer-wins rule changed");
}

fn test_the_last_writer_owns_the_shared_payload() {
  a = overlap_pair(11, 1.5);
  b = overlap_pair(22, 9.5);
  c = hexset_chunk(-8,-8,30,30); h = heights_new(-8,-8,30,30); l = labels_new(-8,-8,30,30);
  stencil_stamp(c, h, l, a, 0, 0);
  stencil_stamp(c, h, l, b, 1, 0);
  // (1,0) is inside both footprints; B went second, so B owns it
  assert(label_get(l, 1, 0) == 22, "the second stamp did not win the shared cell: {label_get(l, 1, 0)}");
  assert(height_get(h, 1, 0) == 9.5, "the second stamp did not win the shared height");
  // (0,0) is A's alone — B's footprint starts at its anchor
  assert(label_get(l, 0, 0) == 11, "the second stamp reached a cell it does not cover");
}

// ── stamp then un-stamp, on free ground ──────────────────────────────────
//
// Invariant 3 of moros#5.  Stated over the WHOLE payload — cells, heights,
// labels, named layers and the walls including the rim — because a version that
// restored the cells and left the walls standing passes every cell count.
// `walled_ring` is the fixture with something in every one of those, which is
// why it is reused here.
fn test_stamp_then_unstamp_restores_the_target_bit_for_bit() {
  st = walled_ring();
  c  = hexset_chunk(-8,-8,20,20); hz = heights_new(-8,-8,20,20); lb = labels_new(-8,-8,20,20);
  ly = layers_new(-8,-8,20,20);   eg = edgeset_new(-8,-8,20,20);

  // an ODD anchor row, the case a naive translation shears
  stencil_stamp_all(c, hz, lb, ly, eg, st, 3, 3);
  assert(hexset_count(c) == hexset_count(st.sn_cells), "the stamp did not land");
  assert(edgeset_count_all(eg) > 0, "the stamp laid no walls, so un-stamping them proves nothing");

  stencil_unstamp_all(c, hz, lb, ly, eg, st, 3, 3);

  assert(hexset_count(c) == 0, "un-stamp left {hexset_count(c)} cells behind");
  assert(edgeset_count_all(eg) == 0,
         "un-stamp left {edgeset_count_all(eg)} edge slots behind — the rim walls survived");
  for qi in 0..20 { for ri in 0..20 {
    q = qi - 8; r = ri - 8;
    assert(!hexset_get(c, q, r), "cell {q},{r} survived the un-stamp");
    assert(height_get(hz, q, r) == 0.0, "height at {q},{r} is {height_get(hz, q, r)}, want 0.0");
    assert(label_get(lb, q, r) == -1, "label at {q},{r} is {label_get(lb, q, r)}, want -1");
    lv = layer_get(ly, "item", q, r);
    assert(lv == 0, "layer at {q},{r} is {lv}, want 0");
  } }
}

// The other half: an un-stamp must not reach past its own footprint.
fn test_unstamp_leaves_the_rest_of_the_target_alone() {
  st = walled_ring();
  c  = hexset_chunk(0,0,20,20); hz = heights_new(0,0,20,20); lb = labels_new(0,0,20,20);
  ly = layers_new(0,0,20,20);   eg = edgeset_new(0,0,20,20);
  hexset_set(c, 15, 15, true);
  label_set(lb, 15, 15, 999);
  height_set(hz, 15, 15, 7.25);
  stencil_stamp_all(c, hz, lb, ly, eg, st, 4, 4);
  stencil_unstamp_all(c, hz, lb, ly, eg, st, 4, 4);
  assert(hexset_get(c, 15, 15), "an unrelated cell was cleared by an un-stamp");
  assert(label_get(lb, 15, 15) == 999, "an unrelated label was cleared: {label_get(lb, 15, 15)}");
  assert(height_get(hz, 15, 15) == 7.25, "an unrelated height was cleared");
  assert(hexset_count(c) == 1, "un-stamp left {hexset_count(c)} cells, want just the unrelated one");
}

// Six rotations, and the other six by flipping — the twelve the design promises.
fn test_twelve_orientations_all_keep_the_whole_stencil() {
  st = walled_ring();
  n0 = hexset_count(st.sn_cells);
  e0 = edgeset_count_all(st.sn_edges);
  flipped = stencil_mirror(st);
  for n in 0..6 {
    a = stencil_rotate(st, n);
    b = stencil_rotate(flipped, n);
    assert(hexset_count(a.sn_cells) == n0, "orientation {n}: cells lost");
    assert(edgeset_count_all(a.sn_edges) == e0, "orientation {n}: walls lost");
    assert(hexset_count(b.sn_cells) == n0, "flipped orientation {n}: cells lost");
    assert(edgeset_count_all(b.sn_edges) == e0, "flipped orientation {n}: walls lost");
  }
}

tests/05-edges.loft

// Walls in stencils.  An edge's identity is the doubled midpoint of its two cell
// centres, so it survives rotation and translation for the same reason a cell
// does — exactly, on the integer lattice.
use hex_field::*;

// A room: a 3x3 block with a wall on one specific edge.
fn walled() -> Stencil {
  s  = hexset_chunk(0, 0, 3, 3);
  hz = heights_new(0, 0, 3, 3);
  lb = labels_new(0, 0, 3, 3);
  eg = edgeset_new(0, 0, 3, 3);
  for q in 0..3 { for r in 0..3 { hexset_set(s, q, r, true); label_set(lb, q, r, 5); } }
  // wall between (1,1) and its E neighbour
  edge_set_mat(eg, 1, 1, nb_q(1, 1, 0), nb_r(1, 1, 0), 7);
  stencil_with_edges(s, hz, lb, eg, false, true)
}

fn test_edge_key_is_symmetric() {
  (ka, ma) = edge_key(1, 1, 2, 1);
  (kb, mb) = edge_key(2, 1, 1, 1);
  assert(ka == kb && ma == mb, "an edge has two identities depending on who names it");
}

fn test_a_wall_is_stored_once_and_read_from_either_side() {
  eg = edgeset_new(0, 0, 4, 4);
  nq = nb_q(1, 1, 0); nr = nb_r(1, 1, 0);
  edge_set_mat(eg, 1, 1, nq, nr, 9);
  assert(edge_mat(eg, 1, 1, nq, nr) == 9, "wall not readable from the near side");
  assert(edge_mat(eg, nq, nr, 1, 1) == 9, "wall not readable from the far side");
  assert(edgeset_count(eg) == 1, "one wall counted {edgeset_count(eg)} times");
}

fn test_rotation_carries_the_wall() {
  st = walled();
  assert(edgeset_count(st.sn_edges) == 1, "source lost its wall");
  for n in 1..6 {
    rot = stencil_rotate(st, n);
    assert(edgeset_count(rot.sn_edges) == 1,
           "rotating by {n} left {edgeset_count(rot.sn_edges)} walls, want 1");
    // the wall must still sit between the ROTATED images of the same two cells
    (aq, ar) = cell_rot(1, 1, n);
    (bq, br) = cell_rot(nb_q(1, 1, 0), nb_r(1, 1, 0), n);
    assert(edge_mat(rot.sn_edges, aq, ar, bq, br) == 7,
           "at {n} turns the wall is not between the rotated cells");
  }
}

fn test_stamping_carries_the_wall_onto_an_odd_row() {
  st = walled();
  dc = hexset_chunk(-6, -6, 24, 24);
  dh = heights_new(-6, -6, 24, 24);
  dl = labels_new(-6, -6, 24, 24);
  de = edgeset_new(-6, -6, 24, 24);
  stencil_stamp_edges(dc, dh, dl, de, st, 5, 3);   // ODD anchor row
  assert(edgeset_count(de) == 1, "stamped {edgeset_count(de)} walls, want 1");
  // between the translated images of the same two cells
  dk = lattice_k(5, 3); dm = lattice_m(5, 3);
  (tq, tr) = lattice_to_cell(lattice_k(1, 1) + dk, lattice_m(1, 1) + dm);
  (uq, ur) = lattice_to_cell(lattice_k(nb_q(1, 1, 0), nb_r(1, 1, 0)) + dk,
                             lattice_m(nb_q(1, 1, 0), nb_r(1, 1, 0)) + dm);
  assert(edge_mat(de, tq, tr, uq, ur) == 7, "the wall did not land on the stamped edge");
}

fn test_walls_survive_the_document_format() {
  st = walled();
  p = "edge_rt.bin";
  doc_write_edges(p, st.sn_cells, st.sn_heights, st.sn_labels, st.sn_edges, false, true, true);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "refused {d.doc_code}");
  assert(d.doc_has_e, "the EDGE section was lost");
  assert(edgeset_count(d.doc_edges) == 1, "read back {edgeset_count(d.doc_edges)} walls, want 1");
  assert(edge_mat(d.doc_edges, 1, 1, nb_q(1, 1, 0), nb_r(1, 1, 0)) == 7,
         "the wall came back on the wrong edge or with the wrong material");
  delete(p);
}

// A reader with no EDGE support must still load the rest — the whole point of
// sections carrying lengths.
fn test_a_file_with_walls_still_loads_its_cells() {
  st = walled();
  p = "edge_fwd.bin";
  doc_write_edges(p, st.sn_cells, st.sn_heights, st.sn_labels, st.sn_edges, false, true, true);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "refused {d.doc_code}");
  assert(hexset_count(d.doc_cells) == 9, "cells lost when walls were present");
  delete(p);
}

// The other sections have a length gate; this one did not, and removing the
// check broke no test until this existed.  An EDGE payload is sized by the HALO
// grid ((w+2)(h+2)3), not w*h*3, so getting that wrong is easy and silent.
fn test_refuses_an_edge_section_of_the_wrong_length() {
  p = "edge_badlen.bin";
  {
    f = file(p); f#format = LittleEndian;
    f += (HXF_MAGIC as i32); f += (HXF_SCHEMA as i32);
    f += (0 as i32); f += (0 as i32);
    f += (3 as i32); f += (3 as i32);      // 3x3 -> halo grid is 5x5x3 = 75
    f += (2 as i32); f += (0 as i32);
    f += (TAG_OCCU as i32); f += (9 as i32);
    for _ in 0..9 { f += ((1 as u8?) ?? 0); }
    f += (TAG_EDGE as i32); f += (27 as i32);   // w*h*3 — the plausible wrong answer
    for _ in 0..27 { f += ((0 as u8?) ?? 0); }
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_BAD_SECTION,
         "an EDGE section sized w*h*3 instead of the halo grid gave {d.doc_code}");
  delete(p);
}

// The second shared fixture: one committed file with a WALL in it, read by both
// consumers.  `canonical.hxf` proved the cell layers were an interface rather
// than two implementations that happened to agree; this does the same for edges,
// which is the layer most likely to drift because its indexing is subtle.
const WALL_FIXTURE = "fixtures/walled.hxf";

fn test_committed_wall_fixture_loads() {
  d = doc_read(WALL_FIXTURE);
  assert(d.doc_code == HXF_OK, "the committed wall fixture no longer loads: {d.doc_code}");
  assert(hexset_count(d.doc_cells) == 9, "cells: {hexset_count(d.doc_cells)}, want 9");
  assert(d.doc_has_e, "the EDGE section is gone");
  assert(edgeset_count(d.doc_edges) == 1, "walls: {edgeset_count(d.doc_edges)}, want 1");
  // the wall sits on the E edge of (0,1) — an ODD row, at a negative-origin extent
  assert(edge_mat(d.doc_edges, 0, 1, nb_q(0, 1, 0), nb_r(0, 1, 0)) == 12,
         "the wall is not on the edge it was authored on");
  // and NOTHING else is set anywhere in the layer, halo included — otherwise a
  // corrupted halo slot rides along unnoticed
  assert(edgeset_count_all(d.doc_edges) == 1,
         "the layer carries {edgeset_count_all(d.doc_edges)} non-zero slots, want exactly 1");
}

fn test_rewriting_the_wall_fixture_reproduces_it() {
  d = doc_read(WALL_FIXTURE);
  assert(d.doc_code == HXF_OK, "refused {d.doc_code}");
  tmp = "wall_rewrite.bin";
  doc_write_edges(tmp, d.doc_cells, d.doc_heights, d.doc_labels, d.doc_edges,
                  d.doc_has_h, d.doc_has_l, d.doc_has_e);
  fa = file(WALL_FIXTURE); fa#format = LittleEndian;
  fb = file(tmp);          fb#format = LittleEndian;
  na = fa#size; nb = fb#size;
  assert(na == nb, "rewrite is {nb} bytes, fixture is {na}");
  for i in 0..na {
    x = fa#read as u8;
    y = fb#read as u8;
    assert(x == y, "byte {i} differs — the edge writer has drifted from the fixture");
  }
  delete(tmp);
}

tests/06-layers.loft

// Named integer layers.  The library holds "a named integer field over the
// chunk"; WHICH layers exist is the consumer's vocabulary — moros carries an
// item and its rotation, another game would carry something else.
use hex_field::*;

fn test_layers_are_independent_and_named() {
  ls = layers_new(0, 0, 4, 4);
  layer_add(ls, "item");
  layer_add(ls, "rotation");
  assert(layer_count(ls) == 2, "expected 2 layers, got {layer_count(ls)}");
  layer_set(ls, "item", 1, 1, 7);
  layer_set(ls, "rotation", 1, 1, 9);
  assert(layer_get(ls, "item", 1, 1) == 7, "item layer lost its value");
  assert(layer_get(ls, "rotation", 1, 1) == 9, "rotation layer lost its value");
  assert(layer_get(ls, "item", 2, 2) == 0, "an unset cell is not zero");
  // an unknown name reads 0 and writes nowhere, rather than aliasing layer 0
  assert(layer_get(ls, "nonesuch", 1, 1) == 0, "an unknown layer aliased a real one");
  layer_set(ls, "nonesuch", 1, 1, 42);
  assert(layer_get(ls, "item", 1, 1) == 7, "writing an unknown layer corrupted a real one");
}

fn test_adding_an_existing_layer_is_a_no_op() {
  ls = layers_new(0, 0, 3, 3);
  a = layer_add(ls, "item");
  layer_set(ls, "item", 0, 0, 5);
  b = layer_add(ls, "item");
  assert(a == b, "re-adding returned a different index");
  assert(layer_count(ls) == 1, "re-adding created a second layer");
  assert(layer_get(ls, "item", 0, 0) == 5, "re-adding cleared the layer");
}

fn withlayers() -> Stencil {
  s  = hexset_chunk(0, 0, 3, 3);
  // Populate the field BEFORE constructing the stencil — stencil_from takes the
  // field by value, so cells added afterwards do not reach it.  An earlier
  // version of this helper did it the other way round and produced an empty
  // stencil that still reported the right layer COUNT, so only the values failed.
  for q in 0..3 { hexset_set(s, q, 0, true); }
  hexset_set(s, 0, 1, true);
  st = stencil_from(s, heights_new(0, 0, 3, 3), labels_new(0, 0, 3, 3), false, false);
  layer_add(st.sn_layers, "item");
  layer_add(st.sn_layers, "rotation");
  layer_set(st.sn_layers, "item", 2, 0, 31);
  layer_set(st.sn_layers, "rotation", 2, 0, 9);
  st
}

fn test_rotation_carries_named_layers() {
  st = withlayers();
  for n in 1..6 {
    rot = stencil_rotate(st, n);
    assert(layer_count(rot.sn_layers) == 2, "turn {n} lost a layer");
    (tq, tr) = cell_rot(2, 0, n);
    assert(layer_get(rot.sn_layers, "item", tq, tr) == 31,
           "turn {n}: item did not follow its cell to {tq},{tr}");
    assert(layer_get(rot.sn_layers, "rotation", tq, tr) == 9,
           "turn {n}: rotation value did not follow its cell");
  }
}

fn test_stamping_carries_named_layers_onto_an_odd_row() {
  st = withlayers();
  dc = hexset_chunk(-6, -6, 24, 24);
  dh = heights_new(-6, -6, 24, 24);
  dl = labels_new(-6, -6, 24, 24);
  dy = layers_new(-6, -6, 24, 24);
  stencil_stamp_layers(dc, dh, dl, dy, st, 5, 3);
  dk = lattice_k(5, 3); dm = lattice_m(5, 3);
  (tq, tr) = lattice_to_cell(lattice_k(2, 0) + dk, lattice_m(2, 0) + dm);
  assert(layer_get(dy, "item", tq, tr) == 31, "item did not land at {tq},{tr}");
  assert(layer_get(dy, "rotation", tq, tr) == 9, "rotation did not land");
}

fn test_named_layers_survive_the_document_format() {
  st = withlayers();
  p = "layers_rt.bin";
  doc_write_all(p, st.sn_cells, st.sn_heights, st.sn_labels, st.sn_edges, st.sn_layers,
                false, false, false);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "refused {d.doc_code}");
  assert(layer_count(d.doc_layers) == 2, "read back {layer_count(d.doc_layers)} layers, want 2");
  assert(layer_index(d.doc_layers, "item") >= 0, "the layer NAME was lost");
  assert(layer_index(d.doc_layers, "rotation") >= 0, "the second layer name was lost");
  assert(layer_get(d.doc_layers, "item", 2, 0) == 31, "item value lost in the round-trip");
  assert(layer_get(d.doc_layers, "rotation", 2, 0) == 9, "rotation value lost");
  delete(p);
}

// A LAYR whose declared length disagrees with its name length is corrupt, and
// must be refused rather than read short.
fn test_refuses_a_layer_section_of_the_wrong_length() {
  p = "layers_badlen.bin";
  {
    f = file(p); f#format = LittleEndian;
    f += (HXF_MAGIC as i32); f += (HXF_SCHEMA as i32);
    f += (0 as i32); f += (0 as i32);
    f += (2 as i32); f += (2 as i32);
    f += (2 as i32); f += (0 as i32);
    f += (TAG_OCCU as i32); f += (4 as i32);
    for _ in 0..4 { f += ((1 as u8?) ?? 0); }
    f += (TAG_LAYR as i32);
    f += (20 as i32);          // claims 20; a 4-byte name + 4 cells needs 4+4+16 = 24
    f += (4 as i32); f += "item";
    for _ in 0..4 { f += (0 as i32); }
  }
  d = doc_read(p);
  assert(d.doc_code == HXF_BAD_SECTION, "a short LAYR section gave {d.doc_code}");
  delete(p);
}

// The old fixtures use TAG_LABL and no layers; they must still load untouched.
fn test_the_committed_fixtures_still_load_without_layers() {
  d = doc_read("fixtures/canonical.hxf");
  assert(d.doc_code == HXF_OK, "canonical.hxf broke: {d.doc_code}");
  assert(layer_count(d.doc_layers) == 0, "it grew {layer_count(d.doc_layers)} layers from nowhere");
  assert(d.doc_has_l, "its unnamed label layer was lost");
}

tests/07-worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `hex_field` (@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: @HXL-0NN`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling.  These are
// teaching examples, distinct from the conformance suite in `01-hex-field.loft`.
//
// WHAT THIS PACKAGE ASKS OF A CALLER.  Everything here is EXACT: a cell is
// (q,r), a corner is a lattice point (k,m), an edge is a pair of cells, and an
// area is an integer.  There is no epsilon anywhere and none is wanted.  Every
// trap below is one of three things:
//
//   a UNIT      the lattice's world unit is the hex CIRCUMRADIUS, so a hex STEP
//               is sqrt(3) of them — and one of the two disc routines takes each
//   a PREMISE   a rule that is true of the shape the author had in mind and not
//               of the shapes this package is built to hold (a bounded chunk
//               holds several forms; a document arrives from somewhere else)
//   a DOMAIN    a slot narrower than the value offered to it, where the value
//               out of range means something rather than failing
//
// The fixtures are written BY HAND and are as small as the claim allows: one
// hex, two hexes, a ring of six.  A shrunken real thing would not be readable.
use hex_field::*;

// Names a scratch file AND clears any leftover from a run that died before its own
// cleanup.  `file(path)` opens an existing file to APPEND, so without this a crashed
// run hands the next one a header with a header already in front of it — and the
// failure then lands in a different test from the one that caused it.
fn tmp(name: text) -> text {
  p = "_tmp_hxl_{name}.bin";
  delete(p);
  p
}

const SQRT3 = 1.7320508075688772;

// One cell, on its own, in the smallest chunk that can hold it.
fn one_hex() -> HexSet {
  s = hexset_chunk(0, 0, 1, 1);
  hexset_set(s, 0, 0, true);
  s
}

// A ring of six around an empty centre — the smallest form with a HOLE.
fn donut() -> HexSet {
  s = hexset_chunk(-2, -2, 5, 5);
  hexdisk_into(s, 0, 0, 1);
  hexset_set(s, 0, 0, false);
  s
}

// @HXL-001 — ONE HEX IS SIX NAMED LATTICE POINTS, and you can write them down.
//
// This is the whole representation in one value, which is why it is the first
// example rather than a footnote.  A cell's centre is (2q + (r&1), 3r) and its
// six corners are (0,+-2) and (+-1,+-1) away from it, so the cell at the origin
// traces to exactly the points asserted below — no scaling, no rounding, nothing
// derived at run time.  The order is the one `corner_k`/`corner_m` fixes, rotated
// to start at the loop's lexicographically smallest vertex, which is what makes
// two independent implementations produce the SAME array rather than the same
// shape.
fn test_one_hex_is_six_lattice_points() {
  v = trace(one_hex());
  assert(v.vm_loops == 1, "one cell is one loop, got {v.vm_loops}");
  assert(len(v.vm_k) == 6, "and six vertices, got {len(v.vm_k)}");

  ks: vector<integer> = [-1,  0,  1, 1, 0, -1];
  ms: vector<integer> = [-1, -2, -1, 1, 2,  1];
  for i in 0..6 {
    assert((v.vm_k[i] ?? 99) == (ks[i] ?? 0) && (v.vm_m[i] ?? 99) == (ms[i] ?? 0),
           "vertex {i} is ({v.vm_k[i] ?? 99},{v.vm_m[i] ?? 99}), want ({ks[i] ?? 0},{ms[i] ?? 0})");
  }
  // The canonical start is the SMALLEST vertex, not the one iteration happened to reach.
  assert((v.vm_k[0] ?? 0) == -1 && (v.vm_m[0] ?? 0) == -1, "the loop starts at (-1,-1)");
}

// @HXL-002 — THE AREA ROUND-TRIP IS AN INTEGER IDENTITY, AND A HOLE IS A
// NEGATIVE ONE.  A single hex shoelaces to exactly 12, so a form of n hexes
// shoelaces to exactly 12n whatever its shape — and a hole subtracts.  The ring
// of six below is 6 cells: its outer loop encloses the 7-cell disc (84) and its
// hole gives back one cell (-12), which is 72 and nothing else.  Because both
// sides are integers, "cell count and traced outline can never disagree" is a
// statement about equality, not about tolerance.
fn test_the_hole_is_a_negative_hex_and_the_areas_are_exact() {
  s = donut();
  assert(hexset_count(s) == 6, "a ring of six, got {hexset_count(s)}");
  v = trace(s);
  assert(v.vm_loops == 2, "an outer loop and a hole, got {v.vm_loops}");

  outer = -1;
  hole  = -1;
  for i in 0..2 {
    if shoelace2(v, i) > 0 { outer = i; }
    if shoelace2(v, i) < 0 { hole  = i; }
  }
  assert(outer >= 0 && hole >= 0, "one of each");
  assert(shoelace2(v, outer) == 84, "the outer loop encloses seven hexes: {shoelace2(v, outer)}");
  assert(shoelace2(v, hole) == 0 - 12, "the hole gives one back: {shoelace2(v, hole)}");
  assert(shoelace_total(v) == 72, "84 - 12 = 72");
  assert(shoelace_total(v) == 12 * hexset_count(s), "and 72 is 12 x 6");

  // A single hex is the unit of that identity.
  assert(shoelace_total(trace(one_hex())) == 12, "one hex is exactly 12");
}

// @HXL-003 — A CHUNK MAY HOLD MORE THAN ONE FORM, AND THE VALIDATOR MUST SAY YES.
//
// This package traces BOUNDED CHUNKS on purpose — "the same code serves a 32x32
// world chunk and a one-off tower window" — so two buildings in one window, or one
// building the chunk edge cuts in half, is the ordinary case rather than an error.
// Every such map used to be refused with code 5, "wrong number of outer loops",
// while `trace` had produced it correctly and the areas summed exactly: two
// disjoint hexes are two loops of 12 each, and 24 is 24.
//
// The rule that refused them is a property of a CONNECTED form, and connectedness
// is not something a chunk gets to assume.  So code 5 now means *no* outer loop,
// and a caller who knows its form is one piece keeps the stronger property by
// asking for it: `outline_count(v) == 1`.
fn test_a_chunk_may_hold_two_forms() {
  s = hexset_chunk(0, 0, 9, 9);
  hexset_set(s, 1, 1, true);
  hexset_set(s, 7, 7, true);              // far enough apart to share no edge
  v = trace(s);
  assert(v.vm_loops == 2, "two components, two loops");
  assert(shoelace_total(v) == 24, "and 12 per hex: {shoelace_total(v)}");
  assert(shoelace_total(v) == 12 * hexset_count(s), "the round-trip holds across components");
  assert(validate(v, hexset_count(s)) == 0, "a two-form chunk is VALID, got {validate(v, hexset_count(s))}");
  assert(outline_count(v) == 2, "and it reports two outlines, got {outline_count(v)}");

  // One form cut in half by the chunk edge — the same shape, arrived at the other way.
  c = hexset_chunk(0, 0, 7, 7);
  for r in 0..7 { hexset_set(c, 0, r, true); hexset_set(c, 6, r, true); }
  vc = trace(c);
  assert(validate(vc, hexset_count(c)) == 0, "a clipped form is VALID, got {validate(vc, hexset_count(c))}");
  assert(outline_count(vc) == 2, "in two pieces");

  // And the single-piece caller still gets the strong answer.
  assert(outline_count(trace(donut())) == 1, "a donut is ONE outline plus a hole");
}

// @HXL-004 — THE OUTLINE MUST NOT TOUCH ITSELF, AND THE CHEAP CHECKS CANNOT SEE IT.
//
// `trace` can never emit a pinch: a hex vertex touches three MUTUALLY ADJACENT
// cells, so a boundary cannot pass through the same point twice.  That is exactly
// why the check belongs in `validate` rather than in the tracer — the maps that
// arrive from a file, or from a consumer's own builder, are the ones that can.
//
// The map below is two hexes emitted as two circuits joined at the corners they
// share, which is what a naive per-cell tracer produces.  Every other check in
// `validate` passes it: twelve segments, each a legal hex edge; a shoelace of
// exactly 12 x 2; one positive loop.  It is still unusable — the shared edge is
// traversed in both directions, a zero-width slit no triangulator can resolve.
fn test_a_figure_eight_passes_every_other_check() {
  fk: vector<integer> = [1, 0, -1, -1,  0,  1, 1,  1,  2,  3, 3, 2];
  fm: vector<integer> = [1, 2,  1, -1, -2, -1, 1, -1, -2, -1, 1, 2];
  st: vector<integer> = [0, 12];
  f8 = VecMap { vm_k: fk, vm_m: fm, vm_start: st, vm_loops: 1 };

  assert(shoelace_total(f8) == 24, "the area is right: {shoelace_total(f8)}");
  assert(shoelace_total(f8) == 12 * 2, "which is 12 per hex, for two hexes");
  assert(outline_count(f8) == 1, "and there is one positive loop");
  assert(validate(f8, 2) == 6, "yet it is refused as self-touching, got {validate(f8, 2)}");

  // The honest trace of the same two cells: ONE loop of ten vertices, no repeats.
  two = hexset_chunk(0, 0, 2, 1);
  hexset_set(two, 0, 0, true);
  hexset_set(two, 1, 0, true);
  vt = trace(two);
  assert(vt.vm_loops == 1, "two adjacent hexes are one loop");
  assert(len(vt.vm_k) == 10, "of ten vertices — the two shared corners are visited once each");
  assert(shoelace_total(vt) == 24, "same area");
  assert(validate(vt, 2) == 0, "and it validates");
}

// @HXL-005 — `radius` IS NOT IN HEX STEPS, AND THE FACTOR IS sqrt(3).
//
// `form_hexdisk(w, n)` counts hex STEPS; `form_circle(w, radius)` measures LATTICE
// WORLD UNITS, the ones `x = k*sqrt(3)/2, y = m/2` defines — in which a hex has
// circumradius 1 and two neighbouring centres are sqrt(3) apart.  Both are
// dimensionless, and they are not the same dimension.
//
// So the same `3` asks for two different discs, and neither call says so at the
// call site.  Passing hex steps to `form_circle` gives you a disc 1.73x too small;
// the conversion is one multiplication and it is exact.
fn test_a_disc_radius_and_a_hex_distance_are_different_units() {
  assert(hexset_count(form_hexdisk(8, 1)) ==  7, "1 hex step out is 7 cells");
  assert(hexset_count(form_hexdisk(8, 2)) == 19, "2 out is 19");
  assert(hexset_count(form_hexdisk(8, 3)) == 37, "3 out is 37");

  assert(hexset_count(form_circle(8, 1.0)) ==  1, "radius 1.0 does not even reach a neighbour");
  assert(hexset_count(form_circle(8, 2.0)) ==  7, "radius 2.0 reaches the first ring and no further");
  assert(hexset_count(form_circle(8, 3.0)) == 13, "radius 3.0 is 13 cells, NOT 37");

  // The conversion, and the proof that it is the whole difference.
  assert(hexset_count(form_circle(8, 1.0 * SQRT3)) ==  7, "sqrt(3) world units is one hex step");
  assert(hexset_count(form_circle(8, 2.0 * SQRT3)) == 19, "and two steps is 19");
  assert(hexset_count(form_circle(8, 3.0 * SQRT3)) == 37, "and three is 37 — the same disc");

  // `form_octagon`'s apothem is in the same world units.
  assert(hexset_count(form_octagon(8, 3.0)) == 13, "an apothem of 3.0 is 13 cells");
  assert(hexset_count(form_octagon(8, 3.0 * SQRT3)) == 37, "3 hex steps of apothem is 37");
}

// @HXL-006 — `hex_at` IS THE EXACT INVERSE OF THE CENTRE MAP, INCLUDING ON
// NEGATIVE ODD ROWS.  It is the one routine here that leaves the integers — a
// world point is a float — so it is the one place a rounding convention can drift
// without anything else noticing.  The dangerous half is the odd-row shift: `r & 1`
// and `r % 2` agree for every non-negative row and differ for every negative odd
// one, where loft's `%` answers -1, and a copy of this map that used `%` put every
// such row a full hex step west for a year with a green suite.
//
// So the sweep below covers both parities AND both signs, and asserts the identity
// rather than a sample of it.
fn test_hex_at_inverts_the_centre_map_on_both_signs() {
  bad = 0;
  neg_odd = 0;
  for r in 0 - 6..7 {
    for q in 0 - 6..7 {
      px = (lattice_k(q, r) as float) * 0.8660254037844386;
      py = (lattice_m(q, r) as float) * 0.5;
      (aq, ar) = hex_at(px, py);
      if aq != q || ar != r { bad = bad + 1; }
      if r < 0 && (r & 1) == 1 { neg_odd = neg_odd + 1; }
    }
  }
  assert(neg_odd == 39, "the sweep must actually contain negative odd rows, got {neg_odd}");
  assert(bad == 0, "{bad} of 169 cells did not survive the round trip");
}

// @HXL-007 — SIX TURNS ARE THE IDENTITY, ON EVERY SLOT A STENCIL CARRIES.
//
// A 60-degree turn is the integer map k' = (k-m)/2, m' = (3k+m)/2, and both halves
// divide evenly because centres satisfy k == m (mod 2).  So a rotation resamples
// nothing and six of them come back exactly — that is the property the exact
// lattice exists to give, and `edgeset_equal` (which compares BOTH edge slots, and
// the halo) is the comparator strong enough to state it.
//
// It has to be stated on both slots because it was true on only one: the walk
// carried each edge's MATERIAL and dropped its SURFACE, so a stencil holding
// derived geometry came back with the cells identical, the materials identical and
// the geometry gone — under a comment promising a stencil never loses data.
//
// The extent is the other half of the answer.  A rotation re-fits the extent to
// what the rotated cells need, so it is the CONTENT that returns, not the box: a
// stencil authored in a roomy window comes back trimmed to its own occupied cells.
fn test_six_turns_return_every_slot() {
  s = hexset_chunk(0, 0, 2, 1);
  hexset_set(s, 0, 0, true);
  hexset_set(s, 1, 0, true);
  eg = edgeset_new(0, 0, 2, 1);
  edge_set_both(eg, 0, 0, 1, 0, 7, 3);          // surface 7, material 3, one wall
  st = stencil_with_edges(s, heights_new(0, 0, 2, 1), labels_new(0, 0, 2, 1), eg, false, false);
  assert(edgeset_surf_count(st.sn_edges) == 1, "one surface to lose");
  assert(edgeset_count(st.sn_edges) == 1, "and one material");

  r6 = stencil_rotate(st, 6);
  assert(hexset_count(r6.sn_cells) == 2, "the cells come back");
  assert(edge_mat(r6.sn_edges, 0, 0, 1, 0) == 3, "the material comes back");
  assert(edge_surf(r6.sn_edges, 0, 0, 1, 0) == 7, "and so does the surface");
  assert(edgeset_surf_count(r6.sn_edges) == 1, "counted, not just stored");
  assert(edgeset_equal(st.sn_edges, r6.sn_edges), "six turns are the identity on the whole layer");

  // Two reflections likewise.
  m2 = stencil_mirror(stencil_mirror(st));
  assert(edgeset_equal(st.sn_edges, m2.sn_edges), "and so are two reflections");

  // One turn is NOT the identity — the gate above would pass on a rotation that did nothing.
  r1 = stencil_rotate(st, 1);
  assert(!edgeset_equal(st.sn_edges, r1.sn_edges), "one turn must move the wall");
  assert(edgeset_count(r1.sn_edges) == 1, "without losing it");

  // The extent follows the CELLS, so it is content that returns and not the box.
  roomy = hexset_chunk(0 - 3, 0 - 3, 7, 7);
  hexset_set(roomy, 0, 0, true);
  wide = stencil_from(roomy, heights_new(0 - 3, 0 - 3, 7, 7), labels_new(0 - 3, 0 - 3, 7, 7),
                      false, false);
  back = stencil_rotate(wide, 6);
  assert(hexset_count(back.sn_cells) == 1, "the one cell survives");
  assert(hexset_w(back.sn_cells) == 1 && hexset_h(back.sn_cells) == 1,
         "and the 7x7 window is trimmed to 1x1, not restored");
}

// @HXL-008 — ONE WALL, TWO NAMES, ONE SLOT — AND THE HALO IS WHY A RIM WALL EXISTS.
//
// An edge's identity is the DOUBLED MIDPOINT of the two cell centres, which is
// symmetric in the two cells: whichever side names it, it is the same edge and the
// same stored slot.  That is what makes a wall a property of the seam rather than
// of a cell, so two neighbours cannot disagree about it.
//
// The halo is the other half.  Exactly one of an edge's two cells owns the slot, so
// for a wall on the chunk RIM the owner is a cell OUTSIDE the extent — which is why
// the layer is (w+2) x (h+2) and why a loop over the extent alone silently drops
// every rim wall.
fn test_an_edge_has_one_identity_and_the_rim_is_inside_the_halo() {
  (ka, ma) = edge_key(0, 0, 1, 0);
  (kb, mb) = edge_key(1, 0, 0, 0);
  assert(ka == 2 && ma == 0, "the doubled midpoint of (0,0) and (1,0) is (2,0), got ({ka},{ma})");
  assert(ka == kb && ma == mb, "and naming it the other way round gives the same key");

  e = edgeset_new(0, 0, 3, 3);
  edge_set_mat(e, 0, 0, 1, 0, 4);
  assert(edge_mat(e, 1, 0, 0, 0) == 4, "written from one side, read from the other");
  assert(edgeset_count(e) == 1, "and stored ONCE, not once per side");

  // A wall on the rim: its owner is outside the extent and it is still stored.
  edge_set_mat(e, 0 - 1, 0, 0, 0, 5);
  assert(edge_mat(e, 0, 0, 0 - 1, 0) == 5, "a rim wall lives in the halo");
  assert(edgeset_count_all(e) == 2, "and `count_all` sees it, got {edgeset_count_all(e)}");
  assert(edgeset_count(e) == 1, "while `count` walks in-chunk cells only, got {edgeset_count(e)}");
}

// @HXL-009 — A MATERIAL PAST THE SLOT IS REFUSED, BECAUSE NARROWING IT WOULD MEAN
// "NO WALL".
//
// The material slot is one byte and the narrowing cast is CHECKED, so a value it
// cannot hold answers null rather than its low byte — and the `?? 0` behind such a
// cast writes 0, which in this layer is not a fallback value but the ABSENCE of a
// wall.  `edge_set_mat(e, ..., 300)` therefore used to delete the wall it was
// setting, and read back as open ground.
//
// A label is the deliberate contrast: same package, same per-cell shape, i32 slot,
// so a label of 301 is a label of 301.  The two domains differ and only one of them
// has a value that means "nothing here".
fn test_a_material_past_the_slot_is_refused_not_narrowed() {
  e = edgeset_new(0, 0, 2, 1);
  edge_set_mat(e, 0, 0, 1, 0, EDGE_MAT_MAX);
  assert(edge_mat(e, 0, 0, 1, 0) == 255, "255 is a material");
  assert(edgeset_refused(e) == 0, "and nothing was refused");

  edge_set_mat(e, 0, 0, 1, 0, EDGE_MAT_MAX + 1);
  assert(edge_mat(e, 0, 0, 1, 0) == 255, "256 leaves the wall standing, got {edge_mat(e, 0, 0, 1, 0)}");
  edge_set_mat(e, 0, 0, 1, 0, 0 - 1);
  assert(edge_mat(e, 0, 0, 1, 0) == 255, "and so does a negative id");
  assert(edgeset_refused(e) == 2, "both refusals are counted, got {edgeset_refused(e)}");
  assert(edgeset_count(e) == 1, "and the wall is still there");

  // 0 is in the domain: it is how a wall is REMOVED, not an out-of-range value.
  edge_set_mat(e, 0, 0, 1, 0, 0);
  assert(edgeset_count(e) == 0, "0 opens the edge");
  assert(edgeset_refused(e) == 2, "and is not a refusal");

  // The contrast: a label is i32, so a value past a byte is just a value.
  lb = labels_new(0, 0, 2, 1);
  label_set(lb, 0, 0, 301);
  assert(label_get(lb, 0, 0) == 301, "a label of 301 is a label of 301");
}

// @HXL-010 — THE READER REFUSES; IT NEVER PARTLY PARSES.  A half-read field is
// worse than no field, so every fault below returns a CODE and an untouched
// document rather than whatever it managed to read.
//
// Two of these are the format's design rather than its defence.  An unknown section
// is SKIPPED by its declared length, which is why a field written by a newer
// consumer still loads in an older one — a flags word could not do that, because an
// unknown bit means an array of unknown length.  And the section lengths are checked
// against w*h, which is how truncation is detected at all.
fn test_the_reader_refuses_rather_than_partly_parsing() {
  bad_magic = tmp("magic");
  raw_header(bad_magic, 0x99999999, HXF_SCHEMA, 0, 0, 2, 2, 0, 0);
  assert(doc_read(bad_magic).doc_code == HXF_BAD_MAGIC, "a foreign file is refused");
  delete(bad_magic);

  bad_schema = tmp("schema");
  raw_header(bad_schema, HXF_MAGIC, 99, 0, 0, 2, 2, 0, 0);
  assert(doc_read(bad_schema).doc_code == HXF_BAD_SCHEMA, "a future schema is refused, not guessed");
  delete(bad_schema);

  bad_rsv = tmp("rsv");
  raw_header(bad_rsv, HXF_MAGIC, HXF_SCHEMA, 0, 0, 2, 2, 0, 7);
  assert(doc_read(bad_rsv).doc_code == HXF_BAD_RESERVED, "the reserved word must be 0");
  delete(bad_rsv);

  // A section whose declared length disagrees with the extent.
  wrong_len = tmp("seclen");
  f = file(wrong_len);
  f#format = LittleEndian;
  put_header(f, 0, 0, 2, 2, 1);
  f += (TAG_OCCU as i32);
  f += (3 as i32);                       // 3 bytes for a 4-cell extent
  for _ in 0..3 { f += ((0 as u8?) ?? 0); }
  assert(doc_read(wrong_len).doc_code == HXF_BAD_SECTION, "a section length must match w*h");
  delete(wrong_len);

  // A section that runs off the end of the file.
  cut = tmp("cut");
  g = file(cut);
  g#format = LittleEndian;
  put_header(g, 0, 0, 2, 2, 1);
  g += (TAG_OCCU as i32);
  g += (4 as i32);                       // declares 4, supplies 2
  for _ in 0..2 { g += ((0 as u8?) ?? 0); }
  assert(doc_read(cut).doc_code == HXF_TRUNCATED, "a short read is a refusal");
  delete(cut);

  // Occupancy is mandatory: a document without it is not a field.
  no_occu = tmp("noocc");
  h = file(no_occu);
  h#format = LittleEndian;
  put_header(h, 0, 0, 2, 2, 1);
  h += (0x5A5A5A5A as i32);              // a tag this reader does not know
  h += (4 as i32);
  for _ in 0..4 { h += ((1 as u8?) ?? 0); }
  d = doc_read(no_occu);
  assert(d.doc_code == HXF_MISSING_OCCU, "no OCCU section is a refusal");
  delete(no_occu);

  // FORWARD COMPATIBILITY: the same unknown section, skipped, beside a real one.
  fwd = tmp("fwd");
  k = file(fwd);
  k#format = LittleEndian;
  put_header(k, 0, 0, 2, 2, 2);
  k += (0x5A5A5A5A as i32);
  k += (4 as i32);
  for _ in 0..4 { k += ((1 as u8?) ?? 0); }
  k += (TAG_OCCU as i32);
  k += (4 as i32);
  cells: vector<integer> = [1, 0, 0, 1];
  for i in 0..4 { k += (((cells[i] ?? 0) as u8?) ?? 0); }
  fd = doc_read(fwd);
  assert(fd.doc_code == HXF_OK, "a newer writer's file still loads, got {fd.doc_code}");
  assert(fd.doc_skipped == 1, "and says what it skipped, got {fd.doc_skipped}");
  assert(hexset_count(fd.doc_cells) == 2, "while the section it DID know was read");
  assert(hexset_get(fd.doc_cells, 0, 0) && hexset_get(fd.doc_cells, 1, 1),
         "at the two cells the bytes name");
  delete(fwd);
}

// @HXL-011 — THE EXTENT IS CHECKED AGAINST THE FILE, NOT TAKEN ON TRUST.
//
// `w` and `h` are the two header fields that decide how much this reader ALLOCATES,
// and they were the only part of the header nothing verified: a 32-byte file
// claiming 4000 x 4000 built sixteen million cells — plus heights, labels, edges and
// layers over the same extent — and only then reported a missing section.  The
// format's own doctrine already says why that is wrong: explicit dimensions exist so
// truncation can be detected, and a dimension nobody checks detects nothing.
//
// The bound needs no second pass.  Occupancy is mandatory and costs one byte per
// cell, so a file can never describe more cells than it has bytes.
fn test_the_header_cannot_ask_for_more_than_the_file_holds() {
  huge = tmp("huge");
  raw_header(huge, HXF_MAGIC, HXF_SCHEMA, 0, 0, 4000, 4000, 0, 0);
  hf = file(huge);
  assert(hf#size == 32, "the whole file is a header: {hf#size} bytes");
  assert(doc_read(huge).doc_code == HXF_BAD_EXTENT,
         "a 32-byte file cannot hold 16 million cells");
  delete(huge);

  neg = tmp("neg");
  raw_header(neg, HXF_MAGIC, HXF_SCHEMA, 0, 0, 0 - 3, 4, 0, 0);
  assert(doc_read(neg).doc_code == HXF_BAD_EXTENT, "and an extent cannot be negative");
  delete(neg);

  // The bound is not so tight that it refuses real files: a written document loads.
  ok = tmp("ok");
  s = hexset_chunk(2, 3, 5, 4);
  for q in 0..5 { for r in 0..4 { if (q + r) % 2 == 0 { hexset_set(s, 2 + q, 3 + r, true); } } }
  doc_write(ok, s, heights_new(2, 3, 5, 4), labels_new(2, 3, 5, 4), false, false);
  d = doc_read(ok);
  assert(d.doc_code == HXF_OK, "a real document still loads, got {d.doc_code}");
  assert(hexset_count(d.doc_cells) == hexset_count(s), "with its cells");
  delete(ok);
}

// @HXL-012 — THE WALL LAYER IS WRITTEN BY WHICH EDGE IT IS, NOT BY WHERE IT SITS.
//
// The EDGE section's LENGTH comes from the field's extent, `(w+2)*(h+2)*3`.  Copying
// the source layer's vector into it therefore assumes the two agree — and when they
// do not, every material lands at a slot that means a DIFFERENT edge.  The file
// loads cleanly, the wall count is right, and the walls are somewhere else.
//
// Keying each byte by the two cells its edge lies between removes the assumption:
// the bytes are identical whenever the extents agree (the committed `walled.hxf`
// fixture is that gate), and correct when they do not.
fn test_a_wall_keeps_its_identity_across_a_layer_of_another_extent() {
  s = hexset_chunk(0, 0, 3, 2);
  for q in 0..3 { for r in 0..2 { hexset_set(s, q, r, true); } }

  // A layer built at ANOTHER extent — 2x1, so its row stride is 4 and the field's is 5.
  narrow = edgeset_new(0, 0, 2, 1);
  edge_set_mat(narrow, 0, 0, 1, 0, 9);
  assert(edgeset_w(narrow) != hexset_w(s), "the extents really do differ");

  p = tmp("stride");
  doc_write_edges(p, s, heights_new(0, 0, 3, 2), labels_new(0, 0, 3, 2), narrow, false, false, true);
  d = doc_read(p);
  assert(d.doc_code == HXF_OK, "the file loads: {d.doc_code}");
  assert(d.doc_has_e, "with its wall layer");
  assert(edge_mat(d.doc_edges, 0, 0, 1, 0) == 9,
         "and the wall is on the edge it was set on, got {edge_mat(d.doc_edges, 0, 0, 1, 0)}");
  assert(edgeset_count_all(d.doc_edges) == 1, "exactly one wall, not one somewhere else");
  delete(p);

  // The same wall through a layer that DOES match — the two must agree.
  same = edgeset_new(0, 0, 3, 2);
  edge_set_mat(same, 0, 0, 1, 0, 9);
  pmatch = tmp("match");
  doc_write_edges(pmatch, s, heights_new(0, 0, 3, 2), labels_new(0, 0, 3, 2), same, false, false, true);
  e = doc_read(pmatch);
  assert(edge_mat(e.doc_edges, 0, 0, 1, 0) == 9, "the matching layer round-trips too");
  assert(edgeset_equal(d.doc_edges, e.doc_edges), "and both files describe the SAME layer");
  delete(pmatch);
}

// ── raw-file helpers, so the reader can meet a header nobody wrote ──────────

fn put_header(f: File, q0: integer, r0: integer, w: integer, h: integer, nsec: integer) {
  f += (HXF_MAGIC as i32);
  f += (HXF_SCHEMA as i32);
  f += (q0 as i32);
  f += (r0 as i32);
  f += (w as i32);
  f += (h as i32);
  f += (nsec as i32);
  f += (0 as i32);
}

fn raw_header(path: text, magic: integer, schema: integer, q0: integer, r0: integer,
              w: integer, h: integer, nsec: integer, rsv: integer) {
  f = file(path);
  f#format = LittleEndian;
  f += (magic as i32);
  f += (schema as i32);
  f += (q0 as i32);
  f += (r0 as i32);
  f += (w as i32);
  f += (h as i32);
  f += (nsec as i32);
  f += (rsv as i32);
}

tests/08-hex-grid-parity.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// `hex_field` is a leaf: it does not depend on `hex_grid`, so `lattice_k` / `lattice_m` and
// `nb_q` / `nb_r` restate `hex_grid`'s convention (pointy-top, odd-r offset).  Two copies of a
// convention drift silently — an odd-row shift written `r % 2` instead of `r & 1` agrees for
// every non-negative row and is a full hex step off on every negative odd one.  This test is
// what keeps the copies equal: both parities and both signs, every cell and every neighbour.
use hex_field::*;
use hex_grid::*;

fn test_the_lattice_map_is_hex_grids_world_position() {
  for r in -5..5 { for q in -5..5 {
    (x, y) = hex_grid::hex_to_px(q, r);
    assert(abs((lattice_k(q, r) as float) * 0.8660254037844386 - x) < 0.000000001,
           "x of ({q}, {r}): lattice {lattice_k(q, r)} against hex_grid {x}");
    assert(abs((lattice_m(q, r) as float) * 0.5 - y) < 0.000000001,
           "y of ({q}, {r}): lattice {lattice_m(q, r)} against hex_grid {y}");
  } }
}

fn test_every_neighbour_is_one_of_hex_grids() {
  for r in -5..5 { for q in -5..5 {
    for d in 0..6 {
      nq = nb_q(q, r, d);
      nr = nb_r(q, r, d);
      found = false;
      for g in 0..6 {
        (gq, gr) = hex_grid::hex_neighbor(q, r, g);
        if gq == nq && gr == nr { found = true; }
      }
      assert(found, "neighbour {d} of ({q}, {r}) is ({nq}, {nr}), not one of hex_grid's six");
    }
  } }
}