drawing source

← drawing · API reference · version 0.4.3

11 file(s), 3717 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
Brush struct43—148 150 152 418 180 765 … (8 total)
LockStyle struct54—87 88 206 418 130 816
Layer struct78—418 419 509 516
scaled fn87—1529
hair_brush fn114—766 801
load_brush fn148—777
lock_layer fn417—1528
composite_layer fn516—1530
SUPERSAMPLE const56—1460 1486 1529 1541 1542 82
DEFAULT_INK_R const59—209 63
DEFAULT_INK_G const60—209 64
DEFAULT_INK_B const61—209 65
PaintKind enum67—99
FrondSpec struct74—699 1608 1676
Paint struct98—120 284 289 297 304 309 … (13 total)
OpKind enum109—118
Op struct117—169 564 567 619 729 827 … (15 total)
Elem struct135—170 874 899 1091 1305 1306
Landmark struct152—171 962
Sketch struct165DRW-002527 576 654 746 757 789 … (25 total)
grey fn193—309 931 168 175 176 177
PointList struct354—369 370
read_points fn369—790 836
circle_pts fn412—561
smooth_applies fn448—462 854
smooth_pts fn458—726 805 841 853 860
smooth_vals fn494—727 854
parse_scene fn974DRW-00146 96 206 229 237 37 … (43 total)
parse_scene_at fn978—974 1563
Check struct1067—1196 1220 1229 1243 1249 1250 … (8 total)
DEFAULT_TOL const1088—1197
eval_check fn1196—1251
eval_checks fn1249DRW-00338 48 62 69 79 87 … (14 total)
scene_ok fn1262—113 117 120 123
metric_report fn1274—103 129 137
render fn1540—1564 49 98 206 229 52 … (10 total)
render_file fn1559—none in this package
hash01 fn1575—1632 1633 1636
lowfreq fn1580—1624
Frond struct1583—1609 1610 1661
fronds fn1608—657 718 1011 1683
PI const13—187 269 417 1634 45 46 … (7 total)
seed_hash fn30—119 120 121 257 267 269 … (10 total)
seed_wave fn44—128 132 1580
Pt struct41—119 355 413 415 429 439 … (35 total)
pt fn47—394 418 442 475 476 485 … (26 total)
round_up fn66—227 308 104 105
round_down fn77—228 309 310 311 312 313 … (9 total)
fill_poly fn240—1347 1446 1515 36 48 60 … (7 total)
wide_line fn294—1469 82
thin_line fn330—1339 1467 93
Scan struct23—627 629 631 1159 1169 31 … (16 total)
scan_fail fn31—1174 99 114 121 123 145 … (7 total)
is_digit fn35—88 107 120
is_word_byte fn38—742 956 76
is_space_byte fn43—774 1211 59
at fn49—218 227 231 232 235 236 … (123 total)
lower_byte fn54—544 551 685 943 68
skip_space fn57—228 231 233 235 237 324 … (64 total)
matches_at fn64—557 692 937 137 159
word_boundary fn74—1163 137
read_number fn84—343 376 380 391 535 539 … (20 total)
read_unsigned fn103—308 927 928 1141 1168
read_uint fn118—218 229 233 237 552 688 … (8 total)
find_option fn133—216 261 285 293 301 306 … (15 total)
find_word_from fn155—530 579 657

src/brush.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// brush — an IMAGE dragged along a path, and the lock of hair it paints.
//
// Every other mark in a `.draw` scene deposits its colour opaquely; a brush is how
// paint gets TEXTURE.  A `Brush` is a small RGBA footprint: its columns map ACROSS
// the stroke, stretched to the local width, its rows ALONG it, tiled every
// `period` canvas px.  `Lock` drags one along a path with the width profile of a
// lock of hair — pinched where it grows from the skin (`w0`), swelling to `w` by
// fraction `swell` of the length, body to fraction `body`, then `tips` spikes of
// uneven length that taper to points where the hairs ran out — and shades it as a
// half-cylinder against a light: the flank facing the light rises to `lit`, the
// underside sinks to `dark`, the crest is the base colour.  `dark` is a colour
// and not a factor because the shadow of white hair is blue in some styles.
//
// ── THE INVARIANT IS draw.py's (@FR-Oracle-Bytes) ──────────────────────────────────────
//
// Like everything in this package, the picture is the one `draw.py` renders, byte
// for byte (`tests/lock.loft` carries its bytes).  Two things make that reachable
// for a brush, where the polygon filler needed Pillow's own code:
//
//  1. A stroke is built in its own LAYER first — one pass over its bounding box in
//     which each pixel keeps the sample from the centreline it is transversally
//     CLOSEST to (smallest `|s|`), so body and spikes join without a seam whatever
//     order they are laid in — and composited over the canvas ONCE, in integer
//     arithmetic.  The oracle composites with the same integers rather than with
//     Pillow's paste, so there is no library rounding to reproduce.
//  2. The arithmetic is the same expressions in the same order: `sqrt` of a sum
//     of squares rather than `hypot`, a running sum rather than Python's
//     compensated `sum()`, degrees times `PI / 180.0`.  Both run on the same
//     libm, so only the expression order could separate them.
//
// Written in the same portable style as its oracle: flat integer buffers,
// explicit loops.
use graphics::*;
use imaging::*;
use noise::*;

/// A footprint: `bw` columns across the stroke, `bh` rows along it, packed
/// 0xAARRGGBB, tiled every `period` canvas px along the stroke.  Column 0 lands on
/// the stroke's RIGHT side walking root→tip (`flip` mirrors it).
pub struct Brush {
  pub bname: text = "",
  pub bw: integer = 0,
  pub bh: integer = 0,
  pub period: float = 0.0,
  pub img: vector<integer>,
}

/// Everything a `Lock` line says past its points.  Widths and `period` are in
/// canvas px here and scaled to the supersampled canvas by `scaled` at the raster
/// call, like every other mark.
pub struct LockStyle {
  pub w0: float = 2.0,
  pub w: float = 10.0,
  pub swell: float = 0.3,
  pub body: float = 0.8,
  pub tips: integer = 3,
  pub tipvar: float = 0.35,
  pub spread: float = 8.0,
  pub seed: integer = 1,
  pub period: float = 48.0,
  /// The transverse colour ramp, packed: underside, crest, lit flank.
  pub dark: integer = 0,
  pub base: integer = 0,
  pub lit: integer = 0,
  /// The light's direction; `lz` points out of the paper.
  pub lx: float = -0.5,
  pub ly: float = -0.8,
  pub lz: float = 0.6,
  pub alpha: float = 1.0,
  pub flip: boolean = false,
}

/// A stroke as a layer over the canvas: `px` is `lw × lh` packed 0xAARRGGBB placed
/// at `(x0, y0)`; `lw == 0` means the stroke painted nothing.
pub struct Layer {
  x0: integer = 0,
  y0: integer = 0,
  pub lw: integer = 0,
  pub lh: integer = 0,
  pub px: vector<integer>,
}

/// The style with its canvas-px lengths scaled by `k` — the supersample factor.
pub fn scaled(st: const LockStyle, k: float) -> LockStyle {
  LockStyle { w0: st.w0 * k, w: st.w * k, swell: st.swell, body: st.body, tips: st.tips,
              tipvar: st.tipvar, spread: st.spread, seed: st.seed, period: st.period * k,
              dark: st.dark, base: st.base, lit: st.lit, lx: st.lx, ly: st.ly, lz: st.lz,
              alpha: st.alpha, flip: st.flip }
}

/// `floor` on the truncating cast, correct on both sides of zero.
fn floor_i(v: float) -> integer {
  fi_i = v as integer;
  if (fi_i as float) > v { fi_i - 1 } else { fi_i }
}

fn clampf(v: float, lo: float, hi: float) -> float {
  if v < lo { lo } else if v > hi { hi } else { v }
}

fn clampi(v: integer, lo: integer, hi: integer) -> integer {
  if v < lo { lo } else if v > hi { hi } else { v }
}

/// The built-in split-bristle footprint.  Columns group into channels 1..3 px
/// wide, each a bristle with its own value; a `gap` fraction are thin translucent
/// strands that FADE OUT along the run (the broken parallel streaks a worn brush
/// lays); the outermost columns are soft so the silhouette frays.  Greyscale
/// value × alpha — the stroke's colour ramp multiplies in at resolve time.
/// @FR-Brush-Image — the built-in footprint.
pub fn hair_brush(bw: integer, bh: integer, seed: integer, gap: float) -> vector<integer> {
  hb_img = [for _i in 0..bw * bh { 0 }];
  hb_x = 0;
  hb_ci = 0;
  while hb_x < bw {
    hb_cw = 1 + ((2.99 * (noise::seed_hash(seed, hb_ci, 7) + 1.0) * 0.5) as integer);
    hb_val = 0.72 + 0.28 * (noise::seed_hash(seed, hb_ci, 8) + 1.0) * 0.5;
    hb_split = (noise::seed_hash(seed, hb_ci, 9) + 1.0) * 0.5 < gap;
    for k in 0..hb_cw {
      hb_xx = hb_x + k;
      if hb_xx >= bw { break }
      hb_edge = if hb_xx == 0 || hb_xx == bw - 1 { 0.6 } else { 1.0 };
      for y in 0..bh {
        hb_u = ((y as float) / (bh as float)) ?? 0.0;
        hb_v = hb_val * (1.0 - 0.12 * (1.0 + noise::seed_wave(seed * 3 + hb_ci, hb_u)) * 0.5);
        hb_a = hb_edge;
        if hb_split {
          hb_a = 0.35 * hb_edge
                 * clampf((noise::seed_wave(seed * 7 + hb_ci, hb_u) + 0.6) / 0.6, 0.0, 1.0);
        }
        hb_g = (255.0 * hb_v + 0.5) as integer;
        hb_ai = (255.0 * hb_a + 0.5) as integer;
        hb_img[y * bw + hb_xx] = (hb_ai << 24) | (hb_g << 16) | (hb_g << 8) | hb_g;
      }
    }
    hb_x += hb_cw;
    hb_ci += 1;
  }
  hb_img
}

/// An authored footprint from a PNG: rows along the stroke, columns across.
/// `bw == 0` when the file could not be read.  `period` 0 means one row per
/// canvas px — the image's own height.
pub fn load_brush(path: text, bname: text, period: float) -> Brush {
  lb_im = file(path).png();
  if lb_im == null { return Brush { bname: bname, bw: 0, bh: 0, period: 0.0, img: [] } }
  lb_px = [for i in 0..len(lb_im.data) { imaging::rgba(lb_im.data[i]?) }];
  Brush { bname: bname, bw: lb_im.width, bh: lb_im.height,
          period: if period > 0.0 { period } else { lb_im.height as float }, img: lb_px }
}

// ── The lock's geometry ─────────────────────────────────────────────

/// A point on the path with the unit tangent there.
struct PathPt {
  x: float = 0.0,
  y: float = 0.0,
  tx: float = 0.0,
  ty: float = 0.0,
}

/// The path at arc length `dist`, extrapolated past its end along the last
/// segment — the spikes run on past where the path stops.
fn path_at(px: const vector<float>, py: const vector<float>, cum: const vector<float>,
           dist: float) -> PathPt {
  pa_n = len(px);
  pa_k = pa_n - 2;
  for i in 0..(pa_n - 1) {
    if dist <= cum[i + 1]? { pa_k = i; break }
  }
  pa_seg = cum[pa_k + 1]? - cum[pa_k]?;
  pa_u = ((dist - cum[pa_k]?) / pa_seg) ?? 0.0;
  pa_dx = px[pa_k + 1]? - px[pa_k]?;
  pa_dy = py[pa_k + 1]? - py[pa_k]?;
  PathPt { x: px[pa_k]? + pa_u * pa_dx, y: py[pa_k]? + pa_u * pa_dy,
           tx: (pa_dx / pa_seg) ?? 0.0, ty: (pa_dy / pa_seg) ?? 0.0 }
}

/// The body's width at fraction `t`: `w0` at the root, `w` from `swell` on, a
/// sine ease between — the hairs gain their freedom quickly once off the skin.
fn lock_width(t: float, w0: float, w: float, swell: float) -> float {
  if swell <= 0.000000001 || t >= swell { return w }
  w0 + (w - w0) * sin((0.5 * noise::PI * t / swell) ?? 0.0)
}

/// A sampled centreline with a half-width, an arc position and a spike fraction
/// per sample, plus the slice `[slo, shi]` of the body's across axis it carries:
/// the body is `[-1, 1]`, a spike keeps its share, so the bristle streaks run on
/// from the body into the spike.
struct Ribbon {
  rx: vector<float>,
  ry: vector<float>,
  rhw: vector<float>,
  ral: vector<float>,
  rmx: vector<float>,
  slo: float = -1.0,
  shi: float = 1.0,
}

/// The lock as ribbons: the body first, then one per spike.
/// @FR-Lock-Profile · @FR-Lock-Streak (the `[slo, shi]` slice each ribbon carries)
fn lock_ribbons(xs: const vector<float>, ys: const vector<float>, st: const LockStyle)
    -> vector<Ribbon> {
  lr_px: vector<float> = [xs[0]?];
  lr_py: vector<float> = [ys[0]?];
  lr_cum: vector<float> = [0.0];
  for i in 1..len(xs) {
    lr_ddx = xs[i]? - lr_px[len(lr_px) - 1]?;
    lr_ddy = ys[i]? - lr_py[len(lr_py) - 1]?;
    lr_d = sqrt(lr_ddx * lr_ddx + lr_ddy * lr_ddy) ?? 0.0;
    if lr_d > 0.000001 {
      lr_px += [xs[i]?];
      lr_py += [ys[i]?];
      lr_cum += [lr_cum[len(lr_cum) - 1]? + lr_d];
    }
  }
  lr_out: vector<Ribbon> = [];
  if len(lr_px) < 2 { return lr_out }
  lr_len = lr_cum[len(lr_cum) - 1]?;
  lr_body = clampf(st.body, 0.05, 1.0);
  lr_swell = clampf(st.swell, 0.0, lr_body);
  lr_ds = clampf(0.5 * st.w, 2.0, 6.0);
  lr_nb = -floor_i(-((lr_body * lr_len / lr_ds) ?? 0.0));
  if lr_nb < 2 { lr_nb = 2; }
  lr_nb += 1;
  lr_bx: vector<float> = [];
  lr_by: vector<float> = [];
  lr_bhw: vector<float> = [];
  lr_bal: vector<float> = [];
  lr_bmx: vector<float> = [];
  for j in 0..lr_nb {
    lr_t = (lr_body * (j as float) / ((lr_nb - 1) as float)) ?? 0.0;
    lr_dist = lr_t * lr_len;
    lr_q = path_at(lr_px, lr_py, lr_cum, lr_dist);
    lr_bx += [lr_q.x];
    lr_by += [lr_q.y];
    lr_bhw += [0.5 * lock_width(lr_t, st.w0, st.w, lr_swell)];
    lr_bal += [lr_dist];
    lr_bmx += [0.0];
  }
  lr_out += [Ribbon { rx: lr_bx, ry: lr_by, rhw: lr_bhw, ral: lr_bal, rmx: lr_bmx,
                      slo: -1.0, shi: 1.0 }];
  lr_hwb = 0.5 * lock_width(lr_body, st.w0, st.w, lr_swell);
  lr_tail = (1.0 - lr_body) * lr_len;
  if st.tips <= 0 || lr_tail < 1.0 || lr_hwb < 0.3 { return lr_out }
  // The body's end is shared out between the spikes unevenly, and each spike
  // continues the path's own curve at its offset, drifts by its own angle, ends
  // at its own length, and keeps full width leaving the body — `1 - u²` — so no
  // notch opens at the base and the end is still a point.
  lr_raw: vector<float> = [];
  lr_tot = 0.0;
  for i in 0..st.tips {
    lr_r = 1.0 + 0.5 * noise::seed_hash(st.seed, i, 41);
    lr_raw += [lr_r];
    lr_tot += lr_r;
  }
  lr_a1 = -1.0;
  for i in 0..st.tips {
    lr_a0 = lr_a1;
    lr_a1 = lr_a0 + ((2.0 * lr_raw[i]? / lr_tot) ?? 0.0);
    lr_c = 0.5 * (lr_a0 + lr_a1);
    lr_hs = 0.5 * (lr_a1 - lr_a0);
    lr_ell = lr_tail * (1.0 + st.tipvar * noise::seed_hash(st.seed, i, 42));
    if lr_ell < 0.15 * lr_tail { lr_ell = 0.15 * lr_tail; }
    lr_th = st.spread * noise::seed_hash(st.seed, i, 43) * (noise::PI / 180.0);
    lr_m = -floor_i(-((lr_ell / lr_ds) ?? 0.0));
    if lr_m < 2 { lr_m = 2; }
    lr_m += 1;
    lr_sx: vector<float> = [];
    lr_sy: vector<float> = [];
    lr_shw: vector<float> = [];
    lr_sal: vector<float> = [];
    lr_smx: vector<float> = [];
    for j in 0..lr_m {
      lr_u = ((j as float) / ((lr_m - 1) as float)) ?? 0.0;
      lr_sd = lr_body * lr_len + lr_u * lr_ell * cos(lr_th);
      lr_sq = path_at(lr_px, lr_py, lr_cum, lr_sd);
      lr_off = lr_c * lr_hwb + lr_u * lr_ell * sin(lr_th);
      lr_sx += [lr_sq.x + lr_sq.ty * lr_off];
      lr_sy += [lr_sq.y - lr_sq.tx * lr_off];
      lr_shw += [lr_hs * lr_hwb * (1.0 - lr_u * lr_u)];
      lr_sal += [lr_sd];
      lr_smx += [lr_u];
    }
    lr_out += [Ribbon { rx: lr_sx, ry: lr_sy, rhw: lr_shw, ral: lr_sal, rmx: lr_smx,
                        slo: lr_a0, shi: lr_a1 }];
  }
  lr_out
}

// ── Rasterising into a layer ────────────────────────────────────────

/// The stroke while it is being built: per layer pixel, the best `|s|` so far and
/// the sample that set it — the body across-coordinate `sb`, the ribbon's own
/// `sl`, the arc position `al`, how far into a spike `mx`, and the ribbon's left
/// normal.
struct Lay {
  x0: integer = 0,
  y0: integer = 0,
  lw: integer = 0,
  lh: integer = 0,
  best: vector<float>,
  sb: vector<float>,
  sl: vector<float>,
  al: vector<float>,
  mx: vector<float>,
  nx: vector<float>,
  ny: vector<float>,
}

/// One ribbon segment into the layer: every pixel centre within the local
/// half-width of its projection onto the segment (round joins for free — the
/// projection clamps to the endpoints).  A pixel keeps the sample with the
/// SMALLEST `|s|`, from the centreline it is transversally closest to; that is
/// what makes the union of body and spikes seamless whatever order they are laid
/// in.  `mx` is how far the sample is into a spike (0 on the body): the shading
/// blends from the lock's cylinder to the spike's own by it.
/// @FR-Brush-Layer
fn raster_segment(lay: Lay, ax: float, ay: float, bx: float, by: float, hwa: float, hwb: float,
                  ala: float, alb: float, mxa: float, mxb: float, slo: float, shi: float) {
  rs_dx = bx - ax;
  rs_dy = by - ay;
  rs_l2 = rs_dx * rs_dx + rs_dy * rs_dy;
  if rs_l2 < 0.000000001 { return }
  rs_ln = sqrt(rs_l2) ?? 0.0;
  rs_nx = (rs_dy / rs_ln) ?? 0.0;
  rs_ny = (-rs_dx / rs_ln) ?? 0.0;
  rs_hm = if hwa > hwb { hwa } else { hwb };
  rs_x0 = floor_i((if ax < bx { ax } else { bx }) - rs_hm);
  if rs_x0 < lay.x0 { rs_x0 = lay.x0; }
  rs_x1 = -floor_i(-((if ax > bx { ax } else { bx }) + rs_hm));
  if rs_x1 > lay.x0 + lay.lw - 1 { rs_x1 = lay.x0 + lay.lw - 1; }
  rs_y0 = floor_i((if ay < by { ay } else { by }) - rs_hm);
  if rs_y0 < lay.y0 { rs_y0 = lay.y0; }
  rs_y1 = -floor_i(-((if ay > by { ay } else { by }) + rs_hm));
  if rs_y1 > lay.y0 + lay.lh - 1 { rs_y1 = lay.y0 + lay.lh - 1; }
  if rs_x1 < rs_x0 || rs_y1 < rs_y0 { return }
  // Hoisted: a struct field read costs a store lookup per pixel in the native build, and
  // nothing hoists it for us (loft#1426).
  rs_ly0 = lay.y0;
  rs_llw = lay.lw;
  rs_lx0 = lay.x0;
  for yy in rs_y0..(rs_y1 + 1) {
    rs_cy = (yy as float) + 0.5;
    for xx in rs_x0..(rs_x1 + 1) {
      rs_cx = (xx as float) + 0.5;
      rs_u = (((rs_cx - ax) * rs_dx + (rs_cy - ay) * rs_dy) / rs_l2) ?? 0.0;
      if rs_u < 0.0 { rs_u = 0.0; } else if rs_u > 1.0 { rs_u = 1.0; }   // clampf, inlined: a call per pixel costs ~7 ns of entry instrumentation
      rs_hw = hwa + (hwb - hwa) * rs_u;
      if rs_hw <= 0.01 { continue }
      rs_dist = (rs_cx - ax - rs_u * rs_dx) * rs_nx + (rs_cy - ay - rs_u * rs_dy) * rs_ny;
      rs_s = (rs_dist / rs_hw) ?? 0.0;
      rs_a = if rs_s >= 0.0 { rs_s } else { -rs_s };
      if rs_a > 1.0 { continue }
      rs_idx = (yy - rs_ly0) * rs_llw + (xx - rs_lx0);
      if rs_a < lay.best[rs_idx]? {
        lay.best[rs_idx] = rs_a;
        lay.sl[rs_idx] = rs_s;
        lay.sb[rs_idx] = slo + (rs_s + 1.0) * 0.5 * (shi - slo);
        lay.al[rs_idx] = ala + (alb - ala) * rs_u;
        lay.mx[rs_idx] = mxa + (mxb - mxa) * rs_u;
        lay.nx[rs_idx] = rs_nx;
        lay.ny[rs_idx] = rs_ny;
      }
    }
  }
}

/// A bilinear RGBA sample of the footprint, each channel 0..255 as a float.
struct Smp {
  a: float = 0.0,
  r: float = 0.0,
  g: float = 0.0,
  b: float = 0.0,
}

fn chan(c: integer, sh: integer) -> float { ((c >> sh) & 255) as float }

/// Bilinear: clamp across (`fu`), wrap along (`fv`, already non-negative).
/// @FR-Brush-Image — the sampling half.
fn brush_sample(img: const vector<integer>, bw: integer, bh: integer, fu: float, fv: float) -> Smp {
  bs_iu = floor_i(fu);
  bs_tu = fu - (bs_iu as float);
  bs_iv = fv as integer;
  bs_tv = fv - (bs_iv as float);
  bs_u0 = clampi(bs_iu, 0, bw - 1);
  bs_u1 = clampi(bs_iu + 1, 0, bw - 1);
  bs_v0 = (bs_iv % bh) ?? 0;
  bs_v1 = ((bs_iv + 1) % bh) ?? 0;
  bs_c00 = img[bs_v0 * bw + bs_u0]?;
  bs_c10 = img[bs_v0 * bw + bs_u1]?;
  bs_c01 = img[bs_v1 * bw + bs_u0]?;
  bs_c11 = img[bs_v1 * bw + bs_u1]?;
  bs_w00 = (1.0 - bs_tu) * (1.0 - bs_tv);
  bs_w10 = bs_tu * (1.0 - bs_tv);
  bs_w01 = (1.0 - bs_tu) * bs_tv;
  bs_w11 = bs_tu * bs_tv;
  Smp { a: chan(bs_c00, 24) * bs_w00 + chan(bs_c10, 24) * bs_w10 + chan(bs_c01, 24) * bs_w01 + chan(bs_c11, 24) * bs_w11,
        r: chan(bs_c00, 16) * bs_w00 + chan(bs_c10, 16) * bs_w10 + chan(bs_c01, 16) * bs_w01 + chan(bs_c11, 16) * bs_w11,
        g: chan(bs_c00, 8) * bs_w00 + chan(bs_c10, 8) * bs_w10 + chan(bs_c01, 8) * bs_w01 + chan(bs_c11, 8) * bs_w11,
        b: chan(bs_c00, 0) * bs_w00 + chan(bs_c10, 0) * bs_w10 + chan(bs_c01, 0) * bs_w01 + chan(bs_c11, 0) * bs_w11 }
}

