mesh3d source

← mesh3d · API reference · version 0.1.5

8 file(s), 1157 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
Vec2 struct9—15 16 13 19
vec2 fn15—73 74 75 76 79 80 … (37 total)
Vec3 struct20—29 30 51 52 57 58 … (27 total)
vec3 fn29—73 74 75 76 79 80 … (69 total)
Vec4 struct34—43 44
vec4 fn43—none in this package
add3 fn51—16
sub3 fn57—186 180 261
scale3 fn63—none in this package
dot3 fn70—193 25 27
cross fn76—187 188 33 34 35 36 … (7 total)
length3 fn86—97 234 261
normalize3 fn96MSH-005186 187 149 41 233
Mat4 struct107—112 113 122 123 133 134 … (23 total)
mat4_identity fn112—71 48
mat4_translate fn122—57 75 192
mat4_scale fn133—66 76
mat4_mul fn144MSH-00477 198 204
mat4_transform fn163MSH-00459 68 79 198 204 214 … (8 total)
mat4_perspective fn173—none in this package
mat4_look_at fn185MSH-005242 249
mat4_rotate_y fn198—193
mat4_rotate_x fn210—none in this package
mat4_ortho fn223—none in this package
mat4_trs fn234MSH-004213
Vertex struct10—19 20 33 46
vertex fn19—73 74 75 76 79 80 … (44 total)
Triangle struct24—34 55
Mesh struct31—40 41 46 54 61 70 … (14 total)
mesh fn40—71 115 132 48 6 153 … (47 total)
add_vertex fn46MSH-00173 74 75 76 79 80 … (41 total)
add_triangle fn54MSH-00162 63 28 49 54
add_quad fn61MSH-00377 83 89 95 101 107 … (9 total)
cube fn70—71 36 37 38 39 25 … (7 total)
plane fn114MSH-00343 44 45 46 67 120 … (8 total)
sphere fn131—53 54 56 58
mesh_to_floats_uv fn173MSH-002100 121
mesh_to_floats fn190MSH-00250 55 99 105 116
Material struct11—24 25 33 34 41 42 … (9 total)
material fn24—33 36 44
material_color fn33—none in this package
material_metal fn41—none in this package
set_color fn50—34
Node struct59—70 71 75 76 155 181
node fn70—45 47 48
node_at fn75—none in this package
Camera struct83—100 101 156 186
camera fn100—11 12 52 56 255
LightType enum113—119
Light struct117—130 131 140 141 157 191
directional_light fn130—none in this package
point_light fn140—none in this package
Scene struct151—162 163 167 174 181 186 … (7 total)
scene fn162—49 5 10 16 17 24 … (13 total)
add_mesh fn167—26 43
add_material fn174—35 44
add_node fn181—46
add_camera fn186—11
add_light fn191—none in this package

src/math.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// 3D math types for the graphics package.
// Uses float (64-bit) for precision in transforms and projections.

// --- Vectors ---

// A 2D vector / point.
pub struct Vec2 {
  pub x: float,
  pub y: float
}

// Make one.  Texture coordinates are what this is usually carrying.
pub fn vec2(vx: float, vy: float) -> Vec2 {
  Vec2 { x: vx, y: vy }
}

// A 3D vector / point.
pub struct Vec3 {
  pub x: float,
  pub y: float,
  pub z: float
}

// Make one.  The workhorse of the package — positions, normals and directions
// are all this type, and which of the three a value is depends on what the
// caller does with it, not on the type.
pub fn vec3(vx: float, vy: float, vz: float) -> Vec3 {
  Vec3 { x: vx, y: vy, z: vz }
}

// A 4D vector (homogeneous coordinates).
pub struct Vec4 {
  x: float,
  y: float,
  z: float,
  w: float
}

// Make one.  The w is what separates a POINT (w = 1, moved by a translation)
// from a DIRECTION (w = 0, which a translation must leave alone).
pub fn vec4(vx: float, vy: float, vz: float, vw: float) -> Vec4 {
  Vec4 { x: vx, y: vy, z: vz, w: vw }
}

// --- Vec3 operations ---

// Component-wise sum.  Answers a FRESH vector and touches neither argument —
// which is what the `const` on both of them says.
pub fn add3(va: const Vec3, vb: const Vec3) -> Vec3 {
  Vec3 { x: va.x + vb.x, y: va.y + vb.y, z: va.z + vb.z }
}

// The way from `vb` to `va`.  `sub3(target, eye)` is where the camera is
// looking; length and direction both come out of it.
pub fn sub3(va: const Vec3, vb: const Vec3) -> Vec3 {
  Vec3 { x: va.x - vb.x, y: va.y - vb.y, z: va.z - vb.z }
}

// Stretch by a scalar.  After `normalize3` this reads as *that far along that
// direction*, which is how a step or an offset is built.
pub fn scale3(sv: const Vec3, ss: float) -> Vec3 {
  Vec3 { x: sv.x * ss, y: sv.y * ss, z: sv.z * ss }
}

// How much of `da` points along `db`.  Zero is perpendicular and negative is
// facing away — the one test a back-face cull and a diffuse light term are
// both written on.
pub fn dot3(da: const Vec3, db: const Vec3) -> float {
  da.x * db.x + da.y * db.y + da.z * db.z
}

// The vector perpendicular to both, right-handed.  A triangle's face normal is
// the cross of two of its edges, which is where most calls come from.
pub fn cross(ca: const Vec3, cb: const Vec3) -> Vec3 {
  Vec3 {
    x: ca.y * cb.z - ca.z * cb.y,
    y: ca.z * cb.x - ca.x * cb.z,
    z: ca.x * cb.y - ca.y * cb.x
  }
}

// How long it is.  Comparing two lengths does not need the square root —
// `dot3(v, v)` orders them the same way and is cheaper.
pub fn length3(lv: const Vec3) -> float {
  sqrt(lv.x * lv.x + lv.y * lv.y + lv.z * lv.z)
}

