graphics source

← graphics · API reference · version 0.9.12

19 file(s), 3801 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
rgba fn49GFX-00158 173 510 511 1458 236 … (14 total)
rgb fn57GFX-00115 16 17 18 19 23 … (52 total)
color_r fn63—169 501 1446 206 225 8 … (10 total)
color_g fn65—170 502 1447 207 226 9 … (9 total)
color_b fn67—171 503 1448 208 227 10 … (8 total)
color_a fn70—165 172 11 16 259 47 … (8 total)
Canvas struct77—117 120 128 144 155 179 … (31 total)
width const78—120 129 132 145 146 180 … (50 total)
height const79—120 129 145 180 197 215 … (46 total)
data const80—120 133 147 156 158 182 … (78 total)
canvas fn117—809 844 1745 22 23 32 … (60 total)
get_pixel fn128GFX-00335 36 37 38 77 78 … (80 total)
set_pixel fn144GFX-003268 293 294 295 296 297 … (24 total)
clear fn155—44
blend fn164—184 52 59 66 68 69 … (10 total)
blend_pixel fn179GFX-002513 514 516 517 1458 83 … (7 total)
fill_rect fn193GFX-00276 118 88 89
hline fn214GFX-004249 250 318 319 320 321 … (21 total)
vline fn231GFX-004251 252 96 97 98 139 … (8 total)
draw_rect fn248GFX-004104 153
draw_line fn259—442 129 138 147 156
draw_circle fn286—166
fill_circle fn312—182
fill_ellipse fn333—206
draw_ellipse fn367—194
draw_bezier fn413—221 231 248
draw_aa_line fn472—257 266 277
fill_triangle fn527—287 302 320 342 354 93 … (7 total)
Coord struct580—600 639 22 23 24 37 … (23 total)
fill_polygon fn600GFX-00625 42 56 71 97 102 … (8 total)
resize_lanczos fn782GFX-00736 45 54 63 72 81 … (10 total)
resize_bicubic fn797—40 50 60 70 80 90 … (9 total)
save_png fn886GFX-001171 176 181
gl_screenshot fn893—none in this package
gl_create_window fn901—133
gl_create_fullscreen_window fn908—none in this package
gl_set_fullscreen fn916—none in this package
gl_window_width fn922—149
gl_window_height fn925—149
gl_poll_events fn929—185
gl_swap_buffers fn933—295
gl_clear fn937—236 252
gl_destroy_window fn941—315
gl_create_shader fn945—1546 1653 1654 136 137
gl_use_shader fn949—1572 1706 1728 237 253
gl_draw fn953—1580 1710 1732 245 282
gl_upload_vertices fn959—1042 1660 167
gl_upload_instance_buffer fn972—none in this package
gl_instance_attrib fn979—none in this package
gl_draw_instanced fn983—none in this package
gl_update_buffer fn991—none in this package
gl_upload_indices fn996—none in this package
GroupVboSet struct1023—1030 1031 1038 1057 1064
group_vbos_new fn1030—9
group_vbos_upsert fn1038—none in this package
group_vbos_draw_all fn1057—none in this package
group_vbos_destroy fn1064—none in this package
gl_set_uniform_mat4 fn1071—1579 1707 1731 238 242 254 … (9 total)
gl_set_uniform_float fn1077—1575 1576 1577 1578 1709 280 … (7 total)
gl_set_uniform_int fn1081—1574 1730 264 265 268
gl_set_uniform_vec2 fn1085—none in this package
gl_set_uniform_vec3 fn1089—1708 257 258 259 260 262 … (7 total)
gl_set_uniform_vec4 fn1093—none in this package
GL_DEPTH_TEST const1099—12
GL_BLEND const1100—12
GL_CULL_FACE const1101—none in this package
GL_SCISSOR_TEST const1102—none in this package
gl_enable fn1105—none in this package
gl_disable fn1109—none in this package
BLEND_ZERO const1113—12
BLEND_ONE const1114—12
BLEND_SRC_ALPHA const1115—none in this package
BLEND_ONE_MINUS_SRC_ALPHA const1116—none in this package
BLEND_DST_ALPHA const1117—none in this package
BLEND_ONE_MINUS_DST_ALPHA const1118—none in this package
gl_blend_func fn1121—none in this package
gl_cull_face fn1125—none in this package
gl_depth_mask fn1129—none in this package
gl_finish fn1138—none in this package
gl_viewport fn1142—234 249
gl_scissor fn1148—none in this package
gl_create_framebuffer fn1154—140
gl_bind_framebuffer fn1158—235 248
gl_framebuffer_texture fn1162—141
gl_create_depth_texture fn1166—139
gl_create_color_texture fn1170—none in this package
gl_texture_filter fn1174—none in this package
gl_generate_mipmap fn1179—none in this package
gl_texture_subimage fn1184—none in this package
gl_draw_fullscreen_quad fn1189—none in this package
DRAW_TRIANGLES const1195—1026 9 13
DRAW_LINES const1196—1026 13
DRAW_POINTS const1197—none in this package
gl_draw_mode fn1200—1059
gl_draw_elements fn1204—none in this package
gl_line_width fn1208—none in this package
gl_point_size fn1212—none in this package
KEY_W const1218—none in this package
KEY_A const1219—11
KEY_S const1220—none in this package
KEY_D const1221—none in this package
KEY_Q const1222—none in this package
KEY_E const1223—none in this package
KEY_SPACE const1224—11
KEY_ESCAPE const1225—none in this package
KEY_UP const1226—none in this package
KEY_DOWN const1227—none in this package
KEY_LEFT const1228—11
KEY_RIGHT const1229—none in this package
KEY_SHIFT const1230—none in this package
KEY_CTRL const1231—none in this package
KEY_0 const1235—none in this package
KEY_1 const1236—none in this package
KEY_2 const1237—none in this package
KEY_3 const1238—none in this package
KEY_4 const1239—none in this package
KEY_5 const1240—none in this package
KEY_6 const1241—none in this package
KEY_7 const1242—none in this package
KEY_8 const1243—none in this package
KEY_9 const1244—none in this package
KEY_O const1248—none in this package
KEY_R const1249—none in this package
KEY_F const1250—none in this package
KEY_Z const1251—none in this package
KEY_Y const1252—none in this package
KEY_LBRACKET const1255—none in this package
KEY_RBRACKET const1256—none in this package
KEY_TAB const1259—none in this package
KEY_F1 const1264—11
KEY_F2 const1265—none in this package
KEY_F3 const1266—none in this package
KEY_F4 const1267—none in this package
KEY_F5 const1268—none in this package
KEY_F6 const1269—none in this package
KEY_F7 const1270—none in this package
KEY_F8 const1271—none in this package
KEY_F9 const1272—none in this package
KEY_F10 const1273—none in this package
KEY_F11 const1274—none in this package
KEY_F12 const1275—11
gl_key_pressed fn1278—none in this package
gl_mouse_x fn1282—none in this package
gl_mouse_y fn1286—none in this package
gl_mouse_button fn1290—none in this package
gl_mouse_wheel fn1297—none in this package
EV_NONE const1319—13 14
EV_KEY_DOWN const1320—14
EV_KEY_UP const1321—none in this package
EV_TEXT const1322—21
EV_MOUSE_DOWN const1323—none in this package
EV_MOUSE_UP const1324—none in this package
EV_MOUSE_MOVE const1325—none in this package
EV_WHEEL const1326—none in this package
EV_TOUCH_BEGIN const1327—none in this package
EV_TOUCH_MOVE const1328—none in this package
EV_TOUCH_END const1329—none in this package
EV_RESIZE const1330—22
MOD_SHIFT const1333—14
MOD_CTRL const1334—23 14
MOD_ALT const1335—none in this package
MOD_SUPER const1336—none in this package
KEY_ENTER const1339—25
KEY_BACKSPACE const1340—none in this package
KEY_DELETE const1341—none in this package
KEY_INSERT const1342—none in this package
KEY_HOME const1343—24
KEY_END const1344—none in this package
KEY_PAGEUP const1345—none in this package
KEY_PAGEDOWN const1346—none in this package
gl_next_event fn1350—13
gl_event_key fn1354—14
gl_event_mods fn1358—15
gl_event_repeat fn1362—none in this package
gl_event_text fn1366—16
gl_event_x fn1370—17
gl_event_y fn1374—none in this package
gl_event_button fn1378—none in this package
gl_event_wheel fn1382—18
gl_event_touch_id fn1386—none in this package
gl_load_font fn1406—12 10 9
gl_measure_text fn1414—1440 1742 14 15 16
gl_text_height fn1418—1441 1743 13 14
gl_font_ascent fn1425—17 18 18
draw_text fn1438—1746
gl_load_texture fn1468—none in this package
gl_upload_canvas fn1473—1547 1747
gl_bind_texture fn1477—1573 1729 267
gl_delete_texture fn1481—313
gl_delete_shader fn1487—311 312
gl_delete_vao fn1491—1041 1065 309
gl_delete_framebuffer fn1495—314
SpriteSheet struct1532—1545 1548 1562 1716 12
ss_tex const1533—1547 1549 1573 12
create_sprite_sheet fn1545—none in this package
draw_sprite fn1562—1719
Painter2D struct1641—1652 1669 1679 1703 1716 1725 … (8 total)
create_painter_2d fn1652—none in this package
painter_vao fn1679—none in this package
draw_rect_at fn1703—none in this package
draw_sprite_at fn1716—none in this package
draw_texture_at fn1725—none in this package
create_text_texture fn1740—none in this package
audio_load fn1752—none in this package
audio_play fn1767—none in this package
audio_stop fn1771—none in this package
audio_stop_all fn1774—none in this package
audio_set_volume fn1777—none in this package
audio_set_pan fn1784—none in this package
audio_seek fn1791—none in this package
audio_play_raw fn1795—1839 1852 1865 1878
sfx_beep fn1832—1883
sfx_descend fn1843—none in this package
sfx_chirp fn1856—none in this package
sfx_noise fn1869—none in this package
sfx_bounce fn1882—none in this package
Renderer struct116—132 134 142 158 163 183 … (9 total)
create_renderer fn132—none in this package
elapsed fn158—none in this package
upload_scene fn163—125 126 186
render_frame_no_swap fn183—294
render_frame fn292—302
render_loop fn300—none in this package
destroy fn307—none in this package

src/graphics.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// graphics — a 2-D pixel canvas, and the window/GPU layer under it.
//
// TWO HALVES, and which one you want decides everything else.  The `Canvas` half
// is a flat vector of packed RGBA integers: it needs no window, no driver and no
// GL context, so it runs under `loft test` and answers questions about pixels.
// The `gl_*` half is the real window — shaders, VBOs, framebuffers — and every
// one of those is a native call that answers *"native function not loaded"* where
// there is no context.  Write the drawing against `Canvas` and it stays testable.
//
// ── Using it ─────────────────────────────────────────────────────────────────
//
//     cv = graphics::canvas(64, 48, graphics::rgb(20, 20, 30));
//     cv.fill_rect(8, 8, 20, 12, graphics::rgb(200, 60, 40));
//     cv.draw_line(0, 0, 63, 47, graphics::rgb(255, 255, 255));
//
//     // Alpha COMPOSITES; `set_pixel` would have replaced instead.
//     cv.blend_pixel(10, 10, graphics::rgba(0, 0, 255, 128));
//     px = cv.get_pixel(10, 10);          // -> r 99, g 29, b 147, a 255:
//                                         //    half the red rect, half the blue
//     cv.save_png("out.png");
//
// ⚠ COLOUR IS 0xAARRGGBB, so a bare `0xFF0000` literal is red at alpha ZERO and
// composites to nothing.  `rgb()` is the same pack with alpha 255 — reach for it
// rather than for a literal, and `color_r`/`_g`/`_b`/`_a` take the channels back
// out.
//
// The rest of the canvas surface is more primitives at the same level: lines,
// rects, circles, ellipses, beziers, an anti-aliased line, triangles and
// polygons, plus two resamplers (`resize_lanczos`, `resize_bicubic`).  The `gl_*`
// surface below them mirrors the OpenGL calls it wraps, and `stage` is the
// retained scene that drives either half.

use mesh3d::*;
use glb::*;

// RGBA color packed as a single integer: 0xAARRGGBB.
// Alpha: 255 = fully opaque, 0 = fully transparent.
// Use rgba() to construct and r()/g()/b()/a() to extract channels.

// Pack RGBA channels into a single integer using bitwise ops.
// Layout: bits 24..31 = alpha, 16..23 = red, 8..15 = green, 0..7 = blue.
// Loft integers are 64-bit signed post-2c, so the packed value fits
// directly — no widening cast needed.  Each channel is MASKED to 8 bits, so an
// out-of-range argument wraps rather than saturating or being refused.
// Example: @GFX-001 — the alpha byte is the one a hand-written hex literal
// leaves at zero, and a zero alpha is fully transparent.
pub fn rgba(cr: integer, cg: integer, cb: integer, ca: integer) -> integer {
  ((ca & 255) << 24) | ((cr & 255) << 16) | ((cg & 255) << 8) | (cb & 255)
}

// Opaque colour: the same pack with alpha 255.  `rgb(255, 0, 0)` is 0xFFFF0000 —
// NOT the 0xFF0000 a reader reaches for, which has alpha 0.
// Example: @GFX-001 — why the literal and this call are different colours, and
// what each compositing path does with the difference.
pub fn rgb(cr: integer, cg: integer, cb: integer) -> integer {
  rgba(cr, cg, cb, 255)
}

// The channels back out of a packed colour, matching `rgba`'s layout — red,
// green, blue, then alpha off the high byte.
pub fn color_r(color: integer) -> integer { (color >> 16) & 255 }
// Green.
pub fn color_g(color: integer) -> integer { (color >> 8) & 255 }
// Blue.
pub fn color_b(color: integer) -> integer { color & 255 }
// Alpha.  255 is opaque; a colour written as a bare 0xRRGGBB literal reads 0
// here, which is what makes it composite to nothing.
pub fn color_a(color: integer) -> integer {
  // integer is i64 post-2c, so the shift is logical on the high byte.
  ((color & 0xFF000000) >> 24) & 255
}

// A 2D pixel canvas backed by a flat vector of RGBA integers.
// Pixels are stored row-major: data[y * width + x].
pub struct Canvas {
  pub const width: integer,
  pub const height: integer,
  pub const data: vector<integer>
}

// `count` copies of `value`, built by doubling rather than one element
// at a time.
//
// A comprehension — `[for _ in 0..count { value }]` — emits three
// bytecodes per element (allocate the slot, write it, bump the length),
// so a 960x720 pixel buffer costs about 2.07 million dispatches.
// Appending a vector to ITSELF is a single block copy, so doubling
// reaches `count` in roughly log2(count) of them.
//
// Walking the bits of `count` from the top — double, then append one
// element where that bit is set — lands on `count` EXACTLY, which is
// what makes this usable: no overshoot to trim, and no tail loop.
// A prefix-slice variant was measured too and is both slower (51 ms)
// and wrong at count = 0.
//
// Measured on the interpreter, count = 691 200 (one 960x720 frame):
// 177 ms -> 10 ms, a factor of 17.  Sizes 0, 1, 9 216, 65 537 and
// 691 200 all match the comprehension element for element.
fn filled(count: integer, value: integer) -> vector<integer> {
  fi_out: vector<integer> = [];
  if count <= 0 { return fi_out; }
  // Highest set bit of `count`.
  fi_top = 0;
  while (count >> (fi_top + 1)) != 0 { fi_top = fi_top + 1; }
  fi_i = fi_top;
  while fi_i >= 0 {
    fi_out += fi_out;
    if ((count >> fi_i) & 1) == 1 { fi_out += [value]; }
    fi_i = fi_i - 1;
  }
  fi_out
}

// Create a canvas filled with a solid color.
pub fn canvas(cw: integer, ch: integer, fill_color: integer) -> Canvas {
  // Build `data` inside the literal so the const field is set once at
  // construction (was a post-literal `result.data = …` fill).
  Canvas { width: cw, height: ch, data: filled(cw * ch, fill_color) }
}

// Get pixel color at (x, y). Returns 0 if out of bounds — and 0 is a COLOUR the
// canvas can hold (`rgba(0, 0, 0, 0)`), so the answer cannot serve as a bounds
// test.  Bound with `width`/`height`.
// Example: @GFX-003 — a neighbourhood scan that tests the returned colour
// miscounts its own transparent pixels as off-canvas.
pub fn get_pixel(self: const Canvas, gx: integer, gy: integer) -> integer {
  if gx < 0 or gx >= self.width or gy < 0 or gy >= self.height {
    0
  } else {
    gp_idx = gy * self.width + gx;
    gp_d = &self.data;
    if gp_idx < len(gp_d) { gp_d[gp_idx] ?? 0 } else { 0 }
  }
}

// Set pixel color at (x, y). No-op if out of bounds.  STORES the colour byte for
// byte, alpha included — it does not composite; `blend_pixel` does.
// Example: @GFX-002 — storing a half-transparent colour replaces what was under
// it, rather than showing through it.
// Example: @GFX-003 — an out-of-range coordinate is declined in silence, so a
// wrong index is a missing part of the drawing, never a message.
pub fn set_pixel(self: Canvas, sx: integer, sy: integer, color: integer) {
  if sx >= 0 and sx < self.width and sy >= 0 and sy < self.height {
    sp_idx = sy * self.width + sx;
    sp_d = &self.data;
    if sp_idx < len(sp_d) {
      sp_d[sp_idx] = color;
    }
  }
}

// Fill the entire canvas with a solid color.
pub fn clear(self: Canvas, color: integer) {
  cl_n = self.data.len();
  for cl_i in 0..cl_n {
    self.data[cl_i] = color;
  }
}

// Alpha-blend src over dst. Both are 0xAARRGGBB packed integers.
// Uses standard "src over" compositing: out = src * alpha + dst * (1 - alpha).
pub fn blend(dst: integer, src: integer) -> integer {
  sa = color_a(src);
  if sa == 255 { return src; }
  if sa == 0 { return dst; }
  inv = 255 - sa;
  br = (color_r(src) * sa + color_r(dst) * inv) / 255;
  bg = (color_g(src) * sa + color_g(dst) * inv) / 255;
  bb = (color_b(src) * sa + color_b(dst) * inv) / 255;
  ba = sa + (color_a(dst) * inv) / 255;
  rgba(br, bg, bb, ba)
}

// Draw a pixel with alpha blending at (x, y) — the compositing counterpart to
// `set_pixel`, and the one a caller reaching for alpha usually meant.
// Example: @GFX-002 — store versus composite, side by side.
pub fn blend_pixel(self: Canvas, bx: integer, by: integer, color: integer) {
  if bx >= 0 and bx < self.width and by >= 0 and by < self.height {
    bp_idx = by * self.width + bx;
    bp_d = &self.data;
    if bp_idx < len(bp_d) {
      bp_d[bp_idx] = blend(bp_d[bp_idx] ?? 0, color);
    }
  }
}

// Fill a rectangle with a solid color. Clipped to canvas bounds.  Like every
// solid primitive here it STORES the colour (see `set_pixel`); a rectangle drawn
// in a half-transparent colour is not a translucent overlay.
// Example: @GFX-002 — what the buffer holds afterwards.
pub fn fill_rect(self: Canvas, rx: integer, ry: integer, rw: integer, rh: integer, color: integer) {
  x0 = if rx < 0 { 0 } else { rx };
  y0 = if ry < 0 { 0 } else { ry };
  x1 = if rx + rw > self.width { self.width } else { rx + rw };
  y1 = if ry + rh > self.height { self.height } else { ry + rh };
  fr_d = &self.data;
  fr_n = len(fr_d);
  for fr_y in y0..y1 {
    for fr_x in x0..x1 {
      fr_idx = fr_y * self.width + fr_x;
      if fr_idx < fr_n {
        fr_d[fr_idx] = color;
      }
    }
  }
}

// Draw a horizontal line from (x0, y) to (x1, y). Clipped.  The end is
// EXCLUSIVE — `[x0, x1)` — and a reversed span draws nothing rather than being
// normalised.
// Example: @GFX-004 — where a span starts and stops, and what silence looks like.
pub fn hline(self: Canvas, hx0: integer, hx1: integer, hy: integer, color: integer) {
  if hy < 0 or hy >= self.height { return; }
  left = if hx0 < 0 { 0 } else { hx0 };
  right = if hx1 > self.width { self.width } else { hx1 };
  hl_d = &self.data;
  hl_n = len(hl_d);
  for hl_x in left..right {
    hl_idx = hy * self.width + hl_x;
    if hl_idx < hl_n {
      hl_d[hl_idx] = color;
    }
  }
}

// Draw a vertical line from (x, y0) to (x, y1). Clipped.  Exclusive end and
// no normalisation, exactly like `hline`.
// Example: @GFX-004 — the half-open span rule on both axes.
pub fn vline(self: Canvas, vx: integer, vy0: integer, vy1: integer, color: integer) {
  if vx < 0 or vx >= self.width { return; }
  top = if vy0 < 0 { 0 } else { vy0 };
  bottom = if vy1 > self.height { self.height } else { vy1 };
  vl_d = &self.data;
  vl_n = len(vl_d);
  for vl_y in top..bottom {
    vl_idx = vl_y * self.width + vx;
    if vl_idx < vl_n {
      vl_d[vl_idx] = color;
    }
  }
}

// Draw a rectangle outline (1px border). Clipped.  Built from four half-open
// spans, so (x, y, w, h) covers x .. x + w - 1 by y .. y + h - 1.
// Example: @GFX-004 — the exact pixels, and the hollow interior.
pub fn draw_rect(self: Canvas, drx: integer, dry: integer, drw: integer, drh: integer, color: integer) {
  self.hline(drx, drx + drw, dry, color);
  self.hline(drx, drx + drw, dry + drh - 1, color);
  self.vline(drx, dry, dry + drh, color);
  self.vline(drx + drw - 1, dry, dry + drh, color);
}

// --- GL2.2: Bresenham line drawing ---

// Draw a line from (x0, y0) to (x1, y1) using Bresenham's algorithm.
// Handles all octants. Each pixel is set (no blending).
pub fn draw_line(self: Canvas, lx0: integer, ly0: integer, lx1: integer, ly1: integer, color: integer) {
  dx = if lx1 > lx0 { lx1 - lx0 } else { lx0 - lx1 };
  dy = if ly1 > ly0 { ly1 - ly0 } else { ly0 - ly1 };
  sx = if lx0 < lx1 { 1 } else { -1 };
  sy = if ly0 < ly1 { 1 } else { -1 };
  err = dx - dy;
  cx = lx0;
  cy = ly0;
  for _ in 0..dx + dy + 1 {
    self.set_pixel(cx, cy, color);
    if cx == lx1 and cy == ly1 { break; }
    e2 = 2 * err;
    if e2 > -dy {
      err -= dy;
      cx += sx;
    }
    if e2 < dx {
      err += dx;
      cy += sy;
    }
  }
}

// --- GL2.3: Circle and ellipse ---

// Draw a circle outline using the midpoint algorithm.
// Center at (cx, cy) with the given radius. Clipped via set_pixel.
pub fn draw_circle(self: Canvas, ccx: integer, ccy: integer, radius: integer, color: integer) {
  cx = 0;
  cy = radius;
  d = 1 - radius;
  for _ in 0..radius * 4 + 4 {
    if cx > cy { break; }
    // Draw all 8 octant-symmetric points.
    self.set_pixel(ccx + cx, ccy + cy, color);
    self.set_pixel(ccx - cx, ccy + cy, color);
    self.set_pixel(ccx + cx, ccy - cy, color);
    self.set_pixel(ccx - cx, ccy - cy, color);
    self.set_pixel(ccx + cy, ccy + cx, color);
    self.set_pixel(ccx - cy, ccy + cx, color);
    self.set_pixel(ccx + cy, ccy - cx, color);
    self.set_pixel(ccx - cy, ccy - cx, color);
    if d < 0 {
      d += 2 * cx + 3;
    } else {
      d += 2 * (cx - cy) + 5;
      cy -= 1;
    }
    cx += 1;
  }
}

// Fill a circle (solid disk). Center at (cx, cy), given radius.
pub fn fill_circle(self: Canvas, fcx: integer, fcy: integer, radius: integer, color: integer) {
  cx = 0;
  cy = radius;
  d = 1 - radius;
  for _ in 0..radius * 4 + 4 {
    if cx > cy { break; }
    self.hline(fcx - cx, fcx + cx + 1, fcy + cy, color);
    self.hline(fcx - cx, fcx + cx + 1, fcy - cy, color);
    self.hline(fcx - cy, fcx + cy + 1, fcy + cx, color);
    self.hline(fcx - cy, fcx + cy + 1, fcy - cx, color);
    if d < 0 {
      d += 2 * cx + 3;
    } else {
      d += 2 * (cx - cy) + 5;
      cy -= 1;
    }
    cx += 1;
  }
}

// Fill an axis-aligned ellipse (solid). Center (cx, cy), radii (rx, ry).
pub fn fill_ellipse(self: Canvas, fex: integer, fey: integer, ferx: integer, fery: integer, color: integer) {
  fe_x = 0;
  fe_y = fery;
  fe_rx2 = ferx * ferx;
  fe_ry2 = fery * fery;
  fe_d1 = fe_ry2 - fe_rx2 * fery + fe_rx2 / 4;
  for _ in 0..ferx + fery + 1 {
    if fe_ry2 * fe_x > fe_rx2 * fe_y { break; }
    self.hline(fex - fe_x, fex + fe_x + 1, fey + fe_y, color);
    self.hline(fex - fe_x, fex + fe_x + 1, fey - fe_y, color);
    if fe_d1 < 0 {
      fe_d1 += fe_ry2 * (2 * fe_x + 3);
    } else {
      fe_d1 += fe_ry2 * (2 * fe_x + 3) + fe_rx2 * (-2 * fe_y + 2);
      fe_y -= 1;
    }
    fe_x += 1;
  }
  fe_d2 = fe_ry2 * (fe_x * 2 + 1) * (fe_x * 2 + 1) / 4 + fe_rx2 * (fe_y - 1) * (fe_y - 1) - fe_rx2 * fe_ry2;
  for _ in 0..ferx + fery + 1 {
    if fe_y < 0 { break; }
    self.hline(fex - fe_x, fex + fe_x + 1, fey + fe_y, color);
    self.hline(fex - fe_x, fex + fe_x + 1, fey - fe_y, color);
    if fe_d2 > 0 {
      fe_d2 += fe_rx2 * (-2 * fe_y + 3);
    } else {
      fe_d2 += fe_ry2 * (2 * fe_x + 2) + fe_rx2 * (-2 * fe_y + 3);
      fe_x += 1;
    }
    fe_y -= 1;
  }
}

// Draw an axis-aligned ellipse outline. Center (cx, cy), radii (rx, ry).
pub fn draw_ellipse(self: Canvas, ecx: integer, ecy: integer, erx: integer, ery: integer, color: integer) {
  // Midpoint ellipse algorithm — two regions.
  ex = 0;
  ey = ery;
  rx2 = erx * erx;
  ry2 = ery * ery;
  // Region 1: dy/dx > -1
  d1 = ry2 - rx2 * ery + rx2 / 4;
  for _ in 0..erx + ery + 1 {
    if ry2 * ex > rx2 * ey { break; }
    self.set_pixel(ecx + ex, ecy + ey, color);
    self.set_pixel(ecx - ex, ecy + ey, color);
    self.set_pixel(ecx + ex, ecy - ey, color);
    self.set_pixel(ecx - ex, ecy - ey, color);
    if d1 < 0 {
      d1 += ry2 * (2 * ex + 3);
    } else {
      d1 += ry2 * (2 * ex + 3) + rx2 * (-2 * ey + 2);
      ey -= 1;
    }
    ex += 1;
  }
  // Region 2: dy/dx <= -1
  d2 = ry2 * (ex * 2 + 1) * (ex * 2 + 1) / 4 + rx2 * (ey - 1) * (ey - 1) - rx2 * ry2;
  for _ in 0..erx + ery + 1 {
    if ey < 0 { break; }
    self.set_pixel(ecx + ex, ecy + ey, color);
    self.set_pixel(ecx - ex, ecy + ey, color);
    self.set_pixel(ecx + ex, ecy - ey, color);
    self.set_pixel(ecx - ex, ecy - ey, color);
    if d2 > 0 {
      d2 += rx2 * (-2 * ey + 3);
    } else {
      d2 += ry2 * (2 * ex + 2) + rx2 * (-2 * ey + 3);
      ex += 1;
    }
    ey -= 1;
  }
}

// --- GL2.4: Cubic Bezier curves ---

// Draw a cubic Bezier curve from (bx0,by0) to (bx3,by3) with control points
// (bx1,by1) and (bx2,by2). Uses adaptive subdivision — splits until segments
// are flat enough, then draws with Bresenham lines.
// Flatness threshold: max deviation of control points from the chord.
pub fn draw_bezier(self: Canvas, bx0: integer, by0: integer, bx1: integer, by1: integer, bx2: integer, by2: integer, bx3: integer, by3: integer, color: integer) {
  // Stack-based adaptive subdivision. Each segment = 8 entries (4 points × 2 coords).
  // Use a stack pointer (`bz_top`) with truncate-then-append on each
  // push so the lint sees only appends + slice reads, never indexed
  // writes — see the push site below.
  bz_sx: vector<integer> = [bx0, bx1, bx2, bx3];
  bz_sy: vector<integer> = [by0, by1, by2, by3];
  bz_top = 4;  // stack pointer = current vector length post-push
  for _ in 0..1000 {
    if bz_top == 0 { break; }
    // Pop segment (read 4 values, then decrease pointer).  `?? 0`
    // suppresses the OOB lint — `bz_top` is the stack pointer kept
    // invariantly >= 4 by the `break if bz_top == 0` guard above plus
    // the push-4-pop-4 protocol below.
    bz_p0x = bz_sx[bz_top - 4] ?? 0; bz_p1x = bz_sx[bz_top - 3] ?? 0;
    bz_p2x = bz_sx[bz_top - 2] ?? 0; bz_p3x = bz_sx[bz_top - 1] ?? 0;
    bz_p0y = bz_sy[bz_top - 4] ?? 0; bz_p1y = bz_sy[bz_top - 3] ?? 0;
    bz_p2y = bz_sy[bz_top - 2] ?? 0; bz_p3y = bz_sy[bz_top - 1] ?? 0;
    bz_top -= 4;
    // Flatness test: cross product distances of control points from chord
    bz_dx = bz_p3x - bz_p0x;
    bz_dy = bz_p3y - bz_p0y;
    bz_d1 = (bz_p1x - bz_p3x) * bz_dy - (bz_p1y - bz_p3y) * bz_dx;
    bz_d2 = (bz_p2x - bz_p3x) * bz_dy - (bz_p2y - bz_p3y) * bz_dx;
    if bz_d1 < 0 { bz_d1 = -bz_d1; }
    if bz_d2 < 0 { bz_d2 = -bz_d2; }
    bz_flat = if bz_d1 > bz_d2 { bz_d1 } else { bz_d2 };
    bz_chord2 = bz_dx * bz_dx + bz_dy * bz_dy;
    if bz_flat * bz_flat <= bz_chord2 or bz_chord2 == 0 {
      self.draw_line(bz_p0x, bz_p0y, bz_p3x, bz_p3y, color);
    } else {
      // de Casteljau subdivision at t=0.5
      bz_m01x = (bz_p0x + bz_p1x) / 2; bz_m01y = (bz_p0y + bz_p1y) / 2;
      bz_m12x = (bz_p1x + bz_p2x) / 2; bz_m12y = (bz_p1y + bz_p2y) / 2;
      bz_m23x = (bz_p2x + bz_p3x) / 2; bz_m23y = (bz_p2y + bz_p3y) / 2;
      bz_m012x = (bz_m01x + bz_m12x) / 2; bz_m012y = (bz_m01y + bz_m12y) / 2;
      bz_m123x = (bz_m12x + bz_m23x) / 2; bz_m123y = (bz_m12y + bz_m23y) / 2;
      bz_mx = (bz_m012x + bz_m123x) / 2; bz_my = (bz_m012y + bz_m123y) / 2;
      // Push second half first (so first half is popped next).
      // Truncate-then-append: the stack pointer `bz_top` is always
      // the next free slot; truncating to `bz_top` ensures `+=`
      // appends at that slot.  A vector keeps its own slice in place
      // (`v = v[0..n]`), so the pop costs no copy.
      bz_sx = bz_sx[0..bz_top];
      bz_sy = bz_sy[0..bz_top];
      bz_sx += [bz_mx, bz_m123x, bz_m23x, bz_p3x];
      bz_sy += [bz_my, bz_m123y, bz_m23y, bz_p3y];
      bz_top += 4;
      bz_sx += [bz_p0x, bz_m01x, bz_m012x, bz_mx];
      bz_sy += [bz_p0y, bz_m01y, bz_m012y, bz_my];
      bz_top += 4;
    }
  }
}