/// One channel of the ramp `c0 → c1` at `f`, times the footprint's value `v`.
fn ramp(c0: integer, c1: integer, f: float, v: float) -> integer {
  (((c0 as float) + ((c1 - c0) as float) * f) * v / 255.0 + 0.5) as integer
}

/// Drag brush `br` along the pixel path (`xs`, `ys`) as a lock styled by `st`
/// (pixel units), answering the stroke as a layer clipped to the `cw × ch`
/// canvas — `lw == 0` when it paints nothing.
/// @FR-Brush-Ramp (the resolve) · @FR-Oracle-Order (expression for expression the oracle's)
pub fn lock_layer(xs: const vector<float>, ys: const vector<float>, cw: integer, ch: integer,
                  br: const Brush, st: const LockStyle) -> Layer {
  ll_none = Layer { x0: 0, y0: 0, lw: 0, lh: 0, px: [] };
  ll_rib = lock_ribbons(xs, ys, st);
  if len(ll_rib) == 0 { return ll_none }
  ll_minx = 1000000000000000000.0;
  ll_miny = 1000000000000000000.0;
  ll_maxx = -1000000000000000000.0;
  ll_maxy = -1000000000000000000.0;
  for r in ll_rib {
    for i in 0..len(r.rx) {
      ll_x = r.rx[i]?;
      ll_y = r.ry[i]?;
      ll_h = r.rhw[i]?;
      if ll_x - ll_h < ll_minx { ll_minx = ll_x - ll_h; }
      if ll_x + ll_h > ll_maxx { ll_maxx = ll_x + ll_h; }
      if ll_y - ll_h < ll_miny { ll_miny = ll_y - ll_h; }
      if ll_y + ll_h > ll_maxy { ll_maxy = ll_y + ll_h; }
    }
  }
  ll_x0 = floor_i(ll_minx) - 1;
  if ll_x0 < 0 { ll_x0 = 0; }
  ll_y0 = floor_i(ll_miny) - 1;
  if ll_y0 < 0 { ll_y0 = 0; }
  ll_x1 = floor_i(ll_maxx) + 1;
  if ll_x1 > cw - 1 { ll_x1 = cw - 1; }
  ll_y1 = floor_i(ll_maxy) + 1;
  if ll_y1 > ch - 1 { ll_y1 = ch - 1; }
  if ll_x1 < ll_x0 || ll_y1 < ll_y0 { return ll_none }
  ll_lw = ll_x1 - ll_x0 + 1;
  ll_lh = ll_y1 - ll_y0 + 1;
  ll_n = ll_lw * ll_lh;
  ll_lay = Lay { x0: ll_x0, y0: ll_y0, lw: ll_lw, lh: ll_lh,
                 best: [for _i in 0..ll_n { 2.0 }], sb: [for _i in 0..ll_n { 0.0 }],
                 sl: [for _i in 0..ll_n { 0.0 }], al: [for _i in 0..ll_n { 0.0 }],
                 mx: [for _i in 0..ll_n { 0.0 }], nx: [for _i in 0..ll_n { 0.0 }],
                 ny: [for _i in 0..ll_n { 0.0 }] };
  for r in ll_rib {
    for i in 0..(len(r.rx) - 1) {
      raster_segment(ll_lay, r.rx[i]?, r.ry[i]?, r.rx[i + 1]?, r.ry[i + 1]?,
                     r.rhw[i]?, r.rhw[i + 1]?, r.ral[i]?, r.ral[i + 1]?,
                     r.rmx[i]?, r.rmx[i + 1]?, r.slo, r.shi);
    }
  }
  // Resolve: the footprint sample, then the half-cylinder against the light.
  ll_lx = st.lx;
  ll_ly = st.ly;
  ll_lz = st.lz;
  ll_ll = sqrt(ll_lx * ll_lx + ll_ly * ll_ly + ll_lz * ll_lz) ?? 0.0;
  if ll_ll < 0.000000001 { ll_lx = 0.0; ll_ly = 0.0; ll_lz = 1.0; ll_ll = 1.0; }
  ll_lx = (ll_lx / ll_ll) ?? 0.0;
  ll_ly = (ll_ly / ll_ll) ?? 0.0;
  ll_lz = (ll_lz / ll_ll) ?? 0.0;
  ll_crest = clampf(ll_lz, 0.05, 0.95);
  ll_period = if st.period > 0.000001 { st.period } else { 1.0 };
  // Where along the tile this lock starts, so two locks do not share a pattern.
  ll_phase = (noise::seed_hash(st.seed, 0, 44) + 1.0) * 0.5 * ll_period;
  ll_bw = br.bw as float;
  ll_bh = br.bh as float;
  ll_out = [for _i in 0..ll_n { 0 }];
  for idx in 0..ll_n {
    if ll_lay.best[idx]? > 1.5 { continue }
    ll_s = ll_lay.sb[idx]?;
    ll_uu = (ll_s + 1.0) * 0.5;
    if st.flip { ll_uu = 1.0 - ll_uu; }
    ll_smp = brush_sample(br.img, br.bw, br.bh, ll_uu * ll_bw - 0.5,
                          (((ll_lay.al[idx]? + ll_phase) / ll_period) ?? 0.0) * ll_bh - 0.5
                          + ll_bh * 4096.0);
    ll_ia = ll_smp.a * st.alpha;
    if ll_ia < 0.5 { continue }
    // A spike shades as its own cylinder the further it is from the body.
    ll_s = ll_s + (ll_lay.sl[idx]? - ll_s) * ll_lay.mx[idx]?;
    ll_nz2 = 1.0 - ll_s * ll_s;
    ll_nz = sqrt(if ll_nz2 > 0.0 { ll_nz2 } else { 0.0 }) ?? 0.0;
    ll_lit = clampf(ll_s * ll_lay.nx[idx]? * ll_lx + ll_s * ll_lay.ny[idx]? * ll_ly + ll_nz * ll_lz,
                    0.0, 1.0);
    ll_c0 = st.dark;
    ll_c1 = st.base;
    ll_f = 0.0;
    if ll_lit < ll_crest {
      ll_f = (ll_lit / ll_crest) ?? 0.0;
    } else {
      ll_f = ((ll_lit - ll_crest) / (1.0 - ll_crest)) ?? 0.0;
      ll_c0 = st.base;
      ll_c1 = st.lit;
    }
    ll_r = ramp(graphics::color_r(ll_c0), graphics::color_r(ll_c1), ll_f, ll_smp.r);
    ll_g = ramp(graphics::color_g(ll_c0), graphics::color_g(ll_c1), ll_f, ll_smp.g);
    ll_b = ramp(graphics::color_b(ll_c0), graphics::color_b(ll_c1), ll_f, ll_smp.b);
    ll_out[idx] = (((ll_ia + 0.5) as integer) << 24) | (clampi(ll_r, 0, 255) << 16)
                  | (clampi(ll_g, 0, 255) << 8) | clampi(ll_b, 0, 255);
  }
  Layer { x0: ll_x0, y0: ll_y0, lw: ll_lw, lh: ll_lh, px: ll_out }
}

/// Alpha-OVER the layer onto the canvas, straight alpha, in the integer
/// arithmetic the oracle uses — `(s·a·255 + d·A·(255−a) + ½) / (a·255 + A·(255−a))`
/// per channel, so an opaque ground and a transparent sprite are one formula.
/// @FR-Brush-Over
pub fn composite_layer(cv: graphics::Canvas, lay: const Layer) {
  for j in 0..lay.lh {
    for i in 0..lay.lw {
      cl_c = lay.px[j * lay.lw + i]?;
      cl_sa = (cl_c >> 24) & 255;
      if cl_sa == 0 { continue }
      cl_d = cv.get_pixel(lay.x0 + i, lay.y0 + j);
      cl_t = graphics::color_a(cl_d) * (255 - cl_sa);
      cl_oa = cl_sa * 255 + cl_t;
      cl_r = ((((cl_c >> 16) & 255) * cl_sa * 255 + graphics::color_r(cl_d) * cl_t + cl_oa / 2) / cl_oa) ?? 0;
      cl_g = ((((cl_c >> 8) & 255) * cl_sa * 255 + graphics::color_g(cl_d) * cl_t + cl_oa / 2) / cl_oa) ?? 0;
      cl_b = (((cl_c & 255) * cl_sa * 255 + graphics::color_b(cl_d) * cl_t + cl_oa / 2) / cl_oa) ?? 0;
      cv.set_pixel(lay.x0 + i, lay.y0 + j, graphics::rgba(cl_r, cl_g, cl_b, (cl_oa + 127) / 255));
    }
  }
}

src/drawing.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// drawing — a sprite authored as lines of text, rendered in loft.
//
// A `.draw` scene is a dozen lines of marks on fractional paper:
//
//     size 256x256
//     Background transparent
//     name blade
//     Poly (0.48,0.10) (0.52,0.10) (0.52,0.70) (0.48,0.70) rgb=190,196,206
//     Circle (0.50,0.78) r=0.06 rgb=120,90,50
//
// ── ⚠⚠ THE INVARIANT ────────────────────────────────────────────────
//
// > **The picture is the one `draw.py` renders — pixel for pixel, not
// > approximately.**  (@FR-Oracle-Bytes — crawler's `formal/draw.md`, the register
// > every `@FR-` tag in this package cites.)
//
// That is not a quality bar, it is the definition.  A corpus of `.draw` scenes
// already exists and already has art in it; the Python renderer is what made
// that art look the way it does, so "close enough" here means every sprite in
// the corpus quietly changes the day it is re-rendered.  The whole port is
// therefore measured by a byte diff against Pillow's output and nothing else.
//
// Three things follow from that, and each of them cost a probe to learn:
//
//  1. **The rasteriser is Pillow's, not `graphics`'.**  `graphics::fill_polygon`
//     is a fine polygon filler that obeys a different rule, and the two agree on
//     4 of 400 random polygons.  See [raster.loft](raster.loft).
//  2. **The picture is drawn at 3x and resampled with Lanczos.**  The
//     supersample factor and the filter are part of the contract a scene signs,
//     not an implementation choice — `graphics::resize_lanczos` is byte-identical
//     to `Image.resize(…, LANCZOS)` for exactly this reason.
//  3. **Absence is reported, never guessed.**  A line no command accepts is
//     collected and named rather than dropped, because a typo'd mark has to read
//     as a syntax problem and not as a geometry one.
//
// ── WHAT THIS BUILD DRAWS ───────────────────────────────────────────
//
// `size`, `Background`, `name`, `Line`, `Circle`, `Poly`, with solid fills
// (`rgb=` / `fill=`), `grad=` / `radial=` gradients, coloured strokes (`stroke=`),
// pen widths (`w=`, and the per-point `@N` taper), smooth points (`~`), the
// `Fronds` array, and the brush stroke — `Brush` defines a footprint, `Lock` drags
// it as a lock of hair ([brush.loft](brush.loft)).  `Petals` parses and is listed
// in `Sketch.deferred` — recognised, and a caller can see it was not drawn.
use brush::*;
use graphics::*;
use noise::*;
pub use raster::*;
pub use scan::*;

/// The supersample factor the corpus is drawn at.  Not adjustable: every golden
/// in the corpus carries it, and a scene rendered at any other factor is a
/// different picture.  @FR-Raster-Supersample
pub const SUPERSAMPLE = 3;

/// The ink a mark uses when it names no colour.
pub const DEFAULT_INK_R = 38;
pub const DEFAULT_INK_G = 32;
pub const DEFAULT_INK_B = 36;

/// Control points per curve segment when a smooth point (`~`) expands.
const SMOOTH_SAMPLES = 10;

/// How a mark is filled, if it is filled at all.
pub enum PaintKind { Stroked, Solid, Linear, Radial }

/// The settings of one `Fronds` line: how many strokes run along the spine, how their
/// length, width and angle ramp from the first to the last (`len1` → `len2`, `wid1` →
/// `wid2`, `ang1` → `ang2`), and how far they stray from a regular comb — `jitter`,
/// `clump`, `fray` and `bow`, all drawn from `seed`.  `mirror` repeats each stroke on the
/// other side of the spine; `depth` and `sub` grow smaller fronds off each one.
pub struct FrondSpec {
  pub count: integer = 8,
  pub len1: float = 0.1,
  pub len2: float = 0.1,
  pub wid1: float = 3.0,
  pub wid2: float = 3.0,
  pub ang1: float = 30.0,
  pub ang2: float = 30.0,
  pub mirror: boolean = false,
  pub jitter: float = 0.5,
  pub clump: float = 0.6,
  pub fray: float = 0.4,
  pub bow: float = 0.0,
  pub seed: integer = 1,
  pub depth: integer = 1,
  pub sub: float = 0.32,
}

/// A mark's fill descriptor.
///
/// `Stroked` is the absence of a fill, not a fill of nothing: it is what sends a
/// `Poly` down the pen path instead of the polygon path, and the two produce
/// completely different pictures from the same points.  `c1` is the fill colour, and
/// `c2` the second colour of a `Linear` or `Radial` gradient.
pub struct Paint {
  pub pk: PaintKind = Stroked,
  pub c1: integer = 0,
  pub c2: integer = 0,
  /// `Linear`: `ax,ay,bx,by`.  `Radial`: `cx,cy,r`.  Empty means "derive it from
  /// the shape's own bounding box", which is what a fill with no `dir=` / `at=`
  /// asks for.
  pub spec: vector<float>,
}

/// What an op does.  `Lock` is the brush stroke — see [brush.loft](brush.loft).
pub enum OpKind { Sky, Fill, Stroke, Lock }

/// One entry in the ordered draw program.
///
/// ⚠ `pts` are FRACTIONS of the paper (0..1, origin top-left, y down), never
/// pixels.  The scale to pixels happens once, at the raster call, because the
/// paper size is a property of the scene and the supersample factor is a
/// property of the renderer — neither belongs in a parsed mark.  @FR-Scene-Fraction
pub struct Op {
  pub kind: OpKind = Sky,
  pub pts: vector<raster::Pt>,
  pub paint: Paint,
  /// Stroke: one width per point (a taper).  Empty means the uniform `w`.
  pub widths: vector<float>,
  pub w: integer = 3,
  /// Stroke ink, or `Sky`'s top colour.
  pub color: integer = 0,
  /// `Sky`'s bottom colour.
  pub color2: integer = 0,
  /// `Lock`: the stroke's style, and the index of its brush in `Sketch.brushes`
  /// (`-1` for every other kind).
  pub style: brush::LockStyle,
  pub brush: integer = -1,
}

/// A named group of marks and the box they cover, in paper fractions.
pub struct Elem {
  pub ename: text = "",
  /// False until a mark has actually contributed a point: an element that is
  /// named and never drawn has no box, and answering `0,0,0,0` for it would be a
  /// measurement rather than an absence.
  pub seen: boolean = false,
  pub bx0: float = 0.0,
  pub by0: float = 0.0,
  pub bx1: float = 0.0,
  pub by1: float = 0.0,
  /// How many marks the scene had drawn when this element first took ink, or
  /// `-1` for one that never did.  It orders the metric report the way the
  /// oracle's does — by first ink, not by where the `name` line sits.
  pub first_at: integer = -1,
}

/// A `landmark <name> = <value>` line.
pub struct Landmark {
  pub lname: text = "",
  pub lvalue: float = 0.0,
}

/// A parsed scene: the paper, the ordered draw program, and everything the
/// parser could measure or could not accept.
///
/// ⚠ NOT called `Scene`: `mesh3d::Scene` owns that name and `graphics` depends on
/// mesh3d, so the collision is a hard error — the same trap `graphics::Coord`
/// hit with `Point`.  Reach for `Sketch` in `.draw` code and the 3-D names stay
/// where they belong.
// Example: @DRW-002
pub struct Sketch {
  pub sw: integer = 800,
  pub sh: integer = 800,
  pub transparent: boolean = false,
  pub ops: vector<Op>,
  pub elems: vector<Elem>,
  pub landmarks: vector<Landmark>,
  checks: vector<text>,
  /// `line N: <text>` for every line no command accepted.  @FR-Scene-Unparsed
  pub unparsed: vector<text>,
  /// `line N: <what>` for every mark this build recognises but does not draw.
  /// @FR-Scene-Deferred
  pub deferred: vector<text>,
  /// The footprints the scene's `Brush` lines defined, plus the default `hair`
  /// once a `Lock` asked for it.
  pub brushes: vector<brush::Brush>,
  /// The directory a `Brush … file=` path is relative to — the scene's own,
  /// which `render_file` knows and a caller holding source text may not.
  base_dir: text = "",
}

// ── Colour ──────────────────────────────────────────────────────────

/// A grey level in 0..1 as an opaque colour, rounded HALF TO EVEN.
///
/// Python's `round` is half-to-even and `draw.py` uses it here, so `fill=0.5`
/// is 128 and not 127 — a difference of one level that no eye would find and a
/// byte diff would.
pub fn grey(gl: float) -> integer {
  gy = round_half_even(gl * 255.0) as integer;
  if gy < 0 { return graphics::rgba(0, 0, 0, 255) }
  if gy > 255 { return graphics::rgba(255, 255, 255, 255) }
  graphics::rgba(gy, gy, gy, 255)
}

fn round_half_even(rv: float) -> float {
  rh_f = floor(rv);
  rh_d = rv - rh_f;
  if rh_d > 0.5 { return rh_f + 1.0 }
  if rh_d < 0.5 { return rh_f }
  if ((rh_f as integer) % 2 ?? 0) == 0 { rh_f } else { rh_f + 1.0 }
}

fn default_ink() -> integer {
  graphics::rgba(DEFAULT_INK_R, DEFAULT_INK_G, DEFAULT_INK_B, 255)
}

// ── Option readers ──────────────────────────────────────────────────

/// `\bw\s*=\s*(\d+)` — the uniform pen width, or `fallback`.
fn read_width(s: text, fallback: integer) -> integer {
  rw_o = scan::find_option(s, "w");
  if !rw_o.ok { return fallback }
  rw_n = scan::read_uint(s, rw_o.at);
  if !rw_n.ok { return fallback }
  rw_n.value as integer
}

/// `\(?(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\)?` at `i` — a colour triple, optionally
/// parenthesised, as `draw.py` writes every colour option.
fn read_rgb_at(s: text, i: integer) -> Scan3 {
  rr_i = i;
  rr_paren = scan::at(s, rr_i) == 40;
  if rr_paren { rr_i = scan::skip_space(s, rr_i + 1); }
  rr_r = scan::read_uint(s, rr_i);
  if !rr_r.ok { return scan3_fail() }
  rr_i = scan::skip_space(s, rr_r.at);
  if scan::at(s, rr_i) != 44 { return scan3_fail() }
  rr_g = scan::read_uint(s, scan::skip_space(s, rr_i + 1));
  if !rr_g.ok { return scan3_fail() }
  rr_i = scan::skip_space(s, rr_g.at);
  if scan::at(s, rr_i) != 44 { return scan3_fail() }
  rr_b = scan::read_uint(s, scan::skip_space(s, rr_i + 1));
  if !rr_b.ok { return scan3_fail() }
  rr_i = rr_b.at;
  if scan::at(s, rr_i) == 41 { rr_i += 1; }
  Scan3 { ok: true, at: rr_i,
          color: graphics::rgba(rr_r.value as integer, rr_g.value as integer,
                                rr_b.value as integer, 255) }
}

/// A colour read and where it ended.
struct Scan3 {
  ok: boolean = false,
  at: integer = 0,
  color: integer = 0,
}

fn scan3_fail() -> Scan3 { Scan3 { ok: false, at: 0, color: 0 } }

/// `\bstroke\s*=\s*R,G,B` — a pen colour.  `rgb=` still means FILL; this lets a
/// stroke carry colour without becoming one.
fn read_stroke_colour(s: text) -> integer { read_colour_opt(s, "stroke", default_ink()) }

/// `\b<key>\s*=\s*R,G,B`, or `fallback` when the line does not say.
fn read_colour_opt(s: text, key: text, fallback: integer) -> integer {
  rc_o = scan::find_option(s, key);
  if !rc_o.ok { return fallback }
  rc_c = read_rgb_at(s, rc_o.at);
  if !rc_c.ok { return fallback }
  rc_c.color
}

/// `draw.py`'s default shadow for a `Lock`: each channel halved.
fn half_colour(c: integer) -> integer {
  graphics::rgba(graphics::color_r(c) / 2, graphics::color_g(c) / 2, graphics::color_b(c) / 2, 255)
}

/// `draw.py`'s default highlight: each channel lifted 35 % of the way to white.
fn lifted_colour(c: integer) -> integer {
  lc_r = graphics::color_r(c);
  lc_g = graphics::color_g(c);
  lc_b = graphics::color_b(c);
  graphics::rgba(lc_r + (255 - lc_r) * 35 / 100, lc_g + (255 - lc_g) * 35 / 100,
                 lc_b + (255 - lc_b) * 35 / 100, 255)
}

/// `draw.py`'s `_paint`, in its order: `radial=` beats `grad=` beats `rgb=`
/// beats `fill=`, and nothing at all means the mark is stroked.
fn read_paint(s: text) -> Paint {
  rp_rad = scan::find_option(s, "radial");
  if rp_rad.ok {
    rp_pair = read_colour_pair(s, rp_rad.at);
    if rp_pair.ok {
      return Paint { pk: Radial, c1: rp_pair.c1, c2: rp_pair.c2,
                     spec: read_spec(s, "at", 3) }
    }
  }
  rp_grad = scan::find_option(s, "grad");
  if rp_grad.ok {
    rp_pair2 = read_colour_pair(s, rp_grad.at);
    if rp_pair2.ok {
      return Paint { pk: Linear, c1: rp_pair2.c1, c2: rp_pair2.c2,
                     spec: read_spec(s, "dir", 4) }
    }
  }
  rp_rgb = scan::find_option(s, "rgb");
  if rp_rgb.ok {
    rp_c = read_rgb_at(s, rp_rgb.at);
    if rp_c.ok { return Paint { pk: Solid, c1: rp_c.color, c2: 0, spec: [] } }
  }
  rp_fill = scan::find_option(s, "fill");
  if rp_fill.ok {
    rp_l = scan::read_unsigned(s, rp_fill.at);
    if rp_l.ok { return Paint { pk: Solid, c1: grey(rp_l.value), c2: 0, spec: [] } }
  }
  Paint { pk: Stroked, c1: 0, c2: 0, spec: [] }
}

/// Two colours either side of a `>`.
struct ColourPair {
  ok: boolean = false,
  c1: integer = 0,
  c2: integer = 0,
}

fn read_colour_pair(s: text, i: integer) -> ColourPair {
  cp_a = read_rgb_at(s, i);
  if !cp_a.ok { return ColourPair { ok: false, c1: 0, c2: 0 } }
  cp_j = scan::skip_space(s, cp_a.at);
  if scan::at(s, cp_j) != 62 { return ColourPair { ok: false, c1: 0, c2: 0 } }
  cp_b = read_rgb_at(s, scan::skip_space(s, cp_j + 1));
  if !cp_b.ok { return ColourPair { ok: false, c1: 0, c2: 0 } }
  ColourPair { ok: true, c1: cp_a.color, c2: cp_b.color }
}

/// `\b<key>\s*=\s*n,n[,n[,n]]` — a comma-separated run of exactly `count`
/// numbers, or empty when the option is absent or short.
fn read_spec(s: text, key: text, count: integer) -> vector<float> {
  rs_out: vector<float> = [];
  rs_o = scan::find_option(s, key);
  if !rs_o.ok { return rs_out }
  rs_i = rs_o.at;
  for k in 0..count {
    if k > 0 {
      if scan::at(s, rs_i) != 44 { rs_out = []; return rs_out }
      rs_i += 1;
    }
    rs_n = scan::read_number(s, rs_i);
    if !rs_n.ok { rs_out = []; return rs_out }
    rs_out += [rs_n.value];
    rs_i = rs_n.at;
  }
  rs_out
}

// ── Points on a line ────────────────────────────────────────────────

/// Every `(x,y)` on a line, with the two suffixes a point may carry.
pub struct PointList {
  pts: vector<raster::Pt>,
  /// `~` — this point curves; a point without it is a corner.
  smooth: vector<boolean>,
  /// `@N` — the pen width AT this point.  `has_width` says which were given.
  widths: vector<float>,
  has_width: vector<boolean>,
  any_width: boolean = false,
}

