gridmesh source

← gridmesh · API reference · version 0.2.6

6 file(s), 988 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
CellRef struct18—29 30 32 70 77 198 … (7 total)
enc_coord fn22—32 71 177 251 263 294 … (11 total)
build_index fn29—219
axial_dq fn39—55
axial_dr fn45—56 63
step_x fn53GRM-00580 357 151
step_y fn62—80 357 152
idx_at fn70GRM-00580 357 30 31 32 75 … (19 total)
nbr_count_idx fn77—159 160
SegMesh struct95—106 107 113 125 131
seg_mesh_new fn106—14 26 41 65
emit_segment fn113—27 44 66
seg_len fn125—15 28 47
seg_mesh_append fn131—none in this package
ChunkKey struct154—173 177 201 251 334 379 … (7 total)
chunk_of fn173—225 264 298 320 359 14 … (12 total)
ChunkField struct194—216 217 239 260 277 293 … (16 total)
field_new fn215—27 41 56 70 93 116 … (14 total)
field_add_cell fn260—305 72 119 44 110
field_mark_dirty fn277—42 44 46 57 62 66 … (8 total)
field_remove_cell fn293GRM-00395 108 117 104
clear_dirty fn311GRM-00159 71 94 74 57
dirty_count fn316—33 43 45 47 58 60 … (13 total)
chunk_is_dirty fn319—48 49 61 46 127
ChunkInput struct333—375 376 379 391 392 397 … (7 total)
all_inputs fn375GRM-00224 38 57 72
collect_dirty_inputs fn391GRM-00167 75 49 59
GroupKey struct416—420 427 430 445 462
GroupInput struct420—435 443 445 461 466 477
group_of fn427—438 464 29 30 31 32 … (7 total)
all_groups fn435—39 52 60
collect_dirty_groups fn461GRM-00472 129

src/gridmesh.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// gridmesh — shared primitives for chunk-local, bounded-extent grid→mesh
// generation: world-building rules (wall placement, edge rounding, surface
// generation) that run as LOCAL routines over a limited set of chunks and
// produce meshes that do not extend much outside their chunk.  It holds the
// reusable pipeline — spatial-index neighbour queries, bounded mesh
// accumulation keyed by owning cell, dirty-region / per-chunk rebuild.  A
// TOOLKIT of primitives, not a framework: each consumer supplies its own
// per-cell rule and calls these.
//
// Coordinates are hex OFFSET coordinates: whole rows, every odd row shifted
// half a cell (`step_x` / `step_y` carry the parity rule).

// A coord→cell-index reference, stored in the spatial-index hash keyed on
// the encoded coordinate `ck`.
pub struct CellRef { pub ck: integer, pub ix: integer }

// Encode an offset coord into one integer key.  The hex world is small;
// ±2^20 per axis covers it with room to spare and stays within i64.
pub fn enc_coord(x: integer, y: integer) -> integer {
  (x + 1048576) * 2097152 + (y + 1048576)
}

// Build the coord→cell-index spatial index for a placement-ordered cell
// list given as parallel (xs, ys).  O(N), built once and reusable across
// neighbour/on-axis queries.
pub fn build_index(xs: const vector<integer>, ys: const vector<integer>) -> hash<CellRef[ck]> {
  cidx: hash<CellRef[ck]> = [];
  for bi_i in 0..len(xs) {
    cidx += [CellRef{ck: enc_coord(xs[bi_i], ys[bi_i]), ix: bi_i}];
  }
  cidx
}

// Axial unit-direction components for hex axis d (0..5), via 60°·d.
// Branch form avoids per-call vector-literal allocation.
pub fn axial_dq(d: integer) -> integer {
  if d == 0 { return 1; } if d == 1 { return 0; } if d == 2 { return 0 - 1; }
  if d == 3 { return 0 - 1; } if d == 4 { return 0; } 1
}
// The r half of that same direction.  Read as a PAIR with `axial_dq` — one
// direction, two components, and neither is meaningful alone.
pub fn axial_dr(d: integer) -> integer {
  if d == 0 { return 0; } if d == 1 { return 1; } if d == 2 { return 1; }
  if d == 3 { return 0; } if d == 4 { return 0 - 1; } 0 - 1
}

// Offset coord `k` steps from (x, y) along hex axis `d`.
// Example: @GRM-005 — step_x/step_y are a PAIR on the same (x, y); row parity is
// why step_x needs the y argument.
pub fn step_x(x: integer, y: integer, d: integer, k: integer) -> integer {
  par = y - (y / 2) * 2; if par < 0 { par = par + 2; }
  q2 = (x - (y - par) / 2) + k * axial_dq(d);
  r2 = y + k * axial_dr(d);
  p2 = r2 - (r2 / 2) * 2; if p2 < 0 { p2 = p2 + 2; }
  q2 + (r2 - p2) / 2
}
// The y half of the pair.  Row parity does not reach it — a step along an axis
// moves whole rows — which is why this ignores x where `step_x` cannot.
pub fn step_y(_x: integer, y: integer, d: integer, k: integer) -> integer {
  y + k * axial_dr(d)
}

// snap index of the cell at (x, y), or -1 if none.  (`cidx` not `index`
// — `index` is a reserved keyed-collection-type keyword in loft.)
// Example: @GRM-005 — a miss is the -1 sentinel, so guard with `>= 0`, not a
// null test.
pub fn idx_at(cidx: const hash<CellRef[ck]>, x: integer, y: integer) -> integer {
  r = cidx[enc_coord(x, y)];
  if r == null { return 0 - 1; }
  r.ix
}

// Index-backed filled-neighbour count (distance 1) — the 6 axis-1 cells.
pub fn nbr_count_idx(cidx: const hash<CellRef[ck]>, cx: integer, cy: integer) -> integer {
  c = 0;
  for d in 0..6 {
    if idx_at(cidx, step_x(cx, cy, d, 1), step_y(cx, cy, d, 1)) >= 0 { c = c + 1; }
  }
  c
}

// ── B1: SegMesh accumulator ──────────────────────────────────────────
//
// Struct-of-arrays line-segment mesh for segment/ribbon consumers (the
// crystal rule).  Each segment is a 3D line (x0,y0,z0)→(x1,y1,z1) with a
// kind, a colour, and the OWNING cell index (`cell_ix`) — the latter lets
// a consumer attribute a segment back to its cell (e.g. GPU bloom-from-
// centre keyed by cell age) and lets the dirty-rebuild find-and-replace a
// cell's segments.  Coordinates are `single` (f32 — the GPU VBO format,
// no precision loss vs what is rendered) and kind/colour are `u8`
// (M1 narrowing: ~60 % less memory than the i64/f64 prototype).
pub struct SegMesh {
  pub kinds:   vector<u8>,
  pub x0s:     vector<single>, pub y0s: vector<single>, pub z0s: vector<single>,
  pub x1s:     vector<single>, pub y1s: vector<single>, pub z1s: vector<single>,
  pub colors:  vector<u8>,
  pub cell_ix: vector<integer>,
}

