hex_form source

← hex_form · API reference · version 0.1.7

5 file(s), 1327 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
form_write fn37HXF-001119 26 29 40 41 53 … (10 total)
form_canon fn92HXF-004119
form_canon_text fn119HXF-00441 42 136 137 148
form_read_name fn167HXF-003101
form_read fn176HXF-00227 51 68 72 76 82 … (10 total)
form_eq fn207—28 52 105
HEAD_N const49—64 116
EDGE_N2_4 const53—none in this package
VERTEX_N2_4 const54—none in this package
head_norm fn64—71 186 71 95 152 164
head_step fn70—90 117 184 290 307
head_n2_4 fn89HXF-007213 214 215
head_is_edge fn95—216 225
rot60_k fn102—none in this package
rot60_m fn105—none in this package
refl_k fn108—none in this package
refl_m fn111—none in this package
head_of_step fn115—none in this package
Form struct140—37 67 92 119 176 207 … (19 total)
TURN_FULL const147—199
form_new fn151—98 113 179 181 182 183 … (34 total)
form_sides fn157—197 68 72 76 82 88 … (8 total)
form_heading fn160—223 224 225
form_turn_sum fn169—199 190 196
form_close fn177—198 188 195
form_closes fn196HXF-006364 615 164 191 198
perp_k fn219—none in this package
perp_m fn222—none in this package
vec_n2_4 fn225—none in this package
plan_holds fn246—none in this package
plan_u_can_tie fn252—none in this package
form_poly_k fn282—365 598
form_poly_m fn299—366 599
poly_holds fn323—372
form_fill fn363—20 130 131 147 171 172
set_shoelace2 fn388—none in this package
tri_cells fn419—173
rhombus_cells fn422—132
hexagon_cells fn425—none in this package
boundary_ends fn487—none in this package
boundary_branches fn492—none in this package
boundary_loops fn498—none in this package
SURFACE_LANDED const559—none in this package
REBUILD_LANDED const564—none in this package
form_is_convex fn589HXF-005615 166
form_is_simple fn597—615 165
form_admissible fn614HXF-00518 48 127 146 167 168 … (7 total)
cell_x fn631—688 698 749 796
cell_y fn633—689 699 750 797
fabs fn635—800 801
Plan struct649—662 663 669 670 676 678 … (14 total)
plan_new fn661—none in this package
plan_flip fn669—none in this package
plan_hw fn676—715 774
plan_hd fn678—725 775
plan_to_world fn684—741
plan_to_local fn694—798
plan_side_point fn736—748
plan_side_normal fn747—none in this package
SideRun struct759—768 773 823
side_run_len fn768—none in this package
side_edges fn773—none in this package

src/formtext.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// formtext — THE CANONICAL TEXT `𝕋` FOR A CYCLE FORM, AND ITS PARSER.
//
// `write` and `read` for the S1 cycle form only: `stencil / h0 / side len turn`. Rules **C1–C5**
// (`DESIGN.md` §3) — integers only, fixed order, reduced forms, fixed layout, defaults omitted.
//
// A MODEL HAS EXACTLY ONE SPELLING. That is the whole point of a canonical text: the round-trip
// gate is a byte `diff` (`P3`), which only means anything if the same model cannot be written two
// ways. Two things make a cycle ambiguous, and both have to be pinned:
//
//   the WINDING     a cycle walked the other way round is the same shape with turn total -12.
//                   Law J already requires +12, so C3 picks one of the two — SETTLED at S1.
//   the START CORNER  a closed cycle has no distinguished first corner: `[2,5,5]`, `[5,5,2]` and
//                   `[5,2,5]` are one triangle walked from three places, at three different h0.
//                   The S5 census MEASURED this — 183 collisions, every one a re-spelling — and
//                   it is what `X38` records. Fixing the winding is necessary and NOT sufficient.
//
// THE RULE: of the n cyclic spellings, the canonical one is the lexicographically smallest
// `(turns, lens, h0)`. Deterministic, total, and it needs no tie-break convention on top —
// a fully periodic cycle like `[4,4,4]` has several spellings with identical turns, and the `h0`
// component then decides.
//
// ORIENTATION IS NOT QUOTIENTED HERE, deliberately. `h0` is the stencil's own authored heading,
// and placement carries `orient` separately (`DESIGN.md` §3's example). Two stencils differing
// only by `h0` are different authorings that law **I** makes interchangeable at USE time — that is
// placement's job, not the text's. After corner-canonicalisation the only multiplicity left among
// the S5 forms is exactly `h0`, which is the clean decomposition.
use hexform::*;

// ── C2/C4: the layout ──────────────────────────────────────────────────────
//
// One element per line, single-space separators, no trailing space. Fields in declaration order:
// `stencil <name> h0 <n>` then one `side <i> len <l> turn <t>` per side, in index order.
//
// Example: @HXF-001
pub fn form_write(f: Form, name: text) -> text {
  lens = f.fm_len;
  turns = f.fm_turn;
  s = "stencil {name} h0 {f.fm_h0}";
  for wi in 0..len(lens) {
    s = s + "\nside {wi} len {lens[wi] ?? 0} turn {turns[wi] ?? 0}";
  }
  s
}

// ── the canonical spelling: fix the START CORNER (X38) ─────────────────────
//
// Spelling `s` starts the walk at corner `s`: the side and turn sequences rotate left by `s`, and
// the starting heading advances by the turns that were skipped.
fn shift_lens(lens: vector<integer>, s: integer) -> vector<integer> {
  n = len(lens);
  out: vector<integer> = [];
  // `% n` types nullable (a zero divisor answers null); the loop does not run when
  // `n` is 0, so `?? 0` names an unreachable index rather than a chosen one.
  for li in 0..n { out += [lens[(li + s) % n ?? 0] ?? 0]; }
  out
}

fn shift_turns(turns: vector<integer>, s: integer) -> vector<integer> {
  n = len(turns);
  out: vector<integer> = [];
  for ti in 0..n { out += [turns[(ti + s) % n ?? 0] ?? 0]; }
  out
}

fn shift_h0(f: Form, s: integer) -> integer {
  turns = f.fm_turn;
  h = f.fm_h0;
  for hi in 0..s { h = h + (turns[hi] ?? 0); }
  head_norm(h)
}

// Lexicographic compare on two equal-length integer vectors: -1, 0, +1.
fn cmp_vec(a: vector<integer>, b: vector<integer>) -> integer {
  n = len(a);
  if len(b) < n { n = len(b); }
  for ci in 0..n {
    av = a[ci] ?? 0;
    bv = b[ci] ?? 0;
    if av < bv { return -1; }
    if av > bv { return 1; }
  }
  if len(a) < len(b) { return -1; }
  if len(a) > len(b) { return 1; }
  0
}

// THE CANONICAL SPELLING — smallest `(turns, lens, h0)` over the n cyclic starts.
//
// Example: @HXF-004
pub fn form_canon(f: Form) -> Form {
  n = len(f.fm_len);
  // A FRESH value on this path too, never the parameter itself. Returning a by-value struct
  // PARAMETER on one path and a fresh value on another leaks the fresh one -- one store per
  // call, on the path that IS taken, on both backends (loft#982). `form_canon` is called once
  // per admitted form by every enumerator, so the census leaked thousands of `Form`s.
  if n == 0 { return form_new(f.fm_h0, f.fm_len, f.fm_turn); }
  bt = shift_turns(f.fm_turn, 0);
  bl = shift_lens(f.fm_len, 0);
  bh = shift_h0(f, 0);
  for si in 1..n {
    ct = shift_turns(f.fm_turn, si);
    cl = shift_lens(f.fm_len, si);
    ch = shift_h0(f, si);
    ord = cmp_vec(ct, bt);
    if ord == 0 { ord = cmp_vec(cl, bl); }
    if ord == 0 {
      if ch < bh { ord = -1; } else { ord = 1; }
    }
    if ord < 0 { bt = ct; bl = cl; bh = ch; }
  }
  form_new(bh, bl, bt)
}

// The canonical text of a form, whichever corner it happened to be written from.
//
// Example: @HXF-004
pub fn form_canon_text(f: Form, name: text) -> text { form_write(form_canon(f), name) }

// ── the parser ─────────────────────────────────────────────────────────────
//
// Strict by design: the reader accepts exactly what `form_write` emits. A lenient reader would
// let a non-canonical spelling in and the byte-diff gate would stop meaning anything — the text
// IS the contract, so anything it cannot round-trip must be refused rather than repaired.
//
// THAT WAS A CLAIM BEFORE IT WAS A TEST, and the claim was wrong in seven ways: a trailing
// space, an extra field, junk after the header, a non-integer length, an empty length, an
// unnormalised `h0` and a non-integer `h0` were all ADMITTED and then re-written differently.
// Six of those were spelling; one was not — `len x` became `len 0`, a side that is not there,
// admitted into the model without a word. What pins it now is a RE-SPELLING IDENTITY: a field
// is an integer only if printing the parse reproduces the original characters, and a line is a
// record only if it carries exactly the fields the writer emits.
//
// Example: @HXF-002
fn nth_word(line: text, i: integer) -> text {
  parts = line.split(' ');
  parts[i] ?? ""
}

