Skip to main content

wavefront_obj_io/
lib.rs

1//! Streaming, callback-based Wavefront OBJ reader and writer with matched
2//! read/write traits.
3//!
4//! # Why
5//!
6//! Most OBJ crates on crates.io eagerly load a file into a `Mesh` struct.
7//! That works great when the input fits in memory and you don't care about
8//! preserving exact byte layout. This crate fills the opposite niche:
9//!
10//! - **Streaming.** [`read_obj_file`] walks the file once and dispatches to
11//!   trait callbacks - no intermediate allocation per element. You can
12//!   process arbitrarily large meshes by writing your own [`ObjReader`]
13//!   that pushes data straight into the buffer of your choice.
14//! - **Round-trip fidelity.** [`ObjReader`] and [`ObjWriter`] are matched
15//!   trait pairs: every directive a reader can produce, a writer can emit.
16//!   That makes byte-equal round trips of OBJ files trivial.
17//! - **Configurable float precision.** Read and write `f32` or `f64` via
18//!   the [`ObjFloat`] generic parameter.
19//! - **Strict-by-default with explicit opt-in for lenient parsing.**
20//!   [`ObjReader::read_unknown`] returns an error by default; override it
21//!   to silently skip directives you don't care about (e.g. NURBS).
22//!
23//! If what you want is `let mesh = obj::load(path)?`, use
24//! [`tobj`](https://crates.io/crates/tobj) instead - that is the right tool
25//! for that job.
26//!
27//! # Supported directives
28//!
29//! Core geometry: `v`, `vt`, `vn`, `f`, `o`, `#` comments.
30//!
31//! Standard auxiliary directives have first-class trait methods on both
32//! [`ObjReader`] and [`ObjWriter`]: `mtllib`, `usemtl`, `g`, `s` (with
33//! [`SmoothingGroup`]), `l`, `p`.
34//!
35//! Anything else (NURBS / free-form geometry, display attributes, vendor
36//! extensions) routes to [`ObjReader::read_unknown`] - reject or ignore as
37//! you see fit.
38//!
39//! # Quick start
40//!
41//! ```
42//! use wavefront_obj_io::{ObjReader, read_obj_file};
43//! use std::io::Cursor;
44//!
45//! #[derive(Default)]
46//! struct CountVertices(usize);
47//!
48//! impl ObjReader<f32> for CountVertices {
49//!     fn read_comment(&mut self, _: &str) {}
50//!     fn read_object_name(&mut self, _: &str) {}
51//!     fn read_vertex(&mut self, _: f32, _: f32, _: f32, _: Option<f32>) {
52//!         self.0 += 1;
53//!     }
54//!     fn read_texture_coordinate(&mut self, _: f32, _: Option<f32>, _: Option<f32>) {}
55//!     fn read_normal(&mut self, _: f32, _: f32, _: f32) {}
56//!     fn read_face(&mut self, _: &[(usize, Option<usize>, Option<usize>)]) {}
57//! }
58//!
59//! let obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\n";
60//! let mut counter = CountVertices::default();
61//! read_obj_file(Cursor::new(obj), &mut counter).unwrap();
62//! assert_eq!(counter.0, 3);
63//! ```
64//!
65//! Indices follow the Wavefront convention and are kept 1-based throughout
66//! the API.
67
68use std::error::Error as StdError;
69use std::fmt;
70use std::fmt::Display;
71use std::io;
72use std::io::{BufRead, BufReader};
73use std::str::FromStr;
74
75/// Error type returned by [`read_obj_file`].
76///
77/// `Io` wraps an underlying [`io::Error`] from the source. `Parse` carries a
78/// structured description of an OBJ syntax problem at a specific line.
79#[derive(Debug)]
80pub enum ObjError {
81    Io(io::Error),
82    Parse { line: usize, kind: ParseErrorKind },
83}
84
85/// Structured description of an OBJ syntax problem.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum ParseErrorKind {
88    /// A line had no directive prefix after trimming whitespace.
89    EmptyPrefix,
90    /// The line started with a directive the parser does not recognize and
91    /// the [`ObjReader::read_unknown`] callback rejected it.
92    UnknownPrefix(String),
93    /// A directive was missing a required field.
94    MissingField(&'static str),
95    /// A numeric value could not be parsed as a float.
96    InvalidNumber { field: &'static str, value: String },
97    /// A face / line / point index was zero, negative, or non-numeric.
98    InvalidIndex { kind: &'static str, value: String },
99    /// `s <value>` where the value was neither `off` nor a non-negative integer.
100    InvalidSmoothingGroup(String),
101    /// `l` element with fewer than 2 vertices.
102    LineElementTooShort,
103    /// `p` element with no vertices.
104    PointElementEmpty,
105    /// Free-form message, e.g. from a custom [`ObjReader::read_unknown`].
106    Custom(String),
107}
108
109impl Display for ObjError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            ObjError::Io(e) => write!(f, "I/O error: {e}"),
113            ObjError::Parse { line, kind } => write!(f, "line {line}: {kind}"),
114        }
115    }
116}
117
118impl Display for ParseErrorKind {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            ParseErrorKind::EmptyPrefix => write!(f, "empty prefix"),
122            ParseErrorKind::UnknownPrefix(p) => write!(f, "Unknown line prefix: {p}"),
123            ParseErrorKind::MissingField(field) => write!(f, "missing {field}"),
124            ParseErrorKind::InvalidNumber { field, value } => {
125                write!(f, "invalid {field}: {value}")
126            }
127            ParseErrorKind::InvalidIndex { kind, value } => {
128                write!(f, "invalid {kind} index: {value}")
129            }
130            ParseErrorKind::InvalidSmoothingGroup(v) => {
131                write!(
132                    f,
133                    "invalid smoothing group: {v} (expected integer or 'off')"
134                )
135            }
136            ParseErrorKind::LineElementTooShort => {
137                write!(f, "line element needs at least 2 vertices")
138            }
139            ParseErrorKind::PointElementEmpty => {
140                write!(f, "point element needs at least 1 vertex")
141            }
142            ParseErrorKind::Custom(s) => write!(f, "{s}"),
143        }
144    }
145}
146
147impl StdError for ObjError {
148    fn source(&self) -> Option<&(dyn StdError + 'static)> {
149        match self {
150            ObjError::Io(e) => Some(e),
151            ObjError::Parse { .. } => None,
152        }
153    }
154}
155
156impl From<io::Error> for ObjError {
157    fn from(e: io::Error) -> Self {
158        ObjError::Io(e)
159    }
160}
161
162impl From<ObjError> for io::Error {
163    fn from(e: ObjError) -> Self {
164        match e {
165            ObjError::Io(inner) => inner,
166            parse @ ObjError::Parse { .. } => {
167                io::Error::new(io::ErrorKind::InvalidData, parse.to_string())
168            }
169        }
170    }
171}
172
173/// Trait for floating point types that can be used in OBJ files.
174/// This allows the library to work with both f32 and f64 precision.
175pub trait ObjFloat: Copy + Display + FromStr + PartialEq {
176    /// Returns the fractional part of the number
177    fn fract(self) -> Self;
178
179    /// Returns true if the fractional part is zero
180    fn is_zero_fract(self) -> bool {
181        self.fract() == Self::zero()
182    }
183
184    /// Returns the zero value for this type
185    fn zero() -> Self;
186}
187
188impl ObjFloat for f32 {
189    fn fract(self) -> Self {
190        self.fract()
191    }
192    fn zero() -> Self {
193        0.0
194    }
195}
196
197impl ObjFloat for f64 {
198    fn fract(self) -> Self {
199        self.fract()
200    }
201    fn zero() -> Self {
202        0.0
203    }
204}
205
206/// Smoothing group selector for the `s` directive.
207///
208/// `s 0` and `s off` both disable smoothing; any positive integer names a
209/// smoothing group.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum SmoothingGroup {
212    Off,
213    Group(u32),
214}
215
216/// Trait for writing OBJ file data with configurable float precision.
217///
218/// The generic parameter `F` defaults to `f64` for backward compatibility,
219/// but can be set to `f32` for applications that work with single-precision data.
220pub trait ObjWriter<F: ObjFloat = f64> {
221    fn write_comment<S: AsRef<str>>(&mut self, comment: S) -> io::Result<()>;
222    fn write_object_name<S: AsRef<str>>(&mut self, name: S) -> io::Result<()>;
223    fn write_vertex(&mut self, x: F, y: F, z: F, w: Option<F>) -> io::Result<()>;
224    fn write_texture_coordinate(&mut self, u: F, v: Option<F>, w: Option<F>) -> io::Result<()>;
225    fn write_normal(&mut self, nx: F, ny: F, nz: F) -> io::Result<()>;
226    fn write_face(
227        &mut self,
228        vertex_indices: &[(usize, Option<usize>, Option<usize>)],
229    ) -> io::Result<()>;
230
231    /// `mtllib lib1.mtl lib2.mtl ...` - reference one or more material libraries.
232    fn write_material_lib<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()>;
233
234    /// `usemtl name` - select a material from a previously declared library.
235    fn write_use_material<S: AsRef<str>>(&mut self, name: S) -> io::Result<()>;
236
237    /// `g name1 name2 ...` - assign subsequent elements to one or more groups.
238    fn write_group<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()>;
239
240    /// `s 0|off|<n>` - select a smoothing group for subsequent faces.
241    fn write_smoothing_group(&mut self, group: SmoothingGroup) -> io::Result<()>;
242
243    /// `l v1[/vt1] v2[/vt2] ...` - polyline element. Each index is a vertex,
244    /// optionally with a texture coordinate.
245    fn write_line_element(&mut self, indices: &[(usize, Option<usize>)]) -> io::Result<()>;
246
247    /// `p v1 v2 ...` - point element.
248    fn write_point_element(&mut self, indices: &[usize]) -> io::Result<()>;
249}
250
251/// Trait for reading OBJ file data with configurable float precision.
252///
253/// The generic parameter `F` defaults to `f64` for backward compatibility,
254/// but can be set to `f32` for applications that work with single-precision data.
255pub trait ObjReader<F: ObjFloat = f64> {
256    fn read_comment(&mut self, comment: &str) -> ();
257    fn read_object_name(&mut self, name: &str) -> ();
258    fn read_vertex(&mut self, x: F, y: F, z: F, w: Option<F>) -> ();
259    fn read_texture_coordinate(&mut self, u: F, v: Option<F>, w: Option<F>) -> ();
260    fn read_normal(&mut self, nx: F, ny: F, nz: F) -> ();
261    fn read_face(&mut self, vertex_indices: &[(usize, Option<usize>, Option<usize>)]) -> ();
262
263    /// `mtllib lib1.mtl lib2.mtl ...` - default no-op.
264    fn read_material_lib(&mut self, _names: &[&str]) {}
265
266    /// `usemtl name` - default no-op.
267    fn read_use_material(&mut self, _name: &str) {}
268
269    /// `g name1 name2 ...` - default no-op.
270    fn read_group(&mut self, _names: &[&str]) {}
271
272    /// `s 0|off|<n>` - default no-op.
273    fn read_smoothing_group(&mut self, _group: SmoothingGroup) {}
274
275    /// `l v1[/vt1] v2[/vt2] ...` - default no-op.
276    fn read_line_element(&mut self, _indices: &[(usize, Option<usize>)]) {}
277
278    /// `p v1 v2 ...` - default no-op.
279    fn read_point_element(&mut self, _indices: &[usize]) {}
280
281    /// Called when a line with an unknown prefix is encountered.
282    ///
283    /// The default implementation returns `ParseErrorKind::UnknownPrefix`,
284    /// treating any prefix outside the supported core (NURBS / free-form
285    /// geometry, display attributes, vendor extensions) as a hard error.
286    /// Override to skip or otherwise handle these lines.
287    fn read_unknown(&mut self, prefix: &str, _rest: &str, line: usize) -> Result<(), ObjError> {
288        Err(ObjError::Parse {
289            line,
290            kind: ParseErrorKind::UnknownPrefix(prefix.to_string()),
291        })
292    }
293}
294
295pub fn read_obj_file<R: io::Read, T: ObjReader<F>, F: ObjFloat>(
296    reader: R,
297    obj_reader: &mut T,
298) -> Result<(), ObjError>
299where
300    <F as FromStr>::Err: Display,
301{
302    let mut buf_reader = BufReader::new(reader);
303    let mut line = String::new();
304    let mut lineno: usize = 0;
305
306    while buf_reader.read_line(&mut line)? != 0 {
307        lineno += 1;
308        let trimmed = line.trim();
309        if trimmed.is_empty() {
310            line.clear();
311            continue;
312        }
313        let mut parts = trimmed.split_whitespace();
314        let prefix = parts.next().ok_or(ObjError::Parse {
315            line: lineno,
316            kind: ParseErrorKind::EmptyPrefix,
317        })?;
318
319        let parse_f = |s: &str, field: &'static str| -> Result<F, ObjError> {
320            s.parse::<F>().map_err(|_| ObjError::Parse {
321                line: lineno,
322                kind: ParseErrorKind::InvalidNumber {
323                    field,
324                    value: s.to_string(),
325                },
326            })
327        };
328
329        let parse_index = |s: &str, kind: &'static str| -> Result<usize, ObjError> {
330            let index = s.parse::<usize>().map_err(|_| ObjError::Parse {
331                line: lineno,
332                kind: ParseErrorKind::InvalidIndex {
333                    kind,
334                    value: s.to_string(),
335                },
336            })?;
337            if index == 0 {
338                return Err(ObjError::Parse {
339                    line: lineno,
340                    kind: ParseErrorKind::InvalidIndex {
341                        kind,
342                        value: s.to_string(),
343                    },
344                });
345            }
346            Ok(index)
347        };
348
349        let missing = |field: &'static str| -> ObjError {
350            ObjError::Parse {
351                line: lineno,
352                kind: ParseErrorKind::MissingField(field),
353            }
354        };
355
356        match prefix {
357            "#" => {
358                let comment = parts.collect::<Vec<&str>>().join(" ");
359                obj_reader.read_comment(&comment);
360            }
361            "v" => {
362                let x = parts
363                    .next()
364                    .ok_or_else(|| missing("vertex x"))
365                    .and_then(|s| parse_f(s, "vertex x"))?;
366                let y = parts
367                    .next()
368                    .ok_or_else(|| missing("vertex y"))
369                    .and_then(|s| parse_f(s, "vertex y"))?;
370                let z = parts
371                    .next()
372                    .ok_or_else(|| missing("vertex z"))
373                    .and_then(|s| parse_f(s, "vertex z"))?;
374                let w = match parts.next() {
375                    Some(s) => Some(parse_f(s, "vertex w")?),
376                    None => None,
377                };
378                obj_reader.read_vertex(x, y, z, w);
379            }
380            "vt" => {
381                let u = parts
382                    .next()
383                    .ok_or_else(|| missing("texture u"))
384                    .and_then(|s| parse_f(s, "texture u"))?;
385                let v = match parts.next() {
386                    Some(s) => Some(parse_f(s, "texture v")?),
387                    None => None,
388                };
389                let w = match parts.next() {
390                    Some(s) => Some(parse_f(s, "texture w")?),
391                    None => None,
392                };
393                obj_reader.read_texture_coordinate(u, v, w);
394            }
395            "vn" => {
396                let nx = parts
397                    .next()
398                    .ok_or_else(|| missing("normal nx"))
399                    .and_then(|s| parse_f(s, "normal nx"))?;
400                let ny = parts
401                    .next()
402                    .ok_or_else(|| missing("normal ny"))
403                    .and_then(|s| parse_f(s, "normal ny"))?;
404                let nz = parts
405                    .next()
406                    .ok_or_else(|| missing("normal nz"))
407                    .and_then(|s| parse_f(s, "normal nz"))?;
408                obj_reader.read_normal(nx, ny, nz);
409            }
410            "f" => {
411                let mut vertex_indices = Vec::new();
412                for part in parts {
413                    // parse "v[/vt[/vn]]" by slicing without allocating
414                    let first_slash = part.find('/');
415                    let (v_str, rest) = match first_slash {
416                        Some(i) => (&part[..i], &part[i + 1..]),
417                        None => (part, ""),
418                    };
419
420                    let v_idx = parse_index(v_str, "vertex")?;
421
422                    let (vt_idx, vn_idx) = if rest.is_empty() {
423                        (None, None)
424                    } else {
425                        let second_slash = rest.find('/');
426                        if let Some(j) = second_slash {
427                            let vt_part = &rest[..j];
428                            let vn_part = &rest[j + 1..];
429                            let vt = if vt_part.is_empty() {
430                                None
431                            } else {
432                                Some(parse_index(vt_part, "texcoord")?)
433                            };
434                            let vn = if vn_part.is_empty() {
435                                None
436                            } else {
437                                Some(parse_index(vn_part, "normal")?)
438                            };
439                            (vt, vn)
440                        } else {
441                            // only vt present
442                            let vt = if rest.is_empty() {
443                                None
444                            } else {
445                                Some(parse_index(rest, "texcoord")?)
446                            };
447                            (vt, None)
448                        }
449                    };
450
451                    vertex_indices.push((v_idx, vt_idx, vn_idx));
452                }
453                obj_reader.read_face(&vertex_indices);
454            }
455            "o" => {
456                let name = parts.collect::<Vec<&str>>().join(" ");
457                obj_reader.read_object_name(&name);
458            }
459            "mtllib" => {
460                let names: Vec<&str> = parts.collect();
461                obj_reader.read_material_lib(&names);
462            }
463            "usemtl" => {
464                // Material names should not contain whitespace per spec; join
465                // anything we get just to be tolerant.
466                let name = parts.collect::<Vec<&str>>().join(" ");
467                obj_reader.read_use_material(&name);
468            }
469            "g" => {
470                let names: Vec<&str> = parts.collect();
471                obj_reader.read_group(&names);
472            }
473            "s" => {
474                let value = parts
475                    .next()
476                    .ok_or_else(|| missing("smoothing group value"))?;
477                let group = if value.eq_ignore_ascii_case("off") {
478                    SmoothingGroup::Off
479                } else {
480                    let n = value.parse::<u32>().map_err(|_| ObjError::Parse {
481                        line: lineno,
482                        kind: ParseErrorKind::InvalidSmoothingGroup(value.to_string()),
483                    })?;
484                    if n == 0 {
485                        SmoothingGroup::Off
486                    } else {
487                        SmoothingGroup::Group(n)
488                    }
489                };
490                obj_reader.read_smoothing_group(group);
491            }
492            "l" => {
493                let mut indices: Vec<(usize, Option<usize>)> = Vec::new();
494                for part in parts {
495                    let (v_str, vt_str) = match part.find('/') {
496                        Some(i) => (&part[..i], Some(&part[i + 1..])),
497                        None => (part, None),
498                    };
499                    let v_idx = parse_index(v_str, "vertex")?;
500                    let vt_idx = match vt_str {
501                        Some(s) if !s.is_empty() => Some(parse_index(s, "texcoord")?),
502                        _ => None,
503                    };
504                    indices.push((v_idx, vt_idx));
505                }
506                if indices.len() < 2 {
507                    return Err(ObjError::Parse {
508                        line: lineno,
509                        kind: ParseErrorKind::LineElementTooShort,
510                    });
511                }
512                obj_reader.read_line_element(&indices);
513            }
514            "p" => {
515                let mut indices: Vec<usize> = Vec::new();
516                for part in parts {
517                    indices.push(parse_index(part, "vertex")?);
518                }
519                if indices.is_empty() {
520                    return Err(ObjError::Parse {
521                        line: lineno,
522                        kind: ParseErrorKind::PointElementEmpty,
523                    });
524                }
525                obj_reader.read_point_element(&indices);
526            }
527            other => {
528                let rest = parts.collect::<Vec<&str>>().join(" ");
529                obj_reader.read_unknown(other, &rest, lineno)?;
530            }
531        }
532
533        line.clear();
534    }
535
536    Ok(())
537}
538
539pub struct IoObjWriter<W: io::Write, F: ObjFloat = f64> {
540    out: W,
541    line_buf: Vec<u8>,
542    /// When true, format floats with 6 decimal places (matching C `printf %f`).
543    printf_f_format: bool,
544    _phantom: std::marker::PhantomData<F>,
545}
546impl<W: io::Write, F: ObjFloat> IoObjWriter<W, F> {
547    /// Creates a new OBJ writer with default formatting (full precision).
548    pub fn new(writer: W) -> Self {
549        IoObjWriter {
550            out: writer,
551            line_buf: Vec::with_capacity(256),
552            printf_f_format: false,
553            _phantom: std::marker::PhantomData,
554        }
555    }
556
557    /// Creates a new OBJ writer that formats floats with 6 decimal places,
558    /// matching the output produced by C's `fprintf("%f", ...)`.
559    #[cfg(test)]
560    pub fn new_with_printf_f_format(writer: W) -> Self {
561        IoObjWriter {
562            out: writer,
563            line_buf: Vec::with_capacity(256),
564            printf_f_format: true,
565            _phantom: std::marker::PhantomData,
566        }
567    }
568
569    /// Toggle 6-decimal-place float formatting (`printf %f` style).
570    #[cfg(test)]
571    pub fn set_printf_f_format(&mut self, enabled: bool) {
572        self.printf_f_format = enabled;
573    }
574
575    #[inline]
576    fn push_str(&mut self, s: &str) {
577        self.line_buf.extend_from_slice(s.as_bytes());
578    }
579
580    #[inline]
581    fn push_u<T: itoa::Integer>(&mut self, v: T) {
582        let mut buf = itoa::Buffer::new();
583        self.push_str(buf.format(v));
584    }
585
586    #[inline]
587    fn push_f(&mut self, v: F) {
588        // we want 0 as "0" not "0.0"
589        if v.is_zero_fract() {
590            self.push_str(&format!("{}", v));
591            return;
592        }
593        // 6 decimal places when matching C `printf %f`, otherwise full
594        // round-trippable precision via the type's Display impl.
595        if self.printf_f_format {
596            self.push_str(&format!("{:.6}", v));
597        } else {
598            self.push_str(&format!("{}", v));
599        }
600    }
601
602    #[inline]
603    fn flush_line(&mut self) -> io::Result<()> {
604        self.line_buf.push(b'\n');
605        self.out.write_all(&self.line_buf)?;
606        self.line_buf.clear();
607        Ok(())
608    }
609}
610impl<W: io::Write, F: ObjFloat> ObjWriter<F> for IoObjWriter<W, F> {
611    fn write_comment<S: AsRef<str>>(&mut self, comment: S) -> io::Result<()> {
612        self.push_str("# ");
613        self.push_str(comment.as_ref());
614        self.flush_line()
615    }
616
617    fn write_object_name<S: AsRef<str>>(&mut self, name: S) -> io::Result<()> {
618        self.push_str("o ");
619        self.push_str(name.as_ref());
620        self.flush_line()
621    }
622
623    fn write_vertex(&mut self, x: F, y: F, z: F, w: Option<F>) -> io::Result<()> {
624        self.push_str("v ");
625        self.push_f(x);
626        self.push_str(" ");
627        self.push_f(y);
628        self.push_str(" ");
629        self.push_f(z);
630        if let Some(wv) = w {
631            self.push_str(" ");
632            self.push_f(wv);
633        }
634        self.flush_line()
635    }
636
637    fn write_texture_coordinate(&mut self, u: F, v: Option<F>, w: Option<F>) -> io::Result<()> {
638        self.push_str("vt ");
639        self.push_f(u);
640        if let Some(vv) = v {
641            self.push_str(" ");
642            self.push_f(vv);
643            if let Some(wv) = w {
644                self.push_str(" ");
645                self.push_f(wv);
646            }
647        }
648        self.flush_line()
649    }
650
651    fn write_normal(&mut self, nx: F, ny: F, nz: F) -> io::Result<()> {
652        self.push_str("vn ");
653        self.push_f(nx);
654        self.push_str(" ");
655        self.push_f(ny);
656        self.push_str(" ");
657        self.push_f(nz);
658        self.flush_line()
659    }
660
661    fn write_face(
662        &mut self,
663        vertex_indices: &[(usize, Option<usize>, Option<usize>)],
664    ) -> io::Result<()> {
665        // Build the whole face line and write once.
666        self.push_str("f");
667        for (v_idx, vt_idx, vn_idx) in vertex_indices.iter() {
668            self.push_str(" ");
669            // If your internal indices are zero-based, emit +1 here:
670            self.push_u(*v_idx);
671            match (vt_idx, vn_idx) {
672                (None, None) => {}
673                (Some(vt), None) => {
674                    self.push_str("/");
675                    self.push_u(*vt);
676                }
677                (None, Some(vn)) => {
678                    self.push_str("//");
679                    self.push_u(*vn);
680                }
681                (Some(vt), Some(vn)) => {
682                    self.push_str("/");
683                    self.push_u(*vt);
684                    self.push_str("/");
685                    self.push_u(*vn);
686                }
687            }
688        }
689        self.flush_line()
690    }
691
692    fn write_material_lib<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()> {
693        self.push_str("mtllib");
694        for name in names {
695            self.push_str(" ");
696            self.push_str(name.as_ref());
697        }
698        self.flush_line()
699    }
700
701    fn write_use_material<S: AsRef<str>>(&mut self, name: S) -> io::Result<()> {
702        self.push_str("usemtl ");
703        self.push_str(name.as_ref());
704        self.flush_line()
705    }
706
707    fn write_group<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()> {
708        self.push_str("g");
709        for name in names {
710            self.push_str(" ");
711            self.push_str(name.as_ref());
712        }
713        self.flush_line()
714    }
715
716    fn write_smoothing_group(&mut self, group: SmoothingGroup) -> io::Result<()> {
717        match group {
718            SmoothingGroup::Off => self.push_str("s off"),
719            SmoothingGroup::Group(n) => {
720                self.push_str("s ");
721                self.push_u(n);
722            }
723        }
724        self.flush_line()
725    }
726
727    fn write_line_element(&mut self, indices: &[(usize, Option<usize>)]) -> io::Result<()> {
728        self.push_str("l");
729        for (v_idx, vt_idx) in indices.iter() {
730            self.push_str(" ");
731            self.push_u(*v_idx);
732            if let Some(vt) = vt_idx {
733                self.push_str("/");
734                self.push_u(*vt);
735            }
736        }
737        self.flush_line()
738    }
739
740    fn write_point_element(&mut self, indices: &[usize]) -> io::Result<()> {
741        self.push_str("p");
742        for v_idx in indices.iter() {
743            self.push_str(" ");
744            self.push_u(*v_idx);
745        }
746        self.flush_line()
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use pretty_assertions::assert_eq;
754    use std::io::Cursor;
755
756    type Face = Vec<(usize, Option<usize>, Option<usize>)>;
757
758    #[derive(Default)]
759    struct TestObjReader64 {
760        comments: Vec<String>,
761        names: Vec<String>,
762        vertices: Vec<(f64, f64, f64, Option<f64>)>,
763        texture_coordinates: Vec<(f64, Option<f64>, Option<f64>)>,
764        normals: Vec<(f64, f64, f64)>,
765        faces: Vec<Face>,
766    }
767
768    impl ObjReader for TestObjReader64 {
769        fn read_comment(&mut self, comment: &str) {
770            self.comments.push(comment.to_string());
771        }
772
773        fn read_object_name(&mut self, name: &str) {
774            self.names.push(name.to_string());
775        }
776
777        fn read_vertex(&mut self, x: f64, y: f64, z: f64, w: Option<f64>) {
778            self.vertices.push((x, y, z, w));
779        }
780
781        fn read_texture_coordinate(&mut self, u: f64, v: Option<f64>, w: Option<f64>) {
782            self.texture_coordinates.push((u, v, w));
783        }
784
785        fn read_normal(&mut self, nx: f64, ny: f64, nz: f64) {
786            self.normals.push((nx, ny, nz));
787        }
788
789        fn read_face(&mut self, vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {
790            self.faces.push(vertex_indices.to_vec());
791        }
792    }
793
794    #[test]
795    fn test_obj_reading() {
796        // read testdata/screw.obj using TestObjReader
797        let obj_data = include_str!("../testdata/screw_f64.obj");
798        let cursor = Cursor::new(obj_data);
799        let mut reader: TestObjReader64 = Default::default();
800        read_obj_file(cursor, &mut reader).unwrap();
801        // this does not check correctness and ordering of data, just that all data was read
802        assert_eq!(reader.comments.len(), 3);
803        assert_eq!(reader.names.len(), 1);
804        assert_eq!(reader.vertices.len(), 41);
805        assert_eq!(reader.texture_coordinates.len(), 41);
806        assert_eq!(reader.normals.len(), 41);
807        assert_eq!(reader.faces.len(), 48);
808    }
809
810    #[test]
811    fn test_obj_reading_2() {
812        let input = "# This is a test OBJ file
813o TestObject
814v 1 2 3
815vt 0.5 0.5
816vn 0 1 1.1
817f 1/1/1 2/2/2 3/3/3
818";
819
820        let reader = Cursor::new(input);
821        let mut test_reader: TestObjReader64 = Default::default();
822        read_obj_file(reader, &mut test_reader).unwrap();
823        assert_eq!(test_reader.comments, vec!["This is a test OBJ file"]);
824        assert_eq!(test_reader.names, vec!["TestObject"]);
825        assert_eq!(test_reader.vertices, vec![(1.0, 2.0, 3.0, None)]);
826        assert_eq!(
827            test_reader.texture_coordinates,
828            vec![(0.5, Some(0.5), None)]
829        );
830        assert_eq!(test_reader.normals, vec![(0.0, 1.0, 1.1)]);
831        assert_eq!(
832            test_reader.faces,
833            vec![vec![
834                (1, Some(1), Some(1)),
835                (2, Some(2), Some(2)),
836                (3, Some(3), Some(3))
837            ]]
838        );
839    }
840
841    #[test]
842    fn test_obj_writing() {
843        let mut buffer = Vec::new();
844        let mut writer = IoObjWriter::new(&mut buffer);
845        writer.write_comment("This is a test OBJ file").unwrap();
846        writer.write_object_name("TestObject").unwrap();
847        writer.write_vertex(1.0, 2.0, 3.0, None).unwrap();
848        writer
849            .write_texture_coordinate(0.5, Some(0.5), None)
850            .unwrap();
851        writer.write_normal(0.0, 1.0, 1.1).unwrap();
852        writer
853            .write_face(&[
854                (1, Some(1), Some(1)),
855                (2, Some(2), Some(2)),
856                (3, Some(3), Some(3)),
857            ])
858            .unwrap();
859
860        let output = String::from_utf8(buffer).unwrap();
861        let expected_output = "# This is a test OBJ file
862o TestObject
863v 1 2 3
864vt 0.5 0.5
865vn 0 1 1.1
866f 1/1/1 2/2/2 3/3/3
867";
868        assert_eq!(output, expected_output);
869    }
870
871    struct WritingReader64 {
872        writer: IoObjWriter<Vec<u8>>,
873    }
874    impl ObjReader for WritingReader64 {
875        fn read_comment(&mut self, comment: &str) {
876            self.writer.write_comment(comment).unwrap();
877        }
878
879        fn read_object_name(&mut self, name: &str) {
880            self.writer.write_object_name(name).unwrap();
881        }
882
883        fn read_vertex(&mut self, x: f64, y: f64, z: f64, w: Option<f64>) {
884            self.writer.write_vertex(x, y, z, w).unwrap();
885        }
886
887        fn read_texture_coordinate(&mut self, u: f64, v: Option<f64>, w: Option<f64>) {
888            self.writer.write_texture_coordinate(u, v, w).unwrap();
889        }
890
891        fn read_normal(&mut self, nx: f64, ny: f64, nz: f64) {
892            self.writer.write_normal(nx, ny, nz).unwrap();
893        }
894
895        fn read_face(&mut self, vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {
896            self.writer.write_face(vertex_indices).unwrap();
897        }
898    }
899
900    #[test]
901    fn test_obj_read_write_compare_64() {
902        // git might change line endings as they are text files, so normalize to \n
903        let obj_data = include_str!("../testdata/screw_f64.obj").replace("\r\n", "\n");
904        let cursor = Cursor::new(&obj_data);
905        // Default full-precision writer is enough to round-trip the test fixture.
906        let writer: IoObjWriter<_, f64> = IoObjWriter::new(Vec::new());
907        let mut reader = WritingReader64 { writer };
908        read_obj_file(cursor, &mut reader).unwrap();
909
910        let output = String::from_utf8(reader.writer.out).unwrap();
911        assert_eq!(output, obj_data);
912    }
913
914    // Tests for f32 support
915
916    #[derive(Default)]
917    struct TestObjReader32 {
918        vertices: Vec<(f32, f32, f32, Option<f32>)>,
919        texture_coordinates: Vec<(f32, Option<f32>, Option<f32>)>,
920        normals: Vec<(f32, f32, f32)>,
921    }
922
923    impl ObjReader<f32> for TestObjReader32 {
924        fn read_comment(&mut self, _comment: &str) {}
925        fn read_object_name(&mut self, _name: &str) {}
926
927        fn read_vertex(&mut self, x: f32, y: f32, z: f32, w: Option<f32>) {
928            self.vertices.push((x, y, z, w));
929        }
930
931        fn read_texture_coordinate(&mut self, u: f32, v: Option<f32>, w: Option<f32>) {
932            self.texture_coordinates.push((u, v, w));
933        }
934
935        fn read_normal(&mut self, nx: f32, ny: f32, nz: f32) {
936            self.normals.push((nx, ny, nz));
937        }
938
939        fn read_face(&mut self, _vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {}
940    }
941
942    struct WritingReader32 {
943        writer: IoObjWriter<Vec<u8>, f32>,
944    }
945    impl ObjReader<f32> for WritingReader32 {
946        fn read_comment(&mut self, comment: &str) {
947            self.writer.write_comment(comment).unwrap();
948        }
949
950        fn read_object_name(&mut self, name: &str) {
951            self.writer.write_object_name(name).unwrap();
952        }
953
954        fn read_vertex(&mut self, x: f32, y: f32, z: f32, w: Option<f32>) {
955            self.writer.write_vertex(x, y, z, w).unwrap();
956        }
957
958        fn read_texture_coordinate(&mut self, u: f32, v: Option<f32>, w: Option<f32>) {
959            self.writer.write_texture_coordinate(u, v, w).unwrap();
960        }
961
962        fn read_normal(&mut self, nx: f32, ny: f32, nz: f32) {
963            self.writer.write_normal(nx, ny, nz).unwrap();
964        }
965
966        fn read_face(&mut self, vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {
967            self.writer.write_face(vertex_indices).unwrap();
968        }
969    }
970
971    #[test]
972    fn test_obj_f32_reading() {
973        let input = "o TestObject
974v 1.5 2.5 3.5
975vt 0.25 0.75
976vn 0.0 1.0 0.0
977";
978
979        let reader = Cursor::new(input);
980        let mut test_reader = TestObjReader32::default();
981        read_obj_file(reader, &mut test_reader).unwrap();
982
983        assert_eq!(test_reader.vertices, vec![(1.5f32, 2.5f32, 3.5f32, None)]);
984        assert_eq!(
985            test_reader.texture_coordinates,
986            vec![(0.25f32, Some(0.75f32), None)]
987        );
988        assert_eq!(test_reader.normals, vec![(0.0f32, 1.0f32, 0.0f32)]);
989    }
990
991    #[test]
992    fn test_obj_f32_writing() {
993        let mut buffer = Vec::new();
994        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
995
996        writer.write_comment("f32 test").unwrap();
997        writer.write_object_name("F32Object").unwrap();
998        writer.write_vertex(1.5f32, 2.5f32, 3.5f32, None).unwrap();
999        writer
1000            .write_texture_coordinate(0.25f32, Some(0.75f32), None)
1001            .unwrap();
1002        writer.write_normal(0.0f32, 1.0f32, 0.0f32).unwrap();
1003
1004        let output = String::from_utf8(buffer).unwrap();
1005        let expected = "# f32 test
1006o F32Object
1007v 1.5 2.5 3.5
1008vt 0.25 0.75
1009vn 0 1 0
1010";
1011        assert_eq!(output, expected);
1012    }
1013
1014    #[test]
1015    fn test_obj_f32_round_trip() {
1016        // Test that f32 values round-trip correctly
1017        let input = "o Test
1018v 0.123456789 0.987654321 1.5
1019vn 0.577 0.577 0.577
1020";
1021
1022        let reader = Cursor::new(input);
1023        let mut test_reader = TestObjReader32::default();
1024        read_obj_file(reader, &mut test_reader).unwrap();
1025
1026        // Write back using f32 writer
1027        let mut buffer = Vec::new();
1028        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
1029        writer.write_object_name("Test").unwrap();
1030        for (x, y, z, w) in &test_reader.vertices {
1031            writer.write_vertex(*x, *y, *z, *w).unwrap();
1032        }
1033        for (nx, ny, nz) in &test_reader.normals {
1034            writer.write_normal(*nx, *ny, *nz).unwrap();
1035        }
1036
1037        let output = String::from_utf8(buffer).unwrap();
1038
1039        // The values should be f32-precision
1040        assert!(output.contains("o Test"));
1041        assert!(output.contains("v "));
1042        assert!(output.contains("vn "));
1043    }
1044
1045    #[test]
1046    fn test_printf_f_formatting() {
1047        let mut buffer = Vec::new();
1048        let mut writer: IoObjWriter<_, f64> = IoObjWriter::new_with_printf_f_format(&mut buffer);
1049
1050        writer.write_object_name("PrintfFTest").unwrap();
1051        writer
1052            .write_vertex(754.4214477539063, 1753.2353515625, -91.72238159179688, None)
1053            .unwrap();
1054        writer
1055            .write_texture_coordinate(0.123456789, Some(0.987654321), None)
1056            .unwrap();
1057        writer
1058            .write_normal(0.5773502691896257, 0.5773502691896257, 0.5773502691896257)
1059            .unwrap();
1060
1061        let output = String::from_utf8(buffer).unwrap();
1062
1063        // C `printf %f` defaults to 6 decimal places.
1064        let expected = "o PrintfFTest
1065v 754.421448 1753.235352 -91.722382
1066vt 0.123457 0.987654
1067vn 0.577350 0.577350 0.577350
1068";
1069        assert_eq!(output, expected);
1070    }
1071
1072    #[test]
1073    fn test_printf_f_flag_toggle() {
1074        // Test that we can toggle the flag
1075        let mut buffer = Vec::new();
1076        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
1077
1078        // Start with default (full precision)
1079        writer
1080            .write_vertex(1.2345678_f32, 2.345678_f32, 3.45678_f32, None)
1081            .unwrap();
1082
1083        // Enable printf %f formatting
1084        writer.set_printf_f_format(true);
1085        writer
1086            .write_vertex(1.2345678_f32, 2.3456789_f32, 3.456789_f32, None)
1087            .unwrap();
1088
1089        let output = String::from_utf8(buffer).unwrap();
1090
1091        // First line should have full f32 precision, second should have 6 decimals
1092        let lines: Vec<&str> = output.lines().collect();
1093        assert_eq!(lines.len(), 2);
1094
1095        // Full precision output (f32 Display)
1096        assert_eq!(lines[0], "v 1.2345678 2.345678 3.45678");
1097
1098        // printf %f output (6 decimal places)
1099        assert_eq!(lines[1], "v 1.234568 2.345679 3.456789");
1100    }
1101
1102    #[test]
1103    fn test_obj_read_write_compare_32() {
1104        // git might change line endings as they are text files, so normalize to \n
1105        let obj_data = include_str!("../testdata/screw_f32.obj").replace("\r\n", "\n");
1106        let cursor = Cursor::new(&obj_data);
1107        // Default full-precision writer is enough to round-trip the test fixture.
1108        let writer: IoObjWriter<_, f32> = IoObjWriter::new(Vec::new());
1109        let mut reader = WritingReader32 { writer };
1110        read_obj_file(cursor, &mut reader).unwrap();
1111
1112        let output = String::from_utf8(reader.writer.out).unwrap();
1113        assert_eq!(output, obj_data);
1114    }
1115
1116    #[test]
1117    fn f32_reader_can_read_f64_obj() {
1118        let obj_data = include_str!("../testdata/screw_f64.obj");
1119        let cursor = Cursor::new(obj_data);
1120        let mut test_reader: TestObjReader32 = Default::default();
1121        read_obj_file(cursor, &mut test_reader).unwrap();
1122        // just check that some data was read
1123        assert_eq!(test_reader.vertices.len(), 41);
1124        assert_eq!(test_reader.texture_coordinates.len(), 41);
1125        assert_eq!(test_reader.normals.len(), 41);
1126    }
1127
1128    // Tests for the standard auxiliary directives:
1129    //   mtllib, usemtl, g, s, l, p
1130
1131    /// Captures every directive seen, for round-trip and equality testing.
1132    #[derive(Default, Debug, PartialEq)]
1133    struct ExtendedReader32 {
1134        material_libs: Vec<Vec<String>>,
1135        use_materials: Vec<String>,
1136        groups: Vec<Vec<String>>,
1137        smoothing_groups: Vec<SmoothingGroup>,
1138        line_elements: Vec<Vec<(usize, Option<usize>)>>,
1139        point_elements: Vec<Vec<usize>>,
1140    }
1141
1142    impl ObjReader<f32> for ExtendedReader32 {
1143        fn read_comment(&mut self, _: &str) {}
1144        fn read_object_name(&mut self, _: &str) {}
1145        fn read_vertex(&mut self, _: f32, _: f32, _: f32, _: Option<f32>) {}
1146        fn read_texture_coordinate(&mut self, _: f32, _: Option<f32>, _: Option<f32>) {}
1147        fn read_normal(&mut self, _: f32, _: f32, _: f32) {}
1148        fn read_face(&mut self, _: &[(usize, Option<usize>, Option<usize>)]) {}
1149
1150        fn read_material_lib(&mut self, names: &[&str]) {
1151            self.material_libs
1152                .push(names.iter().map(|s| s.to_string()).collect());
1153        }
1154        fn read_use_material(&mut self, name: &str) {
1155            self.use_materials.push(name.to_string());
1156        }
1157        fn read_group(&mut self, names: &[&str]) {
1158            self.groups
1159                .push(names.iter().map(|s| s.to_string()).collect());
1160        }
1161        fn read_smoothing_group(&mut self, group: SmoothingGroup) {
1162            self.smoothing_groups.push(group);
1163        }
1164        fn read_line_element(&mut self, indices: &[(usize, Option<usize>)]) {
1165            self.line_elements.push(indices.to_vec());
1166        }
1167        fn read_point_element(&mut self, indices: &[usize]) {
1168            self.point_elements.push(indices.to_vec());
1169        }
1170    }
1171
1172    #[test]
1173    fn read_mtllib_and_usemtl() {
1174        let input = "mtllib first.mtl second.mtl
1175usemtl SomeMaterial
1176";
1177        let mut reader = ExtendedReader32::default();
1178        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1179        assert_eq!(
1180            reader.material_libs,
1181            vec![vec!["first.mtl".to_string(), "second.mtl".to_string()]]
1182        );
1183        assert_eq!(reader.use_materials, vec!["SomeMaterial".to_string()]);
1184    }
1185
1186    #[test]
1187    fn read_group_with_multiple_names() {
1188        let input = "g cube top
1189g default
1190";
1191        let mut reader = ExtendedReader32::default();
1192        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1193        assert_eq!(
1194            reader.groups,
1195            vec![
1196                vec!["cube".to_string(), "top".to_string()],
1197                vec!["default".to_string()],
1198            ]
1199        );
1200    }
1201
1202    #[test]
1203    fn read_smoothing_group_off_zero_and_named() {
1204        let input = "s off
1205s 0
1206s 1
1207s 42
1208";
1209        let mut reader = ExtendedReader32::default();
1210        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1211        assert_eq!(
1212            reader.smoothing_groups,
1213            vec![
1214                SmoothingGroup::Off,
1215                SmoothingGroup::Off,
1216                SmoothingGroup::Group(1),
1217                SmoothingGroup::Group(42),
1218            ]
1219        );
1220    }
1221
1222    #[test]
1223    fn read_smoothing_group_invalid_value_errors() {
1224        let input = "s notanumber\n";
1225        let mut reader = ExtendedReader32::default();
1226        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1227        assert!(
1228            err.to_string().contains("invalid smoothing group"),
1229            "got: {}",
1230            err
1231        );
1232    }
1233
1234    #[test]
1235    fn read_line_and_point_elements() {
1236        let input = "l 1 2 3
1237l 4/1 5/2 6/3
1238p 1 2 3 4
1239";
1240        let mut reader = ExtendedReader32::default();
1241        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1242        assert_eq!(
1243            reader.line_elements,
1244            vec![
1245                vec![(1, None), (2, None), (3, None)],
1246                vec![(4, Some(1)), (5, Some(2)), (6, Some(3))],
1247            ]
1248        );
1249        assert_eq!(reader.point_elements, vec![vec![1, 2, 3, 4]]);
1250    }
1251
1252    #[test]
1253    fn read_line_element_with_one_vertex_errors() {
1254        let mut reader = ExtendedReader32::default();
1255        let err = read_obj_file(Cursor::new("l 1\n"), &mut reader).unwrap_err();
1256        assert!(
1257            err.to_string().contains("at least 2 vertices"),
1258            "got: {}",
1259            err
1260        );
1261    }
1262
1263    #[test]
1264    fn write_directive_round_trip() {
1265        // Write every new directive, parse the output back, compare.
1266        let mut buffer = Vec::new();
1267        {
1268            let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
1269            writer
1270                .write_material_lib(&["lib1.mtl", "lib2.mtl"])
1271                .unwrap();
1272            writer.write_use_material("Wood").unwrap();
1273            writer.write_group(&["cube", "top"]).unwrap();
1274            writer.write_smoothing_group(SmoothingGroup::Off).unwrap();
1275            writer
1276                .write_smoothing_group(SmoothingGroup::Group(7))
1277                .unwrap();
1278            writer
1279                .write_line_element(&[(1, None), (2, Some(2)), (3, None)])
1280                .unwrap();
1281            writer.write_point_element(&[1, 2, 3]).unwrap();
1282        }
1283        let text = String::from_utf8(buffer.clone()).unwrap();
1284        let expected = "mtllib lib1.mtl lib2.mtl
1285usemtl Wood
1286g cube top
1287s off
1288s 7
1289l 1 2/2 3
1290p 1 2 3
1291";
1292        assert_eq!(text, expected);
1293
1294        let mut reader = ExtendedReader32::default();
1295        read_obj_file(Cursor::new(buffer), &mut reader).unwrap();
1296        assert_eq!(
1297            reader.material_libs,
1298            vec![vec!["lib1.mtl".to_string(), "lib2.mtl".to_string()]]
1299        );
1300        assert_eq!(reader.use_materials, vec!["Wood".to_string()]);
1301        assert_eq!(
1302            reader.groups,
1303            vec![vec!["cube".to_string(), "top".to_string()]]
1304        );
1305        assert_eq!(
1306            reader.smoothing_groups,
1307            vec![SmoothingGroup::Off, SmoothingGroup::Group(7)]
1308        );
1309        assert_eq!(
1310            reader.line_elements,
1311            vec![vec![(1, None), (2, Some(2)), (3, None)]]
1312        );
1313        assert_eq!(reader.point_elements, vec![vec![1, 2, 3]]);
1314    }
1315
1316    #[test]
1317    fn unknown_prefix_still_errors_by_default() {
1318        // Sanity-check that adding the typed directives didn't accidentally
1319        // make the strict reader lenient for prefixes outside the new set.
1320        let input = "vp 0.1 0.2 0.3\n";
1321        let mut reader = ExtendedReader32::default();
1322        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1323        assert!(
1324            err.to_string().contains("Unknown line prefix: vp"),
1325            "got: {}",
1326            err
1327        );
1328    }
1329
1330    // Typed-error tests: callers can pattern-match on `ObjError` and
1331    // `ParseErrorKind` instead of string-matching on the Display.
1332
1333    #[test]
1334    fn typed_error_unknown_prefix_carries_prefix_and_line() {
1335        let input = "v 0 0 0\nvp 0.1 0.2\n";
1336        let mut reader = ExtendedReader32::default();
1337        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1338        match err {
1339            ObjError::Parse {
1340                line,
1341                kind: ParseErrorKind::UnknownPrefix(prefix),
1342            } => {
1343                assert_eq!(line, 2);
1344                assert_eq!(prefix, "vp");
1345            }
1346            other => panic!("wrong error variant: {:?}", other),
1347        }
1348    }
1349
1350    #[test]
1351    fn typed_error_invalid_number_carries_field_and_value() {
1352        let input = "v 1.0 nope 3.0\n";
1353        let mut reader = ExtendedReader32::default();
1354        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1355        match err {
1356            ObjError::Parse {
1357                line: 1,
1358                kind: ParseErrorKind::InvalidNumber { field, value },
1359            } => {
1360                assert_eq!(field, "vertex y");
1361                assert_eq!(value, "nope");
1362            }
1363            other => panic!("wrong error variant: {:?}", other),
1364        }
1365    }
1366
1367    #[test]
1368    fn typed_error_invalid_index_for_face() {
1369        // Index 0 is illegal in OBJ.
1370        let input = "v 0 0 0\nf 0/0/0 0/0/0 0/0/0\n";
1371        let mut reader = ExtendedReader32::default();
1372        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1373        match err {
1374            ObjError::Parse {
1375                line: 2,
1376                kind: ParseErrorKind::InvalidIndex { kind, value },
1377            } => {
1378                assert_eq!(kind, "vertex");
1379                assert_eq!(value, "0");
1380            }
1381            other => panic!("wrong error variant: {:?}", other),
1382        }
1383    }
1384
1385    #[test]
1386    fn typed_error_invalid_smoothing_group() {
1387        let input = "s notanumber\n";
1388        let mut reader = ExtendedReader32::default();
1389        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1390        match err {
1391            ObjError::Parse {
1392                line: 1,
1393                kind: ParseErrorKind::InvalidSmoothingGroup(value),
1394            } => assert_eq!(value, "notanumber"),
1395            other => panic!("wrong error variant: {:?}", other),
1396        }
1397    }
1398
1399    #[test]
1400    fn typed_error_missing_field() {
1401        let input = "v 1.0 2.0\n";
1402        let mut reader = ExtendedReader32::default();
1403        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1404        match err {
1405            ObjError::Parse {
1406                line: 1,
1407                kind: ParseErrorKind::MissingField(field),
1408            } => assert_eq!(field, "vertex z"),
1409            other => panic!("wrong error variant: {:?}", other),
1410        }
1411    }
1412
1413    #[test]
1414    fn typed_error_converts_to_io_error() {
1415        // `read_obj_file` returns `ObjError`; callers that work in
1416        // `io::Result` get a free conversion via `From<ObjError>`.
1417        fn legacy_caller<R: io::Read>(input: R) -> io::Result<()> {
1418            let mut reader = ExtendedReader32::default();
1419            read_obj_file(input, &mut reader)?;
1420            Ok(())
1421        }
1422
1423        let err = legacy_caller(Cursor::new("vp 0.1 0.2\n")).unwrap_err();
1424        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1425        assert!(err.to_string().contains("Unknown line prefix: vp"));
1426    }
1427
1428    #[test]
1429    fn typed_error_io_failure_propagates() {
1430        // Force a Read error mid-stream.
1431        struct FailingRead;
1432        impl io::Read for FailingRead {
1433            fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1434                Err(io::Error::other("disk on fire"))
1435            }
1436        }
1437
1438        let mut reader = ExtendedReader32::default();
1439        let err = read_obj_file(FailingRead, &mut reader).unwrap_err();
1440        match err {
1441            ObjError::Io(inner) => {
1442                assert_eq!(inner.to_string(), "disk on fire");
1443            }
1444            other => panic!("wrong error variant: {:?}", other),
1445        }
1446    }
1447
1448    #[test]
1449    fn custom_read_unknown_can_return_objerror() {
1450        // A reader that surfaces unknown prefixes as Custom errors.
1451        struct StrictCustom;
1452        impl ObjReader<f32> for StrictCustom {
1453            fn read_comment(&mut self, _: &str) {}
1454            fn read_object_name(&mut self, _: &str) {}
1455            fn read_vertex(&mut self, _: f32, _: f32, _: f32, _: Option<f32>) {}
1456            fn read_texture_coordinate(&mut self, _: f32, _: Option<f32>, _: Option<f32>) {}
1457            fn read_normal(&mut self, _: f32, _: f32, _: f32) {}
1458            fn read_face(&mut self, _: &[(usize, Option<usize>, Option<usize>)]) {}
1459            fn read_unknown(&mut self, prefix: &str, _: &str, line: usize) -> Result<(), ObjError> {
1460                Err(ObjError::Parse {
1461                    line,
1462                    kind: ParseErrorKind::Custom(format!("nope: {prefix}")),
1463                })
1464            }
1465        }
1466
1467        let err = read_obj_file(Cursor::new("vp 0 0\n"), &mut StrictCustom).unwrap_err();
1468        match err {
1469            ObjError::Parse {
1470                line: 1,
1471                kind: ParseErrorKind::Custom(msg),
1472            } => assert_eq!(msg, "nope: vp"),
1473            other => panic!("wrong error variant: {:?}", other),
1474        }
1475    }
1476}