// An empty segment mesh: the nine parallel arrays `emit_segment` appends into.
// Flat and parallel rather than a vector of records, so a chunk rebuild
// concatenates (`seg_mesh_append`) instead of walking a structure.
pub fn seg_mesh_new() -> SegMesh {
  SegMesh{ kinds: [], x0s: [], y0s: [], z0s: [], x1s: [], y1s: [], z1s: [],
           colors: [], cell_ix: [] }
}

// Append one segment owned by cell `owner`.  Callers pass `float` coords
// and `integer` kind/colour; they are narrowed to the storage width here.
pub fn emit_segment(m: SegMesh, kind: integer,
                    x0: float, y0: float, z0: float,
                    x1: float, y1: float, z1: float,
                    color: integer, owner: integer) {
  m.kinds   += [(kind & 255) as u8];
  m.x0s += [x0 as single]; m.y0s += [y0 as single]; m.z0s += [z0 as single];
  m.x1s += [x1 as single]; m.y1s += [y1 as single]; m.z1s += [z1 as single];
  m.colors  += [(color & 255) as u8];
  m.cell_ix += [owner];
}

// Number of segments in the mesh.
pub fn seg_len(m: const SegMesh) -> integer { len(m.kinds) }

// Append all of `src`'s segments onto `dst` (concatenate the nine parallel
// arrays).  Used to assemble a render-group mesh from its chunks' CACHED
// meshes and to concatenate chunk meshes into a full build — order-preserving,
// so the consumer can rely on a stable per-group draw order.
pub fn seg_mesh_append(dst: SegMesh, src: const SegMesh) {
  dst.kinds += src.kinds;
  dst.x0s += src.x0s; dst.y0s += src.y0s; dst.z0s += src.z0s;
  dst.x1s += src.x1s; dst.y1s += src.y1s; dst.z1s += src.z1s;
  dst.colors += src.colors;
  dst.cell_ix += src.cell_ix;
}

// ── ChunkField: chunk partition + wired-in dirty index ─────────────────
//
// The FIELD tracks WHERE cells are: placement-ordered coords (index 0 =
// oldest), the spatial index, the chunk partition, and — wired in
// as a first-class member — the dirty-chunk INDEX, a keyed SET deduped by
// construction (`hash<ChunkKey[ck]>`).  It is payload- and mesh-agnostic:
// the consumer keeps its own colours/heights/meshes and indexes them by
// the cell indices the field hands out.  `halo_k` lives on the field so an
// edit can mark the adjacent chunk(s) dirty when the edited cell is within
// the halo radius of a chunk border (those chunks' meshes read it through
// the halo).  The dirty set is a keyed collection: `+=` inserts without
// duplicates, `f.dirty[ck]` tests membership, `f.dirty = []` clears it.

// A chunk-space coordinate.  `ck = enc_coord(cx, cy)` is its identity for
// dedup in the dirty index.
pub struct ChunkKey { pub ck: integer, pub cx: integer, pub cy: integer }

// floor(v / cs) for cs > 0, correct for negative coords (truncating `/`
// would map cells across the origin to the wrong chunk).  Mirrors moros's
// chunk_idx_32, generalized to an arbitrary chunk size.
fn chunk_div(v: integer, cs: integer) -> integer {
  vr = (v % cs) ?? 0;
  // `?? 0` only to satisfy the divide-by-zero lint — `cs = 1 << shift` is
  // always ≥ 1, so the division never actually divides by zero.
  if vr < 0 { (v - vr - cs) / cs ?? 0 } else { (v - vr) / cs ?? 0 }
}
// Position of `v` within its chunk, in [0, cs).
fn chunk_loc(v: integer, cs: integer) -> integer {
  vr = (v % cs) ?? 0;
  if vr < 0 { vr + cs } else { vr }
}

// The chunk owning offset coord (x, y) for a field with `shift`
// (chunk = 1<<shift cells per axis; shift 5 = the moros 32×32 chunk).
pub fn chunk_of(x: integer, y: integer, shift: integer) -> ChunkKey {
  cs = 1 << shift;
  cx = chunk_div(x, cs);
  cy = chunk_div(y, cs);
  ChunkKey{ck: enc_coord(cx, cy), cx: cx, cy: cy}
}

// A ck-keyed bucket of the cell indices a chunk owns — a wired-in member of
// ChunkField, maintained incrementally by field_new / field_add_cell.
// Defined before ChunkField so the `buckets` keyed field type resolves
// (a forward reference leaves its known_type unset → parse OOB).
struct ChunkBucket {
  pub ck:       integer,
  pub cx:       integer,
  pub cy:       integer,
  pub cell_ixs: vector<integer>,
}

// A grid of cells partitioned into chunks: the cell coordinates, a coordinate-to-cell
// index, and the dirty chunks and per-chunk buckets kept up to date as cells are added.
// `field_new` makes one and `field_add_cell` grows it.
pub struct ChunkField {
  layout:      integer,                // coordinate layout: 0, odd-row offset — the one there is
  pub xs:          vector<integer>,        // cell coords (index 0 = oldest)
  pub ys:          vector<integer>,
  pub cidx:        hash<CellRef[ck]>,      // coord -> cell index
  chunk_shift: integer,                // chunk = coord >> chunk_shift (5 = 32×32)
  pub halo_k:      integer,                // halo radius (border-dirty propagation)
  pub dirty:       hash<ChunkKey[ck]>,     // WIRED-IN dirty-chunk index (keyed set; dedup)
  pub buckets:     hash<ChunkBucket[ck]>,
                                       // WIRED-IN chunk -> owned cell indices,
                                       // maintained incrementally so dirty-chunk
                                       // extraction is O(dirty), not a re-scan.
}

// Build a field from placement-ordered coords.  `layout` is 0 (odd-row
// offset coordinates, the only layout); `chunk_shift` makes a chunk 1 <<
// chunk_shift cells a side; `halo_k` is how far across a border a rule may
// read.  The dirty index starts empty; the spatial index (cidx) and the
// chunk->cell partition (buckets) are built once (O(N)) and maintained
// incrementally thereafter (field_add_cell), so a later collect_dirty_inputs
// is O(dirty), not O(N).
pub fn field_new(layout: integer, xs: vector<integer>, ys: vector<integer>,
                 chunk_shift: integer, halo_k: integer) -> ChunkField {
  f = ChunkField{
    layout: layout, xs: xs, ys: ys,
    cidx: build_index(xs, ys),
    chunk_shift: chunk_shift, halo_k: halo_k,
    dirty: [],
    buckets: [],
  };
  for fn_i in 0..len(xs) {
    k = chunk_of(xs[fn_i] ?? 0, ys[fn_i] ?? 0, chunk_shift);
    if !f.buckets[k.ck] {
      f.buckets += [ChunkBucket{ck: k.ck, cx: k.cx, cy: k.cy, cell_ixs: []}];
    }
    b = f.buckets[k.ck];
    b.cell_ixs += [fn_i];
  }
  f
}