// The same direction at length 1 — what a normal, a light direction and an axis
// all have to be before the arithmetic below means anything.
//
// ⚠ A ZERO vector answers ZERO rather than a NaN, so a degenerate triangle
// poisons one normal instead of the whole mesh.
// Example: @MSH-005
pub fn normalize3(nv: const Vec3) -> Vec3 {
  nl = length3(nv);
  // `/ nl ?? 0.0` yields a zero vector for a zero-length input — same
  // result the explicit guard gave, but defended in the analyzer's eyes.
  Vec3 { x: nv.x / nl ?? 0.0, y: nv.y / nl ?? 0.0, z: nv.z / nl ?? 0.0 }
}

// --- 4x4 Matrix (column-major, matching OpenGL/GLB convention) ---

// A 4x4 transformation matrix stored as 16 floats in column-major order.
// m[0..3] = column 0, m[4..7] = column 1, m[8..11] = column 2, m[12..15] = column 3.
pub struct Mat4 {
  pub m: vector<float>
}

// Create an identity matrix.
pub fn mat4_identity() -> Mat4 {
  Mat4 { m: [
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    0.0, 0.0, 0.0, 1.0
  ] }
}

// Create a translation matrix.
pub fn mat4_translate(tx: float, ty: float, tz: float) -> Mat4 {
  Mat4 { m: [
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    tx,  ty,  tz,  1.0
  ] }
}

// Create a scale matrix: `sx`, `sy`, `sz` scale each axis on its own, so equal
// values scale uniformly.
pub fn mat4_scale(sx: float, sy: float, sz: float) -> Mat4 {
  Mat4 { m: [
    sx,  0.0, 0.0, 0.0,
    0.0, sy,  0.0, 0.0,
    0.0, 0.0, sz,  0.0,
    0.0, 0.0, 0.0, 1.0
  ] }
}

// Multiply two 4x4 matrices (A * B). Result = A applied after B.
// Example: @MSH-004
pub fn mat4_mul(ma: const Mat4, mb: const Mat4) -> Mat4 {
  mul_r = Mat4 { m: [
    0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
    0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0
  ] };
  for mul_col in 0..4 {
    for mul_row in 0..4 {
      mul_sum = 0.0;
      for mul_k in 0..4 {
        mul_sum += ma.m[mul_k * 4 + mul_row] * mb.m[mul_col * 4 + mul_k];
      }
      mul_r.m[mul_col * 4 + mul_row] = mul_sum;
    }
  }
  mul_r
}

// Transform a Vec3 by a Mat4 (as a point, w=1).
// Example: @MSH-004
pub fn mat4_transform(tm: const Mat4, tv: const Vec3) -> Vec3 {
  Vec3 {
    x: tm.m[0] * tv.x + tm.m[4] * tv.y + tm.m[8]  * tv.z + tm.m[12],
    y: tm.m[1] * tv.x + tm.m[5] * tv.y + tm.m[9]  * tv.z + tm.m[13],
    z: tm.m[2] * tv.x + tm.m[6] * tv.y + tm.m[10] * tv.z + tm.m[14]
  }
}

// Perspective projection matrix.
// fov_y = vertical field of view in radians, aspect = width/height.
pub fn mat4_perspective(fov_y: float, aspect: float, near: float, far: float) -> Mat4 {
  per_f = 1.0 / tan(fov_y / 2.0) ?? 0.0;
  Mat4 { m: [
    per_f / aspect ?? 0.0, 0.0, 0.0, 0.0,
    0.0, per_f, 0.0, 0.0,
    0.0, 0.0, (far + near) / (near - far) ?? 0.0, -1.0,
    0.0, 0.0, (2.0 * far * near) / (near - far) ?? 0.0, 0.0
  ] }
}

// View matrix looking from eye towards target with up direction.
// Example: @MSH-005
pub fn mat4_look_at(eye: const Vec3, target: const Vec3, up: const Vec3) -> Mat4 {
  la_f = normalize3(sub3(target, eye));
  la_s = normalize3(cross(la_f, up));
  la_u = cross(la_s, la_f);
  Mat4 { m: [
    la_s.x,  la_u.x,  -la_f.x, 0.0,
    la_s.y,  la_u.y,  -la_f.y, 0.0,
    la_s.z,  la_u.z,  -la_f.z, 0.0,
    -dot3(la_s, eye), -dot3(la_u, eye), dot3(la_f, eye), 1.0
  ] }
}

// Rotation around the Y axis by angle radians.
pub fn mat4_rotate_y(angle: float) -> Mat4 {
  ry_c = cos(angle);
  ry_s = sin(angle);
  Mat4 { m: [
    ry_c,  0.0, ry_s, 0.0,
    0.0,   1.0, 0.0,  0.0,
    -ry_s, 0.0, ry_c, 0.0,
    0.0,   0.0, 0.0,  1.0
  ] }
}

// Rotation around the X axis by angle radians.
pub fn mat4_rotate_x(angle: float) -> Mat4 {
  rx_c = cos(angle);
  rx_s = sin(angle);
  Mat4 { m: [
    1.0, 0.0,   0.0,  0.0,
    0.0, rx_c, -rx_s, 0.0,
    0.0, rx_s,  rx_c, 0.0,
    0.0, 0.0,   0.0,  1.0
  ] }
}

// Orthographic projection matrix.
// Maps the box [left,right] x [bottom,top] x [near,far] to the [-1,1] cube.
pub fn mat4_ortho(left: float, right: float, bottom: float, top: float, near: float, far: float) -> Mat4 {
  Mat4 { m: [
    2.0 / (right - left) ?? 0.0, 0.0, 0.0, 0.0,
    0.0, 2.0 / (top - bottom) ?? 0.0, 0.0, 0.0,
    0.0, 0.0, -2.0 / (far - near) ?? 0.0, 0.0,
    -(right + left) / (right - left) ?? 0.0, -(top + bottom) / (top - bottom) ?? 0.0, -(far + near) / (far - near) ?? 0.0, 1.0
  ] }
}