// --- GL2.6: Wu anti-aliased line ---

// Draw an anti-aliased line using Xiaolin Wu's algorithm.
// Uses blend_pixel for sub-pixel coverage.
pub fn draw_aa_line(self: Canvas, ax0: integer, ay0: integer, ax1: integer, ay1: integer, color: integer) {
  aa_dx = if ax1 > ax0 { ax1 - ax0 } else { ax0 - ax1 };
  aa_dy = if ay1 > ay0 { ay1 - ay0 } else { ay0 - ay1 };
  aa_steep = aa_dy > aa_dx;
  // If steep, we step along y instead of x.
  // Map to canonical form: always step along the longer axis.
  aa_x0 = if aa_steep { ay0 } else { ax0 };
  aa_y0 = if aa_steep { ax0 } else { ay0 };
  aa_x1 = if aa_steep { ay1 } else { ax1 };
  aa_y1 = if aa_steep { ax1 } else { ay1 };
  // Ensure we draw left-to-right
  if aa_x0 > aa_x1 {
    aa_tmp = aa_x0; aa_x0 = aa_x1; aa_x1 = aa_tmp;
    aa_tmp = aa_y0; aa_y0 = aa_y1; aa_y1 = aa_tmp;
  }
  aa_run = aa_x1 - aa_x0;
  if aa_run == 0 {
    // Single pixel
    self.set_pixel(ax0, ay0, color);
    return;
  }
  // Fixed-point gradient: rise * 256 / run.  `?? 0` suppresses the
  // div-by-zero lint — `aa_run == 0` is the vertical-line case
  // handled above with `set_pixel(...); return;`.  Parens scope `??`
  // to the div so the parser's null-fallback swap recognises it.
  aa_rise = aa_y1 - aa_y0;
  aa_grad256 = (aa_rise * 256 / aa_run) ?? 0;
  // y in fixed-point (8 fractional bits)
  aa_yf = aa_y0 * 256;
  aa_cr = graphics::color_r(color);
  aa_cg = graphics::color_g(color);
  aa_cb = graphics::color_b(color);
  for aa_xi in aa_x0..aa_x1 + 1 {
    aa_yi = aa_yf / 256;
    aa_frac = aa_yf - aa_yi * 256;
    if aa_frac < 0 { aa_frac += 256; aa_yi -= 1; }
    aa_a1 = 255 - aa_frac;
    aa_a2 = aa_frac;
    aa_c1 = graphics::rgba(aa_cr, aa_cg, aa_cb, aa_a1);
    aa_c2 = graphics::rgba(aa_cr, aa_cg, aa_cb, aa_a2);
    if aa_steep {
      self.blend_pixel(aa_yi, aa_xi, aa_c1);
      if aa_a2 > 0 { self.blend_pixel(aa_yi + 1, aa_xi, aa_c2); }
    } else {
      self.blend_pixel(aa_xi, aa_yi, aa_c1);
      if aa_a2 > 0 { self.blend_pixel(aa_xi, aa_yi + 1, aa_c2); }
    }
    aa_yf += aa_grad256;
  }
}

// --- GL2.5: Triangle fill (scanline) ---

// Fill a triangle with vertices (tx0,ty0), (tx1,ty1), (tx2,ty2).
// Uses flat-top/flat-bottom decomposition with scanline fill.
pub fn fill_triangle(self: Canvas, tx0: integer, ty0: integer, tx1: integer, ty1: integer, tx2: integer, ty2: integer, color: integer) {
  // Sort vertices by y: ta_y <= tb_y <= tc_y
  ta_x = tx0; ta_y = ty0;
  tb_x = tx1; tb_y = ty1;
  tc_x = tx2; tc_y = ty2;
  if ta_y > tb_y { tr_t = ta_x; ta_x = tb_x; tb_x = tr_t; tr_t = ta_y; ta_y = tb_y; tb_y = tr_t; }
  if tb_y > tc_y { tr_t = tb_x; tb_x = tc_x; tc_x = tr_t; tr_t = tb_y; tb_y = tc_y; tc_y = tr_t; }
  if ta_y > tb_y { tr_t = ta_x; ta_x = tb_x; tb_x = tr_t; tr_t = ta_y; ta_y = tb_y; tb_y = tr_t; }
  if ta_y == tc_y { return; }  // degenerate
  for tr_y in ta_y..tc_y + 1 {
    // x along edge A→C (the long edge, always valid).
    //
    // MULTIPLY BEFORE DIVIDING.  These are integers, so
    // `(tr_y - ta_y) / (tc_y - ta_y)` on its own is 0 on every
    // scanline but the last — which collapses the whole fill to a
    // vertical line at the apex plus the base row.  Scaling the run
    // first keeps the ratio's precision inside the integer divide.
    //
    // `?? 0` suppresses the div lint — `tc_y - ta_y != 0` is
    // guaranteed by the degenerate-triangle early-return above.
    // Parens scope `??` to just the div so the parser's null-fallback
    // swap fires.
    tr_xac = ta_x + ((tc_x - ta_x) * (tr_y - ta_y) / (tc_y - ta_y) ?? 0);
    // x along the short edge (A→B or B→C depending on scanline).
    // Both branches gate their divisor explicitly with the AND-side
    // `!= ta_y` / `!= tb_y` check, so `?? 0` is lint suppression.
    tr_xother = tr_xac;
    if tr_y < tb_y and tb_y != ta_y {
      tr_xother = ta_x + ((tb_x - ta_x) * (tr_y - ta_y) / (tb_y - ta_y) ?? 0);
    }
    if tr_y >= tb_y and tc_y != tb_y {
      tr_xother = tb_x + ((tc_x - tb_x) * (tr_y - tb_y) / (tc_y - tb_y) ?? 0);
    }
    if tr_y >= tb_y and tc_y == tb_y {
      tr_xother = tb_x;
    }
    tr_left = if tr_xac < tr_xother { tr_xac } else { tr_xother };
    tr_right = if tr_xac > tr_xother { tr_xac } else { tr_xother };
    self.hline(tr_left, tr_right + 1, tr_y, color);
  }
}

/// A point on the canvas, in pixels.
///
/// Exists because a polygon is a LIST of them and `fill_triangle`'s six loose
/// integers do not generalise — six is already the edge of what a caller gets
/// right in order, and a polygon has no fixed count.
///
/// NOT called `Point`: `mesh3d::LightType` has a `Point` variant and `graphics`
/// depends on mesh3d, so the name is taken and the collision is a hard error
/// ("struct 'Point' conflicts with a enumvalue of the same name"). `Vertex` is
/// mesh3d's too. Reach for `Coord` in 2-D graphics code and the 3-D names stay
/// where they belong.
pub struct Coord {
  pub px: integer,
  pub py: integer,
}

/// Fill a closed polygon under the EVEN-ODD rule.
///
/// The polygon closes itself: the last point joins the first, so a triangle is
/// three points and not four. Fewer than three points draws nothing.
///
/// Even-odd means a point is inside when a ray from it crosses the outline an odd
/// number of times, so a self-intersecting outline leaves its overlap EMPTY — the
/// classic five-point star is a pentagon-shaped hole with five filled arms. That
/// is a choice, not a bug: the alternative (non-zero winding) fills the middle,
/// and the two disagree on exactly the shapes people draw deliberately.
///
/// Matches `fill_triangle` pixel for pixel on a triangle, which is the reason the
/// row rule below is inclusive rather than the half-open rule a scanline filler
/// usually takes. See the note on `polygon_crossings`.
// Example: @GFX-006 — the star's hollow middle, and the triangle agreement.
pub fn fill_polygon(self: Canvas, pts: const vector<Coord>, color: integer) {
  fp_n = len(pts);
  if fp_n < 3 { return; }
  fp_top = pts[0]?.py;
  fp_bot = fp_top;
  for p in pts {
    if p.py < fp_top { fp_top = p.py; }
    if p.py > fp_bot { fp_bot = p.py; }
  }
  if fp_top < 0 { fp_top = 0; }
  if fp_bot >= self.height { fp_bot = self.height - 1; }
  for fp_y in fp_top..=fp_bot {
    fp_xs = polygon_crossings(pts, fp_y, fp_y == fp_bot);
    fp_i = 0;
    while fp_i + 1 < len(fp_xs) {
      // Inclusive span, like `fill_triangle`: `hline` takes a half-open end, so
      // the right edge is +1.
      self.hline(fp_xs[fp_i]?, fp_xs[fp_i + 1]? + 1, fp_y, color);
      fp_i += 2;
    }
  }
}

/// Where the outline crosses scanline `y`, left to right.
///
/// The rule is HALF-OPEN on an edge's lower end (`ylo <= y < yhi`) and that is
/// what makes even-odd work: a vertex the outline passes straight through belongs
/// to exactly one of its two edges, so it counts once. Count it twice — the
/// obvious inclusive rule — and a through-vertex yields two crossings at the same
/// x, which pair with each other and fill a single pixel where a whole span
/// belonged.
///
/// The cost is that the polygon's LOWEST row loses every edge, so `closing` turns
/// the rule inclusive for exactly that row. It is safe there and nowhere else: the
/// half-open rule exists to disambiguate a vertex the outline passes THROUGH, and
/// the extreme row has none — every edge meeting it goes back up, so no crossing
/// is counted twice. Measured: left half-open throughout, a triangle loses its
/// bottom vertex (1 px) or its whole base (34 px on a 34-wide one), which was the
/// entire `fill_triangle` disagreement.
fn polygon_crossings(pc_pts: const vector<Coord>, pc_y: integer,
                     closing: boolean) -> vector<integer> {
  pc_out: vector<integer> = [];
  pc_n = len(pc_pts);
  for i in 0..pc_n {
    pc_a = pc_pts[i]?;
    // `% pc_n` types nullable because a zero divisor answers null; `?? 0` names the
    // wrap target, which is where the last edge closes anyway.  The loop does not run
    // at all when `pc_n` is 0, so the default is unreachable rather than chosen.
    pc_b = pc_pts[(i + 1) % pc_n ?? 0]?;
    pc_ylo = if pc_a.py < pc_b.py { pc_a.py } else { pc_b.py };
    pc_yhi = if pc_a.py < pc_b.py { pc_b.py } else { pc_a.py };
    // A horizontal edge crosses no scanline: its endpoints are already carried by
    // the two edges that meet it, and counting it would flip parity along a row it
    // only touches.
    if pc_ylo == pc_yhi { continue }
    if pc_y < pc_ylo { continue }
    if pc_y > pc_yhi { continue }
    if pc_y == pc_yhi && !closing { continue }
    // Interpolate from the edge's TOP end, and multiply before dividing — the
    // same integer-precision rule `fill_triangle` documents.
    pc_ty = if pc_a.py < pc_b.py { pc_a } else { pc_b };
    pc_by = if pc_a.py < pc_b.py { pc_b } else { pc_a };
    pc_out += [pc_ty.px + ((pc_by.px - pc_ty.px) * (pc_y - pc_ty.py) / (pc_by.py - pc_ty.py) ?? 0)];
  }
  // Insertion sort: a crossing list is a handful of numbers, and a fresh vector
  // per scanline would cost more than the compare.
  for i in 1..len(pc_out) {
    pc_v = pc_out[i]?;
    pc_j = i;
    while pc_j > 0 && pc_out[pc_j - 1]? > pc_v {
      pc_out[pc_j] = pc_out[pc_j - 1]?;
      pc_j -= 1;
    }
    pc_out[pc_j] = pc_v;
  }
  pc_out
}

// ── Resampling ──────────────────────────────────────────────────────

/// Fixed-point fraction bits for resample weights — Pillow's `PRECISION_BITS`.
RESAMPLE_PREC = 22;
RESAMPLE_PI = 3.14159265358979323846;
BICUBIC_A = -0.5;

fn sinc_at(x: float) -> float {
  if x == 0.0 { return 1.0 }
  sc_xp = x * RESAMPLE_PI;
  (sin(sc_xp) / sc_xp)?
}

/// Pillow's bicubic kernel — the Keys cubic with `a = -0.5`, support 2.
///
/// The two branches are one polynomial each and the constant is the whole
/// identity of the filter: `a = -0.5` is Catmull-Rom, and another `a` is a
/// different picture, not a rounding difference.
fn bicubic_at(bc_x0: float) -> float {
  bc_x = if bc_x0 < 0.0 { -bc_x0 } else { bc_x0 };
  if bc_x < 1.0 { return ((BICUBIC_A + 2.0) * bc_x - (BICUBIC_A + 3.0)) * bc_x * bc_x + 1.0 }
  if bc_x < 2.0 { return (((bc_x - 5.0) * bc_x + 8.0) * bc_x - 4.0) * BICUBIC_A }
  0.0
}

/// The Lanczos-3 kernel: `sinc(x) * sinc(x/3)` inside a radius of 3, else 0.
fn lanczos_at(lz_x0: float) -> float {
  lz_x = if lz_x0 < 0.0 { -lz_x0 } else { lz_x0 };
  if lz_x < 3.0 { return sinc_at(lz_x) * sinc_at(lz_x / 3.0) }
  0.0
}

/// One axis of Pillow's `precompute_coeffs`, flattened into three parallel
/// vectors: each output index gets a window START, a TAP COUNT, and that many
/// fixed-point weights appended to `kk`.
///
/// The weights are normalised to sum to 1 in floating point and only then scaled
/// to integers, rounding AWAY from zero — both filters here have negative lobes,
/// and rounding them the same direction as the positive ones is what keeps the
/// taps summing back to `1 << RESAMPLE_PREC`.
///
/// `support0` and `filt` are the ONLY things that differ between Pillow's
/// filters, which is why they are parameters rather than two copies of this
/// function: the window arithmetic, the normalisation and the fixed-point
/// rounding are one derived fact and belong in one place.
fn resample_coeffs(in_size: integer, out_size: integer, support0: float,
                   filt: fn(float) -> float, starts: &vector<integer>,
                   counts: &vector<integer>, kk: &vector<integer>) {
  rc_scale = ((in_size as float) / (out_size as float))?;
  // Upscaling does not widen the kernel: the filter scale floors at 1.
  rc_fscale = if rc_scale < 1.0 { 1.0 } else { rc_scale };
  rc_support = support0 * rc_fscale;
  rc_ss = (1.0 / rc_fscale)?;
  for xx in 0..out_size {
    rc_centre = ((xx as float) + 0.5) * rc_scale;
    rc_min = (rc_centre - rc_support + 0.5) as integer;
    if rc_min < 0 { rc_min = 0; }
    rc_max = (rc_centre + rc_support + 0.5) as integer;
    if rc_max > in_size { rc_max = in_size; }
    rc_max -= rc_min;
    rc_row: vector<float> = [];
    rc_ww = 0.0;
    for x in 0..rc_max {
      rc_w = filt((((x + rc_min) as float) - rc_centre + 0.5) * rc_ss);
      rc_row += [rc_w];
      rc_ww += rc_w;
    }
    for x in 0..rc_max {
      rc_k = rc_row[x]?;
      if rc_ww != 0.0 { rc_k = (rc_k / rc_ww)?; }
      rc_scaled = rc_k * ((1 << RESAMPLE_PREC) as float);
      kk += [if rc_k < 0.0 { (rc_scaled - 0.5) as integer }
             else { (rc_scaled + 0.5) as integer }];
    }
    starts += [rc_min];
    counts += [rc_max];
  }
}

/// Fixed-point accumulator to a byte: shift the fraction off and clamp.
fn resample_clip(rq_v: integer) -> integer {
  rq_s = rq_v >> RESAMPLE_PREC;
  if rq_s < 0 { return 0 }
  if rq_s > 255 { return 255 }
  rq_s
}

/// Resample this canvas to `ow` x `oh` with a Lanczos-3 filter.
///
/// Byte-identical to Pillow's `Image.resize(..., Image.LANCZOS)`, which is the
/// point: the `.draw` corpus renders at 3x and downscales with exactly that, so a
/// renderer that resamples any other way cannot be pixel-compared against it
/// however good it looks (@PLN146 W0 finding 5).
///
/// **Alpha is premultiplied across the resample and undone after**, because
/// Pillow defines `resize` on an image with alpha as
/// `convert("RGBa") -> resize -> convert("RGBA")`. That is a contract, not an
/// optimisation: resampling straight RGBA lets a transparent texel's colour bleed
/// into its neighbours, and the two disagree on any edge where alpha varies.
/// Both roundings are Pillow's and neither is the obvious one — `(v*a + 127)/255`
/// going in, a TRUNCATING divide coming out, and alpha 0 passes its colour
/// through untouched rather than going black.
// Example: @GFX-007 — why a 3x supersample plus this is not the same picture as
// drawing once at final size.
pub fn resize_lanczos(self: const Canvas, ow: integer, oh: integer) -> Canvas {
  resample(self, ow, oh, 3.0, lanczos_at)
}

/// Resample with a BICUBIC filter — Pillow's `Image.resize` default.
///
/// The default is the reason this exists: `resize(size)` with no `resample=`
/// argument is bicubic, and code that builds a small image and enlarges it (a
/// gradient computed at 100x100, say) takes that path without naming it. Read
/// the call site's filter off the ARGUMENT LIST and it looks like there isn't
/// one.
///
/// ⚠ Bicubic has negative lobes, so an ENLARGEMENT overshoots: a hard 40→200
/// step upscaled 4x spans 28..212 in Pillow and here alike. That is the filter,
/// not a defect — clamping it away would be the defect.
pub fn resize_bicubic(self: const Canvas, ow: integer, oh: integer) -> Canvas {
  resample(self, ow, oh, 2.0, bicubic_at)
}

/// Pillow's two-pass resample, with the filter left to the caller.
///
/// One body, because the passes, the premultiply and the fixed-point
/// accumulation are the same derived fact for every filter — only the kernel and
/// its support differ.
fn resample(self: const Canvas, ow: integer, oh: integer, support0: float,
            filt: fn(float) -> float) -> Canvas {
  if ow < 1 || oh < 1 || self.width < 1 || self.height < 1 {
    return canvas(if ow < 1 { 1 } else { ow }, if oh < 1 { 1 } else { oh }, 0);
  }
  rl_iw = self.width;
  rl_ih = self.height;
  // Premultiplied channel planes, in Pillow's RGBA byte order.
  rl_pre: vector<integer> = [];
  for i in 0..(rl_iw * rl_ih) {
    rl_c = self.data[i]?;
    rl_a = (rl_c >> 24) & 255;
    rl_pre += [(((rl_c >> 16) & 255) * rl_a + 127) / 255 ?? 0,
               (((rl_c >> 8) & 255) * rl_a + 127) / 255 ?? 0,
               ((rl_c & 255) * rl_a + 127) / 255 ?? 0,
               rl_a];
  }
  hs: vector<integer> = []; hc: vector<integer> = []; hk: vector<integer> = [];
  resample_coeffs(rl_iw, ow, support0, filt, hs, hc, hk);
  rl_mid: vector<integer> = [];
  for _ in 0..(ow * rl_ih * 4) { rl_mid += [0]; }
  rl_base = 0;
  for xx in 0..ow {
    rl_xmin = hs[xx]?;
    rl_n = hc[xx]?;
    for yy in 0..rl_ih {
      for ch in 0..4 {
        rl_acc = 1 << (RESAMPLE_PREC - 1);
        for x in 0..rl_n {
          rl_acc += rl_pre[(yy * rl_iw + rl_xmin + x) * 4 + ch]? * hk[rl_base + x]?;
        }
        rl_mid[(yy * ow + xx) * 4 + ch] = resample_clip(rl_acc);
      }
    }
    rl_base += rl_n;
  }
  vs: vector<integer> = []; vc: vector<integer> = []; vk: vector<integer> = [];
  resample_coeffs(rl_ih, oh, support0, filt, vs, vc, vk);
  rl_out = canvas(ow, oh, 0);
  rl_od = &rl_out.data;
  rl_vbase = 0;
  for yy in 0..oh {
    rl_ymin = vs[yy]?;
    rl_n = vc[yy]?;
    for xx in 0..ow {
      rl_ch: vector<integer> = [];
      for ch in 0..4 {
        rl_acc = 1 << (RESAMPLE_PREC - 1);
        for y in 0..rl_n {
          rl_acc += rl_mid[((rl_ymin + y) * ow + xx) * 4 + ch]? * vk[rl_vbase + y]?;
        }
        rl_ch += [resample_clip(rl_acc)];
      }
      // Undo the premultiply and repack to 0xAARRGGBB.
      rl_a = rl_ch[3]?;
      rl_rgb: vector<integer> = [];
      for ch in 0..3 {
        rl_v = rl_ch[ch]?;
        if rl_a == 0 { rl_rgb += [rl_v]; }
        else {
          rl_u = (rl_v * 255) / rl_a ?? 0;
          rl_rgb += [if rl_u > 255 { 255 } else { rl_u }];
        }
      }
      rl_od[yy * ow + xx] = (rl_a << 24) | (rl_rgb[0]? << 16) | (rl_rgb[1]? << 8) | rl_rgb[2]?;
    }
    rl_vbase += rl_n;
  }
  rl_out
}

// Native: write packed pixel data to a PNG file.
fn save_png_raw(path: text, width: integer, height: integer, data: const vector<integer>) -> boolean;
#native "loft_save_png"

// Save canvas as a PNG file. Automatically uses RGBA if any pixel has alpha < 255,
// otherwise uses RGB. Returns true on success.
// Example: @GFX-005 — the choice is read off the pixels, so ONE alpha-0 pixel
// (a hand-written hex literal — @GFX-001) makes the whole file RGBA and that
// pixel a transparent hole.
pub fn save_png(self: const Canvas, path: text) -> boolean {
  save_png_raw(path, self.width, self.height, self.data)
}

// Capture the current GL back-buffer to an RGB PNG.  Call after a frame's
// draw calls and BEFORE gl_swap_buffers (reads GL_BACK).  Works on a
// headless / Wayland desktop where the window itself can't be grabbed.
pub fn gl_screenshot(width: integer, height: integer, path: text) -> boolean;
#native "loft_gl_screenshot"

// --- OpenGL (GL5) ---
// Native OpenGL functions for window management and rendering.
// Names match the symbols registered via loft_register_v1 in the cdylib.

// Create a window with an OpenGL 3.3 context. Returns true on success.
pub fn gl_create_window(width: integer, height: integer, title: text) -> boolean;
#native "loft_gl_create_window"

// Open a borderless fullscreen window at the primary monitor's native
// resolution. Returns true on success. Read the actual viewport size
// afterwards with `gl_window_width` / `gl_window_height` if you need
// to set up an aspect-aware camera.
pub fn gl_create_fullscreen_window(title: text) -> boolean;
#native "loft_gl_create_fullscreen_window"

// Toggle fullscreen on the existing window (runtime flip, cheaper
// than recreating the GL context — textures / VAOs / shaders
// survive).  Pass `true` for borderless fullscreen, `false` for
// windowed.  Browsers (WASM) require a user gesture to enter
// fullscreen; calling outside one is non-fatal.
pub fn gl_set_fullscreen(on: boolean);
#native "loft_gl_set_fullscreen"

// Current window inner size in physical pixels.  Query after creating a
// (especially fullscreen) window so projection / 2D ortho / overlays use
// the real resolution rather than a windowed hint.
pub fn gl_window_width() -> integer;
#native "loft_gl_window_width"
// And its height — the pair is what a projection or a 2-D ortho is built from.
pub fn gl_window_height() -> integer;
#native "loft_gl_window_height"

// Poll window events. Returns false when the window should close.
pub fn gl_poll_events() -> boolean;
#native "loft_gl_poll_events"

// Swap buffers (present the frame).
pub fn gl_swap_buffers();
#native "loft_gl_swap_buffers"

// Clear the screen with an RGBA color (packed as 0xRRGGBBAA).
pub fn gl_clear(color: integer);
#native "loft_gl_clear"

// Destroy the window and OpenGL context.
pub fn gl_destroy_window();
#native "loft_gl_destroy_window"

// Compile and link a shader program. Returns the program ID (0 on failure).
pub fn gl_create_shader(vertex_source: text, fragment_source: text) -> integer;
#native "loft_gl_create_shader"

// Use a shader program for subsequent draw calls.
pub fn gl_use_shader(program: integer);
#native "loft_gl_use_shader"

// Draw a VAO with vertex_count vertices as triangles.
pub fn gl_draw(vao: integer, vertex_count: integer);
#native "loft_gl_draw"

// Upload a vector<single> as a vertex buffer. Returns VAO handle.
// stride = floats per vertex (3=pos, 6=pos+normal, 10=pos+normal+color).
// Native codegen: loft_gl_upload_vertices(data_ptr: *const f32, count: u32, stride: i32) -> i32
pub fn gl_upload_vertices(data: vector<single>, stride: integer) -> integer;
#native "loft_gl_upload_vertices"

// ── Instanced rendering ───────────────────────────────────────────────────
// Draw one base mesh many times in a single call, each instance pulling
// per-instance attributes from a SECOND buffer. The contract: vertex
// attributes occupy locations 0-4 (per-vertex, divisor 0, via gl_upload_*);
// instance attributes occupy locations >=5 (divisor 1). The CALLER owns the
// instance layout (component count + stride + offset, in floats) and must
// match the shader's `layout(location=N)` — explicit, so no heuristic.

// Upload per-instance data to a DYNAMIC_DRAW buffer. Returns the VBO handle
// (pass it to gl_instance_attrib). DYNAMIC because instances change per frame.
pub fn gl_upload_instance_buffer(data: vector<single>) -> integer;
#native "loft_gl_upload_instance_buffer"

// Bind instance VBO `ivbo`'s field to attribute `location` on `vao`, advancing
// once per INSTANCE (VertexAttribDivisor=1). components = floats in the field
// (2=vec2, 4=vec4); stride_floats = floats per instance record; offset_floats =
// this field's start within the record. Use locations >=5 to clear the vertex attrs.
pub fn gl_instance_attrib(vao: integer, ivbo: integer, location: integer, components: integer, stride_floats: integer, offset_floats: integer);
#native "loft_gl_instance_attrib"

// Draw vertex_count vertices of `vao` as triangles, instance_count times.
pub fn gl_draw_instanced(vao: integer, vertex_count: integer, instance_count: integer);
#native "loft_gl_draw_instanced"

// ── Dynamic buffer update + index buffers ─────────────────────────────────

// Orphan + re-upload an existing VBO (returned by gl_upload_vertices /
// gl_upload_instance_buffer) with new data, DYNAMIC_DRAW — per-frame updates
// without recreating the buffer.
pub fn gl_update_buffer(vbo: integer, data: vector<single>);
#native "loft_gl_update_buffer"

// Upload triangle indices as an element (index) buffer bound to `vao`, so
// gl_draw_elements(vao, index_count, mode) draws indexed. Returns the EBO handle.
pub fn gl_upload_indices(vao: integer, data: vector<integer>) -> integer;
#native "loft_gl_upload_indices"

// ── Per-group VBO set — a reusable multi-VBO renderer ─────────────────────
//
// A `GroupVboSet` keeps one independently-uploadable VBO per "group" (keyed by
// an integer group key), so a consumer that partitions its world into render
// groups (e.g. gridmesh's `all_groups` / `collect_dirty_groups`) can RE-UPLOAD
// ONLY the groups whose geometry changed (`group_vbos_upsert` on the dirty
// groups) while every other group keeps its cached VBO — turning a per-edit
// O(N) full re-upload into O(dirty).  Generic: keyed by an integer, holds the
// baked `vector<single>` the consumer produced; it knows nothing about the
// consumer's mesh type.  Each group also stores its world-space XZ bounds so a
// frustum-cull draw can be layered on later (the projector / moros payoff).
//
// One consumer is the audience projector (per-group crystal + ground VBOs); the
// moros terrain renderer is the next.  GL calls cannot run headless, so this is
// validated by those consumers, not a unit test.
struct GroupVbo {
  const gk:    integer,   // group key (NOT 'key' — reserved in hash-value structs)
  const vao:   integer,
  const count: integer,   // vertices = floats / stride
  const min_x: single, const min_z: single, const max_x: single, const max_z: single,   // world bounds (future cull)
}

// The vertex buffers of a grouped mesh: one `GroupVbo` per group key in `slots`, all
// sharing one vertex layout (`stride` floats per vertex) and one `draw_mode`.
pub struct GroupVboSet {
  pub slots: hash<GroupVbo[gk]>,   // not const: reassigned (`set.slots = []`) + grown via `+=`
  const stride:    integer,   // floats per vertex (e.g. 14 crystal, 10 ground)
  const draw_mode: integer,   // DRAW_TRIANGLES / DRAW_LINES / …
}

// Create an empty set for a fixed vertex stride + draw mode.
pub fn group_vbos_new(stride: integer, draw_mode: integer) -> GroupVboSet {
  GroupVboSet{ slots: [], stride: stride, draw_mode: draw_mode }
}

// Upload (or RE-upload) one group's baked vertices: delete the group's old VAO
// if present, upload the new data, and record the handle + vertex count + world
// bounds.  Call this only for the DIRTY groups each edit; untouched groups keep
// their cached VBOs.
pub fn group_vbos_upsert(set: GroupVboSet, gk: integer, verts: vector<single>,
                         min_x: single, min_z: single, max_x: single, max_z: single) {
  old = set.slots[gk];
  if old != null { gl_delete_vao(old.vao); set.slots[gk] = null; }
  vao = gl_upload_vertices(verts, set.stride);
  set.slots += [GroupVbo{ gk: gk, vao: vao, count: (len(verts) / set.stride) ?? 0,
                          min_x: min_x, min_z: min_z, max_x: max_x, max_z: max_z }];
}

