Skip to main content

bunny_codec/
obj.rs

1use bunny_mesh::Triangle32;
2
3mod error;
4mod float;
5mod iter;
6
7pub use error::ObjError;
8use iter::find_record;
9pub use iter::{ObjTriangles, ObjVertices};
10
11/// A borrowed OBJ mesh view over the original text input.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub struct ObjMesh<'a> {
14    source: &'a str,
15    vertex_count: usize,
16    face_count: usize,
17}
18
19impl<'a> ObjMesh<'a> {
20    /// Returns the borrowed OBJ source text.
21    #[must_use]
22    pub const fn source(self) -> &'a str {
23        self.source
24    }
25
26    /// Returns the number of parsed `v` records.
27    #[must_use]
28    pub const fn vertex_count(self) -> usize {
29        self.vertex_count
30    }
31
32    /// Returns the number of parsed triangular `f` records.
33    #[must_use]
34    pub const fn face_count(self) -> usize {
35        self.face_count
36    }
37
38    /// Reads the requested vertex from the borrowed source.
39    ///
40    /// This performs a linear scan from the start of the source. Use
41    /// [`ObjMesh::vertices`] for full-mesh iteration.
42    ///
43    /// # Errors
44    /// Returns `ObjError::IndexOutOfBounds` if the index is out of range.
45    pub fn vertex(self, index: usize) -> Result<ObjVertex, ObjError> {
46        find_record(self.source, "v", index)
47            .ok_or(ObjError::IndexOutOfBounds)
48            .and_then(parse_vertex_line)
49    }
50
51    /// Reads the requested triangle from the borrowed source.
52    ///
53    /// This performs a linear scan from the start of the source. Use
54    /// [`ObjMesh::triangles`] for full-mesh iteration.
55    ///
56    /// # Errors
57    /// Returns `ObjError::IndexOutOfBounds` if the index is out of range,
58    /// or another `ObjError` if the face record is invalid.
59    pub fn triangle(self, index: usize) -> Result<Triangle32, ObjError> {
60        find_record(self.source, "f", index)
61            .ok_or(ObjError::IndexOutOfBounds)
62            .and_then(parse_face_line)
63    }
64
65    /// Returns a forward iterator over decoded OBJ vertex records.
66    ///
67    /// The iterator scans the borrowed source once and does not allocate.
68    #[must_use]
69    pub fn vertices(self) -> ObjVertices<'a> {
70        ObjVertices::new(self.source)
71    }
72
73    /// Returns a forward iterator over decoded OBJ triangle face records.
74    ///
75    /// The iterator scans the borrowed source once and does not allocate.
76    #[must_use]
77    pub fn triangles(self) -> ObjTriangles<'a> {
78        ObjTriangles::new(self.source, self.vertex_count)
79    }
80}
81
82/// A text OBJ vertex decoded from a borrowed `v` record.
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct ObjVertex {
85    /// X coordinate.
86    pub x: f32,
87    /// Y coordinate.
88    pub y: f32,
89    /// Z coordinate.
90    pub z: f32,
91}
92
93/// Parses a supported OBJ text mesh as a zero-copy borrowed view.
94///
95/// The supported profile accepts `v x y z` vertices, triangular `f` faces, and
96/// common metadata statements. Face indices are converted from OBJ one-based
97/// indexing into zero-based `Triangle32` values.
98///
99/// # Errors
100/// Returns `ObjError` when the source contains unsupported statements,
101/// malformed vertices, malformed faces, or out-of-range face indices.
102pub fn parse_obj_text(source: &str) -> Result<ObjMesh<'_>, ObjError> {
103    let counts = count_records(source)?;
104    if counts.vertex_count == 0 {
105        return Err(ObjError::MissingVertices);
106    }
107    if counts.face_count == 0 {
108        return Err(ObjError::MissingFaces);
109    }
110    validate_face_indices(source, counts.vertex_count)?;
111    Ok(ObjMesh { source, vertex_count: counts.vertex_count, face_count: counts.face_count })
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115struct ObjCounts {
116    vertex_count: usize,
117    face_count: usize,
118}
119
120fn count_records(source: &str) -> Result<ObjCounts, ObjError> {
121    let mut vertex_count = 0;
122    let mut face_count = 0;
123    for line in source.lines() {
124        match statement_kind(line)? {
125            Some("v") => {
126                parse_vertex_line(line)?;
127                vertex_count += 1;
128            }
129            Some("f") => {
130                parse_face_line(line)?;
131                face_count += 1;
132            }
133            Some(_) | None => {}
134        }
135    }
136    Ok(ObjCounts { vertex_count, face_count })
137}
138
139fn validate_face_indices(source: &str, vertex_count: usize) -> Result<(), ObjError> {
140    for line in source.lines() {
141        if statement_kind(line)? == Some("f") {
142            let triangle = parse_face_line(line)?;
143            if !face_indices_are_valid(triangle, vertex_count) {
144                return Err(ObjError::IndexOutOfBounds);
145            }
146        }
147    }
148    Ok(())
149}
150
151fn face_indices_are_valid(face: Triangle32, vertex_count: usize) -> bool {
152    u32_index_is_valid(face.v0, vertex_count)
153        && u32_index_is_valid(face.v1, vertex_count)
154        && u32_index_is_valid(face.v2, vertex_count)
155}
156
157fn u32_index_is_valid(index: u32, vertex_count: usize) -> bool {
158    usize::try_from(index).is_ok_and(|index| index < vertex_count)
159}
160
161fn statement_kind(line: &str) -> Result<Option<&str>, ObjError> {
162    let mut parts = record_body(line).split_whitespace();
163    let Some(kind) = parts.next() else {
164        return Ok(None);
165    };
166    if kind.starts_with('#') || harmless_statement(kind) {
167        Ok(None)
168    } else if matches!(kind, "v" | "f") {
169        Ok(Some(kind))
170    } else {
171        Err(ObjError::UnsupportedStatement)
172    }
173}
174
175fn harmless_statement(kind: &str) -> bool {
176    matches!(kind, "o" | "g" | "s" | "usemtl" | "mtllib" | "vt" | "vn")
177}
178
179fn record_body(line: &str) -> &str {
180    line.split_once('#').map_or(line, |(record, _comment)| record).trim()
181}
182
183fn parse_vertex_line(line: &str) -> Result<ObjVertex, ObjError> {
184    let mut parts = record_body(line).split_whitespace();
185    if parts.next() != Some("v") {
186        return Err(ObjError::InvalidVertex);
187    }
188    let vertex = ObjVertex {
189        x: parse_coord(parts.next())?,
190        y: parse_coord(parts.next())?,
191        z: parse_coord(parts.next())?,
192    };
193    if !(vertex.x.is_finite() && vertex.y.is_finite() && vertex.z.is_finite()) {
194        return Err(ObjError::NonFiniteVertex);
195    }
196    if parts.next().is_some() {
197        Err(ObjError::InvalidVertex)
198    } else {
199        Ok(vertex)
200    }
201}
202
203fn parse_coord(value: Option<&str>) -> Result<f32, ObjError> {
204    float::parse_ascii_float(value.ok_or(ObjError::InvalidVertex)?).ok_or(ObjError::InvalidVertex)
205}
206
207fn parse_face_line(line: &str) -> Result<Triangle32, ObjError> {
208    let mut parts = record_body(line).split_whitespace();
209    if parts.next() != Some("f") {
210        return Err(ObjError::NonTriangularFace);
211    }
212    let face = Triangle32::new(
213        parse_index(parts.next())?,
214        parse_index(parts.next())?,
215        parse_index(parts.next())?,
216    );
217    if parts.next().is_some() {
218        Err(ObjError::NonTriangularFace)
219    } else {
220        Ok(face)
221    }
222}
223
224fn parse_index(token: Option<&str>) -> Result<u32, ObjError> {
225    let token = token.ok_or(ObjError::NonTriangularFace)?;
226    let mut fields = token.split('/');
227    let vertex_index = parse_vertex_index(fields.next().ok_or(ObjError::InvalidIndex)?)?;
228    match (fields.next(), fields.next(), fields.next()) {
229        (None, None, None) => Ok(vertex_index),
230        (Some(texture), None, None) => {
231            parse_auxiliary_index(texture)?;
232            Ok(vertex_index)
233        }
234        (Some(texture), Some(normal), None) => {
235            if !texture.is_empty() {
236                parse_auxiliary_index(texture)?;
237            }
238            parse_auxiliary_index(normal)?;
239            Ok(vertex_index)
240        }
241        _ => Err(ObjError::InvalidIndex),
242    }
243}
244
245fn parse_vertex_index(index_text: &str) -> Result<u32, ObjError> {
246    let one_based = index_text.parse::<i64>().map_err(|_| ObjError::InvalidIndex)?;
247    let zero_based = one_based.checked_sub(1).ok_or(ObjError::InvalidIndex)?;
248    u32::try_from(zero_based).map_err(|_| ObjError::InvalidIndex)
249}
250
251fn parse_auxiliary_index(index_text: &str) -> Result<(), ObjError> {
252    let one_based = index_text.parse::<i64>().map_err(|_| ObjError::InvalidIndex)?;
253    let zero_based = one_based.checked_sub(1).ok_or(ObjError::InvalidIndex)?;
254    u32::try_from(zero_based).map(|_| ()).map_err(|_| ObjError::InvalidIndex)
255}