// Insert (x, y)'s chunk into the dirty index, plus any adjacent chunk whose
// mesh reads (x, y) through its halo (i.e. (x, y) is within halo_k of that
// border).  `+=` dedups by key, so many edits in one chunk and the
// overlapping border marks all coalesce to one entry per chunk.
fn mark_borders(f: ChunkField, x: integer, y: integer) {
  cs = 1 << f.chunk_shift;
  cx = chunk_div(x, cs); cy = chunk_div(y, cs);
  lx = chunk_loc(x, cs); ly = chunk_loc(y, cs);
  k = f.halo_k;
  dxlo = 0; if lx < k { dxlo = 0 - 1; }
  dxhi = 0; if lx >= cs - k { dxhi = 1; }
  dylo = 0; if ly < k { dylo = 0 - 1; }
  dyhi = 0; if ly >= cs - k { dyhi = 1; }
  for ddx in dxlo..(dxhi + 1) {
    for ddy in dylo..(dyhi + 1) {
      ncx = cx + ddx; ncy = cy + ddy;
      f.dirty += [ChunkKey{ck: enc_coord(ncx, ncy), cx: ncx, cy: ncy}];
    }
  }
}

// Edit — append a new cell (its index = old len(xs)), add it to its chunk's
// bucket (O(1) amortised), and mark its chunk (+ halo-border neighbours)
// dirty.  Keeping the bucket current here is what makes collect_dirty_inputs
// O(dirty) rather than a full re-partition.
pub fn field_add_cell(f: ChunkField, x: integer, y: integer) {
  ix = len(f.xs);
  f.xs += [x]; f.ys += [y];
  f.cidx += [CellRef{ck: enc_coord(x, y), ix: ix}];
  k = chunk_of(x, y, f.chunk_shift);
  if !f.buckets[k.ck] {
    f.buckets += [ChunkBucket{ck: k.ck, cx: k.cx, cy: k.cy, cell_ixs: []}];
  }
  b = f.buckets[k.ck];
  b.cell_ixs += [ix];
  mark_borders(f, x, y);
}

// Edit — RECOLOUR an existing cell: mark its chunk (+ halo border) dirty
// without touching the coord arrays or index (the cell stays; only its
// consumer-side payload — colour/height — changed).  Erase uses
// field_remove_cell instead (it must also drop the cell from the index).
pub fn field_mark_dirty(f: ChunkField, x: integer, y: integer) {
  mark_borders(f, x, y);
}

// Edit — REMOVE an existing cell: drop it from the spatial index (cidx) and
// its chunk bucket, then mark its chunk (+ halo border) dirty.  The MIRROR of
// field_add_cell, sharing mark_borders so a removal flows through the IDENTICAL
// incremental rebuild path as a paint.  The coord slot in xs/ys is deliberately
// LEFT in place (not compacted): cell indices the field already handed out stay
// stable, so a consumer's parallel per-cell arrays (e.g. the projector's bloom
// births, indexed by cell index) are not invalidated.  The orphaned slot is
// never read again — its index is in no bucket and no longer in cidx — and
// re-painting the same coord allocates a FRESH index (field_add_cell appends),
// so identity is never reused.
// Example: @GRM-003 — the indices a consumer already holds stay valid across a
// removal; a repaint is a new one.
pub fn field_remove_cell(f: ChunkField, x: integer, y: integer) {
  cr = f.cidx[enc_coord(x, y)];
  if cr == null { return; }          // not present — nothing to remove
  ix = cr.ix;
  f.cidx[enc_coord(x, y)] = null;    // drop from the spatial index
  k = chunk_of(x, y, f.chunk_shift);
  b = f.buckets[k.ck];
  if b != null {
    keep: vector<integer> = [];
    for frc_cix in b.cell_ixs { if frc_cix != ix { keep += [frc_cix]; } }
    b.cell_ixs = keep;
  }
  mark_borders(f, x, y);             // SAME dirty-marking as field_add_cell
}

// Clear the dirty index after a rebuild.
// Example: @GRM-001 — where `clear_dirty` sits in the edit → collect → rebuild
// cycle, and what skipping it costs.
pub fn clear_dirty(f: ChunkField) {
  f.dirty = [];
}

// Number of chunks currently dirty (verification / O(dirty) sizing).
pub fn dirty_count(f: const ChunkField) -> integer { len(f.dirty) }

// Is the chunk owning (x, y) currently marked dirty?
pub fn chunk_is_dirty(f: const ChunkField, x: integer, y: integer) -> boolean {
  k = chunk_of(x, y, f.chunk_shift);
  if f.dirty[k.ck] { true } else { false }
}

// ── B3: ChunkInput + per-chunk gather ─────────────────────────────────
//
// What a per-cell RULE needs for ONE chunk: the indices of the cells it
// OWNS (emit geometry for these) + the ≤halo_k border-neighbour cells in
// OTHER chunks it may READ as context.  COMPACT — no payload, and NO copy
// of the shared spatial index (`cidx`).  The rule receives `cidx`
// SEPARATELY as a read-only ref (keyed params don't deep-copy) and `par`
// forwards it as a context arg, so the per-chunk working set stays small
// and cache-resident (copying the whole index per chunk would be O(N)).
pub struct ChunkInput {
  pub key:      ChunkKey,
  pub cell_ixs: vector<integer>,   // cells owned by this chunk (emit for these)
  pub halo_ixs: vector<integer>,   // ≤halo_k border-neighbour cells (read-only context)
}

// Internal: a cell-index set for halo dedup.
struct SeenIx { ix: integer }