/// `\(\s*(n)\s*,\s*(n)\s*\)\s*(~?)\s*(?:@\s*(n))?`, found repeatedly along the line.
///
/// Scanning for points rather than splitting the line is what lets the same
/// grammar carry options: `rgb=140,104,62` has no parentheses, so it is not a
/// point, and no separator has to be agreed between the two.
pub fn read_points(s: text) -> PointList {
  rp_out = PointList { pts: [], smooth: [], widths: [], has_width: [], any_width: false };
  rp_i = 0;
  rp_n = size(s);
  while rp_i < rp_n {
    if scan::at(s, rp_i) != 40 { rp_i += 1; continue }
    rp_j = scan::skip_space(s, rp_i + 1);
    rp_x = scan::read_number(s, rp_j);
    if !rp_x.ok { rp_i += 1; continue }
    rp_j = scan::skip_space(s, rp_x.at);
    if scan::at(s, rp_j) != 44 { rp_i += 1; continue }
    rp_y = scan::read_number(s, scan::skip_space(s, rp_j + 1));
    if !rp_y.ok { rp_i += 1; continue }
    rp_j = scan::skip_space(s, rp_y.at);
    if scan::at(s, rp_j) != 41 { rp_i += 1; continue }
    rp_j += 1;
    rp_k = scan::skip_space(s, rp_j);
    rp_sm = scan::at(s, rp_k) == 126;
    if rp_sm { rp_k = scan::skip_space(s, rp_k + 1); }
    rp_wv = 0.0;
    rp_has = false;
    if scan::at(s, rp_k) == 64 {
      rp_w = scan::read_number(s, scan::skip_space(s, rp_k + 1));
      if rp_w.ok { rp_wv = rp_w.value; rp_has = true; rp_k = rp_w.at; }
    }
    rp_out.pts += [raster::pt(rp_x.value, rp_y.value)];
    rp_out.smooth += [rp_sm];
    rp_out.widths += [rp_wv];
    rp_out.has_width += [rp_has];
    if rp_has { rp_out.any_width = true; }
    rp_i = rp_k;
  }
  rp_out
}

// ── Geometry ────────────────────────────────────────────────────────

/// The outline of a `Circle`, as `draw.py` builds it: `n` segments round the
/// centre with the FIRST point repeated at the end, so the polygon closes
/// itself and Pillow adds no edge of its own.
///
/// `flat` squashes the vertical radius; the `W/H` factor keeps the circle round
/// on non-square paper, since the coordinates are fractions of each axis.
pub fn circle_pts(cx: float, cy: float, r: float, n: integer, flat: float,
                  pw: integer, ph: integer) -> vector<raster::Pt> {
  cp_ary = r * (((pw as float) / (ph as float)) ?? 1.0) * (1.0 - flat);
  cp_out: vector<raster::Pt> = [];
  for i in 0..=n {
    cp_a = ((2.0 * noise::PI * (i as float)) / (n as float)) ?? 0.0;
    cp_out += [raster::pt(cx + r * cos(cp_a), cy + cp_ary * sin(cp_a))];
  }
  cp_out
}

fn any_smooth(flags: const vector<boolean>) -> boolean {
  for f in flags { if f { return true } }
  false
}

/// The control point `i`, clamped on an open outline and wrapped on a closed one.
fn ctrl(pts: const vector<raster::Pt>, i: integer, closed: boolean) -> raster::Pt {
  cn = len(pts);
  if closed { return pts[i.floor_mod(cn) ?? 0]? }
  ci = i;
  if ci < 0 { ci = 0; }
  if ci > cn - 1 { ci = cn - 1; }
  pts[ci]?
}

/// Half the chord between a point's two neighbours — the Catmull-Rom tangent.
fn half_chord(pts: const vector<raster::Pt>, i: integer, closed: boolean) -> raster::Pt {
  hc_a = ctrl(pts, i - 1, closed);
  hc_b = ctrl(pts, i + 1, closed);
  raster::pt((hc_b.ptx - hc_a.ptx) * 0.5, (hc_b.pty - hc_a.pty) * 0.5)
}

/// Does this outline curve at all?  Fewer than three points, or no `~`, and the
/// control points ARE the polyline — which is what keeps a plain `Poly`
/// unchanged.
pub fn smooth_applies(pts: const vector<raster::Pt>, flags: const vector<boolean>) -> boolean {
  len(pts) >= 3 && any_smooth(flags)
}

/// Expand control points to a dense polyline.  @FR-Mark-Smooth
///
/// A point flagged `~` curves (its tangent is half the neighbour chord); a
/// corner uses the segment chord, which makes that segment exactly straight.  So
/// one outline mixes straight runs and curves, and a segment between two corners
/// is a line however many `~` the rest of the outline has.
pub fn smooth_pts(pts: const vector<raster::Pt>, flags: const vector<boolean>,
                  closed: boolean) -> vector<raster::Pt> {
  sp_n = len(pts);
  sp_out: vector<raster::Pt> = [];
  if !smooth_applies(pts, flags) {
    for p in pts { sp_out += [p]; }
    return sp_out
  }
  sp_out += [pts[0]?];
  sp_segs = if closed { sp_n } else { sp_n - 1 };
  for i in 0..sp_segs {
    sp_ia = i % sp_n ?? 0;
    sp_ib = (i + 1) % sp_n ?? 0;
    sp_a = pts[sp_ia]?;
    sp_b = pts[sp_ib]?;
    sp_chx = sp_b.ptx - sp_a.ptx;
    sp_chy = sp_b.pty - sp_a.pty;
    sp_ta = if flags[sp_ia]? { half_chord(pts, sp_ia, closed) } else { raster::pt(sp_chx, sp_chy) };
    sp_tb = if flags[sp_ib]? { half_chord(pts, sp_ib, closed) } else { raster::pt(sp_chx, sp_chy) };
    for k in 1..=SMOOTH_SAMPLES {
      sp_t = ((k as float) / (SMOOTH_SAMPLES as float)) ?? 0.0;
      sp_t2 = sp_t * sp_t;
      sp_t3 = sp_t2 * sp_t;
      sp_h00 = 2.0 * sp_t3 - 3.0 * sp_t2 + 1.0;
      sp_h10 = sp_t3 - 2.0 * sp_t2 + sp_t;
      sp_h01 = -2.0 * sp_t3 + 3.0 * sp_t2;
      sp_h11 = sp_t3 - sp_t2;
      sp_out += [raster::pt(
        sp_h00 * sp_a.ptx + sp_h10 * sp_ta.ptx + sp_h01 * sp_b.ptx + sp_h11 * sp_tb.ptx,
        sp_h00 * sp_a.pty + sp_h10 * sp_ta.pty + sp_h01 * sp_b.pty + sp_h11 * sp_tb.pty)];
    }
  }
  sp_out
}

/// The per-point widths carried across the same expansion, interpolated linearly.
pub fn smooth_vals(vals: const vector<float>, closed: boolean) -> vector<float> {
  sv_n = len(vals);
  sv_out: vector<float> = [];
  sv_out += [vals[0]?];
  sv_segs = if closed { sv_n } else { sv_n - 1 };
  for i in 0..sv_segs {
    sv_a = vals[i % sv_n ?? 0]?;
    sv_b = vals[(i + 1) % sv_n ?? 0]?;
    for k in 1..=SMOOTH_SAMPLES {
      sv_out += [sv_a + (sv_b - sv_a) * (((k as float) / (SMOOTH_SAMPLES as float)) ?? 0.0)];
    }
  }
  sv_out
}

// ── Commands ────────────────────────────────────────────────────────

/// What one command line produced: whether the command matched at all, whether
/// it matched but is not a legal mark, and the points it covers.
///
/// The middle case is the one worth having a flag for.  `Poly (0.1,0.2)
/// rgb=1,2,3` IS a `Poly` — it just cannot be filled with one point — and
/// reporting it as "no command accepted this line" would send the author
/// looking for a typo that is not there.
struct Mark {
  matched: boolean = false,
  bad: boolean = false,
  pts: vector<raster::Pt>,
}

fn no_mark() -> Mark { Mark { matched: false, bad: false, pts: [] } }

/// `Circle\s*\(\s*(n)\s*,\s*(n)\s*\)\s*r=(n)(?:\s+n=(\d+))?(?:\s+flat=(n))?`
fn parse_circle(sc: Sketch, s: text) -> Mark {
  pc_from = 0;
  while true {
    pc_w = scan::find_word_from(s, "circle", pc_from);
    if !pc_w.ok { return no_mark() }
    pc_from = pc_w.at;
    pc_i = scan::skip_space(s, pc_w.at);
    if scan::at(s, pc_i) != 40 { continue }
    pc_x = scan::read_number(s, scan::skip_space(s, pc_i + 1));
    if !pc_x.ok { continue }
    pc_i = scan::skip_space(s, pc_x.at);
    if scan::at(s, pc_i) != 44 { continue }
    pc_y = scan::read_number(s, scan::skip_space(s, pc_i + 1));
    if !pc_y.ok { continue }
    pc_i = scan::skip_space(s, pc_y.at);
    if scan::at(s, pc_i) != 41 { continue }
    pc_i = scan::skip_space(s, pc_i + 1);
    if scan::lower_byte(scan::at(s, pc_i)) != 114 { continue }
    if scan::at(s, pc_i + 1) != 61 { continue }
    pc_r = scan::read_number(s, pc_i + 2);
    if !pc_r.ok { continue }
    pc_i = pc_r.at;
    pc_n = 28;
    pc_j = scan::skip_space(s, pc_i);
    if pc_j > pc_i && scan::lower_byte(scan::at(s, pc_j)) == 110 && scan::at(s, pc_j + 1) == 61 {
      pc_cnt = scan::read_uint(s, pc_j + 2);
      if pc_cnt.ok { pc_n = pc_cnt.value as integer; pc_i = pc_cnt.at; }
    }
    pc_flat = 0.0;
    pc_j = scan::skip_space(s, pc_i);
    if pc_j > pc_i && scan::matches_at(s, pc_j, "flat") && scan::at(s, pc_j + 4) == 61 {
      pc_f = scan::read_number(s, pc_j + 5);
      if pc_f.ok { pc_flat = pc_f.value; }
    }
    pc_pts = circle_pts(pc_x.value, pc_y.value, pc_r.value, pc_n, pc_flat, sc.sw, sc.sh);
    pc_paint = read_paint(s);
    if pc_paint.pk == Stroked {
      sc.ops += [Op { kind: Stroke, pts: pc_pts, paint: pc_paint, widths: [],
                      w: read_width(s, 3), color: read_stroke_colour(s), color2: 0 }];
    } else {
      sc.ops += [Op { kind: Fill, pts: pc_pts, paint: pc_paint, widths: [],
                      w: 3, color: 0, color2: 0 }];
    }
    return Mark { matched: true, bad: false, pts: pc_pts }
  }
  no_mark()
}

/// `Line\s*\(\s*(n)\s*,\s*(n)\s*\)\s*(?:@\s*(n))?\s*-\s*\(…\)\s*(?:@\s*(n))?\s*(?:w\s*=\s*(\d+))?`
fn parse_line_cmd(sc: Sketch, s: text) -> Mark {
  pl_from = 0;
  while true {
    pl_w = scan::find_word_from(s, "line", pl_from);
    if !pl_w.ok { return no_mark() }
    pl_from = pl_w.at;
    pl_i = scan::skip_space(s, pl_w.at);
    if scan::at(s, pl_i) != 40 { continue }
    pl_x0 = scan::read_number(s, scan::skip_space(s, pl_i + 1));
    if !pl_x0.ok { continue }
    pl_i = scan::skip_space(s, pl_x0.at);
    if scan::at(s, pl_i) != 44 { continue }
    pl_y0 = scan::read_number(s, scan::skip_space(s, pl_i + 1));
    if !pl_y0.ok { continue }
    pl_i = scan::skip_space(s, pl_y0.at);
    if scan::at(s, pl_i) != 41 { continue }
    pl_i += 1;
    pl_wa = read_at_width(s, pl_i);
    pl_i = pl_wa.at;
    pl_i = scan::skip_space(s, pl_i);
    // `[-\d.]+` swallows the `-` separator when no space precedes it, and the
    // regex backtracks off it.  Retract the same way rather than refusing.
    if scan::at(s, pl_i) != 45 && pl_wa.ok && scan::at(s, pl_i - 1) == 45 { pl_i -= 1; }
    if scan::at(s, pl_i) != 45 { continue }
    pl_i = scan::skip_space(s, pl_i + 1);
    if scan::at(s, pl_i) != 40 { continue }
    pl_x1 = scan::read_number(s, scan::skip_space(s, pl_i + 1));
    if !pl_x1.ok { continue }
    pl_i = scan::skip_space(s, pl_x1.at);
    if scan::at(s, pl_i) != 44 { continue }
    pl_y1 = scan::read_number(s, scan::skip_space(s, pl_i + 1));
    if !pl_y1.ok { continue }
    pl_i = scan::skip_space(s, pl_y1.at);
    if scan::at(s, pl_i) != 41 { continue }
    pl_wb = read_at_width(s, pl_i + 1);
    pl_base = read_width(s, 3);
    pl_pts: vector<raster::Pt> = [raster::pt(pl_x0.value, pl_y0.value),
                                  raster::pt(pl_x1.value, pl_y1.value)];
    pl_widths: vector<float> = [];
    if pl_wa.ok || pl_wb.ok {
      pl_widths += [if pl_wa.ok { pl_wa.value } else { pl_base as float }];
      pl_widths += [if pl_wb.ok { pl_wb.value } else { pl_base as float }];
    }
    sc.ops += [Op { kind: Stroke, pts: pl_pts, paint: Paint { pk: Stroked, c1: 0, c2: 0, spec: [] },
                    widths: pl_widths, w: pl_base, color: read_stroke_colour(s), color2: 0 }];
    return Mark { matched: true, bad: false, pts: pl_pts }
  }
  no_mark()
}

/// `\s*(?:@\s*([-\d.]+))?` — an endpoint's own pen width.
fn read_at_width(s: text, i: integer) -> Scan {
  ra_j = scan::skip_space(s, i);
  if scan::at(s, ra_j) != 64 { return Scan { ok: false, at: i, value: 0.0 } }
  ra_n = scan::read_number(s, scan::skip_space(s, ra_j + 1));
  if !ra_n.ok { return Scan { ok: false, at: i, value: 0.0 } }
  ra_n
}

/// `\b<key>\s*=\s*([-\d.]+)` anywhere on the line, or `dflt` — `draw.py`'s `optf`.
///
/// The word boundary is what keeps `w=` out of `w2=` and `ang=` out of `ang2=`,
/// which on a `Fronds` line are four different options sharing two prefixes.
fn opt_num(s: text, key: text, dflt: float) -> float {
  on_o = scan::find_option(s, key);
  if !on_o.ok { return dflt }
  on_n = scan::read_number(s, on_o.at);
  if !on_n.ok { return dflt }
  on_n.value
}

/// `Fronds (x,y)-(x,y) n=N len=L [len2= w= w2= ang= ang2= mirror= jitter= field=
/// fray= bow= seed= depth= sub= stroke=]`
///
/// The two endpoints and `n=` / `len=` are REQUIRED and positional — everything
/// else is found anywhere on the line, which is why the defaults live here rather
/// than in `FrondSpec`: they are `draw.py`'s, and two of them chain (`len2`
/// defaults to `len`, `ang2` to `ang`, `w2` to `w`).
fn parse_fronds(sc: Sketch, s: text) -> Mark {
  pf_from = 0;
  while true {
    pf_w = scan::find_word_from(s, "fronds", pf_from);
    if !pf_w.ok { return no_mark() }
    pf_from = pf_w.at;
    pf_i = scan::skip_space(s, pf_w.at);
    if scan::at(s, pf_i) != 40 { continue }
    pf_x0 = scan::read_number(s, scan::skip_space(s, pf_i + 1));
    if !pf_x0.ok { continue }
    pf_i = scan::skip_space(s, pf_x0.at);
    if scan::at(s, pf_i) != 44 { continue }
    pf_y0 = scan::read_number(s, scan::skip_space(s, pf_i + 1));
    if !pf_y0.ok { continue }
    pf_i = scan::skip_space(s, pf_y0.at);
    if scan::at(s, pf_i) != 41 { continue }
    pf_i = scan::skip_space(s, pf_i + 1);
    if scan::at(s, pf_i) != 45 { continue }
    pf_i = scan::skip_space(s, pf_i + 1);
    if scan::at(s, pf_i) != 40 { continue }
    pf_x1 = scan::read_number(s, scan::skip_space(s, pf_i + 1));
    if !pf_x1.ok { continue }
    pf_i = scan::skip_space(s, pf_x1.at);
    if scan::at(s, pf_i) != 44 { continue }
    pf_y1 = scan::read_number(s, scan::skip_space(s, pf_i + 1));
    if !pf_y1.ok { continue }
    pf_i = scan::skip_space(s, pf_y1.at);
    if scan::at(s, pf_i) != 41 { continue }
    // `\s+n\s*=\s*(\d+)\s+len\s*=\s*(n)` — required, in this order.
    pf_j = scan::skip_space(s, pf_i + 1);
    if pf_j == pf_i + 1 { continue }
    if scan::lower_byte(scan::at(s, pf_j)) != 110 { continue }
    pf_j = scan::skip_space(s, pf_j + 1);
    if scan::at(s, pf_j) != 61 { continue }
    pf_cnt = scan::read_uint(s, scan::skip_space(s, pf_j + 1));
    if !pf_cnt.ok { continue }
    pf_k = scan::skip_space(s, pf_cnt.at);
    if pf_k == pf_cnt.at { continue }
    if !scan::matches_at(s, pf_k, "len") { continue }
    pf_k = scan::skip_space(s, pf_k + 3);
    if scan::at(s, pf_k) != 61 { continue }
    pf_len = scan::read_number(s, scan::skip_space(s, pf_k + 1));
    if !pf_len.ok { continue }
    pf_wv = opt_num(s, "w", 3.0);
    pf_av = opt_num(s, "ang", 30.0);
    pf_spec = FrondSpec {
      count: pf_cnt.value as integer,
      len1: pf_len.value,
      len2: opt_num(s, "len2", pf_len.value),
      wid1: pf_wv,
      wid2: opt_num(s, "w2", pf_wv),
      ang1: pf_av,
      ang2: opt_num(s, "ang2", pf_av),
      mirror: (opt_num(s, "mirror", 0.0) as integer) != 0,
      jitter: opt_num(s, "jitter", 0.5),
      clump: opt_num(s, "field", 0.6),
      fray: opt_num(s, "fray", 0.4),
      bow: opt_num(s, "bow", 0.0),
      seed: opt_num(s, "seed", 1.0) as integer,
      depth: opt_num(s, "depth", 1.0) as integer,
      sub: opt_num(s, "sub", 0.32),
    };
    pf_ink = read_stroke_colour(s);
    pf_all: vector<raster::Pt> = [];
    for f in fronds(pf_x0.value, pf_y0.value, pf_x1.value, pf_y1.value, pf_spec, sc.sw, sc.sh) {
      // A bowed frond is three control points with only the MIDDLE one smooth —
      // a fixed pattern, not one derived from the points, so the curve is the
      // bow and nothing else.
      pf_line = f.fpts;
      pf_wids = f.fwid;
      if len(f.fpts) > 2 {
        pf_flags: vector<boolean> = [false, true, false];
        pf_line = smooth_pts(f.fpts, pf_flags, false);
        pf_wids = smooth_vals(f.fwid, false);
      }
      sc.ops += [Op { kind: Stroke, pts: pf_line,
                      paint: Paint { pk: Stroked, c1: 0, c2: 0, spec: [] },
                      widths: pf_wids, w: 3, color: pf_ink, color2: 0 }];
      for p in pf_line { pf_all += [p]; }
    }
    return Mark { matched: true, bad: false, pts: pf_all }
  }
  no_mark()
}

/// The `\w+` at `i`, or `""`.
fn read_word_at(s: text, i: integer) -> text {
  wd_j = i;
  while scan::is_word_byte(scan::at(s, wd_j)) { wd_j += 1; }
  s[i..wd_j]
}

fn brush_index(sc: const Sketch, nm: text) -> integer {
  for i in 0..len(sc.brushes) {
    if sc.brushes[i]?.bname == nm { return i }
  }
  -1
}

/// `Brush <name> hair [w= period= seed= gap=]` — the built-in split-bristle
/// footprint — or `Brush <name> file=<png> [period=]`, an authored one.  A file
/// that cannot be read makes the line unparsed: a brush that silently came out
/// empty would erase every lock that used it.
fn parse_brush(sc: Sketch, s: text) -> boolean {
  pb_i = scan::skip_space(s, 5);
  pb_name = read_word_at(s, pb_i);
  if pb_name == "" { return false }
  pb_kind = read_word_at(s, scan::skip_space(s, pb_i + size(pb_name))).to_lowercase();
  if pb_kind == "hair" {
    pb_w = opt_num(s, "w", 12.0) as integer;
    pb_h = opt_num(s, "period", 48.0) as integer;
    sc.brushes += [brush::Brush { bname: pb_name, bw: pb_w, bh: pb_h, period: pb_h as float,
                                  img: brush::hair_brush(pb_w, pb_h, opt_num(s, "seed", 1.0) as integer,
                                                         opt_num(s, "gap", 0.35)) }];
    return true
  }
  if pb_kind == "file" {
    pb_o = scan::find_option(s, "file");
    if !pb_o.ok { return false }
    pb_k = pb_o.at;
    while pb_k < size(s) && !scan::is_space_byte(scan::at(s, pb_k)) { pb_k += 1; }
    pb_path = s[pb_o.at..pb_k];
    if sc.base_dir != "" && !pb_path.starts_with("/") { pb_path = "{sc.base_dir}/{pb_path}"; }
    pb_br = brush::load_brush(pb_path, pb_name, opt_num(s, "period", 0.0));
    if pb_br.bw == 0 { return false }
    sc.brushes += [pb_br];
    return true
  }
  false
}

/// `Lock (x,y)[~] … [brush= w0= w= swell= body= tips= tipvar= spread= seed= rgb=
/// dark= lit= light= alpha= flip= period=]` — one lock of hair, the brush dragged
/// root→tip.  The defaults are `draw.py`'s; `dark` and `lit` derive from `rgb`
/// unless given, and a `brush=` that names nothing makes the line unparsed.
fn parse_lock(sc: Sketch, s: text) -> Mark {
  pk_raw = read_points(s);
  if len(pk_raw.pts) < 2 { return Mark { matched: true, bad: true, pts: [] } }
  pk_bi = -1;
  pk_bo = scan::find_option(s, "brush");
  if pk_bo.ok {
    pk_bi = brush_index(sc, read_word_at(s, pk_bo.at));
    if pk_bi < 0 { return Mark { matched: true, bad: true, pts: [] } }
  } else {
    pk_bi = brush_index(sc, "hair");
    if pk_bi < 0 {
      sc.brushes += [brush::Brush { bname: "hair", bw: 12, bh: 48, period: 48.0,
                                    img: brush::hair_brush(12, 48, 1, 0.35) }];
      pk_bi = len(sc.brushes) - 1;
    }
  }
  pk_line = smooth_pts(pk_raw.pts, pk_raw.smooth, false);
  pk_base = read_colour_opt(s, "rgb", graphics::rgba(120, 80, 40, 255));
  pk_light = read_spec(s, "light", 3);
  pk_lx = -0.5;
  pk_ly = -0.8;
  pk_lz = 0.6;
  if len(pk_light) == 3 {
    pk_lx = pk_light[0]?;
    pk_ly = pk_light[1]?;
    pk_lz = pk_light[2]?;
  }
  pk_style = brush::LockStyle {
    w0: opt_num(s, "w0", 2.0), w: opt_num(s, "w", 10.0),
    swell: opt_num(s, "swell", 0.3), body: opt_num(s, "body", 0.8),
    tips: opt_num(s, "tips", 3.0) as integer, tipvar: opt_num(s, "tipvar", 0.35),
    spread: opt_num(s, "spread", 8.0), seed: opt_num(s, "seed", 1.0) as integer,
    period: opt_num(s, "period", sc.brushes[pk_bi]?.period),
    dark: read_colour_opt(s, "dark", half_colour(pk_base)), base: pk_base,
    lit: read_colour_opt(s, "lit", lifted_colour(pk_base)),
    lx: pk_lx, ly: pk_ly, lz: pk_lz, alpha: opt_num(s, "alpha", 1.0),
    flip: (opt_num(s, "flip", 0.0) as integer) != 0,
  };
  sc.ops += [Op { kind: Lock, pts: pk_line,
                  paint: Paint { pk: Stroked, c1: 0, c2: 0, spec: [] },
                  widths: [], w: 3, color: 0, color2: 0, style: pk_style, brush: pk_bi }];
  Mark { matched: true, bad: false, pts: pk_line }
}