// Combined translate + rotate_y + scale in one call.
// Example: @MSH-004
pub fn mat4_trs(tx: float, ty: float, tz: float, ry: float, sx: float, sy: float, sz: float) -> Mat4 {
  trs_c = cos(ry);
  trs_s = sin(ry);
  Mat4 { m: [
    trs_c * sx, 0.0,     trs_s * sx, 0.0,
    0.0,        sy,      0.0,        0.0,
    -trs_s * sz, 0.0,    trs_c * sz, 0.0,
    tx,         ty,      tz,         1.0
  ] }
}

src/mesh.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Mesh types for the graphics package.
// Vertex and triangle data structures for 3D geometry.
use math::*;

// --- Vertex and mesh ---

// A single vertex with position, normal, and texture coordinates.
pub struct Vertex {
  pub pos: math::Vec3,
  pub normal: math::Vec3,
  pub uv: math::Vec2
}

// One vertex: where it is, which way it faces, and where it samples a texture.
// `mesh_to_floats_uv` writes those three in exactly this order, so a GL
// consumer's attribute layout is read off this line.
pub fn vertex(vp: math::Vec3, vn: math::Vec3, vuv: math::Vec2) -> Vertex {
  Vertex { pos: vp, normal: vn, uv: vuv }
}

// A triangle defined by three vertex indices.
pub struct Triangle {
  pub a: integer,
  pub b: integer,
  pub c: integer
}

// A mesh: a collection of vertices and triangles.
pub struct Mesh {
  pub name: text,
  pub vertices: vector<Vertex>,
  pub triangles: vector<Triangle>
}

// An empty mesh under a name.  Fill it with `add_vertex`, then `add_triangle`
// with the indices `add_vertex` handed back — the triangles index the vertex
// array, so a vertex shared by two faces is added once and named twice.
pub fn mesh(mn: text) -> Mesh {
  Mesh { name: mn }
}

// Add a vertex to the mesh and return its index.
// Example: @MSH-001
pub fn add_vertex(self: Mesh, av: Vertex) -> integer {
  idx = self.vertices.len();
  self.vertices += [av];
  idx
}

// Add a triangle by vertex indices.
// Example: @MSH-001
pub fn add_triangle(self: Mesh, ti0: integer, ti1: integer, ti2: integer) {
  self.triangles += [Triangle { a: ti0, b: ti1, c: ti2 }];
}

// Build a simple quad (two triangles) from 4 vertices.
// Vertices should be in counter-clockwise order.
// Example: @MSH-003
pub fn add_quad(self: Mesh, qi0: integer, qi1: integer, qi2: integer, qi3: integer) {
  self.add_triangle(qi0, qi1, qi2);
  self.add_triangle(qi0, qi2, qi3);
}

// --- Primitive builders ---

// Create a unit cube centered at origin (side length 1).
// 24 vertices (4 per face with correct per-face normals), 12 triangles.
pub fn cube() -> Mesh {
  cm = mesh("cube");
  // Front face (z = +0.5, normal = (0, 0, 1))
  cv0 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5,  0.5), math::vec3(0.0, 0.0, 1.0), math::vec2(0.0, 0.0)));
  cv1 = cm.add_vertex(vertex(math::vec3( 0.5, -0.5,  0.5), math::vec3(0.0, 0.0, 1.0), math::vec2(1.0, 0.0)));
  cv2 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5,  0.5), math::vec3(0.0, 0.0, 1.0), math::vec2(1.0, 1.0)));
  cv3 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5,  0.5), math::vec3(0.0, 0.0, 1.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv0, cv1, cv2, cv3);
  // Back face (z = -0.5, normal = (0, 0, -1))
  cv4 = cm.add_vertex(vertex(math::vec3( 0.5, -0.5, -0.5), math::vec3(0.0, 0.0, -1.0), math::vec2(0.0, 0.0)));
  cv5 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5, -0.5), math::vec3(0.0, 0.0, -1.0), math::vec2(1.0, 0.0)));
  cv6 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5, -0.5), math::vec3(0.0, 0.0, -1.0), math::vec2(1.0, 1.0)));
  cv7 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5, -0.5), math::vec3(0.0, 0.0, -1.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv4, cv5, cv6, cv7);
  // Right face (x = +0.5, normal = (1, 0, 0))
  cv8  = cm.add_vertex(vertex(math::vec3( 0.5, -0.5,  0.5), math::vec3(1.0, 0.0, 0.0), math::vec2(0.0, 0.0)));
  cv9  = cm.add_vertex(vertex(math::vec3( 0.5, -0.5, -0.5), math::vec3(1.0, 0.0, 0.0), math::vec2(1.0, 0.0)));
  cv10 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5, -0.5), math::vec3(1.0, 0.0, 0.0), math::vec2(1.0, 1.0)));
  cv11 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5,  0.5), math::vec3(1.0, 0.0, 0.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv8, cv9, cv10, cv11);
  // Left face (x = -0.5, normal = (-1, 0, 0))
  cv12 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5, -0.5), math::vec3(-1.0, 0.0, 0.0), math::vec2(0.0, 0.0)));
  cv13 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5,  0.5), math::vec3(-1.0, 0.0, 0.0), math::vec2(1.0, 0.0)));
  cv14 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5,  0.5), math::vec3(-1.0, 0.0, 0.0), math::vec2(1.0, 1.0)));
  cv15 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5, -0.5), math::vec3(-1.0, 0.0, 0.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv12, cv13, cv14, cv15);
  // Top face (y = +0.5, normal = (0, 1, 0))
  cv16 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5,  0.5), math::vec3(0.0, 1.0, 0.0), math::vec2(0.0, 0.0)));
  cv17 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5,  0.5), math::vec3(0.0, 1.0, 0.0), math::vec2(1.0, 0.0)));
  cv18 = cm.add_vertex(vertex(math::vec3( 0.5,  0.5, -0.5), math::vec3(0.0, 1.0, 0.0), math::vec2(1.0, 1.0)));
  cv19 = cm.add_vertex(vertex(math::vec3(-0.5,  0.5, -0.5), math::vec3(0.0, 1.0, 0.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv16, cv17, cv18, cv19);
  // Bottom face (y = -0.5, normal = (0, -1, 0))
  cv20 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5, -0.5), math::vec3(0.0, -1.0, 0.0), math::vec2(0.0, 0.0)));
  cv21 = cm.add_vertex(vertex(math::vec3( 0.5, -0.5, -0.5), math::vec3(0.0, -1.0, 0.0), math::vec2(1.0, 0.0)));
  cv22 = cm.add_vertex(vertex(math::vec3( 0.5, -0.5,  0.5), math::vec3(0.0, -1.0, 0.0), math::vec2(1.0, 1.0)));
  cv23 = cm.add_vertex(vertex(math::vec3(-0.5, -0.5,  0.5), math::vec3(0.0, -1.0, 0.0), math::vec2(0.0, 1.0)));
  cm.add_quad(cv20, cv21, cv22, cv23);
  cm
}