// Gather the ≤halo_k border-neighbour cells (in OTHER chunks) of a chunk's
// owned cells.  Walks the 6 hex axes up to halo_k steps (the axis-neighbour
// pattern of `nbr_count_idx` / the crystal rule); deduped by cell index.
// Cells reached only off-axis (a full k-ring) are not gathered.
fn gather_halo(f: const ChunkField, cell_ixs: const vector<integer>, this_ck: integer,
               halo_k: integer) -> vector<integer> {
  halo: vector<integer> = [];
  seen: hash<SeenIx[ix]> = [];
  for gh_j in 0..len(cell_ixs) {
    // `?? 0` only to satisfy the OOB lint — every index here is provably
    // in bounds (`gh_j` loop-bounded; `ci`/`nb` are valid cell indices).
    ci = cell_ixs[gh_j] ?? 0;
    cx = f.xs[ci] ?? 0; cy = f.ys[ci] ?? 0;
    for d in 0..6 {
      for k in 1..(halo_k + 1) {
        nb = idx_at(f.cidx, step_x(cx, cy, d, k), step_y(cx, cy, d, k));
        if nb >= 0 && !seen[nb] {
          nk = chunk_of(f.xs[nb] ?? 0, f.ys[nb] ?? 0, f.chunk_shift);
          if nk.ck != this_ck {
            seen += [SeenIx{ix: nb}];
            halo += [nb];
          }
        }
      }
    }
  }
  halo
}

// One ChunkInput per chunk that holds cells (the full-build work-list).
// Reads the wired-in `f.buckets` (no re-partition).
// Example: @GRM-002 — emit for `cell_ixs`, only READ `halo_ixs`; the partition that
// makes border geometry appear exactly once.
pub fn all_inputs(f: const ChunkField, halo_k: integer) -> vector<ChunkInput> {
  out: vector<ChunkInput> = [];
  for b in f.buckets {
    halo = gather_halo(f, b.cell_ixs, b.ck, halo_k);
    out += [ChunkInput{key: ChunkKey{ck: b.ck, cx: b.cx, cy: b.cy},
                       cell_ixs: b.cell_ixs, halo_ixs: halo}];
  }
  out
}

// One ChunkInput per DIRTY chunk (the incremental-rebuild work-list).
// Iterates ONLY the dirty set and looks up each dirty chunk's bucket — so
// the cost is O(dirty chunks × their cells), independent of total N.  A
// dirty chunk with no owned cells (a halo-marked empty neighbour) is
// skipped.  Clear the dirty index after rebuilding.
// Example: @GRM-001 — the edit → collect → rebuild → clear cycle end to end.
pub fn collect_dirty_inputs(f: const ChunkField, halo_k: integer) -> vector<ChunkInput> {
  out: vector<ChunkInput> = [];
  for dk in f.dirty {
    b = f.buckets[dk.ck];
    if b {
      halo = gather_halo(f, b.cell_ixs, b.ck, halo_k);
      out += [ChunkInput{key: ChunkKey{ck: b.ck, cx: b.cx, cy: b.cy},
                         cell_ixs: b.cell_ixs, halo_ixs: halo}];
    }
  }
  out
}

// ── G2: render-group (tile) layer ─────────────────────────────────────
//
// A render GROUP is a `group_dim × group_dim` block of chunks — the unit of
// VBO upload + draw + frustum cull, decoupled from the chunk (the unit of
// dirty-tracking + per-chunk mesh reuse).  `group_dim` is a TUNABLE knob:
// G=1 → one VBO per chunk (max incremental granularity, max draw calls);
// G large → ~one big VBO (min draws, wholesale re-upload).  The consumer
// caches a mesh per chunk and assembles a group's VBO from its chunks'
// cached meshes, re-uploading only groups that contain a dirty chunk.

// One render group: `(gx, gy)` its position in group coordinates (chunk coordinates
// divided by `group_dim`), `gk` the two encoded as one key.  `group_of` answers it.
pub struct GroupKey { pub gk: integer, pub gx: integer, gy: integer }

// The chunk ck's a group contains that actually hold cells (the assemble
// work-list).  No mesh here — gridmesh partitions; the consumer owns meshes.
pub struct GroupInput { key: GroupKey, pub chunk_cks: vector<integer> }

// Internal accumulator for all_groups (keyed so chunks dedup into their group).
struct GroupAcc { gk: integer, gx: integer, gy: integer, chunk_cks: vector<integer> }

// The group owning CHUNK coord (cx, cy): chunk-space divided by group_dim.
// Uses chunk_div so any group_dim works (not just powers of two) — a free dial.
pub fn group_of(cx: integer, cy: integer, group_dim: integer) -> GroupKey {
  gx = chunk_div(cx, group_dim);
  gy = chunk_div(cy, group_dim);
  GroupKey{gk: enc_coord(gx, gy), gx: gx, gy: gy}
}

// One GroupInput per group that holds cells (the full-build work-list).
// O(chunks): each non-empty chunk dedups into its group's chunk list.
pub fn all_groups(f: const ChunkField, group_dim: integer) -> vector<GroupInput> {
  acc: hash<GroupAcc[gk]> = [];
  for b in f.buckets {
    g = group_of(b.cx, b.cy, group_dim);
    if !acc[g.gk] { acc += [GroupAcc{gk: g.gk, gx: g.gx, gy: g.gy, chunk_cks: []}]; }
    a = acc[g.gk];
    a.chunk_cks += [b.ck];
  }
  out: vector<GroupInput> = [];
  for ga in acc {
    out += [GroupInput{key: GroupKey{gk: ga.gk, gx: ga.gx, gy: ga.gy}, chunk_cks: ga.chunk_cks}];
  }
  out
}

// One GroupInput per DIRTY group (the incremental re-upload work-list).
// A group is dirty if any of its chunks is dirty; its chunk_cks lists ALL its
// member chunks that hold cells (so the consumer reassembles the group's VBO
// from those chunks' CACHED meshes — unchanged chunks copied, not rebuilt).
//
// FLAT COST: derives the dirty groups from f.dirty (O(dirty)), then for each
// enumerates its group_dim × group_dim member chunk coords analytically and
// probes f.buckets — O(dirty groups × group_dim²), independent of total N.
// (Never scans all chunks, which would make per-edit cost grow with N.)
// Example: @GRM-004 — `chunk_cks` lists the group's CLEAN chunks too (copy their
// cached meshes); reading it as "chunks to rebuild" rebuilds the whole group.
pub fn collect_dirty_groups(f: const ChunkField, group_dim: integer) -> vector<GroupInput> {
  dirty_groups: hash<GroupKey[gk]> = [];
  for dk in f.dirty {
    dirty_groups += [group_of(dk.cx, dk.cy, group_dim)];   // dedup by gk
  }
  out: vector<GroupInput> = [];
  for g in dirty_groups {
    cks: vector<integer> = [];
    base_cx = g.gx * group_dim;
    base_cy = g.gy * group_dim;
    for ix in 0..group_dim {
      for iy in 0..group_dim {
        ck = enc_coord(base_cx + ix, base_cy + iy);
        if f.buckets[ck] { cks += [ck]; }
      }
    }
    out += [GroupInput{key: g, chunk_cks: cks}];
  }
  out
}