/// `Poly (x,y)… [w=N] [<fill>]` — the workhorse: filled when it names a fill,
/// a pen stroke when it does not.
fn parse_poly(sc: Sketch, s: text) -> Mark {
  pp_raw = read_points(s);
  pp_paint = read_paint(s);
  pp_need = if pp_paint.pk == Stroked { 2 } else { 3 };
  if len(pp_raw.pts) < pp_need { return Mark { matched: true, bad: true, pts: [] } }
  if pp_paint.pk != Stroked {
    pp_pts = smooth_pts(pp_raw.pts, pp_raw.smooth, true);
    sc.ops += [Op { kind: Fill, pts: pp_pts, paint: pp_paint, widths: [], w: 3,
                    color: 0, color2: 0 }];
    return Mark { matched: true, bad: false, pts: pp_pts }
  }
  pp_base = read_width(s, 3);
  pp_ink = read_stroke_colour(s);
  if pp_raw.any_width {
    pp_vals: vector<float> = [];
    for i in 0..len(pp_raw.pts) {
      pp_vals += [if pp_raw.has_width[i]? { pp_raw.widths[i]? } else { pp_base as float }];
    }
    pp_open = smooth_pts(pp_raw.pts, pp_raw.smooth, false);
    pp_ow = if smooth_applies(pp_raw.pts, pp_raw.smooth) { smooth_vals(pp_vals, false) }
            else { pp_vals };
    sc.ops += [Op { kind: Stroke, pts: pp_open, paint: pp_paint, widths: pp_ow,
                    w: pp_base, color: pp_ink, color2: 0 }];
    return Mark { matched: true, bad: false, pts: pp_open }
  }
  pp_line = smooth_pts(pp_raw.pts, pp_raw.smooth, false);
  sc.ops += [Op { kind: Stroke, pts: pp_line, paint: pp_paint, widths: [],
                  w: pp_base, color: pp_ink, color2: 0 }];
  Mark { matched: true, bad: false, pts: pp_line }
}


// ── The scene ───────────────────────────────────────────────────────

/// Where `name`'s box lives, creating it on first mention.
fn elem_index(sc: Sketch, nm: text) -> integer {
  for i in 0..len(sc.elems) {
    if sc.elems[i]?.ename == nm { return i }
  }
  sc.elems += [Elem { ename: nm, seen: false, bx0: 0.0, by0: 0.0, bx1: 0.0, by1: 0.0,
                      first_at: -1 }];
  len(sc.elems) - 1
}

/// Grow the current element's box to hold these points.
fn acc_pts(sc: Sketch, idx: integer, pts: const vector<raster::Pt>) {
  if idx < 0 || len(pts) == 0 { return }
  ap_e = sc.elems[idx]?;
  ap_seen = ap_e.seen;
  ap_x0 = ap_e.bx0;
  ap_y0 = ap_e.by0;
  ap_x1 = ap_e.bx1;
  ap_y1 = ap_e.by1;
  for p in pts {
    if !ap_seen {
      ap_x0 = p.ptx; ap_y0 = p.pty; ap_x1 = p.ptx; ap_y1 = p.pty;
      ap_seen = true;
    } else {
      if p.ptx < ap_x0 { ap_x0 = p.ptx; }
      if p.pty < ap_y0 { ap_y0 = p.pty; }
      if p.ptx > ap_x1 { ap_x1 = p.ptx; }
      if p.pty > ap_y1 { ap_y1 = p.pty; }
    }
  }
  sc.elems[idx] = Elem { ename: ap_e.ename, seen: ap_seen,
                         bx0: ap_x0, by0: ap_y0, bx1: ap_x1, by1: ap_y1,
                         first_at: if ap_e.first_at >= 0 { ap_e.first_at }
                                   else { len(sc.ops) } };
}

/// `Background …` — transparent, a colour ramp, or a grey ramp.
fn parse_background(sc: Sketch, s: text, low: text) -> boolean {
  if low.contains("transparent") || low.contains("none") {
    sc.transparent = true;
    return true
  }
  pb_tc = scan::find_option(s, "topc");
  pb_bc = scan::find_option(s, "botc");
  if pb_tc.ok && pb_bc.ok {
    pb_t = read_rgb_at(s, pb_tc.at);
    pb_b = read_rgb_at(s, pb_bc.at);
    if pb_t.ok && pb_b.ok {
      sc.ops += [Op { kind: Sky, pts: [], paint: Paint { pk: Stroked, c1: 0, c2: 0, spec: [] },
                      widths: [], w: 3, color: pb_t.color, color2: pb_b.color }];
      return true
    }
  }
  pb_gt = scan::find_option(s, "top");
  if !pb_gt.ok { return false }
  pb_gb = scan::find_option(s, "bottom");
  if !pb_gb.ok { pb_gb = scan::find_option(s, "bot"); }
  if !pb_gb.ok { return false }
  pb_tv = scan::read_unsigned(s, pb_gt.at);
  pb_bv = scan::read_unsigned(s, pb_gb.at);
  if !pb_tv.ok || !pb_bv.ok { return false }
  sc.ops += [Op { kind: Sky, pts: [], paint: Paint { pk: Stroked, c1: 0, c2: 0, spec: [] },
                  widths: [], w: 3, color: grey(pb_tv.value), color2: grey(pb_bv.value) }];
  true
}

/// `size WxH` — a FULL-line match, so nothing else can be on it.
fn parse_size(sc: Sketch, s: text) -> boolean {
  if !scan::matches_at(s, 0, "size") { return false }
  pz_i = scan::skip_space(s, 4);
  if pz_i == 4 { return false }
  pz_w = scan::read_uint(s, pz_i);
  if !pz_w.ok { return false }
  pz_i = scan::skip_space(s, pz_w.at);
  if scan::lower_byte(scan::at(s, pz_i)) != 120 { return false }
  pz_h = scan::read_uint(s, scan::skip_space(s, pz_i + 1));
  if !pz_h.ok || pz_h.at != size(s) { return false }
  sc.sw = pz_w.value as integer;
  sc.sh = pz_h.value as integer;
  true
}

/// `landmark <name> = <value>` — anchored at the start of the line.
fn parse_landmark(sc: Sketch, s: text) -> boolean {
  pm_i = scan::skip_space(s, 8);
  if pm_i == 8 { return false }
  pm_j = pm_i;
  while scan::is_word_byte(scan::at(s, pm_j)) { pm_j += 1; }
  if pm_j == pm_i { return false }
  pm_k = scan::skip_space(s, pm_j);
  if scan::at(s, pm_k) != 61 { return false }
  pm_v = scan::read_number(s, scan::skip_space(s, pm_k + 1));
  if !pm_v.ok { return false }
  sc.landmarks += [Landmark { lname: s[pm_i..pm_j], lvalue: pm_v.value }];
  true
}

/// Parse a whole `.draw` source.
///
/// The command order below is `draw.py`'s, and it is not alphabetical or
/// tidy — `Circle` is tried before `Poly`, `Line` last of all.  Keep it: a
/// grammar this loose is defined by the order its alternatives are tried in,
/// and re-sorting it silently re-reads lines that sit in two alternatives.
/// @FR-Scene-Order
// Example: @DRW-001
pub fn parse_scene(src: text) -> Sketch { parse_scene_at(src, "") }

/// `parse_scene` for a scene that lives in a directory: `base` is what a
/// `Brush … file=` path is relative to.
pub fn parse_scene_at(src: text, base: text) -> Sketch {
  sc = Sketch { sw: 800, sh: 800, transparent: false, ops: [], elems: [], landmarks: [],
               checks: [], unparsed: [], deferred: [], brushes: [], base_dir: base };
  ps_cur = -1;
  ps_no = 0;
  for raw in src.split('\n') {
    ps_no += 1;
    ps_s = raw.trim();
    if ps_s == "" || ps_s.starts_with("#") { continue }
    ps_low = ps_s.to_lowercase();
    if ps_low.starts_with("name ") {
      ps_cur = elem_index(sc, ps_s[5..size(ps_s)].trim());
      continue
    }
    if ps_low.starts_with("landmark") {
      if !parse_landmark(sc, ps_s) { sc.unparsed += ["line {ps_no}: {ps_s}"]; }
      continue
    }
    if ps_low.starts_with("check") {
      sc.checks += [ps_s[5..size(ps_s)].trim()];
      continue
    }
    if ps_low.starts_with("background") {
      if !parse_background(sc, ps_s, ps_low) { sc.unparsed += ["line {ps_no}: {ps_s}"]; }
      continue
    }
    if parse_size(sc, ps_s) { continue }
    ps_m = parse_circle(sc, ps_s);
    if ps_m.matched { acc_pts(sc, ps_cur, ps_m.pts); continue }
    // `Petals` and `Fronds` are recognised here so they cannot fall through to
    // `Line` and be read as something else — and listed, so their absence from
    // the picture is a fact the caller has rather than one they discover.
    if ps_low.starts_with("petals") { sc.deferred += ["line {ps_no}: Petals"]; continue }
    if ps_low.starts_with("fronds") {
      ps_f = parse_fronds(sc, ps_s);
      if ps_f.matched { acc_pts(sc, ps_cur, ps_f.pts); }
      continue
    }
    if ps_low.starts_with("brush ") {
      if !parse_brush(sc, ps_s) { sc.unparsed += ["line {ps_no}: {ps_s}"]; }
      continue
    }
    if ps_low.starts_with("lock") {
      ps_k = parse_lock(sc, ps_s);
      if ps_k.bad { sc.unparsed += ["line {ps_no}: {ps_s}"]; }
      else { acc_pts(sc, ps_cur, ps_k.pts); }
      continue
    }
    if ps_low.starts_with("poly") {
      ps_p = parse_poly(sc, ps_s);
      if ps_p.bad { sc.unparsed += ["line {ps_no}: {ps_s}"]; }
      else { acc_pts(sc, ps_cur, ps_p.pts); }
      continue
    }
    ps_l = parse_line_cmd(sc, ps_s);
    if ps_l.matched { acc_pts(sc, ps_cur, ps_l.pts); continue }
    sc.unparsed += ["line {ps_no}: {ps_s}"];
  }
  sc
}

// ── The metric channel: `landmark`, `check`, and the report ──────────
//
// A `.draw` scene can state what it MEANS as well as what it draws:
//
//     landmark horizon = 0.62
//     check head.cx ~ 0.333 tol 0.04
//     check mouth.cy > nose.cy
//
// A check reads the boxes the named marks actually covered, so it measures the
// picture rather than the source.  That is what makes iteration cheap for
// something that cannot look at the result: a metric report costs a read where a
// PNG costs a look.  `draw.py` evaluates these into `stats.txt` and turns them
// into its `--once` exit status; this is that channel, verdict for verdict.

/// A resolved value, or the name that could not be resolved.
///
/// The error is a NAME with a `?` after it — `head?`, `head.middle?` — because a
/// report has to say which spelling was wrong, not that something was.
struct Val {
  ok: boolean = false,
  value: float = 0.0,
  err: text = "",
}

fn val_ok(v: float) -> Val { Val { ok: true, value: v, err: "" } }
fn val_err(name: text) -> Val { Val { ok: false, value: 0.0, err: "{name}?" } }

/// One evaluated `check` line.
pub struct Check {
  /// The check as written, with any `tol` clause removed — what the report echoes.
  pub claim: text = "",
  pub ok: boolean = false,
  /// The two sides, as measured.  Both `0.0` when a name did not resolve.
  pub lhs: float = 0.0,
  pub rhs: float = 0.0,
  /// The tolerance `~` was judged at; the other operators ignore it.
  ///
  /// ⚠ Spelled `0.02` and not `DEFAULT_TOL`: a field default that names a
  /// file-scope constant is an internal compiler error today
  /// ([loft#1086](https://github.com/loft-lang/loft/issues/1086)).
  pub tol: float = 0.02,
  /// Empty when the check was measured, whatever its verdict.  Otherwise the
  /// unresolved name (`head.middle?`) or `(unparsed)` for a line with no operator.
  pub err: text = "",
  /// The one-line report, in `draw.py`'s spelling.
  pub report: text = "",
}

/// What `~` means without a `tol` clause: a fraction of the paper.
pub const DEFAULT_TOL = 0.02;

/// One measurable property of an element's box — `draw.py`'s `props()`.
fn box_prop(e: const Elem, p: text) -> Val {
  if p == "left" { return val_ok(e.bx0) }
  if p == "top" { return val_ok(e.by0) }
  if p == "right" { return val_ok(e.bx1) }
  if p == "bottom" { return val_ok(e.by1) }
  if p == "w" { return val_ok(e.bx1 - e.bx0) }
  if p == "h" { return val_ok(e.by1 - e.by0) }
  if p == "cx" { return val_ok((e.bx0 + e.bx1) * 0.5) }
  if p == "cy" { return val_ok((e.by0 + e.by1) * 0.5) }
  val_err("{e.ename}.{p}")
}

/// A literal number, an `element.property`, or a landmark — `draw.py`'s `value_of`.
///
/// An element that was named and never drawn resolves like an unknown one: it
/// has no box, and answering `0,0,0,0` for it would be a measurement rather than
/// an absence.
fn value_of(sk: const Sketch, tok: text) -> Val {
  vo_n = tok as float?;
  if vo_n != null { return val_ok(vo_n ?? 0.0) }
  vo_dot = tok.find(".");
  if vo_dot != null {
    vo_at = vo_dot ?? 0;
    vo_e = tok[0..vo_at];
    vo_p = tok[vo_at + 1..size(tok)];
    for e in sk.elems if e.ename == vo_e {
      if e.seen { return box_prop(e, vo_p) }
      return val_err(vo_e)
    }
    return val_err(vo_e)
  }
  for l in sk.landmarks if l.lname == tok { return val_ok(l.lvalue) }
  val_err(tok)
}

/// A value, or a value plus or minus a literal — `draw.py`'s `term_of`.
///
/// The pattern is `^(\S+)\s*([+-])\s*([\d.]+)$`, and its greed decides which `-`
/// is the operator: the RIGHTMOST one that leaves a space-free base on its left
/// and nothing but digits and dots on its right.  So `-0.1` is a negative
/// literal and `x-0.1` is a subtraction, with no rule beyond the pattern.
fn term_of(sk: const Sketch, expr: text) -> Val {
  te_s = expr.trim();
  te_i = size(te_s) - 1;
  while te_i > 0 {
    te_b = scan::at(te_s, te_i);
    if te_b == 43 || te_b == 45 {
      te_head = te_s[0..te_i];
      te_base = te_head.trim_end();
      te_tail = te_s[te_i + 1..size(te_s)];
      te_num = scan::read_unsigned(te_tail, scan::skip_space(te_tail, 0));
      if te_base != "" && !te_base.contains(" ") && te_num.ok
         && te_num.at == size(te_tail) {
        te_v = value_of(sk, te_base);
        if !te_v.ok { return te_v }
        return val_ok(if te_b == 43 { te_v.value + te_num.value }
                      else { te_v.value - te_num.value })
      }
    }
    te_i -= 1;
  }
  value_of(sk, te_s)
}

/// The first `\btol\s+([\d.]+)` clause: where it starts, and the number it names.
///
/// Case-SENSITIVE, unlike every other word in the grammar — the tolerance regex
/// is the one `draw.py` writes without `re.I`.
fn find_tol(c: text) -> scan::Scan {
  ft_n = size(c);
  ft_i = 0;
  while ft_i < ft_n {
    if scan::word_boundary(c, ft_i) && scan::at(c, ft_i) == 116
       && scan::at(c, ft_i + 1) == 111 && scan::at(c, ft_i + 2) == 108 {
      ft_j = ft_i + 3;
      ft_k = scan::skip_space(c, ft_j);
      if ft_k > ft_j {
        ft_v = scan::read_unsigned(c, ft_k);
        if ft_v.ok { return scan::Scan { ok: true, at: ft_i, value: ft_v.value } }
      }
    }
    ft_i += 1;
  }
  scan::scan_fail()
}

/// The comparison operator starting at byte `i`, or `""` — in the alternation
/// order `draw.py` writes, which is what makes `<=` win over `<`.
fn op_at(c: text, i: integer) -> text {
  oa_b = scan::at(c, i);
  oa_n = scan::at(c, i + 1);
  if oa_b == 126 { return "~" }
  if oa_b == 60 { return if oa_n == 61 { "<=" } else { "<" } }
  if oa_b == 62 { return if oa_n == 61 { ">=" } else { ">" } }
  if oa_b == 61 && oa_n == 61 { return "==" }
  ""
}

/// The `+`-signed rendering of a delta.
///
/// `draw.py` writes `{delta:+.3f}` and loft's format spec drops the `+` on a
/// float, so the sign is written here rather than left to differ silently.
fn signed3(d: float) -> text { if d < 0.0 { "{d:.3}" } else { "+{d:.3}" } }

/// One `check` line, measured — `draw.py`'s `eval_check`, verdict for verdict.
pub fn eval_check(sk: const Sketch, raw: text) -> Check {
  ec_tol = DEFAULT_TOL;
  ec_c = raw;
  ec_t = find_tol(raw);
  if ec_t.ok {
    ec_tol = ec_t.value;
    ec_c = raw[0..ec_t.at].trim();
  }
  ec_i = size(ec_c) - 1;
  ec_at = -1;
  ec_op = "";
  while ec_i > 0 {
    ec_try = op_at(ec_c, ec_i);
    if ec_try != "" && ec_i + size(ec_try) < size(ec_c) {
      ec_head = ec_c[0..ec_i].trim_end();
      if ec_head != "" && !ec_head.contains(" ") && !scan::is_space_byte(scan::at(ec_c, 0)) {
        ec_at = ec_i;
        ec_op = ec_try;
        break
      }
    }
    ec_i -= 1;
  }
  if ec_at < 0 {
    return Check { claim: ec_c, ok: false, lhs: 0.0, rhs: 0.0, tol: ec_tol,
                   err: "(unparsed)", report: "check: {ec_c}  (unparsed)" }
  }
  ec_lt = ec_c[0..ec_at].trim_end();
  ec_rt = ec_c[ec_at + size(ec_op)..size(ec_c)].trim();
  ec_l = value_of(sk, ec_lt);
  ec_r = term_of(sk, ec_rt);
  if !ec_l.ok || !ec_r.ok {
    ec_e = if ec_l.err != "" { ec_l.err } else { ec_r.err };
    return Check { claim: ec_c, ok: false, lhs: 0.0, rhs: 0.0, tol: ec_tol,
                   err: ec_e, report: "check: {ec_c}  ERR {ec_e}" }
  }
  ec_d = ec_l.value - ec_r.value;
  ec_ok = if ec_op == "~" { abs(ec_d) <= ec_tol }
          else if ec_op == "<" { ec_l.value < ec_r.value }
          else if ec_op == ">" { ec_l.value > ec_r.value }
          else if ec_op == "<=" { ec_l.value <= ec_r.value }
          else if ec_op == ">=" { ec_l.value >= ec_r.value }
          else { abs(ec_d) < 0.000000001 };
  ec_v = if ec_ok { "PASS" } else { "FAIL" };
  ec_sd = signed3(ec_d);
  ec_lv = ec_l.value;
  ec_rv = ec_r.value;
  Check { claim: ec_c, ok: ec_ok, lhs: ec_lv, rhs: ec_rv, tol: ec_tol, err: "",
          report: "check: {ec_lt} {ec_op} {ec_rt}  ->  {ec_lv:.3} vs {ec_rv:.3}  d={ec_sd}  {ec_v}" }
}

/// Every `check` the scene states, measured.
// Example: @DRW-003
pub fn eval_checks(sk: const Sketch) -> vector<Check> {
  ev_out: vector<Check> = [];
  for c in sk.checks { ev_out += [eval_check(sk, c)]; }
  ev_out
}

/// Did the scene come out whole — every line understood, every mark drawn, every
/// check passing?  This is what `--once` exits on.
///
/// A DEFERRED mark counts as a failure, where `draw.py` has no such notion: a
/// mark this build recognises and does not draw means the picture is short of
/// something the scene asked for, and a renderer that answers "fine" to that is
/// the one thing this channel exists to prevent.
pub fn scene_ok(sk: const Sketch, results: const vector<Check>) -> boolean {
  if len(sk.unparsed) > 0 || len(sk.deferred) > 0 { return false }
  for r in results if !r.ok { return false }
  true
}

/// The metric report: what did not parse, where every named element sits, and
/// how each check came out.
///
/// `draw.py`'s `stats.txt` blocks, in its spelling, minus the two that are
/// pictures of the render (the density map and the composition notes) —
/// this channel measures the SCENE, and a picture already has a diff.
pub fn metric_report(sk: const Sketch, results: const vector<Check>) -> text {
  mr_out = "";
  if len(sk.unparsed) > 0 {
    mr_out += "UNPARSED {len(sk.unparsed)} line(s) — NOT drawn (fix the syntax before judging geometry):\n";
    for u in sk.unparsed { mr_out += "  {u}\n"; }
    mr_out += "\n";
  }
  if len(sk.deferred) > 0 {
    mr_out += "DEFERRED {len(sk.deferred)} mark(s) — recognised, not drawn by this build:\n";
    for d in sk.deferred { mr_out += "  {d}\n"; }
    mr_out += "\n";
  }
  mr_out += "ops: {len(sk.ops)}\npaper: {sk.sw}x{sk.sh}\n\n";
  mr_out += "elements (bbox frac):\n";
  for e in seen_in_ink_order(sk) {
    mr_out += "  {e.ename:8} x[{e.bx0:.2}..{e.bx1:.2}] y[{e.by0:.2}..{e.by1:.2}]\n";
  }
  mr_out += "\n";
  mr_pass = 0;
  for r in results if r.ok { mr_pass += 1; }
  mr_out += "CHECKS  {mr_pass}/{len(results)} pass:\n";
  for r in results { mr_out += "  {r.report}\n"; }
  mr_out
}

/// The drawn elements, in the order they first took ink.
///
/// `draw.py` keys its element table by name in a dict, so its report's order is
/// the order each element first got a mark — NOT the order the `name` lines
/// appear in, which is what a scene that names an element long before drawing it
/// would show.
fn seen_in_ink_order(sk: const Sketch) -> vector<Elem> {
  si_out: vector<Elem> = [];
  for k in 0..=len(sk.ops) {
    for e in sk.elems if e.seen && e.first_at == k { si_out += [e]; }
  }
  si_out
}

// ── Rendering ───────────────────────────────────────────────────────

/// Paper fractions to pixels on the supersampled canvas.
fn to_pixels(pts: const vector<raster::Pt>, bw: integer, bh: integer) -> vector<raster::Pt> {
  tp_out: vector<raster::Pt> = [];
  for p in pts { tp_out += [raster::pt(p.ptx * (bw as float), p.pty * (bh as float))]; }
  tp_out
}

/// The vertical background ramp, one Bresenham row at a time — which is what
/// `draw.py` does, and the row-by-row truncation of the interpolated colour is
/// visible in the result.
fn draw_sky(cv: graphics::Canvas, top: integer, bot: integer, bw: integer, bh: integer) {
  dk_den = if bh - 1 < 1 { 1 } else { bh - 1 };
  dk_tr = graphics::color_r(top);
  dk_tg = graphics::color_g(top);
  dk_tb = graphics::color_b(top);
  dk_dr = graphics::color_r(bot) - dk_tr;
  dk_dg = graphics::color_g(bot) - dk_tg;
  dk_db = graphics::color_b(bot) - dk_tb;
  for y in 0..bh {
    dk_t = ((y as float) / (dk_den as float)) ?? 0.0;
    dk_c = graphics::rgba(((dk_tr as float) + (dk_dr as float) * dk_t) as integer,
                          ((dk_tg as float) + (dk_dg as float) * dk_t) as integer,
                          ((dk_tb as float) + (dk_db as float) * dk_t) as integer, 255);
    dk_row: vector<raster::Pt> = [raster::pt(0.0, y as float), raster::pt(bw as float, y as float)];
    raster::thin_line(cv, dk_row, dk_c);
  }
}

fn draw_fill(cv: graphics::Canvas, op: const Op, bw: integer, bh: integer) {
  if op.paint.pk == Stroked { return }
  df_px = to_pixels(op.pts, bw, bh);
  if op.paint.pk == Solid {
    raster::fill_poly(cv, df_px, op.paint.c1);
    return
  }
  fill_gradient(cv, op, df_px, bw, bh);
}

