Skip to main content

falcon_library/model3d/
export.rs

1//! Writing a model back out, for the viewer's Export: Wavefront OBJ (with
2//! an MTL of the material colours), binary STL, glTF 2.0 (binary GLB, or a
3//! `.gltf` with its buffer embedded), binary PLY and ASCII OFF. Only what
4//! falcon holds is written: positions, normals where the file had them,
5//! vertex colours, triangles and material colours (OFF keeps positions and
6//! triangles only). 3DM, which Eagle's viewer also writes, is not.
7
8use super::math::{self, V3};
9use super::{Mesh, geom};
10
11/// Formats the viewer exports to.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Format {
14    Obj,
15    Stl,
16    /// glTF 2.0 in one binary file.
17    Glb,
18    /// glTF 2.0 as JSON with the buffer embedded as a data URI.
19    Gltf,
20    Ply,
21    /// ASCII Object File Format: positions and triangles.
22    Off,
23}
24
25impl Format {
26    /// In the order of Eagle's viewer's Export menu where it has them.
27    pub const ALL: [Format; 6] = [Format::Glb, Format::Gltf, Format::Off, Format::Obj, Format::Ply, Format::Stl];
28
29    pub fn ext(self) -> &'static str {
30        match self {
31            Format::Obj => "obj",
32            Format::Stl => "stl",
33            Format::Glb => "glb",
34            Format::Gltf => "gltf",
35            Format::Ply => "ply",
36            Format::Off => "off",
37        }
38    }
39}
40
41/// A name usable after `o`, `usemtl` and `newmtl`: no spaces, never empty.
42fn token(name: &str, fallback: &str) -> String {
43    let t: String = name.trim().chars().map(|c| if c.is_whitespace() { '_' } else { c }).collect();
44    if t.is_empty() { fallback.to_string() } else { t }
45}
46
47/// The OBJ text of `meshes`, referring to `mtl_name` when any has a
48/// material, and the MTL text (empty when there are no materials).
49pub fn obj<'a>(meshes: impl IntoIterator<Item = &'a Mesh>, mtl_name: &str) -> (String, String) {
50    use std::fmt::Write;
51    let meshes: Vec<&Mesh> = meshes.into_iter().collect();
52    let mut out = String::from("# Exported by Falcon\n");
53    let mut mtl = String::new();
54    let mut seen: Vec<String> = Vec::new();
55    if meshes.iter().any(|m| m.material.is_some()) {
56        let _ = writeln!(out, "mtllib {mtl_name}");
57    }
58    let (mut v_base, mut n_base) = (1usize, 1usize);
59    for (k, m) in meshes.iter().enumerate() {
60        let _ = writeln!(out, "o {}", token(&m.name, &format!("mesh{}", k + 1)));
61        for p in &m.positions {
62            let _ = writeln!(out, "v {} {} {}", p[0], p[1], p[2]);
63        }
64        let normals = m.normals.as_ref().filter(|n| n.len() == m.positions.len());
65        if let Some(ns) = normals {
66            for n in ns {
67                let _ = writeln!(out, "vn {} {} {}", n[0], n[1], n[2]);
68            }
69        }
70        if let Some(mat) = &m.material {
71            let name = token(&mat.name, &format!("material{}", seen.len() + 1));
72            if !seen.contains(&name) {
73                let c = mat.color;
74                let _ = writeln!(mtl, "newmtl {name}\nKd {} {} {}\nd {}\n", c[0], c[1], c[2], mat.opacity);
75                seen.push(name.clone());
76            }
77            let _ = writeln!(out, "usemtl {name}");
78        }
79        for t in m.indices.as_chunks::<3>().0 {
80            let [a, b, c] = t.map(|i| i as usize);
81            if normals.is_some() {
82                let _ = writeln!(out, "f {0}//{3} {1}//{4} {2}//{5}", a + v_base, b + v_base, c + v_base, a + n_base, b + n_base, c + n_base);
83            } else {
84                let _ = writeln!(out, "f {} {} {}", a + v_base, b + v_base, c + v_base);
85            }
86        }
87        v_base += m.positions.len();
88        if let Some(ns) = normals {
89            n_base += ns.len();
90        }
91    }
92    (out, mtl)
93}
94
95/// Binary STL of `meshes`: an 80-byte header, the triangle count, then per
96/// triangle its face normal, three corners and a zero attribute word.
97pub fn stl<'a>(meshes: impl IntoIterator<Item = &'a Mesh>) -> Vec<u8> {
98    let meshes: Vec<&Mesh> = meshes.into_iter().collect();
99    let count: usize = meshes.iter().map(|m| m.triangle_count()).sum();
100    let mut out = Vec::with_capacity(84 + count * 50);
101    let mut header = b"Exported by Falcon".to_vec();
102    header.resize(80, 0);
103    out.extend_from_slice(&header);
104    out.extend_from_slice(&(count as u32).to_le_bytes());
105    for m in meshes {
106        for t in m.indices.as_chunks::<3>().0 {
107            let p: [V3; 3] = t.map(|i| m.positions[i as usize]);
108            let n = geom::face_normal(p[0], p[1], p[2]);
109            for v in std::iter::once(n).chain(p) {
110                for c in v {
111                    out.extend_from_slice(&c.to_le_bytes());
112                }
113            }
114            out.extend_from_slice(&[0, 0]);
115        }
116    }
117    out
118}
119
120/// A colour channel from the sRGB values falcon keeps to the linear ones
121/// glTF stores (the reader's `srgb` undone).
122fn linear(c: f32) -> f32 {
123    let c = c.clamp(0.0, 1.0);
124    if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
125}
126
127/// The JSON document and binary buffer of a glTF 2.0 asset holding
128/// `meshes`, one node and mesh each, with 32-bit indices and the buffer
129/// `uri` (`None` for a GLB, whose buffer is its BIN chunk).
130fn gltf_parts<'a>(meshes: impl IntoIterator<Item = &'a Mesh>, uri: impl FnOnce(&[u8]) -> Option<String>) -> (serde_json::Value, Vec<u8>) {
131    use serde_json::{Value, json};
132    let mut bin: Vec<u8> = Vec::new();
133    let (mut views, mut accessors, mut gl_meshes, mut nodes, mut materials) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::<Value>::new());
134    let mut mat_names: Vec<String> = Vec::new();
135    // Appends floats or indices as a buffer view and an accessor; returns its index.
136    let mut push = |bin: &mut Vec<u8>, bytes: Vec<u8>, count: usize, ty: &str, component: u32, target: u32, minmax: Option<(V3, V3)>| {
137        while !bin.len().is_multiple_of(4) {
138            bin.push(0);
139        }
140        views.push(json!({"buffer": 0, "byteOffset": bin.len(), "byteLength": bytes.len(), "target": target}));
141        bin.extend_from_slice(&bytes);
142        let mut a = json!({"bufferView": views.len() - 1, "componentType": component, "count": count, "type": ty});
143        if let Some((lo, hi)) = minmax {
144            a["min"] = json!(lo);
145            a["max"] = json!(hi);
146        }
147        accessors.push(a);
148        accessors.len() - 1
149    };
150    let floats = |vs: &[V3]| vs.iter().flatten().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
151    for (k, m) in meshes.into_iter().enumerate() {
152        if m.positions.is_empty() || m.indices.is_empty() {
153            continue;
154        }
155        let lo = m.positions.iter().fold([f32::INFINITY; 3], |a, p| [a[0].min(p[0]), a[1].min(p[1]), a[2].min(p[2])]);
156        let hi = m.positions.iter().fold([f32::NEG_INFINITY; 3], |a, p| [a[0].max(p[0]), a[1].max(p[1]), a[2].max(p[2])]);
157        const ARRAY: u32 = 34962;
158        const ELEMENTS: u32 = 34963;
159        const FLOAT: u32 = 5126;
160        let mut attrs = serde_json::Map::new();
161        attrs.insert("POSITION".into(), json!(push(&mut bin, floats(&m.positions), m.positions.len(), "VEC3", FLOAT, ARRAY, Some((lo, hi)))));
162        if let Some(ns) = m.normals.as_ref().filter(|n| n.len() == m.positions.len()) {
163            attrs.insert("NORMAL".into(), json!(push(&mut bin, floats(ns), ns.len(), "VEC3", FLOAT, ARRAY, None)));
164        }
165        if let Some(cs) = m.colors.as_ref().filter(|c| c.len() == m.positions.len()) {
166            let lin: Vec<V3> = cs.iter().map(|c| c.map(linear)).collect();
167            attrs.insert("COLOR_0".into(), json!(push(&mut bin, floats(&lin), lin.len(), "VEC3", FLOAT, ARRAY, None)));
168        }
169        let idx: Vec<u8> = m.indices.iter().flat_map(|i| i.to_le_bytes()).collect();
170        let indices = push(&mut bin, idx, m.indices.len(), "SCALAR", 5125, ELEMENTS, None);
171        let mut prim = json!({"attributes": attrs, "indices": indices, "mode": 4});
172        if let Some(mat) = &m.material {
173            let at = mat_names.iter().position(|n| *n == mat.name).unwrap_or_else(|| {
174                let c = mat.color.map(linear);
175                let mut v = json!({"name": mat.name, "pbrMetallicRoughness": {"baseColorFactor": [c[0], c[1], c[2], mat.opacity.clamp(0.0, 1.0)], "metallicFactor": 0.0}});
176                if mat.opacity < 1.0 {
177                    v["alphaMode"] = json!("BLEND");
178                }
179                materials.push(v);
180                mat_names.push(mat.name.clone());
181                materials.len() - 1
182            });
183            prim["material"] = json!(at);
184        }
185        let name = if m.name.trim().is_empty() { format!("mesh{}", k + 1) } else { m.name.clone() };
186        gl_meshes.push(json!({"name": name, "primitives": [prim]}));
187        nodes.push(json!({"name": name, "mesh": gl_meshes.len() - 1}));
188    }
189    while !bin.len().is_multiple_of(4) {
190        bin.push(0);
191    }
192    let mut buffer = json!({"byteLength": bin.len()});
193    if let Some(u) = uri(&bin) {
194        buffer["uri"] = json!(u);
195    }
196    let mut doc = json!({
197        "asset": {"version": "2.0", "generator": "Falcon"},
198        "scene": 0,
199        "scenes": [{"nodes": (0..nodes.len()).collect::<Vec<_>>()}],
200        "nodes": nodes,
201        "meshes": gl_meshes,
202    });
203    if !bin.is_empty() {
204        doc["buffers"] = json!([buffer]);
205        doc["bufferViews"] = json!(views);
206        doc["accessors"] = json!(accessors);
207    }
208    if !materials.is_empty() {
209        doc["materials"] = json!(materials);
210    }
211    (doc, bin)
212}
213
214/// A binary glTF (GLB) of `meshes`: the 12-byte header, the JSON chunk
215/// (padded with spaces) and the BIN chunk.
216pub fn glb<'a>(meshes: impl IntoIterator<Item = &'a Mesh>) -> Vec<u8> {
217    let (doc, bin) = gltf_parts(meshes, |_| None);
218    let mut json = serde_json::to_vec(&doc).unwrap_or_default();
219    while !json.len().is_multiple_of(4) {
220        json.push(b' ');
221    }
222    let total = 12 + 8 + json.len() + if bin.is_empty() { 0 } else { 8 + bin.len() };
223    let mut out = Vec::with_capacity(total);
224    out.extend_from_slice(b"glTF");
225    out.extend_from_slice(&2u32.to_le_bytes());
226    out.extend_from_slice(&(total as u32).to_le_bytes());
227    out.extend_from_slice(&(json.len() as u32).to_le_bytes());
228    out.extend_from_slice(b"JSON");
229    out.extend_from_slice(&json);
230    if !bin.is_empty() {
231        out.extend_from_slice(&(bin.len() as u32).to_le_bytes());
232        out.extend_from_slice(b"BIN\0");
233        out.extend_from_slice(&bin);
234    }
235    out
236}
237
238/// A `.gltf` of `meshes`: the JSON with its buffer as a base64 data URI.
239pub fn gltf<'a>(meshes: impl IntoIterator<Item = &'a Mesh>) -> String {
240    use base64::Engine;
241    let (doc, _) = gltf_parts(meshes, |bin| Some(format!("data:application/octet-stream;base64,{}", base64::engine::general_purpose::STANDARD.encode(bin))));
242    serde_json::to_string_pretty(&doc).unwrap_or_default()
243}
244
245/// Binary little-endian PLY of `meshes` merged into one: positions, normals
246/// when every mesh has them, and colours (vertex colours, else the mesh's
247/// material colour, else the viewer's default) when any mesh has either;
248/// then the triangles.
249pub fn ply<'a>(meshes: impl IntoIterator<Item = &'a Mesh>) -> Vec<u8> {
250    let meshes: Vec<&Mesh> = meshes.into_iter().filter(|m| !m.positions.is_empty()).collect();
251    let vertices: usize = meshes.iter().map(|m| m.positions.len()).sum();
252    let faces: usize = meshes.iter().map(|m| m.triangle_count()).sum();
253    let normals = !meshes.is_empty() && meshes.iter().all(|m| m.normals.as_ref().is_some_and(|n| n.len() == m.positions.len()));
254    let colors = meshes.iter().any(|m| m.colors.is_some() || m.material.is_some());
255    let mut head = format!("ply\nformat binary_little_endian 1.0\ncomment Exported by Falcon\nelement vertex {vertices}\nproperty float x\nproperty float y\nproperty float z\n");
256    if normals {
257        head.push_str("property float nx\nproperty float ny\nproperty float nz\n");
258    }
259    if colors {
260        head.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
261    }
262    head.push_str(&format!("element face {faces}\nproperty list uchar int vertex_indices\nend_header\n"));
263    let mut out = head.into_bytes();
264    let byte = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
265    for m in &meshes {
266        let fallback = m.material.as_ref().map_or(super::DEFAULT_COLOR, |mat| mat.color);
267        for (i, p) in m.positions.iter().enumerate() {
268            for c in p {
269                out.extend_from_slice(&c.to_le_bytes());
270            }
271            if normals && let Some(n) = m.normals.as_ref().and_then(|ns| ns.get(i)) {
272                for c in n {
273                    out.extend_from_slice(&c.to_le_bytes());
274                }
275            }
276            if colors {
277                let c = m.colors.as_ref().and_then(|cs| cs.get(i)).copied().unwrap_or(fallback);
278                out.extend(c.map(byte));
279            }
280        }
281    }
282    let mut base = 0u32;
283    for m in &meshes {
284        for t in m.indices.as_chunks::<3>().0 {
285            out.push(3);
286            for i in t {
287                out.extend_from_slice(&((base + i) as i32).to_le_bytes());
288            }
289        }
290        base += m.positions.len() as u32;
291    }
292    out
293}
294
295/// ASCII OFF of `meshes` merged into one: `OFF`, the vertex and face counts
296/// and a zero edge count, a line per vertex, then `3 a b c` per triangle
297/// with zero-based indices offset per mesh.
298pub fn off<'a>(meshes: impl IntoIterator<Item = &'a Mesh>) -> String {
299    use std::fmt::Write;
300    let meshes: Vec<&Mesh> = meshes.into_iter().filter(|m| !m.positions.is_empty()).collect();
301    let vertices: usize = meshes.iter().map(|m| m.positions.len()).sum();
302    let faces: usize = meshes.iter().map(|m| m.triangle_count()).sum();
303    let mut out = format!("OFF\n{vertices} {faces} 0\n");
304    for p in meshes.iter().flat_map(|m| &m.positions) {
305        let _ = writeln!(out, "{} {} {}", p[0], p[1], p[2]);
306    }
307    let mut base = 0usize;
308    for m in &meshes {
309        for t in m.indices.as_chunks::<3>().0 {
310            let [a, b, c] = t.map(|i| i as usize + base);
311            let _ = writeln!(out, "3 {a} {b} {c}");
312        }
313        base += m.positions.len();
314    }
315    out
316}
317
318/// Writes `meshes` to `path` in `format`; an OBJ gets its MTL next to it
319/// (same name, `.mtl`) when it has materials.
320pub fn write<'a>(meshes: impl IntoIterator<Item = &'a Mesh>, format: Format, path: &std::path::Path) -> std::io::Result<()> {
321    match format {
322        Format::Stl => std::fs::write(path, stl(meshes)),
323        Format::Glb => std::fs::write(path, glb(meshes)),
324        Format::Gltf => std::fs::write(path, gltf(meshes)),
325        Format::Ply => std::fs::write(path, ply(meshes)),
326        Format::Off => std::fs::write(path, off(meshes)),
327        Format::Obj => {
328            let mtl_path = path.with_extension("mtl");
329            let mtl_name = mtl_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
330            let (obj, mtl) = obj(meshes, &mtl_name);
331            std::fs::write(path, obj)?;
332            if !mtl.is_empty() {
333                std::fs::write(mtl_path, mtl)?;
334            }
335            Ok(())
336        }
337    }
338}
339
340/// A point where a ray meets a visible triangle.
341#[derive(Clone, Copy, Debug, PartialEq)]
342pub struct Hit {
343    pub point: V3,
344    /// The face's unit normal.
345    pub normal: V3,
346    /// The index of the mesh hit.
347    pub mesh: usize,
348}
349
350/// The nearest triangle of the meshes for which `visible(i)` holds that the
351/// ray from `origin` along `dir` meets (Möller–Trumbore), for the viewer's
352/// measure tool.
353pub fn pick(meshes: &[Mesh], visible: impl Fn(usize) -> bool, origin: V3, dir: V3) -> Option<Hit> {
354    let mut best: Option<(f32, Hit)> = None;
355    for (k, m) in meshes.iter().enumerate().filter(|(k, _)| visible(*k)) {
356        for t in m.indices.as_chunks::<3>().0 {
357            let [a, b, c] = t.map(|i| m.positions[i as usize]);
358            let (e1, e2) = (math::sub(b, a), math::sub(c, a));
359            let p = math::cross(dir, e2);
360            let det = math::dot(e1, p);
361            if det.abs() < 1e-12 {
362                continue;
363            }
364            let inv = 1.0 / det;
365            let s = math::sub(origin, a);
366            let u = math::dot(s, p) * inv;
367            if !(0.0..=1.0).contains(&u) {
368                continue;
369            }
370            let q = math::cross(s, e1);
371            let v = math::dot(dir, q) * inv;
372            if v < 0.0 || u + v > 1.0 {
373                continue;
374            }
375            let d = math::dot(e2, q) * inv;
376            if d > 0.0 && best.is_none_or(|(bd, _)| d < bd) {
377                let hit = Hit { point: math::add(origin, math::scale(dir, d)), normal: geom::face_normal(a, b, c), mesh: k };
378                best = Some((d, hit));
379            }
380        }
381    }
382    best.map(|(_, h)| h)
383}
384
385/// What the measure tool shows for two picked points: their distance, the
386/// angle between the faces (degrees) and, when the faces are parallel, the
387/// distance between their planes.
388#[derive(Clone, Copy, Debug, PartialEq)]
389pub struct Measure {
390    pub distance: f32,
391    pub angle: f32,
392    pub parallel: Option<f32>,
393}
394
395pub fn measure(a: &Hit, b: &Hit) -> Measure {
396    let distance = math::length(math::sub(b.point, a.point));
397    let cos = math::dot(a.normal, b.normal).clamp(-1.0, 1.0);
398    let angle = cos.acos().to_degrees();
399    let parallel = (cos.abs() > 1.0 - 1e-5).then(|| math::dot(math::sub(b.point, a.point), a.normal).abs());
400    Measure { distance, angle, parallel }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::super::Material;
406    use super::super::geom::tests::cube;
407    use super::*;
408
409    #[test]
410    fn obj_round_trips_through_the_reader() {
411        let mut m = cube(false);
412        m.name = "box one".into();
413        m.material = Some(Material { name: "red paint".into(), color: [1.0, 0.0, 0.0], opacity: 1.0 });
414        let second = Mesh { name: "".into(), ..cube(false) };
415        let (text, mtl) = obj([&m, &second], "x.mtl");
416        assert!(text.starts_with("# Exported by Falcon\nmtllib x.mtl\no box_one\n"));
417        assert!(mtl.contains("newmtl red_paint\nKd 1 0 0"));
418        assert!(text.contains("o mesh2"));
419        let side = |name: &str| (name == "x.mtl").then(|| mtl.clone().into_bytes());
420        let back = super::super::parse(text.as_bytes(), "obj", &side).unwrap();
421        assert_eq!(back.triangle_count(), 24);
422        assert_eq!(back.meshes[0].material.as_ref().map(|m| m.color), Some([1.0, 0.0, 0.0]));
423    }
424
425    #[test]
426    fn stl_round_trips_through_the_reader() {
427        let m = cube(false);
428        let bytes = stl([&m]);
429        assert_eq!(bytes.len(), 84 + 12 * 50);
430        let back = super::super::parse(&bytes, "stl", &|_| None).unwrap();
431        assert_eq!(back.triangle_count(), 12);
432        assert_eq!(back.bounds(), Some(([0.0; 3], [1.0; 3])));
433    }
434
435    fn painted() -> (Mesh, Mesh) {
436        let mut a = cube(true);
437        a.name = "box".into();
438        a.material = Some(Material { name: "red".into(), color: [0.8, 0.2, 0.1], opacity: 1.0 });
439        a.normals = Some(a.indices.as_chunks::<3>().0.iter().flat_map(|t| {
440            let n = geom::face_normal(a.positions[t[0] as usize], a.positions[t[1] as usize], a.positions[t[2] as usize]);
441            [n; 3]
442        }).collect());
443        let mut b = cube(false);
444        b.positions.iter_mut().for_each(|p| p[0] += 2.0);
445        b.colors = Some(vec![[0.0, 1.0, 0.5]; b.positions.len()]);
446        (a, b)
447    }
448
449    fn close(a: V3, b: V3) -> bool {
450        (0..3).all(|i| (a[i] - b[i]).abs() < 2e-3)
451    }
452
453    #[test]
454    fn glb_round_trips_through_the_reader() {
455        let (a, b) = painted();
456        let bytes = glb([&a, &b]);
457        assert_eq!(&bytes[..4], b"glTF");
458        assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize, bytes.len());
459        assert_eq!(bytes.len() % 4, 0);
460        let back = super::super::parse(&bytes, "glb", &|_| None).unwrap();
461        assert_eq!((back.meshes.len(), back.triangle_count(), back.vertex_count()), (2, 24, a.positions.len() + b.positions.len()));
462        assert_eq!(back.meshes[0].name, "box");
463        let mat = back.meshes[0].material.as_ref().unwrap();
464        assert!(close(mat.color, [0.8, 0.2, 0.1]), "sRGB survives the linear store: {:?}", mat.color);
465        assert!(back.meshes[0].normals.is_some());
466        assert!(close(back.meshes[1].colors.as_ref().unwrap()[0], [0.0, 1.0, 0.5]));
467        assert_eq!(back.bounds(), Some(([0.0; 3], [3.0, 1.0, 1.0])));
468        // Nothing visible still makes a well-formed file.
469        let empty = glb(std::iter::empty());
470        assert_eq!(u32::from_le_bytes(empty[8..12].try_into().unwrap()) as usize, empty.len());
471    }
472
473    #[test]
474    fn gltf_embeds_its_buffer() {
475        let (a, b) = painted();
476        let text = gltf([&a, &b]);
477        assert!(text.contains("data:application/octet-stream;base64,"));
478        let back = super::super::gltf::parse_gltf(text.as_bytes(), &|_| None).unwrap();
479        assert_eq!(back.triangle_count(), 24);
480        assert!(close(back.meshes[0].material.as_ref().unwrap().color, [0.8, 0.2, 0.1]));
481    }
482
483    #[test]
484    fn ply_round_trips_through_the_reader() {
485        let (a, b) = painted();
486        let bytes = ply([&a, &b]);
487        let head = String::from_utf8_lossy(&bytes[..bytes.windows(11).position(|w| w == b"end_header\n").unwrap()]).into_owned();
488        assert!(head.contains(&format!("element vertex {}\n", a.positions.len() + b.positions.len())));
489        assert!(!head.contains("nx"), "one mesh has no normals, so none are written");
490        let back = super::super::parse(&bytes, "ply", &|_| None).unwrap();
491        assert_eq!(back.triangle_count(), 24);
492        let cs = back.meshes[0].colors.as_ref().unwrap();
493        assert!(close(cs[0], [204.0 / 255.0, 51.0 / 255.0, 26.0 / 255.0]), "the material colour per vertex: {:?}", cs[0]);
494        assert!(close(cs[a.positions.len()], [0.0, 1.0, 128.0 / 255.0]));
495        assert_eq!(back.bounds(), Some(([0.0; 3], [3.0, 1.0, 1.0])));
496        // A plain mesh writes positions and faces only.
497        let plain = ply([&cube(false)]);
498        assert!(!String::from_utf8_lossy(&plain).contains("red"));
499        assert_eq!(super::super::parse(&plain, "ply", &|_| None).unwrap().triangle_count(), 12);
500    }
501
502    #[test]
503    fn off_round_trips_through_the_reader() {
504        let (a, b) = painted();
505        let text = off([&a, &b]);
506        let nv = a.positions.len() + b.positions.len();
507        assert!(text.starts_with(&format!("OFF\n{nv} 24 0\n")), "{}", &text[..20]);
508        assert_eq!(text.lines().count(), 2 + nv + 24);
509        let back = super::super::off::parse(text.as_bytes()).unwrap();
510        let m = &back.meshes[0];
511        let all: Vec<V3> = a.positions.iter().chain(&b.positions).copied().collect();
512        assert_eq!(m.positions.len(), all.len());
513        assert!(m.positions.iter().zip(&all).all(|(p, q)| close(*p, *q)));
514        let want: Vec<u32> = a.indices.iter().copied().chain(b.indices.iter().map(|i| i + a.positions.len() as u32)).collect();
515        assert_eq!(m.indices, want);
516        assert_eq!(back.bounds(), Some(([0.0; 3], [3.0, 1.0, 1.0])));
517    }
518
519    #[test]
520    fn off_offsets_indices_per_mesh() {
521        let (a, b) = painted();
522        let text = off([&a, &b]);
523        let n = a.positions.len();
524        let last = text.lines().last().unwrap();
525        let ix: Vec<usize> = last.split(' ').skip(1).map(|s| s.parse().unwrap()).collect();
526        assert!(ix.iter().all(|&i| i >= n && i < n + b.positions.len()), "{last}");
527        assert!(off(std::iter::empty()).starts_with("OFF\n0 0 0\n"));
528    }
529
530    #[test]
531    fn off_reader_takes_comments_polygons_and_colours() {
532        let text = "OFF\n# comment\n\n4 1 0\n0 0 0\n1 0 0\n1 1 0 # c\n0 1 0\n4 0 1 2 3 255 0 0\n";
533        let m = &super::super::off::parse(text.as_bytes()).unwrap().meshes[0];
534        assert_eq!(m.indices, vec![0, 1, 2, 0, 2, 3]);
535    }
536
537    #[test]
538    fn rays_hit_the_nearest_visible_face() {
539        let meshes = vec![cube(false)];
540        let h = pick(&meshes, |_| true, [0.5, 0.5, 5.0], [0.0, 0.0, -1.0]).unwrap();
541        assert!((h.point[2] - 1.0).abs() < 1e-5, "{h:?}");
542        assert!((h.normal[2].abs() - 1.0).abs() < 1e-5);
543        assert!(pick(&meshes, |_| false, [0.5, 0.5, 5.0], [0.0, 0.0, -1.0]).is_none());
544        assert!(pick(&meshes, |_| true, [5.0, 5.0, 5.0], [0.0, 0.0, -1.0]).is_none());
545        let far = pick(&meshes, |_| true, [0.5, 0.5, -5.0], [0.0, 0.0, 1.0]).unwrap();
546        let m = measure(&h, &far);
547        assert!((m.distance - 1.0).abs() < 1e-5);
548        assert!((m.parallel.unwrap() - 1.0).abs() < 1e-5);
549        let side = pick(&meshes, |_| true, [5.0, 0.5, 0.5], [-1.0, 0.0, 0.0]).unwrap();
550        let m = measure(&h, &side);
551        assert!((m.angle - 90.0).abs() < 1e-3 && m.parallel.is_none());
552    }
553}