tests/chunkfield.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// gridmesh Phase B2 — ChunkField + chunk partition + wired-in dirty index.
// The dirty index is a keyed SET (`hash<ChunkKey[ck]>`) deduped by key,
// relying on the plan-44 keyed-collection primitives: `+=` dedup-insert
// (@P306), `if h[k]` membership, `f.dirty = []` clear (@P307 OpClearKeyed).

use gridmesh::*;

// chunk_of partitions offset coords by `1 << shift` (shift 3 → 8/axis),
// flooring correctly across the origin (negative coords).
fn test_chunk_partition() {
  a = chunk_of(0, 0, 3);    assert(a.cx == 0 && a.cy == 0, "(0,0)->chunk(0,0)");
  b = chunk_of(8, 0, 3);    assert(b.cx == 1 && b.cy == 0, "(8,0)->chunk(1,0)");
  c = chunk_of(0 - 1, 0, 3); assert(c.cx == 0 - 1 && c.cy == 0, "(-1,0)->chunk(-1,0)");
  d = chunk_of(7, 7, 3);    assert(d.cx == 0 && d.cy == 0, "(7,7)->chunk(0,0)");
}

// field_new builds the spatial index from the coords (the cidx hash field
// is populated via `cidx: build_index(...)` — a hash-returning call deep-
// copied into the field, working since @P308 fixed the keyed-HASH
// struct-field initialiser).
fn test_field_new_builds_index() {
  xs: vector<integer> = [0, 1, 8, 0 - 1];
  ys: vector<integer> = [0, 0, 0, 0];
  f = field_new(0, xs, ys, 3, 2);
  assert(len(f.xs) == 4, "coords stored");
  assert(len(f.cidx) == 4, "spatial index populated (not empty)");
  assert(idx_at(f.cidx, 0, 0) == 0, "lookup oldest cell");
  assert(idx_at(f.cidx, 8, 0) == 2, "lookup third cell");
  assert(idx_at(f.cidx, 99, 99) == 0 - 1, "lookup miss = -1");
  assert(dirty_count(f) == 0, "dirty starts empty");
}

// Marking dedups by chunk; a border cell (within halo_k of a chunk edge)
// marks the adjacent chunk too.
fn test_mark_and_dedup() {
  xs: vector<integer> = [0];
  ys: vector<integer> = [0];
  f = field_new(0, xs, ys, 3, 2);     // cs=8, halo_k=2
  field_mark_dirty(f, 4, 4);          // interior → its chunk only
  assert(dirty_count(f) == 1, "interior mark → 1 chunk");
  field_mark_dirty(f, 3, 3);          // same chunk again → dedup
  assert(dirty_count(f) == 1, "re-mark same chunk dedups");
  field_mark_dirty(f, 1, 4);          // lx=1 < halo_k=2 → chunk(0,0)+chunk(-1,0)
  assert(dirty_count(f) == 2, "low-x border marks adjacent chunk");
  assert(chunk_is_dirty(f, 4, 4), "membership: dirty chunk");
  assert(!chunk_is_dirty(f, 100, 100), "membership: clean chunk");
}

// clear_dirty empties the wired-in index (OpClearKeyed); re-marking works.
fn test_clear_and_remark() {
  xs: vector<integer> = [0];
  ys: vector<integer> = [0];
  f = field_new(0, xs, ys, 3, 2);
  field_mark_dirty(f, 4, 4);
  assert(dirty_count(f) == 1, "marked");
  clear_dirty(f);
  assert(dirty_count(f) == 0, "cleared");
  assert(!chunk_is_dirty(f, 4, 4), "membership false after clear");
  field_mark_dirty(f, 4, 4);
  assert(dirty_count(f) == 1, "re-mark after clear works");
}

// field_add_cell appends a cell, indexes it, and marks its chunk dirty.
fn test_add_cell() {
  xs: vector<integer> = [0];
  ys: vector<integer> = [0];
  f = field_new(0, xs, ys, 3, 2);
  clear_dirty(f);
  field_add_cell(f, 100, 100);
  assert(len(f.xs) == 2, "cell appended");
  assert(len(f.cidx) == 2, "cell indexed");
  assert(idx_at(f.cidx, 100, 100) == 1, "new cell lookup → index 1");
  assert(dirty_count(f) == 1, "add marked its chunk dirty");
}

fn frc_has(v: vector<integer>, target: integer) -> boolean {
  found = false;
  for frc_h_i in 0..len(v) { if (v[frc_h_i] ?? (0 - 1)) == target { found = true; } }
  found
}

// field_remove_cell is the MIRROR of field_add_cell: it drops the cell from the
// spatial index AND its chunk bucket and marks the chunk dirty, WITHOUT
// compacting the coord arrays — so surviving cells keep their indices (C4 needs
// stable cell_ix for the projector's per-cell bloom births).
fn test_remove_cell() {
  // three cells, one per chunk (shift 3 → 8 cells/axis): ix 0,1,2.
  xs: vector<integer> = [0, 8, 16];
  ys: vector<integer> = [0, 0, 0];
  f = field_new(0, xs, ys, 3, 2);
  clear_dirty(f);
  field_remove_cell(f, 8, 0);                 // remove the middle cell (ix 1)
  assert(idx_at(f.cidx, 8, 0) == 0 - 1, "removed cell: cidx lookup now a miss");
  assert(idx_at(f.cidx, 0, 0) == 0, "survivor (0,0) keeps index 0");
  assert(idx_at(f.cidx, 16, 0) == 2, "survivor (16,0) keeps index 2 (NOT compacted)");
  assert(len(f.xs) == 3, "coord slot orphaned, not compacted (index stability)");
  assert(dirty_count(f) >= 1, "remove marked the chunk dirty (same path as add)");
  // the removed index is gone from its chunk bucket → a rebuild won't emit it.
  b1 = f.buckets[chunk_of(8, 0, 3).ck];
  assert(b1 != null, "removed cell's chunk bucket still exists");
  // `?? []` cannot mask a missing bucket — the assert above has already failed if
  // there is none, so the default is unreachable rather than a weaker check.
  assert(!frc_has(b1.cell_ixs ?? [], 1), "bucket no longer lists the removed index 1");
  // removing a coord with no cell is a harmless no-op.
  field_remove_cell(f, 99, 99);
  assert(idx_at(f.cidx, 0, 0) == 0, "remove-miss is a no-op");
}

// A removed coord can be re-painted; it gets a FRESH index (identity not reused).
fn test_remove_then_readd() {
  xs: vector<integer> = [0, 8];
  ys: vector<integer> = [0, 0];
  f = field_new(0, xs, ys, 3, 2);
  field_remove_cell(f, 8, 0);                 // ix 1 orphaned
  assert(idx_at(f.cidx, 8, 0) == 0 - 1, "removed");
  field_add_cell(f, 8, 0);                    // re-paint same coord
  assert(idx_at(f.cidx, 8, 0) == 2, "re-add gets a fresh index (2), not the orphaned 1");
  assert(len(f.xs) == 3, "re-add appended a new slot");
}