// (An emptied group is handled by `group_vbos_upsert` with zero vertices: it
// stores a count-0 slot that `group_vbos_draw_all` skips.  A dedicated
// `group_vbos_remove` that frees a single empty group's VAO via a keyed remove
// (`set.slots[gk] = null`) is now unblocked — the @P320 false "never modified"
// rejection of a `&`-param whose only mutation is a keyed remove was fixed
// 2026-05-23 — but is not yet needed, so it stays unwritten.)

// Draw every group's cached VBO with the set's draw mode.  (Per-group frustum
// culling — skip groups whose world bounds fall outside the view — is a future
// addition; the bounds are already stored.)
pub fn group_vbos_draw_all(set: const GroupVboSet) {
  for gv in set.slots {
    if gv.count > 0 { gl_draw_mode(gv.vao, gv.count, set.draw_mode); }
  }
}

// Free every group's VAO and clear the set (call on teardown / world reset).
pub fn group_vbos_destroy(set: GroupVboSet) {
  for gvd in set.slots { gl_delete_vao(gvd.vao); }
  set.slots = [];
}

// Set a mat4 uniform on a shader program. mat is a Mat4.m vector (16 floats).
// Native codegen: loft_gl_set_mat4(program: i32, name_ptr, name_len, mat_ptr: *const f64, count: u32)
pub fn gl_set_uniform_mat4(program: integer, name: text, mat: const vector<float>);
#native "loft_gl_set_mat4"

// --- Uniforms ---

// Set a float uniform.
pub fn gl_set_uniform_float(program: integer, name: text, val: float);
#native "loft_gl_set_uniform_float"

// Set an integer uniform (also used for sampler2D texture unit bindings).
pub fn gl_set_uniform_int(program: integer, name: text, val: integer);
#native "loft_gl_set_uniform_int"

// Set a vec2 uniform (e.g. screen size, texel size, a 2D offset).
pub fn gl_set_uniform_vec2(program: integer, name: text, x: float, y: float);
#native "loft_gl_set_uniform_vec2"

// Set a vec3 uniform (e.g. light position, color).
pub fn gl_set_uniform_vec3(program: integer, name: text, x: float, y: float, z: float);
#native "loft_gl_set_uniform_vec3"

// Set a vec4 uniform (e.g. an RGBA color, a rect x/y/w/h).
pub fn gl_set_uniform_vec4(program: integer, name: text, x: float, y: float, z: float, w: float);
#native "loft_gl_set_uniform_vec4"

// --- GL state ---

// Capability constants for gl_enable/gl_disable:
pub const GL_DEPTH_TEST = 1;
pub const GL_BLEND = 2;
pub const GL_CULL_FACE = 3;
pub const GL_SCISSOR_TEST = 4;

// Enable a GL capability (GL_DEPTH_TEST, GL_BLEND, GL_CULL_FACE, GL_SCISSOR_TEST).
pub fn gl_enable(cap: integer);
#native "loft_gl_enable"

// Disable a GL capability.
pub fn gl_disable(cap: integer);
#native "loft_gl_disable"

// Blend factor constants for gl_blend_func:
pub const BLEND_ZERO = 0;
pub const BLEND_ONE = 1;
pub const BLEND_SRC_ALPHA = 2;
pub const BLEND_ONE_MINUS_SRC_ALPHA = 3;
pub const BLEND_DST_ALPHA = 4;
pub const BLEND_ONE_MINUS_DST_ALPHA = 5;

// Set blend function (e.g. gl_blend_func(BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA)).
pub fn gl_blend_func(src: integer, dst: integer);
#native "loft_gl_blend_func"

// Set which face to cull (0=back, 1=front).
pub fn gl_cull_face(face: integer);
#native "loft_gl_cull_face"

// Enable/disable depth buffer writing.
pub fn gl_depth_mask(write: boolean);
#native "loft_gl_depth_mask"

// Block until every GL command issued so far has completed.
//
// The call that makes a read-back trustworthy: `gl_screenshot` and any other
// framebuffer read otherwise race the driver, so the same program can produce a
// complete frame or an empty one from run to run.  Costs a stall by definition —
// use it in a test or a screenshot path, never in a frame loop.
pub fn gl_finish();
#native "loft_gl_finish"

// Set the viewport rectangle (used for FBO render passes).
pub fn gl_viewport(x: integer, y: integer, w: integer, h: integer);
#native "loft_gl_viewport"

// Set the scissor box — clips rendering to this rect (origin bottom-left, like
// the viewport). Takes effect only while gl_enable(GL_SCISSOR_TEST) is set; use
// it for partial-damage redraw (re-render just a changed region).
pub fn gl_scissor(x: integer, y: integer, w: integer, h: integer);
#native "loft_gl_scissor"

// --- Framebuffer objects ---

// Create a framebuffer object. Returns FBO handle.
pub fn gl_create_framebuffer() -> integer;
#native "loft_gl_create_framebuffer"

// Bind a framebuffer (0 = default screen framebuffer).
pub fn gl_bind_framebuffer(fbo: integer);
#native "loft_gl_bind_framebuffer"

// Attach a texture to an FBO (attachment: 0=color, 1=depth).
pub fn gl_framebuffer_texture(fbo: integer, attachment: integer, tex: integer);
#native "loft_gl_framebuffer_texture"

// Create a depth-only texture for shadow mapping.
pub fn gl_create_depth_texture(width: integer, height: integer) -> integer;
#native "loft_gl_create_depth_texture"

// Create an empty RGBA texture for render-to-texture.
pub fn gl_create_color_texture(width: integer, height: integer) -> integer;
#native "loft_gl_create_color_texture"

// Set a texture's filtering: nearest = crisp pixel-art, else linear/smooth.
pub fn gl_texture_filter(tex: integer, nearest: boolean);
#native "loft_gl_texture_filter"

// Generate mipmaps + trilinear minification for `tex` (after its pixels are
// uploaded) — keeps minified sprites / atlas pages clean.
pub fn gl_generate_mipmap(tex: integer);
#native "loft_gl_generate_mipmap"

// Upload a sub-region of RGBA pixels (Canvas data, 0xAARRGGBB) into an existing
// texture at (x,y) sized w*h — incremental atlas packing without re-uploading.
pub fn gl_texture_subimage(tex: integer, x: integer, y: integer, w: integer, h: integer, data: vector<integer>);
#native "loft_gl_texture_subimage"

// Draw a fullscreen quad (for post-processing). Uses built-in VAO with UV coords.
// Vertex shader receives: layout(location=0) vec2 aPos, layout(location=1) vec2 aUV.
pub fn gl_draw_fullscreen_quad();
#native "loft_gl_draw_fullscreen_quad"

// --- Draw modes ---

// Draw mode constants for gl_draw_mode and gl_draw_elements:
pub const DRAW_TRIANGLES = 0;
pub const DRAW_LINES = 1;
pub const DRAW_POINTS = 2;

// Draw a VAO with explicit mode (DRAW_TRIANGLES, DRAW_LINES, DRAW_POINTS).
pub fn gl_draw_mode(vao: integer, vertex_count: integer, mode: integer);
#native "loft_gl_draw_mode"

// Draw using an index buffer (EBO). mode: DRAW_TRIANGLES, DRAW_LINES, DRAW_POINTS.
pub fn gl_draw_elements(vao: integer, index_count: integer, mode: integer);
#native "loft_gl_draw_elements"

// Set line width for DRAW_LINES rendering.
pub fn gl_line_width(width: float);
#native "loft_gl_line_width"

// Set point size for DRAW_POINTS rendering.
pub fn gl_point_size(size: float);
#native "loft_gl_point_size"

// --- Input ---

// Key code constants for gl_key_pressed:
pub const KEY_W = 119;
pub const KEY_A = 97;
pub const KEY_S = 115;
pub const KEY_D = 100;
pub const KEY_Q = 113;
pub const KEY_E = 101;
pub const KEY_SPACE = 32;
pub const KEY_ESCAPE = 27;
pub const KEY_UP = 128;
pub const KEY_DOWN = 129;
pub const KEY_LEFT = 130;
pub const KEY_RIGHT = 131;
pub const KEY_SHIFT = 132;
pub const KEY_CTRL = 133;

// Initiative 03 Phase 1: extra keys for the editor hotkey set.
// Digits route through ASCII (`gl_key_pressed(KEY_1)` etc).
pub const KEY_0 = 48;
pub const KEY_1 = 49;
pub const KEY_2 = 50;
pub const KEY_3 = 51;
pub const KEY_4 = 52;
pub const KEY_5 = 53;
pub const KEY_6 = 54;
pub const KEY_7 = 55;
pub const KEY_8 = 56;
pub const KEY_9 = 57;

// Common editor hotkey letters (lowercase ASCII; `gl_key_pressed` is
// case-insensitive — the native key_index lowercases).
pub const KEY_O = 111;  // occlusion toggle
pub const KEY_R = 114;  // raise-layer
pub const KEY_F = 102;  // lower-layer
pub const KEY_Z = 122;  // undo (with Ctrl)
pub const KEY_Y = 121;  // redo (with Ctrl)

// Bracket keys for wall-direction cycling.
pub const KEY_LBRACKET = 91;   // [
pub const KEY_RBRACKET = 93;   // ]

// Tab for camera-mode toggle.
pub const KEY_TAB = 9;

// F-key range for save / load / fullscreen-toggle.  Values match
// `lib/graphics/native/src/lib.rs::key_index` mapping of
// `NamedKey::F1..F12` → 135..146.
pub const KEY_F1  = 135;
pub const KEY_F2  = 136;
pub const KEY_F3  = 137;
pub const KEY_F4  = 138;
pub const KEY_F5  = 139;
pub const KEY_F6  = 140;
pub const KEY_F7  = 141;
pub const KEY_F8  = 142;
pub const KEY_F9  = 143;
pub const KEY_F10 = 144;
pub const KEY_F11 = 145;
pub const KEY_F12 = 146;

// Returns true if the key is currently pressed (use KEY_* constants).
pub fn gl_key_pressed(key_code: integer) -> boolean;
#native "loft_gl_key_pressed"

// Mouse position X (pixels from left edge of window).
pub fn gl_mouse_x() -> float;
#native "loft_gl_mouse_x"

// Mouse position Y (pixels from top edge of window).
pub fn gl_mouse_y() -> float;
#native "loft_gl_mouse_y"

// Mouse button bitmask: 1=left, 2=right, 4=middle.
pub fn gl_mouse_button() -> integer;
#native "loft_gl_mouse_button"

// Scroll-wheel delta accumulated since the last call (positive = up).
// Reset on read.  Use per-frame to drive the editor's `InputState.in_wheel`
// or any other scroll-driven UI.  Pixel-delta trackpad scrolling is
// quantised at 20 px/line.
pub fn gl_mouse_wheel() -> integer;
#native "loft_gl_mouse_wheel"

// --- Input events (Gap 01): an ordered, drained-per-frame event queue ---
//
// The polling API above loses text, key-repeat, per-event modifiers, and event
// ORDER.  After gl_poll_events(), drain the queue: call gl_next_event()
// repeatedly (it returns an EV_* tag, or EV_NONE when empty) and read the
// current event's fields with the gl_event_* accessors below.  Delivers text /
// IME, key repeat, per-event modifiers, the named keys the polling API drops,
// and multitouch — in arrival order.  Example:
//   while gl_poll_events() {
//     t = gl_next_event();
//     while t != EV_NONE {
//       if t == EV_TEXT { type_text(gl_event_text()); }
//       else if t == EV_KEY_DOWN { key(gl_event_key(), gl_event_mods()); }
//       t = gl_next_event();
//     }
//     // ... render ...
//   }

// Event type tags returned by gl_next_event().
pub const EV_NONE = 0;
pub const EV_KEY_DOWN = 1;
pub const EV_KEY_UP = 2;
pub const EV_TEXT = 3;
pub const EV_MOUSE_DOWN = 4;
pub const EV_MOUSE_UP = 5;
pub const EV_MOUSE_MOVE = 6;
pub const EV_WHEEL = 7;
pub const EV_TOUCH_BEGIN = 8;
pub const EV_TOUCH_MOVE = 9;
pub const EV_TOUCH_END = 10;
pub const EV_RESIZE = 11;

// Modifier bitmask for gl_event_mods().
pub const MOD_SHIFT = 1;
pub const MOD_CTRL = 2;
pub const MOD_ALT = 4;
pub const MOD_SUPER = 8;

// Named keys the event queue reports (extend the KEY_* set above).
pub const KEY_ENTER = 13;
pub const KEY_BACKSPACE = 8;
pub const KEY_DELETE = 147;
pub const KEY_INSERT = 148;
pub const KEY_HOME = 149;
pub const KEY_END = 150;
pub const KEY_PAGEUP = 151;
pub const KEY_PAGEDOWN = 152;

// Pop the next queued input event and latch it; returns its EV_* tag, or
// EV_NONE when the queue is drained.  Call after gl_poll_events().
pub fn gl_next_event() -> integer;
#native "loft_gl_next_event"

// Named/keycode of the latched key event (the KEY_* constants).
pub fn gl_event_key() -> integer;
#native "loft_gl_event_key"

// Modifier bitmask of the latched event (MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER).
pub fn gl_event_mods() -> integer;
#native "loft_gl_event_mods"

// True if the latched key-down is an auto-repeat.
pub fn gl_event_repeat() -> boolean;
#native "loft_gl_event_repeat"

// UTF-8 text of the latched EV_TEXT event ("" otherwise).
pub fn gl_event_text() -> text;
#native "loft_gl_event_text"

// X position (pixels) of the latched mouse/touch event; width for EV_RESIZE.
pub fn gl_event_x() -> float;
#native "loft_gl_event_x"

// Y position (pixels) of the latched mouse/touch event; height for EV_RESIZE.
pub fn gl_event_y() -> float;
#native "loft_gl_event_y"

// Button of the latched mouse event (1=left, 2=right, 4=middle).
pub fn gl_event_button() -> integer;
#native "loft_gl_event_button"

// Wheel delta of the latched EV_WHEEL event (positive = up).
pub fn gl_event_wheel() -> integer;
#native "loft_gl_event_wheel"

// Stable finger id of the latched EV_TOUCH_* event.
pub fn gl_event_touch_id() -> integer;
#native "loft_gl_event_touch_id"

// --- Text rendering (GL3) ---

// Native font loader — reads `path` straight from the filesystem (relative to
// the process cwd).  Use the `gl_load_font` wrapper below, which resolves the
// path program-relative first.
fn gl_load_font_raw(path: text) -> integer;
#native "loft_gl_load_font"

// Load a font from a file path. Returns a font handle (>= 0) or null on error.
//
// #255: a RELATIVE path resolves against the program's OWN directory
// (`source_dir()` — the source dir under `--interpret`, the executable's dir
// under `--native`), so a bundled font travels with the program and loads
// regardless of the launch directory (the symptom #255 hit: a font addressed
// relative to the source missed under `--native`, which runs from a temp dir,
// and text rendered invisible).  An ABSOLUTE path is used verbatim.  This
// matches loft's program-relative file-I/O default (`file()` / `read_file`).
pub fn gl_load_font(path: text) -> integer {
  if path.starts_with("/") { return gl_load_font_raw(path); }
  dir = source_dir();
  if dir == "" { return gl_load_font_raw(path); }
  return gl_load_font_raw("{dir}/{path}");
}

// Measure text width in pixels at the given font size.
pub fn gl_measure_text(font: integer, content: text, size: float) -> float;
#native "loft_gl_measure_text"

// Return the line height in pixels for a font at the given size.
pub fn gl_text_height(font: integer, size: float) -> integer;
#native "loft_text_height"

// @P340: return the font ASCENT in pixels (baseline -> top of glyphs) from the
// font's real metrics.  gl_draw_text(x, y) anchors by the TOP, placing the
// baseline at y + gl_font_ascent(font, size); to baseline-align mixed sizes,
// draw each at y = baseline - gl_font_ascent(font, size_i).
pub fn gl_font_ascent(font: integer, size: float) -> float;
#native "loft_gl_font_ascent"

// Rasterize text into a pre-allocated integer buffer of alpha values (0-255).
fn rasterize_text_into(font: integer, content: text, size: float, buf: vector<integer>) -> integer;
#native "loft_rasterize_text_into"

// Rasterise text into the canvas at (x, y), anchored by the TOP-left.
//
// ⚠ NEEDS THE NATIVE RASTERISER, so this is one of the canvas routines that
// does NOT work headless: without it the call is a no-op and the canvas keeps
// whatever was under the text.  `text2d` exists for exactly that case — it
// carries a baked face and draws through `set_pixel`, so text always appears.
pub fn draw_text(self: Canvas, font: integer, content: text, size: float,
                 dt_x: integer, dt_y: integer, color: integer) {
  dt_tw = gl_measure_text(font, content, size) as integer + 1;
  dt_th = gl_text_height(font, size);
  if dt_tw <= 0 or dt_th <= 0 { return; }
  dt_buf = filled(dt_tw * dt_th, 0);
  dt_bw = rasterize_text_into(font, content, size, dt_buf);
  if dt_bw <= 0 { return; }
  dt_cr = color_r(color);
  dt_cg = color_g(color);
  dt_cb = color_b(color);
  for dt_by in 0..dt_th {
    for dt_bx in 0..dt_bw {
      dt_idx = dt_by * dt_bw + dt_bx;
      if dt_idx >= dt_buf.len() { break; }
      // `dt_idx` is computed (not a `for` index), so loft can't prove the read
      // fit — discharge at the source. The `>=` guard above keeps it in bounds,
      // so `?? 0` never actually fires.
      dt_a = dt_buf[dt_idx] ?? 0;
      if dt_a > 0 {
        self.blend_pixel(dt_x + dt_bx, dt_y + dt_by, rgba(dt_cr, dt_cg, dt_cb, dt_a));
      }
    }
  }
}

// --- Textures (GL5.5) ---

// Load an image file (PNG, JPG) and upload it as a GL texture.
// Returns the texture ID (0 on failure).
pub fn gl_load_texture(path: text) -> integer;
#native "loft_gl_load_texture"

// Upload Canvas pixel data as a GL texture. Returns the texture ID.
// Converts 0xAARRGGBB packed integers to RGBA bytes for OpenGL.
pub fn gl_upload_canvas(data: const vector<integer>, width: integer, height: integer) -> integer;
#native "loft_gl_upload_canvas"

// Bind a texture to a texture unit (0-based) for rendering.
pub fn gl_bind_texture(texture_id: integer, unit: integer);
#native "loft_gl_bind_texture"

// Delete a texture.
pub fn gl_delete_texture(texture_id: integer);
#native "loft_gl_delete_texture"

// --- GPU resource cleanup ---

// Delete a shader program.
pub fn gl_delete_shader(program: integer);
#native "loft_gl_delete_shader"

// Delete a vertex array object.
pub fn gl_delete_vao(vao: integer);
#native "loft_gl_delete_vao"

// Delete a framebuffer object.
pub fn gl_delete_framebuffer(fbo: integer);
#native "loft_gl_delete_framebuffer"

// ── G1/G2: Sprite Sheet API ─────────────────────────────────────────

// Vertex shader for textured 2D quads. Uses the unit quad positions (0-1)
// to interpolate UVs between uU0,uV0 and uU1,uV1.
SPRITE_VERT = `
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 uMVP;
uniform float uU0;
uniform float uV0;
uniform float uU1;
uniform float uV1;
out vec2 vUV;
void main() {{
    gl_Position = uMVP * vec4(aPos, 1.0);
    vUV = vec2(mix(uU0, uU1, aPos.x), mix(uV0, uV1, aPos.y));
}}
`;

// Fragment shader — samples the atlas texture.
SPRITE_FRAG = `
#version 330 core
in vec2 vUV;
uniform sampler2D uTex;
out vec4 FragColor;
void main() {{
    vec4 c = texture(uTex, vUV);
    if (c.a < 0.01) discard;
    FragColor = c;
}}
`;

/// A sprite sheet backed by a Canvas atlas uploaded as a GL texture.
/// Sprites are arranged in a grid of `ss_cols` x `ss_rows` cells.
pub struct SpriteSheet {
  pub const ss_tex: integer,  // GL texture handle
  const ss_shader: integer,   // textured quad shader
  const ss_vao: integer,      // unit quad VAO (shared)
  const ss_cols: integer,
  const ss_rows: integer,
  const ss_cell_w: integer,
  const ss_cell_h: integer,
}

/// Create a sprite sheet from a Canvas atlas.
/// The canvas is divided into a grid of `cols` x `rows` cells.
/// Sprite index 0 is top-left, incrementing left-to-right then top-to-bottom.
pub fn create_sprite_sheet(atlas: const Canvas, cols: integer, rows: integer, vao: integer) -> SpriteSheet {
  ss_shader = gl_create_shader(SPRITE_VERT, SPRITE_FRAG);
  ss_tex = gl_upload_canvas(atlas.data, atlas.width, atlas.height);
  SpriteSheet {
    ss_tex: ss_tex,
    ss_shader: ss_shader,
    ss_vao: vao,
    ss_cols: cols,
    ss_rows: rows,
    ss_cell_w: atlas.width / cols ?? 0,
    ss_cell_h: atlas.height / rows ?? 0,
  }
}

/// Draw sprite at `index` (0-based, row-major).
/// `mvp` is the pre-multiplied projection * model matrix (16 singles).
/// The caller builds the MVP for the desired screen position and size.
pub fn draw_sprite(sheet: const SpriteSheet, mvp: const vector<float>, sp_idx: integer) {
  // `?? 0` on each div is lint suppression — `ss_cols` / `ss_rows`
  // are construction-time positive (zero would mean an empty atlas
  // with no sprites to draw).
  sp_col = (sp_idx % sheet.ss_cols) ?? 0;
  sp_row = (sp_idx / sheet.ss_cols) ?? 0;
  sp_u0 = ((sp_col as float) / (sheet.ss_cols as float)) ?? 0.0;
  sp_v0 = ((sp_row as float) / (sheet.ss_rows as float)) ?? 0.0;
  sp_u1 = (((sp_col + 1) as float) / (sheet.ss_cols as float)) ?? 0.0;
  sp_v1 = (((sp_row + 1) as float) / (sheet.ss_rows as float)) ?? 0.0;
  gl_use_shader(sheet.ss_shader);
  gl_bind_texture(sheet.ss_tex, 0);
  gl_set_uniform_int(sheet.ss_shader, "uTex", 0);
  gl_set_uniform_float(sheet.ss_shader, "uU0", sp_u0);
  gl_set_uniform_float(sheet.ss_shader, "uV0", sp_v0);
  gl_set_uniform_float(sheet.ss_shader, "uU1", sp_u1);
  gl_set_uniform_float(sheet.ss_shader, "uV1", sp_v1);
  gl_set_uniform_mat4(sheet.ss_shader, "uMVP", mvp);
  gl_draw(sheet.ss_vao, 6);
}

// ── G3: Painter2D — ergonomic 2D draw helpers ───────────────────────
//
// Painter2D bundles a flat-color shader, a textured-quad shader, a shared
// unit-quad VAO, and an orthographic projection sized for the window. It
// lets 2D games draw rectangles, sprites, and textures with (x, y, w, h)
// screen coordinates without hand-building MVP matrices.
//
// Screen-space origin: top-left. Y increases downward (pixel-style).
// All draw helpers assume the unit quad is at Z=0.

// Flat-color vertex shader — transforms the unit quad by uMVP.
FLAT_VERT_2D = `
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 uMVP;
void main() {{ gl_Position = uMVP * vec4(aPos, 1.0); }}
`;

// Flat-color fragment shader — uColor (vec3) + uAlpha (float).
FLAT_FRAG_2D = `
#version 330 core
uniform vec3 uColor;
uniform float uAlpha;
out vec4 FragColor;
void main() {{ FragColor = vec4(uColor, uAlpha); }}
`;

// Textured quad vertex shader — samples the full texture with identity UV.
// `glTexImage2D` stores canvas-top at TC.y=0 and the 2D ortho projection
// maps top-left screen coords onto clip-space, so no per-shader V flip is
// needed.  PNG-loaded textures sample the same way.
TEX_VERT_2D = `
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 uMVP;
out vec2 vUV;
void main() {{
    gl_Position = uMVP * vec4(aPos, 1.0);
    vUV = vec2(aPos.x, aPos.y);
}}
`;

// Textured quad fragment shader — alpha-discards fully transparent pixels.
TEX_FRAG_2D = `
#version 330 core
in vec2 vUV;
uniform sampler2D uTex;
out vec4 FragColor;
void main() {{
    vec4 c = texture(uTex, vUV);
    if (c.a < 0.01) discard;
    FragColor = c;
}}
`;

/// A 2D painter bundling shaders, a unit-quad VAO, and an orthographic
/// projection. Create once per window with `create_painter_2d`, then use
/// `draw_rect_at`, `draw_sprite_at`, and `draw_texture_at` to render.
pub struct Painter2D {
  p2_flat: integer,        // flat color shader
  pub p2_tex: integer,     // textured quad shader (V-flipped)
  pub p2_vao: integer,     // shared unit-quad VAO
  pub p2_proj: vector<float>,           // orthographic projection (16 floats)
}

/// Create a Painter2D for a window of size `screen_w` x `screen_h`.
/// Compiles the flat and textured shaders, uploads the unit-quad VAO, and
/// builds an orthographic projection with the origin at the top-left.
/// Call once after `gl_create_window`.
pub fn create_painter_2d(screen_w: float, screen_h: float) -> Painter2D {
  cpn_flat = gl_create_shader(FLAT_VERT_2D, FLAT_FRAG_2D);
  cpn_tex = gl_create_shader(TEX_VERT_2D, TEX_FRAG_2D);
  // Unit quad — two triangles from (0,0) to (1,1), z=0, in f32.
  cpn_quad = [
    0.0f, 0.0f, 0.0f,  1.0f, 0.0f, 0.0f,  1.0f, 1.0f, 0.0f,
    0.0f, 0.0f, 0.0f,  1.0f, 1.0f, 0.0f,  0.0f, 1.0f, 0.0f
  ];
  cpn_vao = gl_upload_vertices(cpn_quad, 3);
  // `?? 0.0` is lint suppression — both screen_w and screen_h are
  // construction-time positive (caller passes window dimensions).
  cpn_proj = [
    2.0 / screen_w ?? 0.0, 0.0, 0.0, 0.0,
    0.0, -2.0 / screen_h ?? 0.0, 0.0, 0.0,
    0.0, 0.0, -1.0, 0.0,
    -1.0, 1.0, 0.0, 1.0
  ];
  Painter2D {
    p2_flat: cpn_flat,
    p2_tex: cpn_tex,
    p2_vao: cpn_vao,
    p2_proj: cpn_proj,
  }
}

/// Return the unit-quad VAO owned by the painter, so it can be shared with
/// `create_sprite_sheet` instead of uploading a second copy.
pub fn painter_vao(self: const Painter2D) -> integer { self.p2_vao }

// Build an MVP = proj * translate(x, y, 0) * scale(w, h, 1) without going
// through the generic math::mat4_mul path. The model matrix is sparse, so
// this collapses to 4 column updates on the 16-float projection.
fn build_mvp_2d(proj: const vector<float>, x: float, y: float, w: float, h: float) -> vector<float> {
  bmn_mvp = [
    // column 0 = proj.col0 * w
    proj[0] * w,  proj[1] * w,  proj[2] * w,  proj[3] * w,
    // column 1 = proj.col1 * h
    proj[4] * h,  proj[5] * h,  proj[6] * h,  proj[7] * h,
    // column 2 = proj.col2 (z untouched)
    proj[8],      proj[9],      proj[10],     proj[11],
    // column 3 = proj.col0 * x + proj.col1 * y + proj.col3
    proj[0] * x + proj[4] * y + proj[12],
    proj[1] * x + proj[5] * y + proj[13],
    proj[2] * x + proj[6] * y + proj[14],
    proj[3] * x + proj[7] * y + proj[15]
  ];
  bmn_mvp
}

/// Draw a filled rectangle at screen position (x, y) with size (w, h).
/// Colour channels are in 0.0..1.0. Uses the painter's flat-color shader.
pub fn draw_rect_at(painter: const Painter2D, x: float, y: float, w: float, h: float,
                    r: float, g: float, b: float, a: float) {
  drn_mvp = build_mvp_2d(painter.p2_proj, x, y, w, h);
  gl_use_shader(painter.p2_flat);
  gl_set_uniform_mat4(painter.p2_flat, "uMVP", drn_mvp);
  gl_set_uniform_vec3(painter.p2_flat, "uColor", r, g, b);
  gl_set_uniform_float(painter.p2_flat, "uAlpha", a);
  gl_draw(painter.p2_vao, 6);
}

/// Draw a sprite from `sheet` at screen position (x, y) with size (w, h).
/// `sp_idx` is the 0-based sprite index (row-major) in the sheet.
/// This is a one-liner replacement for `make_mvp` + `draw_sprite`.
pub fn draw_sprite_at(sheet: const SpriteSheet, painter: const Painter2D,
                      x: float, y: float, w: float, h: float, sp_idx: integer) {
  dsn_mvp = build_mvp_2d(painter.p2_proj, x, y, w, h);
  draw_sprite(sheet, dsn_mvp, sp_idx);
}

/// Draw an arbitrary GL texture as a screen-space quad at (x, y, w, h).
/// Uses the painter's textured shader, which flips V to match canvas origin.
/// Suitable for textures created with `create_text_texture` or `gl_upload_canvas`.
pub fn draw_texture_at(painter: const Painter2D, tex: integer,
                       x: float, y: float, w: float, h: float) {
  dxn_mvp = build_mvp_2d(painter.p2_proj, x, y, w, h);
  gl_use_shader(painter.p2_tex);
  gl_bind_texture(tex, 0);
  gl_set_uniform_int(painter.p2_tex, "uTex", 0);
  gl_set_uniform_mat4(painter.p2_tex, "uMVP", dxn_mvp);
  gl_draw(painter.p2_vao, 6);
}

/// Rasterize `content` with `font` at `size` pixels, upload the glyph bitmap
/// as a GL texture, and return the texture ID. The texture is sized to fit
/// the measured text plus a small padding; pair with `draw_texture_at` to
/// render. Caller owns the texture — call `gl_delete_texture` when done.
/// Returns 0 if the text cannot be measured.
pub fn create_text_texture(font: integer, content: text, size: float, color: integer) -> integer {
  ctn_pad = 2;
  ctn_tw = gl_measure_text(font, content, size) as integer + ctn_pad * 2 + 1;
  ctn_th = gl_text_height(font, size) + ctn_pad * 2;
  if ctn_tw <= 0 or ctn_th <= 0 { return 0; }
  ctn_cv = canvas(ctn_tw, ctn_th, 0);
  ctn_cv.draw_text(font, content, size, ctn_pad, ctn_pad, color);
  gl_upload_canvas(ctn_cv.data, ctn_tw, ctn_th)
}

