Skip to main content

ogeom_io/
mesh_formats.rs

1//! Tessellated interchange: glTF 2.0, OBJ and PLY.
2//!
3//! Three formats, one philosophy carried over from the DXF writer: the
4//! functions take bare tessellations rather than a model, so anything that
5//! produces a [`Triangulation`] (a solid, one face, a healed import)
6//! exports without this module knowing where it came from. glTF is written
7//! as GLB, the single-file binary form, with positions, normals, indices
8//! and an optional base colour per mesh; OBJ and PLY are the plain-text
9//! dialects every downstream tool reads.
10//!
11//! # Reading is not writing backwards
12//!
13//! What this module writes is one of many shapes each format allows, and a
14//! reader that assumed its own writer's choices would read almost nothing.
15//! glTF is the sharp case: its geometry reaches the file through accessors
16//! over buffer views over buffers, any of which may stride, offset, use any
17//! of six component types, scale integers into fractions, or be overridden
18//! piecewise by a sparse block, and the whole is placed by a node hierarchy
19//! stated as a matrix or as translation, rotation and scale. All of that is
20//! read. What is not read is refused by name rather than approximated.
21
22use ogeom_core::ogeom_bail;
23use ogeom_math::{Point, Vector};
24use ogeom_topo::Triangulation;
25use std::fmt::Write as _;
26
27/// One mesh to export, with what the format can say about it.
28#[derive(Debug, Clone)]
29pub struct ExportMesh<'a> {
30    /// The tessellation.
31    pub mesh: &'a Triangulation,
32    /// An RGBA base colour in `[0, 1]`, where the format carries one.
33    pub colour: Option<[f64; 4]>,
34    /// A name, where the format carries one.
35    pub name: Option<String>,
36}
37
38impl<'a> ExportMesh<'a> {
39    /// A bare mesh, no colour, no name.
40    #[must_use]
41    pub fn plain(mesh: &'a Triangulation) -> Self {
42        Self {
43            mesh,
44            colour: None,
45            name: None,
46        }
47    }
48}
49
50// --- glTF 2.0 (GLB) ----------------------------------------------------------
51
52/// Write meshes as a GLB: glTF 2.0's single-file binary form.
53///
54/// One buffer, one node per mesh under one scene; positions and normals as
55/// `f32` vectors, indices as `u32`, and a metallic-roughness material with
56/// the base colour where one was given. Empty meshes are skipped; a node
57/// with nothing to draw is not something a viewer should be handed.
58#[must_use]
59pub fn write_glb(meshes: &[ExportMesh<'_>]) -> Vec<u8> {
60    let mut binary: Vec<u8> = Vec::new();
61    let mut accessors = String::new();
62    let mut buffer_views = String::new();
63    let mut mesh_json = String::new();
64    let mut node_json = String::new();
65    let mut material_json = String::new();
66    let mut accessor_count = 0_usize;
67    let mut view_count = 0_usize;
68    let mut mesh_count = 0_usize;
69    let mut material_count = 0_usize;
70
71    for export in meshes {
72        let mesh = export.mesh;
73        if mesh.triangles.is_empty() || mesh.positions.is_empty() {
74            continue;
75        }
76        let comma = |s: &mut String| {
77            if !s.is_empty() {
78                s.push(',');
79            }
80        };
81
82        // Positions.
83        let position_offset = binary.len();
84        let (mut low, mut high) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
85        for p in &mesh.positions {
86            for (k, v) in [p.x, p.y, p.z].into_iter().enumerate() {
87                low[k] = low[k].min(v);
88                high[k] = high[k].max(v);
89                #[allow(clippy::cast_possible_truncation)]
90                binary.extend_from_slice(&(v as f32).to_le_bytes());
91            }
92        }
93        comma(&mut buffer_views);
94        let _ = write!(
95            buffer_views,
96            r#"{{"buffer":0,"byteOffset":{position_offset},"byteLength":{}}}"#,
97            binary.len() - position_offset
98        );
99        let position_view = view_count;
100        view_count += 1;
101        comma(&mut accessors);
102        #[allow(clippy::cast_possible_truncation)]
103        let (lo, hi) = (low.map(|v| v as f32), high.map(|v| v as f32));
104        let _ = write!(
105            accessors,
106            r#"{{"bufferView":{position_view},"componentType":5126,"count":{},"type":"VEC3","min":[{},{},{}],"max":[{},{},{}]}}"#,
107            mesh.positions.len(),
108            lo[0],
109            lo[1],
110            lo[2],
111            hi[0],
112            hi[1],
113            hi[2],
114        );
115        let position_accessor = accessor_count;
116        accessor_count += 1;
117
118        // Normals, normalized as glTF requires.
119        let normal_offset = binary.len();
120        for n in &mesh.normals {
121            let magnitude = n.magnitude();
122            let unit = if magnitude > 0.0 {
123                [n.x / magnitude, n.y / magnitude, n.z / magnitude]
124            } else {
125                [0.0, 0.0, 1.0]
126            };
127            for v in unit {
128                #[allow(clippy::cast_possible_truncation)]
129                binary.extend_from_slice(&(v as f32).to_le_bytes());
130            }
131        }
132        comma(&mut buffer_views);
133        let _ = write!(
134            buffer_views,
135            r#"{{"buffer":0,"byteOffset":{normal_offset},"byteLength":{}}}"#,
136            binary.len() - normal_offset
137        );
138        let normal_view = view_count;
139        view_count += 1;
140        comma(&mut accessors);
141        let _ = write!(
142            accessors,
143            r#"{{"bufferView":{normal_view},"componentType":5126,"count":{},"type":"VEC3"}}"#,
144            mesh.normals.len(),
145        );
146        let normal_accessor = accessor_count;
147        accessor_count += 1;
148
149        // Indices.
150        let index_offset = binary.len();
151        for t in &mesh.triangles {
152            for &k in t {
153                binary.extend_from_slice(&k.to_le_bytes());
154            }
155        }
156        comma(&mut buffer_views);
157        let _ = write!(
158            buffer_views,
159            r#"{{"buffer":0,"byteOffset":{index_offset},"byteLength":{}}}"#,
160            binary.len() - index_offset
161        );
162        let index_view = view_count;
163        view_count += 1;
164        comma(&mut accessors);
165        let _ = write!(
166            accessors,
167            r#"{{"bufferView":{index_view},"componentType":5125,"count":{},"type":"SCALAR"}}"#,
168            mesh.triangles.len() * 3,
169        );
170        let index_accessor = accessor_count;
171        accessor_count += 1;
172
173        // Material, when a colour was given.
174        let material = export.colour.map(|[r, g, b, a]| {
175            comma(&mut material_json);
176            let _ = write!(
177                material_json,
178                r#"{{"pbrMetallicRoughness":{{"baseColorFactor":[{r},{g},{b},{a}],"metallicFactor":0.1,"roughnessFactor":0.8}}}}"#,
179            );
180            material_count += 1;
181            material_count - 1
182        });
183
184        comma(&mut mesh_json);
185        let material_field = material.map_or(String::new(), |m| format!(r#","material":{m}"#));
186        let _ = write!(
187            mesh_json,
188            r#"{{"primitives":[{{"attributes":{{"POSITION":{position_accessor},"NORMAL":{normal_accessor}}},"indices":{index_accessor}{material_field}}}]}}"#,
189        );
190        comma(&mut node_json);
191        let name_field = export.name.as_ref().map_or(String::new(), |n| {
192            format!(r#","name":"{}""#, escape_json(n))
193        });
194        let _ = write!(node_json, r#"{{"mesh":{mesh_count}{name_field}}}"#);
195        mesh_count += 1;
196    }
197
198    let node_indices: Vec<String> = (0..mesh_count).map(|i| i.to_string()).collect();
199    let json = format!(
200        r#"{{"asset":{{"version":"2.0","generator":"ogeom"}},"scene":0,"scenes":[{{"nodes":[{}]}}],"nodes":[{node_json}],"meshes":[{mesh_json}],"materials":[{material_json}],"accessors":[{accessors}],"bufferViews":[{buffer_views}],"buffers":[{{"byteLength":{}}}]}}"#,
201        node_indices.join(","),
202        binary.len(),
203    );
204    // A colour-free file carries no materials array worth having.
205    let json = json.replace(r#","materials":[],"#, ",");
206
207    // GLB framing: 4-byte alignment, JSON padded with spaces, binary with
208    // zeros.
209    let mut json_bytes = json.into_bytes();
210    while !json_bytes.len().is_multiple_of(4) {
211        json_bytes.push(b' ');
212    }
213    while !binary.len().is_multiple_of(4) {
214        binary.push(0);
215    }
216    let total = 12 + 8 + json_bytes.len() + 8 + binary.len();
217    let mut out = Vec::with_capacity(total);
218    out.extend_from_slice(&0x4654_6C67_u32.to_le_bytes()); // "glTF"
219    out.extend_from_slice(&2_u32.to_le_bytes());
220    out.extend_from_slice(&u32::try_from(total).unwrap_or(u32::MAX).to_le_bytes());
221    out.extend_from_slice(
222        &u32::try_from(json_bytes.len())
223            .unwrap_or(u32::MAX)
224            .to_le_bytes(),
225    );
226    out.extend_from_slice(&0x4E4F_534A_u32.to_le_bytes()); // "JSON"
227    out.extend_from_slice(&json_bytes);
228    out.extend_from_slice(
229        &u32::try_from(binary.len())
230            .unwrap_or(u32::MAX)
231            .to_le_bytes(),
232    );
233    out.extend_from_slice(&0x004E_4942_u32.to_le_bytes()); // "BIN\0"
234    out.extend_from_slice(&binary);
235    out
236}
237
238fn escape_json(s: &str) -> String {
239    s.chars()
240        .flat_map(|c| match c {
241            '"' => vec!['\\', '"'],
242            '\\' => vec!['\\', '\\'],
243            c if c.is_control() => format!("\\u{:04x}", c as u32).chars().collect(),
244            c => vec![c],
245        })
246        .collect()
247}
248
249// --- OBJ ---------------------------------------------------------------------
250
251/// Write meshes as Wavefront OBJ.
252///
253/// `v`/`vn`/`f` records, one `o` group per named mesh, indices one-based
254/// and shared across the file as the format demands. A mesh without one
255/// normal per vertex is written without `vn` records, its faces as plain
256/// vertex indices.
257#[must_use]
258pub fn write_obj(meshes: &[ExportMesh<'_>]) -> String {
259    let mut out = String::from("# ogeom\n");
260    let mut base = 1_usize;
261    let mut normal_base = 1_usize;
262    for (i, export) in meshes.iter().enumerate() {
263        let mesh = export.mesh;
264        if mesh.triangles.is_empty() {
265            continue;
266        }
267        let name = export.name.clone().unwrap_or_else(|| format!("mesh-{i}"));
268        let _ = writeln!(out, "o {name}");
269        for p in &mesh.positions {
270            let _ = writeln!(out, "v {} {} {}", p.x, p.y, p.z);
271        }
272        let normals = has_normals(mesh);
273        if normals {
274            for n in &mesh.normals {
275                let _ = writeln!(out, "vn {} {} {}", n.x, n.y, n.z);
276            }
277        }
278        for t in &mesh.triangles {
279            let [a, b, c] = t.map(|k| k as usize + base);
280            if normals {
281                let [na, nb, nc] = t.map(|k| k as usize + normal_base);
282                let _ = writeln!(out, "f {a}//{na} {b}//{nb} {c}//{nc}");
283            } else {
284                let _ = writeln!(out, "f {a} {b} {c}");
285            }
286        }
287        base += mesh.positions.len();
288        if normals {
289            normal_base += mesh.normals.len();
290        }
291    }
292    out
293}
294
295/// Whether a mesh carries one normal per vertex, as the writers need to
296/// write normals at all.
297fn has_normals(mesh: &Triangulation) -> bool {
298    !mesh.normals.is_empty() && mesh.normals.len() == mesh.positions.len()
299}
300
301// --- PLY ---------------------------------------------------------------------
302
303/// Write one mesh as ASCII PLY.
304///
305/// Vertices with their normals when the mesh has one per vertex, faces as
306/// index lists; a colour, when given, is carried per vertex as the `uchar`
307/// triple the format convention expects.
308#[must_use]
309pub fn write_ply(export: &ExportMesh<'_>) -> String {
310    let mesh = export.mesh;
311    let colour = export.colour.map(|[r, g, b, _]| {
312        [
313            (r.clamp(0.0, 1.0) * 255.0).round(),
314            (g.clamp(0.0, 1.0) * 255.0).round(),
315            (b.clamp(0.0, 1.0) * 255.0).round(),
316        ]
317    });
318    let mut out = String::from("ply\nformat ascii 1.0\ncomment ogeom\n");
319    let _ = writeln!(out, "element vertex {}", mesh.positions.len());
320    let normals = has_normals(mesh);
321    out.push_str("property float x\nproperty float y\nproperty float z\n");
322    if normals {
323        out.push_str("property float nx\nproperty float ny\nproperty float nz\n");
324    }
325    if colour.is_some() {
326        out.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
327    }
328    let _ = writeln!(out, "element face {}", mesh.triangles.len());
329    out.push_str("property list uchar uint vertex_indices\nend_header\n");
330    for (k, p) in mesh.positions.iter().enumerate() {
331        let _ = write!(out, "{} {} {}", p.x, p.y, p.z);
332        if normals {
333            let n = mesh.normals[k];
334            let _ = write!(out, " {} {} {}", n.x, n.y, n.z);
335        }
336        if let Some([r, g, b]) = colour {
337            let _ = write!(out, " {r} {g} {b}");
338        }
339        out.push('\n');
340    }
341    for t in &mesh.triangles {
342        let _ = writeln!(out, "3 {} {} {}", t[0], t[1], t[2]);
343    }
344    out
345}
346
347// --- reading -----------------------------------------------------------------
348
349pub use crate::obj::{ObjImport, read_obj, read_obj_file, read_obj_with_materials};
350pub use crate::ply::{PlyImport, read_ply_bytes, write_ply_binary};
351
352/// The colours a mesh read carries, in the most compact form that says
353/// exactly what the file said.
354///
355/// A file colours a mesh at the corners of its triangles, or at its
356/// vertices, or per face. Where every corner meeting at a vertex has the
357/// same colour, they are given per vertex; else, where each triangle's
358/// three corners agree, per triangle; else per corner.
359#[derive(Debug, Clone, PartialEq)]
360pub enum MeshColours {
361    /// One RGBA colour per vertex of the mesh.
362    PerVertex(Vec<[f64; 4]>),
363    /// One RGBA colour per triangle.
364    PerTriangle(Vec<[f64; 4]>),
365    /// Three RGBA colours per triangle, at its corners in order.
366    PerCorner(Vec<[[f64; 4]; 3]>),
367}
368
369impl MeshColours {
370    /// The colour at corner `corner` (0, 1 or 2) of triangle `triangle` of
371    /// `mesh`, whichever form the colours take; `None` past the end.
372    #[must_use]
373    pub fn at(&self, mesh: &Triangulation, triangle: usize, corner: usize) -> Option<[f64; 4]> {
374        match self {
375            Self::PerVertex(colours) => {
376                let vertex = *mesh.triangles.get(triangle)?.get(corner)?;
377                colours.get(vertex as usize).copied()
378            }
379            Self::PerTriangle(colours) => (corner < 3).then(|| colours.get(triangle).copied())?,
380            Self::PerCorner(colours) => colours.get(triangle)?.get(corner).copied(),
381        }
382    }
383
384    /// The compact form of colours given at every corner of `triangles`,
385    /// over a mesh of `vertex_count` vertices.
386    pub(crate) fn from_corners(
387        triangles: &[[u32; 3]],
388        vertex_count: usize,
389        corners: Vec<[[f64; 4]; 3]>,
390    ) -> Self {
391        let mut per_vertex: Vec<Option<[f64; 4]>> = vec![None; vertex_count];
392        let mut agree = true;
393        'corners: for (triangle, colours) in triangles.iter().zip(&corners) {
394            for (&v, colour) in triangle.iter().zip(colours) {
395                match per_vertex.get_mut(v as usize) {
396                    Some(slot @ None) => *slot = Some(*colour),
397                    Some(Some(held)) if held == colour => {}
398                    _ => {
399                        agree = false;
400                        break 'corners;
401                    }
402                }
403            }
404        }
405        if agree && let Some(vertex) = per_vertex.into_iter().collect::<Option<Vec<_>>>() {
406            return Self::PerVertex(vertex);
407        }
408        if corners.iter().all(|[a, b, c]| a == b && b == c) {
409            return Self::PerTriangle(corners.into_iter().map(|[a, _, _]| a).collect());
410        }
411        Self::PerCorner(corners)
412    }
413}
414
415/// Whether a normal slot is still waiting to be accumulated into.
416///
417/// A normal the file gave is a unit vector; a slot nobody has filled starts
418/// at zero and grows by the faces around it. Telling them apart by length is
419/// enough, and it means a file that gives *some* normals keeps them while
420/// the rest are worked out.
421pub(crate) fn held_is_unset(slot: &ogeom_math::Vector) -> bool {
422    !(0.999..=1.001).contains(&slot.magnitude())
423}
424
425/// Read a PLY into a triangulation.
426///
427/// The mesh of [`read_ply_bytes`], which reads every encoding: text, and
428/// binary of either byte order. A file without faces reads as its
429/// vertices alone.
430///
431/// # Errors
432///
433/// As [`read_ply_bytes`].
434pub fn read_ply(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
435    Ok(read_ply_bytes(text.as_bytes())?.mesh())
436}
437
438// --- VRML --------------------------------------------------------------------
439
440/// Write meshes as VRML 97.
441///
442/// One `Shape` per mesh, each an `IndexedFaceSet` over its own coordinates,
443/// with a material where a colour was given. Written rather than read: VRML
444/// is a scene-description language with a great deal in it that is not
445/// geometry, and a reader that took only the geometry would be claiming
446/// more than it did.
447#[must_use]
448pub fn write_vrml(meshes: &[ExportMesh<'_>]) -> String {
449    let mut out = String::from("#VRML V2.0 utf8\n\n");
450    for export in meshes {
451        if export.mesh.triangles.is_empty() {
452            continue;
453        }
454        if let Some(name) = &export.name {
455            let _ = writeln!(out, "# {name}");
456        }
457        out.push_str("Shape {\n");
458        if let Some([r, g, b, a]) = export.colour {
459            out.push_str("  appearance Appearance {\n    material Material {\n");
460            let _ = writeln!(out, "      diffuseColor {r} {g} {b}");
461            if a < 1.0 {
462                let _ = writeln!(out, "      transparency {}", 1.0 - a);
463            }
464            out.push_str("    }\n  }\n");
465        }
466        out.push_str("  geometry IndexedFaceSet {\n    coord Coordinate {\n      point [\n");
467        for p in &export.mesh.positions {
468            let _ = writeln!(out, "        {} {} {},", p.x, p.y, p.z);
469        }
470        out.push_str("      ]\n    }\n    coordIndex [\n");
471        for [a, b, c] in &export.mesh.triangles {
472            let _ = writeln!(out, "      {a} {b} {c} -1,");
473        }
474        out.push_str("    ]\n    solid TRUE\n  }\n}\n\n");
475    }
476    out
477}
478
479#[cfg(test)]
480#[allow(clippy::unwrap_used, clippy::expect_used)]
481mod tests {
482    use super::*;
483    use ogeom_core::Tolerances;
484    use ogeom_math::Frame;
485    use ogeom_topo::Model;
486
487    const T: Tolerances = Tolerances::millimetres();
488
489    fn box_mesh() -> Triangulation {
490        let mut model = Model::new();
491        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
492            .unwrap()
493            .shape;
494        ogeom_mesh::triangulate(&model, &solid, ogeom_mesh::Deflection::default(), T).unwrap()
495    }
496
497    /// A tetrahedron with no normals, as `boolean`, `hollow`, `thicken` and
498    /// `Editable::to_triangulation` give one.
499    fn bare_tetrahedron() -> Triangulation {
500        Triangulation {
501            positions: vec![
502                Point::new(0.0, 0.0, 0.0),
503                Point::new(1.0, 0.0, 0.0),
504                Point::new(0.0, 1.0, 0.0),
505                Point::new(0.0, 0.0, 1.0),
506            ],
507            normals: Vec::new(),
508            parameters: Vec::new(),
509            triangles: vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]],
510            deflection_met: true,
511        }
512    }
513
514    #[test]
515    fn a_mesh_without_normals_writes_every_vertex_to_ply_and_reads_back() {
516        let tri = bare_tetrahedron();
517        let text = write_ply(&ExportMesh::plain(&tri));
518        assert!(!text.contains("property float nx"));
519        let back = read_ply_bytes(text.as_bytes()).unwrap().mesh();
520        assert_eq!(back.positions, tri.positions);
521        assert_eq!(back.triangles, tri.triangles);
522    }
523
524    #[test]
525    fn a_mesh_without_normals_writes_plain_obj_faces_and_reads_back() {
526        let tri = bare_tetrahedron();
527        let text = write_obj(&[ExportMesh::plain(&tri)]);
528        assert!(!text.contains("vn ") && !text.contains("//"));
529        let back = read_obj(&text).unwrap();
530        assert_eq!(back.positions, tri.positions);
531        assert_eq!(back.triangles, tri.triangles);
532    }
533
534    #[test]
535    fn obj_faces_index_the_normals_of_their_own_mesh_after_a_bare_one() {
536        let bare = bare_tetrahedron();
537        let boxed = box_mesh();
538        let text = write_obj(&[ExportMesh::plain(&bare), ExportMesh::plain(&boxed)]);
539        // The box's first face names its first vertex after the four bare ones
540        // and its first normal, the file's first `vn`.
541        let first_box_face = text
542            .lines()
543            .filter(|l| l.starts_with("f "))
544            .nth(bare.triangles.len())
545            .unwrap();
546        let a = boxed.triangles[0][0] as usize;
547        assert!(first_box_face.starts_with(&format!("f {}//{} ", a + 5, a + 1)));
548        assert_eq!(
549            text.lines().filter(|l| l.starts_with("vn ")).count(),
550            boxed.normals.len()
551        );
552    }
553
554    #[test]
555    fn a_glb_frames_its_chunks_and_counts_its_geometry() {
556        let mesh = box_mesh();
557        let glb = write_glb(&[ExportMesh {
558            mesh: &mesh,
559            colour: Some([0.8, 0.2, 0.1, 1.0]),
560            name: Some("box".into()),
561        }]);
562        // Magic, version, and a total length that matches the file.
563        assert_eq!(&glb[0..4], b"glTF");
564        assert_eq!(u32::from_le_bytes(glb[4..8].try_into().unwrap()), 2);
565        assert_eq!(
566            u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize,
567            glb.len()
568        );
569        // The JSON chunk parses far enough to carry the structure.
570        let json_length = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
571        assert_eq!(&glb[16..20], b"JSON");
572        let json = core::str::from_utf8(&glb[20..20 + json_length]).unwrap();
573        assert!(json.contains(r#""version":"2.0""#));
574        assert!(json.contains(r#""POSITION":0"#));
575        assert!(json.contains(r#""indices":2"#));
576        assert!(json.contains("baseColorFactor"));
577        assert!(json.contains(r#""name":"box""#));
578        assert!(json.contains(r#""min":["#), "POSITION carries bounds");
579        // The binary chunk holds what the accessors promise: positions,
580        // normals, indices.
581        let expected =
582            mesh.positions.len() * 12 + mesh.normals.len() * 12 + mesh.triangles.len() * 12;
583        let padded = expected + (4 - expected % 4) % 4;
584        let binary_length =
585            u32::from_le_bytes(glb[20 + json_length..24 + json_length].try_into().unwrap());
586        assert_eq!(binary_length as usize, padded);
587    }
588
589    #[test]
590    fn an_obj_counts_its_records_and_shares_its_index_space() {
591        let mesh = box_mesh();
592        let one = ExportMesh::plain(&mesh);
593        let text = write_obj(&[one.clone(), one]);
594        assert_eq!(
595            text.lines().filter(|l| l.starts_with("v ")).count(),
596            mesh.positions.len() * 2
597        );
598        assert_eq!(
599            text.lines().filter(|l| l.starts_with("f ")).count(),
600            mesh.triangles.len() * 2
601        );
602        // The second mesh's faces reference the second mesh's vertices.
603        let last_face = text.lines().rev().find(|l| l.starts_with("f ")).unwrap();
604        let first_index: usize = last_face
605            .split_whitespace()
606            .nth(1)
607            .unwrap()
608            .split('/')
609            .next()
610            .unwrap()
611            .parse()
612            .unwrap();
613        assert!(first_index > mesh.positions.len());
614    }
615
616    #[test]
617    fn a_ply_declares_what_it_carries() {
618        let mesh = box_mesh();
619        let text = write_ply(&ExportMesh {
620            mesh: &mesh,
621            colour: Some([0.0, 0.5, 1.0, 1.0]),
622            name: None,
623        });
624        assert!(text.starts_with("ply\nformat ascii 1.0\n"));
625        assert!(text.contains(&format!("element vertex {}", mesh.positions.len())));
626        assert!(text.contains(&format!("element face {}", mesh.triangles.len())));
627        assert!(text.contains("property uchar red"));
628        let body_faces = text
629            .lines()
630            .skip_while(|l| *l != "end_header")
631            .filter(|l| l.starts_with("3 "))
632            .count();
633        assert_eq!(body_faces, mesh.triangles.len());
634    }
635}
636
637// --- glTF 2.0, reading -------------------------------------------------------
638
639/// One mesh read back out of a glTF document.
640///
641/// The owning counterpart of [`ExportMesh`]: what the file said, with the
642/// node transform that placed it already applied to the geometry, so the
643/// positions are where the scene puts them.
644#[derive(Debug, Clone)]
645pub struct ImportedMesh {
646    /// The tessellation, in scene coordinates.
647    pub mesh: Triangulation,
648    /// The base colour of the primitive's material, where it had one.
649    pub colour: Option<[f64; 4]>,
650    /// The node's name, where it had one.
651    pub name: Option<String>,
652}
653
654/// Read a GLB: glTF 2.0's single-file binary form.
655///
656/// The whole indirection is honoured, because a writer chooses it and a
657/// reader does not get to assume: a primitive names accessors, an accessor
658/// names a buffer view and a component type, a view names a buffer and may
659/// stride over it, and any of them may be replaced piecewise by a *sparse*
660/// block. Every component type the standard defines is read (signed and
661/// unsigned bytes, shorts, unsigned ints and floats), with `normalized`
662/// honoured where it is set, which is the difference between a colour of
663/// `255` and a colour of `1`.
664///
665/// The scene's node hierarchy is walked and each node's transform composed,
666/// stated as a matrix or as translation, rotation and scale; the geometry
667/// comes back placed. Normals are carried through the transform's inverse
668/// transpose, which is what keeps them normal to a scaled surface.
669///
670/// What is *not* read is refused rather than approximated: a primitive whose
671/// mode is not triangles, a buffer that points at an external file this
672/// function cannot open, and a `KHR_draco_mesh_compression` payload, each by
673/// name.
674///
675/// # Errors
676///
677/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) for a
678/// file whose framing, JSON or indices do not hold together, and for the
679/// refusals above.
680pub fn read_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
681    let (json, binary) = split_glb(bytes)?;
682    let document = crate::json::parse(&json)?;
683    read_gltf_document(&document, binary.as_deref())
684}
685
686/// Read a `.gltf`: the JSON form, whose buffers are data URIs.
687///
688/// A buffer with an external `uri` is refused by name: this function is handed
689/// bytes, not a directory, and quietly producing a mesh with no positions
690/// would be worse than saying so.
691///
692/// # Errors
693///
694/// As [`read_glb`].
695pub fn read_gltf(text: &str) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
696    let document = crate::json::parse(text)?;
697    read_gltf_document(&document, None)
698}
699
700/// The JSON chunk and the binary chunk of a GLB.
701fn split_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<(String, Option<Vec<u8>>)> {
702    let word = |at: usize| -> ogeom_core::OgeomResult<u32> {
703        let Some(slice) = bytes.get(at..at + 4) else {
704            ogeom_bail!(Construction, "the file ends inside its own header");
705        };
706        Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
707    };
708    if word(0)? != 0x4654_6C67 {
709        ogeom_bail!(
710            Construction,
711            "this is not a GLB: the first four bytes are not `glTF`"
712        );
713    }
714    let version = word(4)?;
715    if version != 2 {
716        ogeom_bail!(Construction, "GLB version {version} is not glTF 2.0");
717    }
718    let mut json = None;
719    let mut binary = None;
720    let mut at = 12;
721    while at + 8 <= bytes.len() {
722        let length = word(at)? as usize;
723        let kind = word(at + 4)?;
724        let start = at + 8;
725        let Some(chunk) = bytes.get(start..start + length) else {
726            ogeom_bail!(
727                Construction,
728                "a chunk claims {length} bytes it does not have"
729            );
730        };
731        match kind {
732            0x4E4F_534A => {
733                let Ok(text) = core::str::from_utf8(chunk) else {
734                    ogeom_bail!(Construction, "the JSON chunk is not UTF-8");
735                };
736                json = Some(text.trim_end_matches(['\0', ' ']).to_owned());
737            }
738            0x004E_4942 => binary = Some(chunk.to_vec()),
739            // The standard says an unknown chunk is skipped, not refused.
740            _ => {}
741        }
742        at = start + length.next_multiple_of(4);
743    }
744    let Some(json) = json else {
745        ogeom_bail!(Construction, "the GLB carries no JSON chunk");
746    };
747    Ok((json, binary))
748}
749
750/// One buffer's bytes, from the binary chunk or a data URI.
751fn buffers(
752    document: &crate::json::Json,
753    binary: Option<&[u8]>,
754) -> ogeom_core::OgeomResult<Vec<Vec<u8>>> {
755    let mut out = Vec::new();
756    for buffer in document.get("buffers").map(|b| b.items()).unwrap_or(&[]) {
757        match buffer.get("uri").and_then(crate::json::Json::text) {
758            None => {
759                // The GLB's own binary chunk: only the first buffer may take
760                // it, which is what "the buffer with no uri" means.
761                let Some(chunk) = binary else {
762                    ogeom_bail!(
763                        Construction,
764                        "a buffer with no uri wants the GLB's binary chunk, and \
765                         there is none"
766                    );
767                };
768                out.push(chunk.to_vec());
769            }
770            Some(uri) if uri.starts_with("data:") => {
771                let Some((_, payload)) = uri.split_once("base64,") else {
772                    ogeom_bail!(
773                        Construction,
774                        "a data uri that is not base64 is not something this reads"
775                    );
776                };
777                out.push(from_base64(payload)?);
778            }
779            Some(uri) => ogeom_bail!(
780                Construction,
781                "the buffer points at `{uri}`, an external file this reader is \
782                 not given; hand it a GLB or a document with data uris"
783            ),
784        }
785    }
786    Ok(out)
787}
788
789/// Standard base64, padding tolerated and whitespace ignored.
790fn from_base64(text: &str) -> ogeom_core::OgeomResult<Vec<u8>> {
791    let value = |c: u8| -> Option<u32> {
792        match c {
793            b'A'..=b'Z' => Some(u32::from(c - b'A')),
794            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
795            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
796            b'+' => Some(62),
797            b'/' => Some(63),
798            _ => None,
799        }
800    };
801    let mut out = Vec::with_capacity(text.len() / 4 * 3);
802    let mut acc = 0_u32;
803    let mut held = 0_u32;
804    for c in text.bytes() {
805        if c == b'=' || c.is_ascii_whitespace() {
806            continue;
807        }
808        let Some(v) = value(c) else {
809            ogeom_bail!(Construction, "`{}` is not base64", c as char);
810        };
811        acc = (acc << 6) | v;
812        held += 6;
813        if held >= 8 {
814            held -= 8;
815            #[allow(clippy::cast_possible_truncation, reason = "masked to a byte")]
816            out.push(((acc >> held) & 0xFF) as u8);
817        }
818    }
819    Ok(out)
820}
821
822/// The whole document: buffers, then every mesh the scene's nodes place.
823fn read_gltf_document(
824    document: &crate::json::Json,
825    binary: Option<&[u8]>,
826) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
827    use crate::json::Json;
828    let buffers = buffers(document, binary)?;
829    let views = document.get("bufferViews").map_or(&[][..], Json::items);
830    let accessors = document.get("accessors").map_or(&[][..], Json::items);
831    let meshes = document.get("meshes").map_or(&[][..], Json::items);
832    let nodes = document.get("nodes").map_or(&[][..], Json::items);
833    let materials = document.get("materials").map_or(&[][..], Json::items);
834
835    // The nodes the scene names, or (for a document with no scene at all)
836    // every node, which is what a reader can honestly do with one.
837    let scene = document
838        .index_at("scene")
839        .and_then(|i| document.get("scenes")?.items().get(i))
840        .and_then(|s| s.get("nodes"))
841        .map(Json::items);
842    let roots: Vec<usize> = match scene {
843        Some(list) => list.iter().filter_map(Json::index).collect(),
844        None => (0..nodes.len()).collect(),
845    };
846
847    let mut out = Vec::new();
848    let mut pending: Vec<(usize, Placement)> = roots
849        .into_iter()
850        .rev()
851        .map(|i| (i, Placement::IDENTITY))
852        .collect();
853    let mut seen = vec![false; nodes.len()];
854    while let Some((index, parent)) = pending.pop() {
855        let Some(node) = nodes.get(index) else {
856            ogeom_bail!(Construction, "node {index} is not in this document");
857        };
858        // A cycle in the node graph is a broken document, and walking it
859        // forever is not a better answer than saying so.
860        if seen.get(index).copied().unwrap_or(false) {
861            ogeom_bail!(Construction, "node {index} is its own descendant");
862        }
863        if let Some(flag) = seen.get_mut(index) {
864            *flag = true;
865        }
866        let here = parent.then(node_transform(node)?);
867        for child in node.get("children").map_or(&[][..], Json::items) {
868            let Some(child) = child.index() else {
869                ogeom_bail!(Construction, "a child index is not an index");
870            };
871            pending.push((child, here));
872        }
873        let Some(mesh_index) = node.index_at("mesh") else {
874            continue;
875        };
876        let Some(mesh) = meshes.get(mesh_index) else {
877            ogeom_bail!(Construction, "mesh {mesh_index} is not in this document");
878        };
879        let name = node
880            .get("name")
881            .and_then(Json::text)
882            .or_else(|| mesh.get("name").and_then(Json::text))
883            .map(str::to_owned);
884        for primitive in mesh.get("primitives").map_or(&[][..], Json::items) {
885            let built = read_primitive(primitive, accessors, views, &buffers, materials, here)?;
886            if let Some((mesh, colour)) = built {
887                out.push(ImportedMesh {
888                    mesh,
889                    colour,
890                    name: name.clone(),
891                });
892            }
893        }
894    }
895    Ok(out)
896}
897
898/// A node's placement: three basis columns and a translation.
899///
900/// Not the kernel's own `Transform`, and deliberately: a glTF node may scale
901/// unevenly, which is not a placement at all: it carries a circle to an
902/// ellipse. What comes out of a glTF file is a *mesh*, where an uneven scale
903/// is nothing worse than three multiplications, so the reader carries the
904/// affine map plainly and applies it to points and normals.
905#[derive(Debug, Clone, Copy)]
906pub(crate) struct Placement {
907    pub(crate) columns: [Vector; 3],
908    pub(crate) translation: Vector,
909}
910
911impl Placement {
912    pub(crate) const IDENTITY: Self = Self {
913        columns: [
914            Vector::new(1.0, 0.0, 0.0),
915            Vector::new(0.0, 1.0, 0.0),
916            Vector::new(0.0, 0.0, 1.0),
917        ],
918        translation: Vector::new(0.0, 0.0, 0.0),
919    };
920
921    /// `self` after `inner`: the child's own map applied first.
922    pub(crate) fn then(self, inner: Self) -> Self {
923        let map = |v: Vector| self.columns[0] * v.x + self.columns[1] * v.y + self.columns[2] * v.z;
924        Self {
925            columns: inner.columns.map(map),
926            translation: map(inner.translation) + self.translation,
927        }
928    }
929
930    pub(crate) fn point(self, p: Point) -> Point {
931        Point::ORIGIN
932            + self.columns[0] * p.x
933            + self.columns[1] * p.y
934            + self.columns[2] * p.z
935            + self.translation
936    }
937
938    /// Whether the map is a reflection: it turns a triangle's winding
939    /// against its normal.
940    pub(crate) fn mirrors(self) -> bool {
941        let [a, b, c] = self.columns;
942        a.cross(b).dot(c) < 0.0
943    }
944
945    /// A normal carried through: the inverse transpose, which is what keeps a
946    /// normal normal to a surface an uneven scale has stretched.
947    ///
948    /// Built from the cofactors, which *is* the inverse transpose times the
949    /// determinant. A normal is renormalized anyway, so only the
950    /// determinant's sign matters, and the singular case does not divide.
951    fn normal(self, n: Vector) -> Vector {
952        let [a, b, c] = self.columns;
953        let cofactors = [b.cross(c), c.cross(a), a.cross(b)];
954        let out = cofactors[0] * n.x + cofactors[1] * n.y + cofactors[2] * n.z;
955        let magnitude = out.magnitude();
956        if magnitude == 0.0 {
957            n
958        } else if self.mirrors() {
959            -out / magnitude
960        } else {
961            out / magnitude
962        }
963    }
964}
965
966/// A node's own transform: a matrix, or translation, rotation and scale.
967fn node_transform(node: &crate::json::Json) -> ogeom_core::OgeomResult<Placement> {
968    use crate::json::Json;
969    let triple = |value: &Json, what: &str| -> ogeom_core::OgeomResult<[f64; 3]> {
970        let v: Vec<f64> = value.items().iter().filter_map(Json::number).collect();
971        let [x, y, z] = v[..] else {
972            ogeom_bail!(Construction, "a node {what} has three numbers");
973        };
974        Ok([x, y, z])
975    };
976    if let Some(matrix) = node.get("matrix") {
977        let values: Vec<f64> = matrix.items().iter().filter_map(Json::number).collect();
978        if values.len() != 16 {
979            ogeom_bail!(Construction, "a node matrix has sixteen numbers");
980        }
981        // Column-major, as the standard states it.
982        return Ok(Placement {
983            columns: [
984                Vector::new(values[0], values[1], values[2]),
985                Vector::new(values[4], values[5], values[6]),
986                Vector::new(values[8], values[9], values[10]),
987            ],
988            translation: Vector::new(values[12], values[13], values[14]),
989        });
990    }
991    // Scale first, then rotate, then translate: the order the standard sets.
992    let mut placement = Placement::IDENTITY;
993    if let Some(scale) = node.get("scale") {
994        let [x, y, z] = triple(scale, "scale")?;
995        placement = Placement {
996            columns: [
997                Vector::new(x, 0.0, 0.0),
998                Vector::new(0.0, y, 0.0),
999                Vector::new(0.0, 0.0, z),
1000            ],
1001            translation: Vector::new(0.0, 0.0, 0.0),
1002        }
1003        .then(placement);
1004    }
1005    if let Some(rotation) = node.get("rotation") {
1006        let q: Vec<f64> = rotation.items().iter().filter_map(Json::number).collect();
1007        let [x, y, z, w] = q[..] else {
1008            ogeom_bail!(Construction, "a node rotation is a quaternion of four");
1009        };
1010        placement = quaternion_placement(x, y, z, w).then(placement);
1011    }
1012    if let Some(translation) = node.get("translation") {
1013        let [x, y, z] = triple(translation, "translation")?;
1014        placement = Placement {
1015            translation: Vector::new(x, y, z),
1016            ..Placement::IDENTITY
1017        }
1018        .then(placement);
1019    }
1020    Ok(placement)
1021}
1022
1023/// The rotation a glTF quaternion `(x, y, z, w)` names.
1024fn quaternion_placement(x: f64, y: f64, z: f64, w: f64) -> Placement {
1025    let n = x.mul_add(x, y.mul_add(y, z.mul_add(z, w * w))).sqrt();
1026    let (x, y, z, w) = if n > 0.0 {
1027        (x / n, y / n, z / n, w / n)
1028    } else {
1029        (0.0, 0.0, 0.0, 1.0)
1030    };
1031    Placement {
1032        columns: [
1033            Vector::new(
1034                2.0f64.mul_add(-y.mul_add(y, z * z), 1.0),
1035                2.0 * x.mul_add(y, z * w),
1036                2.0 * x.mul_add(z, -(y * w)),
1037            ),
1038            Vector::new(
1039                2.0 * x.mul_add(y, -(z * w)),
1040                2.0f64.mul_add(-x.mul_add(x, z * z), 1.0),
1041                2.0 * y.mul_add(z, x * w),
1042            ),
1043            Vector::new(
1044                2.0 * x.mul_add(z, y * w),
1045                2.0 * y.mul_add(z, -(x * w)),
1046                2.0f64.mul_add(-x.mul_add(x, y * y), 1.0),
1047            ),
1048        ],
1049        translation: Vector::new(0.0, 0.0, 0.0),
1050    }
1051}
1052
1053/// One primitive as a triangulation, placed by its node.
1054///
1055/// `None` where the primitive draws nothing (no positions, or no triangles
1056/// once the indices are read), which is a thing a document may legitimately
1057/// contain and not a thing to hand on as a mesh.
1058fn read_primitive(
1059    primitive: &crate::json::Json,
1060    accessors: &[crate::json::Json],
1061    views: &[crate::json::Json],
1062    buffers: &[Vec<u8>],
1063    materials: &[crate::json::Json],
1064    placement: Placement,
1065) -> ogeom_core::OgeomResult<Option<(Triangulation, Option<[f64; 4]>)>> {
1066    use crate::json::Json;
1067    if primitive
1068        .get("extensions")
1069        .and_then(|e| e.get("KHR_draco_mesh_compression"))
1070        .is_some()
1071    {
1072        ogeom_bail!(
1073            Construction,
1074            "this primitive's geometry is Draco-compressed, which is a codec \
1075             this reader does not carry"
1076        );
1077    }
1078    // The default mode is 4, triangles; anything else draws something a
1079    // triangulation is not, and fanning a strip here would be inventing.
1080    let mode = primitive.index_at("mode").unwrap_or(4);
1081    if mode != 4 {
1082        ogeom_bail!(
1083            Construction,
1084            "primitive mode {mode} is not triangles; only triangle meshes read \
1085             back as a triangulation"
1086        );
1087    }
1088    let Some(attributes) = primitive.get("attributes") else {
1089        return Ok(None);
1090    };
1091    let Some(position_index) = attributes.index_at("POSITION") else {
1092        return Ok(None);
1093    };
1094    let positions_raw = read_accessor(position_index, accessors, views, buffers)?;
1095    if positions_raw.components != 3 {
1096        ogeom_bail!(Construction, "POSITION is a VEC3");
1097    }
1098    let positions: Vec<Point> = positions_raw
1099        .values
1100        .as_chunks::<3>()
1101        .0
1102        .iter()
1103        .map(|v| placement.point(Point::new(v[0], v[1], v[2])))
1104        .collect();
1105
1106    let normals: Vec<Vector> = match attributes.index_at("NORMAL") {
1107        None => Vec::new(),
1108        Some(index) => {
1109            let raw = read_accessor(index, accessors, views, buffers)?;
1110            if raw.components != 3 {
1111                ogeom_bail!(Construction, "NORMAL is a VEC3");
1112            }
1113            if raw.count != positions.len() {
1114                ogeom_bail!(
1115                    Construction,
1116                    "the primitive has {} positions and {} normals",
1117                    positions.len(),
1118                    raw.count
1119                );
1120            }
1121            raw.values
1122                .as_chunks::<3>()
1123                .0
1124                .iter()
1125                .map(|v| placement.normal(Vector::new(v[0], v[1], v[2])))
1126                .collect()
1127        }
1128    };
1129
1130    let indices: Vec<u32> = match primitive.index_at("indices") {
1131        // A primitive with no indices draws its vertices in order, three at a
1132        // time, which the standard says and a reader has to honour.
1133        None => (0..u32::try_from(positions.len()).unwrap_or(u32::MAX)).collect(),
1134        Some(index) => {
1135            let raw = read_accessor(index, accessors, views, buffers)?;
1136            if raw.components != 1 {
1137                ogeom_bail!(Construction, "an index accessor is a SCALAR");
1138            }
1139            let mut out = Vec::with_capacity(raw.values.len());
1140            for v in raw.values {
1141                #[expect(
1142                    clippy::cast_possible_truncation,
1143                    clippy::cast_sign_loss,
1144                    reason = "range-checked against the vertex count below"
1145                )]
1146                let k = v as u32;
1147                if f64::from(k) != v || (k as usize) >= positions.len() {
1148                    ogeom_bail!(
1149                        Construction,
1150                        "index {v} names no vertex among {}",
1151                        positions.len()
1152                    );
1153                }
1154                out.push(k);
1155            }
1156            out
1157        }
1158    };
1159    // A mirroring node turns the winding inside out; turn it back.
1160    let mirrored = placement.mirrors();
1161    let triangles: Vec<[u32; 3]> = indices
1162        .as_chunks::<3>()
1163        .0
1164        .iter()
1165        .map(|c| {
1166            if mirrored {
1167                [c[0], c[2], c[1]]
1168            } else {
1169                [c[0], c[1], c[2]]
1170            }
1171        })
1172        .collect();
1173    if triangles.is_empty() {
1174        return Ok(None);
1175    }
1176
1177    let colour = primitive
1178        .index_at("material")
1179        .and_then(|i| materials.get(i))
1180        .and_then(|m| m.get("pbrMetallicRoughness"))
1181        .and_then(|p| p.get("baseColorFactor"))
1182        .and_then(|f| {
1183            let v: Vec<f64> = f.items().iter().filter_map(Json::number).collect();
1184            <[f64; 4]>::try_from(v).ok()
1185        });
1186
1187    // A mesh with no normals given gets none invented from nothing: the
1188    // triangles' own are the only honest answer, and they are what a viewer
1189    // would have computed anyway.
1190    let normals = if normals.is_empty() {
1191        normals_from_triangles(&positions, &triangles)
1192    } else {
1193        normals
1194    };
1195    Ok(Some((
1196        Triangulation {
1197            parameters: vec![(0.0, 0.0); positions.len()],
1198            positions,
1199            normals,
1200            triangles,
1201            // The file says nothing about what deflection it was built at, so
1202            // nothing is claimed about one.
1203            deflection_met: false,
1204        },
1205        colour,
1206    )))
1207}
1208
1209/// Area-weighted vertex normals, for a file that gave none.
1210pub(crate) fn normals_from_triangles(positions: &[Point], triangles: &[[u32; 3]]) -> Vec<Vector> {
1211    let mut out = vec![Vector::new(0.0, 0.0, 0.0); positions.len()];
1212    for [a, b, c] in triangles {
1213        let (pa, pb, pc) = (
1214            positions[*a as usize],
1215            positions[*b as usize],
1216            positions[*c as usize],
1217        );
1218        // Not normalized: the cross product's length is twice the triangle's
1219        // area, which is exactly the weight a vertex normal wants.
1220        let n = (pb - pa).cross(pc - pa);
1221        for &k in &[*a, *b, *c] {
1222            out[k as usize] += n;
1223        }
1224    }
1225    for n in &mut out {
1226        let magnitude = n.magnitude();
1227        *n = if magnitude > 0.0 {
1228            *n / magnitude
1229        } else {
1230            Vector::new(0.0, 0.0, 1.0)
1231        };
1232    }
1233    out
1234}
1235
1236/// One accessor's values, flattened.
1237struct AccessorValues {
1238    /// `count * components` numbers, in order.
1239    values: Vec<f64>,
1240    /// How many numbers each element holds.
1241    components: usize,
1242    /// How many elements there are.
1243    count: usize,
1244}
1245
1246/// Read an accessor: its buffer view, its component type, its stride, and the
1247/// sparse block that overrides part of it.
1248///
1249/// An accessor with no buffer view is all zeros, which the standard says and
1250/// which is exactly what a sparse accessor over nothing means.
1251fn read_accessor(
1252    index: usize,
1253    accessors: &[crate::json::Json],
1254    views: &[crate::json::Json],
1255    buffers: &[Vec<u8>],
1256) -> ogeom_core::OgeomResult<AccessorValues> {
1257    let Some(accessor) = accessors.get(index) else {
1258        ogeom_bail!(Construction, "accessor {index} is not in this document");
1259    };
1260    let Some(kind) = accessor.get("type").and_then(crate::json::Json::text) else {
1261        ogeom_bail!(Construction, "accessor {index} states no type");
1262    };
1263    let components = match kind {
1264        "SCALAR" => 1,
1265        "VEC2" => 2,
1266        "VEC3" => 3,
1267        "VEC4" | "MAT2" => 4,
1268        "MAT3" => 9,
1269        "MAT4" => 16,
1270        other => ogeom_bail!(Construction, "`{other}` is not an accessor type"),
1271    };
1272    let Some(component_type) = accessor.index_at("componentType") else {
1273        ogeom_bail!(Construction, "accessor {index} states no componentType");
1274    };
1275    let normalized = accessor.get("normalized") == Some(&crate::json::Json::Bool(true));
1276    let count = accessor.index_at("count").unwrap_or(0);
1277    // Every element the document holds data for takes at least a byte of
1278    // its buffers per component, so no honest accessor asks for more
1279    // numbers than the buffers have bytes; one that does would size an
1280    // allocation from a number in the file.
1281    let held: usize = buffers.iter().map(Vec::len).sum();
1282    let Some(numbers) = count
1283        .checked_mul(components)
1284        .filter(|n| *n <= held.max(1 << 16))
1285    else {
1286        ogeom_bail!(
1287            Construction,
1288            "accessor {index} asks for {count} elements of {components} from {held} bytes"
1289        );
1290    };
1291    let mut values = vec![0.0; numbers];
1292
1293    if let Some(view_index) = accessor.index_at("bufferView") {
1294        let offset = accessor.index_at("byteOffset").unwrap_or(0);
1295        read_into(
1296            &mut values,
1297            view_index,
1298            offset,
1299            component_type,
1300            components,
1301            count,
1302            normalized,
1303            views,
1304            buffers,
1305        )?;
1306    }
1307
1308    // The sparse block: a run of indices, and the elements to put at them.
1309    // It comes *after* the dense read, because that is what "sparse" means:
1310    // a document may give a base and then override part of it.
1311    if let Some(sparse) = accessor.get("sparse") {
1312        let sparse_count = sparse.index_at("count").unwrap_or(0).min(count);
1313        let Some(indices) = sparse.get("indices") else {
1314            ogeom_bail!(Construction, "a sparse accessor names its indices");
1315        };
1316        let Some(sparse_values) = sparse.get("values") else {
1317            ogeom_bail!(Construction, "a sparse accessor names its values");
1318        };
1319        let Some(index_type) = indices.index_at("componentType") else {
1320            ogeom_bail!(Construction, "a sparse index has a componentType");
1321        };
1322        let Some(index_view) = indices.index_at("bufferView") else {
1323            ogeom_bail!(Construction, "a sparse index has a bufferView");
1324        };
1325        let mut which = vec![0.0; sparse_count];
1326        read_into(
1327            &mut which,
1328            index_view,
1329            indices.index_at("byteOffset").unwrap_or(0),
1330            index_type,
1331            1,
1332            sparse_count,
1333            false,
1334            views,
1335            buffers,
1336        )?;
1337        let Some(value_view) = sparse_values.index_at("bufferView") else {
1338            ogeom_bail!(Construction, "a sparse value block has a bufferView");
1339        };
1340        let mut replacement = vec![0.0; sparse_count * components];
1341        read_into(
1342            &mut replacement,
1343            value_view,
1344            sparse_values.index_at("byteOffset").unwrap_or(0),
1345            component_type,
1346            components,
1347            sparse_count,
1348            normalized,
1349            views,
1350            buffers,
1351        )?;
1352        for (slot, target) in which.iter().enumerate() {
1353            #[expect(
1354                clippy::cast_possible_truncation,
1355                clippy::cast_sign_loss,
1356                reason = "range-checked against the element count below"
1357            )]
1358            let at = *target as usize;
1359            if f64::from(u32::try_from(at).unwrap_or(u32::MAX)) != *target || at >= count {
1360                ogeom_bail!(
1361                    Construction,
1362                    "a sparse index names element {target} of {count}"
1363                );
1364            }
1365            for k in 0..components {
1366                values[at * components + k] = replacement[slot * components + k];
1367            }
1368        }
1369    }
1370
1371    Ok(AccessorValues {
1372        values,
1373        components,
1374        count,
1375    })
1376}
1377
1378/// Read `count` elements of `components` numbers out of a buffer view.
1379#[allow(clippy::too_many_arguments)]
1380fn read_into(
1381    out: &mut [f64],
1382    view_index: usize,
1383    accessor_offset: usize,
1384    component_type: usize,
1385    components: usize,
1386    count: usize,
1387    normalized: bool,
1388    views: &[crate::json::Json],
1389    buffers: &[Vec<u8>],
1390) -> ogeom_core::OgeomResult<()> {
1391    let Some(view) = views.get(view_index) else {
1392        ogeom_bail!(
1393            Construction,
1394            "buffer view {view_index} is not in this document"
1395        );
1396    };
1397    let buffer_index = view.index_at("buffer").unwrap_or(0);
1398    let Some(buffer) = buffers.get(buffer_index) else {
1399        ogeom_bail!(
1400            Construction,
1401            "buffer {buffer_index} is not in this document"
1402        );
1403    };
1404    let view_offset = view.index_at("byteOffset").unwrap_or(0);
1405    let width = match component_type {
1406        5120 | 5121 => 1,
1407        5122 | 5123 => 2,
1408        5125 | 5126 => 4,
1409        other => ogeom_bail!(Construction, "{other} is not a glTF component type"),
1410    };
1411    // A view may stride over the buffer, which is how a writer interleaves
1412    // several attributes; with no stride the elements are packed.
1413    let element = components * width;
1414    let stride = view.index_at("byteStride").unwrap_or(element);
1415    if stride < element {
1416        ogeom_bail!(
1417            Construction,
1418            "a stride of {stride} is shorter than the {element} bytes an \
1419             element occupies"
1420        );
1421    }
1422    for i in 0..count {
1423        let base = view_offset + accessor_offset + i * stride;
1424        for k in 0..components {
1425            let at = base + k * width;
1426            let Some(slice) = buffer.get(at..at + width) else {
1427                ogeom_bail!(
1428                    Construction,
1429                    "the buffer ends at {} where the accessor wants byte {at}",
1430                    buffer.len()
1431                );
1432            };
1433            out[i * components + k] = component_value(component_type, slice, normalized);
1434        }
1435    }
1436    Ok(())
1437}
1438
1439/// One component, as the number it stands for.
1440///
1441/// `normalized` is what the standard means by an integer standing in for a
1442/// fraction: unsigned types map to `[0, 1]` and signed to `[-1, 1]`, with the
1443/// signed floor clamped, which is the mapping the standard states exactly.
1444fn component_value(component_type: usize, bytes: &[u8], normalized: bool) -> f64 {
1445    match component_type {
1446        5120 => {
1447            let v = f64::from(bytes[0] as i8);
1448            if normalized { (v / 127.0).max(-1.0) } else { v }
1449        }
1450        5121 => {
1451            let v = f64::from(bytes[0]);
1452            if normalized { v / 255.0 } else { v }
1453        }
1454        5122 => {
1455            let v = f64::from(i16::from_le_bytes([bytes[0], bytes[1]]));
1456            if normalized {
1457                (v / 32767.0).max(-1.0)
1458            } else {
1459                v
1460            }
1461        }
1462        5123 => {
1463            let v = f64::from(u16::from_le_bytes([bytes[0], bytes[1]]));
1464            if normalized { v / 65535.0 } else { v }
1465        }
1466        5125 => f64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1467        _ => f64::from(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1468    }
1469}