← glb · API reference · version 0.1.7
5 file(s), 1092 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 |
|---|---|---|---|
save_glb fn | 139 | GLB-004 | 11 19 36 65 84 61 … (10 total) |
save_scene_glb fn | 457 | GLB-002 | 13 22 36 14 25 43 … (14 total) |
src/glb.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// GLB (GL Transmission Format Binary) file writer.
// Exports a Mesh or Scene as a GLB 2.0 file readable by Blender, three.js, and gltf-validator.
// Format reference: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
//
// Writes the Mesh / Scene / Mat4 / Vec3 types of the `mesh3d` package. A scene's
// cameras are not written.
use mesh3d::*;
// Build the float32 position buffer (3 × f32 per vertex, x y z).
fn glb_pos_buf(verts: vector<Vertex>) -> vector<single> {
result = [];
for v in verts {
result += [v.pos.x as single, v.pos.y as single, v.pos.z as single];
}
result
}
// Build the float32 normal buffer (3 × f32 per vertex).
fn glb_norm_buf(verts: vector<Vertex>) -> vector<single> {
result = [];
for v in verts {
result += [v.normal.x as single, v.normal.y as single, v.normal.z as single];
}
result
}
// Build the float32 UV buffer (2 × f32 per vertex, u=x v=y).
fn glb_uv_buf(verts: vector<Vertex>) -> vector<single> {
result = [];
for v in verts {
result += [v.uv.x as single, v.uv.y as single];
}
result
}
// Build the uint32 index buffer (3 indices per triangle: a, b, c).
// Element type is `i32` so narrow-vector storage is 4 bytes per index,
// matching the glTF BIN chunk-size accounting for `f += glb_idx_buf(...)`.
fn glb_idx_buf(tris: vector<Triangle>) -> vector<i32> {
result: vector<i32> = [];
for t in tris {
result += [t.a as i32, t.b as i32, t.c as i32];
}
result
}
// Compute the minimum position across all vertices (component-wise).
fn glb_pos_min(verts: vector<Vertex>) -> Vec3 {
if verts.len() == 0 {
vec3(0.0, 0.0, 0.0)
} else {
mx = verts[0].pos.x;
my = verts[0].pos.y;
mz = verts[0].pos.z;
for v in verts {
if v.pos.x < mx { mx = v.pos.x; }
if v.pos.y < my { my = v.pos.y; }
if v.pos.z < mz { mz = v.pos.z; }
}
vec3(mx, my, mz)
}
}
// Compute the maximum position across all vertices (component-wise).
fn glb_pos_max(verts: vector<Vertex>) -> Vec3 {
if verts.len() == 0 {
vec3(0.0, 0.0, 0.0)
} else {
mx = verts[0].pos.x;
my = verts[0].pos.y;
mz = verts[0].pos.z;
for v in verts {
if v.pos.x > mx { mx = v.pos.x; }
if v.pos.y > my { my = v.pos.y; }
if v.pos.z > mz { mz = v.pos.z; }
}
vec3(mx, my, mz)
}
}
// Number of padding bytes needed to reach the next 4-byte boundary.
fn glb_pad4(n: integer) -> integer {
r = n % 4;
if r == 0 { 0 } else { 4 - r }
}
// Build the glTF 2.0 JSON string for a single mesh.
// Buffer layout (all contiguous in BIN chunk):
// [0] POSITION: nv * 12 bytes (VEC3, f32)
// [1] NORMAL: nv * 12 bytes (VEC3, f32)
// [2] TEXCOORD_0: nv * 8 bytes (VEC2, f32)
// [3] INDICES: ni * 4 bytes (SCALAR, u32)
fn glb_json(m: Mesh, pos_min: Vec3, pos_max: Vec3) -> text {
nv = m.vertices.len();
nt = m.triangles.len();
ni = nt * 3;
pos_bytes = nv * 12;
norm_bytes = nv * 12;
uv_bytes = nv * 8;
idx_bytes = ni * 4;
bin_bytes = pos_bytes + norm_bytes + uv_bytes + idx_bytes;
off_norm = pos_bytes;
off_uv = pos_bytes + norm_bytes;
off_idx = pos_bytes + norm_bytes + uv_bytes;
mn = m.name;
minx = pos_min.x; miny = pos_min.y; minz = pos_min.z;
maxx = pos_max.x; maxy = pos_max.y; maxz = pos_max.z;
// Use {{ and }} for literal { } in loft format strings.
// Use result += "..." to avoid the ++ return-concat + Vec3-param type bug.
result = "{{\"asset\":{{\"version\":\"2.0\",\"generator\":\"loft\"}},";
result += "\"scene\":0,\"scenes\":[{{\"nodes\":[0]}}],\"nodes\":[{{\"mesh\":0}}],";
result += "\"meshes\":[{{\"name\":\"{mn}\",\"primitives\":[{{\"attributes\":{{\"POSITION\":0,\"NORMAL\":1,\"TEXCOORD_0\":2}},\"indices\":3}}]}}],";
result += "\"accessors\":[";
result += "{{\"bufferView\":0,\"componentType\":5126,\"count\":{nv},\"type\":\"VEC3\",\"min\":[{minx:.6},{miny:.6},{minz:.6}],\"max\":[{maxx:.6},{maxy:.6},{maxz:.6}]}},";
result += "{{\"bufferView\":1,\"componentType\":5126,\"count\":{nv},\"type\":\"VEC3\"}},";
result += "{{\"bufferView\":2,\"componentType\":5126,\"count\":{nv},\"type\":\"VEC2\"}},";
result += "{{\"bufferView\":3,\"componentType\":5125,\"count\":{ni},\"type\":\"SCALAR\"}}";
result += "],";
result += "\"bufferViews\":[";
result += "{{\"buffer\":0,\"byteOffset\":0,\"byteLength\":{pos_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_norm},\"byteLength\":{norm_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_uv},\"byteLength\":{uv_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_idx},\"byteLength\":{idx_bytes},\"target\":34963}}";
result += "],";
result += "\"buffers\":[{{\"byteLength\":{bin_bytes}}}]}}";
result
}
// Export a Mesh as a GLB 2.0 file.
// The output file will contain POSITION, NORMAL, and TEXCOORD_0 vertex attributes
// plus triangle indices. Readable by Blender, three.js, gltf-validator.
// Example: @GLB-001
// Example: @GLB-003
// Example: @GLB-004
pub fn save_glb(m: Mesh, path: text) {
nv = m.vertices.len();
nt = m.triangles.len();
ni = nt * 3;
pos_bytes = nv * 12;
norm_bytes = nv * 12;
uv_bytes = nv * 8;
idx_bytes = ni * 4;
bin_bytes = pos_bytes + norm_bytes + uv_bytes + idx_bytes;
pmin = glb_pos_min(m.vertices);
pmax = glb_pos_max(m.vertices);
json = glb_json(m, pmin, pmax);
json_len = json.len();
json_pad = glb_pad4(json_len);
json_chunk = json_len + json_pad;
// Total file size: 12 (header) + 8 (json chunk hdr) + json_chunk + 8 (bin chunk hdr) + bin_bytes
total_len = 12 + 8 + json_chunk + 8 + bin_bytes;
f = file(path);
f#format = LittleEndian;
// `+=` is append-only — truncate explicitly so successive saves of
// the same path replace the previous file instead of appending.
f.set_file_size(0);
// GLB header (12 bytes — each u32 is 4 bytes). Post-2c `integer`
// is 8 bytes; cast each scalar header field to `i32` so the file
// lands the expected GLB 2.0 layout.
f += 0x46546C67 as i32; // magic 'glTF'
f += 2 as i32; // version 2
f += total_len as i32; // total file length
// JSON chunk (8-byte header + padded JSON)
f += json_chunk as i32; // chunk data length (padded)
f += 0x4E4F534A as i32; // chunk type 'JSON'
f += json; // JSON bytes (raw UTF-8)
// Pad JSON to 4-byte boundary with ASCII spaces
for _ in 0..json_pad {
f += 32 as u8;
}
// BIN chunk (8-byte header + binary buffers)
f += bin_bytes as i32; // chunk data length
f += 0x004E4942 as i32; // chunk type 'BIN\0'
f += glb_pos_buf(m.vertices);
f += glb_norm_buf(m.vertices);
f += glb_uv_buf(m.vertices);
f += glb_idx_buf(m.triangles);
}
// ─── Scene-level GLB export ───────────────────────────────────────────────────
// Build a JSON material entry for glTF 2.0 pbrMetallicRoughness.
fn glb_material_json(mat: Material) -> text {
mn = mat.name;
cr = mat.base_color_r as single;
cg = mat.base_color_g as single;
cb = mat.base_color_b as single;
ca = mat.base_color_a as single;
me = mat.metallic as single;
ro = mat.roughness as single;
result = "{{\"name\":\"{mn}\",";
result += "\"pbrMetallicRoughness\":{{";
result += "\"baseColorFactor\":[{cr:.6},{cg:.6},{cb:.6},{ca:.6}],";
result += "\"metallicFactor\":{me:.6},";
result += "\"roughnessFactor\":{ro:.6}";
result += "}}}}";
result
}
// True if a Mat4 is the identity matrix (within exact float equality).
fn glb_is_identity_mat4(tr: Mat4) -> boolean {
mm = tr.m;
// `?? 0.0` is OOB-lint suppression — `Mat4.m` is always 16 floats by
// construction (see mat4_identity et al.); the fallback path is
// unreachable.
(mm[0] ?? 0.0) == 1.0 && (mm[1] ?? 0.0) == 0.0 && (mm[2] ?? 0.0) == 0.0 && (mm[3] ?? 0.0) == 0.0 &&
(mm[4] ?? 0.0) == 0.0 && (mm[5] ?? 0.0) == 1.0 && (mm[6] ?? 0.0) == 0.0 && (mm[7] ?? 0.0) == 0.0 &&
(mm[8] ?? 0.0) == 0.0 && (mm[9] ?? 0.0) == 0.0 && (mm[10] ?? 0.0) == 1.0 && (mm[11] ?? 0.0) == 0.0 &&
(mm[12] ?? 0.0) == 0.0 && (mm[13] ?? 0.0) == 0.0 && (mm[14] ?? 0.0) == 0.0 && (mm[15] ?? 0.0) == 1.0
}
// Build a glTF "matrix" JSON property from a column-major Mat4.
// glTF 2.0 also uses column-major order, so emit elements directly.
fn glb_mat4_json(tr: Mat4) -> text {
mm = tr.m;
c0 = mm[0] as single; c1 = mm[1] as single; c2 = mm[2] as single; c3 = mm[3] as single;
c4 = mm[4] as single; c5 = mm[5] as single; c6 = mm[6] as single; c7 = mm[7] as single;
c8 = mm[8] as single; c9 = mm[9] as single; c10 = mm[10] as single; c11 = mm[11] as single;
c12 = mm[12] as single; c13 = mm[13] as single; c14 = mm[14] as single; c15 = mm[15] as single;
result = "\"matrix\":[{c0:.6},{c1:.6},{c2:.6},{c3:.6},";
result += "{c4:.6},{c5:.6},{c6:.6},{c7:.6},";
result += "{c8:.6},{c9:.6},{c10:.6},{c11:.6},";
result += "{c12:.6},{c13:.6},{c14:.6},{c15:.6}]";
result
}
fn glb_light_node_json(lt: Light, li: integer) -> text {
lname = lt.name;
result = "{{\"name\":\"{lname}\",\"extensions\":{{\"KHR_lights_punctual\":{{\"light\":{li}}}}}";
if lt.light_type == Point {
px = lt.position.x as single;
py = lt.position.y as single;
pz = lt.position.z as single;
result += ",\"translation\":[{px:.6},{py:.6},{pz:.6}]";
} else {
dir = normalize3(lt.direction);
dx = dir.x; dy = dir.y; dz = dir.z;
dot_val = -dz;
if dot_val < -0.9999 {
qx = 0.0 as single; qy = 1.0 as single; qz = 0.0 as single; qw = 0.0 as single;
} else if dot_val > 0.9999 {
qx = 0.0 as single; qy = 0.0 as single; qz = 0.0 as single; qw = 1.0 as single;
} else {
half = 1.0 + dot_val;
ax = dy; ay = -dx; az = 0.0;
inv_len = 1.0 / sqrt(ax * ax + ay * ay + az * az + half * half) ?? 0.0;
qx = (ax * inv_len) as single;
qy = (ay * inv_len) as single;
qz = (az * inv_len) as single;
qw = (half * inv_len) as single;
}
result += ",\"rotation\":[{qx:.6},{qy:.6},{qz:.6},{qw:.6}]";
// A spot has a place as well as a direction; a directional light has only the direction.
if lt.light_type == Spot {
sx = lt.position.x as single;
sy = lt.position.y as single;
sz = lt.position.z as single;
result += ",\"translation\":[{sx:.6},{sy:.6},{sz:.6}]";
}
}
result += "}}";
result
}
// Build the full glTF JSON for a Scene.
// BIN layout: for each mesh in order — pos, norm, uv, idx (no padding between meshes).
// accessor base = mesh_idx * 4; bufferView base = mesh_idx * 4.
fn glb_scene_json(sc: Scene, bin_offset: vector<integer>) -> text {
sn = sc.name;
// Build per-mesh material index: for each mesh, the material used by the
// first node that references it. In glTF 2.0, material lives on the mesh
// primitive (not on the node). Two-pass build so the indexed-assignment
// OOB lint stays quiet: first pre-fill a node→mesh lookup, then emit one
// entry per mesh by scanning the lookup.
mesh_mat: vector<integer> = [];
mm_i = 0;
for _ in sc.meshes {
found = 0;
for nd in sc.nodes {
if nd.mesh_idx == mm_i {
found = nd.material_idx;
}
}
mesh_mat += [found];
mm_i = mm_i + 1;
}
n_lights = sc.lights.len();
n_nodes = sc.nodes.len();
total_nodes = n_nodes + n_lights;
// --- asset / extensions ---
result = "{{\"asset\":{{\"version\":\"2.0\",\"generator\":\"loft\"}},";
if n_lights > 0 {
result += "\"extensionsUsed\":[\"KHR_lights_punctual\"],";
result += "\"extensionsRequired\":[\"KHR_lights_punctual\"],";
result += "\"extensions\":{{\"KHR_lights_punctual\":{{\"lights\":[";
li = 0;
for lt in sc.lights {
if li > 0 { result += ","; }
ltname = lt.name;
cr = lt.color_r as single;
cg = lt.color_g as single;
cb = lt.color_b as single;
inten = lt.intensity as single;
if lt.light_type == Point {
result += "{{\"name\":\"{ltname}\",\"type\":\"point\",\"color\":[{cr:.6},{cg:.6},{cb:.6}],\"intensity\":{inten:.6}}}";
} else if lt.light_type == Directional {
result += "{{\"name\":\"{ltname}\",\"type\":\"directional\",\"color\":[{cr:.6},{cg:.6},{cb:.6}],\"intensity\":{inten:.6}}}";
} else {
// KHR_lights_punctual REQUIRES `spot` on a spot light; `Light` carries no cone, so
// the spec's own defaults (inner 0, outer pi/4) are written.
result += "{{\"name\":\"{ltname}\",\"type\":\"spot\",\"color\":[{cr:.6},{cg:.6},{cb:.6}],\"intensity\":{inten:.6},\"spot\":{{\"innerConeAngle\":0.0,\"outerConeAngle\":0.785398}}}}";
}
li = li + 1;
}
result += "]}}}},";
}
// --- scene / scenes ---
result += "\"scene\":0,\"scenes\":[{{\"name\":\"{sn}\",\"nodes\":[";
ni = 0;
for _sn_idx in 0..total_nodes {
if ni > 0 { result += ","; }
result += "{ni}";
ni = ni + 1;
}
result += "]}}],";
// --- nodes: reference mesh; include "matrix" only for non-identity transforms ---
result += "\"nodes\":[";
ni = 0;
for nd in sc.nodes {
if ni > 0 { result += ","; }
mid = nd.mesh_idx;
ndname = nd.name;
if glb_is_identity_mat4(nd.transform) {
result += "{{\"name\":\"{ndname}\",\"mesh\":{mid}}}";
} else {
mat_str = glb_mat4_json(nd.transform);
result += "{{\"name\":\"{ndname}\",\"mesh\":{mid},{mat_str}}}";
}
ni = ni + 1;
}
// --- light nodes ---
li = 0;
for lt in sc.lights {
if ni > 0 { result += ","; }
result += glb_light_node_json(lt, li);
ni = ni + 1;
li = li + 1;
}
result += "],";
// --- meshes: material reference lives on the primitive ---
result += "\"meshes\":[";
mi = 0;
for me in sc.meshes {
if mi > 0 { result += ","; }
mename = me.name;
acc_pos = mi * 4;
acc_norm = mi * 4 + 1;
acc_uv = mi * 4 + 2;
acc_idx = mi * 4 + 3;
mat_idx = mesh_mat[mi] ?? 0;
result += "{{\"name\":\"{mename}\",\"primitives\":[{{\"attributes\":{{\"POSITION\":{acc_pos},\"NORMAL\":{acc_norm},\"TEXCOORD_0\":{acc_uv}}},\"indices\":{acc_idx},\"material\":{mat_idx}}}]}}";
mi = mi + 1;
}
result += "],";
// --- accessors ---
result += "\"accessors\":[";
mi = 0;
for me in sc.meshes {
if mi > 0 { result += ","; }
nv = me.vertices.len();
nt = me.triangles.len();
nidx = nt * 3;
pmin = glb_pos_min(me.vertices);
pmax = glb_pos_max(me.vertices);
minx = pmin.x as single; miny = pmin.y as single; minz = pmin.z as single;
maxx = pmax.x as single; maxy = pmax.y as single; maxz = pmax.z as single;
bv_pos = mi * 4;
bv_norm = mi * 4 + 1;
bv_uv = mi * 4 + 2;
bv_idx = mi * 4 + 3;
result += "{{\"bufferView\":{bv_pos},\"componentType\":5126,\"count\":{nv},\"type\":\"VEC3\",\"min\":[{minx:.6},{miny:.6},{minz:.6}],\"max\":[{maxx:.6},{maxy:.6},{maxz:.6}]}},";
result += "{{\"bufferView\":{bv_norm},\"componentType\":5126,\"count\":{nv},\"type\":\"VEC3\"}},";
result += "{{\"bufferView\":{bv_uv},\"componentType\":5126,\"count\":{nv},\"type\":\"VEC2\"}},";
result += "{{\"bufferView\":{bv_idx},\"componentType\":5125,\"count\":{nidx},\"type\":\"SCALAR\"}}";
mi = mi + 1;
}
result += "],";
// --- bufferViews ---
result += "\"bufferViews\":[";
mi = 0;
for me in sc.meshes {
if mi > 0 { result += ","; }
nv = me.vertices.len();
nt = me.triangles.len();
nidx = nt * 3;
pos_bytes = nv * 12;
norm_bytes = nv * 12;
uv_bytes = nv * 8;
idx_bytes = nidx * 4;
base = bin_offset[mi] ?? 0;
off_norm = base + pos_bytes;
off_uv = base + pos_bytes + norm_bytes;
off_idx = base + pos_bytes + norm_bytes + uv_bytes;
result += "{{\"buffer\":0,\"byteOffset\":{base},\"byteLength\":{pos_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_norm},\"byteLength\":{norm_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_uv},\"byteLength\":{uv_bytes},\"target\":34962}},";
result += "{{\"buffer\":0,\"byteOffset\":{off_idx},\"byteLength\":{idx_bytes},\"target\":34963}}";
mi = mi + 1;
}
result += "],";
// --- materials ---
result += "\"materials\":[";
mi = 0;
for mat in sc.materials {
if mi > 0 { result += ","; }
result += glb_material_json(mat);
mi = mi + 1;
}
result += "],";
// --- buffer ---
// Total bin size = sum of all mesh bin sizes
total_bin = 0;
for me in sc.meshes {
nv = me.vertices.len();
nt = me.triangles.len();
total_bin = total_bin + nv * 12 + nv * 12 + nv * 8 + nt * 3 * 4;
}
result += "\"buffers\":[{{\"byteLength\":{total_bin}}}]}}";
result
}
// Export a Scene as a GLB 2.0 file.
// All meshes are packed into one BIN chunk; materials use pbrMetallicRoughness.
// Example: @GLB-002
pub fn save_scene_glb(sc: Scene, path: text) {
// Compute per-mesh byte offsets in the BIN chunk.
bin_offset = [];
cur_offset = 0;
for me in sc.meshes {
bin_offset += [cur_offset];
nv = me.vertices.len();
nt = me.triangles.len();
cur_offset = cur_offset + nv * 12 + nv * 12 + nv * 8 + nt * 3 * 4;
}
total_bin = cur_offset;
json = glb_scene_json(sc, bin_offset);
json_len = json.len();
json_pad = glb_pad4(json_len);
json_chunk = json_len + json_pad;
total_len = 12 + 8 + json_chunk + 8 + total_bin;
f = file(path);
f#format = LittleEndian;
// `+=` is append-only — truncate explicitly so successive saves of
// the same path replace the previous file instead of appending.
f.set_file_size(0);
// GLB header — each u32 field is 4 bytes per spec. Post-2c
// `integer` is 8 bytes; cast each scalar to `i32` so writes land
// the expected GLB 2.0 header layout.
f += 0x46546C67 as i32;
f += 2 as i32;
f += total_len as i32;
// JSON chunk
f += json_chunk as i32;
f += 0x4E4F534A as i32;
f += json;
for _ in 0..json_pad {
f += 32 as u8;
}
// BIN chunk
f += total_bin as i32;
f += 0x004E4942 as i32;
for me in sc.meshes {
f += glb_pos_buf(me.vertices);
f += glb_norm_buf(me.vertices);
f += glb_uv_buf(me.vertices);
f += glb_idx_buf(me.triangles);
}
}
tests/glb.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the GLB 2.0 binary writer (save_glb).
// Run with: loft --interpret --tests tests
use mesh3d::*;
use glb::*;
// Verify that save_glb produces a non-empty file.
fn test_glb_creates_file() {
gc_m = mesh::cube();
glb::save_glb(gc_m, "test_glb_a.glb");
assert(file("test_glb_a.glb").size > 0, "GLB file was created");
delete("test_glb_a.glb");
}
// Verify the 12-byte GLB header: magic 'glTF', version 2, and total length.
fn test_glb_header() {
gh_m = mesh::cube();
glb::save_glb(gh_m, "test_glb_b.glb");
gh_sz = file("test_glb_b.glb").size;
{gh_f = file("test_glb_b.glb");
gh_f#format = LittleEndian;
assert(gh_f#read(4) as i32 == 0x46546C67, "magic 'glTF'");
assert(gh_f#read(4) as i32 == 2, "version 2");
gh_total = gh_f#read(4) as i32;
assert(gh_total as integer == gh_sz, "total_len matches file size={gh_sz}");
}
delete("test_glb_b.glb");
}
// Verify the JSON chunk type tag and BIN chunk type tag.
// Reads the header sequentially (f#next seek-to-offset does not work before
// the first read on a freshly opened file handle).
fn test_glb_chunk_types() {
gt_m = mesh::cube();
glb::save_glb(gt_m, "test_glb_c.glb");
{gt_f = file("test_glb_c.glb");
gt_f#format = LittleEndian;
gt_f#read(4) as i32; // magic
gt_f#read(4) as i32; // version
gt_f#read(4) as i32; // total_len
gt_jlen = gt_f#read(4) as i32; // JSON chunk data length
assert(gt_f#read(4) as i32 == 0x4E4F534A, "JSON chunk type 'JSON'");
// Skip JSON data (includes padding): seek past it.
// After reading 5 x 4 = 20 bytes, current position = 20.
// JSON data starts at 20 and is gt_jlen bytes long.
// Use #next to jump to BIN chunk header at 20 + gt_jlen.
gt_f#next = (20 + gt_jlen) as integer;
gt_f#read(4) as i32; // skip BIN chunk data length
assert(gt_f#read(4) as i32 == 0x004E4942, "BIN chunk type 'BIN\\0'");
}
delete("test_glb_c.glb");
}
// Verify the BIN chunk data length for a known mesh (3 vertices, 1 triangle).
// bin = pos(3*12) + norm(3*12) + uv(3*8) + idx(3*4) = 36+36+24+12 = 108 bytes.
fn test_glb_bin_size() {
gb_m = mesh::mesh("tri");
gb_n = math::vec3(0.0, 0.0, 1.0);
gb_uv = math::vec2(0.0, 0.0);
gb_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), gb_n, gb_uv));
gb_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), gb_n, gb_uv));
gb_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), gb_n, gb_uv));
gb_m.add_triangle(0, 1, 2);
glb::save_glb(gb_m, "test_glb_d.glb");
{gb_f = file("test_glb_d.glb");
gb_f#format = LittleEndian;
gb_f#read(4) as i32; // magic
gb_f#read(4) as i32; // version
gb_f#read(4) as i32; // total_len
gb_jlen = gb_f#read(4) as i32; // JSON chunk length
gb_f#read(4) as i32; // JSON chunk type
// Now seek past JSON data to BIN chunk header.
gb_f#next = (20 + gb_jlen) as integer;
gb_blen = gb_f#read(4) as i32; // BIN chunk data length
assert(gb_blen == 108, "bin size for 3-vert 1-tri={gb_blen}");
}
delete("test_glb_d.glb");
}
// Verify JSON chunk data length is a multiple of 4.
fn test_glb_json_padding() {
gp_m = mesh::cube();
glb::save_glb(gp_m, "test_glb_e.glb");
{gp_f = file("test_glb_e.glb");
gp_f#format = LittleEndian;
gp_f#read(4) as i32; // magic
gp_f#read(4) as i32; // version
gp_f#read(4) as i32; // total_len
gp_jlen = gp_f#read(4) as i32; // JSON chunk length
assert(gp_jlen % 4 == 0, "JSON chunk is 4-byte aligned={gp_jlen}");
}
delete("test_glb_e.glb");
}
tests/light_glb.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Exercises the directional-light export path (glb_light_node_json), which
// computes a quaternion from the light direction — the only place glb reads
// Vec3 components through normalize3. Guards that path on both backends.
use mesh3d::*;
use glb::*;
fn test_directional_light_exports() {
dls = scene::scene("lit");
dls.add_mesh(mesh::cube());
dls.add_light(scene::directional_light("sun", 1.0, 1.0, 1.0, 1.0, math::vec3(0.3, -1.0, 0.2)));
glb::save_scene_glb(dls, "test_dir_light.glb");
assert(file("test_dir_light.glb").size > 0, "directional-light scene exported");
delete("test_dir_light.glb");
}
fn test_point_light_exports() {
pls = scene::scene("plit");
pls.add_mesh(mesh::cube());
pls.add_light(scene::point_light("bulb", 1.0, 0.8, 0.6, 2.0, math::vec3(1.0, 2.0, 3.0)));
glb::save_scene_glb(pls, "test_point_light.glb");
assert(file("test_point_light.glb").size > 0, "point-light scene exported");
delete("test_point_light.glb");
}
// A spot light carries the `spot` object KHR_lights_punctual requires for that type (the spec's
// default cone, as `Light` has none), and its node both its place and its direction.
fn test_spot_light_exports_valid_gltf() {
sps = scene::scene("spotlit");
sps.add_mesh(mesh::cube());
sps.add_light(scene::Light {
name: "torch", light_type: Spot, color_r: 1.0, color_g: 1.0, color_b: 1.0, intensity: 5.0,
position: math::vec3(1.0, 2.0, 3.0), direction: math::vec3(0.0, -1.0, 0.0)
});
glb::save_scene_glb(sps, "test_spot_light.glb");
f = file("test_spot_light.glb");
f#format = LittleEndian;
_ = f#read(12);
jlen = (f#read(4) as i32) as integer;
_ = f#read(4);
json = f#read(jlen) as text;
assert(json.contains("\"type\":\"spot\""), "a spot light");
assert(json.contains("\"spot\":{{\"innerConeAngle\":0.0,\"outerConeAngle\":0.785398}}"), "with its required cone");
assert(json.contains("\"translation\":[1.000000,2.000000,3.000000]"), "placed where the light is");
assert(json.contains("\"rotation\":["), "and turned the way it points");
delete("test_spot_light.glb");
}
tests/scene_glb.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for save_scene_glb — scene-level GLB 2.0 export.
// Run with: loft --interpret --tests tests
use mesh3d::*;
use glb::*;
// Verify save_scene_glb produces a non-empty file for a single-mesh scene.
fn test_scene_glb_creates_file() {
sg_s = scene::scene("test");
sg_mi = sg_s.add_mesh(mesh::cube());
sg_mati = sg_s.add_material(scene::material("default"));
sg_s.add_node(scene::node("cube", sg_mi, sg_mati));
glb::save_scene_glb(sg_s, "test_sglb_a.glb");
assert(file("test_sglb_a.glb").size > 0, "scene GLB created");
delete("test_sglb_a.glb");
}
// Verify the GLB header for a scene file.
fn test_scene_glb_header() {
sh_s = scene::scene("hdr");
sh_s.add_mesh(mesh::cube());
sh_mati = sh_s.add_material(scene::material("mat"));
sh_s.add_node(scene::node("n", 0, sh_mati));
glb::save_scene_glb(sh_s, "test_sglb_b.glb");
sh_sz = file("test_sglb_b.glb").size;
{sh_f = file("test_sglb_b.glb");
sh_f#format = LittleEndian;
assert(sh_f#read(4) as i32 == 0x46546C67, "magic 'glTF'");
assert(sh_f#read(4) as i32 == 2, "version 2");
sh_total = sh_f#read(4) as i32;
assert(sh_total as integer == sh_sz, "total_len matches file size={sh_sz}");
}
delete("test_sglb_b.glb");
}
// Verify JSON chunk type and BIN chunk type for scene GLB.
fn test_scene_glb_chunk_types() {
sc_s = scene::scene("chunks");
sc_s.add_mesh(mesh::cube());
sc_mati = sc_s.add_material(scene::material("m"));
sc_s.add_node(scene::node("n", 0, sc_mati));
glb::save_scene_glb(sc_s, "test_sglb_c.glb");
{sc_f = file("test_sglb_c.glb");
sc_f#format = LittleEndian;
sc_f#read(4) as i32; // magic
sc_f#read(4) as i32; // version
sc_f#read(4) as i32; // total_len
sc_jlen = sc_f#read(4) as i32;
assert(sc_f#read(4) as i32 == 0x4E4F534A, "JSON chunk type 'JSON'");
sc_f#next = (20 + sc_jlen) as integer;
sc_f#read(4) as i32; // BIN data length
assert(sc_f#read(4) as i32 == 0x004E4942, "BIN chunk type 'BIN\\0'");
}
delete("test_sglb_c.glb");
}
// Verify BIN chunk size for a single triangle-mesh scene.
// 3 verts: pos=36 + norm=36 + uv=24 + idx=12 = 108 bytes.
fn test_scene_glb_bin_size_single_mesh() {
sb_s = scene::scene("binsize");
sb_m = mesh::mesh("tri");
sb_n = math::vec3(0.0, 0.0, 1.0);
sb_uv = math::vec2(0.0, 0.0);
sb_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), sb_n, sb_uv));
sb_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), sb_n, sb_uv));
sb_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), sb_n, sb_uv));
sb_m.add_triangle(0, 1, 2);
sb_mi = sb_s.add_mesh(sb_m);
sb_mati = sb_s.add_material(scene::material("white"));
sb_s.add_node(scene::node("tri", sb_mi, sb_mati));
glb::save_scene_glb(sb_s, "test_sglb_d.glb");
{sb_f = file("test_sglb_d.glb");
sb_f#format = LittleEndian;
sb_f#read(4) as i32; // magic
sb_f#read(4) as i32; // version
sb_f#read(4) as i32; // total_len
sb_jlen = sb_f#read(4) as i32;
sb_f#read(4) as i32; // JSON chunk type
sb_f#next = (20 + sb_jlen) as integer;
sb_blen = sb_f#read(4) as i32;
assert(sb_blen == 108, "bin size for 3-vert 1-tri={sb_blen}");
}
delete("test_sglb_d.glb");
}
// Verify BIN chunk size for a two-mesh scene.
// Mesh A: 3 verts, 1 tri = 108 bytes; Mesh B: 3 verts, 1 tri = 108 bytes → total 216.
fn test_scene_glb_bin_size_two_meshes() {
st_s = scene::scene("two");
st_n = math::vec3(0.0, 0.0, 1.0);
st_uv = math::vec2(0.0, 0.0);
st_ma = mesh::mesh("a");
st_ma.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), st_n, st_uv));
st_ma.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), st_n, st_uv));
st_ma.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), st_n, st_uv));
st_ma.add_triangle(0, 1, 2);
st_mb = mesh::mesh("b");
st_mb.add_vertex(mesh::vertex(math::vec3(2.0, 0.0, 0.0), st_n, st_uv));
st_mb.add_vertex(mesh::vertex(math::vec3(3.0, 0.0, 0.0), st_n, st_uv));
st_mb.add_vertex(mesh::vertex(math::vec3(2.0, 1.0, 0.0), st_n, st_uv));
st_mb.add_triangle(0, 1, 2);
st_mia = st_s.add_mesh(st_ma);
st_mib = st_s.add_mesh(st_mb);
st_mati = st_s.add_material(scene::material("mat"));
st_s.add_node(scene::node("a", st_mia, st_mati));
st_s.add_node(scene::node("b", st_mib, st_mati));
glb::save_scene_glb(st_s, "test_sglb_e.glb");
{st_f = file("test_sglb_e.glb");
st_f#format = LittleEndian;
st_f#read(4) as i32;
st_f#read(4) as i32;
st_f#read(4) as i32;
st_jlen = st_f#read(4) as i32;
st_f#read(4) as i32;
st_f#next = (20 + st_jlen) as integer;
st_blen = st_f#read(4) as i32;
assert(st_blen == 216, "two-mesh bin size={st_blen}");
}
delete("test_sglb_e.glb");
}
// Verify that the JSON chunk contains "material" on the mesh primitive,
// not on the node, per glTF 2.0 spec.
fn test_scene_glb_json_material_in_primitive() {
sm_s = scene::scene("mattest");
sm_s.add_mesh(mesh::cube());
sm_mati = sm_s.add_material(scene::material("red"));
sm_s.add_node(scene::node("cube", 0, sm_mati));
glb::save_scene_glb(sm_s, "test_sglb_g.glb");
{sm_f = file("test_sglb_g.glb");
sm_f#format = LittleEndian;
sm_f#read(4) as i32; // magic
sm_f#read(4) as i32; // version
sm_f#read(4) as i32; // total_len
sm_jlen = sm_f#read(4) as i32;
sm_f#read(4) as i32; // JSON chunk type
// Read the JSON text directly (sm_jlen bytes after offset 20).
sm_json = sm_f#read(sm_jlen) as text;
// "material" must appear inside "primitives" (mesh section).
assert(sm_json.find("\"primitives\"") != null, "has primitives");
assert(sm_json.find("\"material\":") != null, "material present");
// nodes section must NOT reference material.
// The node JSON should be {"name":"cube","mesh":0} — no "material" key.
// We check by verifying node object ends with "mesh" field.
assert(sm_json.find("\"mesh\":0}}") != null, "node ends with mesh ref only");
}
delete("test_sglb_g.glb");
}
// Verify that a non-identity node transform produces a "matrix" field in JSON.
fn test_scene_glb_node_transform() {
st_s = scene::scene("xform");
st_s.add_mesh(mesh::cube());
st_mati = st_s.add_material(scene::material("m"));
// Create node with a non-identity transform (translate by 2 on X).
// Use node_at() to pass the transform at construction — avoids transform field
// reassignment which triggers a store bug with nested vector mutation (P109).
st_n = scene::node_at("moved", 0, st_mati, math::mat4_translate(2.0, 0.0, 0.0));
st_s.add_node(st_n);
glb::save_scene_glb(st_s, "test_sglb_h.glb");
{st_f = file("test_sglb_h.glb");
st_f#format = LittleEndian;
st_f#read(4) as i32;
st_f#read(4) as i32;
st_f#read(4) as i32;
st_jlen = st_f#read(4) as i32;
st_f#read(4) as i32;
st_json = st_f#read(st_jlen) as text;
assert(st_json.find("\"matrix\":") != null, "non-identity has matrix field");
}
delete("test_sglb_h.glb");
}
// Verify that an identity transform does NOT produce a "matrix" field.
fn test_scene_glb_identity_no_matrix() {
si_s = scene::scene("ident");
si_s.add_mesh(mesh::cube());
si_mati = si_s.add_material(scene::material("m"));
si_s.add_node(scene::node("cube", 0, si_mati));
glb::save_scene_glb(si_s, "test_sglb_i.glb");
{si_f = file("test_sglb_i.glb");
si_f#format = LittleEndian;
si_f#read(4) as i32;
si_f#read(4) as i32;
si_f#read(4) as i32;
si_jlen = si_f#read(4) as i32;
si_f#read(4) as i32;
si_json = si_f#read(si_jlen) as text;
assert(si_json.find("\"matrix\":") == null, "identity has no matrix field");
}
delete("test_sglb_i.glb");
}
// Verify JSON chunk is 4-byte aligned.
fn test_scene_glb_json_padding() {
sp_s = scene::scene("pad");
sp_s.add_mesh(mesh::cube());
sp_mati = sp_s.add_material(scene::material("mat"));
sp_s.add_node(scene::node("n", 0, sp_mati));
glb::save_scene_glb(sp_s, "test_sglb_f.glb");
{sp_f = file("test_sglb_f.glb");
sp_f#format = LittleEndian;
sp_f#read(4) as i32;
sp_f#read(4) as i32;
sp_f#read(4) as i32;
sp_jlen = sp_f#read(4) as i32;
assert(sp_jlen % 4 == 0, "JSON chunk 4-byte aligned={sp_jlen}");
}
delete("test_sglb_f.glb");
}
tests/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: @GLB-0NN` line pointing here, and every tag here is
// reachable from the functions it demonstrates.
//
// ⚠ These are examples FIRST and assertions second. `glb.loft` and
// `scene_glb.loft` already pin the byte layout the writer produces; what
// these pin is the shape of a CORRECT CALL.
//
// Why this library owes them: **its whole public surface is two
// functions that return NOTHING.** `save_glb(mesh, path)` and
// `save_scene_glb(scene, path)` are statements — there is no success
// value, no error, no null. The only evidence a save happened is the
// file, and the writer is happy to produce a well-formed GLB container
// holding a glTF no reader will accept.
use mesh3d::*;
use glb::*;
// A three-vertex mesh, wound counter-clockwise, used by the examples below.
fn one_triangle(ot_name: text) -> Mesh {
ot_m = mesh::mesh(ot_name);
ot_n = math::vec3(0.0, 0.0, 1.0);
ot_uv = math::vec2(0.0, 0.0);
ot_m.add_vertex(mesh::vertex(math::vec3(0.0, 0.0, 0.0), ot_n, ot_uv));
ot_m.add_vertex(mesh::vertex(math::vec3(1.0, 0.0, 0.0), ot_n, ot_uv));
ot_m.add_vertex(mesh::vertex(math::vec3(0.0, 1.0, 0.0), ot_n, ot_uv));
ot_m
}
// Read the glTF JSON chunk back out of a written GLB.
fn glb_json_of(gj_path: text) -> text {
gj_f = file(gj_path);
gj_f#format = LittleEndian;
gj_f#read(4) as i32; // magic
gj_f#read(4) as i32; // version
gj_f#read(4) as i32; // total length
gj_len = gj_f#read(4) as i32; // JSON chunk data length
gj_f#read(4) as i32; // chunk type 'JSON'
gj_f#read(gj_len) as text
}
// Saving a mesh.
//
// ⚠⚠ **`save_glb` answers nothing at all.** Not a boolean, not a null —
// the call is a statement. So a save is not something a program can
// check; it is something a program has to go and LOOK at afterwards.
// @GLB-001 — the save has no return value, so the file is the only evidence
fn test_saving_a_mesh_and_checking_it() {
sm_m = one_triangle("wedge");
sm_m.add_triangle(0, 1, 2);
// The whole call site. Nothing is bound, because there is nothing to bind.
glb::save_glb(sm_m, "wex_glb_a.glb");
// ⚠ The check a caller has to write, because the library cannot say it:
// the file exists, is non-empty, and starts with the GLB magic.
assert(file("wex_glb_a.glb").size > 0, "a file appeared");
{sm_f = file("wex_glb_a.glb");
sm_f#format = LittleEndian;
assert(sm_f#read(4) as i32 == 0x46546C67, "magic is 'glTF'");
assert(sm_f#read(4) as i32 == 2, "GLB version 2");
assert((sm_f#read(4) as i32) as integer == file("wex_glb_a.glb").size,
"the header's total length is the real file size");
}
// ⚠ And a save TRUNCATES rather than appends, so writing the same path
// twice replaces it — the size is the answer, not double it.
sm_once = file("wex_glb_a.glb").size;
glb::save_glb(sm_m, "wex_glb_a.glb");
assert(file("wex_glb_a.glb").size == sm_once,
"a second save replaced the file ({file(\"wex_glb_a.glb\").size} vs {sm_once})");
delete("wex_glb_a.glb");
}
// Which door to save through.
//
// ⚠⚠ **`save_glb` writes ONE mesh and nothing else.** No material, no
// node transform, no camera, no light — a scene handed in one mesh at a
// time arrives grey, at the origin, unlit, and nothing reports the loss.
// `save_scene_glb` is the door that carries them.
// @GLB-002 — the mesh door drops everything that is not geometry
fn test_choosing_the_save_door() {
cd_m = one_triangle("solo");
cd_m.add_triangle(0, 1, 2);
// The mesh door, on a mesh that is part of a richer scene:
glb::save_glb(cd_m, "wex_glb_b.glb");
cd_j1 = glb_json_of("wex_glb_b.glb");
assert(cd_j1.find("\"meshes\"") != null, "the geometry is there");
assert(cd_j1.find("\"materials\"") == null, "and no material");
assert(cd_j1.find("\"matrix\"") == null, "no node transform");
assert(cd_j1.find("\"cameras\"") == null, "no camera");
delete("wex_glb_b.glb");
// ⚠ The same mesh through the scene door, with what it belongs to:
cd_s = scene::scene("lit");
cd_s.add_mesh(cd_m);
cd_mat = cd_s.add_material(scene::material_color("red", 1.0, 0.0, 0.0));
cd_s.add_node(scene::node_at("moved", 0, cd_mat, math::mat4_translate(2.0, 0.0, 0.0)));
glb::save_scene_glb(cd_s, "wex_glb_c.glb");
cd_j2 = glb_json_of("wex_glb_c.glb");
assert(cd_j2.find("\"materials\"") != null, "the material travelled");
assert(cd_j2.find("\"matrix\"") != null, "and so did the transform");
delete("wex_glb_c.glb");
// ⚠ A node's transform is compared to the identity by EXACT float
// equality, so a full-turn rotation — mathematically the identity —
// still emits a matrix. Harmless, but it means "no matrix in the
// JSON" is a statement about the bits, not about the geometry.
cd_spun = scene::scene("spun");
cd_spun.add_mesh(cd_m);
cd_smat = cd_spun.add_material(scene::material("m"));
cd_spun.add_node(scene::node_at("turned", 0, cd_smat,
math::mat4_rotate_y(6.283185307179586)));
glb::save_scene_glb(cd_spun, "wex_glb_d.glb");
assert(glb_json_of("wex_glb_d.glb").find("\"matrix\"") != null,
"a 360-degree turn is not bit-identical to the identity");
delete("wex_glb_d.glb");
}
// What a broken mesh does on the way out.
//
// ⚠⚠ **A triangle index is copied into the file unchecked.** The same
// out-of-range index that `mesh3d`'s `mesh_to_floats` silently SKIPS is
// written verbatim here — so the two export paths fail differently for
// one defect: the GL buffer comes out short and sheared, while the GLB
// comes out structurally perfect and semantically invalid, an index of 5
// against an accessor that declares 3 vertices.
// @GLB-003 — an out-of-range index is written straight into the file
fn test_a_bad_index_reaches_the_file() {
bi_m = one_triangle("bad");
bi_m.add_triangle(0, 1, 5); // there is no vertex 5
glb::save_glb(bi_m, "wex_glb_e.glb");
assert(file("wex_glb_e.glb").size > 0, "the file is written anyway");
// The JSON still declares three vertices...
assert(glb_json_of("wex_glb_e.glb").find("\"count\":3") != null,
"POSITION accessor count is 3");
// ...and the BIN chunk carries the index 5 next to them. BIN layout is
// pos(3*12) + norm(3*12) + uv(3*8) + idx(3*4) = 108 bytes.
{bi_f = file("wex_glb_e.glb");
bi_f#format = LittleEndian;
bi_f#read(4) as i32; bi_f#read(4) as i32; bi_f#read(4) as i32;
bi_jlen = bi_f#read(4) as i32;
bi_f#read(4) as i32;
bi_f#next = (20 + bi_jlen) as integer;
assert(bi_f#read(4) as i32 == 108, "BIN chunk is the expected 108 bytes");
bi_f#read(4) as i32; // chunk type 'BIN\0'
bi_f#next = (20 + bi_jlen + 8 + 36 + 36 + 24) as integer; // past pos, norm, uv
assert(bi_f#read(4) as i32 == 0, "index 0");
assert(bi_f#read(4) as i32 == 1, "index 1");
assert(bi_f#read(4) as i32 == 5, "and index 5, which no vertex answers to");
}
delete("wex_glb_e.glb");
// ⚠ So validate before saving. Nothing downstream will: the writer
// does not look, and the first report is a validator or a renderer.
assert(!exportable(bi_m), "the broken mesh is not exportable");
bi_ok = one_triangle("good");
bi_ok.add_triangle(0, 1, 2);
assert(exportable(bi_ok), "a whole one is");
}
// The pre-save check the examples recommend: a mesh reaches a reader
// intact only if it has geometry at all and every index names a vertex.
fn exportable(ex_m: const Mesh) -> boolean {
ex_n = ex_m.vertices.len();
if ex_n == 0 || ex_m.triangles.len() == 0 { return false; }
for ex_t in ex_m.triangles {
if ex_t.a < 0 || ex_t.a >= ex_n { return false; }
if ex_t.b < 0 || ex_t.b >= ex_n { return false; }
if ex_t.c < 0 || ex_t.c >= ex_n { return false; }
}
true
}
// Saving nothing.
//
// ⚠⚠ **An empty mesh writes a complete, well-formed GLB file that is not
// a valid glTF.** The container is right — magic, version, both chunk
// headers, a total length that matches the file — and the accessors it
// declares have `"count":0`, which glTF 2.0 forbids. The save reports
// success the only way it can: by not saying anything.
// @GLB-004 — an empty mesh writes a valid container around an invalid glTF
fn test_saving_an_empty_mesh() {
sn_m = mesh::mesh("nothing"); // no vertices, no triangles
glb::save_glb(sn_m, "wex_glb_f.glb");
// The container is impeccable:
assert(file("wex_glb_f.glb").size > 0, "a file was written");
{sn_f = file("wex_glb_f.glb");
sn_f#format = LittleEndian;
assert(sn_f#read(4) as i32 == 0x46546C67, "magic is 'glTF'");
sn_f#read(4) as i32;
assert((sn_f#read(4) as i32) as integer == file("wex_glb_f.glb").size,
"total length matches");
sn_jlen = sn_f#read(4) as i32;
assert(sn_jlen % 4 == 0, "the JSON chunk is padded to 4 bytes");
sn_f#read(4) as i32;
sn_f#next = (20 + sn_jlen) as integer;
assert(sn_f#read(4) as i32 == 0, "and the BIN chunk is empty");
}
// ⚠ ...and the glTF inside it is not loadable: an accessor may not
// declare zero elements.
assert(glb_json_of("wex_glb_f.glb").find("\"count\":0") != null,
"the accessors declare count 0");
delete("wex_glb_f.glb");
// The same guard as @GLB-003 catches it, which is why it tests both:
assert(!exportable(sn_m), "an empty mesh is not exportable");
}
fn main() {
test_saving_a_mesh_and_checking_it();
test_choosing_the_save_door();
test_a_bad_index_reaches_the_file();
test_saving_an_empty_mesh();
println("glb worked examples passed");
}