// --- G5: Audio ---
// Load an audio clip (WAV or OGG). Returns handle (>= 0) or null on failure.
pub fn audio_load(path: text) -> integer;
#native "loft_audio_load"
// Play a loaded clip.  Returns a playback handle, or -1 when it will not play.
//
// `volume` is 0.0-1.0, `looping` repeats the clip until it is stopped, `pan`
// places it across the stereo field (-1 left, 0 centre, 1 right), and `start`
// begins that many seconds in.  Everything past `volume` has a default, so an
// existing `audio_play(clip, 0.8)` keeps meaning what it meant.
//
// `start` skips into the FIRST pass only: a looping clip then repeats whole,
// which is what a browser's `start(when, offset)` with `loop` does.
//
// A handle is never handed out twice.  When a clip finishes its slot is reused,
// but the handle carries which use it belongs to, so an `audio_stop` on a
// finished sound stops nothing instead of stopping whatever took its place.
pub fn audio_play(clip: integer, volume: float, looping: boolean = false,
                  pan: float = 0.0, start: float = 0.0) -> integer;
#native "loft_audio_play"
// Stop a playing clip by its playback handle.
pub fn audio_stop(sink: integer);
#native "loft_audio_stop"
// Stop every playing clip — a pause menu, a scene change, a game over.
pub fn audio_stop_all();
#native "loft_audio_stop_all"
// Set volume of a playing clip (0.0-1.0).
pub fn audio_set_volume(sink: integer, volume: float);
#native "loft_audio_set_volume"
// Move a playing clip across the stereo field: -1 left, 0 centre, 1 right.
//
// The law is Web Audio's `StereoPannerNode`, equal-power for a mono clip: a
// sweep holds its loudness through the middle rather than dipping.  A stereo
// clip is narrowed toward the near side rather than having the far one muted.
pub fn audio_set_pan(sink: integer, pan: float);
#native "loft_audio_set_pan"
// Jump a playing clip to `seconds` from its start.  Answers whether it moved.
//
// A LOOPING clip cannot seek — repeating needs a buffered source, which has no
// earlier position to go back to — and this answers false rather than leaving
// the caller believing a jump happened.
pub fn audio_seek(sink: integer, seconds: float) -> boolean;
#native "loft_audio_seek"
// Play raw PCM samples (mono, values -1.0 to 1.0) at the given sample rate.
// Returns playback handle, or null on failure.
pub fn audio_play_raw(samples: vector<single>, sample_rate: integer, volume: float) -> integer;
#native "loft_audio_play_raw"

// --- Procedural audio synthesis (chiptune) ---

SFX_RATE = 22050;

// Square wave sample: phase 0.0-1.0, duty 0.0-1.0.
fn sfx_sq(sfx_sq_phase: single, sfx_sq_duty: single) -> single {
  if sfx_sq_phase < sfx_sq_duty { return 1.0f; }
  return -1.0f;
}
// Triangle wave sample: phase 0.0-1.0.
fn sfx_tri(sfx_tri_p: single) -> single {
  if sfx_tri_p < 0.5f { return sfx_tri_p * 4.0f - 1.0f; }
  return 3.0f - sfx_tri_p * 4.0f;
}
// Sawtooth wave sample: phase 0.0-1.0.
fn sfx_saw(sfx_saw_p: single) -> single {
  return sfx_saw_p * 2.0f - 1.0f;
}
// Linear attack-decay envelope: returns 0.0-1.0.
fn sfx_env(sfx_env_t: single, sfx_env_atk: single, sfx_env_dur: single) -> single {
  // `?? 0.0f` is lint suppression — the attack branch only fires when
  // `sfx_env_t < sfx_env_atk`, which transitively implies atk > 0
  // unless sfx_env_t is negative (caller invariant).
  if sfx_env_t < sfx_env_atk { return (sfx_env_t / sfx_env_atk) ?? 0.0f; }
  sfx_env_rem = sfx_env_dur - sfx_env_t;
  if sfx_env_rem < 0.0f { return 0.0f; }
  sfx_env_dec = sfx_env_dur - sfx_env_atk;
  if sfx_env_dec <= 0.0f { return 0.0f; }
  // `?? 0.0f` is lint suppression — `sfx_env_dec > 0.0f` is enforced
  // by the early-return guard above.
  return (sfx_env_rem / sfx_env_dec) ?? 0.0f;
}

// Short percussive beep — brick hit, UI click.
pub fn sfx_beep(sb_freq: single, sb_dur: single, sb_vol: single) -> integer {
  sb_n = (sb_dur * SFX_RATE as single) as integer;
  sb_buf: vector<single> = [for sb_i in 0..sb_n {
    sb_t = sb_i as single / SFX_RATE as single;
    sb_p = (sb_t * sb_freq) % 1.0f;
    sfx_sq(sb_p, 0.5f) * sfx_env(sb_t, 0.005f, sb_dur) * sb_vol
  }];
  return audio_play_raw(sb_buf, SFX_RATE, 1.0);
}

// Descending tone — life lost, negative event.
pub fn sfx_descend(sd_f0: single, sd_f1: single, sd_dur: single, sd_vol: single) -> integer {
  sd_n = (sd_dur * SFX_RATE as single) as integer;
  sd_ph = 0.0f;
  sd_buf: vector<single> = [for sd_i in 0..sd_n {
    sd_t = sd_i as single / SFX_RATE as single;
    sd_f = sd_f0 + (sd_f1 - sd_f0) * ((sd_t / sd_dur) ?? 0.0f);
    sd_ph = (sd_ph + sd_f / SFX_RATE as single) % 1.0f;
    sfx_saw(sd_ph) * sfx_env(sd_t, 0.01f, sd_dur) * sd_vol
  }];
  return audio_play_raw(sd_buf, SFX_RATE, 1.0);
}

// Rising chirp — pickup collected, positive event.
pub fn sfx_chirp(sc_f0: single, sc_f1: single, sc_dur: single, sc_vol: single) -> integer {
  sc_n = (sc_dur * SFX_RATE as single) as integer;
  sc_ph = 0.0f;
  sc_buf: vector<single> = [for sc_i in 0..sc_n {
    sc_t = sc_i as single / SFX_RATE as single;
    sc_f = sc_f0 + (sc_f1 - sc_f0) * ((sc_t / sc_dur) ?? 0.0f);
    sc_ph = (sc_ph + sc_f / SFX_RATE as single) % 1.0f;
    sfx_tri(sc_ph) * sfx_env(sc_t, 0.005f, sc_dur) * sc_vol
  }];
  return audio_play_raw(sc_buf, SFX_RATE, 1.0);
}

// White noise burst — percussion, explosion, brick break.
pub fn sfx_noise(sn_dur: single, sn_vol: single) -> integer {
  sn_n = (sn_dur * SFX_RATE as single) as integer;
  sn_seed = 12345;
  sn_buf: vector<single> = [for sn_i in 0..sn_n {
    sn_t = sn_i as single / SFX_RATE as single;
    sn_seed = (sn_seed * 1103515245 + 12345) % 2147483647;
    sn_rnd = (sn_seed as single / 1073741824.0f) - 1.0f;
    sn_rnd * sfx_env(sn_t, 0.002f, sn_dur) * sn_vol
  }];
  return audio_play_raw(sn_buf, SFX_RATE, 1.0);
}

// Paddle bounce — low square wave thud with pitch drop.
pub fn sfx_bounce(sp_freq: single, sp_vol: single) -> integer {
  return sfx_beep(sp_freq, 0.08f, sp_vol);
}

src/render.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// High-level renderer — scene-driven PBR with shadows.
// One call to render_frame() handles shadow pass, color pass, and buffer swap.
// Design: doc/claude/RENDERER.md
use graphics::*;
use mesh3d::*;

// ── Built-in shaders ─────────────────────────────────────────────────────

SHADOW_VERT = `
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 uModel;
uniform mat4 uLightVP;
void main() {{ gl_Position = uLightVP * uModel * vec4(aPos, 1.0); }}
`;

SHADOW_FRAG = `
#version 330 core
void main() {{}}
`;

PBR_VERT = `
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProj;
uniform mat4 uLightVP;
out vec3 vWorldPos;
out vec3 vNormal;
out vec4 vLightSpacePos;
void main() {{
    vec4 worldPos = uModel * vec4(aPos, 1.0);
    vWorldPos = vec3(worldPos);
    vNormal = mat3(transpose(inverse(uModel))) * aNormal;
    vLightSpacePos = uLightVP * worldPos;
    gl_Position = uProj * uView * worldPos;
}}
`;

PBR_FRAG = `
#version 330 core
in vec3 vWorldPos;
in vec3 vNormal;
in vec4 vLightSpacePos;
out vec4 FragColor;
uniform vec3 uCamPos;
uniform vec3 uLightDir;        // world-space direction the light travels
uniform vec3 uLightColor;      // directional light colour × intensity
uniform vec3 uPointLightPos;   // world-space point-light position
uniform vec3 uPointLightColor; // point-light colour × intensity (0,0,0 = off)
uniform int uHasPointLight;
uniform vec3 uColor;
uniform float uMetallic;
uniform float uRoughness;
uniform sampler2D uShadowMap;
uniform int uHasShadow;
float shadow_calc(vec4 lsp) {{
    vec3 proj = lsp.xyz / lsp.w;
    proj = proj * 0.5 + 0.5;
    if (proj.z > 1.0) return 0.0;
    float closest = texture(uShadowMap, proj.xy).r;
    float current = proj.z;
    float bias = max(0.005 * (1.0 - dot(normalize(vNormal), normalize(-uLightDir))), 0.001);
    return current - bias > closest ? 0.6 : 0.0;
}}
void main() {{
    vec3 N = normalize(vNormal);
    vec3 V = normalize(uCamPos - vWorldPos);
    float rough_pow = mix(8.0, 128.0, 1.0 - uRoughness);
    float fresnel = pow(1.0 - max(dot(N, V), 0.0), 5.0);
    vec3 F0 = mix(vec3(0.04), uColor, uMetallic);
    float shadow = 0.0;
    if (uHasShadow == 1) shadow = shadow_calc(vLightSpacePos);

    // Directional light — scaled by its colour × intensity (uLightColor).
    vec3 L = normalize(-uLightDir);
    vec3 H = normalize(V + L);
    float diff = max(dot(N, L), 0.0);
    float spec = pow(max(dot(N, H), 0.0), rough_pow);
    float shade = 1.0 - shadow;
    vec3 diffuse  = uColor * uLightColor * diff * (1.0 - uMetallic * 0.7) * shade;
    vec3 specular = F0 * uLightColor * spec * (1.0 + fresnel) * shade;

    // Point light — quadratic attenuation, no shadow.  Skipped when the
    // scene has no point light (uHasPointLight == 0).
    if (uHasPointLight == 1) {{
        vec3 Pd = uPointLightPos - vWorldPos;
        float dist = length(Pd);
        vec3 PL = Pd / max(dist, 0.0001);
        vec3 PH = normalize(V + PL);
        float atten = 1.0 / (1.0 + 0.1 * dist + 0.01 * dist * dist);
        float pdiff = max(dot(N, PL), 0.0);
        float pspec = pow(max(dot(N, PH), 0.0), rough_pow);
        diffuse  += uColor * uPointLightColor * pdiff * (1.0 - uMetallic * 0.7) * atten;
        specular += F0 * uPointLightColor * pspec * (1.0 + fresnel) * atten;
    }}

    vec3 ambient = uColor * mix(0.15, 0.08, uMetallic);
    vec3 result = ambient + diffuse + specular;
    // Tone mapping + gamma
    result = result / (result + vec3(1.0));
    result = pow(result, vec3(1.0 / 2.2));
    FragColor = vec4(result, 1.0);
}}
`;

// ── Renderer struct ──────────────────────────────────────────────────────

// A window with the 3D renderer's GL state: its size, the PBR and shadow shaders, the
// shadow-map framebuffer and texture, and the vertex arrays `upload_scene` filled.
// `create_renderer` makes one.
pub struct Renderer {
  const width: integer,
  const height: integer,
  const pbr_shader: integer,
  const shadow_shader: integer,
  const shadow_fbo: integer,
  const shadow_tex: integer,
  const shadow_size: integer,
  const start_tick: integer,
  const vaos: vector<integer>,          // const: contents grown via `+=` in upload_scene
  const vert_counts: vector<integer>,   // const: contents grown via `+=` in upload_scene
  uploaded: boolean               // not const: genuinely reassigned (`self.uploaded = true`)
}

// Create a renderer with a window.  Compiles built-in shaders, allocates
// shadow map FBO.  Returns null on failure (no display).
pub fn create_renderer(rw: integer, rh: integer, title: text) -> Renderer {
  if !graphics::gl_create_window(rw, rh, title) {
    return Renderer {};
  }
  rr_pbr = graphics::gl_create_shader(PBR_VERT, PBR_FRAG);
  rr_shadow = graphics::gl_create_shader(SHADOW_VERT, SHADOW_FRAG);
  rr_sz = 1024;
  rr_dtex = graphics::gl_create_depth_texture(rr_sz, rr_sz);
  rr_fbo = graphics::gl_create_framebuffer();
  graphics::gl_framebuffer_texture(rr_fbo, 1, rr_dtex);
  Renderer {
    // ⚠ THE SURFACE'S SIZE, NOT THE ONE WE ASKED FOR.  `gl_window_width` is
    // documented as the inner size in PHYSICAL pixels precisely so a projection
    // or a restored viewport uses the real resolution rather than a windowed
    // hint — and on a high-density display, or fullscreen, the two differ.
    // Reusing `rw`/`rh` as the colour pass's viewport (below) then renders the
    // scene into a fraction of the surface.
    width: graphics::gl_window_width(), height: graphics::gl_window_height(),
    pbr_shader: rr_pbr, shadow_shader: rr_shadow,
    shadow_fbo: rr_fbo, shadow_tex: rr_dtex, shadow_size: rr_sz,
    start_tick: ticks(),
    uploaded: false
  }
}

// Seconds since the renderer was created.
pub fn elapsed(self: const Renderer) -> float {
  (ticks() - self.start_tick) as float / 1000000.0
}

// Upload all meshes in the scene to GPU VAOs.
pub fn upload_scene(self: Renderer, sc: const scene::Scene) {
  for up_m in sc.meshes {
    up_data = mesh::mesh_to_floats(up_m);
    up_nv = up_data.len() / 6;
    up_vao = graphics::gl_upload_vertices(up_data, 6);
    self.vaos += [up_vao];
    self.vert_counts += [up_nv];
  }
  self.uploaded = true;
}

// Render one frame's 3D passes WITHOUT swapping buffers.  Callers
// that want to draw a 2D overlay between the 3D pass and the swap
// (e.g. the native Moros editor's panel) use this variant and
// invoke `graphics::gl_swap_buffers()` themselves after their
// overlay renders.  Returns false when close requested.
//
// Introduced by Initiative 03 Phase 3b (native moros editor
// panel overlay).  The existing `render_frame` wrapper below
// preserves backward compatibility with every current caller.
pub fn render_frame_no_swap(self: Renderer, sc: const scene::Scene,
                            cam: const scene::Camera) -> boolean {
  if !graphics::gl_poll_events() { return false; }
  if !self.uploaded { self.upload_scene(sc); }
  // Camera matrices
  rf_view = math::mat4_look_at(cam.position, cam.target, math::vec3(0.0, 1.0, 0.0));
  // `?? 1.0` is lint suppression — render targets always have a
  // positive height (zero would be a degenerate viewport).
  rf_proj = math::mat4_perspective(cam.fov * 0.01745, (self.width as float / self.height as float) ?? 1.0,
                                   cam.near, cam.far);
  rf_eye = cam.position;
  // Lighting — picks the first directional light (shadow caster) and
  // the first point light from the scene.  `intensity` is pre-folded
  // into the colour so the shader only reads one vec3 per light.
  // Defaults (no directional in scene): neutral white from upper-front.
  rf_has_shadow = 0;
  rf_light_dir = math::vec3(-1.0, -2.0, -1.0);
  rf_light_color = math::vec3(1.0, 1.0, 1.0);
  rf_light_vp = math::mat4_identity();
  for rl in sc.lights {
    if rl.light_type == scene::Directional {
      rf_light_dir = rl.direction;
      rf_light_color = math::vec3(
        rl.color_r * rl.intensity,
        rl.color_g * rl.intensity,
        rl.color_b * rl.intensity);
      // Orthographic light VP for shadow mapping.
      rf_light_eye = math::vec3(-rf_light_dir.x * 10.0, -rf_light_dir.y * 10.0, -rf_light_dir.z * 10.0);
      rf_light_view = math::mat4_look_at(rf_light_eye, math::vec3(0.0, 0.0, 0.0), math::vec3(0.0, 1.0, 0.0));
      rf_light_proj = math::mat4_ortho(-12.0, 12.0, -12.0, 12.0, 0.1, 30.0);
      rf_light_vp = math::mat4_mul(rf_light_proj, rf_light_view);
      rf_has_shadow = 1;
      break;
    }
  }
  rf_has_point = 0;
  rf_point_pos = math::vec3(0.0, 0.0, 0.0);
  rf_point_color = math::vec3(0.0, 0.0, 0.0);
  for rpl in sc.lights {
    if rpl.light_type == scene::Point {
      rf_point_pos = rpl.position;
      rf_point_color = math::vec3(
        rpl.color_r * rpl.intensity,
        rpl.color_g * rpl.intensity,
        rpl.color_b * rpl.intensity);
      rf_has_point = 1;
      break;
    }
  }
  // Shadow pass
  if rf_has_shadow == 1 {
    graphics::gl_viewport(0, 0, self.shadow_size, self.shadow_size);
    graphics::gl_bind_framebuffer(self.shadow_fbo);
    graphics::gl_clear(graphics::rgba(0, 0, 0, 255));
    graphics::gl_use_shader(self.shadow_shader);
    graphics::gl_set_uniform_mat4(self.shadow_shader, "uLightVP", rf_light_vp.m);
    rf_vaos = self.vaos;
    rf_vcs = self.vert_counts;
    for rn in sc.nodes {
      graphics::gl_set_uniform_mat4(self.shadow_shader, "uModel", rn.transform.m);
      rn_mi = rn.mesh_idx;
      if rn_mi >= 0 and rn_mi < len(rf_vaos) and rn_mi < len(rf_vcs) {
        graphics::gl_draw(rf_vaos[rn_mi], rf_vcs[rn_mi]);
      }
    }
    graphics::gl_bind_framebuffer(0);
    graphics::gl_viewport(0, 0, self.width, self.height);
  }
  // Color pass
  graphics::gl_clear(graphics::rgba(20, 20, 25, 255));
  graphics::gl_use_shader(self.pbr_shader);
  graphics::gl_set_uniform_mat4(self.pbr_shader, "uView", rf_view.m);
  graphics::gl_set_uniform_mat4(self.pbr_shader, "uProj", rf_proj.m);
  graphics::gl_set_uniform_mat4(self.pbr_shader, "uLightVP", rf_light_vp.m);
  graphics::gl_set_uniform_vec3(self.pbr_shader, "uCamPos", rf_eye.x, rf_eye.y, rf_eye.z);
  graphics::gl_set_uniform_vec3(self.pbr_shader, "uLightDir", rf_light_dir.x, rf_light_dir.y, rf_light_dir.z);
  graphics::gl_set_uniform_vec3(self.pbr_shader, "uLightColor", rf_light_color.x, rf_light_color.y, rf_light_color.z);
  graphics::gl_set_uniform_vec3(self.pbr_shader, "uPointLightPos",
    rf_point_pos.x, rf_point_pos.y, rf_point_pos.z);
  graphics::gl_set_uniform_vec3(self.pbr_shader, "uPointLightColor",
    rf_point_color.x, rf_point_color.y, rf_point_color.z);
  graphics::gl_set_uniform_int(self.pbr_shader, "uHasPointLight", rf_has_point);
  graphics::gl_set_uniform_int(self.pbr_shader, "uHasShadow", rf_has_shadow);
  if rf_has_shadow == 1 {
    graphics::gl_bind_texture(self.shadow_tex, 1);
    graphics::gl_set_uniform_int(self.pbr_shader, "uShadowMap", 1);
  }
  rp_vaos = self.vaos;
  rp_vcs = self.vert_counts;
  rp_mats = sc.materials;
  for rn in sc.nodes {
    rn_mi2 = rn.mesh_idx;
    rn_mati = rn.material_idx;
    if rn_mi2 >= 0 and rn_mi2 < len(rp_vaos) and rn_mi2 < len(rp_vcs) and rn_mati < len(rp_mats) {
      rm = rp_mats[rn_mati];
      graphics::gl_set_uniform_mat4(self.pbr_shader, "uModel", rn.transform.m);
      graphics::gl_set_uniform_vec3(self.pbr_shader, "uColor", rm.base_color_r, rm.base_color_g, rm.base_color_b);
      graphics::gl_set_uniform_float(self.pbr_shader, "uMetallic", rm.metallic);
      graphics::gl_set_uniform_float(self.pbr_shader, "uRoughness", rm.roughness);
      graphics::gl_draw(rp_vaos[rn_mi2], rp_vcs[rn_mi2]);
    }
  }
  true
}

// Render one frame (3D passes + buffer swap).  Convenience wrapper
// for callers that don't need a 2D overlay between the 3D pass and
// the swap — brick-buster, 24-renderer-demo, and every other current
// consumer.  Returns false when close requested.
pub fn render_frame(self: Renderer, sc: const scene::Scene,
                    cam: const scene::Camera) -> boolean {
  if !self.render_frame_no_swap(sc, cam) { return false; }
  graphics::gl_swap_buffers();
  true
}

// Convenience: render loop until close.
pub fn render_loop(self: Renderer, sc: const scene::Scene, cam: const scene::Camera) {
  for _ in 0..1000000 {
    if !self.render_frame(sc, cam) { break; }
  }
}

// Destroy GPU resources and close the window.
pub fn destroy(self: Renderer) {
  for rv in self.vaos {
    graphics::gl_delete_vao(rv);
  }
  graphics::gl_delete_shader(self.pbr_shader);
  graphics::gl_delete_shader(self.shadow_shader);
  graphics::gl_delete_texture(self.shadow_tex);
  graphics::gl_delete_framebuffer(self.shadow_fbo);
  graphics::gl_destroy_window();
}

tests/bicubic.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN146 W3 — `resize_bicubic`, pinned against PILLOW rather than against
// itself.
//
// `Image.resize(size)` with no `resample=` argument is BICUBIC, and `draw.py`
// builds every gradient that way: a 100x100 image enlarged to the shape's
// bounding box.  So the corpus depends on this filter without ever naming it,
// and a port that reads the call site's argument list sees no filter at all.
//
// Every expectation below is Pillow 10.2's actual output, captured once and
// written down.  A golden generated by the thing being ported would only pin the
// port to itself.
//
// ⚠ The first case is the one the corpus actually walks, and it is an
// ENLARGEMENT: bicubic has negative lobes, so it overshoots the source's range.
// A resampler that clamps that away passes a smoothness eyeball and fails here.
use graphics::*;

fn from_rgba(fr: const vector<integer>, fw: integer, fh: integer) -> graphics::Canvas {
  c = graphics::canvas(fw, fh, 0);
  d = &c.data;
  for i in 0..(fw * fh) {
    d[i] = (fr[i * 4 + 3]? << 24) | (fr[i * 4]? << 16) | (fr[i * 4 + 1]? << 8) | fr[i * 4 + 2]?;
  }
  c
}

fn differing(dc: const graphics::Canvas, want: const vector<integer>) -> integer {
  n = 0;
  for i in 0..len(want) { if dc.data[i]? != want[i]? { n += 1; } }
  n
}

