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.
255#[must_use]
256pub fn write_obj(meshes: &[ExportMesh<'_>]) -> String {
257    let mut out = String::from("# ogeom\n");
258    let mut base = 1_usize;
259    for (i, export) in meshes.iter().enumerate() {
260        let mesh = export.mesh;
261        if mesh.triangles.is_empty() {
262            continue;
263        }
264        let name = export.name.clone().unwrap_or_else(|| format!("mesh-{i}"));
265        let _ = writeln!(out, "o {name}");
266        for p in &mesh.positions {
267            let _ = writeln!(out, "v {} {} {}", p.x, p.y, p.z);
268        }
269        for n in &mesh.normals {
270            let _ = writeln!(out, "vn {} {} {}", n.x, n.y, n.z);
271        }
272        for t in &mesh.triangles {
273            let [a, b, c] = t.map(|k| k as usize + base);
274            let _ = writeln!(out, "f {a}//{a} {b}//{b} {c}//{c}");
275        }
276        base += mesh.positions.len();
277    }
278    out
279}
280
281// --- PLY ---------------------------------------------------------------------
282
283/// Write one mesh as ASCII PLY.
284///
285/// Vertices with normals, faces as index lists; a colour, when given, is
286/// carried per vertex as the `uchar` triple the format convention expects.
287#[must_use]
288pub fn write_ply(export: &ExportMesh<'_>) -> String {
289    let mesh = export.mesh;
290    let colour = export.colour.map(|[r, g, b, _]| {
291        [
292            (r.clamp(0.0, 1.0) * 255.0).round(),
293            (g.clamp(0.0, 1.0) * 255.0).round(),
294            (b.clamp(0.0, 1.0) * 255.0).round(),
295        ]
296    });
297    let mut out = String::from("ply\nformat ascii 1.0\ncomment ogeom\n");
298    let _ = writeln!(out, "element vertex {}", mesh.positions.len());
299    out.push_str(
300        "property float x\nproperty float y\nproperty float z\n\
301         property float nx\nproperty float ny\nproperty float nz\n",
302    );
303    if colour.is_some() {
304        out.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
305    }
306    let _ = writeln!(out, "element face {}", mesh.triangles.len());
307    out.push_str("property list uchar uint vertex_indices\nend_header\n");
308    for (p, n) in mesh.positions.iter().zip(&mesh.normals) {
309        let _ = write!(out, "{} {} {} {} {} {}", p.x, p.y, p.z, n.x, n.y, n.z);
310        if let Some([r, g, b]) = colour {
311            let _ = write!(out, " {r} {g} {b}");
312        }
313        out.push('\n');
314    }
315    for t in &mesh.triangles {
316        let _ = writeln!(out, "3 {} {} {}", t[0], t[1], t[2]);
317    }
318    out
319}
320
321// --- reading -----------------------------------------------------------------
322
323/// Read an OBJ into a triangulation.
324///
325/// Vertices, faces and vertex normals; everything else (materials, groups,
326/// texture coordinates, smoothing) is a statement about *rendering* a mesh
327/// rather than about the mesh, and is skipped rather than half-honoured. A
328/// face of more than three vertices is fanned from its first, which is what
329/// a convex polygon means and what OBJ writers emit.
330///
331/// Indices may be negative, which in OBJ counts back from the end, and may
332/// carry the `v/vt/vn` triple, of which the first field is the position.
333///
334/// # Errors
335///
336/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if an
337/// index names a vertex that is not there, or a coordinate does not parse.
338pub fn read_obj(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
339    let mut positions: Vec<ogeom_math::Point> = Vec::new();
340    let mut normals: Vec<ogeom_math::Vector> = Vec::new();
341    let mut triangles: Vec<[u32; 3]> = Vec::new();
342    let mut normal_of: Vec<Option<usize>> = Vec::new();
343
344    for line in text.lines() {
345        let line = line.split('#').next().unwrap_or("").trim();
346        let mut fields = line.split_whitespace();
347        match fields.next() {
348            Some("v") => {
349                let coords = triple(&mut fields, "a vertex")?;
350                positions.push(ogeom_math::Point::new(coords[0], coords[1], coords[2]));
351                normal_of.push(None);
352            }
353            Some("vn") => {
354                let coords = triple(&mut fields, "a normal")?;
355                normals.push(ogeom_math::Vector::new(coords[0], coords[1], coords[2]));
356            }
357            Some("f") => {
358                let mut corners: Vec<(usize, Option<usize>)> = Vec::new();
359                for field in fields {
360                    corners.push(corner(field, positions.len(), normals.len())?);
361                }
362                if corners.len() < 3 {
363                    ogeom_core::ogeom_bail!(
364                        Construction,
365                        "a face of {} corners is not a face",
366                        corners.len()
367                    );
368                }
369                for i in 1..corners.len() - 1 {
370                    let fan = [corners[0], corners[i], corners[i + 1]];
371                    let mut indices = [0_u32; 3];
372                    for (k, (vertex, normal)) in fan.iter().enumerate() {
373                        if let Some(n) = normal {
374                            normal_of[*vertex] = Some(*n);
375                        }
376                        indices[k] = u32::try_from(*vertex).unwrap_or(u32::MAX);
377                    }
378                    triangles.push(indices);
379                }
380            }
381            _ => {}
382        }
383    }
384    if positions.is_empty() {
385        ogeom_core::ogeom_bail!(Construction, "the file carries no vertices");
386    }
387    // A vertex whose normal the file did not give takes the average of the
388    // triangles it belongs to, the same answer the writer would have had.
389    let mut resolved = vec![ogeom_math::Vector::ZERO; positions.len()];
390    for (i, held) in normal_of.iter().enumerate() {
391        if let Some(n) = held.and_then(|n| normals.get(n)) {
392            resolved[i] = *n;
393        }
394    }
395    for triangle in &triangles {
396        let [a, b, c] = triangle.map(|i| positions[i as usize]);
397        let face = (b - a).cross(c - a);
398        for index in triangle {
399            let slot = &mut resolved[*index as usize];
400            if held_is_unset(slot) {
401                *slot += face;
402            }
403        }
404    }
405    let normals = resolved
406        .into_iter()
407        .map(|n| {
408            let m = n.magnitude();
409            if m > 0.0 {
410                n / m
411            } else {
412                ogeom_math::Vector::Z
413            }
414        })
415        .collect::<Vec<_>>();
416    let parameters = vec![(0.0, 0.0); positions.len()];
417    Ok(Triangulation {
418        positions,
419        normals,
420        parameters,
421        triangles,
422        deflection_met: true,
423    })
424}
425
426/// Whether a normal slot is still waiting to be accumulated into.
427///
428/// A normal the file gave is a unit vector; a slot nobody has filled starts
429/// at zero and grows by the faces around it. Telling them apart by length is
430/// enough, and it means a file that gives *some* normals keeps them while
431/// the rest are worked out.
432fn held_is_unset(slot: &ogeom_math::Vector) -> bool {
433    !(0.999..=1.001).contains(&slot.magnitude())
434}
435
436/// Three floats off an iterator.
437fn triple<'a>(
438    fields: &mut impl Iterator<Item = &'a str>,
439    what: &str,
440) -> ogeom_core::OgeomResult<[f64; 3]> {
441    let mut out = [0.0; 3];
442    for slot in &mut out {
443        let Some(field) = fields.next() else {
444            ogeom_core::ogeom_bail!(Construction, "{what} needs three coordinates");
445        };
446        let Ok(value) = field.parse::<f64>() else {
447            ogeom_core::ogeom_bail!(
448                Construction,
449                "{what} carries {field}, which is not a number"
450            );
451        };
452        *slot = value;
453    }
454    Ok(out)
455}
456
457/// One OBJ face corner: `v`, `v/vt`, `v//vn` or `v/vt/vn`, one-based, and
458/// negative counting back from the end.
459fn corner(
460    field: &str,
461    vertices: usize,
462    normals: usize,
463) -> ogeom_core::OgeomResult<(usize, Option<usize>)> {
464    let mut parts = field.split('/');
465    let resolve = |text: &str, count: usize| -> Option<usize> {
466        let index = text.parse::<isize>().ok()?;
467        let zero = if index > 0 {
468            usize::try_from(index).ok()?.checked_sub(1)?
469        } else if index < 0 {
470            count.checked_sub(usize::try_from(-index).ok()?)?
471        } else {
472            return None;
473        };
474        (zero < count).then_some(zero)
475    };
476    let Some(vertex) = parts.next().and_then(|t| resolve(t, vertices)) else {
477        ogeom_core::ogeom_bail!(
478            Construction,
479            "a face names vertex {field}, which is not there"
480        );
481    };
482    let _texture = parts.next();
483    let normal = parts.next().and_then(|t| resolve(t, normals));
484    Ok((vertex, normal))
485}
486
487/// Read an ASCII PLY into a triangulation.
488///
489/// The element order the header declares, the properties it names, and the
490/// faces' own vertex lists. Binary PLY is refused by name: its header says
491/// `format binary_little_endian` and this reads `format ascii`, which is
492/// what every writer here emits and what the format's own text dialect is.
493///
494/// # Errors
495///
496/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
497/// header is not a PLY header, the format is binary, or the body does not
498/// match what the header promised.
499pub fn read_ply(text: &str) -> ogeom_core::OgeomResult<Triangulation> {
500    let mut lines = text.lines();
501    if lines.next().map(str::trim) != Some("ply") {
502        ogeom_core::ogeom_bail!(Construction, "this is not a PLY file");
503    }
504    // The header: element counts and, per element, its properties in order.
505    let mut counts: Vec<(String, usize, Vec<String>)> = Vec::new();
506    for line in lines.by_ref() {
507        let line = line.trim();
508        let mut fields = line.split_whitespace();
509        match fields.next() {
510            Some("format") => {
511                let kind = fields.next().unwrap_or("");
512                if kind != "ascii" {
513                    ogeom_core::ogeom_bail!(
514                        Construction,
515                        "this PLY is {kind}; the text dialect is what is read here"
516                    );
517                }
518            }
519            Some("element") => {
520                let name = fields.next().unwrap_or("").to_string();
521                let count = fields
522                    .next()
523                    .and_then(|t| t.parse::<usize>().ok())
524                    .unwrap_or(0);
525                counts.push((name, count, Vec::new()));
526            }
527            Some("property") => {
528                if let Some((_, _, properties)) = counts.last_mut() {
529                    let last = line.split_whitespace().last().unwrap_or("");
530                    properties.push(last.to_string());
531                }
532            }
533            Some("end_header") => break,
534            _ => {}
535        }
536    }
537
538    let mut positions: Vec<ogeom_math::Point> = Vec::new();
539    let mut normals: Vec<ogeom_math::Vector> = Vec::new();
540    let mut triangles: Vec<[u32; 3]> = Vec::new();
541    let mut body = lines.filter(|l| !l.trim().is_empty());
542    for (name, count, properties) in &counts {
543        for _ in 0..*count {
544            let Some(line) = body.next() else {
545                ogeom_core::ogeom_bail!(
546                    Construction,
547                    "the header promised {count} of {name} and the body ran out"
548                );
549            };
550            let fields: Vec<&str> = line.split_whitespace().collect();
551            if name == "vertex" {
552                let read = |what: &str| -> Option<f64> {
553                    let at = properties.iter().position(|p| p == what)?;
554                    fields.get(at)?.parse::<f64>().ok()
555                };
556                let (Some(x), Some(y), Some(z)) = (read("x"), read("y"), read("z")) else {
557                    ogeom_core::ogeom_bail!(Construction, "a vertex is missing a coordinate");
558                };
559                positions.push(ogeom_math::Point::new(x, y, z));
560                normals.push(match (read("nx"), read("ny"), read("nz")) {
561                    (Some(a), Some(b), Some(c)) => ogeom_math::Vector::new(a, b, c),
562                    _ => ogeom_math::Vector::ZERO,
563                });
564            } else if name == "face" {
565                let Some(count) = fields.first().and_then(|t| t.parse::<usize>().ok()) else {
566                    ogeom_core::ogeom_bail!(Construction, "a face does not say how many corners");
567                };
568                let corners: Vec<u32> = fields
569                    .iter()
570                    .skip(1)
571                    .take(count)
572                    .filter_map(|t| t.parse::<u32>().ok())
573                    .collect();
574                if corners.len() != count {
575                    ogeom_core::ogeom_bail!(
576                        Construction,
577                        "a face of {count} corners lists {} of them",
578                        corners.len()
579                    );
580                }
581                for i in 1..corners.len().saturating_sub(1) {
582                    triangles.push([corners[0], corners[i], corners[i + 1]]);
583                }
584            }
585        }
586    }
587    if positions.is_empty() {
588        ogeom_core::ogeom_bail!(Construction, "the file carries no vertices");
589    }
590    // Normals the file did not give come from the triangles, as in OBJ.
591    let mut resolved = normals;
592    resolved.resize(positions.len(), ogeom_math::Vector::ZERO);
593    for triangle in &triangles {
594        let [a, b, c] = triangle.map(|i| positions[i as usize]);
595        let face = (b - a).cross(c - a);
596        for index in triangle {
597            let slot = &mut resolved[*index as usize];
598            if held_is_unset(slot) {
599                *slot += face;
600            }
601        }
602    }
603    let normals = resolved
604        .into_iter()
605        .map(|n| {
606            let m = n.magnitude();
607            if m > 0.0 {
608                n / m
609            } else {
610                ogeom_math::Vector::Z
611            }
612        })
613        .collect::<Vec<_>>();
614    let parameters = vec![(0.0, 0.0); positions.len()];
615    Ok(Triangulation {
616        positions,
617        normals,
618        parameters,
619        triangles,
620        deflection_met: true,
621    })
622}
623
624// --- VRML --------------------------------------------------------------------
625
626/// Write meshes as VRML 97.
627///
628/// One `Shape` per mesh, each an `IndexedFaceSet` over its own coordinates,
629/// with a material where a colour was given. Written rather than read: VRML
630/// is a scene-description language with a great deal in it that is not
631/// geometry, and a reader that took only the geometry would be claiming
632/// more than it did.
633#[must_use]
634pub fn write_vrml(meshes: &[ExportMesh<'_>]) -> String {
635    let mut out = String::from("#VRML V2.0 utf8\n\n");
636    for export in meshes {
637        if export.mesh.triangles.is_empty() {
638            continue;
639        }
640        if let Some(name) = &export.name {
641            let _ = writeln!(out, "# {name}");
642        }
643        out.push_str("Shape {\n");
644        if let Some([r, g, b, a]) = export.colour {
645            out.push_str("  appearance Appearance {\n    material Material {\n");
646            let _ = writeln!(out, "      diffuseColor {r} {g} {b}");
647            if a < 1.0 {
648                let _ = writeln!(out, "      transparency {}", 1.0 - a);
649            }
650            out.push_str("    }\n  }\n");
651        }
652        out.push_str("  geometry IndexedFaceSet {\n    coord Coordinate {\n      point [\n");
653        for p in &export.mesh.positions {
654            let _ = writeln!(out, "        {} {} {},", p.x, p.y, p.z);
655        }
656        out.push_str("      ]\n    }\n    coordIndex [\n");
657        for [a, b, c] in &export.mesh.triangles {
658            let _ = writeln!(out, "      {a} {b} {c} -1,");
659        }
660        out.push_str("    ]\n    solid TRUE\n  }\n}\n\n");
661    }
662    out
663}
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used, clippy::expect_used)]
667mod tests {
668    use super::*;
669    use ogeom_core::Tolerances;
670    use ogeom_math::Frame;
671    use ogeom_topo::Model;
672
673    const T: Tolerances = Tolerances::millimetres();
674
675    fn box_mesh() -> Triangulation {
676        let mut model = Model::new();
677        let solid = ogeom_algo::make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T)
678            .unwrap()
679            .shape;
680        ogeom_mesh::triangulate(&model, &solid, ogeom_mesh::Deflection::default(), T).unwrap()
681    }
682
683    #[test]
684    fn a_glb_frames_its_chunks_and_counts_its_geometry() {
685        let mesh = box_mesh();
686        let glb = write_glb(&[ExportMesh {
687            mesh: &mesh,
688            colour: Some([0.8, 0.2, 0.1, 1.0]),
689            name: Some("box".into()),
690        }]);
691        // Magic, version, and a total length that matches the file.
692        assert_eq!(&glb[0..4], b"glTF");
693        assert_eq!(u32::from_le_bytes(glb[4..8].try_into().unwrap()), 2);
694        assert_eq!(
695            u32::from_le_bytes(glb[8..12].try_into().unwrap()) as usize,
696            glb.len()
697        );
698        // The JSON chunk parses far enough to carry the structure.
699        let json_length = u32::from_le_bytes(glb[12..16].try_into().unwrap()) as usize;
700        assert_eq!(&glb[16..20], b"JSON");
701        let json = core::str::from_utf8(&glb[20..20 + json_length]).unwrap();
702        assert!(json.contains(r#""version":"2.0""#));
703        assert!(json.contains(r#""POSITION":0"#));
704        assert!(json.contains(r#""indices":2"#));
705        assert!(json.contains("baseColorFactor"));
706        assert!(json.contains(r#""name":"box""#));
707        assert!(json.contains(r#""min":["#), "POSITION carries bounds");
708        // The binary chunk holds what the accessors promise: positions,
709        // normals, indices.
710        let expected =
711            mesh.positions.len() * 12 + mesh.normals.len() * 12 + mesh.triangles.len() * 12;
712        let padded = expected + (4 - expected % 4) % 4;
713        let binary_length =
714            u32::from_le_bytes(glb[20 + json_length..24 + json_length].try_into().unwrap());
715        assert_eq!(binary_length as usize, padded);
716    }
717
718    #[test]
719    fn an_obj_counts_its_records_and_shares_its_index_space() {
720        let mesh = box_mesh();
721        let one = ExportMesh::plain(&mesh);
722        let text = write_obj(&[one.clone(), one]);
723        assert_eq!(
724            text.lines().filter(|l| l.starts_with("v ")).count(),
725            mesh.positions.len() * 2
726        );
727        assert_eq!(
728            text.lines().filter(|l| l.starts_with("f ")).count(),
729            mesh.triangles.len() * 2
730        );
731        // The second mesh's faces reference the second mesh's vertices.
732        let last_face = text.lines().rev().find(|l| l.starts_with("f ")).unwrap();
733        let first_index: usize = last_face
734            .split_whitespace()
735            .nth(1)
736            .unwrap()
737            .split('/')
738            .next()
739            .unwrap()
740            .parse()
741            .unwrap();
742        assert!(first_index > mesh.positions.len());
743    }
744
745    #[test]
746    fn a_ply_declares_what_it_carries() {
747        let mesh = box_mesh();
748        let text = write_ply(&ExportMesh {
749            mesh: &mesh,
750            colour: Some([0.0, 0.5, 1.0, 1.0]),
751            name: None,
752        });
753        assert!(text.starts_with("ply\nformat ascii 1.0\n"));
754        assert!(text.contains(&format!("element vertex {}", mesh.positions.len())));
755        assert!(text.contains(&format!("element face {}", mesh.triangles.len())));
756        assert!(text.contains("property uchar red"));
757        let body_faces = text
758            .lines()
759            .skip_while(|l| *l != "end_header")
760            .filter(|l| l.starts_with("3 "))
761            .count();
762        assert_eq!(body_faces, mesh.triangles.len());
763    }
764}
765
766// --- glTF 2.0, reading -------------------------------------------------------
767
768/// One mesh read back out of a glTF document.
769///
770/// The owning counterpart of [`ExportMesh`]: what the file said, with the
771/// node transform that placed it already applied to the geometry, so the
772/// positions are where the scene puts them.
773#[derive(Debug, Clone)]
774pub struct ImportedMesh {
775    /// The tessellation, in scene coordinates.
776    pub mesh: Triangulation,
777    /// The base colour of the primitive's material, where it had one.
778    pub colour: Option<[f64; 4]>,
779    /// The node's name, where it had one.
780    pub name: Option<String>,
781}
782
783/// Read a GLB: glTF 2.0's single-file binary form.
784///
785/// The whole indirection is honoured, because a writer chooses it and a
786/// reader does not get to assume: a primitive names accessors, an accessor
787/// names a buffer view and a component type, a view names a buffer and may
788/// stride over it, and any of them may be replaced piecewise by a *sparse*
789/// block. Every component type the standard defines is read (signed and
790/// unsigned bytes, shorts, unsigned ints and floats), with `normalized`
791/// honoured where it is set, which is the difference between a colour of
792/// `255` and a colour of `1`.
793///
794/// The scene's node hierarchy is walked and each node's transform composed,
795/// stated as a matrix or as translation, rotation and scale; the geometry
796/// comes back placed. Normals are carried through the transform's inverse
797/// transpose, which is what keeps them normal to a scaled surface.
798///
799/// What is *not* read is refused rather than approximated: a primitive whose
800/// mode is not triangles, a buffer that points at an external file this
801/// function cannot open, and a `KHR_draco_mesh_compression` payload, each by
802/// name.
803///
804/// # Errors
805///
806/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) for a
807/// file whose framing, JSON or indices do not hold together, and for the
808/// refusals above.
809pub fn read_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
810    let (json, binary) = split_glb(bytes)?;
811    let document = crate::json::parse(&json)?;
812    read_gltf_document(&document, binary.as_deref())
813}
814
815/// Read a `.gltf`: the JSON form, whose buffers are data URIs.
816///
817/// A buffer with an external `uri` is refused by name: this function is handed
818/// bytes, not a directory, and quietly producing a mesh with no positions
819/// would be worse than saying so.
820///
821/// # Errors
822///
823/// As [`read_glb`].
824pub fn read_gltf(text: &str) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
825    let document = crate::json::parse(text)?;
826    read_gltf_document(&document, None)
827}
828
829/// The JSON chunk and the binary chunk of a GLB.
830fn split_glb(bytes: &[u8]) -> ogeom_core::OgeomResult<(String, Option<Vec<u8>>)> {
831    let word = |at: usize| -> ogeom_core::OgeomResult<u32> {
832        let Some(slice) = bytes.get(at..at + 4) else {
833            ogeom_bail!(Construction, "the file ends inside its own header");
834        };
835        Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
836    };
837    if word(0)? != 0x4654_6C67 {
838        ogeom_bail!(
839            Construction,
840            "this is not a GLB: the first four bytes are not `glTF`"
841        );
842    }
843    let version = word(4)?;
844    if version != 2 {
845        ogeom_bail!(Construction, "GLB version {version} is not glTF 2.0");
846    }
847    let mut json = None;
848    let mut binary = None;
849    let mut at = 12;
850    while at + 8 <= bytes.len() {
851        let length = word(at)? as usize;
852        let kind = word(at + 4)?;
853        let start = at + 8;
854        let Some(chunk) = bytes.get(start..start + length) else {
855            ogeom_bail!(
856                Construction,
857                "a chunk claims {length} bytes it does not have"
858            );
859        };
860        match kind {
861            0x4E4F_534A => {
862                let Ok(text) = core::str::from_utf8(chunk) else {
863                    ogeom_bail!(Construction, "the JSON chunk is not UTF-8");
864                };
865                json = Some(text.trim_end_matches(['\0', ' ']).to_owned());
866            }
867            0x004E_4942 => binary = Some(chunk.to_vec()),
868            // The standard says an unknown chunk is skipped, not refused.
869            _ => {}
870        }
871        at = start + length.next_multiple_of(4);
872    }
873    let Some(json) = json else {
874        ogeom_bail!(Construction, "the GLB carries no JSON chunk");
875    };
876    Ok((json, binary))
877}
878
879/// One buffer's bytes, from the binary chunk or a data URI.
880fn buffers(
881    document: &crate::json::Json,
882    binary: Option<&[u8]>,
883) -> ogeom_core::OgeomResult<Vec<Vec<u8>>> {
884    let mut out = Vec::new();
885    for buffer in document.get("buffers").map(|b| b.items()).unwrap_or(&[]) {
886        match buffer.get("uri").and_then(crate::json::Json::text) {
887            None => {
888                // The GLB's own binary chunk: only the first buffer may take
889                // it, which is what "the buffer with no uri" means.
890                let Some(chunk) = binary else {
891                    ogeom_bail!(
892                        Construction,
893                        "a buffer with no uri wants the GLB's binary chunk, and \
894                         there is none"
895                    );
896                };
897                out.push(chunk.to_vec());
898            }
899            Some(uri) if uri.starts_with("data:") => {
900                let Some((_, payload)) = uri.split_once("base64,") else {
901                    ogeom_bail!(
902                        Construction,
903                        "a data uri that is not base64 is not something this reads"
904                    );
905                };
906                out.push(from_base64(payload)?);
907            }
908            Some(uri) => ogeom_bail!(
909                Construction,
910                "the buffer points at `{uri}`, an external file this reader is \
911                 not given; hand it a GLB or a document with data uris"
912            ),
913        }
914    }
915    Ok(out)
916}
917
918/// Standard base64, padding tolerated and whitespace ignored.
919fn from_base64(text: &str) -> ogeom_core::OgeomResult<Vec<u8>> {
920    let value = |c: u8| -> Option<u32> {
921        match c {
922            b'A'..=b'Z' => Some(u32::from(c - b'A')),
923            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
924            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
925            b'+' => Some(62),
926            b'/' => Some(63),
927            _ => None,
928        }
929    };
930    let mut out = Vec::with_capacity(text.len() / 4 * 3);
931    let mut acc = 0_u32;
932    let mut held = 0_u32;
933    for c in text.bytes() {
934        if c == b'=' || c.is_ascii_whitespace() {
935            continue;
936        }
937        let Some(v) = value(c) else {
938            ogeom_bail!(Construction, "`{}` is not base64", c as char);
939        };
940        acc = (acc << 6) | v;
941        held += 6;
942        if held >= 8 {
943            held -= 8;
944            #[allow(clippy::cast_possible_truncation, reason = "masked to a byte")]
945            out.push(((acc >> held) & 0xFF) as u8);
946        }
947    }
948    Ok(out)
949}
950
951/// The whole document: buffers, then every mesh the scene's nodes place.
952fn read_gltf_document(
953    document: &crate::json::Json,
954    binary: Option<&[u8]>,
955) -> ogeom_core::OgeomResult<Vec<ImportedMesh>> {
956    use crate::json::Json;
957    let buffers = buffers(document, binary)?;
958    let views = document.get("bufferViews").map_or(&[][..], Json::items);
959    let accessors = document.get("accessors").map_or(&[][..], Json::items);
960    let meshes = document.get("meshes").map_or(&[][..], Json::items);
961    let nodes = document.get("nodes").map_or(&[][..], Json::items);
962    let materials = document.get("materials").map_or(&[][..], Json::items);
963
964    // The nodes the scene names, or (for a document with no scene at all)
965    // every node, which is what a reader can honestly do with one.
966    let scene = document
967        .index_at("scene")
968        .and_then(|i| document.get("scenes")?.items().get(i))
969        .and_then(|s| s.get("nodes"))
970        .map(Json::items);
971    let roots: Vec<usize> = match scene {
972        Some(list) => list.iter().filter_map(Json::index).collect(),
973        None => (0..nodes.len()).collect(),
974    };
975
976    let mut out = Vec::new();
977    let mut pending: Vec<(usize, Placement)> = roots
978        .into_iter()
979        .rev()
980        .map(|i| (i, Placement::IDENTITY))
981        .collect();
982    let mut seen = vec![false; nodes.len()];
983    while let Some((index, parent)) = pending.pop() {
984        let Some(node) = nodes.get(index) else {
985            ogeom_bail!(Construction, "node {index} is not in this document");
986        };
987        // A cycle in the node graph is a broken document, and walking it
988        // forever is not a better answer than saying so.
989        if seen.get(index).copied().unwrap_or(false) {
990            ogeom_bail!(Construction, "node {index} is its own descendant");
991        }
992        if let Some(flag) = seen.get_mut(index) {
993            *flag = true;
994        }
995        let here = parent.then(node_transform(node)?);
996        for child in node.get("children").map_or(&[][..], Json::items) {
997            let Some(child) = child.index() else {
998                ogeom_bail!(Construction, "a child index is not an index");
999            };
1000            pending.push((child, here));
1001        }
1002        let Some(mesh_index) = node.index_at("mesh") else {
1003            continue;
1004        };
1005        let Some(mesh) = meshes.get(mesh_index) else {
1006            ogeom_bail!(Construction, "mesh {mesh_index} is not in this document");
1007        };
1008        let name = node
1009            .get("name")
1010            .and_then(Json::text)
1011            .or_else(|| mesh.get("name").and_then(Json::text))
1012            .map(str::to_owned);
1013        for primitive in mesh.get("primitives").map_or(&[][..], Json::items) {
1014            let built = read_primitive(primitive, accessors, views, &buffers, materials, here)?;
1015            if let Some((mesh, colour)) = built {
1016                out.push(ImportedMesh {
1017                    mesh,
1018                    colour,
1019                    name: name.clone(),
1020                });
1021            }
1022        }
1023    }
1024    Ok(out)
1025}
1026
1027/// A node's placement: three basis columns and a translation.
1028///
1029/// Not the kernel's own `Transform`, and deliberately: a glTF node may scale
1030/// unevenly, which is not a placement at all: it carries a circle to an
1031/// ellipse. What comes out of a glTF file is a *mesh*, where an uneven scale
1032/// is nothing worse than three multiplications, so the reader carries the
1033/// affine map plainly and applies it to points and normals.
1034#[derive(Debug, Clone, Copy)]
1035pub(crate) struct Placement {
1036    pub(crate) columns: [Vector; 3],
1037    pub(crate) translation: Vector,
1038}
1039
1040impl Placement {
1041    pub(crate) const IDENTITY: Self = Self {
1042        columns: [
1043            Vector::new(1.0, 0.0, 0.0),
1044            Vector::new(0.0, 1.0, 0.0),
1045            Vector::new(0.0, 0.0, 1.0),
1046        ],
1047        translation: Vector::new(0.0, 0.0, 0.0),
1048    };
1049
1050    /// `self` after `inner`: the child's own map applied first.
1051    pub(crate) fn then(self, inner: Self) -> Self {
1052        let map = |v: Vector| self.columns[0] * v.x + self.columns[1] * v.y + self.columns[2] * v.z;
1053        Self {
1054            columns: inner.columns.map(map),
1055            translation: map(inner.translation) + self.translation,
1056        }
1057    }
1058
1059    pub(crate) fn point(self, p: Point) -> Point {
1060        Point::ORIGIN
1061            + self.columns[0] * p.x
1062            + self.columns[1] * p.y
1063            + self.columns[2] * p.z
1064            + self.translation
1065    }
1066
1067    /// A normal carried through: the inverse transpose, which is what keeps a
1068    /// normal normal to a surface an uneven scale has stretched.
1069    ///
1070    /// Built from the cofactors, which *is* the inverse transpose up to the
1071    /// determinant, and a normal is renormalized anyway, so the factor does
1072    /// not matter and the singular case does not divide.
1073    fn normal(self, n: Vector) -> Vector {
1074        let [a, b, c] = self.columns;
1075        let cofactors = [b.cross(c), c.cross(a), a.cross(b)];
1076        let out = cofactors[0] * n.x + cofactors[1] * n.y + cofactors[2] * n.z;
1077        let magnitude = out.magnitude();
1078        if magnitude > 0.0 { out / magnitude } else { n }
1079    }
1080}
1081
1082/// A node's own transform: a matrix, or translation, rotation and scale.
1083fn node_transform(node: &crate::json::Json) -> ogeom_core::OgeomResult<Placement> {
1084    use crate::json::Json;
1085    let triple = |value: &Json, what: &str| -> ogeom_core::OgeomResult<[f64; 3]> {
1086        let v: Vec<f64> = value.items().iter().filter_map(Json::number).collect();
1087        let [x, y, z] = v[..] else {
1088            ogeom_bail!(Construction, "a node {what} has three numbers");
1089        };
1090        Ok([x, y, z])
1091    };
1092    if let Some(matrix) = node.get("matrix") {
1093        let values: Vec<f64> = matrix.items().iter().filter_map(Json::number).collect();
1094        if values.len() != 16 {
1095            ogeom_bail!(Construction, "a node matrix has sixteen numbers");
1096        }
1097        // Column-major, as the standard states it.
1098        return Ok(Placement {
1099            columns: [
1100                Vector::new(values[0], values[1], values[2]),
1101                Vector::new(values[4], values[5], values[6]),
1102                Vector::new(values[8], values[9], values[10]),
1103            ],
1104            translation: Vector::new(values[12], values[13], values[14]),
1105        });
1106    }
1107    // Scale first, then rotate, then translate: the order the standard sets.
1108    let mut placement = Placement::IDENTITY;
1109    if let Some(scale) = node.get("scale") {
1110        let [x, y, z] = triple(scale, "scale")?;
1111        placement = Placement {
1112            columns: [
1113                Vector::new(x, 0.0, 0.0),
1114                Vector::new(0.0, y, 0.0),
1115                Vector::new(0.0, 0.0, z),
1116            ],
1117            translation: Vector::new(0.0, 0.0, 0.0),
1118        }
1119        .then(placement);
1120    }
1121    if let Some(rotation) = node.get("rotation") {
1122        let q: Vec<f64> = rotation.items().iter().filter_map(Json::number).collect();
1123        let [x, y, z, w] = q[..] else {
1124            ogeom_bail!(Construction, "a node rotation is a quaternion of four");
1125        };
1126        placement = quaternion_placement(x, y, z, w).then(placement);
1127    }
1128    if let Some(translation) = node.get("translation") {
1129        let [x, y, z] = triple(translation, "translation")?;
1130        placement = Placement {
1131            translation: Vector::new(x, y, z),
1132            ..Placement::IDENTITY
1133        }
1134        .then(placement);
1135    }
1136    Ok(placement)
1137}
1138
1139/// The rotation a glTF quaternion `(x, y, z, w)` names.
1140fn quaternion_placement(x: f64, y: f64, z: f64, w: f64) -> Placement {
1141    let n = x.mul_add(x, y.mul_add(y, z.mul_add(z, w * w))).sqrt();
1142    let (x, y, z, w) = if n > 0.0 {
1143        (x / n, y / n, z / n, w / n)
1144    } else {
1145        (0.0, 0.0, 0.0, 1.0)
1146    };
1147    Placement {
1148        columns: [
1149            Vector::new(
1150                2.0f64.mul_add(-y.mul_add(y, z * z), 1.0),
1151                2.0 * x.mul_add(y, z * w),
1152                2.0 * x.mul_add(z, -(y * w)),
1153            ),
1154            Vector::new(
1155                2.0 * x.mul_add(y, -(z * w)),
1156                2.0f64.mul_add(-x.mul_add(x, z * z), 1.0),
1157                2.0 * y.mul_add(z, x * w),
1158            ),
1159            Vector::new(
1160                2.0 * x.mul_add(z, y * w),
1161                2.0 * y.mul_add(z, -(x * w)),
1162                2.0f64.mul_add(-x.mul_add(x, y * y), 1.0),
1163            ),
1164        ],
1165        translation: Vector::new(0.0, 0.0, 0.0),
1166    }
1167}
1168
1169/// One primitive as a triangulation, placed by its node.
1170///
1171/// `None` where the primitive draws nothing (no positions, or no triangles
1172/// once the indices are read), which is a thing a document may legitimately
1173/// contain and not a thing to hand on as a mesh.
1174fn read_primitive(
1175    primitive: &crate::json::Json,
1176    accessors: &[crate::json::Json],
1177    views: &[crate::json::Json],
1178    buffers: &[Vec<u8>],
1179    materials: &[crate::json::Json],
1180    placement: Placement,
1181) -> ogeom_core::OgeomResult<Option<(Triangulation, Option<[f64; 4]>)>> {
1182    use crate::json::Json;
1183    if primitive
1184        .get("extensions")
1185        .and_then(|e| e.get("KHR_draco_mesh_compression"))
1186        .is_some()
1187    {
1188        ogeom_bail!(
1189            Construction,
1190            "this primitive's geometry is Draco-compressed, which is a codec \
1191             this reader does not carry"
1192        );
1193    }
1194    // The default mode is 4, triangles; anything else draws something a
1195    // triangulation is not, and fanning a strip here would be inventing.
1196    let mode = primitive.index_at("mode").unwrap_or(4);
1197    if mode != 4 {
1198        ogeom_bail!(
1199            Construction,
1200            "primitive mode {mode} is not triangles; only triangle meshes read \
1201             back as a triangulation"
1202        );
1203    }
1204    let Some(attributes) = primitive.get("attributes") else {
1205        return Ok(None);
1206    };
1207    let Some(position_index) = attributes.index_at("POSITION") else {
1208        return Ok(None);
1209    };
1210    let positions_raw = read_accessor(position_index, accessors, views, buffers)?;
1211    if positions_raw.components != 3 {
1212        ogeom_bail!(Construction, "POSITION is a VEC3");
1213    }
1214    let positions: Vec<Point> = positions_raw
1215        .values
1216        .as_chunks::<3>()
1217        .0
1218        .iter()
1219        .map(|v| placement.point(Point::new(v[0], v[1], v[2])))
1220        .collect();
1221
1222    let normals: Vec<Vector> = match attributes.index_at("NORMAL") {
1223        None => Vec::new(),
1224        Some(index) => {
1225            let raw = read_accessor(index, accessors, views, buffers)?;
1226            if raw.components != 3 {
1227                ogeom_bail!(Construction, "NORMAL is a VEC3");
1228            }
1229            if raw.count != positions.len() {
1230                ogeom_bail!(
1231                    Construction,
1232                    "the primitive has {} positions and {} normals",
1233                    positions.len(),
1234                    raw.count
1235                );
1236            }
1237            raw.values
1238                .as_chunks::<3>()
1239                .0
1240                .iter()
1241                .map(|v| placement.normal(Vector::new(v[0], v[1], v[2])))
1242                .collect()
1243        }
1244    };
1245
1246    let indices: Vec<u32> = match primitive.index_at("indices") {
1247        // A primitive with no indices draws its vertices in order, three at a
1248        // time, which the standard says and a reader has to honour.
1249        None => (0..u32::try_from(positions.len()).unwrap_or(u32::MAX)).collect(),
1250        Some(index) => {
1251            let raw = read_accessor(index, accessors, views, buffers)?;
1252            if raw.components != 1 {
1253                ogeom_bail!(Construction, "an index accessor is a SCALAR");
1254            }
1255            let mut out = Vec::with_capacity(raw.values.len());
1256            for v in raw.values {
1257                #[expect(
1258                    clippy::cast_possible_truncation,
1259                    clippy::cast_sign_loss,
1260                    reason = "range-checked against the vertex count below"
1261                )]
1262                let k = v as u32;
1263                if f64::from(k) != v || (k as usize) >= positions.len() {
1264                    ogeom_bail!(
1265                        Construction,
1266                        "index {v} names no vertex among {}",
1267                        positions.len()
1268                    );
1269                }
1270                out.push(k);
1271            }
1272            out
1273        }
1274    };
1275    let triangles: Vec<[u32; 3]> = indices
1276        .as_chunks::<3>()
1277        .0
1278        .iter()
1279        .map(|c| [c[0], c[1], c[2]])
1280        .collect();
1281    if triangles.is_empty() {
1282        return Ok(None);
1283    }
1284
1285    let colour = primitive
1286        .index_at("material")
1287        .and_then(|i| materials.get(i))
1288        .and_then(|m| m.get("pbrMetallicRoughness"))
1289        .and_then(|p| p.get("baseColorFactor"))
1290        .and_then(|f| {
1291            let v: Vec<f64> = f.items().iter().filter_map(Json::number).collect();
1292            <[f64; 4]>::try_from(v).ok()
1293        });
1294
1295    // A mesh with no normals given gets none invented from nothing: the
1296    // triangles' own are the only honest answer, and they are what a viewer
1297    // would have computed anyway.
1298    let normals = if normals.is_empty() {
1299        normals_from_triangles(&positions, &triangles)
1300    } else {
1301        normals
1302    };
1303    Ok(Some((
1304        Triangulation {
1305            parameters: vec![(0.0, 0.0); positions.len()],
1306            positions,
1307            normals,
1308            triangles,
1309            // The file says nothing about what deflection it was built at, so
1310            // nothing is claimed about one.
1311            deflection_met: false,
1312        },
1313        colour,
1314    )))
1315}
1316
1317/// Area-weighted vertex normals, for a file that gave none.
1318pub(crate) fn normals_from_triangles(positions: &[Point], triangles: &[[u32; 3]]) -> Vec<Vector> {
1319    let mut out = vec![Vector::new(0.0, 0.0, 0.0); positions.len()];
1320    for [a, b, c] in triangles {
1321        let (pa, pb, pc) = (
1322            positions[*a as usize],
1323            positions[*b as usize],
1324            positions[*c as usize],
1325        );
1326        // Not normalized: the cross product's length is twice the triangle's
1327        // area, which is exactly the weight a vertex normal wants.
1328        let n = (pb - pa).cross(pc - pa);
1329        for &k in &[*a, *b, *c] {
1330            out[k as usize] += n;
1331        }
1332    }
1333    for n in &mut out {
1334        let magnitude = n.magnitude();
1335        *n = if magnitude > 0.0 {
1336            *n / magnitude
1337        } else {
1338            Vector::new(0.0, 0.0, 1.0)
1339        };
1340    }
1341    out
1342}
1343
1344/// One accessor's values, flattened.
1345struct AccessorValues {
1346    /// `count * components` numbers, in order.
1347    values: Vec<f64>,
1348    /// How many numbers each element holds.
1349    components: usize,
1350    /// How many elements there are.
1351    count: usize,
1352}
1353
1354/// Read an accessor: its buffer view, its component type, its stride, and the
1355/// sparse block that overrides part of it.
1356///
1357/// An accessor with no buffer view is all zeros, which the standard says and
1358/// which is exactly what a sparse accessor over nothing means.
1359fn read_accessor(
1360    index: usize,
1361    accessors: &[crate::json::Json],
1362    views: &[crate::json::Json],
1363    buffers: &[Vec<u8>],
1364) -> ogeom_core::OgeomResult<AccessorValues> {
1365    let Some(accessor) = accessors.get(index) else {
1366        ogeom_bail!(Construction, "accessor {index} is not in this document");
1367    };
1368    let Some(kind) = accessor.get("type").and_then(crate::json::Json::text) else {
1369        ogeom_bail!(Construction, "accessor {index} states no type");
1370    };
1371    let components = match kind {
1372        "SCALAR" => 1,
1373        "VEC2" => 2,
1374        "VEC3" => 3,
1375        "VEC4" | "MAT2" => 4,
1376        "MAT3" => 9,
1377        "MAT4" => 16,
1378        other => ogeom_bail!(Construction, "`{other}` is not an accessor type"),
1379    };
1380    let Some(component_type) = accessor.index_at("componentType") else {
1381        ogeom_bail!(Construction, "accessor {index} states no componentType");
1382    };
1383    let normalized = accessor.get("normalized") == Some(&crate::json::Json::Bool(true));
1384    let count = accessor.index_at("count").unwrap_or(0);
1385    let mut values = vec![0.0; count * components];
1386
1387    if let Some(view_index) = accessor.index_at("bufferView") {
1388        let offset = accessor.index_at("byteOffset").unwrap_or(0);
1389        read_into(
1390            &mut values,
1391            view_index,
1392            offset,
1393            component_type,
1394            components,
1395            count,
1396            normalized,
1397            views,
1398            buffers,
1399        )?;
1400    }
1401
1402    // The sparse block: a run of indices, and the elements to put at them.
1403    // It comes *after* the dense read, because that is what "sparse" means:
1404    // a document may give a base and then override part of it.
1405    if let Some(sparse) = accessor.get("sparse") {
1406        let sparse_count = sparse.index_at("count").unwrap_or(0);
1407        let Some(indices) = sparse.get("indices") else {
1408            ogeom_bail!(Construction, "a sparse accessor names its indices");
1409        };
1410        let Some(sparse_values) = sparse.get("values") else {
1411            ogeom_bail!(Construction, "a sparse accessor names its values");
1412        };
1413        let Some(index_type) = indices.index_at("componentType") else {
1414            ogeom_bail!(Construction, "a sparse index has a componentType");
1415        };
1416        let Some(index_view) = indices.index_at("bufferView") else {
1417            ogeom_bail!(Construction, "a sparse index has a bufferView");
1418        };
1419        let mut which = vec![0.0; sparse_count];
1420        read_into(
1421            &mut which,
1422            index_view,
1423            indices.index_at("byteOffset").unwrap_or(0),
1424            index_type,
1425            1,
1426            sparse_count,
1427            false,
1428            views,
1429            buffers,
1430        )?;
1431        let Some(value_view) = sparse_values.index_at("bufferView") else {
1432            ogeom_bail!(Construction, "a sparse value block has a bufferView");
1433        };
1434        let mut replacement = vec![0.0; sparse_count * components];
1435        read_into(
1436            &mut replacement,
1437            value_view,
1438            sparse_values.index_at("byteOffset").unwrap_or(0),
1439            component_type,
1440            components,
1441            sparse_count,
1442            normalized,
1443            views,
1444            buffers,
1445        )?;
1446        for (slot, target) in which.iter().enumerate() {
1447            #[expect(
1448                clippy::cast_possible_truncation,
1449                clippy::cast_sign_loss,
1450                reason = "range-checked against the element count below"
1451            )]
1452            let at = *target as usize;
1453            if f64::from(u32::try_from(at).unwrap_or(u32::MAX)) != *target || at >= count {
1454                ogeom_bail!(
1455                    Construction,
1456                    "a sparse index names element {target} of {count}"
1457                );
1458            }
1459            for k in 0..components {
1460                values[at * components + k] = replacement[slot * components + k];
1461            }
1462        }
1463    }
1464
1465    Ok(AccessorValues {
1466        values,
1467        components,
1468        count,
1469    })
1470}
1471
1472/// Read `count` elements of `components` numbers out of a buffer view.
1473#[allow(clippy::too_many_arguments)]
1474fn read_into(
1475    out: &mut [f64],
1476    view_index: usize,
1477    accessor_offset: usize,
1478    component_type: usize,
1479    components: usize,
1480    count: usize,
1481    normalized: bool,
1482    views: &[crate::json::Json],
1483    buffers: &[Vec<u8>],
1484) -> ogeom_core::OgeomResult<()> {
1485    let Some(view) = views.get(view_index) else {
1486        ogeom_bail!(
1487            Construction,
1488            "buffer view {view_index} is not in this document"
1489        );
1490    };
1491    let buffer_index = view.index_at("buffer").unwrap_or(0);
1492    let Some(buffer) = buffers.get(buffer_index) else {
1493        ogeom_bail!(
1494            Construction,
1495            "buffer {buffer_index} is not in this document"
1496        );
1497    };
1498    let view_offset = view.index_at("byteOffset").unwrap_or(0);
1499    let width = match component_type {
1500        5120 | 5121 => 1,
1501        5122 | 5123 => 2,
1502        5125 | 5126 => 4,
1503        other => ogeom_bail!(Construction, "{other} is not a glTF component type"),
1504    };
1505    // A view may stride over the buffer, which is how a writer interleaves
1506    // several attributes; with no stride the elements are packed.
1507    let element = components * width;
1508    let stride = view.index_at("byteStride").unwrap_or(element);
1509    if stride < element {
1510        ogeom_bail!(
1511            Construction,
1512            "a stride of {stride} is shorter than the {element} bytes an \
1513             element occupies"
1514        );
1515    }
1516    for i in 0..count {
1517        let base = view_offset + accessor_offset + i * stride;
1518        for k in 0..components {
1519            let at = base + k * width;
1520            let Some(slice) = buffer.get(at..at + width) else {
1521                ogeom_bail!(
1522                    Construction,
1523                    "the buffer ends at {} where the accessor wants byte {at}",
1524                    buffer.len()
1525                );
1526            };
1527            out[i * components + k] = component_value(component_type, slice, normalized);
1528        }
1529    }
1530    Ok(())
1531}
1532
1533/// One component, as the number it stands for.
1534///
1535/// `normalized` is what the standard means by an integer standing in for a
1536/// fraction: unsigned types map to `[0, 1]` and signed to `[-1, 1]`, with the
1537/// signed floor clamped, which is the mapping the standard states exactly.
1538fn component_value(component_type: usize, bytes: &[u8], normalized: bool) -> f64 {
1539    match component_type {
1540        5120 => {
1541            let v = f64::from(bytes[0] as i8);
1542            if normalized { (v / 127.0).max(-1.0) } else { v }
1543        }
1544        5121 => {
1545            let v = f64::from(bytes[0]);
1546            if normalized { v / 255.0 } else { v }
1547        }
1548        5122 => {
1549            let v = f64::from(i16::from_le_bytes([bytes[0], bytes[1]]));
1550            if normalized {
1551                (v / 32767.0).max(-1.0)
1552            } else {
1553                v
1554            }
1555        }
1556        5123 => {
1557            let v = f64::from(u16::from_le_bytes([bytes[0], bytes[1]]));
1558            if normalized { v / 65535.0 } else { v }
1559        }
1560        5125 => f64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1561        _ => f64::from(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
1562    }
1563}