glb source

← 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.

ItemDefinedWorked exampleCall sites
save_glb fn139GLB-00411 19 36 65 84 61 … (10 total)
save_scene_glb fn457GLB-00213 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");
}