fn test_bicubic_20x20_to_48x36() {
  // the gradient path's shape: a source ENLARGED, where the negative lobes overshoot
  src: vector<integer> = [219,50,201,180,32,239,84,223,66,113,123,186,250,103,187,116,234,93,20,249,73,236,212,102,11,239,111,90,55,129,82,146,47,0,160,8,131,149,60,99,149,195,163,116,118,182,248,61,150,192,234,187,248,143,241,168,6,27,73,230,38,116,149,12,189,64,105,69,169,177,172,149,244,103,204,37,115,123,154,153,212,165,3,84,50,248,208,239,192,203,12,152,24,22,103,102,52,175,102,72,50,38,68,137,107,174,52,107,26,81,79,187,86,43,250,143,115,110,138,8,10,121,73,224,119,244,85,135,208,1,16,234,31,198,35,247,142,111,12,216,53,149,109,168,117,173,114,4,114,71,19,255,242,77,78,193,217,100,120,175,55,130,160,205,164,248,222,109,92,72,148,216,145,63,133,66,230,207,86,244,115,134,133,38,177,47,88,74,245,224,78,99,64,216,217,5,159,39,45,207,109,235,191,196,210,236,1,228,145,229,146,96,125,32,16,30,135,66,15,16,119,235,112,254,150,159,123,38,252,223,184,127,64,26,106,103,217,233,212,22,161,140,113,96,178,62,71,222,0,251,78,186,59,207,235,99,88,130,46,5,227,171,249,15,180,249,28,81,196,213,112,95,67,60,188,188,48,190,171,119,21,222,233,236,176,18,192,69,25,66,160,77,17,181,195,183,109,127,178,86,244,29,173,93,85,113,149,92,143,117,221,115,113,143,183,137,104,192,40,185,189,74,165,188,99,158,89,161,210,158,55,49,172,99,175,116,253,90,247,170,98,150,93,99,152,101,48,174,58,97,77,52,205,153,11,19,217,235,45,65,110,23,217,84,47,206,56,238,114,217,177,255,118,170,173,20,13,237,2,199,170,251,150,242,82,231,101,95,25,73,108,47,250,86,30,215,222,98,205,144,139,89,86,106,36,115,67,207,145,158,67,125,137,35,235,5,250,166,226,82,150,223,236,56,241,22,48,19,24,41,49,56,158,186,105,42,235,63,2,179,109,93,249,218,146,65,41,139,7,185,173,228,176,3,143,141,25,217,74,134,147,124,200,71,53,118,83,209,163,151,14,52,234,61,204,172,1,10,212,249,245,189,162,102,163,60,16,114,70,231,203,226,13,11,55,170,30,117,94,166,118,19,6,115,131,47,229,246,16,156,255,20,241,133,55,116,179,90,112,39,128,240,176,168,140,165,44,1,179,185,166,167,158,90,250,219,232,6,130,220,241,19,216,157,5,94,10,155,116,86,143,145,21,185,253,129,101,65,62,114,172,91,175,123,50,228,141,215,216,157,8,142,189,143,173,148,135,119,219,111,253,191,153,16,218,246,93,161,231,232,83,110,102,129,48,21,89,195,248,129,210,198,1,38,103,118,9,92,29,242,204,225,237,61,179,205,127,58,115,228,31,185,150,59,8,248,44,105,255,46,57,31,170,59,139,247,82,119,7,7,115,226,176,151,162,3,152,225,213,10,244,120,124,112,82,61,63,181,242,34,248,38,216,47,135,87,139,239,70,216,39,112,221,254,114,175,179,9,106,250,13,242,67,18,136,208,21,16,9,115,238,234,56,175,62,190,234,205,48,3,242,91,74,98,65,20,208,31,202,53,199,197,35,106,133,46,213,158,213,106,19,162,190,112,166,182,101,166,34,44,94,135,46,26,191,95,191,197,34,191,170,192,125,185,183,81,129,169,158,195,159,183,28,112,41,192,144,192,89,229,93,194,203,142,163,164,101,93,127,250,118,245,42,87,116,22,30,238,109,26,102,233,161,205,39,111,251,49,136,206,132,219,227,189,238,236,62,35,8,152,25,184,161,182,200,224,5,21,133,211,61,101,144,25,64,95,203,193,45,209,101,18,135,36,211,209,59,68,129,66,30,64,113,26,244,64,145,121,48,125,187,191,0,31,151,225,171,34,144,123,57,54,176,62,129,21,124,118,172,212,139,162,74,230,68,58,220,111,169,89,176,228,173,168,96,105,106,178,41,217,160,235,204,107,210,232,136,199,19,235,105,43,231,89,202,167,155,181,212,218,209,207,93,167,202,44,190,61,133,117,2,36,145,131,115,161,99,31,27,208,107,207,50,128,202,202,100,57,242,12,127,183,197,52,10,222,132,158,129,36,218,67,249,136,175,113,167,75,1,158,78,121,127,111,32,141,189,100,41,35,113,96,21,58,29,118,141,157,238,45,216,205,61,86,223,178,181,216,113,222,161,80,19,190,137,217,248,197,250,142,77,75,25,209,38,188,172,128,38,208,253,21,15,199,113,32,212,214,118,63,67,95,173,41,240,104,91,254,233,234,35,239,128,198,36,49,165,36,48,51,185,167,102,139,118,17,198,153,21,82,114,178,138,118,121,245,12,219,60,163,62,98,75,35,159,43,134,31,40,80,247,96,144,156,103,8,200,180,6,28,151,15,110,195,34,65,255,135,154,152,188,35,47,237,212,95,55,72,220,224,105,233,66,217,54,63,148,54,35,114,244,99,205,242,37,232,21,206,216,227,29,227,131,236,125,213,17,180,236,6,14,172,141,149,121,191,112,93,211,52,4,72,223,239,75,253,74,52,210,3,4,236,71,115,175,92,143,54,209,31,20,130,238,64,106,237,22,17,167,228,38,139,223,133,42,76,49,193,213,131,96,200,122,221,125,53,118,156,190,158,207,48,227,29,250,25,196,53,221,88,96,38,147,137,13,205,242,33,164,29,123,115,231,224,82,226,238,67,117,37,65,113,161,112,155,216,136,27,202,240,240,180,82,88,30,22,58,19,63,254,50,219,170,161,55,60,76,184,144,86,88,119,238,21,250,206,135,129,45,127,252,176,208,124,29,149,50,179,197,163,103,177,21,169,38,180,100,83,103,142,198,211,50,79,178,43,100,181,67,205,104,21,253,132,27,58,75,144,231,163,225,71,11,110,87,141,221,156,192,74,117,111,233,114,230,10,233,86,57,199,98,127,113,106,84,186,240,42,41,236,25,173,165,73,248,5,229,196,53,146,113,243,25,4,14,9,126,168,11,254,76,198,221,104,37,108,134,107,197,50,39,121,24,143,147,140,108,67,27,233,240,192,157,128,112,69,44,10,230,19,138,122,36,161,66,152,27,193,57,218,199,235,171,177,95,35,203,169,188,18,13,57,143,253,65,70,121,242,187,108,221,142,103,182,51,249,81,227,64,82,42,97,234,114,92,126,232,133,75,37,156,54,204,68,230,77,102,34,232,210,106,212,72,47,238,244,132,0,142,127,216,115,24,18,43,148,47,178,143,159,151,50,122,207,200,241,120,51,201,213,146,192,231,111,167,103,236,248,89,177,16,163,37,3,66,65,116,244,111,39,109,125,37,206,32,84,62,178,74,148,243,13,102,40,206,47,191,240,31,4,135,202,165,39,252,219,76,91,137,103,127,241,66,255,204,243,101,153,139,95,71,133,153,9,127,216,216,20,88,44,87,6,253,77,204,53,99,19,117,187,226,227,152,71,29,151,223,152,159,48,9,164,194,250,234,190,148,71,242,207,255,87,97,122,151,19,45,105,91,184,207,66,16,108,249,222,206,88,194,147,79,33,81,104,92,146,77,16,110,204,130,11,30,139,122,62,109,5,183,226];
  want: vector<integer> = [3119584989,3217637570,3496911231,3726830409,3625373520,3341387379,2907660176,2192204992,2013227960,3154074696,4294334997,3940313891,2442965376,1512964089,1342242770,1476589174,1897446748,2402390101,2033745997,707033410,0,629311750,1635815473,1972216661,1939980429,1722602171,1184481758,913419007,1618659839,2743523327,3148336383,2868491007,2751431167,3447407007,3960150361,2969570112,905969725,262144,402593368,1203648868,1823834004,2326902202,2092221897,986955245,450980863,1299871666,2355854752,2741468826,2382308794,2647349154,3295000460,3878023290,3693796972,3176113246,2675013987,2123850127,1925206177,2530825039,3218628125,3049155371,2323220592,1848683428,1612895372,1596513885,2049289037,2636544333,2452185946,1513310611,810886644,779704486,1032164935,1533910859,2237308265,2421531002,1767029649,1298584502,1972674480,3097984927,3551429280,3417415337,3332350623,3747965802,4013177150,3207213108,1646874184,708219263,546267007,800930663,1688234856,2811199593,2895675498,1992584812,1408784759,1941989001,2644013453,2895082894,1207924245,1673636408,2924667303,4046387143,3780047773,2896026680,2344070659,2003786295,1730485103,1462123884,1296404057,1547859024,2100640079,2318742859,2001695552,1785050679,2252247098,2988137796,3139192422,2839099061,2253859327,1081023487,131755519,872450348,2818863938,3658513992,2871441495,2104030819,2629138494,3637710879,4139000091,4234118440,4196975661,4115113503,3900403728,3531237408,3024977743,2267648373,873376673,111848106,1416642316,3614066449,4219750940,3686615603,3086503760,3002095722,3033883509,3016714109,2204469349,2338300783,2639389116,2974482407,3110657978,3129973352,2744413510,1885615181,1395725666,1836681060,2410401379,2359212382,1885430095,1597513795,1499349311,1569092415,1953922369,2489153604,2421518689,1833915569,1381641215,1267859626,1437665587,2104512321,3157691241,3711347563,3530086728,3198613024,3116740887,3067037470,2930717723,2723512081,2518137862,2219029248,2056683264,2609418794,3613505885,3528641136,1665849982,225831797,1101294895,2962648884,3533727802,3232583234,2713860432,2211857511,1793811071,1610182286,3593502109,3309012899,2489498299,1839332062,2458165434,3479328667,3294183057,1851401618,1134513277,2480846427,4008037461,3454193495,1556267619,525488788,746130291,1235955034,1506900051,1726859853,1392243280,419430260,138370943,1520108831,3198229798,3666956627,3396514981,3346331315,3735745871,3872451587,3167546382,2057955938,1300551589,813338490,478089737,108986368,93913088,1366808161,3531073902,4151765616,2390024301,713401203,922733496,1879041468,2196096686,2039179144,1698436711,1061168241,481144246,251658239,2370211474,2438890894,2610589305,2847826795,3246488169,3593903216,3240208764,2133239970,1331540433,1505020041,1826477641,1425007429,569892987,249214719,632989017,1153151032,1390203713,1543303252,1658311290,1752528297,1933458360,2320710789,2758563937,3243872650,3728463566,3745443548,3061896859,2279319610,1691919664,1215142015,1192860350,1712627904,1931255744,1294247396,660762623,1085242295,2208930700,2611060609,1888128895,1214673293,1369084096,1842065890,2025564899,1953660883,1882942381,1849923178,1834732334,1834999317,1471124864,1823700590,2797290833,3719314757,3665393473,3173047619,2701197149,2216161698,1665517292,911846143,242043135,67043583,165820671,464761751,1001513007,1471146525,1539239980,1472914517,1822139019,2656915630,3074571196,2537119412,1882175149,2317083593,3457343203,3675771626,2486740447,1317416381,1082598307,1296187042,1763539374,2567939775,2970728131,2468524729,1766562717,1553493656,1608794283,1643065773,1587893405,1582921615,1650161582,1769696725,1938256857,2189787078,2457964450,2809636708,3111106616,3245129254,2730774694,2780518041,2996921714,3062461777,2457179202,1616040247,1381033562,1735370907,2039578307,2110219240,2046316799,1793808112,1369078435,1382454602,2122810670,2660925227,2224587570,1350931020,1013614711,1130057620,1247366046,1181326470,1132330869,1332012455,1715574499,2167506637,2704057465,3157310283,3310211183,3362706087,3027225275,1783745238,1091030976,1711659346,2702302494,3226131771,3499744637,3300580765,2426527124,1533006971,1109831054,993552318,1451184258,2596953152,3336464436,3117971284,2680851329,2529531285,3065987491,3031322521,3028055416,2840501079,2069472070,1118933550,838824520,1170695048,1656249779,2246532324,2734644986,2548853481,1872662188,1650498648,2155968307,2526634028,2038978092,1179405373,604458303,253916928,201326336,671079936,1308144131,1490843214,1453921490,1852320201,2906819675,3879190066,4282963810,4284672171,3781743289,1669901003,482284123,1427687680,2969628417,3610376491,3800235126,3601267354,2861296266,2018430049,1259648081,671121228,1100562987,2616544034,3623833395,3370141810,2830123721,2644458997,1923565423,2189644902,2890230356,3425008456,2990310207,2122287410,1354732076,585209411,251630079,788499199,1509919996,1576837110,1253493745,965141709,715372141,567303168,678569472,908141861,873347642,504469862,548392791,1492543488,2616412928,3117172551,3263908301,3261746676,2993967252,2826527789,3276248920,3927686816,3760042417,2640533925,1857290365,2002808139,2349196593,2182086468,1717889166,1770829991,2656407927,3258549081,2385806404,1011653896,954756406,2113882504,3169990821,3682870466,3961787349,4078046432,1522369899,1771469145,2371510589,2973457714,3108470861,2942546295,2405940099,1379366499,771751969,1095124585,1652526996,1670224535,1384877957,1233875855,1301363654,1317999598,1096943571,772804735,722945857,898340177,1205050955,1693735175,2247308544,2864926545,3548674244,3530523625,2391170481,1487741483,2035971674,3157044891,3510149808,3228850873,2896765882,2811773330,2675536749,2240050296,1606303939,1605445833,2605536106,3408423234,2821746230,1700951612,1454077570,1992715992,2594986992,3176346071,3624932542,3792112311,1693961386,1709878925,1725062199,1939813126,2641269612,3528384705,3678130624,3105646199,2634285360,2651400251,2736615260,2469556042,1985625098,2088572461,3229818796,3917555942,2742364410,808793599,358728289,1323602740,1945208631,1408789287,786641190,1318691727,2625623986,2843468729,1350165173,146751327,817475460,2309864346,3148260790,3466230503,3617412087,3719901123,3638301063,3337750157,2835286470,2564626892,2623880803,2718718226,2636012581,2489472106,2339334312,2201919465,2115942655,2134016182,2169455458,2170170691,1777371323,1892781987,2157157749,2506337877,2875378025,3245268101,3329081992,3160438387,2958113637,2823047795,2654694786,2134796398,1398158360,1418405668,2728221589,3701567930,2930213819,1421654435,821029205,1022530594,1222603822,1117949575,1012257023,1264316910,1736703141,1771043464,1015602534,576356096,1365364605,2674780845,3397699779,3584852706,3704047590,4022806708,4139784067,3569619844,2513247925,1923891396,2068215897,2248250383,1947307029,1583439449,1820415899,2741988023,3127407031,2187946901,1097946663,813970688,1925333941,2207870635,2958211487,3593659022,3293960549,2612047899,2346947094,2446626925,2478673584,2309326026,2005629142,1350725605,479290613,419424618,1591867740,2681927005,2665411162,2129062999,1541792331,718587934,374439749,1162260968,2086060536,1902364392,1016385684,634763776,916481792,1483436288,2402206837,3457545662,3862617294,3634660805,3454941616,3771084176,3934926710,3127918963,1649633927,923065995,1342242630,1761933102,1226111000,513384704,1105142641,3149149576,4121574283,2460433289,479283309,0,2257632434,2542117804,3279190690,3865993370,3381352595,2429568390,1912228988,1742960259,1572706708,1285200585,980845567,611869695,234815743,587154393,2125760389,3416626548,3147540066,2190330940,1721301549,1590624081,1776746361,2679668880,3449568150,2744528273,1000029287,67108608,263948288,745766656,1947573421,3439371496,4061503194,3897920925,3431758695,2676909408,1836664429,1417821290,1252808271,1118665537,962180926,974506583,1316779414,1825713343,2094340791,2208768398,2106208374,1554208152,1021683177,939511039,2388852659,2624767917,3247461787,3735357589,3283288235,2394947808,1757482738,1318663338,979986767,776839262,710263459,663728330,703359455,1288718005,2811971994,3968422284,3463735928,2286987849,2019685429,2528116570,3150965613,3921901418,4290256744,3416918885,1451963757,220725148,90636287,265084211,1612873689,3440473836,4094918607,3693255552,2888807475,1428671509,67174144,201290588,1358261327,1776249429,985158505,457310444,1537747671,3133423045,3233760704,1821988794,860264101,1200857530,1807848387,2009830341,1881459398,2118431410,2726662031,3265819000,3046462586,2472683145,2000423066,1545864625,1379151531,1753057626,2259665453,2478427196,2545337709,2712774281,3081399177,3366013831,3214700172,2945561486,2927348345,3143348557,3427964985,3900083012,4204364888,3684141934,2524162723,1717029074,1249227699,1168549475,2373567874,3999518870,4032418195,2487796858,1109108278,285277952,1280,661192591,2306662294,2826752666,1617213876,606295551,1534428845,3180438675,3785600928,3468934611,3034037231,2662041004,2372681549,2219778104,2046860480,2081989806,2154283397,2242169693,2273484365,2268748875,2082091356,1678837654,1631526053,2442494797,3317612064,3418475307,3031353182,2728768385,2561704317,2478660471,2562424203,2762780574,2844044684,2805495891,2853396529,3209334840,3498492238,3247296107,2574502051,2036383690,1568385708,1453368941,2291457379,3414752104,3095789418,1317206130,67109119,195184127,844859801,1852032914,3244875150,3546734227,2186460846,1059873262,1801425323,3280059272,3969696664,3972843214,3772889826,3349580176,2924694816,2771136256,3155079588,2736042657,1613215628,596757540,866174996,1651882041,1869322050,1549875789,1568150855,2544993066,3469327390,2999501094,1604753741,846550675,1068141933,1422634315,1488757320,1486651982,1450991972,1330656919,1380003495,1869507435,2291177027,2006492478,1201057615,714195577,631164574,731565978,1015599214,1317001038,1115413573,308543302,140460031,1366978255,2927129522,3699149199,4018252129,3766333523,2606926718,1748647900,2406697426,3651102129,3769660585,2764214955,2176955794,2793909323,3546718249,3831274784,2972631724,2671494824,1903088793,1138553710,1004076864,1166147376,1367199547,1605064032,2009413489,2881585515,3569136997,3082403173,1756539241,934316911,852270683,920497730,817859093,746536704,846537517,1083389883,1436107468,1971928945,2373730605,2088195092,1266324742,730005248,513408819,515637128,802387836,1239161704,1423772750,1356339481,1472280346,2006341487,2659151011,3096017048,3399254901,3282076267,2441702288,1801537484,2255375044,3112655272,3146736273,2341761382,1854902060,2222827554,2708649004,2893002538,1820780502,2040261059,2730814110,3168398471,2294544760,944308049,826521207,1842632372,2783533250,3334832844,3634474447,3517169847,3032469632,2446643035,1658494061,986917794,616038527,495142400,897678640,1805290105,2627106936,3145549132,3395690274,3126736920,2438829350,1785066036,1115684662,802421067,1459376255,2566394503,3270184807,3688178985,3519296516,2243444480,865495356,934569703,1943488239,2346468332,1842630110,1370318768,1532266880,1964480610,2250413128,2472181294,2374135840,1653568031,932805929,632062512,1118948510,1470416021,2375205776,3180313233,2845613983,2092787391,2026061784,2594653149,3147521751,3550385100,3819094714,3752510354,3434457168,3048773689,2561450630,2074063594,1454288624,937333875,1202035243,2271523143,3193415497,3563419700,3665316382,3482076692,3031130131,2662952745,2409521779,2256882872,2275158957,2378501250,2614233185,3016562248,3133092168,2627762808,2020274386,2069364716,2523184847,2456788417,1536066500,800279491,1177765280,1979734160,2147507067,1731287882,1316772624,1034904584,852587796,737770519,814701056,1113351210,1796897124,2585294729,3061143725,3386586318,3420335071,3128899817,3007142875,3329270444,3702314364,3652763215,3349901337,3197981986,3364788887,3347699700,2527320056,1475585947,1438097191,2385562146,3206204965,3444749094,3499854375,3486277657,3388353798,3388149792,3618509958,3631423440,2925864900,1919759723,1451570506,1454049451,1806382323,2776544248,3781293550,4014735313,3744594076,3055479928,1645926258,623004574,1277889979,2569940409,2519675833,1177238201,276799327,668446720,1288018452,1489672468,1283885426,1634966358,2488778552,3310405426,3278678097,2828890506,2359445687,1750412513,1477520359,1817669271,2358942295,2646378565,2815778115,3033353057,3432733354,3463800797,2423680231,984314565,596298781,1174468359,1863640342,2323892030,2749473116,3254020168,3806800417,3805941536,3015576664,2275027373,2426566299,2847717737,2629684320,1721937026,1265401271,1835309738,2708320155,3380783778,3952975535,3969621669,3110385521,2384255266,2756559654,3515983946,3500255838,2761085303,2004810383,1300754813,782677559,700488460,1919199139,2205006458,2977555251,3581736459,3010325513,1934213916,1295095099,1012236129,881091194,880090431,1030750228,1505177671,2179819134,2682413209,2980130482,2909405634,1985347780,709361834,308503310,712616472,1285202233,1859032193,2464842148,3118029954,3820762444,3719308851,2343444772,1287345950,2144911442,3688946022,3671581288,2091577434,976281635,1094607360,1686581263,2462594158,3405728454,3891161032,3683230583,3408650267,3428839685,3569740301,3721979432,3970426711,3649233781,1987502723,342284466,1,2282927242,2300951164,2337328985,2289819446,1820770590,1267431171,1538690582,2681657915,3151949893,1793519671,249200384,360279296,1582612569,2203432301,1969518477,1585586346,1420165244,1372520482,1743784226,2633513292,3320006504,3583784327,3612550052,3460378028,3126218660,2841469581,2654687317,2620475685,3078713396,3671169361,3236409180,1707397469,792459628,1347855753,2256371596,2225105572,1659652319,1392460271,1555459741,1736152921,1536995381,1324272144,1843119120,3231756581,3767445293,2207488824,434779033,393227,2921373346,2670109089,2017308837,1366409373,1083668319,1102581519,1876126498,3621242696,4292731993,2899692394,1001477813,744640941,1495063632,1746137658,1093657422,648509547,1203394091,2208914195,2898220070,3370994508,3639165036,3716950663,3558807713,2969577151,2080401648,1796709095,2435216752,3124322105,3379258697,3383194479,2898428027,1807849062,1217968223,1853076139,2640816075,2304942533,1214683284,596272698,635183760,804851929,786635769,815742959,1334274676,2391636510,2928770568,2157149979,1184923479,833002641,3899489243,3446505948,2271847146,1130818551,1063621001,1517363763,2207267396,3249371761,3937569165,3969931693,3649576903,3012502974,2108258940,1321720621,533581056,271531791,1430050087,3159025194,3428572974,2308028468,1423405137,1501588611,1730511779,1392740289,755006713,677355493,1455276144,2277687396,2677122720,2908062945,2891479508,2577430892,2378990628,2475334263,2621353428,2637739188,2572726340,2305469701,1633463570,1229104232,1867879866,2892074700,2858846409,1734832552,1097425486,1835441983,2741549129,3077029961,3581695990,3245890800,2438885082,1597345449,1413838460,1551452774,1954957421,2692374146,3245957269,3498394548,3499173579,2996841929,2092786344,1356829317,721370269,548864175,1548077651,3023436865,3241671484,2219430971,1467516480,1739091013,2126017352,1655600464,693143670,492281572,1358922440,2348776636,2947645644,3325855703,3156772033,2306308207,1875801878,2458621769,3226709107,3326981229,2992158795,2573056312,1950069825,1595450216,2185791381,3127736997,2943383472,1598583753,973110191,2202570825,3630273333,4150694704,2339247871,2355109360,2453089179,2434547028,1967347551,1337231287,1370527680,2050652023,2451204704,2018537083,1439267011,1423013606,1737127139,1803248871,1505372918,1307640814,1587177893,2084816748,2469905752,2773390424,3077924178,3636019257,3941215273,3168875308,1634956884,1109106388,2027756028,3120497131,3836885693,4249845385,3475478643,1326681719,275726080,1954783238,4068189966,4017466664,2590228599,1749206229,1679925439,1830843015,1918717533,2058039866,2091008323,2001238652,2199229322,3024251721,3831910432,4151265301,2276286171,2124241356,1751270547,1394288454,1366177127,1491748275,1724210341,2067688797,2281734968,2215488050,2101334083,2187974001,2408629678,2460001489,2308986845,2073367257,1770200504,1449616502,1532793169,2003080781,2474347601,2963959627,3268566090,3000131923,2276820075,2140838540,2896266148,3617486502,3699013773,3427237988,2755101264,1533703513,921134937,1706398230,2727584257,2642846245,1853266836,1348241919,1160996837,1228821387,1671919181,2314113333,2764801879,3043583654,3241298884,3259512203,3228443974,3212452141,2347810973,1995292827,1004915607,192115525,669712514,1693909646,2244291198,2388946265,2585764672,3121395252,3608460597,3577794640,3197824135,2949110966,2996206793,2875205836,2170684849,1251409999,868761856,984071437,1200422727,1416888206,1701969082,2158217635,2715652985,3201209955,3698839896,3828742481,3089294150,1965655858,1600666928,1975606092,2096001884,1459386709,737380423,667181698,1017199551,1100754892,664993981,535181683,1560266581,3052854620,3888887682,4185482950,4064685039,3192919257,2306344348,1955659899,1040175502,1189002608,1517005377,1831770154,1666545981,1404385905,1789880217,2859840671,3680224414,3914244243,3846797706,3411773074,2693776557,2511589584,3248717284,3784527842,3280492212,2289598789,1617074688,1162244608,858610201,639296626,725352369,1501816178,2829029701,3633219132,3813907772,3508384319,2450304573,1041792813,580263484,1037386336,1256609386,687821407,33554176,50331647,496923355,816807865,821669748,1073727052,2228921438,3703281004,4288847505,4285575119,4282820578,3058341078,1819043749,1317619580,524288,459763986,1764790057,2888602671,2217775666,928224339,1100074188,2709773773,3917335500,4034692810,3682219721,2994483918,2022015965,1889058031,3047458042,4070796271,3901717423,3161300542,2488443651,1766092575,1211901542,810501535,746171810,1434362452,2776073003,3479729700,3410517806,2921744698,1864058692,590050420,192133631,641940735,960706024,659895511,279928751,279949199,527674516,797818247,1021963861,1426056008,2327875169,3415302006,3967122573,3998667957,3779133385,2839998143,1918934684,1550739845,41877504,223245626,741971531,1161536081,859284816,322281808,430159820,1136313052,1890098636,2676313003,3245218454,2843026835,1738768032,1404426951,2188356849,3008727272,3093393561,2893960998,2777835802,2661725057,2611927514,2627970038,2677103598,2627489476,2545442930,2428199226,2325037100,2104107571,1516450628,879793048,1077390335,2181920255,2886952185,2538693098,1907973325,1640649125,1551645297,1347566674,947285316,667599950,800628065,1116628091,1382647179,1581374368,1916001194,2538761647,3077056432,3295613106,136281983,140484543,146800639,134217727,83886079,256,100597913,251592959,721356482,1904936576,3009547119,2775524716,1622637682,1154873249,1715262179,2382864864,2604033158,2693211671,2880719910,3150301343,3401907697,3668816127,3767882999,3315874526,2414234018,1845448020,1726817581,1688865323,1352216386,1082105755,1630643692,3070983420,3977342202,3612436202,2830785742,2412464549,2138974830,1665275717,875305018,201342805,512,256,1280,234941440,890786149,2388201637,3731554998,4285530298];
  got = from_rgba(src, 20, 20).resize_bicubic(48, 36);
  assert(got.width == 48 && got.height == 36, "size");
  assert(differing(got, want) == 0,
         "20x20 -> 48x36: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_20x20_to_7x5() {
  // the same source reduced hard — the filter widens on the way down
  src: vector<integer> = [30,101,46,193,82,208,191,205,101,25,15,252,96,76,9,51,208,66,51,129,222,38,200,45,156,178,85,204,18,76,48,108,2,221,172,102,70,227,111,19,30,189,222,93,58,180,111,21,45,119,184,240,10,231,28,65,75,62,243,135,132,49,15,114,59,248,103,190,55,34,168,160,243,14,238,129,169,250,200,154,170,111,50,158,64,94,37,109,129,155,32,56,58,48,43,126,32,102,239,206,62,235,30,107,170,18,33,32,231,123,103,226,76,71,32,170,143,153,229,38,247,60,131,147,210,238,15,86,223,64,241,137,109,49,187,247,240,85,80,99,77,143,8,135,199,1,60,231,155,129,190,41,3,159,228,93,204,146,86,112,9,137,201,180,239,63,24,215,103,216,105,183,233,61,124,206,227,32,2,229,52,241,47,91,23,53,180,26,94,177,103,35,153,47,16,69,93,47,248,177,86,167,151,55,246,149,206,196,135,103,15,92,242,69,60,52,163,252,10,124,39,38,173,43,105,225,12,250,163,211,7,110,99,101,227,54,142,242,83,139,83,54,201,147,68,180,151,144,25,90,135,228,2,50,205,38,109,242,238,52,63,68,10,227,206,230,117,244,210,22,218,214,243,213,252,149,116,196,21,248,228,173,131,19,85,98,39,72,112,135,2,224,91,243,175,48,93,199,182,30,37,36,67,53,150,84,122,15,162,154,220,68,40,132,179,10,255,27,88,224,225,78,238,235,235,72,97,60,125,139,150,211,58,49,111,79,163,142,107,215,56,34,249,66,54,213,164,10,254,84,6,71,240,228,230,193,213,223,54,10,85,204,151,33,54,92,92,246,174,247,153,141,44,60,95,101,191,110,191,161,230,60,80,9,68,77,228,227,154,110,224,22,36,183,45,94,141,71,175,63,243,43,132,203,84,57,210,205,145,117,153,65,103,34,120,110,155,143,218,37,155,15,193,125,182,109,213,120,114,25,178,254,168,165,0,72,51,163,177,11,240,133,187,191,23,197,97,74,163,118,225,68,233,214,218,119,244,222,93,21,68,165,102,135,239,146,31,212,49,252,132,181,237,180,113,218,178,121,104,0,6,93,122,122,57,54,14,228,209,230,142,36,163,249,101,143,63,23,140,145,14,53,140,58,1,65,111,189,153,21,233,101,188,253,86,14,200,143,222,203,118,60,224,162,237,191,170,184,45,113,24,167,65,196,149,195,254,125,166,254,111,19,29,88,23,54,63,47,225,130,112,120,67,56,205,91,185,226,184,108,87,82,167,242,150,11,64,92,175,167,61,48,204,16,243,13,30,241,23,46,233,3,39,115,142,40,229,31,68,190,233,253,212,236,48,60,60,161,133,70,203,189,185,240,215,188,214,242,25,36,100,137,153,246,112,144,143,137,76,237,213,240,83,99,149,106,35,230,59,198,197,11,36,234,126,188,222,29,147,151,175,58,128,34,217,70,2,79,98,64,249,18,170,154,255,101,73,46,118,206,106,113,234,69,205,84,16,128,36,226,107,41,62,134,157,184,109,108,51,118,130,57,173,97,128,106,159,110,149,223,228,3,135,144,91,95,200,228,174,61,146,42,114,92,190,30,88,123,124,158,115,169,152,140,24,9,86,39,74,137,192,131,136,51,44,28,38,115,238,69,190,103,103,82,105,190,76,129,102,17,170,224,74,53,139,122,3,21,23,83,26,253,147,60,95,186,187,166,160,156,116,249,65,51,191,27,216,71,129,107,60,57,8,98,83,159,55,86,228,228,158,231,178,69,201,15,119,52,136,63,43,254,26,119,9,145,36,80,29,6,245,231,36,147,141,68,76,99,153,220,72,6,234,178,86,181,117,34,48,13,234,23,210,89,52,24,64,169,160,40,63,80,204,137,80,191,90,111,56,174,217,5,14,55,227,6,252,89,27,208,200,183,6,159,143,40,30,5,35,37,146,83,45,143,251,85,199,156,166,7,2,86,218,75,130,161,26,165,242,36,27,152,69,181,247,38,85,198,93,5,228,105,198,156,215,43,88,86,157,91,109,82,162,254,134,233,34,3,163,231,120,79,251,225,164,243,202,51,152,51,126,49,76,2,100,120,188,136,183,196,146,222,107,237,85,217,39,129,188,10,127,172,156,34,233,49,34,170,75,30,61,203,69,87,199,5,70,202,49,230,81,241,181,75,19,117,26,81,172,194,240,238,213,205,32,49,65,12,12,241,122,127,36,176,219,99,44,235,93,99,195,234,132,218,173,121,19,179,17,96,0,91,245,175,8,138,59,62,31,34,133,234,73,40,114,3,100,67,163,217,16,25,172,217,24,241,139,101,246,127,254,181,145,40,89,95,218,2,34,147,42,203,227,35,34,203,123,7,200,165,222,74,144,128,198,172,179,162,227,91,209,157,142,188,14,128,158,179,166,213,225,113,248,17,165,240,60,100,90,5,63,133,43,255,76,15,42,1,84,169,249,138,102,41,133,141,65,223,12,112,168,187,10,38,78,88,238,225,113,235,14,31,222,26,74,184,129,62,116,246,213,153,105,196,74,106,144,157,159,214,70,26,191,53,180,139,157,187,204,212,43,80,157,158,189,192,29,48,31,102,217,229,89,118,123,235,4,76,182,30,149,12,247,67,61,124,144,66,118,43,182,191,176,181,36,182,250,104,239,251,168,166,0,223,134,49,223,246,187,61,188,224,18,103,47,66,9,80,163,81,53,170,194,71,26,92,136,202,234,169,173,239,125,70,56,134,60,105,153,195,85,87,100,7,87,115,21,204,35,124,204,182,151,109,149,196,247,18,3,38,43,62,20,83,192,57,182,226,85,49,235,166,191,125,172,181,54,246,62,206,66,111,136,135,156,208,206,50,153,98,152,106,42,85,144,202,187,53,87,46,2,17,14,242,254,239,130,77,196,41,119,165,214,238,31,160,247,47,13,101,129,174,156,85,91,239,141,93,2,122,169,241,204,230,174,119,41,175,110,181,214,36,34,22,19,67,120,205,72,184,217,3,89,26,75,68,46,81,72,133,8,195,79,254,138,196,253,134,28,23,58,149,198,175,106,177,74,70,53,151,132,105,56,137,140,100,173,67,174,68,176,174,208,125,223,143,49,115,103,17,240,133,133,51,109,89,56,239,164,149,246,83,146,53,7,171,123,253,199,83,88,244,112,225,155,231,204,91,17,159,191,45,156,142,83,15,134,13,7,188,181,129,153,98,253,250,152,231,250,188,197,110,170,7,7,3,51,147,153,68,173,60,56,52,218,160,45,114,203,150,121,208,191,219,130,254,89,255,228,116,54,28,152,146,177,59,251,246,80,15,245,63,64,34,238,13,4,184,242,207,94,33,195,236,209,252,127,23,46,126,215,73,234,205,222,68,3,71,110,149,82,136,198,143,171,206,102,254,211,143,28,164,181,86,163,192,6,107,17,207,74,117,21,81,32,37,153,64,72,158,187,210,199,209,16,172,14,46,49,38,231,9,80,108,96,93,200,17,195,30,211,32,236,25,110,181,135,30,148,128,4,79,127,9,17,190,117,94,32,255,147,45,114,244,230,76,237,147,224,248,4,199,131,53,110,81,42,167,20,203,212,71,208,177,35,104,191,16,229,196];
  want: vector<integer> = [2809097580,2306629723,2057077102,1753973894,2038855296,1936947540,1821673094,2306494855,1651466876,2038456480,2124780965,2290653569,2006484843,1854772856,1654220898,1619679868,2205838689,2022866821,1918472085,2022595438,2038586448,2022015370,2374923655,2809101711,1868720517,2406378640,2658630028,2307476094,2324723309,2191692161,2070895511,2103149721,1499953303,2676333435,2257222002];
  got = from_rgba(src, 20, 20).resize_bicubic(7, 5);
  assert(got.width == 7 && got.height == 5, "size");
  assert(differing(got, want) == 0,
         "20x20 -> 7x5: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_9x9_to_3x3() {
  // a plain reduction, beside the Lanczos case of the same shape
  src: vector<integer> = [215,113,73,66,142,247,65,47,222,24,123,115,20,215,14,66,83,159,238,143,40,225,148,36,51,219,45,35,200,160,79,213,35,253,43,91,21,49,43,218,33,247,24,252,137,13,31,106,92,56,47,42,121,5,121,25,118,255,167,203,241,80,144,101,201,13,220,76,20,44,140,172,212,23,192,170,18,59,177,216,43,216,67,51,154,47,16,99,194,220,148,134,155,233,126,115,132,71,9,205,133,43,160,158,194,228,15,175,165,115,243,40,160,44,119,86,52,91,178,203,136,133,87,13,223,60,150,109,44,17,51,66,121,163,202,136,213,124,222,237,1,175,160,126,52,244,173,65,118,219,34,237,208,87,104,224,234,64,9,163,158,86,207,113,9,1,75,230,236,178,247,19,226,102,32,211,187,128,34,141,24,216,156,43,46,140,154,31,197,231,49,118,113,248,65,121,144,197,107,138,55,203,30,92,40,40,189,130,14,93,82,93,128,217,14,32,23,15,177,229,182,27,110,18,64,103,62,1,31,241,233,21,74,167,74,210,62,79,20,24,87,131,103,250,62,201,221,146,145,156,196,117,7,100,1,39,56,83,130,82,238,170,169,194,77,246,39,160,43,39,95,210,185,64,33,122,135,152,23,40,179,106,6,186,110,149,51,185,117,235,241,160,209,95,77,239,177,174,64,2,16,253,143,127,139,115,199,16,17,105,228,100,155,128,17,84,23,33,253,241,68,158,253,27];
  want: vector<integer> = [2270390102,1552391042,2374925939,1987937644,2105568351,2340713843,1719962759,1935053402,2237624182];
  got = from_rgba(src, 9, 9).resize_bicubic(3, 3);
  assert(got.width == 3 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "9x9 -> 3x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_4x4_to_8x8() {
  // an upscale, where the filter must NOT widen
  src: vector<integer> = [254,184,113,237,228,232,206,13,171,33,243,231,44,151,173,16,116,31,163,117,0,71,200,175,16,123,197,31,137,207,67,130,193,218,100,101,131,232,101,68,69,128,92,12,194,103,174,228,186,76,222,198,49,101,30,220,92,138,122,32,231,221,217,165];
  want: vector<integer> = [4294953077,3288319339,637534000,651562495,3534561014,3567853556,982588154,0,3841107829,3034948728,1251449741,1146498555,2879267572,2829396715,1166567345,411041578,2427538833,2457947038,2535475390,2215198676,1515336683,1284074421,1551877968,1702946093,1652172694,1968191655,2652458942,2214811333,570452434,593420616,2157424991,2895883616,1622128991,1587002223,1516811667,1044756900,167837593,849244302,2947968667,3921178525,2093592722,1991488381,1786754632,1196867107,269471488,934899636,3033101753,4023023289,2999475680,3097453227,3327290433,2569760528,840526607,868791501,2480717264,3252665551,3435545591,3633925045,4048116800,3223478797,1109089299,819257316,2229791710,2884496862];
  got = from_rgba(src, 4, 4).resize_bicubic(8, 8);
  assert(got.width == 8 && got.height == 8, "size");
  assert(differing(got, want) == 0,
         "4x4 -> 8x8: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_12x7_to_4x3() {
  // non-square in and out
  src: vector<integer> = [215,54,96,107,174,31,77,189,35,120,3,73,13,65,78,47,166,220,155,15,60,218,61,245,25,151,15,126,8,199,146,227,29,95,249,216,251,201,225,84,120,239,67,88,241,88,80,10,237,78,182,15,166,75,159,225,199,250,218,113,249,220,50,224,243,210,127,134,112,73,222,183,252,27,52,111,140,56,56,42,192,254,173,224,65,186,5,50,167,107,143,14,100,164,251,19,3,215,120,177,221,31,16,12,109,80,54,152,181,199,123,131,198,138,126,172,91,130,119,19,5,174,96,47,161,237,39,70,86,116,8,155,230,52,253,114,137,121,209,36,188,154,15,183,225,108,123,118,249,208,237,15,196,44,54,191,176,7,88,50,197,158,49,66,206,230,70,248,213,66,155,93,188,133,110,170,29,254,121,97,111,122,157,200,92,222,151,223,148,14,180,110,10,26,248,151,45,64,57,229,203,186,240,105,237,17,7,24,80,13,208,198,164,90,21,104,101,143,248,184,66,174,101,209,17,211,102,229,41,51,235,12,23,252,249,106,98,220,227,173,14,177,181,139,18,34,203,38,209,95,203,124,44,4,98,115,101,149,123,206,175,13,56,40,79,120,68,222,28,127,36,241,62,68,60,15,164,194,236,237,140,107,209,244,70,123,210,9,221,245,123,239,9,225,221,235,72,141,223,178,30,230,102,201,7,174,170,5,247,88,202,16,99,4,99,243,220,0,91,126,154,199,247,78,183,84,109,72,49,52,236,26,169,107,126,85];
  want: vector<integer> = [2074308461,2141236324,2321586565,1184671626,1870223216,2058582896,2306188657,2339152799,2622594734,2002412670,2623368550,1820434132];
  got = from_rgba(src, 12, 7).resize_bicubic(4, 3);
  assert(got.width == 4 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "12x7 -> 4x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_1x9_to_1x3() {
  // a single-pixel axis
  src: vector<integer> = [247,204,210,153,183,91,250,62,165,188,106,18,22,152,28,174,251,48,135,118,44,151,62,242,104,201,182,24,136,156,140,214,242,233,227,170];
  want: vector<integer> = [1421452214,2354411585,2627253148];
  got = from_rgba(src, 1, 9).resize_bicubic(1, 3);
  assert(got.width == 1 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "1x9 -> 1x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_7x3_to_3x1() {
  // a degenerate one-row output
  src: vector<integer> = [177,28,117,227,6,105,165,0,107,158,77,36,207,15,157,1,139,25,152,4,20,134,147,46,47,197,4,230,193,250,181,166,206,54,210,94,235,130,218,50,97,33,194,103,84,66,184,41,3,43,31,247,171,188,73,123,17,200,40,22,249,234,227,40,161,193,25,127,160,208,238,88,0,198,236,113,32,251,52,183,236,5,108,139];
  want: vector<integer> = [1488946582,1046905772,2403369790];
  got = from_rgba(src, 7, 3).resize_bicubic(3, 1);
  assert(got.width == 3 && got.height == 1, "size");
  assert(differing(got, want) == 0,
         "7x3 -> 3x1: {differing(got, want)} pixels differ from Pillow");
}

fn test_bicubic_10x10_to_9x9() {
  // a ratio just under 1, where scale and filterscale part company
  src: vector<integer> = [16,89,159,90,115,29,100,27,228,145,16,17,223,96,123,36,61,29,141,218,150,148,116,13,170,165,46,124,5,25,227,62,233,201,145,216,136,16,192,138,211,94,103,9,253,232,94,246,100,58,121,35,186,163,191,3,88,73,128,198,111,147,49,134,26,114,91,163,127,188,30,62,238,235,205,160,238,109,36,146,99,10,252,139,229,135,171,240,94,64,145,11,118,158,49,225,243,251,133,27,204,200,148,217,71,92,66,209,43,249,77,61,53,10,247,102,255,145,195,248,64,138,123,174,27,84,35,130,7,123,59,110,170,97,153,234,20,5,104,30,184,65,235,29,154,223,161,43,102,34,154,59,93,224,173,21,150,231,84,155,171,30,135,130,207,3,87,51,205,8,34,100,246,62,170,13,176,32,104,189,19,254,200,180,118,203,175,9,217,142,85,137,10,98,193,240,123,119,231,241,144,88,12,150,198,249,245,22,138,235,84,62,215,20,45,170,93,188,218,142,214,6,44,116,154,170,22,232,96,254,53,40,249,133,231,249,229,84,189,116,197,30,91,176,170,254,200,142,170,67,9,5,225,153,123,237,187,36,204,93,146,184,191,187,220,86,118,203,65,169,103,224,171,244,120,211,175,223,194,199,33,93,177,237,204,58,179,82,110,234,220,30,132,248,220,207,65,73,125,69,151,57,97,36,225,121,104,13,28,18,240,229,76,76,59,152,202,46,245,59,242,204,183,218,86,186,105,148,137,129,94,128,172,71,139,62,148,179,160,233,109,57,198,171,94,134,22,111,95,34,43,42,136,175,77,212,183,140,208,67,31,46,137,243,235,108,219,36,163,99,154,168,53,76,27,42,154,195,252,13,116,181,87,139,225,27,128,231,219,81,169,212,213,194,19,188,228,220,13,247];
  want: vector<integer> = [1310088086,683181394,201314304,1918576780,2117738635,1320008221,1295536011,3152004506,2459841195,885287856,3707425903,221930062,1516069736,2860021865,2957344593,1513329453,2228540622,2817882974,2774679500,2998434196,1633060934,2726662219,1207954871,3078915431,1445957962,974456054,3555303344,2573035896,1613051663,2104513883,2108898710,1481340566,471007231,1137793913,1661955242,3060969883,2091006273,553070447,1322334749,1924279908,2960228248,2256121148,1372765199,3901190355,3197591010,2680176187,1302527962,1339392323,3184486778,2107015369,3703666783,2892604487,3603550370,2966012858,3594734499,1305315270,1284765907,2979513275,1013859472,889160870,3325310923,3108926054,1756404417,3653482931,2221056616,1636664947,2274602408,3162536625,1750751334,1093346157,3044446544,1219131583,3727965325,1136295339,2341887879,407524661,1416284281,3401139567,3470800798,3201347377,3856718351];
  got = from_rgba(src, 10, 10).resize_bicubic(9, 9);
  assert(got.width == 9 && got.height == 9, "size");
  assert(differing(got, want) == 0,
         "10x10 -> 9x9: {differing(got, want)} pixels differ from Pillow");
}

fn test_the_negative_lobes_overshoot_and_that_is_the_filter() {
  // A hard 40 -> 200 step, enlarged 4x.  Pillow's answer spans 28..212 — OUTSIDE
  // the source's own range, because the kernel goes negative either side of the
  // peak.  Pinned separately from the goldens because it is the property a
  // "smooth enough" resampler quietly loses.
  step: vector<integer> = [];
  for i in 0..10000 {
    step += [if (i % 100) <= 50 { 40 } else { 200 }, 0, 0, 255];
  }
  got = from_rgba(step, 100, 100).resize_bicubic(400, 400);
  lo = 255;
  hi = 0;
  for i in 0..(400 * 400) {
    v = (got.data[i]? >> 16) & 255;
    if v < lo { lo = v; }
    if v > hi { hi = v; }
  }
  assert(lo == 28, "undershoots to 28, got {lo}");
  assert(hi == 212, "overshoots to 212, got {hi}");
}

tests/canvas.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the graphics library canvas and pixel operations.
use graphics::*;

fn test_rgba_pack_unpack() {
  c = graphics::rgba(255, 128, 0, 200);
  assert(graphics::color_r(c) == 255, "r={graphics::color_r(c)}");
  assert(graphics::color_g(c) == 128, "g={graphics::color_g(c)}");
  assert(graphics::color_b(c) == 0, "b={graphics::color_b(c)}");
  assert(graphics::color_a(c) == 200, "a={graphics::color_a(c)}");
}

fn test_rgb_shorthand() {
  c = graphics::rgb(10, 20, 30);
  assert(graphics::color_a(c) == 255, "rgb alpha is 255");
  assert(graphics::color_r(c) == 10, "rgb r");
  assert(graphics::color_g(c) == 20, "rgb g");
  assert(graphics::color_b(c) == 30, "rgb b");
}

fn test_canvas_create() {
  cv = graphics::canvas(10, 8, graphics::rgb(0, 0, 0));
  assert(cv.width == 10, "width");
  assert(cv.height == 8, "height");
  assert(cv.data.len() == 80, "pixel count");
  assert(cv.data[0] == graphics::rgb(0, 0, 0), "fill color");
  assert(cv.data[79] == graphics::rgb(0, 0, 0), "last pixel");
}

fn test_set_get_pixel() {
  cv = graphics::canvas(4, 4, 0);
  red = graphics::rgb(255, 0, 0);
  cv.set_pixel(2, 1, red);
  assert(cv.get_pixel(2, 1) == red, "set then get");
  assert(cv.get_pixel(0, 0) == 0, "untouched pixel");
  assert(cv.get_pixel(-1, 0) == 0, "out of bounds left");
  assert(cv.get_pixel(4, 0) == 0, "out of bounds right");
}

fn test_clear() {
  cv = graphics::canvas(3, 3, graphics::rgb(1, 2, 3));
  white = graphics::rgb(255, 255, 255);
  cv.clear(white);
  assert(cv.data[0] == white, "cleared first");
  assert(cv.data[8] == white, "cleared last");
}

fn test_blend_opaque() {
  dst = graphics::rgb(100, 100, 100);
  src = graphics::rgb(200, 50, 50);
  result = graphics::blend(dst, src);
  assert(result == src, "opaque src replaces dst");
}

fn test_blend_transparent() {
  dst = graphics::rgb(100, 100, 100);
  src = graphics::rgba(200, 50, 50, 0);
  result = graphics::blend(dst, src);
  assert(result == dst, "transparent src leaves dst unchanged");
}

fn test_blend_semi() {
  dst = graphics::rgb(0, 0, 0);
  src = graphics::rgba(255, 0, 0, 128);
  result = graphics::blend(dst, src);
  // 255 * 128/255 ≈ 128
  assert(graphics::color_r(result) >= 126, "semi-blend r: {graphics::color_r(result)}");
  assert(graphics::color_r(result) <= 130, "semi-blend r upper");
  assert(graphics::color_g(result) == 0, "semi-blend g");
}

fn test_fill_rect() {
  cv = graphics::canvas(10, 10, 0);
  blue = graphics::rgb(0, 0, 255);
  cv.fill_rect(2, 3, 4, 2, blue);
  assert(cv.get_pixel(2, 3) == blue, "top-left of rect");
  assert(cv.get_pixel(5, 4) == blue, "bottom-right of rect");
  assert(cv.get_pixel(1, 3) == 0, "left of rect");
  assert(cv.get_pixel(6, 3) == 0, "right of rect");
  assert(cv.get_pixel(2, 5) == 0, "below rect");
}

fn test_hline() {
  cv = graphics::canvas(8, 8, 0);
  green = graphics::rgb(0, 255, 0);
  cv.hline(1, 6, 3, green);
  assert(cv.data[3 * 8 + 1] == green, "hline start");
  assert(cv.data[3 * 8 + 5] == green, "hline end");
  assert(cv.data[3 * 8 + 0] == 0, "hline before");
}

fn test_vline() {
  vv = graphics::canvas(8, 8, 0);
  green = graphics::rgb(0, 255, 0);
  vv.vline(4, 1, 6, green);
  assert(vv.data[1 * 8 + 4] == green, "vline start");
  assert(vv.data[5 * 8 + 4] == green, "vline end");
}

fn test_draw_rect() {
  cv = graphics::canvas(10, 10, 0);
  red = graphics::rgb(255, 0, 0);
  cv.draw_rect(1, 1, 5, 4, red);
  // top edge
  assert(cv.get_pixel(1, 1) == red, "top-left corner");
  assert(cv.get_pixel(5, 1) == red, "top-right corner");
  // bottom edge
  assert(cv.get_pixel(1, 4) == red, "bottom-left corner");
  assert(cv.get_pixel(5, 4) == red, "bottom-right corner");
  // interior should be empty
  assert(cv.get_pixel(3, 2) == 0, "interior empty");
}

fn test_fill_rect_clipped() {
  cv = graphics::canvas(5, 5, 0);
  c = graphics::rgb(1, 1, 1);
  cv.fill_rect(-2, -2, 4, 4, c);
  assert(cv.get_pixel(0, 0) == c, "clipped top-left");
  assert(cv.get_pixel(1, 1) == c, "clipped interior");
  assert(cv.get_pixel(2, 2) == 0, "outside clipped rect");
}

// --- GL2.2: Bresenham line ---

fn test_draw_line_horizontal() {
  cv = graphics::canvas(10, 10, 0);
  red = graphics::rgb(255, 0, 0);
  cv.draw_line(1, 3, 7, 3, red);
  assert(cv.data[3 * 10 + 1] == red, "line start");
  assert(cv.data[3 * 10 + 7] == red, "line end");
  assert(cv.data[3 * 10 + 0] == 0, "before line");
}

fn test_draw_line_vertical() {
  cv = graphics::canvas(10, 10, 0);
  blue = graphics::rgb(0, 0, 255);
  cv.draw_line(4, 1, 4, 8, blue);
  assert(cv.data[1 * 10 + 4] == blue, "vline start");
  assert(cv.data[8 * 10 + 4] == blue, "vline end");
  assert(cv.data[0 * 10 + 4] == 0, "above line");
}

fn test_draw_line_diagonal() {
  cv = graphics::canvas(10, 10, 0);
  green = graphics::rgb(0, 255, 0);
  cv.draw_line(0, 0, 5, 5, green);
  assert(cv.data[0 * 10 + 0] == green, "diagonal start");
  assert(cv.data[5 * 10 + 5] == green, "diagonal end");
  assert(cv.data[2 * 10 + 2] == green, "diagonal mid");
}

fn test_draw_line_reverse() {
  cv = graphics::canvas(10, 10, 0);
  white = graphics::rgb(255, 255, 255);
  cv.draw_line(7, 7, 2, 2, white);
  assert(cv.data[7 * 10 + 7] == white, "reverse start");
  assert(cv.data[2 * 10 + 2] == white, "reverse end");
}

// --- GL2.3: Circle and ellipse ---

fn test_draw_circle() {
  cv = graphics::canvas(20, 20, 0);
  red = graphics::rgb(255, 0, 0);
  cv.draw_circle(10, 10, 5, red);
  // Top of circle: (10, 5)
  assert(cv.data[5 * 20 + 10] == red, "circle top");
  // Bottom: (10, 15)
  assert(cv.data[15 * 20 + 10] == red, "circle bottom");
  // Left: (5, 10)
  assert(cv.data[10 * 20 + 5] == red, "circle left");
  // Right: (15, 10)
  assert(cv.data[10 * 20 + 15] == red, "circle right");
  // Center should be empty
  assert(cv.data[10 * 20 + 10] == 0, "circle center empty");
}

fn test_fill_circle() {
  cv = graphics::canvas(20, 20, 0);
  blue = graphics::rgb(0, 0, 255);
  cv.fill_circle(10, 10, 4, blue);
  // Center should be filled
  assert(cv.data[10 * 20 + 10] == blue, "filled center");
  // Edge should be filled
  assert(cv.data[10 * 20 + 14] == blue, "filled right edge");
  // Outside should be empty
  assert(cv.data[10 * 20 + 16] == 0, "outside circle");
}

fn test_draw_ellipse() {
  cv = graphics::canvas(30, 20, 0);
  green = graphics::rgb(0, 255, 0);
  cv.draw_ellipse(15, 10, 10, 5, green);
  // Rightmost point: (25, 10)
  assert(cv.data[10 * 30 + 25] == green, "ellipse right");
  // Topmost point: (15, 5)
  assert(cv.data[5 * 30 + 15] == green, "ellipse top");
  // Center should be empty (outline only)
  assert(cv.data[10 * 30 + 15] == 0, "ellipse center empty");
}

fn test_fill_ellipse() {
  fev = graphics::canvas(30, 20, 0);
  fe_col = graphics::rgb(0, 128, 255);
  fev.fill_ellipse(15, 10, 8, 4, fe_col);
  // Center should be filled
  assert(fev.get_pixel(15, 10) == fe_col, "fill ellipse center");
  // Right edge should be filled
  assert(fev.get_pixel(23, 10) == fe_col, "fill ellipse right edge");
  // Outside should be empty
  assert(fev.get_pixel(15, 4) == 0, "fill ellipse outside top");
}

// --- GL2.4: Bezier curves ---

fn test_bezier_straight() {
  bv = graphics::canvas(20, 20, 0);
  bc = graphics::rgb(255, 0, 128);
  // Control points on the line → should be a straight line
  bv.draw_bezier(2, 10, 6, 10, 12, 10, 18, 10, bc);
  assert(bv.get_pixel(2, 10) == bc, "bezier start");
  assert(bv.get_pixel(18, 10) == bc, "bezier end");
  assert(bv.get_pixel(10, 10) == bc, "bezier mid");
}

fn test_bezier_curve() {
  bv2 = graphics::canvas(30, 30, 0);
  bc2 = graphics::rgb(0, 200, 100);
  // Curve bowing upward: (5,25) → ctrl(5,5),(25,5) → (25,25)
  bv2.draw_bezier(5, 25, 5, 5, 25, 5, 25, 25, bc2);
  // Start and end should be drawn
  assert(bv2.get_pixel(5, 25) == bc2, "bezier curve start");
  assert(bv2.get_pixel(25, 25) == bc2, "bezier curve end");
  // Somewhere near the top of the arc should have pixels
  // The apex is roughly at (15, ~8) for this curve
  bz_has_top = false;
  for bz_ty in 5..15 {
    if bv2.get_pixel(15, bz_ty) != 0 { bz_has_top = true; }
  }
  assert(bz_has_top, "bezier has pixels near top");
}

fn test_bezier_point() {
  bv3 = graphics::canvas(10, 10, 0);
  bc3 = graphics::rgb(255, 255, 0);
  // All points the same → single pixel
  bv3.draw_bezier(5, 5, 5, 5, 5, 5, 5, 5, bc3);
  assert(bv3.get_pixel(5, 5) == bc3, "bezier degenerate point");
}

// --- GL2.6: Anti-aliased line ---

fn test_aa_line_horizontal() {
  av = graphics::canvas(20, 10, 0);
  ac = graphics::rgb(255, 255, 255);
  av.draw_aa_line(2, 5, 18, 5, ac);
  // Horizontal AA line should have full-alpha pixels on the line
  assert(graphics::color_a(av.get_pixel(10, 5)) > 200, "aa hline mid bright");
  assert(av.get_pixel(0, 5) == 0, "aa hline before");
}

fn test_aa_line_diagonal() {
  av2 = graphics::canvas(20, 20, 0);
  ac2 = graphics::rgb(255, 0, 0);
  av2.draw_aa_line(0, 0, 19, 19, ac2);
  // Diagonal should have pixels along the diagonal
  assert(av2.get_pixel(0, 0) != 0, "aa diag start");
  assert(av2.get_pixel(19, 19) != 0, "aa diag end");
  // At 45 degrees the line is pixel-perfect; verify mid-point
  assert(av2.get_pixel(10, 10) != 0, "aa diag mid");
}

fn test_aa_line_steep() {
  av3 = graphics::canvas(10, 30, 0);
  ac3 = graphics::rgb(0, 255, 0);
  av3.draw_aa_line(2, 1, 8, 28, ac3);
  assert(av3.get_pixel(2, 1) != 0, "aa steep start");
  assert(av3.get_pixel(8, 28) != 0, "aa steep end");
}

// --- GL2.5: Triangle fill ---

fn test_fill_triangle() {
  tv = graphics::canvas(20, 20, 0);
  tc = graphics::rgb(128, 0, 255);
  tv.fill_triangle(10, 2, 2, 18, 18, 18, tc);
  // Center should be filled
  assert(tv.get_pixel(10, 12) == tc, "triangle center");
  // Vertices should be filled
  assert(tv.get_pixel(10, 2) == tc, "triangle top");
  assert(tv.get_pixel(2, 18) == tc, "triangle bottom-left");
  assert(tv.get_pixel(18, 18) == tc, "triangle bottom-right");
  // Outside should be empty
  assert(tv.get_pixel(0, 0) == 0, "triangle outside");
}

fn test_fill_triangle_flat_top() {
  tv2 = graphics::canvas(20, 20, 0);
  tc2 = graphics::rgb(255, 128, 0);
  // Flat-top triangle: (5,5), (15,5), (10,15)
  tv2.fill_triangle(5, 5, 15, 5, 10, 15, tc2);
  assert(tv2.get_pixel(10, 10) == tc2, "flat-top center");
  assert(tv2.get_pixel(5, 5) == tc2, "flat-top left");
  assert(tv2.get_pixel(15, 5) == tc2, "flat-top right");
}

// The interior, sampled AWAY from the apex column and the base row.
//
// Every assertion in test_fill_triangle above lands on either the
// apex column (x == 10) or the base row (y == 18) — and a fill that
// draws nothing but those two lines satisfies all of them.  That is
// exactly what a divide-before-multiply in the edge interpolation
// produces, so those tests cannot see it.  These points are inside
// the triangle and on neither line, which pins the actual fill.
fn test_fill_triangle_interior_off_axis() {
  tvi = graphics::canvas(20, 20, 0);
  tci = graphics::rgb(0, 200, 100);
  // Apex (10,2), base from (2,18) to (18,18).
  tvi.fill_triangle(10, 2, 2, 18, 18, 18, tci);
  // Half-way down the span is roughly x=6..14 — sample either side
  // of the centre column.
  assert(tvi.get_pixel(7, 11) == tci, "interior left of the apex column");
  assert(tvi.get_pixel(13, 11) == tci, "interior right of the apex column");
  // Just inside the slanted edges, near the base.
  assert(tvi.get_pixel(4, 16) == tci, "interior near the left edge");
  assert(tvi.get_pixel(16, 16) == tci, "interior near the right edge");
  // The matching OUTSIDE points, so a bounding-box over-fill cannot
  // pass either.
  assert(tvi.get_pixel(2, 6) == 0, "outside beyond the left edge");
  assert(tvi.get_pixel(18, 6) == 0, "outside beyond the right edge");
}

// A flat-top triangle degenerates the other way: divide-before-
// multiply pins both edges to their top x, so the fill comes out as a
// RECTANGLE spanning the full top width all the way down.  The
// corners below the slanted edges catch that.
fn test_fill_triangle_flat_top_not_a_rectangle() {
  tvr = graphics::canvas(20, 20, 0);
  tcr = graphics::rgb(255, 0, 128);
  // Flat top (5,5)-(15,5), apex down at (10,15).
  tvr.fill_triangle(5, 5, 15, 5, 10, 15, tcr);
  assert(tvr.get_pixel(10, 14) == tcr, "filled just above the bottom apex");
  // By the last row the span has narrowed to about x=10, so the outer
  // columns must be empty.
  assert(tvr.get_pixel(5, 14) == 0, "bottom-left corner is outside");
  assert(tvr.get_pixel(15, 14) == 0, "bottom-right corner is outside");
}

fn test_fill_triangle_degenerate() {
  tv3 = graphics::canvas(10, 10, 0);
  tc3 = graphics::rgb(100, 100, 100);
  // Degenerate: all on same y
  tv3.fill_triangle(2, 5, 5, 5, 8, 5, tc3);
  // Should not crash; degenerate triangle draws nothing
  assert(tv3.get_pixel(5, 5) == 0, "degenerate triangle");
}

// `canvas` builds its pixel buffer by doubling and appending single
// elements on the set bits of the count, rather than one element at a
// time.  That is ~17x faster and it lands on the exact count by
// construction — but only if the bit walk is right, and an off-by-one
// there gives a buffer that is half the size or one too long.
//
// So the sizes below are chosen for their BIT PATTERNS, not for being
// realistic: a power of two, one less and one more than a power of two,
// a Mersenne-style all-ones, a prime, and the two degenerate ends.
// Each asserts the length AND that the first, a middle and the last
// pixel carry the fill — a short buffer would otherwise read as fine
// through `get_pixel`, which answers 0 out of range.
fn test_canvas_buffer_is_exactly_the_pixel_count() {
  tbc_col = graphics::rgb(11, 22, 33);
  tbc_sizes = [1, 2, 3, 4, 7, 8, 9, 15, 16, 17, 31, 63, 64, 65, 127, 255, 256, 257, 1023];
  for tbc_n in tbc_sizes {
    tbc_cv = graphics::canvas(tbc_n, 1, tbc_col);
    assert(tbc_cv.data.len() == tbc_n,
           "n={tbc_n}: buffer is {tbc_cv.data.len()}, want {tbc_n}");
    assert(tbc_cv.data[0] == tbc_col, "n={tbc_n}: first pixel");
    assert(tbc_cv.data[tbc_n / 2] == tbc_col, "n={tbc_n}: middle pixel");
    assert(tbc_cv.data[tbc_n - 1] == tbc_col, "n={tbc_n}: last pixel");
  }
}

// Two dimensions multiplied, so the count is not a loop bound the
// caller handed in — 97 x 61 = 5917, a product of two primes.
fn test_canvas_two_dimensions_multiply_exactly() {
  ttd_cv = graphics::canvas(97, 61, graphics::rgb(1, 2, 3));
  assert(ttd_cv.data.len() == 5917, "97x61 = {ttd_cv.data.len()}, want 5917");
  assert(ttd_cv.get_pixel(96, 60) == graphics::rgb(1, 2, 3), "the far corner is filled");
}

// The degenerate ends.  A zero or negative dimension must give an empty
// buffer rather than looping forever or allocating something huge.
fn test_canvas_empty_dimensions() {
  ted_z = graphics::canvas(0, 5, 99);
  assert(ted_z.data.len() == 0, "zero width is an empty buffer");
  ted_h = graphics::canvas(5, 0, 99);
  assert(ted_h.data.len() == 0, "zero height is an empty buffer");
  ted_n = graphics::canvas(-3, 4, 99);
  assert(ted_n.data.len() == 0, "a negative dimension is an empty buffer");
  ted_1 = graphics::canvas(1, 1, 99);
  assert(ted_1.data.len() == 1, "one pixel");
  assert(ted_1.data[0] == 99, "and it carries the fill");
}

tests/font_ascent.loft

// @P340 regression: lib/graphics must expose the font's real ASCENT metric so
// callers can baseline-align mixed-size text.  Before the fix there was no
// ascent accessor at all (only a hardcoded `size * 0.8` approximation inside
// rasterize_text).  gl_font_ascent reads fontdue's horizontal_line_metrics.
use graphics::*;

fn test_font_ascent_is_positive_and_scales() {
  // #255: the path is program-relative (resolved against THIS test file's dir),
  // so the fixture at the package root's examples/ is "../examples/".  That
  // resolution is the thing under test as much as the metric is: before the fix
  // this path missed under --native, which runs from a temp dir.
  f = graphics::gl_load_font("../examples/LiberationSansNarrow-Bold.ttf");
  // NOT a graceful skip.  This assertion is the point: the suite used to wrap
  // the body in `if f >= 0` with no font in the repo, so it never ran and would
  // have passed with the fix reverted.
  assert(f >= 0, "the bundled fixture font must load (handle {f})");
  a_small = graphics::gl_font_ascent(f, 20.0);
  a_large = graphics::gl_font_ascent(f, 40.0);
  assert(a_small > 0.0, "ascent at 20px should be positive ({a_small})");
  assert(a_large > a_small, "ascent grows with size ({a_large} vs {a_small})");
  // The ascent must be a sensible fraction of the em — strictly less than the
  // full pixel size for a typical Latin font (ascent ~0.7-0.95 em).
  assert(a_large < 40.0, "ascent at 40px should be below the em size ({a_large})");
}

tests/gl_handles.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A program that draws its own textured quad with a painter's shader (crawler's view) reads the
// painter's shader and quad VAO, and binds a sprite sheet's texture: those GL handles are `pub`.
// Building a painter needs a GL context, so the check is that this file COMPILES — outside the
// file that declares it, a field without `pub` is refused.
use graphics::*;

fn quad_shader(p: Painter2D) -> integer { p.p2_tex }
fn quad_vao(p: Painter2D) -> integer { p.p2_vao }
fn sheet_texture(s: SpriteSheet) -> integer { s.ss_tex }

fn test_a_custom_draw_reads_the_painter_and_sheet_handles() {
  painter_readers = [quad_shader, quad_vao];
  sheet_readers = [sheet_texture];
  assert(len(painter_readers) == 2 && len(sheet_readers) == 1, "every reader compiled");
}

tests/glb.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the GLB 2.0 binary writer (GL5: save_glb).
// Run with: cargo run --bin loft -- --lib lib/graphics/src --tests lib/graphics/tests/glb.loft
use mesh3d::*;
use glb::*;

// Verify that save_glb produces a non-empty file.
fn test_glb_creates_file() {
  gc_m = mesh::cube();
  glb::save_glb(gc_m, "test_glb_a.glb");
  assert(file("test_glb_a.glb").size > 0, "GLB file was created");
  delete("test_glb_a.glb");
}

// Verify the 12-byte GLB header: magic 'glTF', version 2, and total length.
fn test_glb_header() {
  gh_m = mesh::cube();
  glb::save_glb(gh_m, "test_glb_b.glb");
  gh_sz = file("test_glb_b.glb").size;
  {gh_f = file("test_glb_b.glb");
   gh_f#format = LittleEndian;
   assert(gh_f#read(4) as i32 == 0x46546C67, "magic 'glTF'");
   assert(gh_f#read(4) as i32 == 2, "version 2");
   gh_total = gh_f#read(4) as i32;
   assert(gh_total as integer == gh_sz, "total_len matches file size={gh_sz}");
  }
  delete("test_glb_b.glb");
}

// Verify the JSON chunk type tag and BIN chunk type tag.
// Reads the header sequentially (f#next seek-to-offset does not work before
// the first read on a freshly opened file handle).
fn test_glb_chunk_types() {
  gt_m = mesh::cube();
  glb::save_glb(gt_m, "test_glb_c.glb");
  {gt_f = file("test_glb_c.glb");
   gt_f#format = LittleEndian;
   gt_f#read(4) as i32;  // magic
   gt_f#read(4) as i32;  // version
   gt_f#read(4) as i32;  // total_len
   gt_jlen = gt_f#read(4) as i32;  // JSON chunk data length
   assert(gt_f#read(4) as i32 == 0x4E4F534A, "JSON chunk type 'JSON'");
   // Skip JSON data (includes padding): seek past it.
   // After reading 5 x 4 = 20 bytes, current position = 20.
   // JSON data starts at 20 and is gt_jlen bytes long.
   // Use #next to jump to BIN chunk header at 20 + gt_jlen.
   gt_f#next = (20 + gt_jlen) as integer;
   gt_f#read(4) as i32;  // skip BIN chunk data length
   assert(gt_f#read(4) as i32 == 0x004E4942, "BIN chunk type 'BIN\\0'");
  }
  delete("test_glb_c.glb");
}

// Verify the BIN chunk data length for a known mesh (3 vertices, 1 triangle).
// bin = pos(3*12) + norm(3*12) + uv(3*8) + idx(3*4) = 36+36+24+12 = 108 bytes.
fn test_glb_bin_size() {
  gb_m = mesh::mesh("tri");
  gb_n = math::vec3(0.0, 0.0, 1.0);
  gb_uv = math::vec2(0.0, 0.0);
  gb_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), gb_n, gb_uv));
  gb_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), gb_n, gb_uv));
  gb_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), gb_n, gb_uv));
  gb_m.add_triangle(0, 1, 2);
  glb::save_glb(gb_m, "test_glb_d.glb");
  {gb_f = file("test_glb_d.glb");
   gb_f#format = LittleEndian;
   gb_f#read(4) as i32;  // magic
   gb_f#read(4) as i32;  // version
   gb_f#read(4) as i32;  // total_len
   gb_jlen = gb_f#read(4) as i32;  // JSON chunk length
   gb_f#read(4) as i32;  // JSON chunk type
   // Now seek past JSON data to BIN chunk header.
   gb_f#next = (20 + gb_jlen) as integer;
   gb_blen = gb_f#read(4) as i32;  // BIN chunk data length
   assert(gb_blen == 108, "bin size for 3-vert 1-tri={gb_blen}");
  }
  delete("test_glb_d.glb");
}