// The parens must NOT wrap the cast: loft wants the `??` discharged at the parse site
// itself (`s as integer ?? 0`), not applied to an already-parenthesised fallible cast.
fn word_int(line: text, i: integer) -> integer { nth_word(line, i) as integer ?? 0 }

// How many space-separated fields the line has. The layout (C4) fixes this per record, so a
// line carrying MORE is a different line — trailing junk and a trailing space both land here.
fn word_count(line: text) -> integer { len(line.split(' ')) }

// Is field `i` an integer SPELLED THE WAY `form_write` spells one?
//
// Not a character class: the parse has to reproduce the original characters. That is the C1
// rule ("integers only") and the C4 rule ("fixed layout") in one test, and it refuses `""`,
// `x`, `007` and `+1` — each of which a fallible cast would otherwise hand back as a plausible
// number. `x` becoming `0` was the sharpest of them: not a spelling slip but a SIDE THAT IS
// NOT THERE, admitted into the model without a word.
fn word_is_int(line: text, i: integer) -> boolean {
  w = nth_word(line, i);
  v = w as integer ?? 0;
  "{v}" == w
}

// The name a text was written with. It is NOT part of the model — `Form` has no name field —
// so a round trip carries it only if the caller carries it.  Names are ONE WORD: the layout puts
// `h0` in the third field, so a name with a space writes a text this reader refuses.
//
// Example: @HXF-003
pub fn form_read_name(t: text) -> text {
  lines = t.split('\n');
  nth_word(lines[0] ?? "", 1)
}

// Parse a canonical text back into a Form. Returns a 0-sided form if the text does not have the
// canonical shape — the caller checks, and the gate treats that as a refusal.
//
// Example: @HXF-002
pub fn form_read(t: text) -> Form {
  lines = t.split('\n');
  nl = len(lines);
  if nl < 2 { return form_new(0, [], []); }
  head = lines[0] ?? "";
  if word_count(head) != 4 { return form_new(0, [], []); }        // C4: `stencil <name> h0 <n>`
  if nth_word(head, 0) != "stencil" { return form_new(0, [], []); }
  if nth_word(head, 2) != "h0" { return form_new(0, [], []); }
  if !word_is_int(head, 3) { return form_new(0, [], []); }        // C1: integers only
  h0 = word_int(head, 3);
  if head_norm(h0) != h0 { return form_new(0, [], []); }          // C3: one heading, one spelling
  lens: vector<integer> = [];
  turns: vector<integer> = [];
  for ri in 1..nl {
    ln = lines[ri] ?? "";
    if word_count(ln) != 6 { return form_new(0, [], []); }         // C4: `side <i> len <l> turn <t>`
    if nth_word(ln, 0) != "side" { return form_new(0, [], []); }
    if nth_word(ln, 2) != "len" { return form_new(0, [], []); }
    if nth_word(ln, 4) != "turn" { return form_new(0, [], []); }
    if !word_is_int(ln, 1) { return form_new(0, [], []); }         // C1: integers only
    if !word_is_int(ln, 3) { return form_new(0, [], []); }
    if !word_is_int(ln, 5) { return form_new(0, [], []); }
    if word_int(ln, 1) != ri - 1 { return form_new(0, [], []); }   // C2: index order
    lens += [word_int(ln, 3)];
    turns += [word_int(ln, 5)];
  }
  form_new(h0, lens, turns)
}

// Are two forms the same shape?  Compares the model rather than the text, which is what
// makes `form_eq(f, form_read(form_write(f)))` a round-trip check and not a tautology.
pub fn form_eq(a: Form, b: Form) -> boolean {
  if a.fm_h0 != b.fm_h0 { return false; }
  if len(a.fm_len) != len(b.fm_len) { return false; }
  if len(a.fm_turn) != len(b.fm_turn) { return false; }
  for ei in 0..len(a.fm_len) {
    if (a.fm_len[ei] ?? 0) != (b.fm_len[ei] ?? 0) { return false; }
    if (a.fm_turn[ei] ?? 0) != (b.fm_turn[ei] ?? 0) { return false; }
  }
  true
}

src/hex_form.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_form — the exact turtle form on the hex lattice, and its canonical text.
//
// The MODEL axis of the `hex_*` family: the 12 headings of `H₁₂` as exact lattice steps, the
// closed turtle cycle that is a stencil's outline, the `Plan` rectangle, the lattice→world map,
// and the canonical text a form is written to and read back from — byte-for-byte.
//
// Entry point: re-exports `hexform` (the lattice and the form) and `formtext` (the text).
pub use hexform::*;
pub use formtext::*;

src/hexform.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hexform — THE 12 HEADINGS OF H12, AS EXACT LATTICE STEPS.
//
// A stencil side runs in one of 12 headings, 30 degrees apart (ROUNDTRIP.md §2.1).  This
// file is the table of those steps and nothing else: no geometry, no rasterisation, pure
// integer arithmetic.  Everything downstream indexes it, so it is the cheapest possible
// thing to be wrong about and the cheapest to check.
//
// STEPS ARE IN DOUBLED (k, m) COORDINATES, NOT IN (q, r).  hex_field stores pointy-top,
// ODD-R OFFSET cells — `lattice_k(q,r) = 2q + (r & 1)`, `lattice_m(q,r) = 3r` — so a step
// expressed in (q, r) DEPENDS ON THE PARITY OF r.  In (k, m) the offset is already absorbed
// and the same six neighbour deltas hold on every row.  Working here keeps the whole file
// parity-free; `formtest` proves that against hex_field's own `nb_q` / `nb_r`.
//
// THE TWO CLASSES (ROUNDTRIP.md §2.1).  Even headings are the six edge-neighbour directions,
// odd headings the six vertex directions.  Their world lengths differ by exactly sqrt(3),
// which this file exposes as an INTEGER: 4 * len^2 = 3k^2 + m^2, giving 12 for an edge step
// and 36 for a vertex one.  Never compare these as floats — the ratio is exact.
// ── Using it ─────────────────────────────────────────────────────────────────
//
// A FORM is a shape written as a walk: a start heading, then a length and a turn per
// side.  That is the whole model, and everything here either builds one, measures one,
// or puts one somewhere.
//
//   f = form_new(0, [1, 1, 1], [4, 4, 4]);   // the unit equilateral triangle
//   n = form_sides(f);                       // 3
//   t = form_write(f, "tri");                // -> canonical text, byte-exact
//   g = form_read(t);                        // <- and back; form_eq(f, g) holds
//
// The text is a fixed layout on purpose (see `formtext.loft`): one model has exactly
// one spelling, so a project file diffs and an undo history is exact.
//
// A PLAN puts a form in the world — a centre cell, a size, a rotation and a mirror —
// and converts between the plan's local frame and world coordinates:
//
//   p  = plan_new(0, 0, 4, 3, 0, false);
//   xy = plan_to_world(p, 0.5, 0.5);         // local -> world
//   uv = plan_to_local(p, x, y);             // world -> local
//
// The rotations are the six exact lattice ones, so `plan_to_local(plan_to_world(..))`
// returns what it was given rather than something a trig round-off left behind.
//
// Worked examples: `tests/02-worked-examples.loft` (@HXF-001 …).

use hex_field::*;
use hex_grid::*;

pub const HEAD_N = 12;

// 4 * the squared world length of one edge step and one vertex step.  From
// x = k*sqrt(3)/2 and y = m/2, len^2 = (3k^2 + m^2)/4, so scaling by 4 keeps it integral.
pub const EDGE_N2_4   = 12;
pub const VERTEX_N2_4 = 36;

// Normalise a heading to 0..11.
//
// EVERY index of the table goes through here.  A running heading is `h0 + sum(turn)` and the
// turns are -5..6, so it leaves 0..11 constantly — a 4-sided cycle turning 3 each time
// reaches h = 12 on its last side.  Two things then go wrong SILENTLY if the raw value is
// used: in loft a negative index reads from the END of a vector instead of returning null,
// and an index at or past the length returns null rather than wrapping.  Both hand back a
// plausible wrong answer, which is why this is the first function in the file.
pub fn head_norm(h: integer) -> integer { ((h % HEAD_N) + HEAD_N) % HEAD_N }