// Create a flat horizontal plane centered at origin.
// pw = width (x-axis), pd = depth (z-axis); normal = (0, 1, 0).
// Example: @MSH-003
pub fn plane(pn: text, pw: float, pd: float) -> Mesh {
  pm = mesh(pn);
  hw = pw / 2.0;
  hd = pd / 2.0;
  pn_v = math::vec3(0.0, 1.0, 0.0);
  pv0 = pm.add_vertex(vertex(math::vec3(-hw, 0.0, -hd), pn_v, math::vec2(0.0, 0.0)));
  pv1 = pm.add_vertex(vertex(math::vec3( hw, 0.0, -hd), pn_v, math::vec2(1.0, 0.0)));
  pv2 = pm.add_vertex(vertex(math::vec3( hw, 0.0,  hd), pn_v, math::vec2(1.0, 1.0)));
  pv3 = pm.add_vertex(vertex(math::vec3(-hw, 0.0,  hd), pn_v, math::vec2(0.0, 1.0)));
  pm.add_quad(pv0, pv3, pv2, pv1);
  pm
}

// Create a UV sphere centered at origin.
// sr = radius, ss = longitudinal slices (≥3), st = latitudinal stacks (≥2).
// Produces (ss+1)*(st+1) vertices and ss*st*2 triangles.  Fewer slices or stacks
// are not refused: they build that many, a flat or degenerate solid.
pub fn sphere(sn: text, sr: float, ss: integer, st: integer) -> Mesh {
  sm = mesh(sn);
  pi2 = 6.283185307179586;  // 2 * pi
  // Generate rings from top (+y) to bottom (-y).  `?? 0` after each
  // divisor is lint-suppression — `ss` and `st` are the segment counts
  // passed by the caller; with sane input they're > 0.  If a caller
  // passes 0 the loop generates a degenerate mesh, not a crash.
  for row in 0..st+1 {
    v_phi = 3.141592653589793 * (row as float) / (st as float) ?? 0.0;  // 0..pi
    sy = sr * cos(v_phi);
    ring_r = sr * sin(v_phi);
    for col in 0..ss+1 {
      v_theta = pi2 * (col as float) / (ss as float) ?? 0.0;  // 0..2pi
      sx = ring_r * cos(v_theta);
      sz = ring_r * sin(v_theta);
      su = (col as float) / (ss as float) ?? 0.0;
      sv = (row as float) / (st as float) ?? 0.0;
      spos = math::vec3(sx, sy, sz);
      snrm = math::normalize3(spos);
      sm.add_vertex(vertex(spos, snrm, math::vec2(su, sv)));
    }
  }
  // Wire triangles.
  for row in 0..st {
    for col in 0..ss {
      i00 = row * (ss + 1) + col;
      i01 = row * (ss + 1) + col + 1;
      i10 = (row + 1) * (ss + 1) + col;
      i11 = (row + 1) * (ss + 1) + col + 1;
      sm.add_quad(i00, i01, i11, i10);
    }
  }
  sm
}

// Flatten a mesh to a vector<single> for GL upload.
// Expands triangles: for each triangle, emits 3 vertices with pos+normal (stride=6).
// Returns the flat buffer suitable for gl_upload_vertices(data, 6).
// Flatten a mesh to a vector<single> with UV coords for GL upload.
// Expands triangles: for each triangle, emits 3 vertices with pos+normal+uv (stride=8).
// Returns the flat buffer suitable for gl_upload_vertices(data, 8).
// Example: @MSH-002
pub fn mesh_to_floats_uv(mu: const Mesh) -> vector<single> {
  mu_buf: vector<single> = [];
  for mu_tri in mu.triangles {
    for mu_idx in [mu_tri.a, mu_tri.b, mu_tri.c] {
      mu_v = mu.vertices[mu_idx];
      if mu_v != null {
        mu_buf += [mu_v.pos.x as single, mu_v.pos.y as single, mu_v.pos.z as single];
        mu_buf += [mu_v.normal.x as single, mu_v.normal.y as single, mu_v.normal.z as single];
        mu_buf += [mu_v.uv.x as single, mu_v.uv.y as single];
      }
    }
  }
  mu_buf
}

// Flatten a mesh to a vector<single> for GL upload.
// Example: @MSH-002
pub fn mesh_to_floats(mf: const Mesh) -> vector<single> {
  mf_buf: vector<single> = [];
  for mf_tri in mf.triangles {
    for mf_idx in [mf_tri.a, mf_tri.b, mf_tri.c] {
      mf_v = mf.vertices[mf_idx];
      if mf_v != null {
        mf_buf += [mf_v.pos.x as single, mf_v.pos.y as single, mf_v.pos.z as single];
        mf_buf += [mf_v.normal.x as single, mf_v.normal.y as single, mf_v.normal.z as single];
      }
    }
  }
  mf_buf
}