// Verify JSON chunk data length is a multiple of 4.
fn test_glb_json_padding() {
  gp_m = mesh::cube();
  glb::save_glb(gp_m, "test_glb_e.glb");
  {gp_f = file("test_glb_e.glb");
   gp_f#format = LittleEndian;
   gp_f#read(4) as i32;  // magic
   gp_f#read(4) as i32;  // version
   gp_f#read(4) as i32;  // total_len
   gp_jlen = gp_f#read(4) as i32;  // JSON chunk length
   assert(gp_jlen % 4 == 0, "JSON chunk is 4-byte aligned={gp_jlen}");
  }
  delete("test_glb_e.glb");
}

tests/group_vbos.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// A renderer asks whether a group already has its buffers and how many groups a set holds
// (dryopea's ground and entity passes): `GroupVboSet.slots` is readable outside this library.
use graphics::*;

fn test_a_new_set_holds_no_groups() {
  set = group_vbos_new(10, DRAW_TRIANGLES);
  assert(len(set.slots) == 0, "a new set holds no groups, got {len(set.slots)}");
}

tests/input_events.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Binding smoke test for the Gap 01 input-event queue: the queue is pollable
// without a GL window (gl_next_event / gl_event_* touch only the event queue,
// not GL), so this validates the loft<->native binding, constant exports, and
// return marshalling on both interpreter and --native — no display needed.
use graphics::*;

