Skip to main content

falcon_library/model3d/
mod.rs

1//! 3D models: readers for the formats falcon can show itself (OBJ with MTL
2//! colours, STL, PLY, GLB, 3MF, FBX, COLLADA, 3DS and IFC), the triangle
3//! model they produce, the measurements Eagle's model viewer shows, and the
4//! geometry the viewer and the thumbnail renderer share (normals, feature
5//! edges, shading).
6//!
7//! IFC geometry comes from web-ifc when falcon is built with it; without
8//! it only tessellated IFC geometry is read, and IFC files without any keep
9//! their QuickLook still.
10
11pub mod dae;
12pub mod earclip;
13pub mod export;
14pub mod fbx;
15pub mod geom;
16pub mod gltf;
17pub mod ifc;
18mod ifc_native;
19pub mod math;
20pub mod obj;
21pub mod off;
22pub mod ply;
23pub mod raster;
24pub mod step;
25pub mod stl;
26pub mod texture;
27pub mod threemf;
28pub mod threeds;
29
30use std::path::Path;
31
32use math::V3;
33
34/// Extensions read here, lower case: Eagle's model formats. A `.gltf` is a
35/// plain item with a QuickLook thumbnail, as in Eagle.
36pub const EXTENSIONS: &[&str] = &["obj", "stl", "ply", "glb", "3mf", "fbx", "dae", "3ds", "ifc"];
37
38/// Whether files with extension `ext` (any case) are read here.
39pub fn is_supported(ext: &str) -> bool {
40    EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext))
41}
42
43/// The colour a mesh without a material is drawn in, as in Eagle's viewer
44/// (200, 200, 200), in 0..1.
45pub const DEFAULT_COLOR: V3 = [200.0 / 255.0, 200.0 / 255.0, 200.0 / 255.0];
46
47/// Why a file could not be read as a model.
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49#[error("{0}")]
50pub struct ParseError(pub String);
51
52impl ParseError {
53    pub fn new(e: impl std::fmt::Display) -> Self {
54        ParseError(e.to_string())
55    }
56}
57
58pub type Result<T> = std::result::Result<T, ParseError>;
59
60/// A material as far as the viewer uses it: a name and a base colour.
61#[derive(Debug, Clone, PartialEq)]
62pub struct Material {
63    pub name: String,
64    /// Base (diffuse) colour, each channel 0..1, in the file's own (sRGB)
65    /// values.
66    pub color: V3,
67    /// 1 is opaque.
68    pub opacity: f32,
69}
70
71/// One part of a model: triangles over shared vertices, drawn in one
72/// material.
73#[derive(Debug, Clone, Default, PartialEq)]
74pub struct Mesh {
75    pub name: String,
76    pub positions: Vec<V3>,
77    /// Normals from the file, one per position, when it has them.
78    pub normals: Option<Vec<V3>>,
79    /// Vertex colours (0..1), one per position, when the file has them;
80    /// they replace the material colour.
81    pub colors: Option<Vec<V3>>,
82    /// Three indices into `positions` per triangle.
83    pub indices: Vec<u32>,
84    pub material: Option<Material>,
85    /// Texture coordinates, one per position, in picture space (u right,
86    /// v down; see [`texture`]), when the file has them.
87    pub uvs: Option<Vec<[f32; 2]>>,
88    /// The material's texture, drawn where the mesh has `uvs`; otherwise
89    /// (and where the picture couldn't be read) the material colour is.
90    pub texture: Option<texture::Texture>,
91    /// What the material adds to its colour and picture.
92    pub surface: Surface,
93}
94
95/// What a material adds to its colour and picture, as far as Eagle's viewer
96/// draws it. The maps are read with the mesh's one set of coordinates
97/// (`uvs`) and are not drawn without them. A map that is missing or can't
98/// be decoded is `None` and the surface draws as if the file had none.
99#[derive(Debug, Clone, Default, PartialEq)]
100pub struct Surface {
101    /// Tangent-space normals, green up in the picture. Wins over `bump_map`.
102    pub normal_map: Option<texture::Texture>,
103    /// Heights in its red channel, 1 being one unit of the model's space.
104    pub bump_map: Option<texture::Texture>,
105    /// Multiplies `emissive`, so it shows nothing when that is black.
106    pub emissive_map: Option<texture::Texture>,
107    /// Light the surface gives off whatever the lighting, in display values.
108    pub emissive: V3,
109    /// Drawn see-through: its alpha is the material's opacity times the
110    /// picture's. Otherwise both are ignored and it is opaque.
111    pub blend: bool,
112}
113
114impl Mesh {
115    pub fn triangle_count(&self) -> usize {
116        self.indices.len() / 3
117    }
118
119    /// Drops triangles that point past the vertices and lists that are
120    /// not one per vertex, so the rest of the code can index freely.
121    pub fn sanitize(&mut self) {
122        let n = self.positions.len();
123        let whole = self.indices.len() / 3 * 3;
124        self.indices.truncate(whole);
125        if self.indices.iter().any(|&i| i as usize >= n) {
126            self.indices = self.indices.as_chunks::<3>().0.iter().filter(|t| t.iter().all(|&i| (i as usize) < n)).flatten().copied().collect();
127        }
128        if self.normals.as_ref().is_some_and(|v| v.len() != n) {
129            self.normals = None;
130        }
131        if self.colors.as_ref().is_some_and(|v| v.len() != n) {
132            self.colors = None;
133        }
134        if self.uvs.as_ref().is_some_and(|v| v.len() != n) {
135            self.uvs = None;
136        }
137        if self.uvs.is_none() {
138            let s = &mut self.surface;
139            (s.normal_map, s.bump_map, s.emissive_map) = (None, None, None);
140        }
141        if !self.surface.emissive.iter().all(|c| c.is_finite()) {
142            self.surface.emissive = [0.0; 3];
143        }
144    }
145
146    /// The texture and coordinates to draw with, when the mesh has both.
147    pub fn textured(&self) -> Option<(&texture::Texture, &[[f32; 2]])> {
148        Some((self.texture.as_ref()?, self.uvs.as_deref()?))
149    }
150
151    /// The alpha the material gives every pixel: its opacity when it is
152    /// drawn see-through, otherwise 1.
153    pub fn opacity(&self) -> f32 {
154        if self.surface.blend { self.material.as_ref().map_or(1.0, |m| m.opacity.clamp(0.0, 1.0)) } else { 1.0 }
155    }
156
157    /// Whether the plain colour pipeline can't draw it: it has a picture,
158    /// a map, its own light or transparency.
159    pub fn has_look(&self) -> bool {
160        let s = &self.surface;
161        self.textured().is_some() || s.blend || s.emissive != [0.0; 3] || (self.uvs.is_some() && (s.normal_map.is_some() || s.bump_map.is_some()))
162    }
163}
164
165/// A whole model: its meshes in file order.
166#[derive(Debug, Clone, Default, PartialEq)]
167pub struct Model {
168    pub meshes: Vec<Mesh>,
169}
170
171impl Model {
172    pub fn vertex_count(&self) -> usize {
173        self.meshes.iter().map(|m| m.positions.len()).sum()
174    }
175
176    pub fn triangle_count(&self) -> usize {
177        self.meshes.iter().map(Mesh::triangle_count).sum()
178    }
179
180    /// The smallest box around every vertex used by a triangle, as
181    /// (min, max); `None` for a model with no triangles.
182    pub fn bounds(&self) -> Option<(V3, V3)> {
183        let mut b: Option<(V3, V3)> = None;
184        for m in &self.meshes {
185            for &i in &m.indices {
186                let p = m.positions[i as usize];
187                if !p.iter().all(|c| c.is_finite()) {
188                    continue;
189                }
190                let (lo, hi) = b.get_or_insert((p, p));
191                for k in 0..3 {
192                    lo[k] = lo[k].min(p[k]);
193                    hi[k] = hi[k].max(p[k]);
194                }
195            }
196        }
197        b
198    }
199
200    /// The sphere around the bounding box, as (centre, radius).
201    pub fn bounding_sphere(&self) -> Option<(V3, f32)> {
202        let (lo, hi) = self.bounds()?;
203        let c = math::scale(math::add(lo, hi), 0.5);
204        Some((c, math::length(math::sub(hi, c))))
205    }
206
207    /// Drops meshes left without triangles and fixes bad indices; every
208    /// reader calls this last.
209    pub fn finish(mut self) -> Result<Model> {
210        for m in &mut self.meshes {
211            m.sanitize();
212        }
213        self.meshes.retain(|m| !m.indices.is_empty());
214        if self.meshes.is_empty() {
215            return Err(ParseError::new("the file has no triangles"));
216        }
217        Ok(self)
218    }
219}
220
221/// Reads the model at `path`, picking the reader by `ext`. Files a model
222/// refers to (an OBJ's MTL, a GLB's external buffers) are looked up next to it.
223pub fn load(path: &Path, ext: &str) -> Result<Model> {
224    let bytes = std::fs::read(path).map_err(ParseError::new)?;
225    let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
226    let side = move |name: &str| -> Option<Vec<u8>> {
227        // Only plain names next to the model: no climbing out of its folder.
228        let rel = Path::new(name);
229        if rel.is_absolute() || rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
230            return None;
231        }
232        std::fs::read(dir.join(rel)).ok()
233    };
234    parse(&bytes, ext, &side)
235}
236
237/// Reads a model from `bytes` by extension. `side` fetches a file the model
238/// refers to by its relative name.
239pub fn parse(bytes: &[u8], ext: &str, side: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
240    match ext.to_ascii_lowercase().as_str() {
241        "obj" => obj::parse(bytes, side),
242        "stl" => stl::parse(bytes),
243        "ply" => ply::parse(bytes),
244        "glb" => gltf::parse_glb(bytes, side),
245        "3mf" => threemf::parse(bytes),
246        "fbx" => fbx::parse(bytes, side),
247        "dae" => dae::parse(bytes, side),
248        "3ds" => threeds::parse(bytes, side),
249        "ifc" => ifc::parse(bytes),
250        other => Err(ParseError(format!("cannot read .{other} models"))),
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn load_reads_files_and_their_neighbours() {
260        let dir = tempfile::tempdir().unwrap();
261        let sub = dir.path().join("models");
262        std::fs::create_dir(&sub).unwrap();
263        std::fs::write(sub.join("cube.mtl"), "newmtl red\nKd 1 0 0\n").unwrap();
264        // A material library outside the model's folder is not read.
265        std::fs::write(dir.path().join("outside.mtl"), "newmtl blue\nKd 0 0 1\n").unwrap();
266        let obj = "mtllib cube.mtl\nmtllib ../outside.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl red\nf 1 2 3\nusemtl blue\nf 1 3 2\n";
267        std::fs::write(sub.join("Cube.OBJ"), obj).unwrap();
268        let m = load(&sub.join("Cube.OBJ"), "OBJ").unwrap();
269        assert_eq!(m.meshes.len(), 2);
270        assert_eq!(m.meshes[0].material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
271        assert_eq!(m.meshes[1].material.as_ref().unwrap().color, DEFAULT_COLOR, "../ is refused");
272        // ASCII STL through the same door.
273        std::fs::write(sub.join("t.stl"), "solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n").unwrap();
274        assert_eq!(load(&sub.join("t.stl"), "stl").unwrap().triangle_count(), 1);
275        assert!(load(&sub.join("missing.ply"), "ply").is_err());
276        assert!(parse(b"", "fbx", &|_| None).is_err());
277        assert!(parse(b"", "dae", &|_| None).is_err());
278        assert!(is_supported("GLB") && is_supported("FBX") && is_supported("Dae") && is_supported("3DS"));
279    }
280}