src/mesh3d.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// mesh3d — 3D geometry primitives for loft.
//
// Aggregates three sub-modules:
//
//   - math  — Vec2 / Vec3 / Vec4 + Mat4 + vector/matrix ops
//   - mesh  — Vertex / Triangle / Mesh + sphere generation +
//             flatten-to-floats helpers for GL upload
//   - scene — Scene / Node / Material / Camera / Light + scene
//             graph construction
//
// A standalone package, so non-rendering consumers (asset processors, glb
// writers, headless physics) use the types without `graphics`' OpenGL stack.
//
// The three are re-exported below: `use mesh3d;` reaches them qualified
// (`mesh3d::Vec3`, `mesh3d::cube()`, as the example below writes them), and
// `use mesh3d::*;` brings the names in bare.
//
// --- Using it ---
//
// A scene is three index spaces — meshes, materials, nodes — and a node is a
// pairing of one of each with a transform:
//
//     sc = mesh3d::scene("hall");
//     mi = sc.add_mesh(mesh3d::cube());
//     ma = sc.add_material(mesh3d::material_color("brass", 0.8, 0.6, 0.2));
//
//     xf = mesh3d::mat4_mul(mesh3d::mat4_translate(0.0, 1.0, 0.0),
//                           mesh3d::mat4_scale(2.0, 2.0, 2.0));
//     sc.add_node(mesh3d::node_at("crate", mi, ma, xf));
//     sc.add_light(mesh3d::directional_light("sun", 1.0, 1.0, 0.9, 3.0,
//                                            mesh3d::vec3(0.0, -1.0, 0.0)));
//
//     mesh3d::mat4_transform(xf, mesh3d::vec3(1.0, 0.0, 0.0));  // -> (2, 1, 0)
//     mesh3d::mesh_to_floats(mesh3d::cube());   // -> 216 floats, ready for GL
//
// Every `add_*` answers the INDEX the scene refers to that thing by, and those
// indices are what a node carries — so a mesh or a material is shared by naming
// it twice, never by copying it.
//
// Matrices are COLUMN-MAJOR, matching OpenGL and glTF, and `mat4_mul(a, b)`
// applies b first: the composition above scales, then translates.  Nothing here
// touches a GPU — `glb` writes a scene out, `graphics` draws one.

pub use math::*;
pub use mesh::*;
pub use scene::*;

src/scene.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Scene graph types for the graphics package.
// Represents a 3D scene with nodes, cameras, lights, and materials.
use math::*;
use mesh::*;

// --- Materials ---

// A simple PBR-like material.
pub struct Material {
  pub name: text,
  pub base_color_r: float,
  pub base_color_g: float,
  pub base_color_b: float,
  pub base_color_a: float,
  pub metallic: float,
  pub roughness: float
}

// A neutral material: light grey, half rough, not metal.  Everything below
// adjusts this rather than building one from nothing, so a new field gets a
// sane value in one place.
pub fn material(mname: text) -> Material {
  Material {
    name: mname,
    base_color_r: 0.8, base_color_g: 0.8, base_color_b: 0.8, base_color_a: 1.0,
    metallic: 0.0, roughness: 0.5
  }
}

// Shorthand: opaque dielectric material with the given color.
pub fn material_color(mname: text, mr: float, mg: float, mb: float) -> Material {
  Material {
    name: mname, base_color_r: mr, base_color_g: mg, base_color_b: mb,
    base_color_a: 1.0, metallic: 0.0, roughness: 0.5
  }
}

// Shorthand: metallic material with the given color and roughness.
pub fn material_metal(mname: text, mr: float, mg: float, mb: float, mrough: float) -> Material {
  Material {
    name: mname, base_color_r: mr, base_color_g: mg, base_color_b: mb,
    base_color_a: 1.0, metallic: 1.0, roughness: mrough
  }
}

// Repaint a material in place.  Alpha, metallic and roughness are left alone,
// which is what makes this safe to call on a material somebody else built.
pub fn set_color(self: Material, scr: float, scg: float, scb: float) {
  self.base_color_r = scr;
  self.base_color_g = scg;
  self.base_color_b = scb;
}

// --- Scene nodes ---

// A node in the scene graph with a mesh, material, and transform.
pub struct Node {
  pub name: text,
  pub mesh_idx: integer,
  pub material_idx: integer,
  pub transform: math::Mat4
}

// Put a mesh in the scene at the origin, naming the mesh and the material by
// the indices `add_mesh` and `add_material` answered.  `node_at` is this with a
// transform, and is the one to reach for — a node built here and then mutated
// is two steps where one would do.
pub fn node(nn: text, nm: integer, nmat: integer) -> Node {
  Node { name: nn, mesh_idx: nm, material_idx: nmat, transform: math::mat4_identity() }
}

// Create a node with an explicit transform (avoids mutation of transform field).
pub fn node_at(nn: text, nm: integer, nmat: integer, xf: math::Mat4) -> Node {
  Node { name: nn, mesh_idx: nm, material_idx: nmat, transform: xf }
}

// --- Camera ---

// A camera as data: where it stands, what it looks at, its vertical field in
// DEGREES and its clipping planes.  `camera` below builds one with defaults.
pub struct Camera {
  name: text,
  pub fov: float,
  pub near: float,
  pub far: float,
  pub position: math::Vec3,
  pub target: math::Vec3
}

// A camera with usable defaults: field 60, clipping at 0.1 and 100, standing at
// z = 5 and looking at the origin.
//
// ⚠ THE FIELD IS IN DEGREES AND NOTHING HERE CONVERTS IT.  `Camera` is carried
// as data — no routine in this package or in `glb` reads `fov` — so the caller
// that eventually builds a projection owns the conversion, and
// `mat4_perspective` wants RADIANS.  Handing it this number unchanged asks for a
// 60-radian field and gets a picture nobody can read.
pub fn camera(cname: text) -> Camera {
  Camera {
    name: cname,
    fov: 60.0, near: 0.1, far: 100.0,
    position: math::vec3(0.0, 0.0, 5.0),
    target: math::vec3(0.0, 0.0, 0.0)
  }
}

// --- Lights (KHR_lights_punctual) ---

// Which kind a `Light` is.  `directional_light` and `point_light` build the first
// two; a `Spot` is written by setting `light_type` on a `Light` directly.
pub enum LightType { Directional, Point, Spot }

// A punctual light (glTF's KHR_lights_punctual): its kind, colour, intensity, and
// the position or direction the kind uses.
pub struct Light {
  pub name: text,
  pub light_type: LightType,
  pub color_r: float,
  pub color_g: float,
  pub color_b: float,
  pub intensity: float,
  pub position: math::Vec3,
  pub direction: math::Vec3
}