// The step for heading `h`, as (dk, dm) in doubled coordinates.
//
// Ordered counter-clockwise from +x: h = 0 is due east, and each step is +30 degrees.  Even
// h are the edge neighbours, odd h the vertex directions.
pub fn head_step(h: integer) -> (integer, integer) {
  n = head_norm(h);
  if n == 0  { return ( 2,  0); }
  if n == 1  { return ( 3,  3); }
  if n == 2  { return ( 1,  3); }
  if n == 3  { return ( 0,  6); }
  if n == 4  { return (-1,  3); }
  if n == 5  { return (-3,  3); }
  if n == 6  { return (-2,  0); }
  if n == 7  { return (-3, -3); }
  if n == 8  { return (-1, -3); }
  if n == 9  { return ( 0, -6); }
  if n == 10 { return ( 1, -3); }
  ( 3, -3)
}

// 4 * the squared world length of the step — EDGE_N2_4 or VERTEX_N2_4, exactly.
//
// Example: @HXF-007
pub fn head_n2_4(h: integer) -> integer {
  st = head_step(h);
  3 * st.0 * st.0 + st.1 * st.1
}

// An even heading is an edge-neighbour direction; an odd one points at a vertex.
pub fn head_is_edge(h: integer) -> boolean { head_norm(h) % 2 == 0 }

// The exact 60-degree rotation on doubled coordinates (ROUNDTRIP.md §1, constraint X1).
//
// Both halves stay integral because every lattice point satisfies k = m (mod 2): k's parity
// is `r & 1` and m = 3r has the same parity.  On the heading table this acts as h -> h + 2,
// which is what makes the six rotations of O a subgroup of the twelve headings.
pub fn rot60_k(k: integer, m: integer) -> integer { (k - m) / 2 }
// The `m` of the same 60-degree rotation `rot60_k` gives the `k` of.  Rotate a step by
// walking both, and the result is still exactly on the lattice.
pub fn rot60_m(k: integer, m: integer) -> integer { (3 * k + m) / 2 }

// The exact reflection (constraint X2).  On the heading table this acts as h -> 6 - h.
pub fn refl_k(k: integer) -> integer { -k }
// The `m` of the reflection — unchanged, since the mirror is about the m axis.  Present so
// a caller reflects by walking the same pair it rotates by, rather than special-casing one.
pub fn refl_m(m: integer) -> integer { m }

// The heading whose step is (dk, dm), or -1 if no heading has that step.  Used by the gate
// to check the table against hex_field's neighbours, and later by recovery.
pub fn head_of_step(dk: integer, dm: integer) -> integer {
  for h in 0..HEAD_N {
    st = head_step(h);
    if st.0 == dk && st.1 == dm { return h; }
  }
  -1
}

// ── LAW J — A STENCIL BOUNDARY IS A CLOSED TURTLE CYCLE (step S1) ───────────
//
// A Form is a walk: start facing `fm_h0`, and for each side walk `fm_len[i]` steps in the
// current heading, then turn by `fm_turn[i]`.  It is admissible only if it CLOSES, and that
// is two independent conditions:
//
//     sum(len_i * e(h_i)) = (0, 0)     the walk returns to its start
//     sum(turn_i)         = 12         it turned through exactly one revolution
//
// NEITHER IMPLIES THE OTHER, and the gate proves it both ways.  The last turn happens AFTER
// the last side, so it cannot move the endpoint — a wrong final turn leaves the vector sum
// at (0,0) and is caught only by the turn total.  Conversely a wrong LENGTH leaves the turn
// total at 12 and is caught only by the vector sum.
//
// THE TURN TOTAL IS +12, NOT -12.  A cycle walked the other way round is the same shape with
// turn total -12, so requiring +12 picks one spelling of the two — the canonical-form rule
// C3 (`DESIGN.md` §3) applied at its cheapest point, before anything is written down.
pub struct Form {
  pub fm_h0:   integer,
  pub fm_len:  vector<integer>,
  pub fm_turn: vector<integer>,
}

// One full revolution, in twelfths.
pub const TURN_FULL = 12;

// Build a form: the heading its first side runs in, then a length and a turn per side.
// This is the only constructor — every other routine here reads one or places one.
pub fn form_new(h0: integer, lens: vector<integer>, turns: vector<integer>) -> Form {
  Form { fm_h0: head_norm(h0), fm_len: lens, fm_turn: turns }
}

// How many sides the form has.  The bound for any walk over it, and 0 for the refusal
// form, which is why a caller can treat "no sides" as "nothing to draw".
pub fn form_sides(f: Form) -> integer { len(f.fm_len) }

// The heading side `i` runs in: the start heading plus every turn taken before it.
pub fn form_heading(f: Form, i: integer) -> integer {
  turns = f.fm_turn;
  h = f.fm_h0;
  for hj in 0..i { h = h + (turns[hj] ?? 0); }
  head_norm(h)
}

// The turns added up.  A closed form's total is a full circle, so this is the cheap test
// for whether the walk comes back — checkable without generating a single coordinate.
pub fn form_turn_sum(f: Form) -> integer {
  turns = f.fm_turn;
  total = 0;
  for ti in 0..len(turns) { total = total + (turns[ti] ?? 0); }
  total
}

// Where the walk ends up, relative to where it started, in doubled coordinates.
pub fn form_close(f: Form) -> (integer, integer) {
  lens = f.fm_len;
  turns = f.fm_turn;
  ck = 0;
  cm = 0;
  h = f.fm_h0;
  for si in 0..len(lens) {
    st = head_step(h);
    sl = lens[si] ?? 0;
    ck = ck + sl * st.0;
    cm = cm + sl * st.1;
    h = h + (turns[si] ?? 0);
  }
  (ck, cm)
}

// Law J.  Both conditions, and a side count that can bound a region at all.
//
// Example: @HXF-006
pub fn form_closes(f: Form) -> boolean {
  if form_sides(f) < 3 { return false; }
  res = form_close(f);
  res.0 == 0 && res.1 == 0 && form_turn_sum(f) == TURN_FULL
}

// ── WHY A LATTICE POLYGON CANNOT BE A RECTANGLE (step S1, OD-11) ────────────
//
// Two headings 90 degrees apart exist (0 and 3), so a turtle cycle CAN turn a right angle.
// What it cannot do is make both sides quantise the same way, and that is a property of the
// hex lattice rather than of this table:
//
//     the perpendicular of (k, m) is (-m, 3k)          3*k*(-m) + m*(3k) = 0
//     its 4*len^2 is  3m^2 + 9k^2 = 3 * (3k^2 + m^2)   EXACTLY 3x the original
//
// Any lattice vector along that perpendicular is an integer multiple of the primitive one,
// so its length is sqrt(3) * (a rational) times the original — never equal, because sqrt(3)
// is irrational.  THERE IS NO SQUARE SUBLATTICE OF A HEXAGONAL LATTICE (a lattice cannot
// carry both 4-fold and 6-fold symmetry), so no choice of headings gives a rectangle whose
// two sides are counted in the same unit.
//
// That is why `housedraw`'s Plan is a CONTINUOUS rectangle rasterised centre-in-region, and
// why it — not a turtle cycle — is domain A's shape primitive.  See DESIGN.md §10.4.
pub fn perp_k(_k: integer, m: integer) -> integer { -m }
// The `m` of the perpendicular `perp_k` gives the `k` of: the step turned a quarter turn,
// which is how an outward normal is taken without leaving the integers.
pub fn perp_m(k: integer, _m: integer) -> integer { 3 * k }

// 4 * the squared world length of an arbitrary doubled-coordinate vector.
pub fn vec_n2_4(k: integer, m: integer) -> integer { 3 * k * k + m * m }

// ── THE INSIDE TEST, EXACT — NO HEX EVER LIES ON THE BOUNDARY (S3) ──────────
//
// A hex must be unambiguously INSIDE or OUTSIDE; a wall never runs through a hex, only along
// its edges.  That is only true by construction if no cell centre can sit ON the boundary
// line — and with the boundary placed AT |k| = wid it can, systematically: `housedraw`'s own
// note records that "a strict comparison sits on the last bit of a float for a whole column",
// which is why `draw_floor` carries an EPS.
//
// Placing the boundary BETWEEN cell centres removes the tie rather than tolerating it, and
// both axes then become exact integer tests that CANNOT tie, for two different reasons:
//
//     u:  2|k| <= 2*wid + 1        EVEN vs ODD — equality is impossible by parity
//     v:  m^2  <= 3 * dep^2        m^2 = 3*dep^2 would make sqrt(3) rational
//
// No epsilon, no tolerance, no tie-break rule to remember.  Verified to reproduce the gated
// cell counts exactly (5x4 -> 27, 4x4 -> 23, 6x4 -> 33).
fn iabs(v: integer) -> integer { if v < 0 { -v } else { v } }

// Is the cell at doubled offset (k, m) from the plan's centre inside a wid x dep plan?
pub fn plan_holds(wid: integer, dep: integer, k: integer, m: integer) -> boolean {
  2 * iabs(k) <= 2 * wid + 1 && m * m <= 3 * dep * dep
}