/// The side of the gradient grid `draw.py` computes on.  Not a quality knob: the
/// picture is this grid ENLARGED by a bicubic filter, so changing it changes
/// every gradient in the corpus.
const GRADIENT_GRID = 100;

/// Pillow's `or`-idiom guard: a zero denominator becomes this rather than
/// dividing by zero.  It is a real number in the arithmetic, not a sentinel — a
/// degenerate axis therefore saturates `t` instead of blanking the shape.
const GRADIENT_EPS = 0.000000001;

/// `draw.py`'s `_make_gradient`: the ramp computed small, then ENLARGED.  @FR-Raster-Gradient
///
/// ⚠ The enlargement is the contract, not an optimisation.  `Image.resize(size)`
/// with no `resample=` is BICUBIC, whose negative lobes overshoot — a 100x100
/// ramp blown up to a shape's bounding box carries ringing that computing the
/// ramp directly at full size would not have. Reproduce the two steps or the
/// pixels differ.
fn make_gradient(pa: const Paint, fx0: float, fy0: float, fx1: float, fy1: float,
                 gw: integer, gh: integer) -> graphics::Canvas {
  // Defaults, when no `dir=` / `at=` was given: a linear ramp runs top to bottom
  // over the bounding box, a radial one starts at its centre.
  mg_ax = (fx0 + fx1) / 2.0;
  mg_ay = fy0;
  mg_bx = (fx0 + fx1) / 2.0;
  mg_by = fy1;
  mg_cx = (fx0 + fx1) / 2.0;
  mg_cy = (fy0 + fy1) / 2.0;
  mg_r = (if fx1 - fx0 > fy1 - fy0 { fx1 - fx0 } else { fy1 - fy0 }) / 2.0;
  if pa.pk == Linear && len(pa.spec) == 4 {
    mg_ax = pa.spec[0]?; mg_ay = pa.spec[1]?; mg_bx = pa.spec[2]?; mg_by = pa.spec[3]?;
  }
  if pa.pk == Radial && len(pa.spec) == 3 {
    mg_cx = pa.spec[0]?; mg_cy = pa.spec[1]?; mg_r = pa.spec[2]?;
  }
  mg_dx = mg_bx - mg_ax;
  mg_dy = mg_by - mg_ay;
  mg_den = mg_dx * mg_dx + mg_dy * mg_dy;
  if mg_den == 0.0 { mg_den = GRADIENT_EPS; }
  if mg_r == 0.0 { mg_r = GRADIENT_EPS; }
  mg_r1 = graphics::color_r(pa.c1); mg_g1 = graphics::color_g(pa.c1); mg_b1 = graphics::color_b(pa.c1);
  mg_r2 = graphics::color_r(pa.c2); mg_g2 = graphics::color_g(pa.c2); mg_b2 = graphics::color_b(pa.c2);
  mg_small = graphics::canvas(GRADIENT_GRID, GRADIENT_GRID, 0);
  mg_g = GRADIENT_GRID as float;
  for j in 0..GRADIENT_GRID {
    mg_fy = fy0 + (fy1 - fy0) * (((j as float) + 0.5) / mg_g ?? 0.0);
    for i in 0..GRADIENT_GRID {
      mg_fx = fx0 + (fx1 - fx0) * (((i as float) + 0.5) / mg_g ?? 0.0);
      mg_t = if pa.pk == Linear {
        (((mg_fx - mg_ax) * mg_dx + (mg_fy - mg_ay) * mg_dy) / mg_den ?? 0.0)
      } else {
        (sqrt((mg_fx - mg_cx) * (mg_fx - mg_cx) + (mg_fy - mg_cy) * (mg_fy - mg_cy)) / mg_r ?? 0.0)
      };
      if mg_t < 0.0 { mg_t = 0.0; }
      if mg_t > 1.0 { mg_t = 1.0; }
      mg_small.set_pixel(i, j, graphics::rgba(
        ((mg_r1 as float) + ((mg_r2 - mg_r1) as float) * mg_t) as integer,
        ((mg_g1 as float) + ((mg_g2 - mg_g1) as float) * mg_t) as integer,
        ((mg_b1 as float) + ((mg_b2 - mg_b1) as float) * mg_t) as integer, 255));
    }
  }
  mg_small.resize_bicubic(if gw < 1 { 1 } else { gw }, if gh < 1 { 1 } else { gh })
}

/// Fill a shape with a gradient: build the ramp over the shape's bounding box,
/// then stamp it through the shape as a mask.
///
/// ⚠ The mask is a POLYGON FILL on a zero-filled buffer, so it only ever holds
/// "set" or "unset" — measured, `ImageDraw.polygon` on an `L` image produces
/// exactly {0, 255} and nothing between. That is what makes the paste a hard
/// SELECT rather than an alpha blend, and why the gradient's own edge is not
/// anti-aliased here: the 3x supersample is what smooths it, one step later.
fn fill_gradient(cv: graphics::Canvas, op: const Op, pxs: const vector<raster::Pt>,
                 bw: integer, bh: integer) {
  fg_n = len(op.pts);
  if fg_n == 0 { return }
  fg_x0 = op.pts[0]?.ptx; fg_y0 = op.pts[0]?.pty;
  fg_x1 = fg_x0; fg_y1 = fg_y0;
  for p in op.pts {
    if p.ptx < fg_x0 { fg_x0 = p.ptx; }
    if p.pty < fg_y0 { fg_y0 = p.pty; }
    if p.ptx > fg_x1 { fg_x1 = p.ptx; }
    if p.pty > fg_y1 { fg_y1 = p.pty; }
  }
  fg_px0 = (fg_x0 * (bw as float)) as integer;
  fg_py0 = (fg_y0 * (bh as float)) as integer;
  fg_w = ((fg_x1 * (bw as float)) as integer) + 1 - fg_px0;
  fg_h = ((fg_y1 * (bh as float)) as integer) + 1 - fg_py0;
  if fg_w < 1 { fg_w = 1; }
  if fg_h < 1 { fg_h = 1; }
  fg_grad = make_gradient(op.paint, fg_x0, fg_y0, fg_x1, fg_y1, fg_w, fg_h);
  fg_mask = graphics::canvas(fg_w, fg_h, 0);
  fg_local: vector<raster::Pt> = [];
  for p in pxs { fg_local += [raster::pt(p.ptx - (fg_px0 as float), p.pty - (fg_py0 as float))]; }
  raster::fill_poly(fg_mask, fg_local, graphics::rgba(255, 255, 255, 255));
  for y in 0..fg_h {
    for x in 0..fg_w {
      if graphics::color_a(fg_mask.get_pixel(x, y)) != 0 {
        cv.set_pixel(fg_px0 + x, fg_py0 + y, fg_grad.get_pixel(x, y));
      }
    }
  }
}

fn draw_stroke(cv: graphics::Canvas, op: const Op, bw: integer, bh: integer) {
  ds_px = to_pixels(op.pts, bw, bh);
  if len(ds_px) < 2 { return }
  if len(op.widths) > 0 { ribbon(cv, ds_px, op.widths, op.color); return }
  ds_w = op.w * SUPERSAMPLE;
  if ds_w < 1 { ds_w = 1; }
  for i in 0..(len(ds_px) - 1) {
    ds_a = ds_px[i]?;
    ds_b = ds_px[i + 1]?;
    if ds_w <= 1 {
      ds_seg: vector<raster::Pt> = [ds_a, ds_b];
      raster::thin_line(cv, ds_seg, op.color);
    } else {
      raster::wide_line(cv, ds_a.ptx, ds_a.pty, ds_b.ptx, ds_b.pty, op.color, ds_w);
    }
  }
}

/// A stroke whose pen width varies along it, drawn as a filled band.  @FR-Mark-Ribbon
///
/// Offsetting each point along its own normal by half its own width and filling
/// between the two sides is what lets a hair strand be thick at the root and
/// thin at the tip — a pen cannot do that, and a polygon can.
fn ribbon(cv: graphics::Canvas, pxs: const vector<raster::Pt>, widths: const vector<float>,
          color: integer) {
  rb_n = len(pxs);
  if rb_n < 2 { return }
  rb_left: vector<raster::Pt> = [];
  rb_right: vector<raster::Pt> = [];
  for i in 0..rb_n {
    rb_hw = widths[i]? * (SUPERSAMPLE as float) / 2.0;
    if rb_hw < 0.5 { rb_hw = 0.5; }
    rb_p = pxs[i]?;
    rb_tx = 0.0;
    rb_ty = 0.0;
    if i == 0 {
      rb_q = pxs[1]?;
      rb_tx = rb_q.ptx - rb_p.ptx;
      rb_ty = rb_q.pty - rb_p.pty;
    } else if i == rb_n - 1 {
      rb_q = pxs[i - 1]?;
      rb_tx = rb_p.ptx - rb_q.ptx;
      rb_ty = rb_p.pty - rb_q.pty;
    } else {
      rb_a = pxs[i + 1]?;
      rb_b = pxs[i - 1]?;
      rb_tx = rb_a.ptx - rb_b.ptx;
      rb_ty = rb_a.pty - rb_b.pty;
    }
    rb_l = sqrt(rb_tx * rb_tx + rb_ty * rb_ty);
    if rb_l == 0.0 { rb_l = 1.0; }
    rb_nx = (-rb_ty / rb_l) ?? 0.0;
    rb_ny = (rb_tx / rb_l) ?? 0.0;
    rb_left += [raster::pt(rb_p.ptx + rb_nx * rb_hw, rb_p.pty + rb_ny * rb_hw)];
    rb_right += [raster::pt(rb_p.ptx - rb_nx * rb_hw, rb_p.pty - rb_ny * rb_hw)];
  }
  rb_poly: vector<raster::Pt> = [];
  for p in rb_left { rb_poly += [p]; }
  for i in 0..rb_n { rb_poly += [rb_right[rb_n - 1 - i]?]; }
  raster::fill_poly(cv, rb_poly, color);
}

/// `Lock`: the brush stroke, built as a layer and composited over the canvas once.
fn draw_lock(cv: graphics::Canvas, sc: const Sketch, op: const Op, bw: integer, bh: integer) {
  if op.brush < 0 || op.brush >= len(sc.brushes) { return }
  dl_px = to_pixels(op.pts, bw, bh);
  dl_xs: vector<float> = [];
  dl_ys: vector<float> = [];
  for p in dl_px {
    dl_xs += [p.ptx];
    dl_ys += [p.pty];
  }
  dl_lay = brush::lock_layer(dl_xs, dl_ys, bw, bh, sc.brushes[op.brush]?,
                             brush::scaled(op.style, SUPERSAMPLE as float));
  if dl_lay.lw > 0 { brush::composite_layer(cv, dl_lay); }
}

/// Draw a parsed scene: at `SUPERSAMPLE`x, then resampled down with Lanczos.
///
/// A transparent background renders on transparent pixels so the resample
/// anti-aliases edges against ALPHA rather than against white — which is what
/// makes the result a sprite that can be composited over anything, with no
/// colour-key hack.  @FR-Raster-Transparent · @FR-Mark-Deposit (fills and strokes
/// overwrite; only `Lock` composites, through `brush`).
pub fn render(sc: const Sketch) -> graphics::Canvas {
  rn_bw = sc.sw * SUPERSAMPLE;
  rn_bh = sc.sh * SUPERSAMPLE;
  rn_cv = graphics::canvas(rn_bw, rn_bh,
                           if sc.transparent { 0 } else { graphics::rgba(255, 255, 255, 255) });
  for op in sc.ops {
    match op.kind {
      Sky => draw_sky(rn_cv, op.color, op.color2, rn_bw, rn_bh),
      Fill => draw_fill(rn_cv, op, rn_bw, rn_bh),
      Stroke => draw_stroke(rn_cv, op, rn_bw, rn_bh),
      Lock => draw_lock(rn_cv, sc, op, rn_bw, rn_bh),
    }
  }
  rn_cv.resize_lanczos(sc.sw, sc.sh)
}

/// Render a `.draw` file to a PNG, answering the parsed scene so the caller can
/// report what it could not accept.  A `Brush … file=` in it resolves against the
/// scene's own directory.
pub fn render_file(src: text, out: text) -> Sketch {
  rf_at = src.rfind("/") ?? -1;
  rf_base = "";
  if rf_at > 0 { rf_base = src[0..rf_at]; }
  rf_sc = parse_scene_at(file(src).content()?, rf_base);
  rf_cv = render(rf_sc);
  _ = rf_cv.save_png(out);
  rf_sc
}

// ── Fronds: a seeded, deliberately non-uniform array of tapered strokes ──────

/// A reproducible pseudo-random float in [-1, 1) from three small non-negative
/// integers — `noise::seed_hash`, kept under this name because the corpus is
/// defined by it.  (Public names are one namespace across a package's modules,
/// which is why the implementation carries a different one.)  @FR-Seed-Hash
pub fn hash01(hseed: integer, hidx: integer, hsalt: integer) -> float {
  noise::seed_hash(hseed, hidx, hsalt)
}

/// A smooth low-frequency wave over [0,1] — `noise::seed_wave`.
pub fn lowfreq(lseed: integer, lu: float) -> float { noise::seed_wave(lseed, lu) }

/// One frond: a centreline in paper fractions and a width per point.
pub struct Frond {
  pub fpts: vector<raster::Pt>,
  pub fwid: vector<float>,
}

/// Everything a `Fronds` line says about the array, past its two endpoints.
///
/// A struct rather than twenty parameters, because the fractal recursion builds a
/// MODIFIED copy of it — a parameter list that long is one a caller gets wrong in
/// order, and the recursion is the caller.

/// A linear array of tapered strokes rooted along the spine `(x1,y1)-(x2,y2)`.
/// @FR-Seed-NonUniform
///
/// Every frond is the same template placed at `u` along the spine and tilted off
/// the normal, then made NON-UNIFORM three ways at once: placement clumps on the
/// seeded low-frequency field, length / width / angle ramp along the run and are
/// then jittered per frond, and the ends FRAY. Zero all three and it is a comb.
///
/// ⚠ `mirror` reuses the SAME sample on both sides — length, angle and width are
/// drawn once, outside the side loop, and reflected. Drawing them per side would
/// consume the hash twice as fast and desynchronise every frond after the first.
///
/// Built in PIXEL space and handed back in fractions, so the array stays correct
/// on non-square paper.
pub fn fronds(x1: float, y1: float, x2: float, y2: float, sp: const FrondSpec,
              pw: integer, ph: integer) -> vector<Frond> {
  fd_out: vector<Frond> = [];
  fd_p1x = x1 * (pw as float);
  fd_p1y = y1 * (ph as float);
  fd_dx = x2 * (pw as float) - fd_p1x;
  fd_dy = y2 * (ph as float) - fd_p1y;
  fd_ln = sqrt(fd_dx * fd_dx + fd_dy * fd_dy);
  if fd_ln == 0.0 { fd_ln = 0.000000001; }
  fd_ux = (fd_dx / fd_ln ?? 0.0);
  fd_uy = (fd_dy / fd_ln ?? 0.0);
  fd_nx = -fd_uy;
  fd_ny = fd_ux;
  fd_n = sp.count;
  for i in 0..fd_n {
    fd_ub = (((i as float) + 0.5) / (fd_n as float) ?? 0.0);
    fd_u = fd_ub + sp.clump * lowfreq(sp.seed, fd_ub) * (0.5 / (fd_n as float) ?? 0.0);
    if fd_u < 0.0 { fd_u = 0.0; }
    if fd_u > 1.0 { fd_u = 1.0; }
    fd_ltr = sp.len1 + (sp.len2 - sp.len1) * fd_u;
    fd_dte = if fd_u < 1.0 - fd_u { fd_u } else { 1.0 - fd_u };
    fd_edge = 1.0 - (fd_dte / 0.25 ?? 0.0);
    if fd_edge < 0.0 { fd_edge = 0.0; }
    fd_lpx = fd_ltr * (1.0 - sp.fray * fd_edge)
             * (1.0 + sp.jitter * 0.4 * hash01(sp.seed, i, 1)) * (pw as float);
    fd_an = (sp.ang1 + (sp.ang2 - sp.ang1) * fd_u) + sp.jitter * 15.0 * hash01(sp.seed, i, 2);
    fd_phi = fd_an * noise::PI / 180.0;
    fd_wi = (sp.wid1 + (sp.wid2 - sp.wid1) * fd_u)
            * (1.0 + sp.jitter * 0.3 * hash01(sp.seed, i, 3));
    fd_rx = fd_p1x + fd_u * fd_dx;
    fd_ry = fd_p1y + fd_u * fd_dy;
    fd_sides: vector<float> = if sp.mirror { [1.0, -1.0] } else { [1.0] };
    for s in fd_sides {
      fd_fx = fd_nx * s * cos(fd_phi) + fd_ux * sin(fd_phi);
      fd_fy = fd_ny * s * cos(fd_phi) + fd_uy * sin(fd_phi);
      fd_tx = fd_rx + fd_lpx * fd_fx;
      fd_ty = fd_ry + fd_lpx * fd_fy;
      fd_tip = fd_wi * 0.15;
      if fd_tip < 0.5 { fd_tip = 0.5; }
      fd_pts: vector<raster::Pt> = [];
      fd_wid: vector<float> = [];
      if sp.bow != 0.0 {
        fd_mx = (fd_rx + fd_tx) / 2.0 + sp.bow * fd_lpx * (-fd_fy);
        fd_my = (fd_ry + fd_ty) / 2.0 + sp.bow * fd_lpx * fd_fx;
        fd_pts += [raster::pt((fd_rx / (pw as float) ?? 0.0), (fd_ry / (ph as float) ?? 0.0)),
                   raster::pt((fd_mx / (pw as float) ?? 0.0), (fd_my / (ph as float) ?? 0.0)),
                   raster::pt((fd_tx / (pw as float) ?? 0.0), (fd_ty / (ph as float) ?? 0.0))];
        fd_wid += [fd_wi, (fd_wi + fd_tip) / 2.0, fd_tip];
      } else {
        fd_pts += [raster::pt((fd_rx / (pw as float) ?? 0.0), (fd_ry / (ph as float) ?? 0.0)),
                   raster::pt((fd_tx / (pw as float) ?? 0.0), (fd_ty / (ph as float) ?? 0.0))];
        fd_wid += [fd_wi, fd_tip];
      }
      fd_out += [Frond { fpts: fd_pts, fwid: fd_wid }];
    }
  }
  if sp.depth <= 1 { return fd_out }
  // FRACTAL: every frond's own centreline becomes the spine of a smaller array.
  // ⚠ The sub-arrays are appended AFTER every base frond, so the draw order is
  // all of level 1 then all of level 2 — not depth-first per frond.  Under a
  // stroke that overlaps, order is what the picture shows.
  fd_base = len(fd_out);
  fd_cn = round_half_even((fd_n as float) * 0.55) as integer;
  if fd_cn < 2 { fd_cn = 2; }
  for k in 0..fd_base {
    fd_f = fd_out[k]?;
    fd_a = fd_f.fpts[0]?;
    fd_b = fd_f.fpts[len(fd_f.fpts) - 1]?;
    fd_sub = FrondSpec {
      count: fd_cn, len1: sp.len1 * sp.sub, len2: sp.len1 * sp.sub * 0.5,
      wid1: sp.wid1 * 0.5, wid2: if sp.wid2 * 0.5 > 0.6 { sp.wid2 * 0.5 } else { 0.6 },
      ang1: sp.ang1, ang2: sp.ang2, mirror: true,
      jitter: sp.jitter, clump: sp.clump, fray: sp.fray, bow: sp.bow,
      seed: sp.seed * 31 + k + 1, depth: sp.depth - 1, sub: sp.sub,
    };
    for f in fronds(fd_a.ptx, fd_a.pty, fd_b.ptx, fd_b.pty, fd_sub, pw, ph) {
      fd_out += [f];
    }
  }
  fd_out
}

src/noise.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// noise — the seeded, reproducible randomness a `.draw` scene is defined by.
//
// `Fronds` scatters its array with THIS hash and `Lock` splits its tips with it,
// and a scene is a picture only because the hash is fixed: swap in a PRNG and
// every jittered frond and every spike moves.  It has its own module because two
// modules need it — `drawing` (the arrays) and `brush` (the footprint and the
// lock) — and a module cannot import the module that imports it.  The public
// names stay `drawing::hash01` / `drawing::lowfreq`; those forward here.

pub const PI = 3.141592653589793;

/// A reproducible pseudo-random float in [-1, 1) from three small non-negative
/// integers.
///
/// ⚠ NOT a PRNG, and deliberately not the `random` package. `draw.py` says so in
/// its own docstring — *"never random()/hash() (those break reproducible
/// renders)"* — and the corpus is defined by THIS hash: swap in PCG-64 and every
/// jittered frond moves, so the substitution fails the gate it was meant to help
/// pass.
///
/// Three details are load-bearing. The masking is 32-bit throughout, and the
/// widest product it forms (`0xFFFFFFFF * 1274126177`) is 5.47e18 — inside i64 by
/// a hair, so this needs no widening but has none to spare. And the final divide
/// is a REAL division: `x / 0xFFFFFFFF` in integer arithmetic is 0 for every input
/// and would give a perfectly smooth, entirely wrong field.
/// @FR-Seed-Hash
pub fn seed_hash(hseed: integer, hidx: integer, hsalt: integer) -> float {
  hx = ((hseed * 73856093) ^ (hidx * 19349663) ^ (hsalt * 83492791)) & 0xFFFFFFFF;
  hx = (hx ^ (hx >> 13)) & 0xFFFFFFFF;
  hx = (hx * 1274126177) & 0xFFFFFFFF;
  ((hx as float) / 4294967295.0 ?? 0.0) * 2.0 - 1.0
}

/// A smooth low-frequency wave over [0,1], two harmonics with seed-derived phases.
///
/// Smooth in `u` is the point: neighbouring fronds see nearby values, so they
/// CLUMP and leave gaps. White noise would scatter them evenly, which reads as
/// man-made — the opposite of what this is for.  Periodic in `u`, which is what
/// lets a brush footprint tile along a stroke without a seam.
/// @FR-Seed-NonUniform — the field an array clumps on.
pub fn seed_wave(lseed: integer, lu: float) -> float {
  lp1 = seed_hash(lseed, 0, 11) * PI;
  lp2 = seed_hash(lseed, 0, 22) * PI;
  0.6 * sin(2.0 * PI * lu + lp1) + 0.4 * sin(4.0 * PI * lu + lp2)
}

src/raster.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// raster — Pillow's polygon and thick-line rasteriser, reproduced exactly (@FR-Raster-Pillow).
//
// ── ⚠ WHY THIS IS NOT `graphics::fill_polygon` ──────────────────────
//
// `graphics` already fills polygons, and this deliberately does not call it.
// The two answer different questions:
//
//   * `graphics::fill_polygon` agrees with `graphics::fill_triangle`, pixel for
//     pixel, on every triangle — a gated, published contract.  It interpolates
//     each crossing in INTEGER arithmetic and fills an inclusive span between a
//     pair of them.
//   * this fills the pixels whose CENTRES fall inside the outline, in C `float`,
//     which is what `ImageDraw.polygon` does.
//
// They are different rules and they disagree constantly: on 400 random polygons
// the two agree on **4**, and on `graphics`' own reference triangle they differ
// by 35 pixels.  A `.draw` scene is specified by what `draw.py` renders, and
// `draw.py`'s filler *is* Pillow — so a renderer that wants to be diffed against
// the corpus has to sign Pillow's rule, not a better one.
//
// ── ⚠ THE `single` IS LOAD-BEARING ──────────────────────────────────
//
// `Edge.dx` and the crossing array are C `float` (32-bit) in `Draw.c`, and the
// two exact-equality tests below — "is this crossing an integer?" and "do these
// two edges cross at the same x?" — read that rounding.  Widen them to `float`
// and 12 of 500 random polygons rasterise differently.  Every value that is a C
// `float` there is a `single` here, and the arithmetic is per-operation binary32
// on both backends.
//
// Ported from Pillow 10.2.0, `src/libImaging/Draw.c` + `src/_imaging.c`.
use graphics::*;

/// A point in canvas pixels, before Pillow truncates it to a whole pixel.
///
/// The truncation is `(int)` on a C double — toward ZERO, so `-0.4` lands on 0
/// and not on -1.  Keep coordinates fractional right up to the rasteriser and
/// the port and the oracle round the same way.
pub struct Pt {
  pub ptx: float,
  pub pty: float,
}

/// Build a point without spelling the field names.
pub fn pt(px: float, py: float) -> Pt {
  Pt { ptx: px, pty: py }
}

