///|
// glTF 2.0 exporter.
//
// The mesh is derived from the compiled B-Rep (not from `Shape`), so booleans,
// composite transforms, and analytic geometry all tessellate uniformly:
//
//   * PLANE faces: boundary loops are sampled, projected to the plane, and
//     triangulated with ear clipping (holes are bridged into the outer loop).
//   * CYLINDRICAL_SURFACE faces: treated as full 360-degree bands and sampled
//     at `gltf_segments` segments. The caps are ordinary circular PLANE faces.
//   * SURFACE_OF_LINEAR_EXTRUSION faces: bands over the swept curve, sampled at
//     `gltf_segments` points.
//
// Output coordinates are Z-up, matching the STEP convention used throughout
// this library. Viewers that expect Y-up can rotate the root node by -90
// degrees about X. Vertex positions are welded (1e-9 grid) so a closed solid
// produces a closed index mesh, and normals are area-weighted per welded
// vertex.

///|
/// gltf_segments is the fixed tessellation resolution for circular and
/// swept-curve bands. A `Cylinder` therefore emits 2*64 band triangles plus
/// two 64-gon caps (62 triangles each).
let gltf_segments : Int32 = 64

///|
/// GltfPrim is one node's welded triangle mesh plus its color.
priv struct GltfPrim {
  name : String
  color : Rgb
  positions : Array[Vec3]
  normals : Array[Vec3]
  indices : Array[Int32]
}

///|
/// MeshBuilder welds positions on a 1e-9 grid and accumulates face normals.
priv struct MeshBuilder {
  verts : Array[Vec3]
  normals : Array[Vec3]
  indices : Array[Int32]
  lookup : Map[String, Int32]
}

///|
fn MeshBuilder::new() -> MeshBuilder {
  { verts: [], normals: [], indices: [], lookup: Map([]), }
}

///|
fn weld_key(p : Vec3) -> String {
  let scale = 1.0e9
  let x = (p.0 * scale).round().to_int64()
  let y = (p.1 * scale).round().to_int64()
  let z = (p.2 * scale).round().to_int64()
  "\{x},\{y},\{z}"
}

///|
fn MeshBuilder::weld(self : MeshBuilder, p : Vec3) -> Int32 {
  let key = weld_key(p)
  match self.lookup.get(key) {
    Some(i) => i
    None => {
      let i = self.verts.length()
      self.verts.push(p)
      self.normals.push((0.0, 0.0, 0.0))
      self.lookup.set(key, i)
      i
    }
  }
}

///|
/// add_triangle emits a triangle whose winding matches `wanted`, accumulating
/// its geometric normal on the three welded vertices.
fn MeshBuilder::add_triangle(
  self : MeshBuilder,
  a : Vec3,
  b : Vec3,
  c : Vec3,
  wanted : Vec3,
) -> Unit {
  let mut n = v_cross(v_sub(b, a), v_sub(c, a))
  if v_len(n) < 1.0e-14 {
    return
  }
  let (b2, c2) = if v_len(wanted) > 0.0 && v_dot(n, wanted) < 0.0 {
    n = (-n.0, -n.1, -n.2)
    (c, b)
  } else {
    (b, c)
  }
  let nn = v_norm(n)
  let ia = self.weld(a)
  let ib = self.weld(b2)
  let ic = self.weld(c2)
  self.indices.push(ia)
  self.indices.push(ib)
  self.indices.push(ic)
  self.normals[ia] = v_add(self.normals[ia], nn)
  self.normals[ib] = v_add(self.normals[ib], nn)
  self.normals[ic] = v_add(self.normals[ic], nn)
}

///|
fn MeshBuilder::finalize(self : MeshBuilder) -> (Array[Vec3], Array[Vec3]) {
  let normals = self.normals.map(fn(n) {
    let len = v_len(n)
    if len < 1.0e-12 {
      (0.0, 0.0, 1.0)
    } else {
      (n.0 / len, n.1 / len, n.2 / len)
    }
  })
  (self.verts, normals)
}

///|
// Small 3D vector helpers (v_sub/v_cross/v_dot/v_len/v_norm come from
// geometry_helpers.mbt).
fn v_add(a : Vec3, b : Vec3) -> Vec3 {
  (a.0 + b.0, a.1 + b.1, a.2 + b.2)
}

///|
fn v_scale(a : Vec3, s : Double) -> Vec3 {
  (a.0 * s, a.1 * s, a.2 * s)
}