// Can a cell centre ever lie exactly ON the boundary?  No — and this returns the reason as a
// checkable fact rather than a comment: the u test compares an even to an odd number.
pub fn plan_u_can_tie(wid: integer, k: integer) -> boolean { 2 * iabs(k) == 2 * wid + 1 }

// ── S3 — FILL A CLOSED TURTLE CYCLE, AND PROVE THE FILL BY ITS OWN AREA ─────
//
// `Form` closes (law J, S1); this turns it into a region.  The turtle walks CENTRE to CENTRE,
// so every polygon vertex is a hex centre and the path traces the centre-line of the boundary
// ring.  The region is therefore every hex whose centre lies inside OR ON that polygon.
//
// WHY THE TURTLE AT ALL, when OD-11 gave domain A's rectangle to `Plan`?  Because a lattice
// polygon provably cannot be a rectangle (`X24`) — but it CAN be a triangle, a rhombus and a
// hexagon, exactly.  The turtle is the **hexagonal-tower** family: the shapes the lattice does
// hold exactly, which is precisely the set `Plan` cannot express.  Two primitives, two families,
// neither a special case of the other.
//
// THE CROSS-CHECK IS THE SHAPE'S OWN AREA, NOT A SECOND IMPLEMENTATION.  crawler's `hexforms.py`
// pins the invariant (T2, re-measured here):
//
//     sum of the integer shoelace over the boundary  ==  12 * (number of cells)
//
// A single hex shoelaces to exactly 12 in doubled (k,m) units, and holes come out negative, so
// the traced outline and the cell count CANNOT disagree.  It is exact integer arithmetic — no
// epsilon, no tolerance — and it needs no second fill routine to compare against, which is what
// `STEPS.md` asks for as option (b).

// The corner offsets in doubled (k,m) are `hex_field`'s `corner_k` / `corner_m` — already the
// canonical table, indexed the same way `hex_edge_corners` indexes it. NOT restated here: L11,
// and a private copy is exactly what caused X26.

// The turtle polygon's vertices, in doubled (k,m) RELATIVE to the start.  One vertex per side:
// the point the walk is standing on before it walks that side.
pub fn form_poly_k(f: Form) -> vector<integer> {
  lens = f.fm_len;
  turns = f.fm_turn;
  outk: vector<integer> = [];
  pk = 0;
  hk = f.fm_h0;
  for pi in 0..len(lens) {
    outk += [pk];
    stk = head_step(hk);
    pk = pk + (lens[pi] ?? 0) * stk.0;
    hk = hk + (turns[pi] ?? 0);
  }
  outk
}

// The `m` coordinates of the form's corners, in walk order — the companion of
// `form_poly_k`.  Zip the two to get the polygon a renderer or an area check wants.
pub fn form_poly_m(f: Form) -> vector<integer> {
  lens = f.fm_len;
  turns = f.fm_turn;
  outm: vector<integer> = [];
  pm = 0;
  hm = f.fm_h0;
  for pj in 0..len(lens) {
    outm += [pm];
    stm = head_step(hm);
    pm = pm + (lens[pj] ?? 0) * stm.1;
    hm = hm + (turns[pj] ?? 0);
  }
  outm
}

fn imin(a: integer, b: integer) -> integer { if a < b { a } else { b } }
fn imax(a: integer, b: integer) -> integer { if a > b { a } else { b } }

// Is (pk, pm) inside or ON the closed polygon?  Exact integer arithmetic throughout: the
// on-edge test is a zero cross product, and the crossing test compares a cross product's SIGN
// rather than computing an intersection, so there is no division and no epsilon anywhere.
//
// ON THE BOUNDARY COUNTS AS INSIDE, and that is the whole reason the closed forms come out
// integral: the turtle traces the centres of the boundary ring, so those cells ARE the shape.
pub fn poly_holds(vk: vector<integer>, vm: vector<integer>,
                  pk: integer, pm: integer) -> boolean {
  n = len(vk);
  if n < 3 { return false; }
  for ei in 0..n {
    ak = vk[ei] ?? 0;
    am = vm[ei] ?? 0;
    ej = (ei + 1) % n ?? 0;      // n >= 3 here, so the null arm is unreachable
    bk = vk[ej] ?? 0;
    bm = vm[ej] ?? 0;
    crs = (bk - ak) * (pm - am) - (bm - am) * (pk - ak);
    if crs == 0 {
      if pk >= imin(ak, bk) && pk <= imax(ak, bk)
         && pm >= imin(am, bm) && pm <= imax(am, bm) { return true; }
    }
  }
  inside = false;
  for ci in 0..n {
    ck = vk[ci] ?? 0;
    cm = vm[ci] ?? 0;
    cj = (ci + 1) % n ?? 0;
    dk = vk[cj] ?? 0;
    dm = vm[cj] ?? 0;
    if (cm > pm) != (dm > pm) {
      cr2 = (dk - ck) * (pm - cm) - (dm - cm) * (pk - ck);
      if dm > cm {
        if cr2 > 0 { inside = !inside; }
      } else {
        if cr2 < 0 { inside = !inside; }
      }
    }
  }
  inside
}

// THE ROUTINE: fill a closed Form into `cells`, anchored so the walk starts at cell (cq, cr).
//
// A NON-CLOSING FORM IS REFUSED, NOT PATCHED (the control).  Law J is the admission test; a walk
// that does not return to its start bounds no region, and silently filling "what it would have
// enclosed" would invent a shape the author never wrote.
pub fn form_fill(f: Form, cq: integer, cr: integer, cells: HexSet) -> integer {
  if !form_closes(f) { return -1; }
  vk = form_poly_k(f);
  vm = form_poly_m(f);
  bk = lattice_k(cq, cr);
  bm = lattice_m(cq, cr);
  n = 0;
  for fr in hexset_r0(cells)..hexset_r0(cells) + hexset_h(cells) {
    for fq in hexset_q0(cells)..hexset_q0(cells) + hexset_w(cells) {
      if poly_holds(vk, vm, lattice_k(fq, fr) - bk, lattice_m(fq, fr) - bm) {
        hexset_set(cells, fq, fr, true);
        n = n + 1;
      }
    }
  }
  n
}

// TWICE THE SIGNED AREA of the set's boundary, in doubled (k,m) units — summed over every
// boundary edge directly, so it needs no loop tracing: the shoelace of a closed loop is the sum
// over its edges, and summing over all loops is summing over all edges.
//
// `hex_edge_corners(dir)` gives the edge's two corners in the winding order that makes the outer
// boundary positive and an enclosed hole negative, which is what makes `= 12 * cells` a real
// check on enclosure and not just on count.
pub fn set_shoelace2(cells: HexSet) -> integer {
  s = 0;
  for sr in hexset_r0(cells)..hexset_r0(cells) + hexset_h(cells) {
    for sq in hexset_q0(cells)..hexset_q0(cells) + hexset_w(cells) {
      if hexset_get(cells, sq, sr) {
        for sd in 0..6 {
          (nq, nr) = hex_neighbor(sq, sr, sd);
          if !hexset_get(cells, nq, nr) {
            (c1, c2) = hex_edge_corners(sd);
            ak = lattice_k(sq, sr) + corner_k(c1);
            am = lattice_m(sq, sr) + corner_m(c1);
            b2k = lattice_k(sq, sr) + corner_k(c2);
            b2m = lattice_m(sq, sr) + corner_m(c2);
            s = s + ak * b2m - b2k * am;
          }
        }
      }
    }
  }
  s
}

// ── THE CLOSED FORMS — predicted, not read off the fill ────────────────────
//
// Each is the count of lattice points inside-or-on the shape in the CENTRE basis (spanned by one
// edge step and the next-but-one), so each is a plain combinatorial identity rather than a
// measurement:
//
//   triangle side n   the lattice points of a right triangle (n+1)(n+2)/2
//   rhombus a x b     a box in the centre basis, (a+1)(b+1)
//   hexagon side n    the hex region of radius n, 3n^2 + 3n + 1
pub fn tri_cells(n: integer) -> integer { ((n + 1) * (n + 2) / 2) ?? 0 }
// How many cells an a-by-b rhombus covers, without building it.  Size a buffer, or reject
// a request as too large, before paying for the cells.
pub fn rhombus_cells(a: integer, b: integer) -> integer { (a + 1) * (b + 1) }
// How many cells a hexagon of radius n covers — the same question as `rhombus_cells` for
// the other common shape, and the same reason to ask it first.
pub fn hexagon_cells(n: integer) -> integer { 3 * n * n + 3 * n + 1 }