/// One outline edge, field for field as `Edge` in `Draw.c`.
struct Edge {
  exmin: integer,
  exmax: integer,
  eymin: integer,
  eymax: integer,
  edx: single,
  ex0: integer,
  ey0: integer,
}

/// `ROUND_UP` from `Draw.c` — half AWAY from zero.
///
/// Not `floor(f + 0.5)`: the sign lives outside, so the negative side mirrors
/// the positive one exactly (`ROUND_UP(-0.5)` is -1, not 0).
pub fn round_up(ru_v: float) -> integer {
  if ru_v >= 0.0 { floor(ru_v + 0.5) as integer }
  else { -(floor(abs(ru_v) + 0.5) as integer) }
}

/// `ROUND_DOWN` from `Draw.c` — `ceil(f - 0.5)`, mirrored about zero.
///
/// The mirroring is the whole subtlety: `ROUND_DOWN(-0.45)` is **0**, because
/// the `ceil` runs on `|f| - 0.5` and the sign is applied afterwards.  Take the
/// obvious `ceil(-0.95)` instead and a span that should end on column 0 ends on
/// column -1, which clips away and draws nothing.
pub fn round_down(rd_v: float) -> integer {
  if rd_v >= 0.0 { ceil(rd_v - 0.5) as integer }
  else { -(ceil(abs(rd_v) - 0.5) as integer) }
}

/// C `roundf` on a 32-bit float: half away from zero, answer stays binary32.
fn roundf(rf_v: single) -> single {
  rf_d = rf_v as float;
  if rf_d >= 0.0 { floor(rf_d + 0.5) as single } else { ceil(rf_d - 0.5) as single }
}

/// `add_edge` from `Draw.c`.
fn make_edge(mx0: integer, my0: integer, mx1: integer, my1: integer) -> Edge {
  Edge {
    exmin: if mx0 <= mx1 { mx0 } else { mx1 },
    exmax: if mx0 <= mx1 { mx1 } else { mx0 },
    eymin: if my0 <= my1 { my0 } else { my1 },
    eymax: if my0 <= my1 { my1 } else { my0 },
    edx: if my0 == my1 { 0.0f }
         else { (((mx1 - mx0) as single) / ((my1 - my0) as single) ?? 0.0f) },
    ex0: mx0,
    ey0: my0,
  }
}

/// Where an edge meets scanline `y`, in the `float` `Draw.c` carries it in.
fn edge_x(exe: const Edge, exy: integer) -> single {
  ((exy - exe.ey0) as single) * exe.edx + (exe.ex0 as single)
}

/// `hline32` from `Draw.c`: an INCLUSIVE span `[x0, x1]`, clipped to the canvas.
///
/// It STORES the ink rather than compositing it — `ImageDraw` on an RGBA image
/// replaces the pixel, alpha included, which is why a half-transparent mark does
/// not show what was under it.
fn pil_hline(cv: graphics::Canvas, hx0: integer, hy: integer, hx1: integer, ink: integer) {
  if hy < 0 || hy >= cv.height { return }
  hl_lo = hx0;
  hl_hi = hx1;
  if hl_lo < 0 { hl_lo = 0; } else if hl_lo >= cv.width { return }
  if hl_hi < 0 { return } else if hl_hi >= cv.width { hl_hi = cv.width - 1; }
  if hl_lo > hl_hi { return }
  hl_d = &cv.data;
  hl_base = hy * cv.width;
  for hl_x in hl_lo..=hl_hi {
    hl_d[hl_base + hl_x] = ink;
  }
}

/// `polygon_generic` from `Draw.c`, in the non-blending 32-bit form
/// `ImageDraw.Draw(rgba_image)` selects.
///
/// Three things here look like mistakes and are the contract:
///
///  * a HORIZONTAL edge is drawn immediately, as a span from its own `xmin` to
///    its own `xmax`, and takes no part in the scanline crossings;
///  * an edge ending on this row contributes its crossing TWICE (unless the row
///    is the polygon's last), which is how a vertex the outline passes through
///    keeps the parity right;
///  * the "connect discontiguous corners" repair writes back to `xx[k]` where
///    `k` is an EDGE index, not a crossing index — so it can move a crossing
///    belonging to a different edge.  Reproduced verbatim, because the goldens
///    were rendered through it.
///
/// ⚠ It scores 124 for control-flow complexity and that is deliberate: this is a
/// line-for-line port of one C function, and the thing a reader needs to be able to
/// do is put the two side by side.  Splitting it would make the correspondence
/// unverifiable and would buy nothing — there is no second caller and no variation
/// to factor out.
fn polygon_generic(cv: graphics::Canvas, edges: const vector<Edge>, ink: integer) {
  if len(edges) <= 0 { return }
  pg_table: vector<Edge> = [];
  pg_ymin = cv.height - 1;
  pg_ymax = 0;
  for e in edges {
    if pg_ymin > e.eymin { pg_ymin = e.eymin; }
    if pg_ymax < e.eymax { pg_ymax = e.eymax; }
    if e.eymin == e.eymax {
      pil_hline(cv, e.exmin, e.eymin, e.exmax, ink);
      continue
    }
    pg_table += [e];
  }
  if pg_ymin < 0 { pg_ymin = 0; }
  if pg_ymax > cv.height { pg_ymax = cv.height; }
  pg_ec = len(pg_table);
  if pg_ec == 0 { return }
  // One crossing buffer for the whole polygon, exactly as `Draw.c` allocates it:
  // the corner repair reaches back into it by edge index, so it must persist
  // across the row and must be at least two slots per edge.
  pg_xx: vector<single> = [];
  for _ in 0..(pg_ec * 2) { pg_xx += [0.0f]; }
  pg_y = pg_ymin;
  while pg_y <= pg_ymax {
    pg_j = 0;
    for i in 0..pg_ec {
      pg_cur = pg_table[i]?;
      if pg_y >= pg_cur.eymin && pg_y <= pg_cur.eymax {
        pg_xx[pg_j] = edge_x(pg_cur, pg_y);
        pg_j += 1;
        if pg_y == pg_cur.eymax && pg_y < pg_ymax {
          pg_xx[pg_j] = pg_xx[pg_j - 1]?;
          pg_j += 1;
        } else if pg_cur.edx != 0.0f && roundf(pg_xx[pg_j - 1]?) == pg_xx[pg_j - 1]? {
          for k in 0..i {
            pg_oth = pg_table[k]?;
            if (pg_cur.edx > 0.0f && pg_oth.edx <= 0.0f)
               || (pg_cur.edx < 0.0f && pg_oth.edx >= 0.0f) { continue }
            if ((pg_y == pg_cur.eymin && pg_y == pg_oth.eymin)
                || (pg_y == pg_cur.eymax && pg_y == pg_oth.eymax))
               && pg_xx[pg_j - 1]? == edge_x(pg_oth, pg_y) {
              // The row below (or above, on the last row) says which side the
              // two edges leave the corner on.
              pg_off = if pg_y == pg_ymax { -1 } else { 1 };
              pg_ac = edge_x(pg_cur, pg_y + pg_off);
              pg_ao = edge_x(pg_oth, pg_y + pg_off);
              if pg_y == pg_cur.eymax {
                if pg_cur.edx > 0.0f {
                  pg_xx[k] = (if pg_ac > pg_ao { pg_ac } else { pg_ao }) + 1.0f;
                } else {
                  pg_xx[k] = (if pg_ac < pg_ao { pg_ac } else { pg_ao }) - 1.0f;
                }
              } else {
                if pg_cur.edx > 0.0f {
                  pg_xx[k] = if pg_ac < pg_ao { pg_ac } else { pg_ao };
                } else {
                  pg_xx[k] = (if pg_ac > pg_ao { pg_ac } else { pg_ao }) + 1.0f;
                }
              }
              break
            }
          }
        }
      }
    }
    // Insertion sort over `xx[0 .. j)` — a row holds a handful of crossings, and
    // equal ones are interchangeable, so this and `Draw.c`'s `qsort` agree.
    pg_s = 1;
    while pg_s < pg_j {
      pg_v = pg_xx[pg_s]?;
      pg_t = pg_s;
      while pg_t > 0 && pg_xx[pg_t - 1]? > pg_v {
        pg_xx[pg_t] = pg_xx[pg_t - 1]?;
        pg_t -= 1;
      }
      pg_xx[pg_t] = pg_v;
      pg_s += 1;
    }
    pg_p = 1;
    while pg_p < pg_j {
      pil_hline(cv, round_up(pg_xx[pg_p - 1]? as float), pg_y,
                round_down(pg_xx[pg_p]? as float), ink);
      pg_p += 2;
    }
    pg_y += 1;
  }
}

/// Fill a polygon the way `ImageDraw.polygon(pts, fill=…)` does.
///
/// The outline closes itself only when the last point is not already the first —
/// a circle handed in with its start repeated gets no extra edge, which is how
/// `draw.py` passes one.
pub fn fill_poly(cv: graphics::Canvas, pts: const vector<Pt>, ink: integer) {
  fp_count = len(pts);
  if fp_count <= 0 { return }
  fp_xs: vector<integer> = [];
  fp_ys: vector<integer> = [];
  for p in pts {
    fp_xs += [p.ptx as integer];
    fp_ys += [p.pty as integer];
  }
  fp_edges: vector<Edge> = [];
  fp_i = 0;
  while fp_i < fp_count - 1 {
    fp_x0 = fp_xs[fp_i]?;
    fp_y0 = fp_ys[fp_i]?;
    fp_x1 = fp_xs[fp_i + 1]?;
    fp_y1 = fp_ys[fp_i + 1]?;
    fp_merged = false;
    // Two horizontal steps running the same way are ONE span in Pillow, so the
    // second widens the first edge instead of adding its own.
    if fp_y0 == fp_y1 && fp_i != 0 && fp_y0 == fp_ys[fp_i - 1]? && len(fp_edges) > 0 {
      fp_prev = fp_xs[fp_i - 1]?;
      fp_last = len(fp_edges) - 1;
      fp_e = fp_edges[fp_last]?;
      if fp_x1 > fp_x0 && fp_x0 > fp_prev {
        fp_edges[fp_last] = Edge { exmin: fp_e.exmin, exmax: fp_x1, eymin: fp_e.eymin,
                                   eymax: fp_e.eymax, edx: fp_e.edx, ex0: fp_e.ex0,
                                   ey0: fp_e.ey0 };
        fp_merged = true;
      } else if fp_x1 < fp_x0 && fp_x0 < fp_prev {
        fp_edges[fp_last] = Edge { exmin: fp_x1, exmax: fp_e.exmax, eymin: fp_e.eymin,
                                   eymax: fp_e.eymax, edx: fp_e.edx, ex0: fp_e.ex0,
                                   ey0: fp_e.ey0 };
        fp_merged = true;
      }
    }
    if !fp_merged {
      fp_edges += [make_edge(fp_x0, fp_y0, fp_x1, fp_y1)];
    }
    fp_i += 1;
  }
  fp_end = fp_count - 1;
  if fp_xs[fp_end]? != fp_xs[0]? || fp_ys[fp_end]? != fp_ys[0]? {
    fp_edges += [make_edge(fp_xs[fp_end]?, fp_ys[fp_end]?, fp_xs[0]?, fp_ys[0]?)];
  }
  polygon_generic(cv, fp_edges, ink);
}

/// One thick segment, the way `ImageDraw.line(…, width=w)` draws it for `w > 1`.
///
/// Pillow does not stroke — it builds a four-cornered quad around the segment
/// and fills that, with the two half-widths rounded APART (`ROUND_UP` one side,
/// `ROUND_DOWN` the other) so an even width still lands on whole pixels.  A
/// polyline is therefore a series of independent quads with no joins, and the
/// overlap at a bend is what rounds the corner.
pub fn wide_line(cv: graphics::Canvas, wx0: float, wy0: float, wx1: float, wy1: float,
                 ink: integer, wwidth: integer) {
  wl_x0 = wx0 as integer;
  wl_y0 = wy0 as integer;
  wl_x1 = wx1 as integer;
  wl_y1 = wy1 as integer;
  wl_dx = wl_x1 - wl_x0;
  wl_dy = wl_y1 - wl_y0;
  if wl_dx == 0 && wl_dy == 0 {
    cv.set_pixel(wl_x0, wl_y0, ink);
    return
  }
  wl_big = sqrt(((wl_dx * wl_dx + wl_dy * wl_dy) as float));
  wl_small = (((wwidth - 1) as float) / 2.0 ?? 0.0);
  wl_rmax = ((round_up(wl_small) as float) / wl_big ?? 0.0);
  wl_rmin = ((round_down(wl_small) as float) / wl_big ?? 0.0);
  wl_dxmin = round_down(wl_rmin * (wl_dy as float));
  wl_dxmax = round_down(wl_rmax * (wl_dy as float));
  wl_dymin = round_down(wl_rmin * (wl_dx as float));
  wl_dymax = round_down(wl_rmax * (wl_dx as float));
  wl_vx: vector<integer> = [wl_x0 - wl_dxmin, wl_x1 - wl_dxmin,
                            wl_x1 + wl_dxmax, wl_x0 + wl_dxmax];
  wl_vy: vector<integer> = [wl_y0 + wl_dymax, wl_y1 + wl_dymax,
                            wl_y1 - wl_dymin, wl_y0 - wl_dymin];
  wl_edges: vector<Edge> = [];
  for k in 0..4 {
    wl_edges += [make_edge(wl_vx[k]?, wl_vy[k]?, wl_vx[(k + 1) % 4]?, wl_vy[(k + 1) % 4]?)];
  }
  polygon_generic(cv, wl_edges, ink);
}

/// `line32` from `Draw.c` — Bresenham, and it stops one pixel SHORT of the end.
///
/// `draw_lines` puts that missing pixel back after the last segment, which is
/// why this is a polyline call rather than a segment one: split it into
/// segments and each one grows a pixel Pillow never drew.
pub fn thin_line(cv: graphics::Canvas, pts: const vector<Pt>, ink: integer) {
  tl_n = len(pts);
  if tl_n < 2 { return }
  tl_lx = 0;
  tl_ly = 0;
  for i in 0..(tl_n - 1) {
    tl_a = pts[i]?;
    tl_b = pts[i + 1]?;
    tl_lx = tl_b.ptx as integer;
    tl_ly = tl_b.pty as integer;
    bresenham(cv, tl_a.ptx as integer, tl_a.pty as integer, tl_lx, tl_ly, ink);
  }
  cv.set_pixel(tl_lx, tl_ly, ink);
}

/// The Bresenham walk itself, end-exclusive.
fn bresenham(cv: graphics::Canvas, bx0: integer, by0: integer, bx1: integer, by1: integer,
             ink: integer) {
  br_x = bx0;
  br_y = by0;
  br_dx = bx1 - bx0;
  br_xs = 1;
  if br_dx < 0 { br_dx = -br_dx; br_xs = -1; }
  br_dy = by1 - by0;
  br_ys = 1;
  if br_dy < 0 { br_dy = -br_dy; br_ys = -1; }
  if br_dx == 0 {
    for _ in 0..br_dy {
      cv.set_pixel(br_x, br_y, ink);
      br_y += br_ys;
    }
    return
  }
  if br_dy == 0 {
    for _ in 0..br_dx {
      cv.set_pixel(br_x, br_y, ink);
      br_x += br_xs;
    }
    return
  }
  if br_dx > br_dy {
    br_n = br_dx;
    br_dy += br_dy;
    br_e = br_dy - br_dx;
    br_dx += br_dx;
    for _ in 0..br_n {
      cv.set_pixel(br_x, br_y, ink);
      if br_e >= 0 { br_y += br_ys; br_e -= br_dx; }
      br_e += br_dy;
      br_x += br_xs;
    }
    return
  }
  br_n2 = br_dy;
  br_dx += br_dx;
  br_e2 = br_dx - br_dy;
  br_dy += br_dy;
  for _ in 0..br_n2 {
    cv.set_pixel(br_x, br_y, ink);
    if br_e2 >= 0 { br_x += br_xs; br_e2 -= br_dy; }
    br_e2 += br_dx;
    br_y += br_ys;
  }
}

src/scan.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// scan — the byte-level reader the `.draw` line grammar is built out of.
//
// `draw.py` states that grammar as a dozen regular expressions, all of them
// `search` (match anywhere on the line) and all case-insensitive.  What matters
// for a port is not that this uses the same engine — it is that it accepts the
// same lines and reads the same numbers out of them, so the helpers here are
// shaped after the regex constructs rather than after a tidier grammar:
// `\b` is `word_boundary`, `[-\d.]+` is `read_number`, `(\d+)` is `read_uint`,
// and `\s*` is `skip_space`.
//
// Everything is BYTE-indexed.  A `.draw` command line is ASCII by construction
// (a line that is not is a comment, and comments never reach here), so a byte
// index and a character index are the same number — and `byte_at` reads without
// decoding, which is what makes a per-character scan of 300 lines cheap.

/// A parse position and what was read there.
///
/// A scan either lands (`ok`) or does not, and the caller needs BOTH the value
/// and where to carry on from, so every reader answers all three at once.
pub struct Scan {
  pub ok: boolean,
  pub at: integer,
  pub value: float,
}

/// The did-not-land answer, which every reader below returns when it cannot
/// match.  A caller tests `ok` and never has to know a sentinel position.
pub fn scan_fail() -> Scan { Scan { ok: false, at: 0, value: 0.0 } }

/// A regex `[0-9]`, ASCII only — which costs nothing here, because a `.draw`
/// command line is ASCII by construction.
pub fn is_digit(b: integer) -> boolean { b >= 48 && b <= 57 }

/// A regex `\w`: letters, digits and underscore.
pub fn is_word_byte(b: integer) -> boolean {
  (b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122) || b == 95
}

/// A regex `\s`: space, tab, and the line endings a stray `\r` leaves behind.
pub fn is_space_byte(b: integer) -> boolean {
  b == 32 || b == 9 || b == 10 || b == 13 || b == 12 || b == 11
}

/// The byte at `i`, or -1 past either end — a value no byte can take, so a
/// caller never has to bound-check before asking.
pub fn at(s: text, i: integer) -> integer {
  if i < 0 || i >= size(s) { -1 } else { s.byte_at(i) }
}

/// ASCII lowercase of one byte.
pub fn lower_byte(b: integer) -> integer { if b >= 65 && b <= 90 { b + 32 } else { b } }

/// Past any run of spaces starting at `i`.
pub fn skip_space(s: text, i: integer) -> integer {
  sk_i = i;
  while is_space_byte(at(s, sk_i)) { sk_i += 1; }
  sk_i
}

/// Does `s` carry `word` at byte `i`, ignoring case?  (`word` must be lowercase.)
pub fn matches_at(s: text, i: integer, word: text) -> boolean {
  ma_n = size(word);
  if i + ma_n > size(s) { return false }
  for k in 0..ma_n {
    if lower_byte(at(s, i + k)) != word.byte_at(k) { return false }
  }
  true
}

/// A regex `\b` before byte `i`: position 0, or a non-word byte to the left.
pub fn word_boundary(s: text, i: integer) -> boolean {
  if i <= 0 { return true }
  !is_word_byte(at(s, i - 1))
}

/// A regex `([-\d.]+)` — a run of digits, dots and minus signs, read as a number.
///
/// The character class is what `draw.py` writes, so this is deliberately loose:
/// the run is taken first and converted second, and a run that is not a number
/// fails the scan rather than being silently read as zero.
pub fn read_number(s: text, i: integer) -> Scan {
  rn_j = i;
  while true {
    rn_b = at(s, rn_j);
    if is_digit(rn_b) || rn_b == 45 || rn_b == 46 { rn_j += 1; } else { break }
  }
  // The class swallows a `-` that is really the next token's separator, and the
  // regex engine backtracks off it because the pattern after demands it.  Give
  // back trailing bytes until what is left is a number, which lands on the same
  // answer without a second pass over the grammar.
  while rn_j > i {
    rn_v = s[i..rn_j] as float?;
    if rn_v != null { return Scan { ok: true, at: rn_j, value: rn_v ?? 0.0 } }
    rn_j -= 1;
  }
  scan_fail()
}

/// A regex `([\d.]+)` — digits and dots, no sign.
pub fn read_unsigned(s: text, i: integer) -> Scan {
  ru_j = i;
  while true {
    ru_b = at(s, ru_j);
    if is_digit(ru_b) || ru_b == 46 { ru_j += 1; } else { break }
  }
  while ru_j > i {
    ru_v = s[i..ru_j] as float?;
    if ru_v != null { return Scan { ok: true, at: ru_j, value: ru_v ?? 0.0 } }
    ru_j -= 1;
  }
  scan_fail()
}

/// A regex `(\d+)` — digits only.
pub fn read_uint(s: text, i: integer) -> Scan {
  rd_j = i;
  while is_digit(at(s, rd_j)) { rd_j += 1; }
  if rd_j == i { return scan_fail() }
  rd_v = s[i..rd_j] as integer?;
  if rd_v == null { return scan_fail() }
  Scan { ok: true, at: rd_j, value: (rd_v ?? 0) as float }
}

/// The first `\b<key>\s*=\s*` on the line, answering where its VALUE starts.
///
/// This is the shape every option regex in `draw.py` opens with, and the
/// word-boundary is load-bearing rather than decorative: without it `w=` would
/// also be found inside `w2=`, which is a different option with a different
/// meaning.  `key` must be lowercase; the match is not.
pub fn find_option(s: text, key: text) -> Scan {
  fo_n = size(s);
  fo_i = 0;
  while fo_i < fo_n {
    if word_boundary(s, fo_i) && matches_at(s, fo_i, key) {
      fo_j = skip_space(s, fo_i + size(key));
      if at(s, fo_j) == 61 {
        return Scan { ok: true, at: skip_space(s, fo_j + 1), value: 0.0 }
      }
    }
    fo_i += 1;
  }
  scan_fail()
}

/// The first occurrence of a lowercase `word` at or after `start`, ignoring case
/// — a regex literal with no boundary condition, which is what `Circle` and
/// `Line` open with.
///
/// The `start` is what makes a failed parse behave like `re.search`: a command
/// whose first occurrence does not parse is retried from the NEXT one rather
/// than given up on.
pub fn find_word_from(s: text, word: text, start: integer) -> Scan {
  fw_n = size(s);
  fw_i = if start < 0 { 0 } else { start };
  while fw_i < fw_n {
    if matches_at(s, fw_i, word) { return Scan { ok: true, at: fw_i + size(word), value: 0.0 } }
    fw_i += 1;
  }
  scan_fail()
}

tests/lock.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @FR-Oracle-Golden — the expected bytes in this file are the oracle's own.
// The brush stroke, against the picture the oracle produced and the shape the
// design claims.
//
// Two goldens are `draw.py`'s own bytes for a `Lock` — one on a transparent
// sprite, one curved on an opaque ground with its own shadow colour — so the
// port is held to the standard every other mark is: the corpus gate in
// miniature, needing no Python.  The shape tests are crawler's
// `tools/lock_probe.py` measurements made here on the same scenes: a lock is
// narrower where it grows than in its body, brighter on top than underneath,
// splits into its `tips`, and paints over the lock behind it.
use drawing::*;
use graphics::*;

const HEX_DIGITS = "0123456789abcdef";

fn byte_hex(v: integer) -> text {
  hi = (v >> 4) & 15;
  lo = v & 15;
  HEX_DIGITS[hi..hi + 1] + HEX_DIGITS[lo..lo + 1]
}

/// The canvas as Pillow lays an RGBA image out in memory: R, G, B, A per pixel.
fn rgba_hex(cv: const graphics::Canvas) -> text {
  h = "";
  for i in 0..(cv.width * cv.height) {
    c = cv.data[i]?;
    h += byte_hex(graphics::color_r(c)) + byte_hex(graphics::color_g(c))
       + byte_hex(graphics::color_b(c)) + byte_hex(graphics::color_a(c));
  }
  h
}

fn sprite_source() -> text {
  `
  size 24x16
  Background transparent
  Lock (0.1,0.5) (0.9,0.5) w0=1 w=6 tips=2 rgb=120,80,40
  `
}