tests/chunkinput.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// gridmesh Phase B3 — ChunkInput + collect_dirty_inputs / all_inputs.
// `all_inputs` partitions cells into one ChunkInput per chunk (cell_ixs) and
// gathers each chunk's ≤halo_k border-neighbour cells in OTHER chunks
// (halo_ixs); `collect_dirty_inputs` restricts that to the wired-in dirty
// set.  ChunkInput carries NO spatial index (cache-locality: the rule gets
// `cidx` as a read-only param, not a per-chunk copy).

use gridmesh::*;

// Two chunks (shift 3 → 8 cells/axis): x=0,7 in chunk(0,0); x=8,15 in
// chunk(1,0).  x=7 and x=8 are adjacent ACROSS the chunk border.
fn mk_field() -> ChunkField {
  xs: vector<integer> = [0, 7, 8, 15];
  ys: vector<integer> = [0, 0, 0, 0];
  field_new(0, xs, ys, 3, 2)
}

// all_inputs: one input per non-empty chunk; cell_ixs partition the cells.
fn test_all_inputs_partition() {
  f = mk_field();
  inputs = all_inputs(f, 2);
  assert(len(inputs) == 2, "two chunks");
  total = 0;
  for ci in inputs {
    total = total + len(ci.cell_ixs);
    assert(len(ci.cell_ixs) == 2, "each chunk owns 2 cells");
  }
  assert(total == 4, "cell_ixs partition covers all 4 cells");
}

// halo: the cell at the border has its cross-border neighbour in halo_ixs,
// and that neighbour is NOT in its own cell_ixs.
fn test_halo_crosses_border() {
  f = mk_field();
  inputs = all_inputs(f, 2);
  for ci in inputs {
    // each chunk's border cell (x=7 / x=8) sees one cross-border neighbour
    assert(len(ci.halo_ixs) == 1, "one cross-border halo cell per chunk");
    // halo cell is not one of this chunk's own cells
    h0 = ci.halo_ixs[0] ?? 0 - 1;
    own = false;
    for j in 0..len(ci.cell_ixs) {
      if (ci.cell_ixs[j] ?? 0 - 1) == h0 { own = true; }
    }
    assert(!own, "halo cell is not owned by the chunk");
  }
}

// an isolated chunk (no neighbours within halo_k) has an empty halo.
fn test_interior_empty_halo() {
  xs: vector<integer> = [0, 1];     // both in chunk(0,0), no other chunk
  ys: vector<integer> = [0, 0];
  f = field_new(0, xs, ys, 3, 2);
  inputs = all_inputs(f, 2);
  assert(len(inputs) == 1, "single chunk");
  for ci in inputs { assert(len(ci.halo_ixs) == 0, "no halo without a neighbour chunk"); }
}

// collect_dirty_inputs returns only marked chunks.
fn test_collect_dirty_only() {
  f = mk_field();
  // mark an interior cell of chunk(1,0) (x=12 → lx=4, no border spill)
  field_mark_dirty(f, 12, 0);
  di = collect_dirty_inputs(f, 2);
  assert(len(di) == 1, "only the dirty chunk");
  for ci in di {
    assert(ci.key.cx == 1 && ci.key.cy == 0, "dirty chunk is (1,0)");
    assert(len(ci.cell_ixs) == 2, "dirty chunk still owns its 2 cells");
  }
  // nothing dirty → no inputs
  clear_dirty(f);
  assert(len(collect_dirty_inputs(f, 2)) == 0, "no dirty → empty work-list");
}

tests/rendergroup.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// gridmesh G2 — render-group (tile) layer.  A group = group_dim × group_dim
// chunks, the unit of VBO upload/draw/cull.  `group_of` maps chunk coords to
// groups; `all_groups` partitions all non-empty chunks into groups;
// `collect_dirty_groups` returns one GroupInput per dirty group listing ALL
// its member chunks (so the consumer reassembles the group from cached chunk
// meshes).  group_dim is a tunable dial: G=1 = per-chunk, G large = one VBO.

use gridmesh::*;

// Four chunks in a row (cx 0..3 at shift 3 = 8 cells/axis), one INTERIOR cell
// each (chunk-local 4,4 → no halo border spill into neighbour chunks).
fn mk_field4() -> ChunkField {
  xs: vector<integer> = [4, 12, 20, 28];
  ys: vector<integer> = [4, 4, 4, 4];
  field_new(0, xs, ys, 3, 2)
}

fn has_ck(v: vector<integer>, ck: integer) -> boolean {
  found = false;
  for i in 0..len(v) { if (v[i] ?? -1) == ck { found = true; } }
  found
}

// group_of divides CHUNK coords by group_dim.
fn test_group_of() {
  assert(group_of(0, 0, 2).gx == 0, "cx0 -> gx0");
  assert(group_of(1, 0, 2).gx == 0, "cx1 -> gx0");
  assert(group_of(2, 0, 2).gx == 1, "cx2 -> gx1");
  assert(group_of(3, 0, 2).gx == 1, "cx3 -> gx1");
  assert(group_of(0, 0, 2).gk != group_of(2, 0, 2).gk, "distinct groups, distinct keys");
}

// group_dim 2: four chunks -> two groups of two; chunk_cks partition the chunks.
fn test_all_groups_g2() {
  f = mk_field4();
  groups = all_groups(f, 2);
  assert(len(groups) == 2, "two groups at G=2");
  total = 0;
  for g in groups {
    assert(len(g.chunk_cks) == 2, "each G=2 group holds 2 chunks");
    total = total + len(g.chunk_cks);
  }
  assert(total == 4, "chunk_cks cover all 4 chunks");
}

// G=1: one group per chunk (max granularity).
fn test_all_groups_g1() {
  f = mk_field4();
  groups = all_groups(f, 1);
  assert(len(groups) == 4, "four groups at G=1");
  for g in groups { assert(len(g.chunk_cks) == 1, "one chunk per G=1 group"); }
}

// G large: all chunks in one group (~ one big VBO).
fn test_all_groups_g_large() {
  f = mk_field4();
  groups = all_groups(f, 8);
  assert(len(groups) == 1, "one group at G=8");
  for g in groups { assert(len(g.chunk_cks) == 4, "the single group holds all 4 chunks"); }
}