fn main() {
  // With no input, the queue is empty: next_event returns EV_NONE and the
  // accessors read their reset defaults.
  assert(graphics::gl_next_event() == graphics::EV_NONE);
  assert(graphics::gl_event_key() == 0);
  assert(graphics::gl_event_mods() == 0);
  assert(graphics::gl_event_text() == "");
  assert(graphics::gl_event_x() == 0.0);
  assert(graphics::gl_event_wheel() == 0);
  // Constants are exported with the expected values (kept in sync with the
  // native EV_*/MOD_*/KEY_* codes).
  assert(graphics::EV_TEXT == 3);
  assert(graphics::EV_RESIZE == 11);
  assert(graphics::MOD_CTRL == 2);
  assert(graphics::KEY_HOME == 149);
  assert(graphics::KEY_ENTER == 13);
  println("graphics input_events smoke: PASS");
}

tests/kerning.loft

// @P339 regression: lib/graphics text rendering must apply kerning pairs.
// Before the fix, `gl_measure_text("AV")` == `measure("A")+measure("V")`
// exactly (no kern table consulted in measure_text / rasterize_text); A–V is
// a textbook negative-kern pair, so the kerned measure must be strictly less.
use graphics::*;

fn test_kerning_pair_narrows_measure() {
  // #255: the path is program-relative (resolved against THIS test file's dir),
  // so the fixture at the package root's examples/ is "../examples/".
  f = graphics::gl_load_font("../examples/LiberationSansNarrow-Bold.ttf");
  // NOT a graceful skip — see font_ascent.loft.  A missing font must fail here,
  // otherwise the kerning guard below silently never runs.
  assert(f >= 0, "the bundled fixture font must load (handle {f})");
  av = graphics::gl_measure_text(f, "AV", 40.0);
  a = graphics::gl_measure_text(f, "A", 40.0);
  v = graphics::gl_measure_text(f, "V", 40.0);
  assert(av < a + v, "AV ({av}) should kern narrower than A+V ({a + v})");
  // sanity: a single glyph is unaffected by kerning
  assert(a > 0.0, "A has positive width");
}

tests/lanczos.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN146 W1 — `resize_lanczos`, pinned against PILLOW rather than against
// itself.
//
// The `.draw` corpus renders at 3x and downscales with `Image.LANCZOS`, so W2's
// pixel-comparison is only possible if this reproduces Pillow byte for byte.
// Every expectation below is Pillow 10.2's actual output, captured once and
// written down — a golden generated by the thing being ported would only pin the
// port to itself.
//
// The eight cases exercise the axes that break a resampler: the oracle's exact 3x
// downscale, non-integer ratios, a non-square image, a single-pixel axis, a
// degenerate 3x1 output, and an UPSCALE (where the filter must not widen).
use graphics::*;

fn from_rgba(fr: const vector<integer>, fw: integer, fh: integer) -> graphics::Canvas {
  c = graphics::canvas(fw, fh, 0);
  d = &c.data;
  for i in 0..(fw * fh) {
    d[i] = (fr[i * 4 + 3]? << 24) | (fr[i * 4]? << 16) | (fr[i * 4 + 1]? << 8) | fr[i * 4 + 2]?;
  }
  c
}

fn differing(dc: const graphics::Canvas, want: const vector<integer>) -> integer {
  n = 0;
  for i in 0..len(want) { if dc.data[i]? != want[i]? { n += 1; } }
  n
}

fn test_lanczos_9x9_to_3x3() {
  src: vector<integer> = [255,70,150,29,155,139,155,78,198,8,227,160,3,95,200,255,71,247,101,254,235,214,20,57,119,60,31,36,115,148,32,34,53,177,70,140,119,178,165,193,212,13,179,4,239,185,193,206,248,241,74,206,167,72,126,2,29,189,138,45,219,126,59,185,137,240,93,166,208,80,190,116,122,106,1,227,228,248,40,96,192,105,74,161,138,42,84,219,99,101,242,98,102,122,75,164,51,243,168,22,144,185,119,10,253,193,12,102,111,197,79,208,255,19,245,81,122,87,239,39,203,148,174,111,251,188,87,237,74,171,242,27,17,146,104,38,196,42,245,116,160,124,139,248,38,214,255,121,18,122,3,132,163,95,213,19,4,167,198,203,43,44,57,135,219,161,9,61,197,208,50,13,26,166,178,223,66,72,46,78,46,33,202,254,166,55,119,231,37,105,182,96,58,61,50,40,156,174,105,16,0,77,90,185,148,19,185,222,30,222,121,99,3,76,101,160,173,138,252,66,152,122,224,119,214,41,193,110,89,140,59,208,30,10,9,5,231,25,251,196,137,255,239,130,141,202,212,215,191,232,65,140,36,238,148,222,230,86,221,114,255,133,98,237,47,13,43,161,102,77,200,22,162,248,230,32,175,9,207,114,16,88,32,250,182,30,30,144,71,214,236,206,226,4,15,185,0,98,125,32,143,24,136,106,109,89,99,174,58,141,52,84,41,135,197,28,81,192,225,121,214,163,146,180];
  want: vector<integer> = [2309852028,2274204282,1386521680,2271703430,1736339326,2170977430,2626580369,1652179836,2557707404];
  got = from_rgba(src, 9, 9).resize_lanczos(3, 3);
  assert(got.width == 3 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "9x9 -> 3x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_12x7_to_4x3() {
  src: vector<integer> = [210,147,179,7,235,6,151,245,156,14,208,66,75,92,25,184,214,43,107,8,192,49,76,161,38,201,214,246,109,93,65,115,190,151,109,55,7,216,240,23,19,141,99,131,141,64,19,188,69,231,164,194,36,177,44,134,132,79,239,130,145,205,12,216,40,88,33,131,254,13,169,96,123,84,68,159,20,71,142,125,84,71,10,76,166,66,29,48,233,70,155,39,209,130,254,143,109,171,116,164,73,51,106,186,123,77,164,124,51,80,14,211,172,145,87,63,132,185,45,57,166,4,109,28,45,143,161,194,84,204,224,82,161,46,3,239,76,213,204,110,58,202,205,102,232,3,90,63,76,180,222,81,159,252,94,241,18,19,229,112,11,87,107,216,204,129,210,201,82,160,17,149,129,74,201,87,207,133,138,100,229,149,116,202,126,28,59,246,45,16,25,103,148,10,180,173,102,101,45,202,230,13,143,66,93,220,185,7,208,94,183,119,78,90,24,16,48,167,31,98,249,250,11,234,79,164,196,7,172,124,123,103,73,215,183,177,19,143,8,222,105,79,205,178,107,115,92,68,93,40,72,42,193,114,19,104,31,248,95,48,108,168,120,73,5,75,104,49,103,23,64,158,254,241,173,171,93,203,31,115,19,74,106,247,159,196,66,145,24,201,16,7,71,190,251,50,87,184,217,53,211,186,68,174,139,122,170,116,123,214,51,202,63,112,190,2,30,67,131,198,48,242,127,241,145,204,208,50,210,124,239,214,160,110,115,20];
  want: vector<integer> = [2507436665,2306303562,1900964482,1585736815,2456778357,2019389047,1957139296,2742382684,1401577360,1419025749,2222040166,2624297855];
  got = from_rgba(src, 12, 7).resize_lanczos(4, 3);
  assert(got.width == 4 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "12x7 -> 4x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_5x5_to_2x2() {
  src: vector<integer> = [244,69,103,174,119,30,96,199,21,207,14,84,116,255,17,211,31,81,70,165,221,201,243,48,41,244,225,125,145,164,56,176,225,54,40,26,66,212,120,89,46,156,169,0,73,113,28,47,131,202,176,102,187,255,75,21,66,170,96,221,249,58,175,211,64,218,16,48,232,168,161,153,138,240,233,230,24,130,222,21,82,57,190,144,115,155,130,42,39,141,47,23,6,165,73,138,16,64,100,177];
  want: vector<integer> = [1888454774,2053028935,1437303202,1985656215];
  got = from_rgba(src, 5, 5).resize_lanczos(2, 2);
  assert(got.width == 2 && got.height == 2, "size");
  assert(differing(got, want) == 0,
         "5x5 -> 2x2: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_16x16_to_5x5() {
  src: vector<integer> = [60,214,183,211,125,66,28,185,58,246,233,222,230,93,170,169,192,63,96,48,130,172,113,30,78,61,234,226,171,61,74,200,187,88,129,239,64,173,124,54,39,3,88,79,17,22,142,248,177,154,100,196,131,105,55,62,112,133,74,153,226,81,98,93,186,116,112,122,203,198,245,180,141,201,249,85,181,197,39,102,23,215,242,134,60,86,38,139,206,64,237,225,46,235,42,242,215,116,91,156,52,168,248,44,160,134,244,6,234,194,44,223,17,85,136,252,9,57,25,109,246,172,75,246,154,162,228,207,132,145,134,124,171,92,212,51,12,209,203,161,61,74,195,218,159,217,49,44,24,195,24,177,84,57,118,203,130,149,174,70,105,78,135,110,32,159,66,208,87,105,74,18,142,198,85,29,158,189,175,70,212,8,101,227,39,79,94,252,103,155,57,204,140,116,191,219,144,124,150,111,253,14,122,116,153,65,94,178,167,254,93,123,124,75,150,137,100,56,216,216,53,58,165,59,48,166,37,198,178,175,45,13,0,41,29,34,207,213,101,174,191,44,238,87,216,200,214,122,167,18,154,147,29,194,70,66,6,121,159,2,111,222,77,213,204,162,136,48,15,106,52,10,139,121,165,30,188,7,170,76,80,205,106,208,79,66,127,23,25,115,207,175,140,246,65,58,27,226,106,17,41,111,9,192,76,204,29,11,1,231,4,63,75,182,38,235,95,38,205,152,188,202,115,233,206,227,98,129,238,244,82,184,81,124,163,11,6,202,134,139,44,34,241,139,165,96,45,105,217,61,243,193,53,155,206,122,96,133,94,77,180,67,137,25,82,205,143,57,95,254,89,34,215,134,101,150,25,196,7,184,51,171,106,39,66,4,10,169,255,166,197,198,240,104,34,45,242,111,30,243,175,235,64,59,27,6,87,16,233,51,201,112,107,27,143,118,98,94,96,116,24,233,171,171,216,173,157,164,27,11,139,9,233,15,216,222,122,192,51,90,84,112,91,124,53,169,122,221,82,129,203,63,253,202,114,51,225,217,221,252,24,137,192,86,35,147,195,38,27,140,109,117,250,246,124,92,245,211,248,144,210,109,149,77,250,70,131,127,255,51,69,175,133,159,82,81,180,112,23,219,115,76,168,103,185,201,136,99,49,9,147,223,98,118,76,73,41,29,5,15,220,76,27,252,77,239,13,54,90,116,160,217,60,11,41,100,70,128,247,201,196,23,115,114,34,66,92,246,113,145,232,88,156,206,92,106,120,250,98,200,232,133,249,99,107,228,165,84,179,255,24,161,150,237,57,138,86,249,12,12,175,56,50,36,109,180,228,217,77,50,246,151,195,80,15,146,15,39,166,113,247,3,2,5,123,78,230,235,133,37,186,40,239,181,20,15,65,134,213,42,107,200,253,224,35,107,146,71,176,121,59,105,131,123,154,107,236,161,32,245,245,176,199,121,247,182,36,152,208,135,183,10,175,112,35,35,144,245,86,209,70,153,216,254,136,174,22,91,2,76,222,252,31,61,115,239,80,16,252,223,241,20,230,9,104,31,45,140,185,157,205,169,216,75,52,184,227,217,3,199,94,70,4,204,4,138,190,204,59,96,26,37,245,246,183,73,183,63,153,105,72,53,251,239,224,198,77,70,5,135,117,62,88,20,185,236,73,103,59,88,162,121,119,84,10,43,220,164,62,227,217,21,234,95,22,16,163,235,123,231,120,75,237,144,19,231,51,117,126,197,74,91,61,159,228,36,123,185,9,67,3,190,170,148,195,204,3,48,137,188,99,203,212,91,91,239,178,12,107,243,61,217,56,120,91,54,181,3,130,190,99,101,239,142,108,166,85,66,154,229,77,252,39,110,248,225,60,36,126,208,51,24,61,191,68,124,54,201,251,214,77,229,203,243,1,216,155,20,202,104,23,177,49,202,49,10,94,189,51,233,198,241,133,182,5,242,191,217,221,201,166,8,142,14,4,66,173,158,92,214,170,181,36,193,172,45,193,59,130,108,30,193,96,227,168,150,75,199,12,163,218,65,19,248,23,236,114,139,188,151,60,238,234,161,1,157,5,196,156,228,123,255,144,6,157,192,231,24,178,168,110,227,21,111,26,208,9,227,226,38,186,250,202,238,72,14,100,215,62,57,224,192,76,181,70,81,179,157,130,106,50,38,164,68,44,163,110,86,56,184,206,182,52,189,162,112,45,226,73,159,251,169,198,27,193,13,147,28,175,221,36,130,150,109,61,33,242,237,152,225,131,245,43,225,238,121,25,187,251,65,210,237,42,181,144,238,235,160,120,192,158,177,212,28];
  want: vector<integer> = [2373623215,2405923986,2523820912,1852470879,3012259948,2142414960,1719629951,2002680984,1267636568,2304932966,1992462450,2037938829,2122145200,2107870344,2458883166,1500883351,2490925709,2639023511,1701803167,2443223373,2623449719,2189274204,2005517150,2929235565,2373615751];
  got = from_rgba(src, 16, 16).resize_lanczos(5, 5);
  assert(got.width == 5 && got.height == 5, "size");
  assert(differing(got, want) == 0,
         "16x16 -> 5x5: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_7x3_to_3x1() {
  src: vector<integer> = [11,9,120,203,129,166,107,235,87,76,33,252,148,252,242,167,213,80,118,216,207,93,121,76,86,63,158,84,59,25,103,209,237,36,188,5,96,208,56,99,152,238,76,39,214,113,63,211,191,221,174,80,188,205,127,10,97,241,7,44,57,95,33,48,171,201,17,153,242,1,90,25,139,126,128,114,109,154,52,57,197,210,173,155];
  want: vector<integer> = [2135450192,2141624926,1605994118];
  got = from_rgba(src, 7, 3).resize_lanczos(3, 1);
  assert(got.width == 3 && got.height == 1, "size");
  assert(differing(got, want) == 0,
         "7x3 -> 3x1: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_1x9_to_1x3() {
  src: vector<integer> = [144,38,121,105,245,0,207,116,132,142,232,74,114,223,229,52,62,9,137,97,106,223,61,175,51,128,156,16,223,87,10,188,91,232,182,233];
  want: vector<integer> = [1572482760,1448840819,2677253720];
  got = from_rgba(src, 1, 9).resize_lanczos(1, 3);
  assert(got.width == 1 && got.height == 3, "size");
  assert(differing(got, want) == 0,
         "1x9 -> 1x3: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_4x4_to_8x8() {
  src: vector<integer> = [165,228,15,5,207,170,54,186,245,17,215,166,137,195,234,109,145,161,248,76,210,117,167,61,228,200,71,142,92,108,90,101,202,246,205,92,146,109,125,225,215,16,60,95,65,193,129,252,24,81,188,252,37,194,248,102,219,242,247,192,204,142,207,215];
  want: vector<integer> = [0,667609856,2647048729,3923149906,3337820593,2431058431,2074577151,1836580846,292128752,732675411,1942600748,3000995157,3052620444,2447523795,1890222552,1534119122,1131777263,981698784,1035768466,1643423849,2515773295,2463538042,1772125822,1314215295,1304486399,1320399089,1386505168,1607363438,1928440863,2176169245,2237438787,2166720606,889192447,1742199228,3047128718,3097970277,1743259406,1854891545,3225137503,3794589311,1920843992,2608242094,3464136080,3264702082,2061189255,2376826514,3781931156,4284470179,3677119690,3425002940,2603713469,2139276500,2513756396,3235103707,3736446660,3903101371,4279915483,3842198466,1946189809,1348927487,2798911487,3772898803,3605503714,3437194195];
  got = from_rgba(src, 4, 4).resize_lanczos(8, 8);
  assert(got.width == 8 && got.height == 8, "size");
  assert(differing(got, want) == 0,
         "4x4 -> 8x8: {differing(got, want)} pixels differ from Pillow");
}

fn test_lanczos_10x10_to_9x9() {
  src: vector<integer> = [149,90,26,212,215,240,163,214,50,247,7,180,142,71,131,21,233,78,55,79,116,245,131,224,234,182,244,66,11,234,191,14,163,63,246,12,95,252,53,57,157,106,93,46,184,58,189,144,226,31,102,89,106,242,0,120,218,170,55,3,77,220,174,59,99,90,239,152,111,217,107,24,122,117,216,193,216,159,244,102,5,114,20,163,4,68,126,76,92,65,88,90,233,67,165,123,104,19,204,177,155,96,107,180,186,154,146,147,49,132,187,201,23,223,3,185,31,195,223,7,62,66,249,135,146,90,243,124,72,29,215,133,63,159,159,189,26,32,186,240,139,191,26,201,139,101,228,249,174,105,40,3,181,12,221,55,69,208,163,202,108,219,25,179,29,60,157,93,181,124,97,222,194,16,230,126,125,121,214,162,158,8,33,243,67,81,224,154,87,47,193,244,120,162,197,220,129,238,4,25,73,191,192,126,233,71,110,222,196,104,149,88,144,110,217,74,107,151,36,14,230,39,249,179,244,143,3,17,92,206,34,74,56,10,163,219,208,155,245,80,202,123,32,108,102,37,186,17,113,48,42,188,169,237,142,203,209,99,16,241,201,80,88,131,213,16,38,161,105,100,223,125,3,0,125,20,29,37,50,203,39,229,49,232,192,233,171,151,24,86,91,10,201,61,160,41,123,194,68,205,45,163,160,166,149,226,180,96,254,79,198,194,105,115,168,112,53,24,187,254,186,59,130,193,245,71,50,67,182,167,248,138,158,163,101,9,145,151,65,124,149,232,91,193,123,71,105,102,152,186,242,32,61,13,126,27,167,137,242,82,201,229,66,195,127,130,207,156,148,236,111,201,90,99,204,104,215,229,130,215,13,229,245,241,247,57,187,166,251,247,197,136,4,139,189,154,141,251,134,218];
  want: vector<integer> = [3265881396,3636390030,2200169731,218092544,2576144994,2441732534,134161663,313145599,1132058734,1213559861,1974082224,1673751603,1401997105,495140324,2272359368,1448442310,3109916836,1523683571,2601343073,1429422766,1684754321,2994025924,3478995596,3116277370,2455406801,2215104005,1278137212,2388625569,1935754973,3162661266,3342753495,3831518536,4135808134,2254120955,1471511542,2538327190,2774126675,2998358892,2212128646,648480511,2662607774,2106986661,2067557319,4284374453,704619007,1573218449,1759123539,2725933676,2612447310,2381330774,2013204838,1282967137,1275265191,2554542672,3259229994,1520561834,1030776877,2863969368,3647968845,1952616833,3202438589,1621392580,4045478285,3314770283,1675661960,1103673599,492848640,3112357978,2272508273,1019696588,797395869,1736003570,3083649366,2710149556,2877345927,2043317653,4050320053,3295639767,2046800307,2199103938,3263362443];
  got = from_rgba(src, 10, 10).resize_lanczos(9, 9);
  assert(got.width == 9 && got.height == 9, "size");
  assert(differing(got, want) == 0,
         "10x10 -> 9x9: {differing(got, want)} pixels differ from Pillow");
}

// Premultiplication is the part no one guesses. Resampling straight RGBA lets a
// transparent texel's COLOUR bleed into its neighbours; Pillow premultiplies, so
// it cannot. A fully transparent black half beside an opaque white half must not
// darken the white.
fn test_a_transparent_neighbour_does_not_bleed_its_colour() {
  c = graphics::canvas(8, 2, 0);
  d = &c.data;
  for y in 0..2 {
    for x in 0..8 {
      // Left half: opaque white.  Right half: transparent BLACK.
      d[y * 8 + x] = if x < 4 { 0xFFFFFFFF } else { 0x00000000 };
    }
  }
  out = c.resize_lanczos(4, 1);
  left = out.data[0]?;
  assert(((left >> 16) & 255) == 255, "the opaque white edge stays white, not grey");
  assert(((left >> 24) & 255) == 255, "and stays opaque");
}

// Degenerate sizes answer a canvas rather than a null or a crash.
fn test_a_degenerate_target_is_still_a_canvas() {
  c = graphics::canvas(4, 4, 0xFF102030);
  z = c.resize_lanczos(0, 0);
  assert(z.width == 1 && z.height == 1, "clamped to 1x1, not zero-sized");
}

tests/math.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the math module.
use mesh3d::*;

fn test_vec3_create() {
  mv = math::vec3(1.0, 2.0, 3.0);
  assert(mv.x == 1.0, "x");
  assert(mv.y == 2.0, "y");
  assert(mv.z == 3.0, "z");
}

fn test_vec3_add() {
  ma = math::vec3(1.0, 2.0, 3.0);
  mb = math::vec3(4.0, 5.0, 6.0);
  mc = math::add3(ma, mb);
  assert(mc.x == 5.0, "add x");
  assert(mc.y == 7.0, "add y");
  assert(mc.z == 9.0, "add z");
}

fn test_vec3_dot() {
  da = math::vec3(1.0, 0.0, 0.0);
  db = math::vec3(0.0, 1.0, 0.0);
  assert(math::dot3(da, db) == 0.0, "orthogonal dot");
  dc = math::vec3(1.0, 0.0, 0.0);
  assert(math::dot3(da, dc) == 1.0, "parallel dot");
}

fn test_vec3_cross() {
  cx = math::vec3(1.0, 0.0, 0.0);
  cy = math::vec3(0.0, 1.0, 0.0);
  cz = math::cross(cx, cy);
  assert(cz.x == 0.0, "cross x");
  assert(cz.y == 0.0, "cross y");
  assert(cz.z == 1.0, "cross z");
}

fn test_vec3_normalize() {
  nv = math::vec3(3.0, 0.0, 0.0);
  nn = math::normalize3(nv);
  assert(nn.x == 1.0, "norm x");
  assert(nn.y == 0.0, "norm y");
  assert(nn.z == 0.0, "norm z");
}

fn test_mat4_identity() {
  id_m = math::mat4_identity();
  assert(id_m.m[0] == 1.0, "m00");
  assert(id_m.m[5] == 1.0, "m11");
  assert(id_m.m[10] == 1.0, "m22");
  assert(id_m.m[15] == 1.0, "m33");
  assert(id_m.m[1] == 0.0, "m10");
}

fn test_mat4_translate() {
  t4_m = math::mat4_translate(10.0, 20.0, 30.0);
  t4_v = math::vec3(0.0, 0.0, 0.0);
  t4_r = math::mat4_transform(t4_m, t4_v);
  assert(t4_r.x == 10.0, "translate x");
  assert(t4_r.y == 20.0, "translate y");
  assert(t4_r.z == 30.0, "translate z");
}

fn test_mat4_scale() {
  s4_m = math::mat4_scale(2.0, 3.0, 4.0);
  s4_v = math::vec3(1.0, 1.0, 1.0);
  s4_r = math::mat4_transform(s4_m, s4_v);
  assert(s4_r.x == 2.0, "scale x");
  assert(s4_r.y == 3.0, "scale y");
  assert(s4_r.z == 4.0, "scale z");
}

fn test_mat4_mul() {
  tma = math::mat4_translate(5.0, 0.0, 0.0);
  tmb = math::mat4_scale(2.0, 2.0, 2.0);
  tmc = math::mat4_mul(tma, tmb);
  tmv = math::vec3(1.0, 0.0, 0.0);
  tmres = math::mat4_transform(tmc, tmv);
  assert(tmres.x == 7.0, "mul result x={tmres.x}");
}

tests/mesh.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the mesh module.
use mesh3d::*;

fn test_mesh_create() {
  mc_m = mesh::mesh("test");
  assert(mc_m.name == "test", "mesh name");
  assert(mc_m.vertices.len() == 0, "empty verts");
  assert(mc_m.triangles.len() == 0, "empty tris");
}

fn test_mesh_add_vertex() {
  mv_m = mesh::mesh("v");
  mv_v = mesh::vertex(math::vec3(1.0, 2.0, 3.0), math::vec3(0.0, 1.0, 0.0), math::vec2(0.5, 0.5));
  mv_i = mv_m.add_vertex(mv_v);
  assert(mv_i == 0, "first vertex index");
  assert(mv_m.vertices.len() == 1, "one vertex");
}

fn test_mesh_add_triangle() {
  mt_m = mesh::mesh("t");
  mt_n = math::vec3(0.0, 0.0, 1.0);
  mt_uv = math::vec2(0.0, 0.0);
  mt_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), mt_n, mt_uv));
  mt_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), mt_n, mt_uv));
  mt_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), mt_n, mt_uv));
  mt_m.add_triangle(0, 1, 2);
  assert(mt_m.triangles.len() == 1, "one triangle");
  assert(mt_m.triangles[0].a == 0, "tri a");
  assert(mt_m.triangles[0].b == 1, "tri b");
  assert(mt_m.triangles[0].c == 2, "tri c");
}

fn test_cube() {
  cb = mesh::cube();
  assert(cb.name == "cube", "cube name");
  assert(cb.vertices.len() == 24, "cube has 24 verts (4 per face)");
  assert(cb.triangles.len() == 12, "cube has 12 tris (2 per face * 6 faces)");
}

fn test_plane() {
  pl = mesh::plane("ground", 2.0, 3.0);
  assert(pl.name == "ground", "plane name");
  assert(pl.vertices.len() == 4, "plane has 4 verts");
  assert(pl.triangles.len() == 2, "plane has 2 tris");
  // Check normal is up (y = 1)
  pln = pl.vertices[0].normal;
  assert(pln.y == 1.0, "normal up");
}

fn test_sphere() {
  sp = mesh::sphere("ball", 1.0, 8, 6);
  assert(sp.name == "ball", "sphere name");
  // (slices+1)*(stacks+1) = 9*7 = 63 verts
  assert(sp.vertices.len() == 63, "sphere verts={sp.vertices.len()}");
  // slices*stacks*2 = 8*6*2 = 96 tris
  assert(sp.triangles.len() == 96, "sphere tris={sp.triangles.len()}");
}