// A light with a direction and no place — the sun.  Every surface in the scene
// receives it at the same angle, however far away it is.
pub fn directional_light(ln: text, lr: float, lg: float, lb: float, li: float, ldir: math::Vec3) -> Light {
  Light {
    name: ln, light_type: Directional,
    color_r: lr, color_g: lg, color_b: lb, intensity: li,
    position: math::vec3(0.0, 0.0, 0.0), direction: ldir
  }
}

// A light with a place and no direction — a lamp.  It carries a direction field
// because `Light` is one struct for all three kinds; nothing reads it here.
pub fn point_light(ln: text, lr: float, lg: float, lb: float, li: float, lpos: math::Vec3) -> Light {
  Light {
    name: ln, light_type: Point,
    color_r: lr, color_g: lg, color_b: lb, intensity: li,
    position: lpos, direction: math::vec3(0.0, -1.0, 0.0)
  }
}

// --- Scene ---

// A complete 3D scene.
pub struct Scene {
  pub name: text,
  pub meshes: vector<mesh::Mesh>,
  pub materials: vector<Material>,
  pub nodes: vector<Node>,
  pub cameras: vector<Camera>,
  pub lights: vector<Light>
}

// An empty scene.  The five `add_*` below fill it, and each answers the INDEX
// the rest of the scene refers to that thing by.
pub fn scene(sn: text) -> Scene {
  Scene { name: sn }
}

// Add a mesh to the scene and return its index.
pub fn add_mesh(self: Scene, sm: mesh::Mesh) -> integer {
  si = self.meshes.len();
  self.meshes += [sm];
  si
}

// Add a material to the scene and return its index.
pub fn add_material(self: Scene, smat: Material) -> integer {
  si = self.materials.len();
  self.materials += [smat];
  si
}

// Add a node to the scene.
pub fn add_node(self: Scene, sn: Node) {
  self.nodes += [sn];
}

// Add a camera to the scene.
pub fn add_camera(self: Scene, sc: Camera) {
  self.cameras += [sc];
}

// Add a light and answer its index.
pub fn add_light(self: Scene, sl: Light) -> integer {
  si = self.lights.len();
  self.lights += [sl];
  si
}

tests/math.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the math sub-module of mesh3d.
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 sub-module of mesh3d.
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/scene.loft

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

// A consumer reads back the cameras a scene holds (moros's map scene does): the field is
// readable outside this library, like the scene's other collections.
fn test_a_scene_lists_its_cameras() {
  sc = scene("view");
  sc.add_camera(camera("main"));
  assert(len(sc.cameras) == 1 && sc.cameras[0].position.z == 5.0, "one camera at z 5, got {len(sc.cameras)}");
}

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/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// One test per non-obvious contract, and each one is that contract's
// worked EXAMPLE.
//
// ⚠⚠ **The pair is the deliverable.**  A snippet in a doc comment rots
// silently — nothing compiles it, so it goes stale at the first
// signature change and the reader finds out.  A test nobody links to is
// correct and invisible.  So every function below carries an
// `// Example: @MSH-0NN` line pointing here, and every tag here is
// reachable from the functions it demonstrates.
//
// ⚠ These are examples FIRST and assertions second: each is written the
// way a caller building geometry would actually write it.  The other
// test files pin that each function computes its own arithmetic; what
// these pin is the shape of a CORRECT CALL.
//
// Why this library owes them: **the geometry is right and the BOOKKEEPING
// is what a caller gets wrong.**  Every handle is a bare `integer`, every
// buffer a bare `vector<single>`, every matrix a bare `vector<float>` —
// so an index into the wrong list, a stride the consumer does not share,
// a quad wound the other way and two matrices multiplied in the other
// order are all well-typed calls that produce a picture, just not the
// one that was asked for.
use mesh3d::*;
use scene::*;

// Building a mesh by hand.
//
// ⚠⚠ **`add_vertex` hands back an INDEX and `add_triangle` takes three
// of them.**  Both are bare `integer`s, so an index from another mesh, a
// stale one, or a vertex COUNT used where an index was meant all
// compile.  And the flatten does not fault on one — it SKIPS that
// vertex, which leaves the triangle two-thirds emitted and shears every
// float after it.
// @MSH-001 — a vertex index is a bare integer, and a bad one shears the buffer
fn test_building_a_triangle_by_index() {
  bi_m = mesh::mesh("wedge");
  bi_n = math::vec3(0.0, 0.0, 1.0);
  bi_uv = math::vec2(0.0, 0.0);

  // ⚠ Keep the returned index.  There is no `add_triangle(v0, v1, v2)`
  // taking vertices — the mesh is index-addressed all the way down.
  bi_a = bi_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), bi_n, bi_uv));
  bi_b = bi_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), bi_n, bi_uv));
  bi_c = bi_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), bi_n, bi_uv));
  assert(bi_a == 0 && bi_b == 1 && bi_c == 2, "indices count from zero");
  bi_m.add_triangle(bi_a, bi_b, bi_c);
  assert(len(mesh::mesh_to_floats(bi_m)) == 18, "one triangle is 3 verts x stride 6");

  // ⚠ Now the mistake, spelled the way it types: a fourth vertex was
  // planned and never added, so the index is one past the end.
  bi_m.add_triangle(bi_a, bi_b, 3);
  bi_buf = mesh::mesh_to_floats(bi_m);

  // Nothing raised.  The buffer is not short by one TRIANGLE (36 floats),
  // it is short by one VERTEX — so it no longer divides into triangles
  // and every vertex after the gap is read as part of the wrong one.
  assert(bi_m.triangles.len() == 2, "the mesh still holds two triangles");
  assert(len(bi_buf) == 30, "the buffer is {len(bi_buf)} floats, not 36");
  assert(len(bi_buf) % 18 != 0, "and no longer divides into whole triangles");

  // The check worth writing, because the library does not: an index is
  // valid exactly while it is less than the vertex count at flatten time.
  assert(!indices_in_range(bi_m), "the wedge has an out-of-range index");
  bi_ok = mesh::plane("ground", 2.0, 2.0);
  assert(indices_in_range(bi_ok), "a built-in primitive never has one");
}