// ── S4 — THE BOUNDARY OF A FILLED REGION, AND WHETHER ITS CORNERS ARE PRECISE ─
//
// The wall is the boundary of the fill (`I3`), taken as EDGES — `housedraw::draw_walls` already
// does that and is reused unchanged.  What was never checked anywhere is what happens WHERE TWO
// RUNS MEET (`DESIGN.md` §10.4): the corner.  These are the topology primitives parts 1 and 3
// need, over the cell set directly, so no EdgeSet has to be threaded through.
//
// A vertex is keyed by its exact doubled-(k,m) integer position, so "the same corner" is integer
// equality — no float quantisation, the same discipline as the OD-12 chain check.
const VKEY_OFF = 500;
const VKEY_SPAN = 2000;

fn vkey(k: integer, m: integer) -> integer { (k + VKEY_OFF) * VKEY_SPAN + (m + VKEY_OFF) }

// The two endpoint keys of boundary edge `dir` of cell (q,r), as a pair.
fn bedge_a(q: integer, r: integer, dir: integer) -> integer {
  (c1, c2) = hex_edge_corners(dir);
  vkey(lattice_k(q, r) + corner_k(c1), lattice_m(q, r) + corner_m(c1))
}

fn bedge_b(q: integer, r: integer, dir: integer) -> integer {
  (c1, c2) = hex_edge_corners(dir);
  vkey(lattice_k(q, r) + corner_k(c2), lattice_m(q, r) + corner_m(c2))
}

// Every boundary vertex of the region, one entry per incident edge — so an entry's multiplicity
// IS that vertex's degree.
fn boundary_vertices(cells: HexSet) -> vector<integer> {
  vs: vector<integer> = [];
  for br in hexset_r0(cells)..hexset_r0(cells) + hexset_h(cells) {
    for bq in hexset_q0(cells)..hexset_q0(cells) + hexset_w(cells) {
      if hexset_get(cells, bq, br) {
        for bd in 0..6 {
          (nq, nr) = hex_neighbor(bq, br, bd);
          if !hexset_get(cells, nq, nr) {
            vs += [bedge_a(bq, br, bd)];
            vs += [bedge_b(bq, br, bd)];
          }
        }
      }
    }
  }
  vs
}

fn degree_count(vs: vector<integer>, want_lo: integer, want_hi: integer) -> integer {
  n = 0;
  for di in 0..len(vs) {
    firstd = true;
    for dk in 0..di { if (vs[dk] ?? 0) == (vs[di] ?? 0) { firstd = false; } }
    if firstd {
      deg = 0;
      for dj in 0..len(vs) { if (vs[dj] ?? 0) == (vs[di] ?? 0) { deg = deg + 1; } }
      if deg >= want_lo && deg <= want_hi { n = n + 1; }
    }
  }
  n
}

// PART 1a — dangling ends.  A closed loop has NONE: every boundary vertex is entered and left.
pub fn boundary_ends(cells: HexSet) -> integer { degree_count(boundary_vertices(cells), 1, 1) }

// PART 1b — pinches.  crawler's tracer relies on a hex boundary never pinching at a vertex
// (each vertex touches exactly 3 mutually adjacent cells), verified there over 412 forms — a T2
// claim, re-measured here as a gate rather than assumed.
pub fn boundary_branches(cells: HexSet) -> integer { degree_count(boundary_vertices(cells), 3, 99) }

// PART 1c — how many separate closed loops the boundary forms.  ONE means the region is a single
// simply-connected shape; TWO means it has a hole (the outer loop plus the hole's), which is `I3`'s
// named failure.  Label propagation over the edge graph — the edge count is small and this needs
// no auxiliary structure.
pub fn boundary_loops(cells: HexSet) -> integer {
  ea: vector<integer> = [];
  eb: vector<integer> = [];
  for lr in hexset_r0(cells)..hexset_r0(cells) + hexset_h(cells) {
    for lq in hexset_q0(cells)..hexset_q0(cells) + hexset_w(cells) {
      if hexset_get(cells, lq, lr) {
        for ld in 0..6 {
          (nq, nr) = hex_neighbor(lq, lr, ld);
          if !hexset_get(cells, nq, nr) {
            ea += [bedge_a(lq, lr, ld)];
            eb += [bedge_b(lq, lr, ld)];
          }
        }
      }
    }
  }
  ne = len(ea);
  if ne == 0 { return 0; }
  comp: vector<integer> = [];
  for ci in 0..ne { comp += [ci]; }
  changed = true;
  while changed {
    changed = false;
    for pi in 0..ne {
      for pj in 0..ne {
        if pi != pj {
          shares = (ea[pi] ?? 0) == (ea[pj] ?? 0) || (ea[pi] ?? 0) == (eb[pj] ?? 0)
                || (eb[pi] ?? 0) == (ea[pj] ?? 0) || (eb[pi] ?? 0) == (eb[pj] ?? 0);
          if shares && (comp[pj] ?? 0) < (comp[pi] ?? 0) {
            comp[pi] = comp[pj] ?? 0;
            changed = true;
          }
        }
      }
    }
  }
  nloops = 0;
  for qi in 0..ne {
    firstq = true;
    for qk in 0..qi { if (comp[qk] ?? 0) == (comp[qi] ?? 0) { firstq = false; } }
    if firstq { nloops = nloops + 1; }
  }
  nloops
}

// ── PARTS 2 AND 4 — WRITTEN NOW, ENFORCED LATER ────────────────────────────
//
// CASHED IN.  The corner ANGLE (part 2) and the MITER POINT (part 4) needed the fitted analytic
// surface; it landed at S4b (`X47`) and gained its extent at `G2` (`X61`), so `tests/form.loft` §17
// now CHECKS them.  Part 4's original wording — *"the miter sits on the exact model corner"* — turned
// out to be wrong: `Plan` is continuous-then-rasterised, so the continuous corner is quantised away
// (I-QUANT) and what the miter recovers exactly is the CELL-REGION corner.  History kept below.
//
// (was) The corner ANGLE (part 2) and the MITER POINT (part 4) need the fitted analytic surface, which
// does not exist until S8.  `DESIGN.md` §10.4 says to write them into the gate red at S4.
//
// A PERMANENTLY RED SUITE IS NOT A SIGNAL, so instead of failing every run from here to S8 they
// are a TRIPWIRE: while `SURFACE_LANDED` is false the gate prints them as PENDING and asserts
// nothing about them; flipping this to true at S8 makes the gate FAIL until the real checks are
// written.  They cannot be forgotten, and `make test` stays a live signal in the meantime.
// (If a red suite is wanted instead, flip this today — the gate section is already written.)
pub const SURFACE_LANDED = true;

// The same discipline for `rebuild` (S8), which `tests/trip.loft` is written against BEFORE it
// exists (`DESIGN.md` §8.0).  While this is false the trip gate is legitimately RED, and the
// runner asserts that redness; flipping it at S8 makes the runner demand a GREEN trip gate.
pub const REBUILD_LANDED = true;

// ── A4 — LAW J IS NOT A SUFFICIENT ADMISSION RULE ──────────────────────────
//
// Law J asks only that the walk CLOSES: `Σ turn = 12` and the vector sum is zero.  Rung A4 grew
// the space to unequal sides and REFLEX corners, and found law J admits two things it must not:
//
//   NON-SIMPLE walks — a repeated vertex, so the "polygon" crosses or touches itself and has no
//                      well-defined inside.
//   NON-CONVEX forms — which VIOLATE LAW F.  Measured over the 70 simple non-convex 4-sided
//                      forms: 62 of 70 at unit scale, and still 36 of 70 grown five times, draw
//                      a field that some OTHER form also draws.  `draw` is not injective there,
//                      so no recovery — hull, tracing, or anything else — can tell them apart.
//                      Scaling shrinks the failure but never removes it.
//
// THAT IS A MODEL-SPACE LIMIT, NOT A METHOD LIMIT, and it is why the fix is at the DOORSTEP
// (`K-FIT`): the editor refuses a non-convex stencil at authoring time rather than letting one be
// drawn and fail to come back.  `DESIGN.md` §5.2 — a restriction the census finds is a fact to
// record, not a defect to engineer away.
//
// A convex form is exactly one whose turns are all POSITIVE: every exterior angle turning the same
// way, summing to one revolution.  A turn of 0 is a straight join, which is a re-spelling of a
// longer side rather than a corner, so it is refused too.
//
// Example: @HXF-005
pub fn form_is_convex(f: Form) -> boolean {
  turns = f.fm_turn;
  for ci in 0..len(turns) { if (turns[ci] ?? 0) <= 0 { return false; } }
  true
}