tests/polygon.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN146 W1 — `fill_polygon`, the one CPU raster primitive that was missing.
//
// Two things are being pinned.  The pixel COUNTS are hand-computed from the
// geometry, so a change in the fill rule shows up as a number nobody can wave
// through.  And a triangle drawn through the polygon path must be pixel-identical
// to `fill_triangle` — the primitive that already shipped — because two fills
// that disagree about a triangle are two different rasterisers.
use graphics::*;

fn filled(fc: const graphics::Canvas) -> integer {
  n = 0;
  for i in 0..len(fc.data) { if fc.data[i]? != 0 { n += 1; } }
  n
}

// A rectangle as a polygon: x 2..11 and y 2..7 INCLUSIVE, so 10 wide by 6 tall.
fn test_a_convex_polygon_fills_its_own_area() {
  c = graphics::canvas(14, 14, 0);
  pts: vector<graphics::Coord> = [
    graphics::Coord{px: 2, py: 2},  graphics::Coord{px: 11, py: 2},
    graphics::Coord{px: 11, py: 7}, graphics::Coord{px: 2, py: 7}];
  c.fill_polygon(pts, 0xFFFFFFFF);
  assert(filled(c) == 60, "10 wide x 6 tall = 60, not {filled(c)}");
  assert(c.get_pixel(2, 2) != 0 && c.get_pixel(11, 7) != 0, "both corners inclusive");
  assert(c.get_pixel(1, 2) == 0 && c.get_pixel(12, 7) == 0, "and nothing outside");
}

// A U — outer 1..12 square with a notch cut up from the bottom, x 5..8.
// Rows 1..4 are solid: 4 rows x 12 = 48.  Rows 5..12 are two 4-wide arms:
// 8 rows x 8 = 64.  112 in total, and the notch is EMPTY, which is the whole
// point of testing a concave outline rather than another box.
fn test_a_concave_polygon_leaves_its_notch_empty() {
  c = graphics::canvas(14, 14, 0);
  pts: vector<graphics::Coord> = [
    graphics::Coord{px: 1, py: 1},   graphics::Coord{px: 12, py: 1},
    graphics::Coord{px: 12, py: 12}, graphics::Coord{px: 9, py: 12},
    graphics::Coord{px: 9, py: 5},   graphics::Coord{px: 4, py: 5},
    graphics::Coord{px: 4, py: 12},  graphics::Coord{px: 1, py: 12}];
  c.fill_polygon(pts, 0xFFFFFFFF);
  assert(filled(c) == 112, "48 solid + 64 in the arms = 112, not {filled(c)}");
  assert(c.get_pixel(6, 9) == 0, "the notch is empty — a convex fill would cover it");
  assert(c.get_pixel(6, 3) != 0, "while the same column above the notch is solid");
  assert(c.get_pixel(2, 9) != 0 && c.get_pixel(11, 9) != 0, "both arms are filled");
}

// A bowtie: the simplest outline that crosses itself.  Symmetric hourglass on its
// side, rows 2..11 counting 2,4,6,8,10,10,8,6,4,2 = 60.
fn test_a_self_intersecting_polygon_is_filled_even_odd() {
  c = graphics::canvas(14, 14, 0);
  pts: vector<graphics::Coord> = [
    graphics::Coord{px: 2, py: 2},  graphics::Coord{px: 11, py: 11},
    graphics::Coord{px: 11, py: 2}, graphics::Coord{px: 2, py: 11}];
  c.fill_polygon(pts, 0xFFFFFFFF);
  assert(filled(c) == 60, "2+4+6+8+10+10+8+6+4+2 = 60, not {filled(c)}");
  assert(c.get_pixel(3, 2) == 0, "the widest row is the middle, not the top");
  assert(c.get_pixel(3, 6) != 0 && c.get_pixel(10, 6) != 0, "both lobes");
}

// EVEN-ODD, stated as the property that distinguishes it: a star's centre is
// covered twice by the outline and is therefore OUTSIDE.  Non-zero winding fills
// it, so this assertion is what says which rule this is.
fn test_even_odd_leaves_a_stars_centre_hollow() {
  c = graphics::canvas(21, 21, 0);
  pts: vector<graphics::Coord> = [
    graphics::Coord{px: 10, py: 0}, graphics::Coord{px: 14, py: 19},
    graphics::Coord{px: 0, py: 7},  graphics::Coord{px: 20, py: 7},
    graphics::Coord{px: 6, py: 19}];
  c.fill_polygon(pts, 0xFFFFFFFF);
  assert(c.get_pixel(10, 10) == 0, "the centre is crossed twice, so it is OUTSIDE");
  assert(c.get_pixel(10, 3) != 0, "while an arm is inside");
}

// The tie to the primitive that already shipped.  Every triangle on a 5x5 lattice
// — 15625 of them, covering flat tops, flat bottoms, degenerate lines, coincident
// vertices and both windings — must raster identically through both paths.
//
// Proven able to fail: with `fill_polygon`'s closing-row rule left half-open,
// 15000 of the 15625 disagree (the 625 survivors are the degenerate single-row
// ones), by up to 21 pixels.
fn test_a_triangle_through_the_polygon_path_is_the_triangle() {
  bad = 0;
  for i in 0..25 {
    for j in 0..25 {
      for k in 0..25 {
        ax = (i % 5) * 5 + 1; ay = (i / 5) * 5 + 1;
        bx = (j % 5) * 5 + 1; by = (j / 5) * 5 + 1;
        cx = (k % 5) * 5 + 1; cy = (k / 5) * 5 + 1;
        t = graphics::canvas(24, 24, 0);
        p = graphics::canvas(24, 24, 0);
        t.fill_triangle(ax, ay, bx, by, cx, cy, 0xFFFFFFFF);
        pts: vector<graphics::Coord> = [graphics::Coord{px: ax, py: ay},
                                        graphics::Coord{px: bx, py: by},
                                        graphics::Coord{px: cx, py: cy}];
        p.fill_polygon(pts, 0xFFFFFFFF);
        for n in 0..len(t.data) { if t.data[n]? != p.data[n]? { bad += 1; } }
      }
    }
  }
  assert(bad == 0, "{bad} pixels differ between fill_triangle and fill_polygon");
}

// Fewer than three points is not a polygon, and draws nothing rather than
// guessing at a closing edge.
fn test_a_degenerate_polygon_draws_nothing() {
  c = graphics::canvas(8, 8, 0);
  none: vector<graphics::Coord> = [];
  two: vector<graphics::Coord> = [graphics::Coord{px: 1, py: 1},
                                  graphics::Coord{px: 6, py: 6}];
  c.fill_polygon(none, 0xFFFFFFFF);
  c.fill_polygon(two, 0xFFFFFFFF);
  assert(filled(c) == 0, "nothing drawn, not {filled(c)}");
}

tests/public_constants.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The codes a caller passes to the `gl_*` calls and compares input against are PUBLIC: a
// program that writes `use graphics::*;` reaches `KEY_SPACE`, `GL_BLEND`, `EV_KEY_DOWN` by name,
// which is how `input`'s bindings are written.  A constant without `pub` is not exported, and a
// glob import does not bring it in.
use graphics::*;

fn test_the_codes_are_reachable_by_a_glob_import() {
  assert(KEY_SPACE == 32 && KEY_A == 97 && KEY_LEFT == 130 && KEY_F12 > KEY_F1, "key codes");
  assert(GL_DEPTH_TEST != GL_BLEND && BLEND_ONE != BLEND_ZERO, "capabilities and blend factors");
  assert(DRAW_TRIANGLES != DRAW_LINES, "draw modes");
  assert(EV_NONE != EV_KEY_DOWN && MOD_SHIFT != MOD_CTRL, "events and modifiers");
}

tests/scene.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the scene module.
use mesh3d::*;

fn test_scene_create() {
  sc = scene::scene("test scene");
  assert(sc.name == "test scene", "scene name");
  assert(sc.meshes.len() == 0, "no meshes");
  assert(sc.materials.len() == 0, "no materials");
  assert(sc.nodes.len() == 0, "no nodes");
}

fn test_scene_add_mesh() {
  sm_s = scene::scene("s");
  sm_m = mesh::cube();
  sm_i = sm_s.add_mesh(sm_m);
  assert(sm_i == 0, "first mesh index");
  assert(sm_s.meshes.len() == 1, "one mesh");
}

fn test_scene_add_material() {
  smat_s = scene::scene("s");
  smat_m = scene::material("red");
  smat_m.set_color(1.0, 0.0, 0.0);
  smat_i = smat_s.add_material(smat_m);
  assert(smat_i == 0, "first material index");
  assert(smat_s.materials[0].base_color_r == 1.0, "red");
  assert(smat_s.materials[0].base_color_g == 0.0, "not green");
}

fn test_scene_with_node() {
  sn_s = scene::scene("complete");
  sn_mi = sn_s.add_mesh(mesh::mesh("simple"));
  sn_mati = sn_s.add_material(scene::material("default"));
  sn_n = scene::node("cube1", sn_mi, sn_mati);
  sn_s.add_node(sn_n);
  assert(sn_s.nodes.len() == 1, "one node");
  assert(sn_s.nodes[0].name == "cube1", "node name");
}

fn test_camera() {
  cam = scene::camera("main");
  assert(cam.fov == 60.0, "default fov");
  assert(cam.near == 0.1, "default near");
  cam.position = math::vec3(0.0, 5.0, 10.0);
  assert(cam.position.y == 5.0, "camera y");
}

tests/scene_glb.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for save_scene_glb — scene-level GLB 2.0 export.
// Run with: cargo run --bin loft -- --lib lib/graphics/src --tests lib/graphics/tests/scene_glb.loft
use mesh3d::*;
use glb::*;

// Verify save_scene_glb produces a non-empty file for a single-mesh scene.
fn test_scene_glb_creates_file() {
  sg_s = scene::scene("test");
  sg_mi = sg_s.add_mesh(mesh::cube());
  sg_mati = sg_s.add_material(scene::material("default"));
  sg_s.add_node(scene::node("cube", sg_mi, sg_mati));
  glb::save_scene_glb(sg_s, "test_sglb_a.glb");
  assert(file("test_sglb_a.glb").size > 0, "scene GLB created");
  delete("test_sglb_a.glb");
}

// Verify the GLB header for a scene file.
fn test_scene_glb_header() {
  sh_s = scene::scene("hdr");
  sh_s.add_mesh(mesh::cube());
  sh_mati = sh_s.add_material(scene::material("mat"));
  sh_s.add_node(scene::node("n", 0, sh_mati));
  glb::save_scene_glb(sh_s, "test_sglb_b.glb");
  sh_sz = file("test_sglb_b.glb").size;
  {sh_f = file("test_sglb_b.glb");
   sh_f#format = LittleEndian;
   assert(sh_f#read(4) as i32 == 0x46546C67, "magic 'glTF'");
   assert(sh_f#read(4) as i32 == 2, "version 2");
   sh_total = sh_f#read(4) as i32;
   assert(sh_total as integer == sh_sz, "total_len matches file size={sh_sz}");
  }
  delete("test_sglb_b.glb");
}

// Verify JSON chunk type and BIN chunk type for scene GLB.
fn test_scene_glb_chunk_types() {
  sc_s = scene::scene("chunks");
  sc_s.add_mesh(mesh::cube());
  sc_mati = sc_s.add_material(scene::material("m"));
  sc_s.add_node(scene::node("n", 0, sc_mati));
  glb::save_scene_glb(sc_s, "test_sglb_c.glb");
  {sc_f = file("test_sglb_c.glb");
   sc_f#format = LittleEndian;
   sc_f#read(4) as i32;  // magic
   sc_f#read(4) as i32;  // version
   sc_f#read(4) as i32;  // total_len
   sc_jlen = sc_f#read(4) as i32;
   assert(sc_f#read(4) as i32 == 0x4E4F534A, "JSON chunk type 'JSON'");
   sc_f#next = (20 + sc_jlen) as integer;
   sc_f#read(4) as i32;  // BIN data length
   assert(sc_f#read(4) as i32 == 0x004E4942, "BIN chunk type 'BIN\\0'");
  }
  delete("test_sglb_c.glb");
}

// Verify BIN chunk size for a single triangle-mesh scene.
// 3 verts: pos=36 + norm=36 + uv=24 + idx=12 = 108 bytes.
fn test_scene_glb_bin_size_single_mesh() {
  sb_s = scene::scene("binsize");
  sb_m = mesh::mesh("tri");
  sb_n = math::vec3(0.0, 0.0, 1.0);
  sb_uv = math::vec2(0.0, 0.0);
  sb_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), sb_n, sb_uv));
  sb_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), sb_n, sb_uv));
  sb_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), sb_n, sb_uv));
  sb_m.add_triangle(0, 1, 2);
  sb_mi = sb_s.add_mesh(sb_m);
  sb_mati = sb_s.add_material(scene::material("white"));
  sb_s.add_node(scene::node("tri", sb_mi, sb_mati));
  glb::save_scene_glb(sb_s, "test_sglb_d.glb");
  {sb_f = file("test_sglb_d.glb");
   sb_f#format = LittleEndian;
   sb_f#read(4) as i32;  // magic
   sb_f#read(4) as i32;  // version
   sb_f#read(4) as i32;  // total_len
   sb_jlen = sb_f#read(4) as i32;
   sb_f#read(4) as i32;  // JSON chunk type
   sb_f#next = (20 + sb_jlen) as integer;
   sb_blen = sb_f#read(4) as i32;
   assert(sb_blen == 108, "bin size for 3-vert 1-tri={sb_blen}");
  }
  delete("test_sglb_d.glb");
}

// Verify BIN chunk size for a two-mesh scene.
// Mesh A: 3 verts, 1 tri = 108 bytes; Mesh B: 3 verts, 1 tri = 108 bytes → total 216.
fn test_scene_glb_bin_size_two_meshes() {
  st_s = scene::scene("two");
  st_n = math::vec3(0.0, 0.0, 1.0);
  st_uv = math::vec2(0.0, 0.0);

  st_ma = mesh::mesh("a");
  st_ma.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), st_n, st_uv));
  st_ma.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), st_n, st_uv));
  st_ma.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), st_n, st_uv));
  st_ma.add_triangle(0, 1, 2);

  st_mb = mesh::mesh("b");
  st_mb.add_vertex(mesh::vertex(math::vec3(2.0, 0.0, 0.0), st_n, st_uv));
  st_mb.add_vertex(mesh::vertex(math::vec3(3.0, 0.0, 0.0), st_n, st_uv));
  st_mb.add_vertex(mesh::vertex(math::vec3(2.0, 1.0, 0.0), st_n, st_uv));
  st_mb.add_triangle(0, 1, 2);

  st_mia = st_s.add_mesh(st_ma);
  st_mib = st_s.add_mesh(st_mb);
  st_mati = st_s.add_material(scene::material("mat"));
  st_s.add_node(scene::node("a", st_mia, st_mati));
  st_s.add_node(scene::node("b", st_mib, st_mati));
  glb::save_scene_glb(st_s, "test_sglb_e.glb");
  {st_f = file("test_sglb_e.glb");
   st_f#format = LittleEndian;
   st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_jlen = st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_f#next = (20 + st_jlen) as integer;
   st_blen = st_f#read(4) as i32;
   assert(st_blen == 216, "two-mesh bin size={st_blen}");
  }
  delete("test_sglb_e.glb");
}

// Verify that the JSON chunk contains "material" on the mesh primitive,
// not on the node, per glTF 2.0 spec.
fn test_scene_glb_json_material_in_primitive() {
  sm_s = scene::scene("mattest");
  sm_s.add_mesh(mesh::cube());
  sm_mati = sm_s.add_material(scene::material("red"));
  sm_s.add_node(scene::node("cube", 0, sm_mati));
  glb::save_scene_glb(sm_s, "test_sglb_g.glb");
  {sm_f = file("test_sglb_g.glb");
   sm_f#format = LittleEndian;
   sm_f#read(4) as i32;  // magic
   sm_f#read(4) as i32;  // version
   sm_f#read(4) as i32;  // total_len
   sm_jlen = sm_f#read(4) as i32;
   sm_f#read(4) as i32;  // JSON chunk type
   // Read the JSON text directly (sm_jlen bytes after offset 20).
   sm_json = sm_f#read(sm_jlen) as text;
   // "material" must appear inside "primitives" (mesh section).
   assert(sm_json.contains("\"primitives\""), "has primitives");
   assert(sm_json.contains("\"material\":"), "material present");
   // nodes section must NOT reference material.
   // The node JSON should be {"name":"cube","mesh":0} — no "material" key.
   // We check by verifying node object ends with "mesh" field.
   assert(sm_json.contains("\"mesh\":0}}"), "node ends with mesh ref only");
  }
  delete("test_sglb_g.glb");
}

// Verify that a non-identity node transform produces a "matrix" field in JSON.
fn test_scene_glb_node_transform() {
  st_s = scene::scene("xform");
  st_s.add_mesh(mesh::cube());
  st_mati = st_s.add_material(scene::material("m"));
  // Create node with a non-identity transform (translate by 2 on X).
  // Use node_at() to pass the transform at construction — avoids transform field
  // reassignment which triggers a store bug with nested vector mutation (P109).
  st_n = scene::node_at("moved", 0, st_mati, math::mat4_translate(2.0, 0.0, 0.0));
  st_s.add_node(st_n);
  glb::save_scene_glb(st_s, "test_sglb_h.glb");
  {st_f = file("test_sglb_h.glb");
   st_f#format = LittleEndian;
   st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_jlen = st_f#read(4) as i32;
   st_f#read(4) as i32;
   st_json = st_f#read(st_jlen) as text;
   assert(st_json.contains("\"matrix\":"), "non-identity has matrix field");
  }
  delete("test_sglb_h.glb");
}

// Verify that an identity transform does NOT produce a "matrix" field.
fn test_scene_glb_identity_no_matrix() {
  si_s = scene::scene("ident");
  si_s.add_mesh(mesh::cube());
  si_mati = si_s.add_material(scene::material("m"));
  si_s.add_node(scene::node("cube", 0, si_mati));
  glb::save_scene_glb(si_s, "test_sglb_i.glb");
  {si_f = file("test_sglb_i.glb");
   si_f#format = LittleEndian;
   si_f#read(4) as i32;
   si_f#read(4) as i32;
   si_f#read(4) as i32;
   si_jlen = si_f#read(4) as i32;
   si_f#read(4) as i32;
   si_json = si_f#read(si_jlen) as text;
   assert(!si_json.contains("\"matrix\":"), "identity has no matrix field");
  }
  delete("test_sglb_i.glb");
}

// Verify JSON chunk is 4-byte aligned.
fn test_scene_glb_json_padding() {
  sp_s = scene::scene("pad");
  sp_s.add_mesh(mesh::cube());
  sp_mati = sp_s.add_material(scene::material("mat"));
  sp_s.add_node(scene::node("n", 0, sp_mati));
  glb::save_scene_glb(sp_s, "test_sglb_f.glb");
  {sp_f = file("test_sglb_f.glb");
   sp_f#format = LittleEndian;
   sp_f#read(4) as i32;
   sp_f#read(4) as i32;
   sp_f#read(4) as i32;
   sp_jlen = sp_f#read(4) as i32;
   assert(sp_jlen % 4 == 0, "JSON chunk 4-byte aligned={sp_jlen}");
  }
  delete("test_sglb_f.glb");
}

tests/text_height.loft

// @P340/#252 regression: gl_text_height must report the font's real line
// height (fontdue's new_line_size = ascent + descent + line gap), not the old
// `size * 1.2` approximation baked into loft_text_height.  Paired with
// gl_font_ascent — a font's line height is sensibly larger than its ascent and
// scales with the requested size.
use graphics::*;

fn test_text_height_uses_real_metrics() {
  f = graphics::gl_load_font("examples/DejaVuSans-Bold.ttf");
  // Skip gracefully if the bundled font isn't found in this run dir (keeps the
  // package suite green where the cwd differs) — mirrors font_ascent.loft.
  if f >= 0 {
    h_small = graphics::gl_text_height(f, 20.0);
    h_large = graphics::gl_text_height(f, 40.0);
    assert(h_small > 0, "line height at 20px should be positive ({h_small})");
    assert(h_large > h_small, "line height grows with size ({h_large} vs {h_small})");
    // Line height = ascent + descent + line gap, so it exceeds the ascent.
    a_large = graphics::gl_font_ascent(f, 40.0);
    assert((h_large as float) > a_large,
      "line height should exceed ascent ({h_large} vs {a_large})");
    // …and stay within a sane band of the em (not the loose size*1.2 guess).
    assert(h_large < 60, "line height at 40px should be well under 1.5em ({h_large})");
  }
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `graphics` library (@PLN141).  Each `test_*` below is a
// REAL, run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @GFX-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling (no test carries
// the tag).  These are teaching examples, distinct from the per-primitive
// assertion suite in `canvas.loft`.
//
// They cover the SOFTWARE canvas half of the library — the half CI can run.  A
// colour is a bare `integer` and a canvas is a flat `vector<integer>`, so the
// compiler sees nothing: which byte holds alpha, whether a call composites or
// overwrites, whether an answer of 0 means "transparent" or "not on the canvas",
// and where a span ends are all conventions a caller can only get from a call
// site.  The `gl_*` half needs a window and has no CI demonstrator; it is a
// recorded coverage gap rather than a tag against a test that cannot run.
use graphics::*;

// The PNG IHDR colour-type byte sits at offset 25: 8 signature bytes, a 4-byte
// chunk length, "IHDR", width, height, then one bit-depth byte.  2 = RGB,
// 6 = RGBA.  Lets the save example below read what was actually written.
fn png_colour_type(path: text) -> integer {
  // The scope block is what flushes and closes the handle, and its value is the byte it
  // read: a local bound inside a block ends with the block.
  pct = {
    f = file(path);
    f#format = BigEndian;
    f#next = 25;
    f#read as u8
  };
  pct
}

// @GFX-001 — a colour is one `integer` packed 0xAARRGGBB, and the alpha byte is
// the one a reader forgets.  `rgb(255, 0, 0)` is 0xFFFF0000, NOT 0xFF0000: the
// hex literal that names the same three channels leaves the top byte zero, which
// is a fully TRANSPARENT red.  Nothing catches that — both are `integer`, both
// report `color_r` 255, and `set_pixel` stores either one happily — but every
// compositing path reads the alpha, so a hand-written literal blends to nothing
// and (see @GFX-005) turns a saved file into RGBA.  `rgba` masks each argument to
// 8 bits rather than rejecting it, so an out-of-range channel wraps silently
// instead of saturating.
fn test_a_colour_carries_an_alpha_byte() {
  red = graphics::rgb(255, 0, 0);
  assert(red == 0xFFFF0000, "rgb() fills the alpha byte: the layout is 0xAARRGGBB");
  assert(graphics::color_a(red) == 255, "so a constructed colour is opaque");

  // The literal names the same three channels...
  assert(graphics::color_r(0xFF0000) == 255, "0xFF0000 really is full red");
  assert(graphics::color_g(0xFF0000) == 0 && graphics::color_b(0xFF0000) == 0, "and nothing else");
  // ...and no alpha at all.
  assert(graphics::color_a(0xFF0000) == 0, "but its alpha byte is 0 — fully transparent");
  assert(0xFF0000 != red, "so the literal is not the colour rgb(255, 0, 0) makes");

  // Which is what every compositing path acts on.
  black = graphics::rgb(0, 0, 0);
  assert(graphics::blend(black, 0xFF0000) == black, "a literal red composites to nothing");
  assert(graphics::blend(black, red) == red, "the constructed one replaces the destination");

  // Out-of-range channels are masked, not clamped and not refused.
  assert(graphics::rgba(256, 0, 0, 255) == graphics::rgba(0, 0, 0, 255), "256 & 255 is 0");
}

// @GFX-002 — the solid primitives STORE a colour; only `blend_pixel` composites
// one.  `set_pixel`, `fill_rect`, `hline`/`vline`, `draw_rect`, `draw_line`,
// the circle and triangle fills all write the argument into the buffer byte for
// byte, alpha included — so painting with a half-transparent colour does not
// show the destination through it, it REPLACES the destination with a colour
// that merely records "half transparent".  The pixel underneath is gone, and
// the alpha rides along until something else reads it (a later `blend`, or
// `save_png` — @GFX-005).  `blend_pixel` is the door that does what a caller
// reaching for alpha usually meant.
fn test_solid_primitives_store_alpha_rather_than_applying_it() {
  half_white = graphics::rgba(255, 255, 255, 128);
  black = graphics::rgb(0, 0, 0);
  cv = graphics::canvas(4, 2, black);

  cv.set_pixel(0, 0, half_white);
  assert(cv.get_pixel(0, 0) == half_white, "set_pixel wrote the argument verbatim");
  assert(graphics::color_a(cv.get_pixel(0, 0)) == 128, "alpha and all — the black underneath is gone");

  cv.blend_pixel(1, 0, half_white);
  assert(cv.get_pixel(1, 0) == graphics::rgba(128, 128, 128, 255),
         "blend_pixel composited: half of white over black is mid grey, and opaque");

  // Every span and shape primitive takes the set_pixel route.
  cv.fill_rect(2, 0, 2, 1, half_white);
  assert(cv.get_pixel(2, 0) == half_white, "fill_rect stores too — it is not a translucent overlay");
  assert(cv.get_pixel(3, 0) == half_white, "for the whole rectangle");
  assert(cv.get_pixel(0, 1) == black, "and leaves the row below alone");
}

// @GFX-003 — `get_pixel` answers 0 for a coordinate off the canvas, and 0 is a
// COLOUR the canvas can legitimately hold: `rgba(0, 0, 0, 0)` is the integer 0,
// and it is what `canvas(w, h, 0)` fills with.  So the answer cannot tell
// "outside" from "transparent black", in either direction, and a neighbourhood
// scan that uses the returned colour as its bounds test miscounts its own
// pixels.  The only honest bound is `width`/`height`.  `set_pixel` is the mirror
// image: it silently declines an out-of-range coordinate, so a wrong index is
// not a crash and not a message, it is simply a drawing that is missing something.
fn test_zero_from_get_pixel_is_a_colour_not_a_bound() {
  assert(graphics::rgba(0, 0, 0, 0) == 0, "transparent black IS the integer 0");

  cv = graphics::canvas(4, 4, graphics::rgb(0, 0, 0));   // OPAQUE black
  cv.set_pixel(0, 0, 0);                                  // one transparent-black pixel
  by_colour = 0;
  by_bounds = 0;
  for x in 0..3 {
    sx = x - 1;                                           // -1, 0, 1
    if cv.get_pixel(sx, 0) == 0 { by_colour += 1; }
    if sx < 0 or sx >= cv.width { by_bounds += 1; }
  }
  assert(by_bounds == 1, "exactly one of the three samples is off the canvas");
  assert(by_colour == 2, "but two answer 0 — the transparent pixel reads as 'outside'");

  // And a write off the canvas is declined in silence.
  cv.set_pixel(9, 9, graphics::rgb(255, 0, 0));
  assert(cv.data.len() == 16, "no growth, no error — the pixel simply never lands");
}

// @GFX-004 — the span primitives take a HALF-OPEN end and never normalise a
// reversed one.  `hline(x0, x1, y, c)` fills `[x0, x1)`, so `x1` itself stays
// clear and a caller thinking in inclusive coordinates loses the last pixel of
// every span; `vline(x, y0, y1, c)` is the same on the other axis.  Passing the
// ends the other way round — `hline(6, 1, …)` — is not corrected and not
// reported: the loop runs zero times and the call draws nothing at all, which is
// the shape that turns "my rectangle has a missing side" into a long afternoon.
// `draw_rect` is built from four of these spans, and its `(x, y, w, h)` covers
// exactly `x .. x + w - 1` by `y .. y + h - 1`.
fn test_spans_are_half_open_and_never_normalised() {
  cv = graphics::canvas(8, 4, 0);
  mark = graphics::rgb(0, 255, 0);
  cv.hline(1, 6, 0, mark);
  assert(cv.get_pixel(0, 0) == 0, "before the span");
  assert(cv.get_pixel(1, 0) == mark, "x0 is included");
  assert(cv.get_pixel(5, 0) == mark, "x1 - 1 is the last pixel drawn");
  assert(cv.get_pixel(6, 0) == 0, "x1 itself is NOT drawn — the end is exclusive");

  cv.vline(2, 1, 3, mark);
  assert(cv.get_pixel(2, 1) == mark && cv.get_pixel(2, 2) == mark, "y0 .. y1 - 1");
  assert(cv.get_pixel(2, 3) == 0, "exclusive on this axis too");

  // A reversed span is silence, not a correction.
  rev = graphics::canvas(8, 4, 0);
  rev.hline(6, 1, 0, mark);
  drawn = 0;
  for rx in 0..8 { if rev.get_pixel(rx, 0) != 0 { drawn += 1; } }
  assert(drawn == 0, "hline(6, 1, ...) draws nothing — no error, no pixels");

  // draw_rect composes four spans, and reads the way its name suggests.
  box = graphics::canvas(8, 8, 0);
  box.draw_rect(1, 1, 5, 4, mark);
  assert(box.get_pixel(1, 1) == mark && box.get_pixel(5, 1) == mark, "top edge spans 5 columns");
  assert(box.get_pixel(1, 4) == mark && box.get_pixel(5, 4) == mark, "bottom edge is 4 rows down");
  assert(box.get_pixel(6, 1) == 0, "and stops there");
  assert(box.get_pixel(3, 2) == 0, "the outline is hollow");
}

// @GFX-005 — `save_png` chooses the file's colour type from the PIXELS, not from
// an argument: every pixel opaque writes a three-channel RGB file, and a SINGLE
// pixel with alpha below 255 writes RGBA for the whole image.  That makes the
// alpha byte of @GFX-001 visible on disk.  One hand-written hex literal painted
// into an otherwise opaque picture adds an alpha channel to the file and punches
// a fully transparent hole where that pixel is — which looks like nothing at all
// in a viewer with a white background and like a hole in one with a dark one.
// The same rule is why `canvas(w, h, 0)` saves as a fully TRANSPARENT image
// rather than the black its zero suggests.
fn test_save_png_reads_its_colour_type_off_the_pixels() {
  opaque = graphics::canvas(2, 2, graphics::rgb(255, 0, 0));
  assert(opaque.save_png("_tmp_gfx_opaque.png"), "saved the opaque canvas");
  assert(png_colour_type("_tmp_gfx_opaque.png") == 2, "all opaque -> a 3-channel RGB file");

  tainted = graphics::canvas(2, 2, graphics::rgb(255, 0, 0));
  tainted.set_pixel(0, 0, 0xFF0000);
  assert(tainted.save_png("_tmp_gfx_tainted.png"), "saved the canvas with one literal-coloured pixel");
  assert(png_colour_type("_tmp_gfx_tainted.png") == 6,
         "ONE alpha-0 pixel turns the whole file RGBA, and that pixel invisible");

  zero = graphics::canvas(2, 2, 0);
  assert(zero.save_png("_tmp_gfx_zero.png"), "saved the zero-filled canvas");
  assert(png_colour_type("_tmp_gfx_zero.png") == 6, "canvas(w, h, 0) is transparent, not black");
}