// dirty a single chunk -> exactly its group is dirty, listing ALL member
// chunks (incl. the non-dirty sibling) so the consumer can reassemble it.
fn test_collect_dirty_groups() {
  f = mk_field4();
  ck0 = chunk_of(4, 4, 3).ck;    // chunk (0,0)
  ck1 = chunk_of(12, 4, 3).ck;   // chunk (1,0)
  field_mark_dirty(f, 12, 4);    // mark only chunk (1,0) (interior, no spill)
  dg = collect_dirty_groups(f, 2);
  assert(len(dg) == 1, "one dirty group (gx0)");
  for g in dg {
    cks = g.chunk_cks;
    assert(len(cks) == 2, "dirty group lists both member chunks");
    assert(has_ck(cks, ck0), "includes the non-dirty sibling chunk 0");
    assert(has_ck(cks, ck1), "includes the dirty chunk 1");
  }
}

tests/segmesh.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// gridmesh M1 — SegMesh: the narrow (u8 kind/colour, single coords) segment
// mesh produced by the chunk rule.  These tests pin the public API directly
// (the chunk/crystal tests only exercise it transitively): `seg_mesh_new`
// starts empty; `emit_segment` narrows int→u8 / float→single and keeps the
// nine parallel arrays in lock-step; `seg_len` == every array's length.

use gridmesh::*;

// A fresh mesh is empty across all nine arrays.
fn test_seg_mesh_new_empty() {
  m = seg_mesh_new();
  assert(seg_len(m) == 0, "new mesh has no segments");
  assert(len(m.kinds) == 0 && len(m.colors) == 0 && len(m.cell_ix) == 0,
         "kind/colour/owner arrays empty");
  assert(len(m.x0s) == 0 && len(m.y0s) == 0 && len(m.z0s) == 0,
         "start-coord arrays empty");
  assert(len(m.x1s) == 0 && len(m.y1s) == 0 && len(m.z1s) == 0,
         "end-coord arrays empty");
}

// One segment reads back field-for-field; coords are exact f32 values.
fn test_emit_one_reads_back() {
  m = seg_mesh_new();
  emit_segment(m, 1, 0.0, 0.5, 0.25, 1.0, 2.0, 4.0, 200, 7);
  assert(seg_len(m) == 1, "one segment");
  assert((m.kinds[0] ?? 0) == 1, "kind narrowed + stored");
  assert((m.colors[0] ?? 0) == 200, "colour narrowed + stored");
  assert((m.cell_ix[0] ?? 0) == 7, "owning cell index stored");
  // 0.0 / 0.5 / 0.25 / 1.0 / 2.0 / 4.0 are all exact in f32.
  assert((m.x0s[0] ?? 9.0f) == 0.0f && (m.y0s[0] ?? 9.0f) == 0.5f
           && (m.z0s[0] ?? 9.0f) == 0.25f, "start coords narrowed exactly");
  assert((m.x1s[0] ?? 9.0f) == 1.0f && (m.y1s[0] ?? 9.0f) == 2.0f
           && (m.z1s[0] ?? 9.0f) == 4.0f, "end coords narrowed exactly");
}

// Several emits keep all nine arrays the same length AND in append order.
fn test_emit_many_aligned() {
  m = seg_mesh_new();
  for e in 0..5 {
    // distinct kind/colour/owner per segment, coords keyed off the index.
    emit_segment(m, e % 2, e as float, 0.0, 0.0, 0.0, 0.0, 0.0,
                 100 + e, 10 + e);
  }
  n = seg_len(m);
  assert(n == 5, "five segments");
  // the parallel-array invariant: every column has exactly seg_len rows.
  assert(len(m.kinds) == n && len(m.colors) == n && len(m.cell_ix) == n
           && len(m.x0s) == n && len(m.y0s) == n && len(m.z0s) == n
           && len(m.x1s) == n && len(m.y1s) == n && len(m.z1s) == n,
         "all nine arrays stay length-aligned");
  // append order preserved across columns.
  for j in 0..n {
    assert((m.kinds[j] ?? 9) == j % 2, "kind at row {j}");
    assert((m.colors[j] ?? 0) == 100 + j, "colour at row {j}");
    assert((m.cell_ix[j] ?? 0) == 10 + j, "owner at row {j}");
    assert((m.x0s[j] ?? 9.0f) == (j as float) as single, "x0 at row {j}");
  }
}

