← hex_edge · API reference · version 0.2.6
3 file(s), 1375 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Surfaces struct | 67 | — | 76 83 89 97 104 120 … (13 total) |
surfaces_new fn | 76 | HXE-001 | 13 20 31 43 42 57 … (11 total) |
surf_straight fn | 89 | HXE-001 | 22 44 60 284 285 328 … (10 total) |
surf_arc fn | 97 | — | 32 45 61 556 |
surf_distance fn | 104 | — | 278 279 343 344 24 34 … (13 total) |
surf_normal fn | 120 | — | 330 331 482 26 69 |
Materials struct | 141 | — | 153 158 164 181 187 193 … (11 total) |
materials_new fn | 153 | — | 51 361 496 |
material_add fn | 164 | — | 52 362 363 497 520 530 |
material_set_solid fn | 181 | HXE-010 | 510 |
mat_solid fn | 187 | — | 53 55 388 511 |
mat_height fn | 193 | — | 706 54 |
mat_opacity fn | 198 | — | 704 |
mat_sound fn | 203 | — | 531 |
mat_perm fn | 209 | — | 532 |
mat_bounce fn | 214 | — | 533 |
edge_k fn | 228 | — | 256 |
edge_m fn | 234 | — | 257 |
edge_block_surf fn | 246 | HXE-006 | 456 466 656 295 296 298 … (8 total) |
edge_point fn | 255 | — | 277 429 481 62 63 287 … (7 total) |
edge_block_arb fn | 270 | HXE-006 | 305 306 308 309 |
JOIN_SMOOTH const | 292 | — | 334 560 561 |
JOIN_CORNER const | 293 | — | 559 |
Junctions struct | 297 | — | 308 311 315 325 340 |
junctions_new fn | 308 | — | 558 |
junction_add fn | 315 | — | 559 560 561 |
junction_ok fn | 325 | HXE-011 | 563 564 566 |
junction_g0 fn | 340 | HXE-011 | 570 |
FEAT_DOOR const | 363 | — | 372 |
FEAT_WINDOW const | 364 | — | none in this package |
FEAT_LOOPHOLE const | 365 | — | none in this package |
Features struct | 370 | — | 383 387 393 419 |
features_new fn | 383 | — | 369 |
feature_add fn | 393 | — | 372 |
surf_param fn | 403 | — | 430 371 394 395 |
apply_features fn | 419 | HXE-008 | 375 |
edge_block_full fn | 454 | — | 367 500 522 536 |
SURF_NONE const | 462 | — | 466 666 332 |
edge_block fn | 465 | HXE-007 | 622 637 37 210 253 331 |
edge_blocked fn | 471 | — | 489 469 |
collide fn | 477 | HXE-007 | 335 336 342 |
passable fn | 488 | HXE-010 | 601 334 425 427 429 506 … (10 total) |
sweep_path fn | 518 | HXE-005 | 100 111 151 186 216 233 … (12 total) |
sweep_path_from fn | 554 | HXE-003 | 521 155 |
edges_cut fn | 615 | HXE-009 | 416 |
edges_solid fn | 633 | HXE-009 | 418 |
edges_halfplane_surf fn | 645 | — | 666 |
edges_halfplane fn | 664 | HXE-009 | 437 451 |
sight_clear fn | 687 | HXE-010 | 507 524 |
surf_point fn | 724 | HXE-008 | 393 |
src/hex_edge.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_edge — collision as an EDGE SET.
//
// THE SPLIT: **`hex_field` owns the STORAGE; this file owns the PHYSICS.** The edge grid
// is `hex_field::EdgeSet`. The dividing line is the WRITE POLICY: `edge_set_surf` writes
// what it is told, while deciding WHICH surface owns an edge where two parts meet is a
// physics question, and baking it into storage would silently settle junctions for every
// consumer of the storage. So `Surfaces`, `Materials`, `Features`, `collide`, `sweep_path`
// and `sight_clear` live here, and every one of them takes an `EdgeSet` rather than owning
// one.
//
// THE MODEL. Collision is a set of blocked hex EDGES, not blocked cells. Movement from
// hex A to hex B is legal iff the edge they share is not blocked. This was settled by
// measurement: a 1-unit-thick wall blocks in
//
// 6 of 24 headings as cells (an off-axis 1-cell chain is not even connected)
// 24 of 24 headings as edges (a cut separates BY CONSTRUCTION)
//
// and it makes wall WIDTH a rendering choice rather than a quantised, anisotropic
// accident — the cell model could only reach a consistent width at >= 2 hexes.
//
// The edge model SUBSUMES the cell model: "you cannot enter this cell" is just "every
// edge of this cell is blocked" (`edges_solid`). So one primitive serves a thin fence,
// a thick curtain wall, and a closed tower ring.
//
// THE KEY. An edge is identified by the DOUBLED MIDPOINT of its two cells' lattice
// centres: (kA+kB, mA+mB). That is exact integer, symmetric in A and B — so no
// ordering convention is needed — and unique per edge (verified: 930 edges, 0
// collisions). It costs nothing because the lattice is already integer.
//
// WHY AN EDGE STORES A SURFACE, NOT A BOOLEAN. To bounce off a wall you need its
// NORMAL, and a hex edge has only 6 possible normals (60 deg apart). Measured against
// the true wall normal across the 24 headings, using the edge's own normal is wrong by
// up to **90 degrees** — a bounce that sends you sideways or backwards. The direction
// simply is not recoverable from the cell grid.
//
// So each blocked edge stores a SURFACE ID, and the surface carries the exact geometry:
// a line's constant normal, or an arc's radial normal evaluated at the contact point.
// The hex grid becomes a SPATIAL INDEX that tells you THAT you hit something and WHICH
// thing; the surface tells you its exact normal. This keeps the design's standing rule —
// never derive a smooth thing from cells — and it needs no mesh, so it works for a large
// area and for small blockers (a tree, a bush) that a mesh would describe badly.
// ── Using it ─────────────────────────────────────────────────────────────────
//
// A wall belongs BETWEEN two hexes, not inside one, so this package puts what a wall is
// made of on the EDGE and answers questions from there.
//
// m = materials_new(); // 1. the palette
// stone = material_add(m, true, 2.0, 1.0, 40.0, 0.0, 0.2);
//
// // 2. mark edges with it (an EdgeSet comes from hex_field)
// // 3. then ask:
// if edge_blocked(e, qa, ra, qb, rb) { … } // can a mover cross here?
// h = mat_height(m, stone); // how tall, how loud, how opaque …
//
// Material id 0 is "nothing there": every `mat_*` reader answers a neutral value for it and
// for any id past the end, so a caller may ask about an unmarked edge without guarding.
//
// `Junctions` and `Features` hang extra detail off the same edges — a corner post, a door,
// a window — for consumers that draw or simulate rather than just collide.
use hex_field::*;
// The surfaces a collision can reference. Small and flat on purpose: a normal must be
// cheap to evaluate at contact time.
pub struct Surfaces {
pub sf_kind: vector<integer>, // 1 = straight, 2 = arc (radial normal)
pub sf_a: vector<float>, // straight: nx arc: centre x
pub sf_b: vector<float>, // straight: ny arc: centre y
pub sf_c: vector<float>, // straight: offset arc: radius
pub sf_n: integer,
}
// Example: @HXE-001
pub fn surfaces_new() -> Surfaces {
k: vector<integer> = [];
a: vector<float> = [];
b: vector<float> = [];
c: vector<float> = [];
// id 0 is reserved for "no surface"
k += [0]; a += [0.0]; b += [0.0]; c += [0.0];
Surfaces { sf_kind: k, sf_a: a, sf_b: b, sf_c: c, sf_n: 1 }
}
// A straight wall with unit normal (nx,ny) at offset c. Normal is constant -> exact for
// every one of the 24 headings, and for anything between them.
// Example: @HXE-001
pub fn surf_straight(s: Surfaces, nx: float, ny: float, c: float) -> integer {
s.sf_kind += [1]; s.sf_a += [nx]; s.sf_b += [ny]; s.sf_c += [c];
s.sf_n = s.sf_n + 1;
s.sf_n - 1
}
// An arc / round tower / tree trunk: the normal is radial, so it is exact everywhere
// along the curve — no faceting, no mesh.
pub fn surf_arc(s: Surfaces, cx: float, cy: float, r: float) -> integer {
s.sf_kind += [2]; s.sf_a += [cx]; s.sf_b += [cy]; s.sf_c += [r];
s.sf_n = s.sf_n + 1;
s.sf_n - 1
}
// Distance from a world point to surface `id` — the metric junction arbitration uses.
pub fn surf_distance(s: Surfaces, id: integer, px: float, py: float) -> float {
if id <= 0 || id >= s.sf_n { return 1000000.0; }
kind = s.sf_kind[id] ?? 0;
if kind == 1 {
d = (s.sf_a[id] ?? 0.0) * px + (s.sf_b[id] ?? 0.0) * py - (s.sf_c[id] ?? 0.0);
if d < 0.0 { d = 0.0 - d; }
return d;
}
ex = px - (s.sf_a[id] ?? 0.0);
ey = py - (s.sf_b[id] ?? 0.0);
d2 = (sqrt(ex * ex + ey * ey) ?? 0.0) - (s.sf_c[id] ?? 0.0);
if d2 < 0.0 { d2 = 0.0 - d2; }
d2
}
// The outward normal of surface `id` at world point (px,py).
pub fn surf_normal(s: Surfaces, id: integer, px: float, py: float) -> (float, float) {
if id <= 0 || id >= s.sf_n { return (0.0, 0.0); }
kind = s.sf_kind[id] ?? 0;
if kind == 1 { return (s.sf_a[id] ?? 0.0, s.sf_b[id] ?? 0.0); }
cx = s.sf_a[id] ?? 0.0;
cy = s.sf_b[id] ?? 0.0;
dx = px - cx;
dy = py - cy;
d = sqrt(dx * dx + dy * dy) ?? 0.0;
if d < 0.000001 { return (0.0, 0.0); }
(dx / d ?? 0.0, dy / d ?? 0.0)
}
// MATERIALS — the PHYSICS half of an edge. Kept separate from Surfaces (the geometry
// half) because they vary independently: a stone wall and a hedge on the SAME arc share a
// surface and differ entirely in material; a wall and a tower of the same stone share a
// material and differ entirely in surface.
//
// The consumers do not all want "blocked". A low wall blocks movement but not sight; a
// hedge muffles sound without stopping it; water runs along a wall it cannot cross. So a
// material carries a small vector of transmission terms rather than one flag.
pub struct Materials {
mt_solid: vector<boolean>, // stops movement outright
mt_height: vector<float>, // world units — step over / see over
mt_opacity: vector<float>, // 0 clear .. 1 opaque (sight)
mt_sound: vector<float>, // attenuation in dB (sound)
mt_perm: vector<float>, // 0 sealed .. 1 open (water / air)
mt_bounce: vector<float>, // restitution 0..1 (balls)
mt_n: integer,
}
// An empty palette. Id 0 is reserved for "nothing there", so the first `material_add`
// returns 1 and an unmarked edge never looks like a real material.
pub fn materials_new() -> Materials {
so: vector<boolean> = []; he: vector<float> = []; op: vector<float> = [];
sd: vector<float> = []; pe: vector<float> = []; bo: vector<float> = [];
// id 0 = "open air": blocks nothing
so += [false]; he += [0.0]; op += [0.0]; sd += [0.0]; pe += [1.0]; bo += [0.0];
Materials { mt_solid: so, mt_height: he, mt_opacity: op, mt_sound: sd,
mt_perm: pe, mt_bounce: bo, mt_n: 1 }
}
// Add a material and get its id. One call fixes everything the simulation asks later —
// whether it blocks, how tall, how opaque, how loud, how permeable, how bouncy.
pub fn material_add(m: Materials, solid: boolean, height: float, opacity: float,
sound_db: float, perm: float, bounce: float) -> integer {
m.mt_solid += [solid]; m.mt_height += [height]; m.mt_opacity += [opacity];
m.mt_sound += [sound_db]; m.mt_perm += [perm]; m.mt_bounce += [bounce];
m.mt_n = m.mt_n + 1;
m.mt_n - 1
}
// Change whether a material counts as solid. Flipping the table entry retargets every
// edge already carrying this id, with no edge write and no surface change — that is why
// material is a separate axis from surface.
//
// ⚠ It changes what `mat_solid` answers, and NOTHING in this package's movement:
// `passable` and `sweep_path` read the edge's surface mark and no material. To open a
// gate, clear the mark (`edge_set_surf(e, …, 0)`); the material says what kind of gate it
// was, for the consumer that reads it.
// Example: @HXE-010
pub fn material_set_solid(m: Materials, id: integer, solid: boolean) {
if id <= 0 || id >= m.mt_n { return; }
m.mt_solid[id] = solid;
}
// The per-consumer accessors. Each reads ONE property of the same edge.
pub fn mat_solid(m: Materials, id: integer) -> boolean {
if id <= 0 || id >= m.mt_n { return false; }
m.mt_solid[id] ?? false
}
// How tall this material stands. 0.0 for id 0 or an unknown id, so an unmarked edge
// answers "nothing there" rather than faulting.
pub fn mat_height(m: Materials, id: integer) -> float {
if id <= 0 || id >= m.mt_n { return 0.0; }
m.mt_height[id] ?? 0.0
}
// How much it blocks sight, 0.0 when there is nothing there.
pub fn mat_opacity(m: Materials, id: integer) -> float {
if id <= 0 || id >= m.mt_n { return 0.0; }
m.mt_opacity[id] ?? 0.0
}
// How much it deadens sound, in dB, 0.0 when there is nothing there.
pub fn mat_sound(m: Materials, id: integer) -> float {
if id <= 0 || id >= m.mt_n { return 0.0; }
m.mt_sound[id] ?? 0.0
}
// How freely air or fluid passes. Note the neutral value is 1.0, not 0.0: nothing there
// means fully permeable, where nothing there means zero height.
pub fn mat_perm(m: Materials, id: integer) -> float {
if id <= 0 || id >= m.mt_n { return 1.0; }
m.mt_perm[id] ?? 1.0
}
// How much it rebounds, 0.0 when there is nothing there.
pub fn mat_bounce(m: Materials, id: integer) -> float {
if id <= 0 || id >= m.mt_n { return 0.0; }
m.mt_bounce[id] ?? 0.0
}
// STORAGE is `hex_field::EdgeSet`, indexed by (cell, canonical direction): a hex has 6
// edges in 3 opposite PAIRS, so 3 slots per cell own every edge once, and a one-cell halo
// covers boundary edges whose canonical owner sits outside the chunk. The surface id is
// an i32 and the material a u8. This file never indexes the storage itself; it goes
// through `edge_surf` / `edge_set_surf` / `edge_mat` / `edge_set_mat`.
// The canonical edge key: doubled midpoint of the two cell centres. Kept as two integer
// accessors rather than the library's tuple-shaped `edge_key`, because `edge_point` wants
// the halves separately and a tuple destructure per edge is a hot-path cost for no gain.
pub fn edge_k(qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
lattice_k(qa, ra) + lattice_k(qb, rb)
}
// The edge's `m` key — the companion of `edge_k`. The pair names the edge between two
// cells the same way from either side, which is what stops one wall becoming two.
pub fn edge_m(qa: integer, ra: integer, qb: integer, rb: integer) -> integer {
lattice_m(qa, ra) + lattice_m(qb, rb)
}
// Block an edge, recording WHICH surface blocks it (surf 0 = a bare blocker with no
// recoverable normal — allowed, but it cannot be bounced off correctly).
//
// FIRST-WRITER-WINS, and that is load-bearing rather than incidental: plain construction
// stamps parts in an arbitrary order, and without the guard the LAST part to touch a
// shared edge would own it. `edge_block_arb` is the version that resolves the contest on
// geometry instead of order; this is the cheap one for edges only one part can claim.
// Example: @HXE-006
pub fn edge_block_surf(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer,
surf: integer) {
if edge_surf(e, qa, ra, qb, rb) == 0 {
edge_set_surf(e, qa, ra, qb, rb, surf);
}
}
// The world point where the two cells' shared edge sits (its midpoint) — the contact
// point a normal is evaluated at.
pub fn edge_point(qa: integer, ra: integer, qb: integer, rb: integer) -> (float, float) {
((edge_k(qa, ra, qb, rb) as float) * 0.4330127018922193,
(edge_m(qa, ra, qb, rb) as float) * 0.25)
}
// ── JUNCTION ARBITRATION ─────────────────────────────────────────────────────
//
// When two parts meet, one edge is claimed by two surfaces. First-writer-wins is
// order-dependent there — stamp the wall then the tower and you get a different world than
// the other way round, which is the class of bug that never shows up in a unit test.
//
// THE RULE: the surface whose geometry is NEAREST the edge's contact point wins; ties
// break to the LOWER surface id. Both halves matter — nearest is what a ball should
// actually bounce off, and the tie-break is what makes it deterministic.
// Example: @HXE-006
pub fn edge_block_arb(e: EdgeSet, sf: Surfaces, qa: integer, ra: integer,
qb: integer, rb: integer, surf: integer, mat: integer) {
cur = edge_surf(e, qa, ra, qb, rb);
if cur == 0 {
edge_set_both(e, qa, ra, qb, rb, surf, mat);
return;
}
(px, py) = edge_point(qa, ra, qb, rb);
dcur = surf_distance(sf, cur, px, py);
dnew = surf_distance(sf, surf, px, py);
take = false;
if dnew < dcur - 0.000000001 { take = true; }
gap = dnew - dcur;
if gap < 0.0 { gap = 0.0 - gap; }
if gap <= 0.000000001 && surf < cur { take = true; }
if take {
edge_set_both(e, qa, ra, qb, rb, surf, mat);
}
}
// A junction records WHICH surfaces meet and what continuity was INTENDED there, so a
// deliberate corner is never mistaken for an accidental kink.
pub const JOIN_SMOOTH = 1; // G1 intended — tangents must agree
pub const JOIN_CORNER = 2; // a deliberate angle — a corner on purpose
// Where surfaces meet: for each junction, the two surfaces, the continuity intended there
// (`JOIN_SMOOTH`, `JOIN_CORNER`) and its position. `junctions_new` makes an empty set.
pub struct Junctions {
jn_a: vector<integer>,
jn_b: vector<integer>,
jn_kind: vector<integer>,
jn_x: vector<float>,
jn_y: vector<float>,
jn_n: integer,
}
// An empty set of junctions: where walls meet, for consumers that draw a corner post or
// need to know a wall's ends rather than just its middle.
pub fn junctions_new() -> Junctions {
a: vector<integer> = []; b: vector<integer> = []; k: vector<integer> = [];
x: vector<float> = []; y: vector<float> = [];
Junctions { jn_a: a, jn_b: b, jn_kind: k, jn_x: x, jn_y: y, jn_n: 0 }
}
// Record that two surfaces meet here, of this kind, at this world point.
pub fn junction_add(j: Junctions, sa: integer, sb: integer, kind: integer,
x: float, y: float) {
j.jn_a += [sa]; j.jn_b += [sb]; j.jn_kind += [kind];
j.jn_x += [x]; j.jn_y += [y];
j.jn_n = j.jn_n + 1;
}
// Does the junction honour what was intended? G0 is exact by construction (both
// surfaces pass through the seam point); this checks the TANGENT.
// Example: @HXE-011
pub fn junction_ok(j: Junctions, i: integer, sf: Surfaces) -> boolean {
sa = j.jn_a[i] ?? 0;
sb = j.jn_b[i] ?? 0;
x = j.jn_x[i] ?? 0.0;
y = j.jn_y[i] ?? 0.0;
(ax, ay) = surf_normal(sf, sa, x, y);
(bx, by) = surf_normal(sf, sb, x, y);
dot = ax * bx + ay * by;
if dot < 0.0 { dot = 0.0 - dot; }
if (j.jn_kind[i] ?? 0) == JOIN_SMOOTH { return dot > 0.999; }
dot < 0.999
}
// G0: both surfaces must actually pass through the seam point.
// Example: @HXE-011
pub fn junction_g0(j: Junctions, i: integer, sf: Surfaces, tol: float) -> boolean {
x = j.jn_x[i] ?? 0.0;
y = j.jn_y[i] ?? 0.0;
da = surf_distance(sf, j.jn_a[i] ?? 0, x, y);
db = surf_distance(sf, j.jn_b[i] ?? 0, x, y);
da <= tol && db <= tol
}
// ── FEATURES — doors, windows, loopholes ────────────────────────────────────
//
// A feature is an INTERVAL ON THE SURFACE, never a hole cut in the cell outline. That is
// forced by measurement: the traced boundary turns at every vertex, so an opening cut
// from cells can never be straight beyond one 1.0-unit edge. On the surface it is
// [s0,s1] of an exact straight or arc — straight because the surface is, at any width.
//
// It carries a VERTICAL interval too. A window is sill..head, and Materials.height is a
// single scalar that can only say "a wall of height h" — so without (z0,z1) a window
// cannot be expressed at all.
//
// And it points at its own MATERIAL rather than replacing terms one by one: a door
// changes `solid`, a window changes `opacity`/`sound`/`permeability` but NOT `solid`
// (you cannot walk through it). Pointing at a material expresses "override this subset"
// without inventing a per-term override mechanism.
pub const FEAT_DOOR = 1;
pub const FEAT_WINDOW = 2;
pub const FEAT_LOOPHOLE = 3;
// The openings cut into surfaces — doors, windows, loopholes (`FEAT_*`): for each, its
// surface, its interval along it, its vertical extent (sill to head), its kind and the
// material inside it. `features_new` makes an empty set.
pub struct Features {
pub fe_surf: vector<integer>,
pub fe_s0: vector<float>, // interval along the surface parameter
pub fe_s1: vector<float>,
pub fe_z0: vector<float>, // vertical extent: sill..head
pub fe_z1: vector<float>,
pub fe_kind: vector<integer>,
pub fe_mat: vector<integer>, // the material INSIDE the feature
pub fe_n: integer,
}
// An empty set of features: doors, windows and openings placed ALONG a surface rather
// than replacing it, so the wall stays one wall with holes in it.
pub fn features_new() -> Features {
a: vector<integer> = []; b: vector<float> = []; c: vector<float> = [];
d: vector<float> = []; e: vector<float> = []; k: vector<integer> = [];
m: vector<integer> = [];
Features { fe_surf: a, fe_s0: b, fe_s1: c, fe_z0: d, fe_z1: e,
fe_kind: k, fe_mat: m, fe_n: 0 }
}
// Place a feature on a surface, between two positions along it and two heights up it, so a
// door or a window is a hole IN a wall rather than a break in it.
pub fn feature_add(f: Features, surf: integer, s0: float, s1: float,
z0: float, z1: float, kind: integer, mat: integer) -> integer {
f.fe_surf += [surf]; f.fe_s0 += [s0]; f.fe_s1 += [s1];
f.fe_z0 += [z0]; f.fe_z1 += [z1]; f.fe_kind += [kind]; f.fe_mat += [mat];
f.fe_n = f.fe_n + 1;
f.fe_n - 1
}
// Where along surface `id` does this point sit? Straight: distance along its direction.
// Arc: arc length from angle 0. This is the parameter a feature interval is stated in.
pub fn surf_param(s: Surfaces, id: integer, px: float, py: float) -> float {
if id <= 0 || id >= s.sf_n { return 0.0; }
if (s.sf_kind[id] ?? 0) == 1 {
nx = s.sf_a[id] ?? 0.0;
ny = s.sf_b[id] ?? 0.0;
return (0.0 - ny) * px + nx * py;
}
cx = s.sf_a[id] ?? 0.0;
cy = s.sf_b[id] ?? 0.0;
atan2(py - cy, px - cx) * (s.sf_c[id] ?? 1.0)
}
// Apply every feature to the edge layer: an edge whose contact point falls inside a
// feature's interval takes that feature's material. The SURFACE is untouched — which is
// why the body never fragments and the matcher still sees one wall.
// Example: @HXE-008
pub fn apply_features(e: EdgeSet, sf: Surfaces, f: Features,
q0: integer, r0: integer, w: integer, h: integer) -> integer {
hits = 0;
for r in r0..r0 + h {
for q in q0..q0 + w {
for d in 0..6 {
nq = nb_q(q, r, d);
nr = nb_r(q, r, d);
sid = edge_surf(e, q, r, nq, nr);
if sid > 0 {
(px, py) = edge_point(q, r, nq, nr);
t = surf_param(sf, sid, px, py);
for i in 0..f.fe_n {
if (f.fe_surf[i] ?? -1) == sid {
lo = f.fe_s0[i] ?? 0.0;
hi = f.fe_s1[i] ?? 0.0;
if t >= lo && t <= hi {
// Through the accessor rather than an index this loop already holds: the
// library keeps its layout private, and one extra index computation per
// feature hit is a build-time cost, not a per-frame one.
edge_set_mat(e, q, r, nq, nr, f.fe_mat[i] ?? 0);
hits = hits + 1;
}
}
}
}
}
}
}
hits
}
// Record BOTH halves: which surface (geometry) and which material (physics). The surface
// keeps first-writer-wins; the material does NOT, because a feature (a door, a window)
// legitimately retargets the material of an edge whose surface is already settled.
pub fn edge_block_full(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer,
surf: integer, mat: integer) {
edge_block_surf(e, qa, ra, qb, rb, surf);
edge_set_mat(e, qa, ra, qb, rb, mat);
}
// A bare blocker with no recoverable geometry: an id no `Surfaces` table reaches, so the
// edge is blocked but `collide` reports a (0,0) normal.
pub const SURF_NONE = 65535;
// Example: @HXE-007
pub fn edge_block(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) {
edge_block_surf(e, qa, ra, qb, rb, SURF_NONE);
}
// Can a mover cross this edge? THE collision question, and the reason the edge layer
// exists — asked from either side with the same answer.
pub fn edge_blocked(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) -> boolean {
edge_surf(e, qa, ra, qb, rb) != 0
}
// THE COLLISION RESPONSE QUERY: blocked?, and the EXACT outward normal to bounce off.
// Example: @HXE-007
pub fn collide(e: EdgeSet, s: Surfaces,
qa: integer, ra: integer, qb: integer, rb: integer) -> (boolean, float, float) {
id = edge_surf(e, qa, ra, qb, rb);
if id == 0 { return (false, 0.0, 0.0); }
(px, py) = edge_point(qa, ra, qb, rb);
(nx, ny) = surf_normal(s, id, px, py);
(true, nx, ny)
}
// THE collision query. Everything else is construction.
// Example: @HXE-010
pub fn passable(e: EdgeSet, qa: integer, ra: integer, qb: integer, rb: integer) -> boolean {
!edge_blocked(e, qa, ra, qb, rb)
}
// ── SWEPT MOVEMENT ───────────────────────────────────────────────────────────
//
// `passable` answers about one edge; this answers about a PATH. Walk the segment
// (x0,y0) -> (x1,y1) across the lattice one edge at a time and stop at the first edge
// the field blocks.
//
// There is no step size anywhere, which is the whole point. A point's cell is its
// NEAREST CENTRE, so a crossing is exactly where the segment meets the bisector between
// the current centre and a neighbour's — one linear solve per neighbour:
//
// |P(t) - C|² = |P(t) - N|² ⟺ P(t)·(N-C) = midpoint·(N-C)
// ⟹ t = ((M - P0)·D) / (V·D) with D = N-C, and only when V·D > 0
//
// So the answer is a function of the SEGMENT, not of how many frames it was cut into:
// dropping frames or falling fast cannot step over a wall. Centres come from the exact
// integer lattice (`lattice_k`/`lattice_m`), so the geometry carries no accumulated error.
//
// Returns the fraction of the segment that may be travelled (1.0 = all of it), the cell
// it stopped in, and the DIRECTION of the blocking edge — `dir < 0` meaning nothing
// blocked. The signal is the direction and never a coordinate: hex coordinates are
// signed, so a -1 sentinel in q or r would collide with real cells in any chunk that
// spans the origin. Feed (q, r, dir) to `nb_q`/`nb_r` and `collide` for the exact
// bounce normal.
// Example: @HXE-002
// Example: @HXE-004
// Example: @HXE-005
pub fn sweep_path(e: EdgeSet, x0: float, y0: float, x1: float, y1: float)
-> (float, integer, integer, integer) {
(sq, sr) = hex_at(x0, y0);
sweep_path_from(e, sq, sr, x0, y0, x1, y1)
}
// THE SAME SWEEP, TOLD WHICH CELL IT STARTS IN — and the one to call in a movement loop.
//
// `sweep_path` derives the starting cell with `hex_at`, and that is exactly what a caller
// cannot afford on the step AFTER a stop. Stopping at a wall leaves the position exactly ON
// the bisector between two cells (the comment in the loop below says so: it is the normal
// state, not an edge case), and `hex_at` on a bisector does not toss a coin — measured over
// all six directions, it answers the FAR cell every time. So the next call starts past the
// wall with the wall behind it, sees nothing, and reports the whole segment clear. The
// symptom is "collision does not work"; the truth is that it worked for exactly one step.
//
// The cure is not a tolerance. The previous call already RETURNED the cell you are resting
// in, so hand it back: `(t, cq, cr, d) = sweep_path_from(e, cq, cr, …)`, threading `cq, cr`
// from one step to the next. Pressing on into the wall then answers `t = 0` in the cell you
// are in, which is the honest answer, and sliding along the wall works from the same call.
//
// WHY NOT A SKIN. The consumer-side workaround is to come to rest a small distance short,
// and it does work — but the smallest distance that works
// is not a constant. Measured: 1e-15 at the origin, 1e-11 about 1.7e3 world units out,
// 1e-9 about 1.7e6 out. It is a float-resolution floor, not a geometric clearance, so a
// constant chosen at one extent is silently wrong at another and the library has no better
// idea of the right value than the caller does. Threading the cell needs no such number.
//
// THE FAR-FIELD LIMIT, which no cure removes: past roughly 5e6 cells from the origin a
// double position can no longer hold "on the bisector" to better than this code's 1e-9
// tolerance (measured: the recoverable offset from an absolute position loses 2.9e-10 at
// 3e6 cells and 6.4e-10 at 1e7). At that point the position itself is the limit, not the
// sweep, and the fix is to recentre the world rather than the arithmetic.
//
// Example: @HXE-002
// Example: @HXE-003
pub fn sweep_path_from(e: EdgeSet, sq: integer, sr: integer,
x0: float, y0: float, x1: float, y1: float)
-> (float, integer, integer, integer) {
vx = x1 - x0;
vy = y1 - y0;
cq = sq;
cr = sr;
pq = cq; // the cell we came from: the ONLY one to exclude
pr = cr;
havep = false;
t = 0.0;
steps = 0;
// Bounded, and the bound fails SAFE: on exhaustion the caller is told it may travel
// only as far as was actually verified, never that the rest is clear.
while steps < 256 {
steps = steps + 1;
ck = (lattice_k(cq, cr) as float) * 0.8660254037844386;
cm = (lattice_m(cq, cr) as float) * 0.5;
bt = 2.0;
bd = -1;
for d in 0..6 {
nq = nb_q(cq, cr, d);
nr = nb_r(cq, cr, d);
// Exclude the cell we CAME FROM, not crossings at the current parameter. Filtering
// by parameter (tt > t + eps) drops a crossing that sits exactly at t — and that is
// the normal state, not an edge case: stopping at a wall leaves the position exactly
// ON a bisector, so the very next sweep starts there and would skip its first edge.
// Excluding the previous cell also lets a corner be walked THROUGH: both of its
// edges get tested at the same t, so a path cannot slip between two walls that meet.
if !(havep && nq == pq && nr == pr) {
nk = (lattice_k(nq, nr) as float) * 0.8660254037844386;
nm = (lattice_m(nq, nr) as float) * 0.5;
dx = nk - ck;
dy = nm - cm;
den = vx * dx + vy * dy;
if den > 0.000000001 { // only bisectors we are moving TOWARD
mx = (nk + ck) * 0.5;
my = (nm + cm) * 0.5;
tt = ((mx - x0) * dx + (my - y0) * dy) / den ?? 2.0;
if tt > t - 0.000000001 && tt < bt { bt = tt; bd = d; }
}
}
}
if bd < 0 || bt > 1.0 { return (1.0, cq, cr, -1); } // no crossing left in range
if bt < t { bt = t; } // never report travel backwards
bq = nb_q(cq, cr, bd);
br = nb_r(cq, cr, bd);
if !passable(e, cq, cr, bq, br) { return (bt, cq, cr, bd); }
t = bt;
pq = cq;
pr = cr;
havep = true;
cq = bq;
cr = br;
}
(t, cq, cr, -1)
}
// A THIN wall: the cut between the cells of `s` and everything else. One edge thick,
// and it separates by construction — in every one of the 24 headings.
// Example: @HXE-009
pub fn edges_cut(s: HexSet, e: EdgeSet) {
for r in hexset_r0(s)..hexset_r0(s) + hexset_h(s) {
for q in hexset_q0(s)..hexset_q0(s) + hexset_w(s) {
if hexset_get(s, q, r) {
for d in 0..6 {
nq = nb_q(q, r, d);
nr = nb_r(q, r, d);
if !hexset_get(s, nq, nr) { edge_block(e, q, r, nq, nr); }
}
}
}
}
}
// A SOLID body: every edge incident to an occupied cell, so the cells cannot be entered
// at all. This is the cell model expressed in the edge primitive — which is the point:
// one collision layer serves both.
// Example: @HXE-009
pub fn edges_solid(s: HexSet, e: EdgeSet) {
for r in hexset_r0(s)..hexset_r0(s) + hexset_h(s) {
for q in hexset_q0(s)..hexset_q0(s) + hexset_w(s) {
if hexset_get(s, q, r) {
for d in 0..6 { edge_block(e, q, r, nb_q(q, r, d), nb_r(q, r, d)); }
}
}
}
}
// Block every edge a straight line crosses: the wall runs along `n·p = c` in world
// units, with n the unit normal. A thin wall in ANY heading, one edge thick.
pub fn edges_halfplane_surf(e: EdgeSet, q0: integer, r0: integer, w: integer, h: integer,
nx: float, ny: float, c: float, surf: integer) {
for r in r0..r0 + h {
for q in q0..q0 + w {
sa = (lattice_k(q, r) as float) * 0.8660254037844386 * nx
+ (lattice_m(q, r) as float) * 0.5 * ny - c;
for d in 0..6 {
nq = nb_q(q, r, d);
nr = nb_r(q, r, d);
sb = (lattice_k(nq, nr) as float) * 0.8660254037844386 * nx
+ (lattice_m(nq, nr) as float) * 0.5 * ny - c;
if sa < 0.0 && sb >= 0.0 { edge_block_surf(e, q, r, nq, nr, surf); }
}
}
}
}
// Back-compat: a halfplane wall with no surface recorded.
// Example: @HXE-009
pub fn edges_halfplane(e: EdgeSet, q0: integer, r0: integer, w: integer, h: integer,
nx: float, ny: float, c: float) {
edges_halfplane_surf(e, q0, r0, w, h, nx, ny, c, SURF_NONE);
}
// ── SIGHT ───────────────────────────────────────────────────────────────────
//
// Is there a clear line of sight from an eye to a target?
//
// Sight is blocked by OPACITY, not by solidity. A chain-link fence is solid and
// transparent; a hedge is permeable and opaque. This is the first consumer to read one
// material axis while deliberately ignoring another, and it is the whole reason a material
// carries a vector of transmission terms rather than a single flag.
//
// Height matters as much as opacity: the sight line rises from eye to target, so an
// obstruction blocks only while the line is still below its top. A near wall hides what
// the identical wall further along does not — which is exactly the railway sighting
// problem, and it falls out of the geometry rather than being special-cased.
//
// The walk steps at 0.2 world units. A hex inradius is sqrt(3)/2 = 0.866, so consecutive
// samples cannot skip a cell: every cell change is a step to a NEIGHBOUR, and the edge
// between them is exactly the one to test.
// Example: @HXE-010
pub fn sight_clear(e: EdgeSet, m: Materials, x0: float, y0: float, z0: float,
x1: float, y1: float, z1: float, opaque_at: float) -> boolean {
dx = x1 - x0;
dy = y1 - y0;
dist = sqrt(dx * dx + dy * dy) ?? 0.0;
if dist < 0.000001 { return true; }
n = ((dist / 0.2 ?? 0.0) as integer) + 1;
(pq, pr) = hex_at(x0, y0);
clear = true;
for i in 1..n + 1 {
t = (i as float) / (n as float) ?? 0.0;
(cq, cr) = hex_at(x0 + dx * t, y0 + dy * t);
if cq != pq || cr != pr {
if clear {
sid = edge_surf(e, pq, pr, cq, cr);
if sid > 0 {
mid = edge_mat(e, pq, pr, cq, cr);
if mat_opacity(m, mid) >= opaque_at {
zl = z0 + (z1 - z0) * t; // sight-line height at the crossing
if mat_height(m, mid) > zl { clear = false; }
}
}
}
pq = cq;
pr = cr;
}
}
clear
}
// The inverse of surf_param: where along a surface does this parameter land?
//
// surf_param answers "where is this point on that surface"; derived placement needs the
// other direction — a Feature states an INTERVAL in surface parameters, and
// putting a door there means turning that interval back into a position. Without this the
// only way to place a door on an existing opening is to search for it.
// Example: @HXE-008
pub fn surf_point(s: Surfaces, id: integer, t: float) -> (float, float) {
if id <= 0 || id >= s.sf_n { return (0.0, 0.0); }
if (s.sf_kind[id] ?? 0) == 1 {
nx = s.sf_a[id] ?? 0.0;
ny = s.sf_b[id] ?? 0.0;
c = s.sf_c[id] ?? 0.0;
// the foot of the perpendicular, then t along the surface's own direction
return (c * nx - t * ny, c * ny + t * nx);
}
cx = s.sf_a[id] ?? 0.0;
cy = s.sf_b[id] ?? 0.0;
r = s.sf_c[id] ?? 1.0;
a = t / r ?? 0.0;
(cx + r * cos(a), cy + r * sin(a))
}
tests/01-hex-edge.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// hex_edge — the conformance tests: the surface set, edge geometry, and the material table.
//
// These assert the library's CONTRACT, not its depth. The exhaustive measurement of this code
// lives in hexbody's gates (`tests/wall.loft`, `tests/arb.loft`, `tests/line.loft`), which print
// their evidence and carry a control that must fire. This file is what a consumer reads to see
// the package is exercised, and what CI runs under `LOFT_DENY_WARNINGS=1`.
use hex_edge::*;
use hex_field::*;
fn main() {
s = surfaces_new();
// id 0 is RESERVED for "no surface", so a fresh set already holds one slot -- a consumer
// indexing surfaces must know that, which is why it is asserted rather than assumed.
assert(s.sf_n == 1, "a fresh surface set holds only the reserved id 0, got {s.sf_n}");
}
fn test_a_straight_surface_measures_distance_along_its_normal() {
s = surfaces_new();
// the line y = 0, normal +y
i = surf_straight(s, 0.0, 1.0, 0.0);
assert(i == 1, "the first real surface is id 1 -- id 0 is reserved for none, got {i}");
d = surf_distance(s, i, 0.0, 3.0);
assert(d > 2.99 && d < 3.01, "a point 3 above the line is 3 away, got {d}");
(nx, ny) = surf_normal(s, i, 0.0, 3.0);
assert(nx > -0.01 && nx < 0.01 && ny > 0.99, "the normal points +y, got ({nx}, {ny})");
}
fn test_an_arc_surface_measures_distance_from_its_rim() {
s = surfaces_new();
i = surf_arc(s, 0.0, 0.0, 5.0);
// a point on the rim is at distance 0; one 2 further out is 2 away
d0 = surf_distance(s, i, 5.0, 0.0);
d2 = surf_distance(s, i, 7.0, 0.0);
assert(d0 > -0.01 && d0 < 0.01, "a point on the rim is 0 away, got {d0}");
assert(d2 > 1.99 && d2 < 2.01, "a point 2 outside the rim is 2 away, got {d2}");
}
// A consumer tells an arc from a straight surface by its kind (crawler's tower walls do): the
// kind is readable outside this library.
fn test_a_surface_says_whether_it_is_straight_or_an_arc() {
s = surfaces_new();
line = surf_straight(s, 0.0, 1.0, 0.0);
arc = surf_arc(s, 0.0, 0.0, 5.0);
assert(s.sf_kind[line] == 1, "a straight surface is kind 1, got {s.sf_kind[line]}");
assert(s.sf_kind[arc] == 2, "an arc is kind 2, got {s.sf_kind[arc]}");
}
fn test_the_material_table_carries_its_properties() {
m = materials_new();
id = material_add(m, true, 3.0, 1.0, 0.0, 0.0, 0.0);
assert(mat_solid(m, id), "stone is solid");
assert(mat_height(m, id) > 2.99 && mat_height(m, id) < 3.01, "stone's height came back");
assert(!mat_solid(m, 0), "material 0 is the empty one and is not solid");
}
fn test_an_edge_midpoint_is_shared_by_the_two_cells_it_separates() {
// The edge between (0,0) and its neighbour is the SAME edge from either side -- this is the
// property `SPEC` L11 exists to protect, and it is cheap to state here.
// (0,0) and (1,0) are neighbours on the odd-r lattice; no `hex_grid` dependency needed.
(ax, ay) = edge_point(0, 0, 1, 0);
(bx, by) = edge_point(1, 0, 0, 0);
assert(ax == bx && ay == by, "the edge midpoint is the same from both sides");
}
tests/02-worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `hex_edge` (@PLN141). Each `test_*` below is a REAL,
// run-every-CI demonstration of a contract the signature cannot state; the
// function it teaches CITES it with `// Example: @HXE-0NN`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling. These are
// teaching examples, distinct from the conformance suite in `01-hex-edge.loft`.
//
// WHAT THIS PACKAGE ASKS OF A CALLER. A wall is DERIVED from the geometry — an
// edge of the lattice, carrying the id of the analytic surface it belongs to — so
// the collision proxy cannot drift from the shape it stands for. What a caller
// gets wrong is one of three things:
//
// A SLOT that is already taken id 0 means "no surface", so a fresh set is not
// empty and the first surface you add is id 1
// A RESULT that must be THREADED the sweep returns the cell you are resting in
// BECAUSE the resting position cannot be turned
// back into a cell — it is on the bisector
// A VALUE that is legal and mute `collide` can answer "blocked" with a normal of
// (0,0), and reflecting off that does nothing
//
// The fixtures are written BY HAND and are as small as the claim allows: one wall,
// one straight, two cells. Distances are in the lattice's own world units, in
// which a hex has circumradius 1 and one hex STEP is sqrt(3) ~ 1.7320508.
use hex_edge::*;
use hex_field::*;
const SQ3_2 = 0.8660254037844386;
fn centre_x(q: integer, r: integer) -> float { (lattice_k(q, r) as float) * SQ3_2 }
fn centre_y(q: integer, r: integer) -> float { (lattice_m(q, r) as float) * 0.5 }
// One blocked edge: between (bq,br) and its neighbour in direction `d`.
fn one_wall(bq: integer, br: integer, d: integer) -> EdgeSet {
e = edgeset_new(bq - 6, br - 6, 13, 13);
edge_block(e, bq, br, nb_q(bq, br, d), nb_r(bq, br, d));
e
}
fn main() {
s = surfaces_new();
assert(s.sf_n == 1, "a fresh surface set already holds the reserved id 0");
}
// @HXE-001 — id 0 IS TAKEN, SO THE FIRST SURFACE YOU ADD IS id 1.
//
// A fresh `Surfaces` is not empty: slot 0 is reserved for "no surface", which is what
// an unblocked edge stores. So `sf_n` is 1 before you add anything, `surf_straight`
// hands back 1, and a caller sizing a parallel array off the surface count, or looping
// `0..sf_n` over "my surfaces", picks up the reserved slot as if it were one of theirs.
//
// THE RESERVED SLOT ANSWERS, IT DOES NOT FAIL, which is what makes the mistake quiet:
// `surf_distance` of id 0 is 1000000.0 (a sentinel far away, so nearest-surface
// arbitration never picks it) and `surf_normal` is (0,0). Both are legal floats.
fn test_surface_id_zero_is_reserved_and_still_answers() {
s = surfaces_new();
assert(s.sf_n == 1, "a fresh set holds one slot, not none, got {s.sf_n}");
wall = surf_straight(s, 1.0, 0.0, 2.0);
tower = surf_arc(s, 0.0, 0.0, 3.0);
assert(wall == 1, "the first surface added is id 1, not 0, got {wall}");
assert(tower == 2, "and the second is 2, got {tower}");
assert(s.sf_n == 3, "so sf_n counts the reserved slot too, got {s.sf_n}");
assert(surf_distance(s, 0, 0.0, 0.0) == 1000000.0,
"id 0 answers a sentinel distance rather than failing, got"
+ " {surf_distance(s, 0, 0.0, 0.0)}");
(nx, ny) = surf_normal(s, 0, 1.0, 1.0);
assert(nx == 0.0 && ny == 0.0,
"and a zero normal, which is a legal pair of floats, got ({nx},{ny})");
}
// @HXE-002 — WHERE A SWEEP LEAVES YOU CANNOT BE TURNED BACK INTO A CELL.
//
// Stopping at a wall leaves the position exactly ON the bisector between two cells.
// That is the normal state, not an edge case — it is where every blocked sweep ends.
// A point on a bisector is equidistant from both centres, so `hex_at` has to break the
// tie, and it is not a coin toss: measured over all six directions it answers the FAR
// cell, every time.
//
// SO THE NEXT CALL STARTS PAST THE WALL, with the wall behind it. It sees nothing in
// the way and reports the whole segment travellable. The symptom a consumer reports is
// "collision does not work"; the truth is that it worked for exactly one
// step, and the second step is the one that walks through.
//
// Nothing about this is float noise — the numbers below are exact halves.
fn test_a_sweep_comes_to_rest_on_a_bisector_and_hex_at_picks_the_far_cell() {
far_side = 0;
walked_through = 0;
for d in 0..6 {
e = one_wall(0, 0, d);
nq = nb_q(0, 0, d);
nr = nb_r(0, 0, d);
x0 = centre_x(0, 0);
y0 = centre_y(0, 0);
x1 = centre_x(nq, nr);
y1 = centre_y(nq, nr);
(t, cq, cr, bd) = sweep_path(e, x0, y0, x1, y1);
assert(t == 0.5, "centre to centre, the wall is exactly halfway, got {t} for d {d}");
assert(cq == 0 && cr == 0, "and the stop is in the cell we came from, for d {d}");
assert(bd == d, "blocked in the direction we were going, got {bd} for d {d}");
sx = x0 + t * (x1 - x0);
sy = y0 + t * (y1 - y0);
(aq, ar) = hex_at(sx, sy);
if aq != cq || ar != cr { far_side = far_side + 1; }
// the very next step, starting from exactly where we were left
(t2, _q2, _r2, b2) = sweep_path(e, sx, sy, x1, y1);
if b2 < 0 && t2 == 1.0 { walked_through = walked_through + 1; }
}
assert(far_side == 6,
"hex_at names the FAR cell in all six directions -- it does not round at random,"
+ " got {far_side}");
assert(walked_through == 6,
"so the next sweep reports the whole segment clear, through the wall, in all six,"
+ " got {walked_through}");
}
// @HXE-003 — THREAD THE CELL THE SWEEP GAVE YOU. DO NOT BACK OFF BY A LITTLE.
//
// `sweep_path` already returns the cell you are resting in, so the cure for @HXE-002 is
// to hand it back rather than re-derive it: `sweep_path_from(e, cq, cr, …)`. Pressing on
// into the wall then answers `t = 0` in the cell you are in, which is the honest answer
// and needs no tolerance anywhere.
//
// THE OBVIOUS ALTERNATIVE IS A SKIN — come to rest a small distance short — and it does
// work, which is why it is the tempting one. But the smallest distance that works is not
// a constant. It is a float-resolution floor, so it grows with how far from the origin
// you are playing:
//
// at the origin 1e-15
// ~1.7e3 world units out 1e-11
// ~1.7e6 world units out 1e-9
//
// A skin calibrated where the author tested is silently wrong somewhere else in the same
// world, and neither the library nor the caller has any principled way to pick it. That
// is the argument against `sweep_path_skin(…)`: not that the arithmetic is hard, but that
// the constant it needs does not exist.
fn test_threading_the_cell_needs_no_skin_and_a_skin_needs_a_constant_that_does_not_exist() {
for d in 0..6 {
e = one_wall(0, 0, d);
nq = nb_q(0, 0, d);
nr = nb_r(0, 0, d);
x0 = centre_x(0, 0);
y0 = centre_y(0, 0);
x1 = centre_x(nq, nr);
y1 = centre_y(nq, nr);
(t, cq, cr, _bd) = sweep_path(e, x0, y0, x1, y1);
sx = x0 + t * (x1 - x0);
sy = y0 + t * (y1 - y0);
(t2, c2q, c2r, b2) = sweep_path_from(e, cq, cr, sx, sy, x1, y1);
assert(t2 == 0.0, "pressing on into the wall travels nothing, got {t2} for d {d}");
assert(b2 == d, "and still reports the wall, got {b2} for d {d}");
assert(c2q == cq && c2r == cr, "in the cell we were resting in, for d {d}");
}
// THE SKIN, MEASURED. A backoff that is enough at the origin is not enough further out.
origin_bad = skin_failures(0, 0, 0.000000000000001);
far_bad = skin_failures(1000, 1000, 0.000000000000001);
assert(origin_bad == 0,
"1e-15 of skin is enough at the origin, got {origin_bad} failures");
assert(far_bad > 0,
"and NOT enough 1.7e3 units out -- the constant does not travel, got {far_bad}"
+ " failures");
assert(skin_failures(1000, 1000, 0.00000000001) == 0,
"out there it takes 1e-11, four orders of magnitude more");
}
// How many of the six directions still land on the far side after backing off `skin`
// world units along the segment. Nothing here is library code — it is the consumer-side
// workaround, written out so the test can measure it.
fn skin_failures(bq: integer, br: integer, skin: float) -> integer {
bad = 0;
for d in 0..6 {
e = one_wall(bq, br, d);
nq = nb_q(bq, br, d);
nr = nb_r(bq, br, d);
x0 = centre_x(bq, br);
y0 = centre_y(bq, br);
x1 = centre_x(nq, nr);
y1 = centre_y(nq, nr);
(t, cq, cr, bd) = sweep_path(e, x0, y0, x1, y1);
if bd >= 0 {
vx = x1 - x0;
vy = y1 - y0;
vl = sqrt(vx * vx + vy * vy) ?? 1.0;
tb = t - ((skin / vl) ?? 0.0);
(aq, ar) = hex_at(x0 + tb * vx, y0 + tb * vy);
if aq != cq || ar != cr { bad = bad + 1; }
}
}
bad
}
// @HXE-004 — THERE IS NO STEP SIZE, SO DROPPING FRAMES CANNOT STEP OVER A WALL.
//
// The sweep solves for the crossing of each bisector directly — one linear solve per
// neighbour — so the answer is a function of the SEGMENT, not of how many pieces the
// caller cut it into. That is the property that makes movement safe on a slow frame or
// during a long fall, and it is the reason there is no `max_step` parameter to tune.
//
// Below, one sweep across six cells and the same motion cut into twelve frames stop in
// the SAME cell against the SAME wall.
fn test_the_sweep_has_no_step_size() {
e = edgeset_new(-6, -6, 13, 13);
edge_block(e, 3, 0, 4, 0);
x0 = centre_x(0, 0);
y0 = centre_y(0, 0);
x1 = centre_x(6, 0);
y1 = centre_y(6, 0);
(t, cq, cr, bd) = sweep_path(e, x0, y0, x1, y1);
// the wall's midpoint sits at lattice k = 6+8 = 14, so x = 14 * sqrt(3)/4 = 6.0621778,
// against a full run of 12 * sqrt(3)/2 = 10.392305 -- exactly 7/12 of the way
assert(cq == 3 && cr == 0, "one sweep stops in (3,0), got ({cq},{cr})");
assert(bd == 0, "against the wall to its east, got dir {bd}");
assert(t > 0.5833333333 && t < 0.5833333334, "at 7/12 of the segment, got {t}");
// the same motion, twelve frames, each starting where the last one ended
px = x0;
py = y0;
hq = 0;
hr = 0;
hd = -1;
for i in 0..12 {
if hd < 0 {
fx = x0 + (((i + 1) as float) / 12.0 ?? 0.0) * (x1 - x0);
fy = y0 + (((i + 1) as float) / 12.0 ?? 0.0) * (y1 - y0);
(tf, qf, rf, df) = sweep_path(e, px, py, fx, fy);
px = px + tf * (fx - px);
py = py + tf * (fy - py);
if df >= 0 { hq = qf; hr = rf; hd = df; }
}
}
assert(hq == cq && hr == cr && hd == bd,
"twelve frames stop in the same cell against the same wall, got ({hq},{hr}) dir"
+ " {hd}");
}
// @HXE-005 — THE "NOTHING BLOCKED" SIGNAL IS THE DIRECTION, NEVER A COORDINATE.
//
// `sweep_path` returns `(t, q, r, dir)` and says "clear" with `dir < 0`. It is worth
// knowing why it cannot say it with the cell instead: hex coordinates are SIGNED, so a
// -1 in `q` or `r` is an ordinary cell in any chunk that spans the origin. The first
// version of this code used that sentinel and reported "no hit" for every wall at
// q = -1 — a whole column of walls that worked everywhere except near the origin.
fn test_the_clear_signal_is_the_direction_because_minus_one_is_a_real_cell() {
e = edgeset_new(-6, -6, 13, 13);
edge_block(e, -1, 0, 0, 0); // the wall on the far side of q = -1
x0 = centre_x(-3, 0);
y0 = centre_y(-3, 0);
x1 = centre_x(2, 0);
y1 = centre_y(2, 0);
(t, cq, cr, bd) = sweep_path(e, x0, y0, x1, y1);
assert(bd >= 0, "the wall is found, got dir {bd}");
assert(cq == -1 && cr == 0,
"and the cell it stopped in is genuinely q = -1, got ({cq},{cr})");
assert(t > 0.0 && t < 1.0, "part-way along, got {t}");
// and with nothing in the way, the SAME cell coordinates would be no signal at all
clear = edgeset_new(-6, -6, 13, 13);
(t2, _q, _r, b2) = sweep_path(clear, x0, y0, x1, y1);
assert(b2 < 0 && t2 == 1.0, "an empty field travels the whole segment, got {t2}/{b2}");
}
// @HXE-006 — FIRST-WRITER-WINS IS ORDER-DEPENDENT ON PURPOSE; ARBITRATION IS NOT.
//
// `edge_block_surf` keeps whichever surface claimed the edge first. That is the cheap
// path, and it is correct only where one part can claim an edge. Where two parts MEET,
// the order in which the caller happened to stamp them decides who owns the shared edge
// — stamp the wall then the tower and you get a different world than the other way
// round, which is the class of bug no unit test with one construction order will find.
//
// `edge_block_arb` decides on GEOMETRY instead: the surface nearest the edge's contact
// point wins, ties to the lower id. Below, the same two surfaces are stamped in both
// orders — 0.87 away and 49.13 away from the shared edge — and only the arbitrated pair
// agrees with itself.
fn test_two_parts_meeting_need_arbitration_not_stamping_order() {
sf = surfaces_new();
near = surf_straight(sf, 1.0, 0.0, 0.0); // the line x = 0
far = surf_straight(sf, 1.0, 0.0, 50.0); // the line x = 50
(ex, ey) = edge_point(0, 0, 1, 0);
assert(surf_distance(sf, near, ex, ey) > 0.866 && surf_distance(sf, near, ex, ey) < 0.867,
"the shared edge is sqrt(3)/2 from the near surface, got"
+ " {surf_distance(sf, near, ex, ey)}");
assert(surf_distance(sf, far, ex, ey) > 49.13 && surf_distance(sf, far, ex, ey) < 49.14,
"and 49.13 from the far one, got {surf_distance(sf, far, ex, ey)}");
a = edgeset_new(-4, -4, 9, 9);
edge_block_surf(a, 0, 0, 1, 0, near);
edge_block_surf(a, 0, 0, 1, 0, far);
b = edgeset_new(-4, -4, 9, 9);
edge_block_surf(b, 0, 0, 1, 0, far);
edge_block_surf(b, 0, 0, 1, 0, near);
assert(edge_surf(a, 0, 0, 1, 0) == near && edge_surf(b, 0, 0, 1, 0) == far,
"first-writer-wins hands the edge to whoever was stamped first -- two different"
+ " worlds from the same two parts");
c = edgeset_new(-4, -4, 9, 9);
edge_block_arb(c, sf, 0, 0, 1, 0, near, 0);
edge_block_arb(c, sf, 0, 0, 1, 0, far, 0);
d = edgeset_new(-4, -4, 9, 9);
edge_block_arb(d, sf, 0, 0, 1, 0, far, 0);
edge_block_arb(d, sf, 0, 0, 1, 0, near, 0);
assert(edge_surf(c, 0, 0, 1, 0) == near && edge_surf(d, 0, 0, 1, 0) == near,
"arbitration gives the edge to the NEARER surface either way, got"
+ " {edge_surf(c, 0, 0, 1, 0)} and {edge_surf(d, 0, 0, 1, 0)}");
}
// @HXE-007 — "BLOCKED" AND "BOUNCEABLE" ARE TWO DIFFERENT ANSWERS.
//
// `edge_block` marks an edge with no recoverable geometry — `SURF_NONE` (65535), an id
// no surface table reaches. `collide` on such an edge answers
// `(true, 0.0, 0.0)`: genuinely blocked, and with a normal that is a legal pair of
// floats carrying no direction.
//
// REFLECT A VELOCITY OFF (0,0) AND NOTHING HAPPENS — `v - 2(v·n)n` is `v`. So a body
// meeting a bare blocker keeps its velocity and grinds along it, and the tuple's type
// says nothing about which case you have. Check the normal, not just the flag: use
// `edge_block_surf` with a real surface wherever a bounce matters.
fn test_a_bare_blocker_is_blocked_with_no_usable_normal() {
sf = surfaces_new();
wall = surf_straight(sf, 1.0, 0.0, 0.0);
bare = edgeset_new(-4, -4, 9, 9);
edge_block(bare, 0, 0, 1, 0);
assert(edge_surf(bare, 0, 0, 1, 0) == SURF_NONE,
"a bare blocker stores the sentinel 65535, got {edge_surf(bare, 0, 0, 1, 0)}");
assert(!passable(bare, 0, 0, 1, 0), "and the edge is not passable");
(b1, nx1, ny1) = collide(bare, sf, 0, 0, 1, 0);
assert(b1, "collide agrees it is blocked");
assert(nx1 == 0.0 && ny1 == 0.0,
"but hands back a zero normal, got ({nx1},{ny1}) -- reflecting off it is a no-op");
known = edgeset_new(-4, -4, 9, 9);
edge_block_surf(known, 0, 0, 1, 0, wall);
(b2, nx2, ny2) = collide(known, sf, 0, 0, 1, 0);
assert(b2 && nx2 == 1.0 && ny2 == 0.0,
"an edge that names its surface bounces exactly, got ({nx2},{ny2})");
}
// @HXE-008 — A FEATURE RETARGETS THE MATERIAL AND NEVER THE SURFACE.
//
// A door is not a hole in the wall. `apply_features` gives the edges inside a feature's
// interval that feature's MATERIAL, and leaves the surface id exactly as it was — so the
// wall is still one wall to everything that reasons about geometry (the bounce normal,
// the junction check, a matcher looking for a straight run), while the physics of that
// stretch changes.
//
// Cut the surface instead and a doorway becomes two walls that happen to be collinear,
// which is how a body starts catching on its own doorframe.
fn test_a_door_changes_the_material_and_leaves_the_wall_one_wall() {
sf = surfaces_new();
wall = surf_straight(sf, 0.0, 1.0, 0.0); // the line y = 0, normal +y
m = materials_new();
stone = material_add(m, true, 3.0, 1.0, 0.2, 0.0, 0.3);
doorway = material_add(m, false, 3.0, 0.0, 1.0, 1.0, 0.0);
e = edgeset_new(-4, -4, 9, 9);
// a run of edges along one row, every one of them owned by the same straight
for q in -2..3 { edge_block_full(e, q, 0, nb_q(q, 0, 2), nb_r(q, 0, 2), wall, stone); }
f = features_new();
(dx, _dy) = edge_point(0, 0, nb_q(0, 0, 2), nb_r(0, 0, 2));
t = surf_param(sf, wall, dx, 0.0);
feature_add(f, wall, t - 0.4, t + 0.4, 0.0, 2.0, FEAT_DOOR, doorway);
before = edge_surf(e, 0, 0, nb_q(0, 0, 2), nb_r(0, 0, 2));
hits = apply_features(e, sf, f, -4, -4, 9, 9);
assert(hits > 0, "the door lands on at least one edge, got {hits}");
assert(edge_mat(e, 0, 0, nb_q(0, 0, 2), nb_r(0, 0, 2)) == doorway,
"the doorway edge takes the door's material");
assert(edge_surf(e, 0, 0, nb_q(0, 0, 2), nb_r(0, 0, 2)) == before,
"and keeps the surface it had -- the wall is still one wall");
// the whole run still names one surface, which is what a geometry consumer reads
same = 0;
for q2 in -2..3 {
if edge_surf(e, q2, 0, nb_q(q2, 0, 2), nb_r(q2, 0, 2)) == wall { same = same + 1; }
}
assert(same == 5, "all five edges still belong to the one straight, got {same}");
assert(!mat_solid(m, doorway) && mat_solid(m, stone),
"while the physics of that stretch is what changed");
// and the interval is stated in the surface's own parameter, which goes both ways --
// `surf_param` asks where a point sits, `surf_point` puts you back there exactly
(bx, by) = surf_point(sf, wall, 2.5);
assert(surf_param(sf, wall, bx, by) == 2.5,
"surface parameter round-trips exactly, got {surf_param(sf, wall, bx, by)}");
}
// @HXE-009 — A WALL YOU CAN BE INSIDE, OR A BODY YOU CANNOT ENTER.
//
// Two ways to turn the same cells into collision, and they are not degrees of the same
// thing. `edges_cut` marks only the boundary — the cut between the set and everything
// else — so the region has an INTERIOR you can move around in: a building. `edges_solid`
// marks every edge touching an occupied cell, so the cells cannot be entered at all: a
// pillar, a rock.
//
// On the same seven-cell disc that is 18 edges against 30, and the difference is entirely
// the twelve edges INSIDE. Pick `edges_solid` for a building and it has no rooms; pick
// `edges_cut` for a rock and a body walks into the middle of it and is then trapped by
// its own wall.
fn test_a_cut_has_an_interior_and_a_solid_does_not() {
s = hexset_chunk(-3, -3, 7, 7);
hexdisk_into(s, 0, 0, 1);
assert(hexset_count(s) == 7, "a centre and its ring of six, got {hexset_count(s)}");
cut = edgeset_new(-4, -4, 9, 9);
edges_cut(s, cut);
solid = edgeset_new(-4, -4, 9, 9);
edges_solid(s, solid);
assert(marked(cut) == 18,
"the boundary is 6 ring cells x 3 outward edges = 18, got {marked(cut)}");
assert(marked(solid) == 30,
"and the solid body adds the 12 interior edges, got {marked(solid)}");
assert(passable(cut, 0, 0, 1, 0),
"inside a cut region you can move: centre to ring is open");
assert(!passable(solid, 0, 0, 1, 0),
"inside a solid one you cannot -- the same step is blocked");
assert(!passable(cut, 1, 0, 2, 0) && !passable(solid, 1, 0, 2, 0),
"both stop you leaving, which is the half they agree on");
// The third constructor is not a cell set at all: `edges_halfplane` marks every edge a
// LINE crosses, so a wall can run at any angle and is still exactly one edge thick.
// What it promises is SEPARATION, not a count, so that is what is measured -- at a
// heading the lattice has no axis for, which is where a cell-shaped wall would leak.
fence = edgeset_new(-6, -6, 13, 13);
edges_halfplane(fence, -6, -6, 13, 13, 0.0, 1.0, 0.5);
crossed = 0;
stayed = 0;
for i in 0..21 {
x = 0.0 - 5.0 + (i as float) * 0.5;
(_t, _q, _r, d) = sweep_path(fence, x, -2.0, x + 1.0, 4.0);
if d >= 0 { crossed = crossed + 1; }
(_t2, _q2, _r2, d2) = sweep_path(fence, x, -3.0, x + 1.0, -1.0);
if d2 < 0 { stayed = stayed + 1; }
}
assert(crossed == 21, "every path across the line is stopped, got {crossed} of 21");
assert(stayed == 21, "and every path that stays on one side is clear, got {stayed} of 21");
diagonal = edgeset_new(-6, -6, 13, 13);
edges_halfplane(diagonal, -6, -6, 13, 13, 0.7071067811865476, 0.7071067811865476, 0.5);
diag = 0;
for i2 in 0..21 {
x2 = 0.0 - 6.0 + (i2 as float) * 0.4;
(_t3, _q3, _r3, d3) = sweep_path(diagonal, x2 - 3.0, -4.0, x2 + 3.0, 4.0);
if d3 >= 0 { diag = diag + 1; }
}
assert(diag == 21,
"a wall at 45 degrees -- no lattice axis -- separates just as completely, got"
+ " {diag} of 21");
}
// Every marked edge in the set, counted once: three canonical directions per cell own
// each edge exactly once.
fn marked(e: EdgeSet) -> integer {
n = 0;
for r in -4..5 {
for q in -4..5 {
for d in 0..3 { if edge_blocked(e, q, r, nb_q(q, r, d), nb_r(q, r, d)) { n = n + 1; } }
}
}
n
}
// @HXE-010 — SIGHT READS THE MATERIAL. MOVEMENT DOES NOT.
//
// A material carries six transmission terms, and the reason given for the vector is that
// consumers want different ones: a chain-link fence is solid and transparent, a hedge is
// permeable and opaque. Sight honours that — `sight_clear` reads `opacity` and `height`,
// so a transparent edge stops a body and not a look.
//
// MOVEMENT DOES NOT READ ANY OF THEM. `passable` and `sweep_path` take an `EdgeSet` and no
// `Materials`, so what blocks you is the MARK on the edge and nothing else. The sharpest
// form of that: `material_set_solid` is documented as the dynamic-material mechanism — "a
// level-crossing barrier, a door, a portcullis" — and lowering `solid` to false leaves
// `passable` false and the sweep stopping in exactly the same place. Raising the
// portcullis does not open it.
//
// So of the six terms, two change an answer in this package (`opacity`, `height`, both in
// `sight_clear`) and four are data for the consumer to read: `solid`, `sound`, `perm`,
// `bounce`. Clearing the edge mark is what opens a gate; the material says what kind of
// gate it was.
fn test_sight_reads_the_material_and_movement_does_not() {
sf = surfaces_new();
wall = surf_straight(sf, 1.0, 0.0, 0.0);
m = materials_new();
gate = material_add(m, true, 3.0, 1.0, 0.0, 0.0, 0.0); // opaque, closed
e = edgeset_new(-4, -4, 9, 9);
edge_block_full(e, 0, 0, 1, 0, wall, gate);
ex = centre_x(0, 0);
ey = centre_y(0, 0);
tx = centre_x(1, 0);
ty = centre_y(1, 0);
assert(!passable(e, 0, 0, 1, 0), "closed, so you cannot walk through");
assert(!sight_clear(e, m, ex, ey, 1.0, tx, ty, 1.0, 0.5), "and cannot see through");
// raise it -- the documented dynamic-material mechanism
material_set_solid(m, gate, false);
assert(!mat_solid(m, gate), "the table now says the gate is open");
assert(!passable(e, 0, 0, 1, 0),
"and movement is UNCHANGED -- passable never consults a material");
(t, cq, cr, d) = sweep_path(e, ex, ey, centre_x(2, 0), centre_y(2, 0));
assert(d == 0 && cq == 0 && cr == 0,
"the sweep still stops at the raised gate, got dir {d} in ({cq},{cr})");
assert(t == 0.25, "in exactly the same place as before, got {t}");
// sight, by contrast, follows the material it is given
clear_mat = material_add(m, true, 3.0, 0.0, 0.0, 0.0, 0.0); // solid, TRANSPARENT
g = edgeset_new(-4, -4, 9, 9);
edge_block_full(g, 0, 0, 1, 0, wall, clear_mat);
assert(!passable(g, 0, 0, 1, 0), "a chain-link fence still stops a body");
assert(sight_clear(g, m, ex, ey, 1.0, tx, ty, 1.0, 0.5),
"and does not stop a look -- sight reads opacity, movement reads the mark");
// The other three terms are stored and readable and change no answer in this package.
// That is not a gap to fix here: they are what a consumer's audio, fluid and bounce
// code reads off the same edge, which is the reason a material is a vector at all.
hedge = material_add(m, false, 1.5, 1.0, 12.5, 0.75, 0.1);
assert(mat_sound(m, hedge) == 12.5, "sound is carried, got {mat_sound(m, hedge)}");
assert(mat_perm(m, hedge) == 0.75, "permeability too, got {mat_perm(m, hedge)}");
assert(mat_bounce(m, hedge) == 0.1, "and bounce, got {mat_bounce(m, hedge)}");
h = edgeset_new(-4, -4, 9, 9);
edge_block_full(h, 0, 0, 1, 0, wall, hedge);
assert(!passable(h, 0, 0, 1, 0),
"a hedge declared permeable still stops a body -- the mark is what blocks");
}
// @HXE-011 — A JUNCTION RECORDS WHAT WAS INTENDED, SO A CORNER IS NOT A KINK.
//
// Where two surfaces meet, "the tangents disagree" is not by itself an error: a building
// corner is supposed to disagree, and a wall running into a round tower is supposed to
// agree. Without the recorded intent the same measurement has to mean both, so either
// every corner is a defect or no broken smooth join is.
//
// `junction_ok` therefore checks the tangent AGAINST the declared kind: `JOIN_SMOOTH`
// wants the normals parallel, `JOIN_CORNER` wants them not. Below, one pair of
// perpendicular walls is a valid corner and an invalid smooth join -- the same geometry,
// two verdicts, and the declaration is the whole difference.
fn test_a_junction_is_checked_against_the_continuity_it_declared() {
sf = surfaces_new();
ew = surf_straight(sf, 1.0, 0.0, 0.0); // the line x = 0
ns = surf_straight(sf, 0.0, 1.0, 0.0); // the line y = 0
tower = surf_arc(sf, 5.0, 0.0, 5.0); // touches x = 0 at the origin
j = junctions_new();
junction_add(j, ew, ns, JOIN_CORNER, 0.0, 0.0);
junction_add(j, ew, ns, JOIN_SMOOTH, 0.0, 0.0);
junction_add(j, ew, tower, JOIN_SMOOTH, 0.0, 0.0);
assert(junction_ok(j, 0, sf), "two perpendicular walls ARE a valid deliberate corner");
assert(!junction_ok(j, 1, sf),
"and the same two are NOT a valid smooth join -- the intent is what decides");
assert(junction_ok(j, 2, sf),
"a wall meeting a tower tangentially is a valid smooth join");
// G0 is the other half, and it is separate: both surfaces must pass through the seam
assert(junction_g0(j, 2, sf, 0.000001),
"and both surfaces really do pass through the seam point");
}