// Does the walk visit any point twice?  A repeated vertex means the boundary is not a simple
// closed curve, so "inside" is not defined and the fill is meaningless.
pub fn form_is_simple(f: Form) -> boolean {
  vk = form_poly_k(f);
  vm = form_poly_m(f);
  n = len(vk);
  for ai in 0..n {
    for aj in 0..n {
      if ai < aj && (vk[ai] ?? 0) == (vk[aj] ?? 0) && (vm[ai] ?? 0) == (vm[aj] ?? 0) {
        return false;
      }
    }
  }
  true
}

// THE ADMISSION RULE, as A4 leaves it: closed (law J), simple, and convex.
//
// Example: @HXF-005
pub fn form_admissible(f: Form) -> boolean {
  form_closes(f) && form_is_simple(f) && form_is_convex(f)
}

// ── THE PLAN, AND THE LATTICE→WORLD MAP ─────────────────────────────────────
//
// A house is a `Plan` — the rectangle it rasterises from — not a turtle cycle, so `Plan` is a
// model object and lives here beside `Form`.  With it: `cell_x`/`cell_y` (the lattice→world map)
// and `side_edges`, which READS the field for a Plan's side and serves the surface fitter as
// well as the drawer.
// ⚠ `HEX_LEN` is NOT declared here — it is `hex_grid`'s (`= SQRT3 * HEX_SIZE`, one hex step).
// The library that owns a shared value is the only place it is written; a private copy would
// be refused by the compiler the moment both are imported.
const SQ3_2 = 0.8660254037844386;         // sqrt(3)/2 — half a hex step; also metres per wu
const EPS = 0.000000001;                  // a float sign-test guard, not a P4 tolerance
// A cell centre's world x.  The one place the exact lattice becomes a float, and it is
// deliberately at the EDGE of the library: everything upstream stays integer.
pub fn cell_x(q: integer, r: integer) -> float { (lattice_k(q, r) as float) * SQ3_2 }
// A cell centre's world y — the other half of `cell_x`.
pub fn cell_y(q: integer, r: integer) -> float { (lattice_m(q, r) as float) * 0.5 }
// Absolute value for floats, used by the world-space helpers below.
pub fn fabs(v: float) -> float { if v < 0.0 { -v } else { v } }

// The 6 rotations are exact on the lattice, so use the exact table rather than trig:
// no rounding, and no fallible float math to discharge.
fn rot_cos(n: integer) -> float {
  if n == 0 { 1.0 } else if n == 1 { 0.5 } else if n == 2 { -0.5 }
  else if n == 3 { -1.0 } else if n == 4 { -0.5 } else { 0.5 }
}
fn rot_sin(n: integer) -> float {
  if n == 0 { 0.0 } else if n == 1 { SQ3_2 } else if n == 2 { SQ3_2 }
  else if n == 3 { 0.0 } else if n == 4 { -SQ3_2 } else { -SQ3_2 }
}

// ── the plan: a rectangle in its own frame ──────────────────────────────────
pub struct Plan {
  pl_cq: integer,     // anchor cell
  pl_cr: integer,
  pl_wid: integer,    // extent along local u, in hex steps (1.5 m each)
  pl_dep: integer,    // extent along local v
  pl_rot: integer,    // 0..5, 60 degree steps
  pl_mir: boolean,    // the 12th orientation: (rot, mir) is the D6 the lattice has
}

// Place a form in the world: which cell it is centred on, how wide and deep, which of the
// six exact rotations, and whether it is mirrored.  The Plan is the frame and the Form is
// the shape — keeping them apart is what lets one shape be placed many times.
pub fn plan_new(cq: integer, cr: integer, wid: integer, dep: integer,
                rot: integer, mir: boolean) -> Plan {
  Plan { pl_cq: cq, pl_cr: cr, pl_wid: wid, pl_dep: dep,
         pl_rot: ((rot % 6) + 6) % 6, pl_mir: mir }
}

// A flip toggles the chirality and NOTHING else — no side remapping, no t -> 1-t.
// If an implementation finds itself remapping, the frame rule above is wrong somewhere.
pub fn plan_flip(p: Plan) -> Plan {
  Plan { pl_cq: p.pl_cq, pl_cr: p.pl_cr, pl_wid: p.pl_wid, pl_dep: p.pl_dep,
         pl_rot: p.pl_rot, pl_mir: !p.pl_mir }
}

// Half the plan's width in world units — the extent a caller needs for a bound or a hit
// test, without reconstructing the frame.
pub fn plan_hw(p: Plan) -> float { (p.pl_wid as float) * HEX_LEN * 0.5 }
// Half the plan's depth in world units, the companion of `plan_hw`.
pub fn plan_hd(p: Plan) -> float { (p.pl_dep as float) * HEX_LEN * 0.5 }

fn frame_s(p: Plan) -> float { if p.pl_mir { -1.0 } else { 1.0 } }

// A point in the plan's own frame, expressed in the world.  The rotations are the six
// exact lattice ones, so this and `plan_to_local` invert each other rather than drifting.
pub fn plan_to_world(p: Plan, lu: float, lv: float) -> (float, float) {
  co = rot_cos(p.pl_rot);
  si = rot_sin(p.pl_rot);
  s = frame_s(p);
  ( cell_x(p.pl_cq, p.pl_cr) + lu * s * co - lv * si,
    cell_y(p.pl_cq, p.pl_cr) + lu * s * si + lv * co )
}

// A world point expressed in the plan's frame — the inverse of `plan_to_world`, and how a
// click or a probe is asked "where is this ON the plan?".
pub fn plan_to_local(p: Plan, px: float, py: float) -> (float, float) {
  co = rot_cos(p.pl_rot);
  si = rot_sin(p.pl_rot);
  s = frame_s(p);
  dx = px - cell_x(p.pl_cq, p.pl_cr);
  dy = py - cell_y(p.pl_cq, p.pl_cr);
  ( dx * s * co + dy * s * si, -dx * si + dy * co )
}

// ── sides ───────────────────────────────────────────────────────────────────
//
// Sides run 0..3 as a loop, each A -> B, with the outward normal 90 degrees clockwise
// from B - A.  t runs A -> B, which is what an observer standing OUTSIDE reads
// left to right.  Under a mirror the endpoints EXCHANGE (rule 1 above).
//
//    side 0  (+u face)   A (+hw,-hd)  B (+hw,+hd)
//    side 1  (+v face)   A (+hw,+hd)  B (-hw,+hd)
//    side 2  (-u face)   A (-hw,+hd)  B (-hw,-hd)
//    side 3  (-v face)   A (-hw,-hd)  B (+hw,-hd)

fn side_u(p: Plan, side: integer, which: integer) -> float {
  hw = plan_hw(p);
  e = which;
  if p.pl_mir { e = 1 - which; }          // the exchange, applied once
  if side == 0 { hw }
  else if side == 1 { if e == 0 { hw } else { -hw } }
  else if side == 2 { -hw }
  else { if e == 0 { -hw } else { hw } }
}

fn side_v(p: Plan, side: integer, which: integer) -> float {
  hd = plan_hd(p);
  e = which;
  if p.pl_mir { e = 1 - which; }
  if side == 0 { if e == 0 { -hd } else { hd } }
  else if side == 1 { hd }
  else if side == 2 { if e == 0 { hd } else { -hd } }
  else { -hd }
}

// A point a fraction `t` along one side of the plan, in world coordinates.  Use it to
// place a door, a label or a joint without computing the side's endpoints yourself.
pub fn plan_side_point(p: Plan, side: integer, t: float) -> (float, float) {
  au = side_u(p, side, 0);
  av = side_v(p, side, 0);
  bu = side_u(p, side, 1);
  bv = side_v(p, side, 1);
  plan_to_world(p, au + t * (bu - au), av + t * (bv - av))
}

// Outward, unit — taken from the MASSING (centre -> side midpoint), never from the
// endpoint order.  Reading it off B - A lets a broken endpoint rule supply its own
// definition of outward, and the facade gate can then never fire.
pub fn plan_side_normal(p: Plan, side: integer) -> (float, float) {
  (mx, my) = plan_side_point(p, side, 0.5);
  ox = cell_x(p.pl_cq, p.pl_cr);
  oy = cell_y(p.pl_cq, p.pl_cr);
  dx = mx - ox;
  dy = my - oy;
  ln = sqrt(dx * dx + dy * dy) ?? 0.0;
  if ln < EPS { return (0.0, 0.0); }
  (dx / ln ?? 0.0, dy / ln ?? 0.0)
}

// ── the boundary edges lying on one side, with their reading parameter ──────
pub struct SideRun {
  pub sr_qa: vector<integer>,
  pub sr_ra: vector<integer>,
  pub sr_qb: vector<integer>,
  pub sr_rb: vector<integer>,
  pub sr_t:  vector<float>,     // 0..1 along the side, A -> B, as read from OUTSIDE
}

// How many entries the run holds — the bound for walking one side's collected points.
pub fn side_run_len(sr: SideRun) -> integer { len(sr.sr_t) }

