← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
rgba fn | 49 | GFX-001 | 58 173 510 511 1458 236 … (14 total) |
rgb fn | 57 | GFX-001 | 15 16 17 18 19 23 … (52 total) |
color_r fn | 63 | — | 169 501 1446 206 225 8 … (10 total) |
color_g fn | 65 | — | 170 502 1447 207 226 9 … (9 total) |
color_b fn | 67 | — | 171 503 1448 208 227 10 … (8 total) |
color_a fn | 70 | — | 165 172 11 16 259 47 … (8 total) |
Canvas struct | 77 | — | 117 120 128 144 155 179 … (31 total) |
width const | 78 | — | 120 129 132 145 146 180 … (50 total) |
height const | 79 | — | 120 129 145 180 197 215 … (46 total) |
data const | 80 | — | 120 133 147 156 158 182 … (78 total) |
canvas fn | 117 | — | 809 844 1745 22 23 32 … (60 total) |
get_pixel fn | 128 | GFX-003 | 35 36 37 38 77 78 … (80 total) |
set_pixel fn | 144 | GFX-003 | 268 293 294 295 296 297 … (24 total) |
clear fn | 155 | — | 44 |
blend fn | 164 | — | 184 52 59 66 68 69 … (10 total) |
blend_pixel fn | 179 | GFX-002 | 513 514 516 517 1458 83 … (7 total) |
fill_rect fn | 193 | GFX-002 | 76 118 88 89 |
hline fn | 214 | GFX-004 | 249 250 318 319 320 321 … (21 total) |
vline fn | 231 | GFX-004 | 251 252 96 97 98 139 … (8 total) |
draw_rect fn | 248 | GFX-004 | 104 153 |
draw_line fn | 259 | — | 442 129 138 147 156 |
draw_circle fn | 286 | — | 166 |
fill_circle fn | 312 | — | 182 |
fill_ellipse fn | 333 | — | 206 |
draw_ellipse fn | 367 | — | 194 |
draw_bezier fn | 413 | — | 221 231 248 |
draw_aa_line fn | 472 | — | 257 266 277 |
fill_triangle fn | 527 | — | 287 302 320 342 354 93 … (7 total) |
Coord struct | 580 | — | 600 639 22 23 24 37 … (23 total) |
fill_polygon fn | 600 | GFX-006 | 25 42 56 71 97 102 … (8 total) |
resize_lanczos fn | 782 | GFX-007 | 36 45 54 63 72 81 … (10 total) |
resize_bicubic fn | 797 | — | 40 50 60 70 80 90 … (9 total) |
save_png fn | 886 | GFX-001 | 171 176 181 |
gl_screenshot fn | 893 | — | none in this package |
gl_create_window fn | 901 | — | 133 |
gl_create_fullscreen_window fn | 908 | — | none in this package |
gl_set_fullscreen fn | 916 | — | none in this package |
gl_window_width fn | 922 | — | 149 |
gl_window_height fn | 925 | — | 149 |
gl_poll_events fn | 929 | — | 185 |
gl_swap_buffers fn | 933 | — | 295 |
gl_clear fn | 937 | — | 236 252 |
gl_destroy_window fn | 941 | — | 315 |
gl_create_shader fn | 945 | — | 1546 1653 1654 136 137 |
gl_use_shader fn | 949 | — | 1572 1706 1728 237 253 |
gl_draw fn | 953 | — | 1580 1710 1732 245 282 |
gl_upload_vertices fn | 959 | — | 1042 1660 167 |
gl_upload_instance_buffer fn | 972 | — | none in this package |
gl_instance_attrib fn | 979 | — | none in this package |
gl_draw_instanced fn | 983 | — | none in this package |
gl_update_buffer fn | 991 | — | none in this package |
gl_upload_indices fn | 996 | — | none in this package |
GroupVboSet struct | 1023 | — | 1030 1031 1038 1057 1064 |
group_vbos_new fn | 1030 | — | 9 |
group_vbos_upsert fn | 1038 | — | none in this package |
group_vbos_draw_all fn | 1057 | — | none in this package |
group_vbos_destroy fn | 1064 | — | none in this package |
gl_set_uniform_mat4 fn | 1071 | — | 1579 1707 1731 238 242 254 … (9 total) |
gl_set_uniform_float fn | 1077 | — | 1575 1576 1577 1578 1709 280 … (7 total) |
gl_set_uniform_int fn | 1081 | — | 1574 1730 264 265 268 |
gl_set_uniform_vec2 fn | 1085 | — | none in this package |
gl_set_uniform_vec3 fn | 1089 | — | 1708 257 258 259 260 262 … (7 total) |
gl_set_uniform_vec4 fn | 1093 | — | none in this package |
GL_DEPTH_TEST const | 1099 | — | 12 |
GL_BLEND const | 1100 | — | 12 |
GL_CULL_FACE const | 1101 | — | none in this package |
GL_SCISSOR_TEST const | 1102 | — | none in this package |
gl_enable fn | 1105 | — | none in this package |
gl_disable fn | 1109 | — | none in this package |
BLEND_ZERO const | 1113 | — | 12 |
BLEND_ONE const | 1114 | — | 12 |
BLEND_SRC_ALPHA const | 1115 | — | none in this package |
BLEND_ONE_MINUS_SRC_ALPHA const | 1116 | — | none in this package |
BLEND_DST_ALPHA const | 1117 | — | none in this package |
BLEND_ONE_MINUS_DST_ALPHA const | 1118 | — | none in this package |
gl_blend_func fn | 1121 | — | none in this package |
gl_cull_face fn | 1125 | — | none in this package |
gl_depth_mask fn | 1129 | — | none in this package |
gl_finish fn | 1138 | — | none in this package |
gl_viewport fn | 1142 | — | 234 249 |
gl_scissor fn | 1148 | — | none in this package |
gl_create_framebuffer fn | 1154 | — | 140 |
gl_bind_framebuffer fn | 1158 | — | 235 248 |
gl_framebuffer_texture fn | 1162 | — | 141 |
gl_create_depth_texture fn | 1166 | — | 139 |
gl_create_color_texture fn | 1170 | — | none in this package |
gl_texture_filter fn | 1174 | — | none in this package |
gl_generate_mipmap fn | 1179 | — | none in this package |
gl_texture_subimage fn | 1184 | — | none in this package |
gl_draw_fullscreen_quad fn | 1189 | — | none in this package |
DRAW_TRIANGLES const | 1195 | — | 1026 9 13 |
DRAW_LINES const | 1196 | — | 1026 13 |
DRAW_POINTS const | 1197 | — | none in this package |
gl_draw_mode fn | 1200 | — | 1059 |
gl_draw_elements fn | 1204 | — | none in this package |
gl_line_width fn | 1208 | — | none in this package |
gl_point_size fn | 1212 | — | none in this package |
KEY_W const | 1218 | — | none in this package |
KEY_A const | 1219 | — | 11 |
KEY_S const | 1220 | — | none in this package |
KEY_D const | 1221 | — | none in this package |
KEY_Q const | 1222 | — | none in this package |
KEY_E const | 1223 | — | none in this package |
KEY_SPACE const | 1224 | — | 11 |
KEY_ESCAPE const | 1225 | — | none in this package |
KEY_UP const | 1226 | — | none in this package |
KEY_DOWN const | 1227 | — | none in this package |
KEY_LEFT const | 1228 | — | 11 |
KEY_RIGHT const | 1229 | — | none in this package |
KEY_SHIFT const | 1230 | — | none in this package |
KEY_CTRL const | 1231 | — | none in this package |
KEY_0 const | 1235 | — | none in this package |
KEY_1 const | 1236 | — | none in this package |
KEY_2 const | 1237 | — | none in this package |
KEY_3 const | 1238 | — | none in this package |
KEY_4 const | 1239 | — | none in this package |
KEY_5 const | 1240 | — | none in this package |
KEY_6 const | 1241 | — | none in this package |
KEY_7 const | 1242 | — | none in this package |
KEY_8 const | 1243 | — | none in this package |
KEY_9 const | 1244 | — | none in this package |
KEY_O const | 1248 | — | none in this package |
KEY_R const | 1249 | — | none in this package |
KEY_F const | 1250 | — | none in this package |
KEY_Z const | 1251 | — | none in this package |
KEY_Y const | 1252 | — | none in this package |
KEY_LBRACKET const | 1255 | — | none in this package |
KEY_RBRACKET const | 1256 | — | none in this package |
KEY_TAB const | 1259 | — | none in this package |
KEY_F1 const | 1264 | — | 11 |
KEY_F2 const | 1265 | — | none in this package |
KEY_F3 const | 1266 | — | none in this package |
KEY_F4 const | 1267 | — | none in this package |
KEY_F5 const | 1268 | — | none in this package |
KEY_F6 const | 1269 | — | none in this package |
KEY_F7 const | 1270 | — | none in this package |
KEY_F8 const | 1271 | — | none in this package |
KEY_F9 const | 1272 | — | none in this package |
KEY_F10 const | 1273 | — | none in this package |
KEY_F11 const | 1274 | — | none in this package |
KEY_F12 const | 1275 | — | 11 |
gl_key_pressed fn | 1278 | — | none in this package |
gl_mouse_x fn | 1282 | — | none in this package |
gl_mouse_y fn | 1286 | — | none in this package |
gl_mouse_button fn | 1290 | — | none in this package |
gl_mouse_wheel fn | 1297 | — | none in this package |
EV_NONE const | 1319 | — | 13 14 |
EV_KEY_DOWN const | 1320 | — | 14 |
EV_KEY_UP const | 1321 | — | none in this package |
EV_TEXT const | 1322 | — | 21 |
EV_MOUSE_DOWN const | 1323 | — | none in this package |
EV_MOUSE_UP const | 1324 | — | none in this package |
EV_MOUSE_MOVE const | 1325 | — | none in this package |
EV_WHEEL const | 1326 | — | none in this package |
EV_TOUCH_BEGIN const | 1327 | — | none in this package |
EV_TOUCH_MOVE const | 1328 | — | none in this package |
EV_TOUCH_END const | 1329 | — | none in this package |
EV_RESIZE const | 1330 | — | 22 |
MOD_SHIFT const | 1333 | — | 14 |
MOD_CTRL const | 1334 | — | 23 14 |
MOD_ALT const | 1335 | — | none in this package |
MOD_SUPER const | 1336 | — | none in this package |
KEY_ENTER const | 1339 | — | 25 |
KEY_BACKSPACE const | 1340 | — | none in this package |
KEY_DELETE const | 1341 | — | none in this package |
KEY_INSERT const | 1342 | — | none in this package |
KEY_HOME const | 1343 | — | 24 |
KEY_END const | 1344 | — | none in this package |
KEY_PAGEUP const | 1345 | — | none in this package |
KEY_PAGEDOWN const | 1346 | — | none in this package |
gl_next_event fn | 1350 | — | 13 |
gl_event_key fn | 1354 | — | 14 |
gl_event_mods fn | 1358 | — | 15 |
gl_event_repeat fn | 1362 | — | none in this package |
gl_event_text fn | 1366 | — | 16 |
gl_event_x fn | 1370 | — | 17 |
gl_event_y fn | 1374 | — | none in this package |
gl_event_button fn | 1378 | — | none in this package |
gl_event_wheel fn | 1382 | — | 18 |
gl_event_touch_id fn | 1386 | — | none in this package |
gl_load_font fn | 1406 | — | 12 10 9 |
gl_measure_text fn | 1414 | — | 1440 1742 14 15 16 |
gl_text_height fn | 1418 | — | 1441 1743 13 14 |
gl_font_ascent fn | 1425 | — | 17 18 18 |
draw_text fn | 1438 | — | 1746 |
gl_load_texture fn | 1468 | — | none in this package |
gl_upload_canvas fn | 1473 | — | 1547 1747 |
gl_bind_texture fn | 1477 | — | 1573 1729 267 |
gl_delete_texture fn | 1481 | — | 313 |
gl_delete_shader fn | 1487 | — | 311 312 |
gl_delete_vao fn | 1491 | — | 1041 1065 309 |
gl_delete_framebuffer fn | 1495 | — | 314 |
SpriteSheet struct | 1532 | — | 1545 1548 1562 1716 12 |
ss_tex const | 1533 | — | 1547 1549 1573 12 |
create_sprite_sheet fn | 1545 | — | none in this package |
draw_sprite fn | 1562 | — | 1719 |
Painter2D struct | 1641 | — | 1652 1669 1679 1703 1716 1725 … (8 total) |
create_painter_2d fn | 1652 | — | none in this package |
painter_vao fn | 1679 | — | none in this package |
draw_rect_at fn | 1703 | — | none in this package |
draw_sprite_at fn | 1716 | — | none in this package |
draw_texture_at fn | 1725 | — | none in this package |
create_text_texture fn | 1740 | — | none in this package |
audio_load fn | 1752 | — | none in this package |
audio_play fn | 1767 | — | none in this package |
audio_stop fn | 1771 | — | none in this package |
audio_stop_all fn | 1774 | — | none in this package |
audio_set_volume fn | 1777 | — | none in this package |
audio_set_pan fn | 1784 | — | none in this package |
audio_seek fn | 1791 | — | none in this package |
audio_play_raw fn | 1795 | — | 1839 1852 1865 1878 |
sfx_beep fn | 1832 | — | 1883 |
sfx_descend fn | 1843 | — | none in this package |
sfx_chirp fn | 1856 | — | none in this package |
sfx_noise fn | 1869 | — | none in this package |
sfx_bounce fn | 1882 | — | none in this package |
Renderer struct | 116 | — | 132 134 142 158 163 183 … (9 total) |
create_renderer fn | 132 | — | none in this package |
elapsed fn | 158 | — | none in this package |
upload_scene fn | 163 | — | 125 126 186 |
render_frame_no_swap fn | 183 | — | 294 |
render_frame fn | 292 | — | 302 |
render_loop fn | 300 | — | none in this package |
destroy fn | 307 | — | 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");
}