// u8 storage holds the full unsigned byte range at the boundary.
fn test_u8_boundary() {
  m = seg_mesh_new();
  emit_segment(m, 255, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 255, 0);
  assert((m.kinds[0] ?? 0) == 255, "kind 255 stored");
  assert((m.colors[0] ?? 0) == 255, "colour 255 stored");
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `gridmesh` library (@PLN141).  Each `test_*` below is a
// REAL, run-every-CI demonstration of a function whose correct use is not obvious
// from its signature alone; the function documents itself by CITING the tag with
// `// Example: @GRM-00N`, and `check_doc_drift.sh examples` fails if a citation goes
// dangling (no test carries the tag).  These are teaching examples, distinct from the
// per-function assertion suites in chunkfield / chunkinput / rendergroup / segmesh.
//
// gridmesh is a TOOLKIT, so what needs demonstrating is not any single call but the
// SHAPE of a correct one: which work-list to ask for, which cells to emit geometry
// for, and which of its answers stay valid across an edit.

use gridmesh::*;

// Is `target` one of the cell indices in `v`?
fn wex_has(v: vector<integer>, target: integer) -> boolean {
  for e in v { if e == target { return true; } }
  false
}

// How many of the chunk inputs claim cell `ix` as OWNED (must be exactly one).
fn wex_owner_count(inputs: vector<ChunkInput>, ix: integer) -> integer {
  n = 0;
  for ci in inputs { if wex_has(ci.cell_ixs, ix) { n = n + 1; } }
  n
}

// @GRM-001 — the edit → collect → rebuild → CLEAR cycle.  A ChunkField carries its
// own dirty index, so an edit (`field_add_cell` / `field_mark_dirty` /
// `field_remove_cell`) does not rebuild anything: it marks the chunks whose meshes
// are now stale.  `collect_dirty_inputs` hands back exactly those chunks as a
// work-list, and `clear_dirty` must be called AFTER the consumer has rebuilt them —
// forget it and every later edit re-collects the same chunks forever, which is the
// one way to turn this O(dirty) API back into O(N).  This SEQUENCE is the API.
fn test_dirty_rebuild_cycle() {
  xs: vector<integer> = [1, 2];
  ys: vector<integer> = [3, 3];
  f = field_new(0, xs, ys, 3, 1);        // chunk = 8 cells/axis, halo 1
  assert(dirty_count(f) == 0, "a fresh field has nothing to rebuild");

  // Paint a cell: the field records it AND marks its chunk stale.
  field_add_cell(f, 3, 3);
  assert(dirty_count(f) == 1, "the edit marked exactly its own chunk");
  assert(chunk_is_dirty(f, 3, 3), "and the chunk holding the edit is the dirty one");

  // Ask for the work-list, then rebuild each chunk's mesh from it.
  work = collect_dirty_inputs(f, 1);
  assert(len(work) == 1, "one chunk to rebuild");
  for ci in work {
    assert(len(ci.cell_ixs) == 3, "the rebuild sees every cell that chunk owns");
    assert(len(ci.halo_ixs) == 0, "no neighbouring chunk to read here");
  }

  // Only now, having rebuilt, clear — the next edit starts from a clean slate.
  clear_dirty(f);
  assert(dirty_count(f) == 0, "cleared after the rebuild");
  assert(len(collect_dirty_inputs(f, 1)) == 0, "nothing stale → an empty work-list");
}

// @GRM-002 — a ChunkInput's two cell lists mean OPPOSITE things.  `cell_ixs` are the
// cells this chunk OWNS: emit geometry for these.  `halo_ixs` are cells in OTHER
// chunks within halo_k of the border: READ them as context (a rule that looks at its
// neighbours needs them) but never emit for them — the chunk that owns them will,
// and emitting twice is how a border grows doubled geometry.  The partition below is
// the invariant that makes that safe: across all inputs, every cell is owned once.
fn test_owned_versus_halo() {
  xs: vector<integer> = [0, 7, 8, 15];   // x=7 and x=8 are adjacent ACROSS the border
  ys: vector<integer> = [0, 0, 0, 0];
  f = field_new(0, xs, ys, 3, 2);
  inputs = all_inputs(f, 2);
  assert(len(inputs) == 2, "two chunks hold cells");

  // Emit-once: every cell is owned by exactly one chunk.
  for ix in 0..4 {
    assert(wex_owner_count(inputs, ix) == 1, "each cell is emitted by exactly one chunk");
  }

  // Read-only: a halo cell belongs to a DIFFERENT chunk's owned list.
  for ci in inputs {
    assert(len(ci.halo_ixs) == 1, "each chunk sees one cell across the border");
    for h in ci.halo_ixs {
      assert(!wex_has(ci.cell_ixs, h), "a halo cell is never one of your own");
      assert(wex_owner_count(inputs, h) == 1, "it is owned — and emitted — elsewhere");
    }
  }
}

// @GRM-003 — cell indices are STABLE identities, and a removal does not renumber
// them.  Consumers keep their own per-cell arrays (colour, height, birth time) keyed
// by the index the field handed out, so `field_remove_cell` deliberately leaves the
// coord slot in place rather than compacting `xs`/`ys`: every index still in use
// keeps meaning the same cell.  The removed coord is simply gone from the spatial
// index (`idx_at` → -1), and re-painting it allocates a FRESH index — never the old
// one — so a stale reference can't be resurrected as a different cell.
fn test_indices_survive_a_removal() {
  xs: vector<integer> = [1, 2, 3];
  ys: vector<integer> = [3, 3, 3];
  f = field_new(0, xs, ys, 3, 1);
  assert(idx_at(f.cidx, 2, 3) == 1, "the middle cell is index 1");
  assert(idx_at(f.cidx, 3, 3) == 2, "the last cell is index 2");

  field_remove_cell(f, 2, 3);
  assert(idx_at(f.cidx, 2, 3) == 0 - 1, "the erased coord is gone from the index");
  assert(idx_at(f.cidx, 3, 3) == 2, "every OTHER cell keeps the index it had");
  assert(len(f.xs) == 3, "the coord array is not compacted — that is what keeps them stable");

  // Re-paint the same coord: a new cell, with a new identity.
  field_add_cell(f, 2, 3);
  assert(idx_at(f.cidx, 2, 3) == 3, "a repaint is a NEW index, not the recycled 1");
}

// @GRM-004 — a dirty GROUP lists all its populated chunks, not just the dirty ones.
// The group is the upload/draw unit (one VBO), the chunk is the rebuild unit, and
// they are deliberately decoupled: to re-upload a group you must reassemble the whole
// VBO, which means concatenating the meshes of EVERY chunk in it.  So `chunk_cks`
// hands back the clean neighbours too — the consumer copies their CACHED meshes
// (`seg_mesh_append`) and only re-runs the rule for the chunks that are actually
// dirty.  Reading this list as "the chunks to rebuild" rebuilds the whole group.
fn test_dirty_group_lists_clean_members() {
  xs: vector<integer> = [3, 11];         // chunk(0,0) and chunk(1,0), one group at G=2
  ys: vector<integer> = [3, 3];
  f = field_new(0, xs, ys, 3, 1);
  field_mark_dirty(f, 3, 3);             // only chunk(0,0) goes stale
  assert(dirty_count(f) == 1, "one dirty chunk");
  assert(!chunk_is_dirty(f, 11, 3), "its group-mate is clean");

  groups = collect_dirty_groups(f, 2);
  assert(len(groups) == 1, "one group to re-upload");
  for g in groups {
    assert(len(g.chunk_cks) == 2, "BOTH populated chunks are listed, not just the dirty one");
    assert(wex_has(g.chunk_cks, enc_coord(0, 0)), "the dirty chunk — re-run the rule");
    assert(wex_has(g.chunk_cks, enc_coord(1, 0)), "the clean chunk — copy its cached mesh");
  }
}

// @GRM-005 — walking the grid takes step_x and step_y TOGETHER, and a miss is -1.
// These are offset coordinates over a hex grid, so a step along axis `d` depends on
// the row's parity: `step_x` needs BOTH coords to compute the new x (its y argument
// is not decoration), while `step_y` ignores x entirely.  Call them as a pair on the
// SAME (x, y) — mixing a stepped x with an unstepped y walks off the axis.  The
// lookup then answers a cell INDEX or -1; it is not nullable, so guard it with
// `>= 0` and never with a null test.
fn test_axis_walk_and_miss() {
  xs: vector<integer> = [1, 2, 3];
  ys: vector<integer> = [3, 3, 3];
  f = field_new(0, xs, ys, 3, 1);

  // One step east (axis 0) from the first cell lands on the second.
  nx = step_x(1, 3, 0, 1);
  ny = step_y(1, 3, 0, 1);
  assert(idx_at(f.cidx, nx, ny) == 1, "the axis-0 neighbour of (1,3) is cell 1");

  // A coord with no cell answers the -1 sentinel, not null.
  assert(idx_at(f.cidx, 9, 9) == 0 - 1, "a miss is -1 — test it with >= 0");

  // The index-backed neighbour count walks all six axes for you.
  assert(nbr_count_idx(f.cidx, 2, 3) == 2, "the middle of a three-cell row has two neighbours");
  assert(nbr_count_idx(f.cidx, 1, 3) == 1, "the end of the row has one");
}