// Which side an edge lies on, and where along it.  An edge's midpoint is the midpoint
// of the two cell centres it separates — the shared edge bisects that line — so no
// corner table is needed.
pub fn side_edges(p: Plan, cells: HexSet, side: integer) -> SideRun {
  hw = plan_hw(p);
  hd = plan_hd(p);
  half = hw;
  if side == 0 || side == 2 { half = hd; }
  // reading direction along the side, before the mirror
  sgn = 1.0;
  if side == 1 || side == 2 { sgn = -1.0; }
  if p.pl_mir { sgn = -sgn; }

  qa: vector<integer> = [];
  ra: vector<integer> = [];
  qb: vector<integer> = [];
  rb: vector<integer> = [];
  ts: vector<float> = [];

  for er in hexset_r0(cells)..hexset_r0(cells) + hexset_h(cells) {
    for eq in hexset_q0(cells)..hexset_q0(cells) + hexset_w(cells) {
      if hexset_get(cells, eq, er) {
        for ed in 0..6 {
          mq = nb_q(eq, er, ed);
          mr = nb_r(eq, er, ed);
          if !hexset_get(cells, mq, mr) {
            mx = (cell_x(eq, er) + cell_x(mq, mr)) * 0.5;
            my = (cell_y(eq, er) + cell_y(mq, mr)) * 0.5;
            (lu, lv) = plan_to_local(p, mx, my);
            // classify by which local coordinate is furthest outside the massing
            ex_u = fabs(lu) - hw;
            ex_v = fabs(lv) - hd;
            got = -1;
            along = 0.0;
            if ex_u > ex_v {
              if lu > 0.0 { got = 0; } else { got = 2; }
              along = lv;
            } else {
              if lv > 0.0 { got = 1; } else { got = 3; }
              along = lu;
            }
            if got == side {
              qa += [eq];
              ra += [er];
              qb += [mq];
              rb += [mr];
              ts += [0.5 + sgn * along / (2.0 * half) ?? 0.0];
            }
          }
        }
      }
    }
  }
  SideRun { sr_qa: qa, sr_ra: ra, sr_qb: qb, sr_rb: rb, sr_t: ts }
}

tests/01-hex-form.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_form — the conformance tests: the turtle form, its canonical text, and the byte round trip.
//
// These assert the library's CONTRACT.  The exhaustive measurement lives in hexbody's gates
// (`tests/form.loft`, `tests/text.loft`), which print their evidence and carry a control that must
// fire.  This is what CI runs under `LOFT_DENY_WARNINGS=1`.
use hex_form::*;
use hex_field::*;

fn main() {
  f = form_new(0, [1, 1, 1], [4, 4, 4]);            // an equilateral triangle
  assert(len(f.fm_len) == 3, "a triangle has three sides");
}

fn test_a_closed_form_is_admissible_and_fills() {
  f = form_new(0, [1, 1, 1], [4, 4, 4]);
  assert(form_admissible(f), "the equilateral triangle is admissible");
  cells = hexset_chunk(-10, -10, 21, 21);
  n = form_fill(f, 0, 0, cells);
  assert(n == 3, "the len-1 triangle fills 3 cells, got {n}");
}

fn test_the_text_round_trips_byte_for_byte() {
  f = form_new(0, [1, 2, 1, 2], [3, 3, 3, 3]);
  t = form_write(f, "house");
  g = form_read(t);
  assert(form_eq(f, g), "read(write(f)) = f");
  assert(form_write(g, "house") == t, "and the bytes match exactly");
}

fn test_the_canonical_text_is_start_corner_invariant() {
  // The same cycle written from its three corners collapses to one canonical text -- this is
  // what makes a project file diffable: two authors of the same shape produce the same bytes.
  // A corner shift rotates the side and turn lists and advances h0 by the turns it skipped;
  // this cycle is fully periodic, so the lists come back unchanged and h0 alone moves.
  a = form_new(0, [1, 1, 1], [4, 4, 4]);
  b = form_new(4, [1, 1, 1], [4, 4, 4]);
  c = form_new(8, [1, 1, 1], [4, 4, 4]);
  assert(form_write(a, "s") != form_write(b, "s"), "three corners, three texts");
  assert(form_canon_text(a, "s") == form_canon_text(b, "s"), "one shape, one canonical text");
  assert(form_canon_text(a, "s") == form_canon_text(c, "s"), "from every corner");
}

fn test_a_non_closing_walk_is_refused() {
  // turns that do not sum to a full revolution do not close -- and an open walk is not a form.
  f = form_new(0, [1, 1, 1], [4, 4, 5]);
  assert(!form_admissible(f), "a walk whose turns miss closure is refused");
}