// The guard the example above recommends, run before uploading a mesh a
// caller assembled itself.
fn indices_in_range(ir_m: const mesh::Mesh) -> boolean {
  ir_n = ir_m.vertices.len();
  for ir_t in ir_m.triangles {
    if ir_t.a < 0 || ir_t.a >= ir_n { return false; }
    if ir_t.b < 0 || ir_t.b >= ir_n { return false; }
    if ir_t.c < 0 || ir_t.c >= ir_n { return false; }
  }
  true
}

// Handing a mesh to the GPU.
//
// ⚠⚠ **Two flatten doors, one return type, and the STRIDE is the
// difference.**  Both answer `vector<single>`; `mesh_to_floats` packs
// position+normal at stride 6 and `mesh_to_floats_uv` packs
// position+normal+uv at stride 8.  The number has to travel to the
// consumer by hand, and a buffer read at the wrong stride is a mesh that
// draws, tumbling.
// @MSH-002 — the stride is the contract, and the buffer expands triangles
fn test_flattening_for_upload() {
  fu_cube = mesh::cube();
  assert(fu_cube.vertices.len() == 24 && fu_cube.triangles.len() == 12, "24 verts, 12 tris");

  // ⚠ The buffer is sized by TRIANGLES, not vertices: each triangle
  // emits its three vertices in full, so a shared vertex is written once
  // per triangle that uses it.  There are no indices on the far side.
  assert(len(mesh::mesh_to_floats(fu_cube)) == 12 * 3 * 6, "stride 6: {len(mesh::mesh_to_floats(fu_cube))}");
  assert(len(mesh::mesh_to_floats_uv(fu_cube)) == 12 * 3 * 8, "stride 8: {len(mesh::mesh_to_floats_uv(fu_cube))}");

  // ...so the buffer length says nothing about the vertex count — 24
  // vertices became 36 emitted ones — and the two doors' answers are the
  // same type and the same mesh at different widths.
  assert(len(mesh::mesh_to_floats(fu_cube)) / 6 == 36, "36 emitted vertices from 24 stored");

  // ⚠ And the degenerate case that reads as success: a mesh with
  // vertices but no triangles flattens to an EMPTY buffer.  Uploading it
  // is a legal call that draws nothing, and nothing says why.
  fu_loose = mesh::mesh("no-faces");
  fu_up = math::vec3(0.0, 1.0, 0.0);
  for _ in 0..3 {
    fu_loose.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), fu_up, math::vec2(0.0, 0.0)));
  }
  assert(fu_loose.vertices.len() == 3, "three vertices are stored");
  assert(len(mesh::mesh_to_floats(fu_loose)) == 0, "and none of them reach the buffer");

  // The first vertex of the first triangle, read at its own stride — the
  // check that a consumer's stride and this one agree.
  fu_plane = mesh::plane("ground", 2.0, 2.0);
  fu_buf = mesh::mesh_to_floats_uv(fu_plane);
  assert(len(fu_buf) == 2 * 3 * 8, "plane at stride 8");
  assert(f_at(fu_buf, 3) == 0.0 && f_at(fu_buf, 4) == 1.0 && f_at(fu_buf, 5) == 0.0,
         "floats 3..5 are the normal, and it points up");
}

// Read one float out of a flat buffer.  The buffer is `vector<single>`,
// so a `?? ` default has to be a `single` too.
fn f_at(fa_buf: const vector<single>, fa_i: integer) -> float {
  (fa_buf[fa_i] ?? (0.0 as single)) as float
}

// Which way does a face point?
//
// ⚠⚠ **A mesh makes that claim TWICE — once as the stored vertex normal
// and once as the winding order — and nothing checks that the two
// agree.**  `add_quad(i0, i1, i2, i3)` emits (i0,i1,i2) and (i0,i2,i3),
// so the corner order decides the winding; the normal is whatever the
// caller put in the vertex.  Get them opposite and the lighting is right
// while the face is invisible under back-face culling.
// @MSH-003 — winding and the stored normal are two independent claims
fn test_which_way_a_face_points() {
  // `plane` passes its corners in reverse (v0, v3, v2, v1) precisely so
  // the winding agrees with the +y normal it stores.
  wf_p = mesh::plane("ground", 2.0, 3.0);
  wf_t = wf_p.triangles[0];
  assert(wf_t.a == 0 && wf_t.b == 3 && wf_t.c == 2, "the quad is wound backwards on purpose");
  assert(geometric_normal_y(wf_p, 0) > 0.0, "the winding says up");
  assert((wf_p.vertices[0].normal.y ?? 0.0) == 1.0, "and so does the stored normal");

  // ⚠ The same four corners in the order they read naturally:
  wf_r = mesh::mesh("flipped");
  wf_up = math::vec3(0.0, 1.0, 0.0);
  wf_0 = wf_r.add_vertex(mesh::vertex(math::vec3(-1.0, 0.0, -1.5), wf_up, math::vec2(0.0, 0.0)));
  wf_1 = wf_r.add_vertex(mesh::vertex(math::vec3( 1.0, 0.0, -1.5), wf_up, math::vec2(1.0, 0.0)));
  wf_2 = wf_r.add_vertex(mesh::vertex(math::vec3( 1.0, 0.0,  1.5), wf_up, math::vec2(1.0, 1.0)));
  wf_3 = wf_r.add_vertex(mesh::vertex(math::vec3(-1.0, 0.0,  1.5), wf_up, math::vec2(0.0, 1.0)));
  wf_r.add_quad(wf_0, wf_1, wf_2, wf_3);

  // Same corners, same stored normal — and the face now points DOWN.
  assert(geometric_normal_y(wf_r, 0) < 0.0, "the winding says down");
  assert((wf_r.vertices[0].normal.y ?? 0.0) == 1.0, "while the stored normal still says up");

  // ...and the two meshes are indistinguishable by everything the API
  // reports: same counts, same positions, same normals.
  assert(wf_r.vertices.len() == wf_p.vertices.len(), "same vertex count");
  assert(wf_r.triangles.len() == wf_p.triangles.len(), "same triangle count");
}