///|
// Small 2D helpers for planar triangulation.
fn sub2(a : Vec2, b : Vec2) -> Vec2 {
  (a.0 - b.0, a.1 - b.1)
}

///|
fn cross2(a : Vec2, b : Vec2) -> Double {
  a.0 * b.1 - a.1 * b.0
}

///|
fn dist2(a : Vec2, b : Vec2) -> Double {
  let dx = a.0 - b.0
  let dy = a.1 - b.1
  dx * dx + dy * dy
}

///|
// Repository access helpers.
fn repo_get(ctx : StepContext, r : @step.Ref) -> @repository.AnyEntity {
  match ctx.entities.get(r.id.value) {
    Some(e) => e
    None => abort("glTF exporter: dangling entity reference #\{r.id.value}")
  }
}

///|
fn parse_num(s : String) -> Double {
  @string.parse_double(s) catch {
    _ => 0.0
  }
}

///|
fn point_of(ctx : StepContext, r : @step.Ref) -> Vec3 {
  match repo_get(ctx, r) {
    CartesianPoint(p) => {
      let c = p.coordinates
      (c[0], c[1], c[2])
    }
    _ => abort("glTF exporter: expected CARTESIAN_POINT")
  }
}

///|
fn direction_of(ctx : StepContext, r : @step.Ref) -> Vec3 {
  match repo_get(ctx, r) {
    Direction(d) => {
      let c = d.ratios
      (c[0], c[1], c[2])
    }
    _ => abort("glTF exporter: expected DIRECTION")
  }
}

///|
/// axis2_frame returns (origin, normalized axis, normalized ref direction).
fn axis2_frame(ctx : StepContext, r : @step.Ref) -> (Vec3, Vec3, Vec3) {
  match repo_get(ctx, r) {
    Axis2Placement3D(a) => {
      let o = point_of(ctx, a.location)
      let z = v_norm(direction_of(ctx, a.axis))
      let x = v_norm(direction_of(ctx, a.ref_direction))
      (o, z, x)
    }
    _ => abort("glTF exporter: expected AXIS2_PLACEMENT_3D")
  }
}

///|
fn vertex_pos(ctx : StepContext, r : @step.Ref) -> Vec3 {
  match repo_get(ctx, r) {
    VertexPoint(v) => point_of(ctx, v.vertex_geometry)
    _ => abort("glTF exporter: expected VERTEX_POINT")
  }
}

///|
/// clean_collinear_closed drops consecutive duplicates and interior collinear
/// vertices from a closed loop, so that a band sampled from the same curve and
/// the corresponding cap end up with identical boundary edges.
fn clean_collinear_closed(pts : Array[Vec3]) -> Array[Vec3] {
  let deduped : Array[Vec3] = []
  for p in pts {
    let dup = match deduped.last() {
      Some(q) => v_len(v_sub(p, q)) <= 1.0e-12
      None => false
    }
    if !dup {
      deduped.push(p)
    }
  }
  let mut out = deduped
  let mut changed = true
  while changed && out.length() > 3 {
    changed = false
    let n = out.length()
    let keep : Array[Vec3] = []
    for i in 0.. 0.0 &&
        lb > 0.0 &&
        v_len(v_cross(a, b)) / (la * lb) < 1.0e-9
      if collinear && v_dot(a, b) > 0.0 {
        changed = true
      } else {
        keep.push(cur)
      }
    }
    out = keep
  }
  out
}

///|
/// expand_knots materializes the full knot vector from multiplicities.
fn expand_knots(mults : Array[Int32], knots : Array[String]) -> Array[Double] {
  let u : Array[Double] = []
  for i in 0.. Vec3 {
  let n = pts.length()
  let mut k = p
  for i in p..= r {
      let denom = u[j + 1 + k - r] - u[j + k - p]
      let alpha = if denom == 0.0 { 0.0 } else { (t - u[j + k - p]) / denom }
      d[j] = v_add(v_scale(d[j - 1], 1.0 - alpha), v_scale(d[j], alpha))
      j = j - 1
    }
  }
  d[p]
}

