Skip to main content

brepkit_io/obj/
reader.rs

1//! OBJ file reader.
2
3use brepkit_math::vec::{Point3, Vec3};
4use brepkit_operations::tessellate::TriangleMesh;
5use brepkit_topology::Topology;
6use brepkit_topology::solid::SolidId;
7
8/// Read an OBJ file from a string and return a triangle mesh.
9///
10/// Supports:
11/// - Vertex positions (`v x y z`)
12/// - Vertex normals (`vn x y z`)
13/// - Triangle and polygon faces (`f v1 v2 v3 ...` or `f v1//n1 v2//n2 ...`)
14///
15/// Polygons with more than 3 vertices are triangulated using fan triangulation.
16///
17/// # Errors
18///
19/// Returns an error if the file is malformed.
20pub fn read_obj(input: &str) -> Result<TriangleMesh, crate::IoError> {
21    let mut positions: Vec<Point3> = Vec::new();
22    let mut normals: Vec<Vec3> = Vec::new();
23    let mut indices: Vec<u32> = Vec::new();
24
25    for line in input.lines() {
26        let line = line.trim();
27        if line.is_empty() || line.starts_with('#') {
28            continue;
29        }
30
31        let mut parts = line.split_whitespace();
32        match parts.next() {
33            Some("v") => {
34                let coords = parse_3_floats(&mut parts, line)?;
35                positions.push(Point3::new(coords[0], coords[1], coords[2]));
36            }
37            Some("vn") => {
38                let coords = parse_3_floats(&mut parts, line)?;
39                normals.push(Vec3::new(coords[0], coords[1], coords[2]));
40            }
41            Some("f") => {
42                let face_indices = parse_face_indices(&mut parts, line)?;
43                if face_indices.len() < 3 {
44                    return Err(crate::IoError::ParseError {
45                        reason: format!("face with fewer than 3 vertices: {line}"),
46                    });
47                }
48                // Fan triangulation: v0-v1-v2, v0-v2-v3, v0-v3-v4, ...
49                let v0 = face_indices[0];
50                for i in 1..face_indices.len() - 1 {
51                    indices.push(v0);
52                    indices.push(face_indices[i]);
53                    indices.push(face_indices[i + 1]);
54                }
55            }
56            _ => {
57                // Ignore unsupported lines (vt, g, mtllib, usemtl, s, etc.)
58            }
59        }
60    }
61
62    if normals.is_empty() {
63        normals = compute_vertex_normals(&positions, &indices);
64    }
65
66    normals.resize(positions.len(), Vec3::new(0.0, 0.0, 1.0));
67
68    Ok(TriangleMesh {
69        positions,
70        normals,
71        indices,
72    })
73}
74
75/// Parse a face index token like "1", "1/2", "1/2/3", or "1//3".
76/// Returns the 0-based vertex index.
77fn parse_face_index(token: &str, line: &str) -> Result<u32, crate::IoError> {
78    let idx_str = token.split('/').next().unwrap_or(token);
79    let idx: i64 = idx_str.parse().map_err(|_| crate::IoError::ParseError {
80        reason: format!("invalid face index in: {line}"),
81    })?;
82    if idx <= 0 {
83        return Err(crate::IoError::ParseError {
84            reason: format!("negative or zero face index in: {line}"),
85        });
86    }
87    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
88    Ok((idx - 1) as u32) // OBJ is 1-indexed
89}
90
91fn parse_face_indices(
92    parts: &mut std::str::SplitWhitespace<'_>,
93    line: &str,
94) -> Result<Vec<u32>, crate::IoError> {
95    let mut result = Vec::new();
96    for token in parts {
97        result.push(parse_face_index(token, line)?);
98    }
99    Ok(result)
100}
101
102fn parse_3_floats(
103    parts: &mut std::str::SplitWhitespace<'_>,
104    line: &str,
105) -> Result<[f64; 3], crate::IoError> {
106    let mut coords = [0.0; 3];
107    for coord in &mut coords {
108        *coord = parts
109            .next()
110            .ok_or_else(|| crate::IoError::ParseError {
111                reason: format!("expected 3 coordinates in: {line}"),
112            })?
113            .parse()
114            .map_err(|_| crate::IoError::ParseError {
115                reason: format!("invalid float in: {line}"),
116            })?;
117    }
118    Ok(coords)
119}
120
121/// Compute per-vertex normals by averaging adjacent face normals.
122fn compute_vertex_normals(positions: &[Point3], indices: &[u32]) -> Vec<Vec3> {
123    let mut normals = vec![Vec3::new(0.0, 0.0, 0.0); positions.len()];
124
125    for tri in indices.chunks_exact(3) {
126        let i0 = tri[0] as usize;
127        let i1 = tri[1] as usize;
128        let i2 = tri[2] as usize;
129
130        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
131            continue;
132        }
133
134        let e1 = positions[i1] - positions[i0];
135        let e2 = positions[i2] - positions[i0];
136        let face_normal = e1.cross(e2);
137
138        normals[i0] += face_normal;
139        normals[i1] += face_normal;
140        normals[i2] += face_normal;
141    }
142
143    for n in &mut normals {
144        let len = n.length();
145        if len > 1e-15 {
146            *n = Vec3::new(n.x() / len, n.y() / len, n.z() / len);
147        } else {
148            *n = Vec3::new(0.0, 0.0, 1.0);
149        }
150    }
151
152    normals
153}
154
155/// Read an OBJ file and import it as a solid with one planar face per triangle.
156///
157/// This is a convenience wrapper that calls [`read_obj`] followed by
158/// [`import_mesh`](crate::stl::import::import_mesh). Vertices within
159/// `tolerance` of each other are merged.
160///
161/// # Errors
162///
163/// Returns [`IoError`](crate::IoError) if the file is malformed or the mesh
164/// cannot be converted to a valid solid.
165pub fn read_obj_solid(
166    topo: &mut Topology,
167    input: &str,
168    tolerance: f64,
169) -> Result<SolidId, crate::IoError> {
170    let mesh = read_obj(input)?;
171    crate::stl::import::import_mesh(topo, &mesh, tolerance)
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn read_simple_triangle() {
181        let obj = "\
182v 0.0 0.0 0.0
183v 1.0 0.0 0.0
184v 0.0 1.0 0.0
185f 1 2 3
186";
187        let mesh = read_obj(obj).unwrap();
188        assert_eq!(mesh.positions.len(), 3);
189        assert_eq!(mesh.indices.len(), 3);
190        assert_eq!(mesh.indices, vec![0, 1, 2]);
191    }
192
193    #[test]
194    fn read_quad_fan_triangulation() {
195        let obj = "\
196v 0.0 0.0 0.0
197v 1.0 0.0 0.0
198v 1.0 1.0 0.0
199v 0.0 1.0 0.0
200f 1 2 3 4
201";
202        let mesh = read_obj(obj).unwrap();
203        assert_eq!(mesh.positions.len(), 4);
204        // Quad should be split into 2 triangles
205        assert_eq!(mesh.indices.len(), 6);
206    }
207
208    #[test]
209    fn read_with_normals() {
210        let obj = "\
211v 0.0 0.0 0.0
212v 1.0 0.0 0.0
213v 0.0 1.0 0.0
214vn 0.0 0.0 1.0
215f 1//1 2//1 3//1
216";
217        let mesh = read_obj(obj).unwrap();
218        assert_eq!(mesh.positions.len(), 3);
219        assert_eq!(mesh.normals.len(), 3);
220    }
221
222    #[test]
223    fn read_with_comments() {
224        let obj = "\
225# This is a comment
226v 0.0 0.0 0.0
227# Another comment
228v 1.0 0.0 0.0
229v 0.0 1.0 0.0
230f 1 2 3
231";
232        let mesh = read_obj(obj).unwrap();
233        assert_eq!(mesh.positions.len(), 3);
234    }
235
236    #[test]
237    fn roundtrip_write_read() {
238        let mut topo = brepkit_topology::Topology::new();
239        let solid = brepkit_operations::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
240
241        let obj_str = crate::obj::write_obj(&topo, &[solid], 0.1).unwrap();
242        let mesh = read_obj(&obj_str).unwrap();
243
244        assert!(!mesh.positions.is_empty(), "should have vertices");
245        assert!(!mesh.indices.is_empty(), "should have faces");
246        assert_eq!(mesh.indices.len() % 3, 0, "indices should be multiple of 3");
247    }
248
249    #[test]
250    fn read_empty_error() {
251        let obj = "";
252        let mesh = read_obj(obj).unwrap();
253        assert_eq!(mesh.positions.len(), 0);
254        assert_eq!(mesh.indices.len(), 0);
255    }
256
257    #[test]
258    fn computed_normals_are_unit() {
259        let obj = "\
260v 0.0 0.0 0.0
261v 1.0 0.0 0.0
262v 0.0 1.0 0.0
263f 1 2 3
264";
265        let mesh = read_obj(obj).unwrap();
266        for n in &mesh.normals {
267            let len = n.length();
268            assert!(
269                (len - 1.0).abs() < 1e-10,
270                "normal should be unit length, got {len}"
271            );
272        }
273    }
274
275    #[test]
276    fn read_obj_solid_returns_solid_id() {
277        // Minimal closed tetrahedron.
278        let obj = "\
279v 0 0 0
280v 1 0 0
281v 0 1 0
282v 0 0 1
283f 1 2 3
284f 1 3 4
285f 1 4 2
286f 2 4 3
287";
288        let mut topo = brepkit_topology::Topology::new();
289        let result = read_obj_solid(&mut topo, obj, 1e-6);
290        assert!(
291            result.is_ok(),
292            "read_obj_solid should return Ok: {result:?}"
293        );
294    }
295}