// The winding of triangle `gn_i`, as its geometric normal's y component.
// Positive means counter-clockwise seen from +y.
fn geometric_normal_y(gn_m: const mesh::Mesh, gn_i: integer) -> float {
  // `?` on each element read: an index the mesh does not hold reads as the zero
  // vertex, whose cross product is the zero vector — so a bad index answers 0.0 and
  // fails the caller's `< 0.0` / `> 0.0` assertion instead of passing quietly.
  gn_t = gn_m.triangles[gn_i]?;
  gn_a = gn_m.vertices[gn_t.a]?.pos;
  gn_b = gn_m.vertices[gn_t.b]?.pos;
  gn_c = gn_m.vertices[gn_t.c]?.pos;
  math::cross(math::sub3(gn_b, gn_a), math::sub3(gn_c, gn_a)).y
}

// Composing transforms.
//
// ⚠⚠ **`mat4_mul(A, B)` applies B FIRST**, and the two orders are both
// well-typed calls that put the object in different places.  The storage
// is COLUMN-major too, so a translation lives at `m[12..14]`, not at
// `m[3]`, `m[7]`, `m[11]` — a matrix copied in from a row-major source
// transposes silently.
// @MSH-004 — the right-hand matrix is applied first, and the layout is column-major
fn test_composing_a_transform() {
  ct_move = math::mat4_translate(10.0, 0.0, 0.0);
  ct_spin = math::mat4_rotate_y(1.5707963267948966);        // a quarter turn
  ct_p = math::vec3(1.0, 0.0, 0.0);

  // "spin AFTER move" — the point is carried out to x=11 and swung round
  // to the +z axis.
  ct_spin_last = math::mat4_transform(math::mat4_mul(ct_spin, ct_move), ct_p);
  assert(about(ct_spin_last.x, 0.0) && about(ct_spin_last.z, 11.0),
         "spin-after-move lands at ({ct_spin_last.x}, {ct_spin_last.y}, {ct_spin_last.z})");

  // ⚠ Swap the arguments and the object is somewhere else entirely: it
  // is swung first, to z=1, and only then carried out along x.
  ct_move_last = math::mat4_transform(math::mat4_mul(ct_move, ct_spin), ct_p);
  assert(about(ct_move_last.x, 10.0) && about(ct_move_last.z, 1.0),
         "move-after-spin lands at ({ct_move_last.x}, {ct_move_last.y}, {ct_move_last.z})");

  // ⚠ Column-major: the translation column is the LAST four floats.
  assert((ct_move.m[12] ?? 0.0) == 10.0, "tx is m[12]");
  assert((ct_move.m[3] ?? 0.0) == 0.0, "not m[3] — that slot is a column-0 element");

  // `mat4_trs` is the same composition in one call, for the common case.
  ct_trs = math::mat4_trs(10.0, 0.0, 0.0, 1.5707963267948966, 1.0, 1.0, 1.0);
  ct_via_trs = math::mat4_transform(ct_trs, ct_p);
  assert(about(ct_via_trs.x, 10.0) && about(ct_via_trs.z, 1.0),
         "trs scales, spins, then moves — the move-after-spin order");
}

fn about(ab_a: float, ab_b: float) -> boolean {
  ab_d = ab_a - ab_b;
  (if ab_d < 0.0 { 0.0 - ab_d } else { ab_d }) < 0.000001
}

// Degenerate input.
//
// ⚠⚠ **Nothing here reports a degenerate case; it answers a zero.**
// `normalize3` of a zero-length vector is the zero vector (not null, not
// a fault), and every matrix builder that divides guards with `?? 0.0`.
// So a camera whose eye equals its target builds a matrix that collapses
// the whole world onto the origin, and the first sign is a black frame.
// @MSH-005 — a degenerate input answers zero, never an error
fn test_a_degenerate_camera() {
  di_zero = math::normalize3(math::vec3(0.0, 0.0, 0.0));
  assert(math::length3(di_zero) == 0.0, "normalizing nothing yields nothing");
  assert(di_zero.x == 0.0 && di_zero.y == 0.0 && di_zero.z == 0.0, "a zero vector, not a unit one");

  // A healthy camera keeps distances: something 5 in front of the eye
  // sits 5 down the view's -z.
  di_eye = math::vec3(0.0, 0.0, 5.0);
  di_at = math::vec3(0.0, 0.0, 0.0);
  di_up = math::vec3(0.0, 1.0, 0.0);
  di_good = math::mat4_look_at(di_eye, di_at, di_up);
  di_seen = math::mat4_transform(di_good, di_at);
  assert(about(di_seen.z, -5.0), "the target sits 5 down -z, at {di_seen.z}");

  // ⚠ Eye and target the same — one line in a controller that forgot to
  // offset the eye.  `look_at` normalizes a zero direction, and every
  // basis vector comes out zero.
  di_bad = math::mat4_look_at(di_eye, di_eye, di_up);
  di_lost = math::mat4_transform(di_bad, math::vec3(7.0, 8.0, 9.0));
  assert(di_lost.x == 0.0 && di_lost.y == 0.0 && di_lost.z == 0.0,
         "every point maps to the origin: ({di_lost.x}, {di_lost.y}, {di_lost.z})");

  // The guard worth writing, since the library will not:
  assert(!can_look(di_eye, di_eye), "eye == target is not a camera");
  assert(can_look(di_eye, di_at), "a real one is");
}

// The check the example above recommends, before building a view matrix.
fn can_look(cl_eye: const math::Vec3, cl_at: const math::Vec3) -> boolean {
  math::length3(math::sub3(cl_at, cl_eye)) > 0.0
}

fn main() {
  test_building_a_triangle_by_index();
  test_flattening_for_upload();
  test_which_way_a_face_points();
  test_composing_a_transform();
  test_a_degenerate_camera();
  println("mesh3d worked examples passed");
}