///|
fn sample_bspline(
  ctx : StepContext,
  b : @geometry.BSplineCurveWithKnots,
  closed~ : Bool,
  along~ : Bool,
) -> Array[Vec3] {
  let p = b.degree
  let pts = b.control_points.map(fn(r) { point_of(ctx, r) })
  let u = expand_knots(b.knot_multiplicities, b.knots)
  let n = pts.length()
  if n < p + 1 || u.length() < n + p + 1 {
    return []
  }
  let t0 = u[p]
  let t1 = u[n]
  let m = gltf_segments
  let out : Array[Vec3] = []
  if closed {
    for i in 0.. Array[Vec3] {
  let (o, a, x) = axis2_frame(ctx, pos_ref)
  let y = v_norm(v_cross(a, x))
  let angle = fn(p : Vec3) -> Double {
    let rel = v_sub(p, o)
    @math.atan2(v_dot(rel, y), v_dot(rel, x))
  }
  let point = fn(theta : Double) -> Vec3 {
    v_add(
      o,
      v_add(
        v_scale(x, rx * @math.cos(theta)),
        v_scale(y, ry * @math.sin(theta)),
      ),
    )
  }
  let m = gltf_segments
  let tau = 2.0 * @math.PI
  let out : Array[Vec3] = []
  if closed {
    let t0 = angle(start_pos)
    for i in 0..= 0.0 {
      delta = delta - tau
    }
    for i in 0..<=m {
      out.push(point(ts + delta * i.to_double() / m.to_double()))
    }
    out
  }
}

///|
/// sample_oriented_edge samples one oriented edge into ordered 3D points.
fn sample_oriented_edge(ctx : StepContext, oe_ref : @step.Ref) -> Array[Vec3] {
  let oe = match repo_get(ctx, oe_ref) {
    OrientedEdge(o) => o
    _ => abort("glTF exporter: expected ORIENTED_EDGE")
  }
  let ec = match repo_get(ctx, oe.edge_element) {
    EdgeCurve(e) => e
    _ => abort("glTF exporter: expected EDGE_CURVE")
  }
  let start = vertex_pos(ctx, ec.edge_start)
  let end = vertex_pos(ctx, ec.edge_end)
  let closed = ec.edge_start.id.value == ec.edge_end.id.value
  let along = oe.orientation == ec.same_sense
  match repo_get(ctx, ec.edge_geometry) {
    Line(_) => if oe.orientation { [start, end] } else { [end, start] }
    Circle(c) => {
      let r = parse_num(c.radius_literal)
      sample_circle(ctx, c.position, r, r, start, end, closed~, along~)
    }
    Ellipse(e) => {
      let rx = parse_num(e.semi_axis_1_literal)
      let ry = parse_num(e.semi_axis_2_literal)
      sample_circle(ctx, e.position, rx, ry, start, end, closed~, along~)
    }
    BSplineCurveWithKnots(b) => sample_bspline(ctx, b, closed~, along~)
    Polyline(p) => {
      let pts = p.points.map(fn(r) { point_of(ctx, r) })
      if along {
        pts
      } else {
        pts.rev()
      }
    }
    _ => abort("glTF exporter: unsupported edge curve")
  }
}

///|
/// sample_loop concatenates a bound's oriented edges into one closed loop.
fn sample_loop(
  ctx : StepContext,
  loop_ref : @step.Ref,
  orientation : Bool,
) -> Array[Vec3] {
  let el = match repo_get(ctx, loop_ref) {
    EdgeLoop(e) => e
    _ => abort("glTF exporter: expected EDGE_LOOP")
  }
  let pts : Array[Vec3] = []
  for oe_ref in el.edges {
    for p in sample_oriented_edge(ctx, oe_ref) {
      let dup = match pts.last() {
        Some(q) => v_len(v_sub(p, q)) <= 1.0e-12
        None => false
      }
      if !dup {
        pts.push(p)
      }
    }
  }
  if pts.length() > 1 && v_len(v_sub(pts[0], pts[pts.length() - 1])) <= 1.0e-12 {
    let _ = pts.pop()
  }
  if orientation {
    pts
  } else {
    pts.rev()
  }
}

///|
/// loop_area returns the signed area of an index loop in 2D.
fn loop_area(pts : Array[Vec2], idx : Array[Int32]) -> Double {
  let n = idx.length()
  let mut area = 0.0
  for i in 0.. Bool {
  let d1 = cross2(sub2(b, a), sub2(c, a))
  let d2 = cross2(sub2(b, a), sub2(d, a))
  let d3 = cross2(sub2(d, c), sub2(a, c))
  let d4 = cross2(sub2(d, c), sub2(b, c))
  ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) &&
  ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
}

///|
fn segment_clear(
  pts : Array[Vec2],
  a : Int32,
  b : Int32,
  poly : Array[Int32],
  holes : Array[Array[Int32]],
  current_hole : Array[Int32],
) -> Bool {
  let pa = pts[a]
  let pb = pts[b]
  let n = poly.length()
  for i in 0.. Bool {
  let eps = 1.0e-12
  let d1 = cross2(sub2(p, a), sub2(b, a))
  let d2 = cross2(sub2(p, b), sub2(c, b))
  let d3 = cross2(sub2(p, c), sub2(a, c))
  let has_neg = d1 < -eps || d2 < -eps || d3 < -eps
  let has_pos = d1 > eps || d2 > eps || d3 > eps
  !(has_neg && has_pos)
}

///|
fn is_ear(
  pts : Array[Vec2],
  poly : Array[Int32],
  prev : Int32,
  cur : Int32,
  next : Int32,
) -> Bool {
  let a = pts[prev]
  let b = pts[cur]
  let c = pts[next]
  if cross2(sub2(b, a), sub2(c, b)) <= 1.0e-12 {
    return false
  }
  for v in poly {
    if v == prev || v == cur || v == next {
      continue
    }
    if point_in_tri(pts[v], a, b, c) {
      return false
    }
  }
  true
}

///|
fn ear_clip(
  pts : Array[Vec2],
  poly_in : Array[Int32],
) -> Array[(Int32, Int32, Int32)] {
  let mut poly = poly_in.copy()
  if loop_area(pts, poly) < 0.0 {
    poly = poly.rev()
  }
  let out : Array[(Int32, Int32, Int32)] = []
  let limit = poly.length() * poly.length() + 10
  let mut steps = 0
  while poly.length() > 3 && steps < limit {
    steps = steps + 1
    let n = poly.length()
    let mut removed = false
    for i in 0.. 3 {
    for i in 1..<(poly.length() - 1) {
      out.push((poly[0], poly[i], poly[i + 1]))
    }
  }
  out
}

///|
/// triangulate_face_2d triangulates an outer loop with holes. Holes are bridged
/// into the outer loop (keyhole), then the resulting simple polygon is ear
/// clipped. Returned indices refer to `pts`.
fn triangulate_face_2d(
  pts : Array[Vec2],
  outer_in : Array[Int32],
  holes_in : Array[Array[Int32]],
) -> Array[(Int32, Int32, Int32)] {
  let mut outer = outer_in.copy()
  if loop_area(pts, outer) < 0.0 {
    outer = outer.rev()
  }
  let mut poly = outer
  for hole_in in holes_in {
    let mut hole = hole_in.copy()
    if hole.length() < 3 {
      continue
    }
    if loop_area(pts, hole) > 0.0 {
      hole = hole.rev()
    }
    let mut hi = 0
    for i in 1.. pts[hole[hi]].0 {
        hi = i
      }
    }
    let hv = hole[hi]
    let mut best = 0
    let mut best_d = 1.0e300
    for j in 0.. Unit {
  let (o, surface_n, x_surf) = axis2_frame(ctx, plane.position)
  let face_n = if af.same_sense { surface_n } else { v_scale(surface_n, -1.0) }
  let u = v_norm(x_surf)
  let v = v_norm(v_cross(face_n, u))
  let pts3d : Array[Vec3] = []
  let pts2d : Array[Vec2] = []
  let mut outer : Array[Int32] = []
  let holes : Array[Array[Int32]] = []
  for bound_ref in af.bounds {
    let (loop_ref, orientation, is_outer) = match repo_get(ctx, bound_ref) {
      FaceOuterBound(b) => (b.bound, b.orientation, true)
      FaceBound(b) => (b.bound, b.orientation, false)
      _ => abort("glTF exporter: expected FACE_BOUND")
    }
    let loop_pts = sample_loop(ctx, loop_ref, orientation)
    if loop_pts.length() < 3 {
      continue
    }
    let idx : Array[Int32] = []
    for p in loop_pts {
      let base = pts3d.length()
      pts3d.push(p)
      let rel = v_sub(p, o)
      pts2d.push((v_dot(rel, u), v_dot(rel, v)))
      idx.push(base)
    }
    if is_outer {
      outer = idx
    } else {
      holes.push(idx)
    }
  }
  if outer.length() < 3 {
    return
  }
  for tri in triangulate_face_2d(pts2d, outer, holes) {
    let (i0, i1, i2) = tri
    mb.add_triangle(pts3d[i0], pts3d[i1], pts3d[i2], face_n)
  }
}

///|
fn tessellate_cylinder(
  ctx : StepContext,
  af : @topology.AdvancedFace,
  cs : @geometry.CylindricalSurface,
  mb : MeshBuilder,
) -> Unit {
  let (o, a, x) = axis2_frame(ctx, cs.position)
  let y = v_norm(v_cross(a, x))
  let r = parse_num(cs.radius_literal)
  let mut s0 = 1.0e300
  let mut s1 = -1.0e300
  for bound_ref in af.bounds {
    let (loop_ref, orientation) = match repo_get(ctx, bound_ref) {
      FaceOuterBound(b) => (b.bound, b.orientation)
      FaceBound(b) => (b.bound, b.orientation)
      _ => abort("glTF exporter: expected FACE_BOUND")
    }
    for p in sample_loop(ctx, loop_ref, orientation) {
      let s = v_dot(v_sub(p, o), a)
      if s < s0 {
        s0 = s
      }
      if s > s1 {
        s1 = s
      }
    }
  }
  if s1 <= s0 {
    return
  }
  let nseg = gltf_segments
  let bottom : Array[Vec3] = []
  let top : Array[Vec3] = []
  for i in 0.. Array[Vec3] {
  match repo_get(ctx, curve_ref) {
    BSplineCurveWithKnots(b) => sample_bspline(ctx, b, closed=true, along=true)
    Circle(c) => {
      let r = parse_num(c.radius_literal)
      let (o, a, x) = axis2_frame(ctx, c.position)
      let y = v_norm(v_cross(a, x))
      let m = gltf_segments
      let out : Array[Vec3] = []
      for i in 0.. {
      let pts = p.points.map(fn(r) { point_of(ctx, r) })
      if pts.length() > 1 &&
        v_len(v_sub(pts[0], pts[pts.length() - 1])) <= 1.0e-12 {
        let _ = pts.pop()
      }
      pts
    }
    _ => abort("glTF exporter: unsupported swept curve")
  }
}

///|
fn tessellate_extrusion(
  ctx : StepContext,
  af : @topology.AdvancedFace,
  s : @geometry.SurfaceOfLinearExtrusion,
  mb : MeshBuilder,
) -> Unit {
  let (direction, magnitude) = match repo_get(ctx, s.extrusion_axis) {
    Vector(v) => (v_norm(direction_of(ctx, v.orientation)), v.magnitude)
    _ => abort("glTF exporter: expected VECTOR")
  }
  let vec = v_scale(direction, magnitude)
  let base = sample_closed_curve(ctx, s.swept_curve)
  let n = base.length()
  if n < 3 {
    return
  }
  let sign = if af.same_sense { 1.0 } else { -1.0 }
  for i in 0.. Unit {
  let af = match repo_get(ctx, face_ref) {
    AdvancedFace(a) => a
    _ => abort("glTF exporter: expected ADVANCED_FACE")
  }
  match repo_get(ctx, af.surface) {
    Plane(p) => tessellate_plane(ctx, af, p, mb)
    CylindricalSurface(c) => tessellate_cylinder(ctx, af, c, mb)
    SurfaceOfLinearExtrusion(s) => tessellate_extrusion(ctx, af, s, mb)
    _ =>
      abort(
        "glTF exporter: unsupported face surface for '\{af.name}'; only PLANE, CYLINDRICAL_SURFACE, and SURFACE_OF_LINEAR_EXTRUSION are tessellated",
      )
  }
}

///|
/// collect_faces_for_node compiles one top-level node exactly as
/// `Design::compile` does and returns its ADVANCED_FACE references.
fn collect_faces_for_node(
  ctx : StepContext,
  node : SceneNode,
) -> Array[@step.Ref] {
  match node.shape {
    Union(u) => {
      if u.nodes.length() == 0 {
        return []
      }
      let parent = Transform::from_node(node)
      if u.nodes.length() == 1 {
        return compile_node_shape(
          ctx,
          { ..u.nodes[0], name: node.name, },
          parent=Some(parent),
        )
      }
      let faces : Array[@step.Ref] = []
      for child in u.nodes {
        faces.push_iter(
          compile_node_shape(ctx, child, parent=Some(parent)).iter(),
        )
      }
      faces
    }
    Difference(d) => {
      let parent = Transform::from_node(node)
      if d.cutters.length() == 0 {
        return compile_node_shape(
          ctx,
          { ..d.base, name: node.name, },
          parent=Some(parent),
        )
      }
      let faces : Array[@step.Ref] = []
      faces.push_iter(
        compile_node_shape(ctx, d.base, parent=Some(parent)).iter(),
      )
      for cutter in d.cutters {
        faces.push_iter(
          compile_node_shape(ctx, cutter, reverse=true, parent=Some(parent)).iter(),
        )
      }
      faces
    }
    _ => compile_node_shape(ctx, node)
  }
}

///|
/// Design::to_gltf serializes the design as a self-contained glTF 2.0 JSON
/// document with an embedded base64 buffer, one mesh per `SceneNode`.
///
/// Coordinates are Z-up (STEP convention). Faces that are not PLANE,
/// CYLINDRICAL_SURFACE, or SURFACE_OF_LINEAR_EXTRUSION are rejected rather
/// than emitted as an invalid mesh.
pub fn Design::to_gltf(self : Design) -> String {
  let ctx = StepContext::new(snapping?=self.snapping)
  ctx.init_standard_context(self.name)
  let prims : Array[GltfPrim] = []
  for node in self.nodes {
    let mb = MeshBuilder::new()
    for face_ref in collect_faces_for_node(ctx, node) {
      tessellate_face(ctx, face_ref, mb)
    }
    if mb.indices.is_empty() {
      continue
    }
    let (positions, normals) = mb.finalize()
    prims.push({
      name: node.name,
      color: node.color,
      positions,
      normals,
      indices: mb.indices,
    })
  }
  build_gltf(prims)
}

///|
/// Design::to_gltf_file writes the design's glTF document to `out_path`.
pub async fn Design::to_gltf_file(self : Design, out_path : String?) -> Unit {
  write_text_file(self.to_gltf(), out_path)
}

///|
fn json_int(n : Int32) -> Json {
  Json::number(n.to_double(), repr=n.to_string())
}

///|
fn json_num(n : Double) -> Json {
  Json::number(n)
}

///|
fn json_str(s : String) -> Json {
  Json::string(s)
}

///|
fn json_obj(pairs : Array[(String, Json)]) -> Json {
  let m : Map[String, Json] = Map([])
  for p in pairs {
    m.set(p.0, p.1)
  }
  Json::object(m)
}

///|
fn vec_bounds(pts : Array[Vec3]) -> (Vec3, Vec3) {
  let mut mn = (1.0e300, 1.0e300, 1.0e300)
  let mut mx = (-1.0e300, -1.0e300, -1.0e300)
  for p in pts {
    if p.0 < mn.0 {
      mn = (p.0, mn.1, mn.2)
    }
    if p.1 < mn.1 {
      mn = (mn.0, p.1, mn.2)
    }
    if p.2 < mn.2 {
      mn = (mn.0, mn.1, p.2)
    }
    if p.0 > mx.0 {
      mx = (p.0, mx.1, mx.2)
    }
    if p.1 > mx.1 {
      mx = (mx.0, p.1, mx.2)
    }
    if p.2 > mx.2 {
      mx = (mx.0, mx.1, p.2)
    }
  }
  (mn, mx)
}

///|
fn pad_to_4(buf : @buffer.Buffer) -> Unit {
  while buf.length() % 4 != 0 {
    buf.write_byte(b'\x00')
  }
}

///|
fn buffer_view_json(off : Int32, len : Int32, target : Int32) -> Json {
  json_obj([
    ("buffer", json_int(0)),
    ("byteOffset", json_int(off)),
    ("byteLength", json_int(len)),
    ("target", json_int(target)),
  ])
}

///|
fn accessor_json(
  bv : Int32,
  component_type : Int32,
  count : Int32,
  ty : String,
  bounds : (Vec3, Vec3)?,
) -> Json {
  let pairs : Array[(String, Json)] = [
    ("bufferView", json_int(bv)),
    ("byteOffset", json_int(0)),
    ("componentType", json_int(component_type)),
    ("count", json_int(count)),
    ("type", json_str(ty)),
  ]
  match bounds {
    Some((mn, mx)) => {
      pairs.push(
        ("min", Json::array([json_num(mn.0), json_num(mn.1), json_num(mn.2)])),
      )
      pairs.push(
        ("max", Json::array([json_num(mx.0), json_num(mx.1), json_num(mx.2)])),
      )
    }
    None => ()
  }
  json_obj(pairs)
}

///|
fn build_gltf(prims : Array[GltfPrim]) -> String {
  let empty : Array[Byte] = []
  let buf = @buffer.from_array(empty)
  let buffer_views : Array[Json] = []
  let accessors : Array[Json] = []
  let meshes : Array[Json] = []
  let nodes : Array[Json] = []
  let materials : Array[Json] = []
  let scene_nodes : Array[Json] = []

  for pi in 0..