fn test_a_lock_on_a_sprite_is_the_oracles() {
  sc = parse_scene(sprite_source());
  assert(len(sc.unparsed) == 0, "every line parsed");
  assert(len(sc.brushes) == 1, "the default hair brush was made once");
  cv = render(sc);
  assert(cv.width == 24 && cv.height == 16, "24x16 out");
  assert(rgba_hex(cv) ==
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000100000001ff000001000000010000000100000001000000000000000000000000" +
"00000000000000000000000155555503555555037f7f55067f7f55067f7f55067f7f55067f7f55067f7f5506bf7f7f04" +
"ffffff01ffffff0101010000000000000000000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000ffffff016f5f4f10695a4b11695a4b11695a4b11695a4b117755440f6b584e1a" +
"6e5e482e6b5a442d6e58482e6a58472b625541276650412367554b1b745f4a18735c51166d6d4807ff00000100000000" +
"000000007f7f7f067f6f5f107c6347527b62475d795f458b785f46907960458f7960458f785f4590795f468d785f4499" +
"775c43ad775e43ad775d43b1765e44b0775c43b6775c42b471553aad6e5135a26e51339d694a3129000000007f000002" +
"0000000071533590785838e1755536ff795837ff795836ff795836ff795836ff795836ff795836ff795836ff785736ff" +
"775635ff775635ff765635ff755534ff755534ff745434ff735332ff715131ff795633ff7053367abbaa880f99663305" +
"00000000472e179a4a3118f1493017ff4a3117ff4b3218ff4b3218ff4b3218ff4b3218ff4b3218ff4b3218ff4b3218ff" +
"4b3218ff4b3218ff4b3218ff4c3218ff4c3218ff4c3218ff4d3319ff50351aff4c3218ff5a4126ff664f35db5f3f1f10" +
"00000000170b00161c11052d2f21107b31220f86332310a7332210a9332210a9332210a9332210a9332210a9342210ab" +
"322211b0332211af332211af342211b0342311b9332211b1362412a83927139b3824128b3522107c34210d5c2a2a0006" +
"0000000000000000000000001e1e0f1124180c15261b0b2e251a0a30251a0a30251a0a30251a0a30251b0a2f28190a33" +
"271a0d3a281a0d39281a0d39271a0d3a26190c3c271a0d3a25170e3622180e3426190c3b241b091c0000000000000001" +
"0000000000000001000000010000000000000000555500033f3f00045555000355550003555500033f3f000455550003" +
"555500035555000355550003555500033f3f0004555500033f3f00043f3f00043f3f0004000000010000000100000000" +
"000000000000000000000000000000010000000100000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
    "the sprite's bytes are draw.py's");
}

fn ground_source() -> text {
  `
  size 20x12
  Background top=0.5 bottom=0.5
  Lock (0.1,0.3) (0.6,0.5)~ (0.9,0.9) w0=1 w=5 tips=2 rgb=200,200,210 dark=100,110,160
  `
}

fn test_a_curved_lock_on_a_ground_is_the_oracles() {
  sc = parse_scene(ground_source());
  assert(len(sc.unparsed) == 0, "every line parsed");
  cv = render(sc);
  assert(rgba_hex(cv) ==
"808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff" +
"808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff7f7f7fff7f7f7eff808080ff" +
"808080ff7f7f7fff818181ff818181ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff" +
"808080ff808080ff808080ff808080ff7f7f7fff8a8a8aff898989ff8a8a8cff878788ff858585ff838385ff838384ff" +
"828283ff808080ff7f7f7fff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff" +
"7e7e7eff90919affa3a4acffaeaeb5ffb3b3b9ffb2b2b9ffaeaeb4ffa7a7adffa6a6acff98989cff939397ff878788ff" +
"818181ff818181ff808080ff808080ff808080ff808080ff808080ff808080ff818180ff74767dff6e7180ff777989ff" +
"828491ff8d8f9bff9b9ba5ffa5a5adffaaaab2ffaeaeb5ffafafb7ffa6a6adff949499ff88878aff808081ff818181ff" +
"808080ff808080ff808080ff808080ff808080ff808080ff7c7d7eff6f717bff6a6d7bff686b7cff696c7dff6f7182ff" +
"777988ff82838fff90919affa0a0a8ffafafb7ffaaaab1ff909094ff828284ff818181ff808080ff808080ff808080ff" +
"808080ff808080ff818180ff808080ff7d7d7fff76777eff72747dff6e6f7bff696d7cff696d7dff6e7081ff797b89ff" +
"8c8d97ffa6a6aeffb2b2baff919196ff7f7f81ff818181ff808080ff808080ff808080ff808080ff808080ff808080ff" +
"818180ff818180ff808080ff7d7e7fff7b7c7fff75767dff70727dff6e707eff747684ff81828eff9999a1ffaaaab3ff" +
"8f8f94ff7f7f80ff818181ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff818180ff" +
"818180ff818180ff7f7f80ff787a7eff73757eff767884ff7f818cff96969effa7a7afff8d8e91ff7e7e7fff808080ff" +
"808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff818180ff" +
"7d7e7fff75777eff787984ff80818cff92929bffaaaab2ff8e8e92ff7f7f7fff808080ff808080ff808080ff808080ff" +
"808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff818180ff7f7f80ff76777dff797a83ff" +
"7e7f8aff92939dff929296ff7e7e7dff808080ff808080ff808080ff808080ff808080ff808080ff808080ff808080ff" +
"808080ff808080ff808080ff808080ff808080ff818180ff7f7f80ff78797eff787a81ff82838cff87878bff7f7f7fff",
    "the ground's bytes are draw.py's");
}

// ── The shape the design claims, measured ───────────────────────────

/// Differs from the mid-grey ground by more than 20 in some channel.
fn painted(cv: const graphics::Canvas, x: integer, y: integer) -> boolean {
  pc = cv.get_pixel(x, y);
  pr = graphics::color_r(pc) - 128;
  pg = graphics::color_g(pc) - 128;
  pb = graphics::color_b(pc) - 128;
  if pr < 0 { pr = -pr; }
  if pg < 0 { pg = -pg; }
  if pb < 0 { pb = -pb; }
  pr > 20 || pg > 20 || pb > 20
}

fn column_ink(cv: const graphics::Canvas, x: integer) -> integer {
  ci_n = 0;
  for y in 0..cv.height { if painted(cv, x, y) { ci_n += 1; } }
  ci_n
}

/// How many separate painted runs a column crosses.
fn runs_down(cv: const graphics::Canvas, x: integer) -> integer {
  rd_n = 0;
  rd_in = false;
  for y in 0..cv.height {
    rd_p = painted(cv, x, y);
    if rd_p && !rd_in { rd_n += 1; }
    rd_in = rd_p;
  }
  rd_n
}

fn lum(c: integer) -> float {
  0.299 * (graphics::color_r(c) as float) + 0.587 * (graphics::color_g(c) as float)
  + 0.114 * (graphics::color_b(c) as float)
}

/// Mean luminance of the PAINTED pixels in `[x0, x1) × [y0, y1)`.
fn band_lum(cv: const graphics::Canvas, x0: integer, x1: integer, y0: integer, y1: integer) -> float {
  bl_s = 0.0;
  bl_n = 0;
  for y in y0..y1 {
    for x in x0..x1 {
      if painted(cv, x, y) { bl_s += lum(cv.get_pixel(x, y)); bl_n += 1; }
    }
  }
  (bl_s / (bl_n as float)) ?? 0.0
}

struct Rgb {
  r: float = 0.0,
  g: float = 0.0,
  b: float = 0.0,
}

/// Mean colour of every pixel in `[x0, x1] × [y0, y1]`.
fn band_mean(cv: const graphics::Canvas, x0: integer, x1: integer, y0: integer, y1: integer) -> Rgb {
  bm_n = 0;
  bm_r = 0.0;
  bm_g = 0.0;
  bm_b = 0.0;
  for y in y0..=y1 {
    for x in x0..=x1 {
      bm_c = cv.get_pixel(x, y);
      bm_r += graphics::color_r(bm_c) as float;
      bm_g += graphics::color_g(bm_c) as float;
      bm_b += graphics::color_b(bm_c) as float;
      bm_n += 1;
    }
  }
  Rgb { r: (bm_r / (bm_n as float)) ?? 0.0, g: (bm_g / (bm_n as float)) ?? 0.0,
        b: (bm_b / (bm_n as float)) ?? 0.0 }
}

fn lock_source() -> text {
  `
  size 120x60
  Background top=0.5 bottom=0.5
  Lock (0.05,0.5) (0.95,0.5) w0=2 w=20 tips=3 tipvar=0 spread=0 rgb=120,80,40
  `
}

fn test_the_lock_has_the_shape_it_claims() {
  cv = render(parse_scene(lock_source()));
  // The lock runs x = 6..114; its body ends at t = 0.8 (x ≈ 92), the spikes after.
  root = column_ink(cv, 8);
  body = column_ink(cv, 60);
  assert(root * 2 < body, "narrower where it grows: root {root} px, body {body} px");
  top = band_lum(cv, 20, 80, 0, 30);
  bot = band_lum(cv, 20, 80, 31, 60);
  assert(top > bot + 20.0, "upper side brighter than the underside: {top} vs {bot}");
  assert(runs_down(cv, 94) == 1, "one body just past its end");
  assert(runs_down(cv, 105) == 3, "three spikes further out, got {runs_down(cv, 105)}");
  assert(!painted(cv, 118, 30), "nothing beyond the longest spike");
}

fn layers_source() -> text {
  `
  size 120x60
  Background top=0.5 bottom=0.5
  Lock (0.05,0.40) (0.95,0.40) w0=20 w=20 swell=0 body=1 tips=0 rgb=200,40,40
  Lock (0.05,0.60) (0.95,0.60) w0=20 w=20 swell=0 body=1 tips=0 rgb=40,40,200
  `
}

fn test_a_lock_paints_over_the_lock_behind_it() {
  cv = render(parse_scene(layers_source()));
  both = band_mean(cv, 40, 80, 27, 33);
  red_only = band_mean(cv, 40, 80, 18, 24);
  assert(both.b > both.r + 30.0, "the later (blue) lock covers the earlier where they overlap");
  assert(red_only.r > red_only.b + 30.0, "the earlier (red) lock is untouched where they do not");
}

fn test_a_lock_names_a_brush_that_exists_or_is_reported() {
  sc = parse_scene("size 8x8\nLock (0.1,0.5) (0.9,0.5) brush=nope\n"
                   + "Brush fur hair w=6 period=8\nLock (0.1,0.5) (0.9,0.5) brush=fur\n");
  assert(len(sc.unparsed) == 1, "the lock naming no brush is reported, got {len(sc.unparsed)}");
  assert(len(sc.ops) == 1, "the lock naming a brush is an op");
  assert(len(sc.brushes) == 1, "one brush defined");
  assert(sc.brushes[0]?.bname == "fur" && sc.brushes[0]?.bw == 6 && sc.brushes[0]?.bh == 8,
         "the brush is the one the line described");
}

tests/metric.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The metric channel: what a `check` measures, what it says, and when a scene
// counts as whole.
//
// Every expected line below is `draw.py`'s own output for the same source — the
// report is a wire format two tools have to agree on, so the strings are pinned
// rather than described.  The scene is the one @PLN146's `w5.sh` runs, at a size
// small enough that no test here draws anything.
use drawing::*;

fn source() -> text {
  `
  size 40x30
  Background transparent
  name a
  Poly (0.10,0.10) (0.30,0.10) (0.30,0.30) (0.10,0.30) rgb=200,10,10
  name b
  Poly (0.50,0.50) (0.90,0.50) (0.90,0.70) (0.50,0.70) rgb=10,200,10
  landmark mid = 0.5
  landmark low = 0.75
  check a.cx ~ 0.2
  check a.w ~ 0.2 tol 0.001
  check a.left < b.left
  check b.right > a.right
  check a.top <= 0.1
  check b.bottom >= mid
  check a.cy == 0.2
  check b.cx ~ mid + 0.2
  check b.cy ~ low - 0.15
  check a.h ~ 0.25
  `
}

fn test_every_operator_reaches_its_verdict() {
  sc = parse_scene(source());
  rs = eval_checks(sc);
  assert(len(rs) == 10, "ten checks, got {len(rs)}");
  for r in rs {
    if r.claim == "a.h ~ 0.25" { assert(!r.ok, "0.200 is not 0.250 within 0.02") }
    else { assert(r.ok, "{r.claim} should pass: {r.report}") }
  }
}

fn test_the_report_line_is_the_oracles() {
  sc = parse_scene(source());
  rs = eval_checks(sc);
  assert(rs[0]?.report == "check: a.cx ~ 0.2  ->  0.200 vs 0.200  d=+0.000  PASS",
         "got {rs[0]?.report}");
  assert(rs[9]?.report == "check: a.h ~ 0.25  ->  0.200 vs 0.250  d=-0.050  FAIL",
         "got {rs[9]?.report}");
  // The tolerance clause comes OFF the echoed claim, and a delta that is
  // negative but tiny still renders with its sign.
  assert(rs[1]?.report == "check: a.w ~ 0.2  ->  0.200 vs 0.200  d=-0.000  PASS",
         "got {rs[1]?.report}");
}

fn test_a_tolerance_clause_is_read() {
  sc = parse_scene("size 8x8\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\n"
                   + "check a.cx ~ 0.25 tol 0.001\ncheck a.cx ~ 0.25 tol 0.06\n");
  rs = eval_checks(sc);
  assert(rs[0]?.tol == 0.001 && !rs[0]?.ok, "0.2 is not 0.25 within 0.001");
  assert(rs[1]?.tol == 0.06 && rs[1]?.ok, "…and is within 0.06");
}

fn test_a_landmark_and_arithmetic_on_the_right() {
  sc = parse_scene(source());
  rs = eval_checks(sc);
  assert(rs[7]?.rhs == 0.7, "mid + 0.2 is 0.7, got {rs[7]?.rhs}");
  assert(rs[8]?.rhs == 0.6, "low - 0.15 is 0.6, got {rs[8]?.rhs}");
}

fn test_a_negative_literal_is_not_a_subtraction() {
  // `^(\S+)\s*([+-])\s*([\d.]+)$` needs a base on the left of the sign, so a
  // bare `-0.1` has none and stays a number.
  sc = parse_scene("size 8x8\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\n"
                   + "check a.cx > -0.1\n");
  rs = eval_checks(sc);
  assert(rs[0]?.ok && rs[0]?.rhs == -0.1, "got {rs[0]?.report}");
}

fn test_a_name_that_does_not_resolve_is_named() {
  sc = parse_scene("size 8x8\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\n"
                   + "name ghost\ncheck ghost.cx ~ 0.5\ncheck nobody.cx ~ 0.5\n"
                   + "check a.middle ~ 0.5\ncheck somewhere ~ 0.5\ncheck a.cx\n");
  rs = eval_checks(sc);
  assert(rs[0]?.err == "ghost?", "an element named and never drawn has no box");
  assert(rs[1]?.err == "nobody?", "got {rs[1]?.err}");
  assert(rs[2]?.err == "a.middle?", "the property is named too");
  assert(rs[3]?.err == "somewhere?", "got {rs[3]?.err}");
  assert(rs[4]?.err == "(unparsed)", "a line with no operator is not a check");
  assert(rs[4]?.report == "check: a.cx  (unparsed)", "got {rs[4]?.report}");
  for r in rs { assert(!r.ok, "an unresolved check never passes: {r.report}"); }
}

fn test_the_element_report_is_in_ink_order() {
  // `late` is NAMED first and INKED second: the oracle keys its table by name in
  // a dict, so its order is first-ink and not first-mention.
  sc = parse_scene("size 8x8\nname late\nname early\n"
                   + "Poly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\nname late\n"
                   + "Poly (0.5,0.5) (0.9,0.5) (0.9,0.7) rgb=1,2,3\n");
  rep = metric_report(sc, eval_checks(sc));
  early = rep.find("early");
  late = rep.find("  late");
  assert(early != null && late != null, "both elements are reported");
  assert((early ?? 0) < (late ?? 0), "early took ink first, so it is reported first");
}

fn test_a_scene_is_whole_only_when_nothing_is_missing() {
  ok = parse_scene("size 8x8\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\n"
                   + "check a.cx ~ 0.2\n");
  assert(scene_ok(ok, eval_checks(ok)), "every line parsed and the check passes");

  failed = parse_scene("size 8x8\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) rgb=1,2,3\n"
                       + "check a.cx ~ 0.9\n");
  assert(!scene_ok(failed, eval_checks(failed)), "a failing check is not whole");

  bad = parse_scene("size 8x8\nWobble (0.1,0.2) r=3\n");
  assert(!scene_ok(bad, eval_checks(bad)), "an unparsed line is not whole");

  short = parse_scene("size 8x8\nPetals (0.5,0.5) n=6 r=0.1 len=0.2 w=0.05\n");
  assert(!scene_ok(short, eval_checks(short)),
         "a mark this build does not draw leaves the picture short");
}

fn test_the_report_counts_what_passed() {
  sc = parse_scene(source());
  rep = metric_report(sc, eval_checks(sc));
  assert(rep.contains("CHECKS  9/10 pass:"), "got:\n{rep}");
  assert(rep.contains("  a        x[0.10..0.30] y[0.10..0.30]"), "the boxes are reported:\n{rep}");
  assert(rep.contains("ops: 2"), "and how many marks were drawn:\n{rep}");
}

fn test_an_unparsed_line_is_reported_before_the_geometry() {
  sc = parse_scene("size 8x8\nWobble (0.1,0.2) r=3\n");
  rep = metric_report(sc, eval_checks(sc));
  assert(rep.starts_with("UNPARSED 1 line(s)"), "got:\n{rep}");
  assert(rep.contains("line 2: Wobble (0.1,0.2) r=3"), "naming the line:\n{rep}");
}

tests/parse.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The line grammar: what each command produces, and what happens to a line no
// command accepts.
//
// The grammar is `draw.py`'s, down to its looseness — options are found ANYWHERE
// on the line, `Circle` is tried before `Poly`, and a line that matches nothing
// is reported rather than dropped.  These pin the parts of that a renderer can
// get wrong without the picture obviously breaking.
use drawing::*;
use graphics::*;

fn source() -> text {
  `
  size 256x200
  Background transparent
  name blade
  Poly (0.1,0.2) (0.9,0.2) (0.5,0.9) rgb=190,60,50
  Circle (0.5,0.5) r=0.25 n=12 flat=0.3 rgb=10,20,30
  Line (0.0,0.0) - (1.0,1.0) w=4
  Poly (0.1,0.1)@6 (0.9,0.9)@2 stroke=250,240,60
  Poly (0.2,0.2)~ (0.5,0.1)~ (0.8,0.2)~ w=2
  # a comment, and the next line is not one
  Wobble (0.1,0.2) r=3
  `
}

fn test_paper_and_background() {
  sc = parse_scene(source());
  assert(sc.sw == 256 && sc.sh == 200, "size WxH");
  assert(sc.transparent, "Background transparent");
}

fn test_a_line_no_command_accepts_is_reported() {
  sc = parse_scene(source());
  assert(len(sc.unparsed) == 1, "exactly one unparsed line, got {len(sc.unparsed)}");
  assert(sc.unparsed[0]?.contains("Wobble"), "the report names the line: {sc.unparsed[0]?}");
}

fn test_five_marks_five_ops() {
  sc = parse_scene(source());
  assert(len(sc.ops) == 5, "five marks, got {len(sc.ops)}");
  assert(sc.ops[0]?.kind == Fill, "a Poly with rgb= fills");
  assert(sc.ops[2]?.kind == Stroke, "a Line strokes");
}

fn test_rgb_is_a_fill_and_stroke_is_a_pen_colour() {
  sc = parse_scene(source());
  fill_c = sc.ops[0]?.paint.c1;
  assert(graphics::color_r(fill_c) == 190 && graphics::color_g(fill_c) == 60
         && graphics::color_b(fill_c) == 50, "rgb=190,60,50");
  assert(graphics::color_a(fill_c) == 255, "a fill colour is opaque");
  pen = sc.ops[3]?.color;
  assert(graphics::color_r(pen) == 250 && graphics::color_b(pen) == 60,
         "stroke=250,240,60 colours the pen, it does not fill");
  assert(sc.ops[3]?.paint.pk == Stroked, "…and leaves the mark unfilled");
}

fn test_an_uncoloured_mark_takes_the_default_ink() {
  sc = parse_scene(source());
  ink = sc.ops[2]?.color;
  assert(graphics::color_r(ink) == drawing::DEFAULT_INK_R
         && graphics::color_g(ink) == drawing::DEFAULT_INK_G
         && graphics::color_b(ink) == drawing::DEFAULT_INK_B, "the dark default ink");
}

fn test_circle_takes_its_segment_count_and_closes_itself() {
  sc = parse_scene(source());
  // `n=12` is twelve segments, so thirteen points: the first is repeated at the
  // end, which is how the outline closes without an extra edge.
  assert(len(sc.ops[1]?.pts) == 13, "n=12 gives 13 points, got {len(sc.ops[1]?.pts)}");
}

fn test_per_point_width_becomes_a_taper() {
  sc = parse_scene(source());
  w = sc.ops[3]?.widths;
  assert(len(w) == 2, "two points carry two widths, got {len(w)}");
  assert(w[0]? == 6.0 && w[1]? == 2.0, "@6 and @2");
}

fn test_a_smooth_point_expands_the_outline() {
  sc = parse_scene(source());
  // Three control points, all `~`, open: the first point plus ten samples per
  // segment.
  assert(len(sc.ops[4]?.pts) == 21, "3 smooth points expand to 21, got {len(sc.ops[4]?.pts)}");
  assert(len(sc.ops[0]?.pts) == 3, "a Poly with no `~` is left alone");
}

fn test_name_measures_the_marks_that_follow() {
  sc = parse_scene(source());
  assert(len(sc.elems) == 1, "one named element");
  e = sc.elems[0]?;
  assert(e.ename == "blade", "the name");
  assert(e.seen, "it has marks");
  assert(e.bx0 == 0.0 && e.bx1 == 1.0, "the box spans everything drawn after it");
}

fn test_a_gradient_fill_parses_with_its_own_spec() {
  sc = parse_scene("size 8x8\nCircle (0.5,0.5) r=0.3 radial=10,20,30>40,50,60 at=0.5,0.5,0.3\n");
  assert(len(sc.unparsed) == 0, "a radial fill is not a syntax problem");
  assert(len(sc.deferred) == 0, "…nor a deferred one: this build draws it");
  assert(sc.ops[0]?.paint.pk == Radial, "it parses as a radial");
  spec = sc.ops[0]?.paint.spec;
  assert(len(spec) == 3 && spec[0]? == 0.5 && spec[1]? == 0.5 && spec[2]? == 0.3,
         "and carries its at= centre and radius");
  assert(graphics::color_g(sc.ops[0]?.paint.c1) == 20
         && graphics::color_g(sc.ops[0]?.paint.c2) == 50, "both stops");
}

fn test_radial_beats_grad_beats_rgb() {
  // `_paint`'s order, which is not the order a reader would guess from the line:
  // the LAST-written option does not win, the most specific KIND does.
  sc = parse_scene("size 8x8\nPoly (0.1,0.1) (0.9,0.1) (0.5,0.9) rgb=1,2,3 grad=4,5,6>7,8,9 radial=10,11,12>13,14,15\n");
  assert(sc.ops[0]?.paint.pk == Radial, "radial= outranks grad= and rgb=");
  sc2 = parse_scene("size 8x8\nPoly (0.1,0.1) (0.9,0.1) (0.5,0.9) rgb=1,2,3 grad=4,5,6>7,8,9\n");
  assert(sc2.ops[0]?.paint.pk == Linear, "grad= outranks rgb=");
}

fn test_a_gradient_with_no_spec_falls_back_to_the_bounding_box() {
  sc = parse_scene("size 8x8\nPoly (0.1,0.1) (0.9,0.1) (0.5,0.9) grad=4,5,6>7,8,9\n");
  assert(sc.ops[0]?.paint.pk == Linear, "a linear fill");
  assert(len(sc.ops[0]?.paint.spec) == 0,
         "with no dir=, so the renderer derives a top-to-bottom axis from the shape");
}

fn test_fronds_draws_and_petals_is_recognised_not_misread() {
  // Without their own branch these fall through to `Line`, whose regex would
  // find the two coordinates on the line and draw a segment nobody wrote.
  // `Fronds` draws; `Petals` is recognised and listed, because no scene in the
  // corpus uses it — so its absence is a fact the caller has rather than one
  // they discover by eye.
  sc = parse_scene("size 8x8\nFronds (0.5,0.9)-(0.5,0.2) n=8 len=0.1\nPetals (0.5,0.5) n=6 r=0.1 len=0.2 w=0.05\n");
  assert(len(sc.ops) == 8, "the array is eight strokes, got {len(sc.ops)} op(s)");
  assert(len(sc.deferred) == 1 && sc.deferred[0]?.contains("Petals"),
         "Petals is listed, and Fronds is not");
  assert(len(sc.unparsed) == 0, "neither is a syntax problem");
}

