← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Vec2 struct | 9 | — | 15 16 13 19 |
vec2 fn | 15 | — | 73 74 75 76 79 80 … (37 total) |
Vec3 struct | 20 | — | 29 30 51 52 57 58 … (27 total) |
vec3 fn | 29 | — | 73 74 75 76 79 80 … (69 total) |
Vec4 struct | 34 | — | 43 44 |
vec4 fn | 43 | — | none in this package |
add3 fn | 51 | — | 16 |
sub3 fn | 57 | — | 186 180 261 |
scale3 fn | 63 | — | none in this package |
dot3 fn | 70 | — | 193 25 27 |
cross fn | 76 | — | 187 188 33 34 35 36 … (7 total) |
length3 fn | 86 | — | 97 234 261 |
normalize3 fn | 96 | MSH-005 | 186 187 149 41 233 |
Mat4 struct | 107 | — | 112 113 122 123 133 134 … (23 total) |
mat4_identity fn | 112 | — | 71 48 |
mat4_translate fn | 122 | — | 57 75 192 |
mat4_scale fn | 133 | — | 66 76 |
mat4_mul fn | 144 | MSH-004 | 77 198 204 |
mat4_transform fn | 163 | MSH-004 | 59 68 79 198 204 214 … (8 total) |
mat4_perspective fn | 173 | — | none in this package |
mat4_look_at fn | 185 | MSH-005 | 242 249 |
mat4_rotate_y fn | 198 | — | 193 |
mat4_rotate_x fn | 210 | — | none in this package |
mat4_ortho fn | 223 | — | none in this package |
mat4_trs fn | 234 | MSH-004 | 213 |
Vertex struct | 10 | — | 19 20 33 46 |
vertex fn | 19 | — | 73 74 75 76 79 80 … (44 total) |
Triangle struct | 24 | — | 34 55 |
Mesh struct | 31 | — | 40 41 46 54 61 70 … (14 total) |
mesh fn | 40 | — | 71 115 132 48 6 153 … (47 total) |
add_vertex fn | 46 | MSH-001 | 73 74 75 76 79 80 … (41 total) |
add_triangle fn | 54 | MSH-001 | 62 63 28 49 54 |
add_quad fn | 61 | MSH-003 | 77 83 89 95 101 107 … (9 total) |
cube fn | 70 | — | 71 36 37 38 39 25 … (7 total) |
plane fn | 114 | MSH-003 | 43 44 45 46 67 120 … (8 total) |
sphere fn | 131 | — | 53 54 56 58 |
mesh_to_floats_uv fn | 173 | MSH-002 | 100 121 |
mesh_to_floats fn | 190 | MSH-002 | 50 55 99 105 116 |
Material struct | 11 | — | 24 25 33 34 41 42 … (9 total) |
material fn | 24 | — | 33 36 44 |
material_color fn | 33 | — | none in this package |
material_metal fn | 41 | — | none in this package |
set_color fn | 50 | — | 34 |
Node struct | 59 | — | 70 71 75 76 155 181 |
node fn | 70 | — | 45 47 48 |
node_at fn | 75 | — | none in this package |
Camera struct | 83 | — | 100 101 156 186 |
camera fn | 100 | — | 11 12 52 56 255 |
LightType enum | 113 | — | 119 |
Light struct | 117 | — | 130 131 140 141 157 191 |
directional_light fn | 130 | — | none in this package |
point_light fn | 140 | — | none in this package |
Scene struct | 151 | — | 162 163 167 174 181 186 … (7 total) |
scene fn | 162 | — | 49 5 10 16 17 24 … (13 total) |
add_mesh fn | 167 | — | 26 43 |
add_material fn | 174 | — | 35 44 |
add_node fn | 181 | — | 46 |
add_camera fn | 186 | — | 11 |
add_light fn | 191 | — | 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");
}