tests/02-worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `hex_form` (@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: @HXF-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling.  These are
// teaching examples, distinct from the conformance suite in `01-hex-form.loft`.
//
// WHAT THIS PACKAGE ASKS OF A CALLER, and why a signature cannot carry it.  A
// form is a walk, and its canonical text is the walk written down.  Both sides
// travel as bare integers:
//
//   fm_h0                 a heading 0..11, where EVEN is an edge neighbour and
//                         ODD a vertex direction — two different step lengths
//   fm_len[i]             a COUNT of steps in side i's own heading, never a
//                         distance: `len 1` is a different length on an even
//                         heading than on an odd one, by exactly sqrt(3)
//   fm_turn[i]            twelfths of a revolution, and the sum must be +12
//
// and the text they are written to has five rules (C1-C5, `DESIGN.md` §3):
// integers only, fixed order, reduced forms, fixed layout, defaults omitted.
// Every one of them is a way a HAND-WRITTEN form parses and means something
// else, so every one of them is worked below.
use hex_form::*;
use hex_field::*;

// A patch of lattice big enough to hold every shape here, well clear of its edges:
// `form_fill` counts only cells inside the patch, so a patch that clipped the shape
// would make an assertion about the count silently measure the patch instead.
fn patch() -> HexSet { hexset_chunk(0 - 12, 0 - 12, 25, 25) }

// @HXF-001 — the canonical text is a FIXED LAYOUT (C2, C4), and the bytes are
// the contract.  One record per line, fields in declaration order, single-space
// separated, no trailing space and no trailing newline.  This matters because
// the round-trip gate is a byte `diff`: a project file is diffable, and an undo
// history is exact, only because one model has exactly one spelling.  If you
// are generating this text from somewhere else, generate THESE bytes.
fn test_the_text_is_a_fixed_layout_byte_for_byte() {
  f = form_new(0, [1, 1, 1], [4, 4, 4]);          // the unit equilateral triangle
  t = form_write(f, "tri");

  // Pinned exactly — including the newline placement and the absence of a
  // trailing one.  Read it as the format's specification, in the format.
  want = "stencil tri h0 0\nside 0 len 1 turn 4\nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(t == want, "the exact bytes; got\n[{t}]");

  // And the round trip is on the BYTES, not merely on the model: reading and
  // re-writing reproduces the same text, which is what makes a `diff` mean
  // "the model changed" rather than "the file was rewritten".
  g = form_read(t);
  assert(form_eq(f, g), "read(write(f)) is f");
  assert(form_write(g, "tri") == t, "write(read(t)) is t, byte for byte");
}

// @HXF-002 — the reader accepts EXACTLY what the writer emits, and a refusal
// arrives as a ZERO-SIDED FORM, not as an error.  `form_read` returns `Form`
// unconditionally, so nothing in the signature tells you a parse can fail:
// the check is yours to write, and it is `form_sides(f) == 0`.
//
// The rule the refusals enforce is C1 (integers only) and C4 (fixed layout).
// Both are ordinary things to get wrong by hand — a trailing space survives
// most editors, and a length that is not a number is a typo — and neither is
// repaired.  Repairing them is what would break the byte diff, so a text this
// library cannot write is a text it will not read.
fn test_a_refusal_is_a_zero_sided_form_not_an_error() {
  good = "stencil tri h0 0\nside 0 len 1 turn 4\nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(form_sides(form_read(good)) == 3, "the canonical text reads three sides");

  // C4 — a trailing space is invisible in an editor and is not the layout.
  trailing = "stencil tri h0 0\nside 0 len 1 turn 4 \nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(form_sides(form_read(trailing)) == 0, "a trailing space is refused");

  // C4 — so is an extra field, however harmless it looks.
  junk = "stencil tri h0 0\nside 0 len 1 turn 4 note\nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(form_sides(form_read(junk)) == 0, "an extra field is refused");

  // C1 — the sharpest one.  A length that is not an integer is not a spelling
  // slip: repaired to 0 it would be a SIDE THAT IS NOT THERE, admitted into the
  // model without a word.  It is refused instead.
  typo = "stencil tri h0 0\nside 0 len x turn 4\nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(form_sides(form_read(typo)) == 0, "a non-integer length is refused, not read as 0");

  // C1 again, on the header — a heading is spelled 0..11 and nothing else, even
  // though 99 and 3 name the same direction.  Two spellings of one heading would
  // be two texts for one model.
  wide = "stencil tri h0 99\nside 0 len 1 turn 4\nside 1 len 1 turn 4\nside 2 len 1 turn 4";
  assert(form_sides(form_read(wide)) == 0, "an unnormalised heading is refused");
  assert(form_sides(form_read(good)) == 3, "and the good text still reads");
}

// @HXF-003 — the NAME is not part of the model, and it has to be one word.
// `form_write` takes a name and `form_read_name` gives it back, but `Form` has
// no name field: two stencils that differ only by name are the same form, and
// the name you get out is the name you last wrote in.  The trap is the space —
// `form_write` will happily emit `stencil my house h0 0`, and nothing will read
// it back, because the layout puts `h0` in the third field.
fn test_the_name_is_written_beside_the_model_not_into_it() {
  f = form_new(0, [1, 1, 1], [4, 4, 4]);
  t = form_write(f, "house");
  assert(form_read_name(t) == "house", "the name comes back from the text");

  // The model round-trips without it, so the name has to be carried by you.
  g = form_read(t);
  assert(form_eq(f, g), "the form is the same form");
  assert(form_write(g, "shed") != t, "the name is a write-time argument, not stored");

  // A name with a space writes a text that cannot be read back — a zero-sided
  // form, silently.  Names are one word; validate yours before you write it.
  spaced = form_write(f, "my house");
  assert(form_sides(form_read(spaced)) == 0, "a multi-word name does not round-trip");
}

// @HXF-004 — one shape, one text, WHICHEVER CORNER it was drawn from (C3).
// A closed cycle has no distinguished first corner, so the same shape drawn
// starting at a different vertex is a different `Form` with the same outline:
// the side and turn lists rotate, and the starting heading advances by the
// turns that were skipped.  `form_canon` picks one of those spellings — the
// lexicographically smallest `(turns, lens, h0)` — so `form_canon_text` is what
// two authors of one shape both produce.  `form_write` is NOT that: it writes
// the spelling you happen to hold.
fn test_one_shape_has_one_canonical_text_from_any_corner() {
  // A 2x3 rhombus, walked from corner 0 and from corner 1.  Corner 1 rotates
  // both lists left by one AND advances h0 by the turn that was skipped (4).
  a = form_new(0, [2, 3, 2, 3], [4, 2, 4, 2]);
  b = form_new(4, [3, 2, 3, 2], [2, 4, 2, 4]);
  assert(form_admissible(a) && form_admissible(b), "both spellings are admissible");

  // Same outline, so the same cells — the fill cannot tell them apart.
  na = form_fill(a, 0, 0, patch());
  nb = form_fill(b, 0, 0, patch());
  assert(na == 12 && nb == 12, "both fill rhombus_cells(2,3) = {rhombus_cells(2, 3)}");

  // But the raw texts differ, and only the canonical one is stable.
  assert(form_write(a, "r") != form_write(b, "r"), "the raw spellings differ");
  ca = form_canon_text(a, "r");
  assert(ca == form_canon_text(b, "r"), "one shape, one canonical text");
  want = "stencil r h0 4\nside 0 len 3 turn 2\nside 1 len 2 turn 4\nside 2 len 3 turn 2\nside 3 len 2 turn 4";
  assert(ca == want, "and it is this text; got\n[{ca}]");

  // THE TRAP.  Rotating the lists by hand and leaving `h0` alone is not the
  // same shape — it is that shape turned in place.  It still closes, it is
  // still admissible, and it still fills twelve cells, so nothing downstream
  // objects.  The canonical text is what notices.
  c = form_new(0, [3, 2, 3, 2], [2, 4, 2, 4]);
  assert(form_admissible(c), "the mis-shifted spelling is admissible too");
  assert(form_fill(c, 0, 0, patch()) == 12, "and fills the same number of cells");
  assert(form_canon_text(c, "r") != ca, "but it is a different shape, and the text says so");
}

// @HXF-005 — REDUCED FORMS (C3's companion rule): a turn of 0 is not a corner.
// Walking straight through a vertex re-spells one long side as two short ones,
// which is a second way to write one region — exactly what the canonical text
// exists to prevent.  So `form_admissible` refuses it, even though the walk
// closes, is simple, and fills precisely the same cells.
//
// This is the reason to call `form_admissible` and not `form_closes`: the fill
// will not tell you, because the two spellings ARE the same region.
fn test_a_turn_of_zero_is_a_respelling_not_a_corner() {
  reduced   = form_new(0, [2, 2, 2], [4, 4, 4]);            // side-2 triangle, 3 corners
  unreduced = form_new(0, [1, 1, 2, 2], [0, 4, 4, 4]);      // its first side, walked in two

  // The unreduced spelling passes every test law J has to offer.
  assert(form_closes(unreduced), "it closes");
  assert(form_is_simple(unreduced), "it visits no point twice");
  assert(!form_is_convex(unreduced), "but a turn of 0 is not a corner");
  assert(!form_admissible(unreduced), "so it is not an admissible form");
  assert(form_admissible(reduced), "the reduced spelling is");

  // And the regions are identical, which is why the fill is no help here.
  n1 = form_fill(reduced, 0, 0, patch());
  n2 = form_fill(unreduced, 0, 0, patch());
  assert(n1 == 6, "the side-2 triangle is tri_cells(2) = {tri_cells(2)} cells");
  assert(n2 == n1, "and the unreduced spelling draws the same {n1}");
}

// @HXF-006 — law J is TWO INDEPENDENT conditions, and each catches what the
// other cannot.  A walk closes when the step vectors sum to zero AND the turns
// sum to one full revolution; neither implies the other, so checking one is
// not a cheap version of checking both.
//
// The asymmetry is worth seeing once: the last turn happens AFTER the last
// side, so it cannot move the endpoint.  Get it wrong and the walk still ends
// where it started.
fn test_closing_is_two_conditions_neither_implying_the_other() {
  // A wrong final turn: the endpoint is perfect, the revolution is not.
  bad_turn = form_new(0, [2, 2, 2], [4, 4, 5]);
  end = form_close(bad_turn);
  assert(end.0 == 0 && end.1 == 0, "the walk still ends where it started");
  assert(form_turn_sum(bad_turn) == 13, "but it turned {form_turn_sum(bad_turn)} twelfths, not 12");
  assert(!form_closes(bad_turn), "only the turn total catches this");

  // A wrong length: the revolution is perfect, the endpoint is not.
  bad_len = form_new(0, [2, 2, 3], [4, 4, 4]);
  off = form_close(bad_len);
  assert(form_turn_sum(bad_len) == 12, "one full revolution, exactly");
  assert(off.0 == 0 - 1 && off.1 == 0 - 3, "but it ends at ({off.0},{off.1}), not the start");
  assert(!form_closes(bad_len), "only the vector sum catches this");
}

// @HXF-007 — INTEGERS ONLY (C1), and the reason it is possible: a length in
// this model is a COUNT of steps in that side's own heading, never a distance.
// The twelve headings come in two classes — even ones step to an edge
// neighbour, odd ones to a vertex — and their world lengths differ by exactly
// sqrt(3).  So `len 1` on heading 0 and `len 1` on heading 1 are two different
// distances written with the same integer, and a caller that compares lengths
// across the classes as floats is comparing an irrational ratio.
//
// The library hands you that ratio as an INTEGER instead: `head_n2_4` is four
// times the squared world length, which is 12 for an edge step and 36 for a
// vertex one — exactly three times, with no rounding anywhere.
fn test_a_length_is_a_step_count_not_a_distance() {
  assert(head_n2_4(0) == 12, "an edge step: 4 * len^2 = 12");
  assert(head_n2_4(1) == 36, "a vertex step: 4 * len^2 = 36");
  assert(head_n2_4(1) == 3 * head_n2_4(0), "exactly 3x — the sqrt(3), squared and exact");
  assert(head_is_edge(0) && !head_is_edge(1), "even headings are the edge neighbours");

  // A parallelogram whose sides alternate between the two classes.  It is a
  // perfectly ordinary admissible form, and its four lengths are written 2, 1,
  // 2, 1 — two of those integers count edge steps and two count vertex steps.
  p = form_new(0, [2, 1, 2, 1], [1, 5, 1, 5]);
  assert(form_admissible(p), "alternating classes is an ordinary form");
  assert(form_heading(p, 0) == 0, "side 0 runs on an edge heading");
  assert(form_heading(p, 1) == 1, "side 1 runs on a vertex heading");
  assert(head_is_edge(form_heading(p, 0)) != head_is_edge(form_heading(p, 1)),
         "so `len 2` and `len 1` here are counts in different units");
}