fn test_a_bowed_frond_curves_and_composes() {
  // The bow was the one axis of `Fronds` nothing covered, and the one reported broken: it
  // panicked the interpreter with `index out of bounds: the len is 30 but the index is
  // 65535` while `mirror` and `depth` were fine without it. The cause was loft#1081 — a
  // vector-valued `if` expression leaking a store keyed with the unknown-type sentinel,
  // which in a program this size eventually reached an index — and it is fixed. This is
  // the guard that says so, and it asserts the bow WORKED rather than merely not crashing.
  plain = parse_scene("size 16x16\nFronds (0.5,0.9)-(0.5,0.2) n=3 len=0.2\n");
  bowed = parse_scene("size 16x16\nFronds (0.5,0.9)-(0.5,0.2) n=3 len=0.2 bow=0.4\n");
  assert(len(plain.ops) == 3 && len(bowed.ops) == 3, "three strokes either way");

  // A straight frond is a two-point segment; a bowed one is three control points smoothed
  // into a curve, so the point count is the bow being applied rather than parsed and lost.
  assert(len(plain.ops[0]?.pts) == 2, "a plain frond is a segment, got {len(plain.ops[0]?.pts)} pts");
  assert(len(bowed.ops[0]?.pts) == 21, "a bowed frond is a curve, got {len(bowed.ops[0]?.pts)} pts");

  // And the composition the bisect named: bow together with mirror and depth, which is
  // where the sentinel used to surface.
  both = parse_scene("size 16x16\nFronds (0.5,0.9)-(0.5,0.2) n=4 len=0.2 bow=0.4 mirror=1 depth=2\n");
  assert(len(both.ops) == 40, "bow + mirror + depth composes, got {len(both.ops)} op(s)");
  assert(len(both.unparsed) == 0, "and none of it is a syntax problem");
}

fn test_topc_is_not_top() {
  // `\btop\s*=` must not match inside `topc=`, or a colour ramp reads as a grey
  // one and the sky comes out monochrome.
  sc = parse_scene("size 8x8\nBackground topc=30,36,64 botc=92,80,104\n");
  assert(len(sc.ops) == 1 && sc.ops[0]?.kind == Sky, "one sky op");
  assert(graphics::color_b(sc.ops[0]?.color) == 64, "the top colour is the colour, not a grey");
  assert(graphics::color_r(sc.ops[0]?.color2) == 92, "and the bottom colour is read too");
}

fn test_grey_rounds_half_to_even() {
  // Python's `round`, which `draw.py` uses here.  `0.5 * 255` is 127.5 and the
  // even neighbour is 128 — one level, and a byte diff finds it.
  assert(graphics::color_r(drawing::grey(0.5)) == 128, "fill=0.5 is 128");
  assert(graphics::color_r(drawing::grey(0.0)) == 0, "fill=0 is black");
  assert(graphics::color_r(drawing::grey(1.0)) == 255, "fill=1 is white");
}

tests/raster_agreement.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Does the rasteriser still draw what Pillow draws?
//
// Every mask below came out of `ImageDraw` itself, so these are not a
// description of what this code does — they are the answer it has to reproduce.
// The two edge cases are deliberate: `axis_square` has horizontal edges, which
// Pillow draws as spans rather than as crossings, and `off_left` hangs off the
// canvas so its crossings go NEGATIVE, which is the only place the mirrored
// `ROUND_DOWN` is visible.
// ⚠ `use drawing;` must come BEFORE `use raster;`.  `raster` is a sibling module of
// this package, not a package of its own, so nothing resolves it until the package's
// entry file has been loaded and pulled it in.  Reversed, the CI gate
// (`loft --interpret --tests tests`) fails with "Library 'raster' not found" while
// `loft test` — which sets up the package context first — passes.
use graphics::*;
use drawing::*;
use raster::*;

const N = 16;

fn mask_of(cv: const graphics::Canvas) -> text {
  m = "";
  for i in 0..(N * N) {
    m += if ((cv.data[i]? >> 24) & 255) != 0 { "#" } else { "." };
  }
  m
}

fn ink() -> integer { graphics::rgba(200, 40, 30, 255) }

fn test_polygon_matches_pillow() {
  cv = graphics::canvas(N, N, 0);
  pts: vector<raster::Pt> = [raster::pt(2.5, 1.5), raster::pt(13.2, 3.9), raster::pt(7.1, 14.6)];
  raster::fill_poly(cv, pts, ink());
  assert(mask_of(cv) ==
"..................#...............######...........###########.." +
    "...##########.......#########.......########........########...." +
    ".....######..........######..........#####............####......" +
    "......###..............##..............#........................", "triangle");
}

fn test_horizontal_edges_are_spans() {
  cv = graphics::canvas(N, N, 0);
  pts: vector<raster::Pt> = [raster::pt(2.0, 2.0), raster::pt(13.0, 2.0),
                             raster::pt(13.0, 13.0), raster::pt(2.0, 13.0)];
  raster::fill_poly(cv, pts, ink());
  assert(mask_of(cv) ==
"..................................############....############.." +
    "..############....############....############....############.." +
    "..############....############....############....############.." +
    "..############....############..................................", "axis-aligned square");
}

fn test_even_odd_leaves_the_star_hollow() {
  cv = graphics::canvas(N, N, 0);
  pts: vector<raster::Pt> = [raster::pt(8.0, 0.5), raster::pt(10.4, 10.5), raster::pt(1.0, 4.5),
                             raster::pt(15.0, 4.5), raster::pt(5.6, 10.5)];
  raster::fill_poly(cv, pts, ink());
  assert(mask_of(cv) ==
"........#...............#..............##..............###......" +
    ".###############...####..#####......###..####.........#..##....." +
    "......#####..........###.##.........##....##...................." +
    "................................................................", "self-intersecting star");
}

fn test_negative_crossings_round_the_mirrored_way() {
  cv = graphics::canvas(N, N, 0);
  pts: vector<raster::Pt> = [raster::pt(-9.0, 2.0), raster::pt(6.0, 1.0),
                             raster::pt(4.0, 14.0), raster::pt(-6.0, 9.0)];
  raster::fill_poly(cv, pts, ink());
  assert(mask_of(cv) ==
"......................##........#######.........#######........." +
    "#######.........######..........######..........######.........." +
    "######..........######..........######..........#####..........." +
    "#####.............###...............#...........................", "polygon hanging off the left edge");
}

fn test_wide_line_is_a_filled_quad() {
  cv = graphics::canvas(N, N, 0);
  raster::wide_line(cv, 2.3, 3.1, 13.8, 12.4, ink(), 5);
  assert(mask_of(cv) ==
"...................#...............##.............####.........." +
    "..######.........########.........########.........########....." +
    ".....########.........########.........########.........######.." +
    "..........####.............##...............#...................", "width-5 segment");
}

fn test_thin_line_puts_its_last_pixel_back() {
  cv = graphics::canvas(N, N, 0);
  pts: vector<raster::Pt> = [raster::pt(1.0, 1.0), raster::pt(14.0, 4.0), raster::pt(3.0, 14.0)];
  raster::thin_line(cv, pts, ink());
  assert(mask_of(cv) ==
".................###................####................####...." +
    "............###..............#..............#..............#...." +
    "..........#.............##.............#..............#........." +
    ".....#..............#..............#............................", "thin polyline");
}

fn test_round_up_and_round_down_mirror_about_zero() {
  // The sign lives OUTSIDE the floor / ceil, so these are not `floor(f+0.5)` and
  // `ceil(f-0.5)`.  Getting this wrong loses a span that ends on column 0.
  assert(raster::round_up(0.5) == 1, "ROUND_UP(0.5)");
  assert(raster::round_up(-0.5) == -1, "ROUND_UP(-0.5)");
  assert(raster::round_down(0.5) == 0, "ROUND_DOWN(0.5)");
  assert(raster::round_down(-0.45) == 0, "ROUND_DOWN(-0.45) is 0, not -1");
  assert(raster::round_down(-1.5) == -1, "ROUND_DOWN(-1.5)");
}

tests/render.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @FR-Oracle-Golden — the expected bytes in this file are the oracle's own.
// The whole pipeline, against the picture the oracle actually produced.
//
// One 16x16 scene with a fill, a circle, a smooth stroke, a tapered stroke and a
// pen line — drawn at 3x and resampled down, exactly as `draw.py` does it.  The
// expected bytes below are `draw.py`'s own output for this source, so this test
// is the corpus gate in miniature: it needs no Python to run, and it fails the
// moment any stage of the pipeline stops agreeing.
use drawing::*;
use graphics::*;

const HEX_DIGITS = "0123456789abcdef";

fn byte_hex(v: integer) -> text {
  hi = (v >> 4) & 15;
  lo = v & 15;
  HEX_DIGITS[hi..hi + 1] + HEX_DIGITS[lo..lo + 1]
}

/// The canvas as Pillow lays an RGBA image out in memory: R, G, B, A per pixel.
fn rgba_hex(cv: const graphics::Canvas) -> text {
  h = "";
  for i in 0..(cv.width * cv.height) {
    c = cv.data[i]?;
    h += byte_hex(graphics::color_r(c)) + byte_hex(graphics::color_g(c))
       + byte_hex(graphics::color_b(c)) + byte_hex(graphics::color_a(c));
  }
  h
}

fn scene_source() -> text {
  `
  size 16x16
  Background transparent
  name body
  Poly (0.15,0.20) (0.80,0.25) (0.70,0.85) (0.20,0.80) rgb=190,60,50
  Circle (0.50,0.45) r=0.22 rgb=40,120,180
  name trim
  Poly (0.10,0.90)~ (0.50,0.55)~ (0.90,0.92)~ stroke=20,220,90 w=2
  Poly (0.20,0.30)@6 (0.85,0.40)@1 stroke=250,240,60
  Line (0.05,0.05) - (0.95,0.15) w=1
  `
}

fn test_the_rendered_picture_is_the_oracles() {
  sc = parse_scene(scene_source());
  assert(len(sc.unparsed) == 0, "every line parsed");
  assert(len(sc.deferred) == 0, "every mark is one this build draws");
  cv = render(sc);
  assert(cv.width == 16 && cv.height == 16, "16x16 out");
  assert(rgba_hex(cv) ==
"231f2341282226782521236c150d2260151523243a3427273f3f1f100000000000000000000000000000000000000000" +
    "00000000000000000000000000000000241d24232820256526212463564e28a22f2926a10d0722861d1623a72b26249a" +
    "2a25259b272024942821236428202566281f2438271f27202920291f1f1f1f08000000000100000000000000ffff3f95" +
    "ffff42d2ffff3f64c3be373300001815000000100b161e432427246120232366221f239c261f249a282026a4251e254b" +
    "7f3f3f0466663305bc423187fae73ee6fffe3fffffff41fffdf33df2ffff3aa0e6e65c53d8534d2e0400000007000000" +
    "ffff7f02020200000000000033333305bf7f3f0400000000c44d33b8ffee40fffbf43bf9f9ee3bfbfcf23dffffff3fff" +
    "fffd45fff4d93effdda437f2c75734f3b83231d9000000000000000101000000bf7f3f0400000000c34a328dfde43efe" +
    "f9f43cfbf7ed3efff6ee3dfef5ed3dfcf7ef3bfbf9f43dfeffff3bfffff53dfff0d03ae8f9ee3b5edfdf3f0800000001" +
    "ffbf3f0400000000f1cc394bffff41fffffa35fbfff833fffff834fffff636fffff735fffff734ffece742fef0e740fb" +
    "ffff3ef7f9ef3cc7ffff5506ffff7f02ffff3f0400000000f5e53c6ef3c53affbcc359fbb2ca67ff9eb773ff68a489ff" +
    "6fa687ff6e9791ff2c85b5fb6a728bffe04d1e63aaaa0003aa005503ffff0001bf3f3f0400000000c155385fd0342bff" +
    "754671fb117eb9ff0fba75ff09d162ff0aca68ff159e8eff1b7bbdfc85516ffad81d153c00000000ff55550300000000" +
    "bf3f3f0400000000b53a3457d73a32ffa95039fb1ad461ff0aef55ff10dc64ff10dd63ff0ceb55fe1ece65ffa2513aee" +
    "ff27000dffff0001ff00000100000000ff00000200ff0001d9312529af4e37f426cd56fd0fe456fd518d62fe546b89ff" +
    "506f89ff3fb053fe0aec5afe27cd56f0d05c2e0b00000000007f0002000000007f7f000200010000ff00000534bd52ed" +
    "00f762ff778145ffd52b2dffc3382dfcc4372ffbc8332efb39b851f904f461fd16db5aa000ff550300cc6605007f0002" +
    "000000000000000014dd5aa10ef463ff2bc554efba3e32efbe3c31f1c64136ffc84236ffcd3e35ffbf4a37ff3aba51ff" +
    "0ffb65ff18d6587100000000000000000fdf5f1014de5ba418ff69ff13dc5aa1728c4c14c6382a12b24c3314bc3b3251" +
    "c03b3256b7453355e0242a5b688d45750ce55cf316e45eff14db5a8a12d95e1b11dd5d1e13e05be914de5ba500000000" +
    "000000000000000000000000000000000000000000000000000000000000000017d6562c14e25cee14e25de213dd5a5a" +
    "0000000014d6511924b648070000000000ff7f02ff000001ff000001bf3f3f04bf3f3f04bf3f3f04bf3f3f0455aa5503" +
    "0000000015db552413d74e0d00000000", "the rendered pixels are the oracle's");
}

fn test_the_supersample_factor_is_part_of_the_contract() {
  // Not a preference: every golden in the corpus was drawn at this factor, and a
  // scene resampled from any other one is a different picture.
  assert(drawing::SUPERSAMPLE == 3, "3x");
}

fn sky_source() -> text {
  `
  size 12x12
  Background topc=30,36,64 botc=92,80,104
  name mass
  Poly (0.10,0.55) (0.90,0.60) (0.85,0.95) (0.15,0.90) fill=0.5
  name whisker
  Poly (0.15,0.20)~@5 (0.50,0.05)~@3 (0.85,0.25)~@1 stroke=230,210,120
  landmark horizon = 0.55
  `
}

fn test_a_sky_a_grey_fill_and_a_smooth_taper() {
  // The other half of the grammar: an opaque background (so the resample has no
  // alpha to work through), the grey `fill=L` spelling, a stroke whose width
  // varies AND whose points curve — the one path where the widths have to be
  // interpolated across the same expansion as the points — and a `landmark`,
  // which draws nothing and must still not read as a syntax problem.
  sc = parse_scene(sky_source());
  assert(len(sc.unparsed) == 0, "every line parsed");
  assert(!sc.transparent, "an opaque background");
  assert(len(sc.landmarks) == 1 && sc.landmarks[0]?.lname == "horizon", "the landmark is read");
  assert(sc.landmarks[0]?.lvalue == 0.55, "…with its value");
  cv = render(sc);
  assert(rgba_hex(cv) ==
"545250ffebd67affe5d177ffe6d278ffe6d278ffe3cf77ffe3cf77ffe5d178ff958c62ff222742ff1c2240ff202641ff" +
    "1d2242ffb3a66afff1dc7bffe3cf77ffe6d278fff2dc7bfff2dc7bfff0db7bfff9e37dffaea169ff232744ff252944ff" +
    "1f2245ff595654ffeed97affe6d278ffebd679ff958b64ff5f5b56ff81795fffd8c674ffffeb7fff7f775eff1c2045ff" +
    "31324cff262849ffb9ab6dfff9e27dff6e685aff1d1f46ff242648ff242748ff38384dff7e765eff4c4a52ff282b4aff" +
    "37364fff2c2c4dff66605aff7b735fff26264bff3a3850ff38374fff37364fff32314eff28294bff31304dff36354fff" +
    "393752ff373450ff312f4eff2d2b4dff383550ff36334fff393651ff393751ff3a3752ff3c3952ff3a3752ff3a3752ff" +
    "444058ff595766ff555263ff565464ff545263ff535162ff444158ff433f57ff434058ff434058ff433f57ff3f3c55ff" +
    "46415aff7a797cff868785ff848483ff848583ff848482ff7d7d7eff7c7c7dff7c7c7eff7d7d7eff79787bff49435cff" +
    "453e59ff6c6a73ff838382ff7e7e7eff7f7f7fff7f7f7fff818181ff818181ff808080ff848483ff737177ff49425bff" +
    "4c435eff6a6772ff848583ff808081ff818181ff818181ff7f7f80ff7f7f80ff7e7e7fff838382ff6f6c75ff4b425dff" +
    "524761ff61596bff7f8080ff7c7b7eff7d7c7eff7d7c7eff818281ff828281ff818181ff858683ff686370ff514661ff" +
    "5a4e66ff5b5067ff5d5268ff5c5268ff5c5168ff5d5269ff675f6fff686170ff675f6fff6b6472ff61586bff584c65ff", "the rendered pixels are the oracle's");
}

fn gradient_source() -> text {
  `
  size 14x14
  Background transparent
  name dome
  Circle (0.50,0.48) r=0.42 radial=214,208,198>180,174,162 at=0.50,0.40,0.46
  name banner
  Poly (0.08,0.06) (0.92,0.10) (0.90,0.34) (0.10,0.30) grad=30,36,64>92,80,104 dir=0.08,0.06,0.90,0.34
  name shadow
  Poly (0.20,0.70)~ (0.50,0.62)~ (0.80,0.72)~ (0.50,0.88)~ radial=40,120,180>10,20,40
  `
}

fn test_both_gradients_are_the_oracles() {
  // A radial with an explicit `at=` whose centre is OFF the shape's own centre, a
  // linear with an explicit `dir=`, and a radial with NEITHER — so the defaults
  // derived from the bounding box are exercised too, and the smooth outline means
  // the mask is not a tidy rectangle.
  //
  // The ramp is computed on a 100x100 grid and ENLARGED with a bicubic filter, so
  // these bytes carry that filter's ringing.  A gradient computed directly at
  // final size is smoother and wrong.
  sk = parse_scene(gradient_source());
  assert(len(sk.unparsed) == 0, "every line parsed");
  assert(len(sk.deferred) == 0, "nothing deferred — gradients draw now");
  assert(sk.ops[0]?.paint.pk == Radial, "an at= radial");
  assert(sk.ops[1]?.paint.pk == Linear, "a dir= linear");
  assert(len(sk.ops[2]?.paint.spec) == 0, "and a radial with no at=, so the bbox default runs");
  cv = render(sk);
  assert(rgba_hex(cv) ==
"172e450b1e2543221e2d4b11383855122d2d4b1182828229afaaa45addd7c65af8f1d127000000000000000000000000" +
    "000000000000000018243c15212541e6272a45eb2b2d48eb2f304aeb393951f64f4d63ff736e7bff686471f24a445bac" +
    "4c445da7514862a8554b649b5c45730b33333305252943c02c304dff2f304bff34344fff373651ff37354ffc36334efc" +
    "403a56ff4e4862ff534c66ff5b526cff574c65f75a4b6911000000001c203ea3212444fe242543fa282946fe2e2c49fe" +
    "33304cff393550ff3e3854fe423a56fb463e59f8524763fb544963a900000001000000007c7a7e87979395ff918e90fb" +
    "969294ff9c9899ff9c979aff756f7dff726d7aff756e7bff756e7afb817785ff6a6072a100000000b9a2a20bc1baadd4" +
    "cfc9baffcdc6b8fdd2ccbfffd8d1c4ffdcd6caffdcd6caffd7d1c3ffd1cbbeffcbc5b7fdccc7b8ffb9b3a7d5b9a2a20b" +
    "b8aaaa12b7b1a6eebfb9aeffc0baaffec4beb4ffc7c2b9ffcbc6bdffcbc6bdffc8c3baffc6c0b5ffc1bbb0fec0b9aeff" +
    "b9b2a7eeb8aaaa12b8aa9b12b7b1a5efbeb8acfec1bbb0fecec6b9ffd4cbbdffd6ccbeffd7cdbeffd1c9bcffc6c0b5ff" +
    "bdb8adfebeb8adfeb7b1a5efb8aa9b12aaaa7f06b4aea3bdc9c2b5ffc0b8acfb969999ff88939aff8f9ba2ff8b98a0ff" +
    "99a0a1ffc1baafffcbc3b4fbc8c1b4ffb4aea3bdaaaa7f0600000000ddd0c22692908bfb1b2e46fd0f3458fd104574ff" +
    "15568aff14558aff154976ff2e4c68fd58626dfda6a29afbc2bbae26000000005555aa03000000008e89837a495867ff" +
    "0e3760fc185182fa276ea5fb266ea5fb1b5383fa05305bfc1c324cff817f7d7a000000005555550300000000bf7f7f04" +
    "00000000d3c5b25d879094ff386489ff206296ff246497ff2b5b84ff546b7eff9c99945d000000007f7f7f0400000000" +
    "00000000000000005555550300000000dcc6b13bafada3706b8290a66e8491a69d9f9d70c6b9a83b0000000055555503" +
    "0000000000000000000000000000000000000000ffff7f02000000000000000000000000000000000000000000000000" +
    "007f7f02000000000000000000000000", "the rendered pixels are the oracle's");
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `drawing` (@PLN141): each `test_*` is a running demonstration of a
// contract the signatures cannot state, cited from the function it explains with
// `// Example: @DRW-00N`.
use drawing::*;
use graphics::*;

// @DRW-001 — coordinates are FRACTIONS of the paper, so `size` is the only pixel count in a
// scene.  The same marks at two sizes parse to the same points and measure the same element
// box; only the canvas they are drawn on changes.  A coordinate written as a pixel (`(16,16)`)
// is a point sixteen papers away.
fn test_coordinates_are_fractions_of_the_paper() {
  body = "Background transparent\nname sq\nPoly (0.25,0.25) (0.75,0.25) (0.75,0.75) (0.25,0.75) rgb=200,40,40";
  small = parse_scene("size 16x16\n" + body);
  large = parse_scene("size 32x32\n" + body);
  assert(small.ops[0].pts[0].ptx == 0.25 && large.ops[0].pts[0].ptx == 0.25, "the point is the fraction");
  assert(small.elems[0].bx1 == 0.75 && large.elems[0].bx1 == 0.75, "and so is the measured box");
  a = render(small);
  b = render(large);
  assert(a.width == 16 && b.width == 32, "only the canvas follows `size`");
  assert(graphics::color_a(a.get_pixel(8, 8)) == 255 && graphics::color_a(b.get_pixel(16, 16)) == 255, "the centre is inked at both");
  assert(graphics::color_a(a.get_pixel(1, 1)) == 0 && graphics::color_a(b.get_pixel(2, 2)) == 0, "the corner is clear at both");
}

// @DRW-002 — a scene that is short of a mark SAYS so.  A line no command accepts lands in
// `unparsed` (a typo reads as a syntax problem, not as a missing shape), and a mark this release
// recognises but does not draw lands in `deferred` — so the caller learns the picture is
// incomplete without comparing it by eye.
fn test_what_is_not_drawn_is_listed() {
  sk = parse_scene("size 8x8\nPloy (0.1,0.1) (0.9,0.1) (0.5,0.9) rgb=1,2,3\nPetals (0.5,0.5) n=6 r=0.1");
  assert(len(sk.ops) == 0, "nothing was drawn");
  assert(sk.unparsed == ["line 2: Ploy (0.1,0.1) (0.9,0.1) (0.5,0.9) rgb=1,2,3"], "the typo, with its line");
  assert(sk.deferred == ["line 3: Petals"], "the mark this release does not draw");
}

// @DRW-003 — `name` tags EVERY mark after it until the next `name`, and an element named but
// never drawn is an absence (`seen` false), not a box at the origin.  A `check` measures those
// boxes, so a mark left under the wrong name moves the measurement, not the picture.
fn test_a_name_tags_what_follows_it() {
  sk = parse_scene(
    "size 40x40\nname a\nPoly (0.1,0.1) (0.3,0.1) (0.3,0.3) (0.1,0.3) rgb=1,1,1\n" +
    "Poly (0.5,0.5) (0.7,0.5) (0.7,0.7) (0.5,0.7) rgb=2,2,2\nname ghost\ncheck a.w ~ 0.6");
  for e in sk.elems {
    if e.ename == "a" { assert(e.seen && e.bx0 == 0.1 && e.bx1 == 0.7, "both polys widen a's box"); }
    if e.ename == "ghost" { assert(!e.seen, "named, never drawn: no box"); }
  }
  rs = eval_checks(sk);
  assert(len(rs) == 1 && rs[0].ok, "a.w is 0.6: 0.1 to 0.7");
}