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    /// Consume the writer and return the underlying sink.
576    pub fn into_inner(self) -> W {
577        self.out
578    }
579
580    #[inline]
581    fn push_str(&mut self, s: &str) {
582        self.line_buf.extend_from_slice(s.as_bytes());
583    }
584
585    #[inline]
586    fn push_u<T: itoa::Integer>(&mut self, v: T) {
587        let mut buf = itoa::Buffer::new();
588        self.push_str(buf.format(v));
589    }
590
591    #[inline]
592    fn push_f(&mut self, v: F) {
593        // we want 0 as "0" not "0.0"
594        if v.is_zero_fract() {
595            self.push_str(&format!("{}", v));
596            return;
597        }
598        // 6 decimal places when matching C `printf %f`, otherwise full
599        // round-trippable precision via the type's Display impl.
600        if self.printf_f_format {
601            self.push_str(&format!("{:.6}", v));
602        } else {
603            self.push_str(&format!("{}", v));
604        }
605    }
606
607    #[inline]
608    fn flush_line(&mut self) -> io::Result<()> {
609        self.line_buf.push(b'\n');
610        self.out.write_all(&self.line_buf)?;
611        self.line_buf.clear();
612        Ok(())
613    }
614}
615impl<W: io::Write, F: ObjFloat> ObjWriter<F> for IoObjWriter<W, F> {
616    fn write_comment<S: AsRef<str>>(&mut self, comment: S) -> io::Result<()> {
617        self.push_str("# ");
618        self.push_str(comment.as_ref());
619        self.flush_line()
620    }
621
622    fn write_object_name<S: AsRef<str>>(&mut self, name: S) -> io::Result<()> {
623        self.push_str("o ");
624        self.push_str(name.as_ref());
625        self.flush_line()
626    }
627
628    fn write_vertex(&mut self, x: F, y: F, z: F, w: Option<F>) -> io::Result<()> {
629        self.push_str("v ");
630        self.push_f(x);
631        self.push_str(" ");
632        self.push_f(y);
633        self.push_str(" ");
634        self.push_f(z);
635        if let Some(wv) = w {
636            self.push_str(" ");
637            self.push_f(wv);
638        }
639        self.flush_line()
640    }
641
642    fn write_texture_coordinate(&mut self, u: F, v: Option<F>, w: Option<F>) -> io::Result<()> {
643        self.push_str("vt ");
644        self.push_f(u);
645        if let Some(vv) = v {
646            self.push_str(" ");
647            self.push_f(vv);
648            if let Some(wv) = w {
649                self.push_str(" ");
650                self.push_f(wv);
651            }
652        }
653        self.flush_line()
654    }
655
656    fn write_normal(&mut self, nx: F, ny: F, nz: F) -> io::Result<()> {
657        self.push_str("vn ");
658        self.push_f(nx);
659        self.push_str(" ");
660        self.push_f(ny);
661        self.push_str(" ");
662        self.push_f(nz);
663        self.flush_line()
664    }
665
666    fn write_face(
667        &mut self,
668        vertex_indices: &[(usize, Option<usize>, Option<usize>)],
669    ) -> io::Result<()> {
670        // Build the whole face line and write once.
671        self.push_str("f");
672        for (v_idx, vt_idx, vn_idx) in vertex_indices.iter() {
673            self.push_str(" ");
674            // If your internal indices are zero-based, emit +1 here:
675            self.push_u(*v_idx);
676            match (vt_idx, vn_idx) {
677                (None, None) => {}
678                (Some(vt), None) => {
679                    self.push_str("/");
680                    self.push_u(*vt);
681                }
682                (None, Some(vn)) => {
683                    self.push_str("//");
684                    self.push_u(*vn);
685                }
686                (Some(vt), Some(vn)) => {
687                    self.push_str("/");
688                    self.push_u(*vt);
689                    self.push_str("/");
690                    self.push_u(*vn);
691                }
692            }
693        }
694        self.flush_line()
695    }
696
697    fn write_material_lib<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()> {
698        self.push_str("mtllib");
699        for name in names {
700            self.push_str(" ");
701            self.push_str(name.as_ref());
702        }
703        self.flush_line()
704    }
705
706    fn write_use_material<S: AsRef<str>>(&mut self, name: S) -> io::Result<()> {
707        self.push_str("usemtl ");
708        self.push_str(name.as_ref());
709        self.flush_line()
710    }
711
712    fn write_group<S: AsRef<str>>(&mut self, names: &[S]) -> io::Result<()> {
713        self.push_str("g");
714        for name in names {
715            self.push_str(" ");
716            self.push_str(name.as_ref());
717        }
718        self.flush_line()
719    }
720
721    fn write_smoothing_group(&mut self, group: SmoothingGroup) -> io::Result<()> {
722        match group {
723            SmoothingGroup::Off => self.push_str("s off"),
724            SmoothingGroup::Group(n) => {
725                self.push_str("s ");
726                self.push_u(n);
727            }
728        }
729        self.flush_line()
730    }
731
732    fn write_line_element(&mut self, indices: &[(usize, Option<usize>)]) -> io::Result<()> {
733        self.push_str("l");
734        for (v_idx, vt_idx) in indices.iter() {
735            self.push_str(" ");
736            self.push_u(*v_idx);
737            if let Some(vt) = vt_idx {
738                self.push_str("/");
739                self.push_u(*vt);
740            }
741        }
742        self.flush_line()
743    }
744
745    fn write_point_element(&mut self, indices: &[usize]) -> io::Result<()> {
746        self.push_str("p");
747        for v_idx in indices.iter() {
748            self.push_str(" ");
749            self.push_u(*v_idx);
750        }
751        self.flush_line()
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use pretty_assertions::assert_eq;
759    use std::io::Cursor;
760
761    type Face = Vec<(usize, Option<usize>, Option<usize>)>;
762
763    #[derive(Default)]
764    struct TestObjReader64 {
765        comments: Vec<String>,
766        names: Vec<String>,
767        vertices: Vec<(f64, f64, f64, Option<f64>)>,
768        texture_coordinates: Vec<(f64, Option<f64>, Option<f64>)>,
769        normals: Vec<(f64, f64, f64)>,
770        faces: Vec<Face>,
771    }
772
773    impl ObjReader for TestObjReader64 {
774        fn read_comment(&mut self, comment: &str) {
775            self.comments.push(comment.to_string());
776        }
777
778        fn read_object_name(&mut self, name: &str) {
779            self.names.push(name.to_string());
780        }
781
782        fn read_vertex(&mut self, x: f64, y: f64, z: f64, w: Option<f64>) {
783            self.vertices.push((x, y, z, w));
784        }
785
786        fn read_texture_coordinate(&mut self, u: f64, v: Option<f64>, w: Option<f64>) {
787            self.texture_coordinates.push((u, v, w));
788        }
789
790        fn read_normal(&mut self, nx: f64, ny: f64, nz: f64) {
791            self.normals.push((nx, ny, nz));
792        }
793
794        fn read_face(&mut self, vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {
795            self.faces.push(vertex_indices.to_vec());
796        }
797    }
798
799    #[test]
800    fn test_obj_reading_2() {
801        let input = "# This is a test OBJ file
802o TestObject
803v 1 2 3
804vt 0.5 0.5
805vn 0 1 1.1
806f 1/1/1 2/2/2 3/3/3
807";
808
809        let reader = Cursor::new(input);
810        let mut test_reader: TestObjReader64 = Default::default();
811        read_obj_file(reader, &mut test_reader).unwrap();
812        assert_eq!(test_reader.comments, vec!["This is a test OBJ file"]);
813        assert_eq!(test_reader.names, vec!["TestObject"]);
814        assert_eq!(test_reader.vertices, vec![(1.0, 2.0, 3.0, None)]);
815        assert_eq!(
816            test_reader.texture_coordinates,
817            vec![(0.5, Some(0.5), None)]
818        );
819        assert_eq!(test_reader.normals, vec![(0.0, 1.0, 1.1)]);
820        assert_eq!(
821            test_reader.faces,
822            vec![vec![
823                (1, Some(1), Some(1)),
824                (2, Some(2), Some(2)),
825                (3, Some(3), Some(3))
826            ]]
827        );
828    }
829
830    #[test]
831    fn test_obj_writing() {
832        let mut buffer = Vec::new();
833        let mut writer = IoObjWriter::new(&mut buffer);
834        writer.write_comment("This is a test OBJ file").unwrap();
835        writer.write_object_name("TestObject").unwrap();
836        writer.write_vertex(1.0, 2.0, 3.0, None).unwrap();
837        writer
838            .write_texture_coordinate(0.5, Some(0.5), None)
839            .unwrap();
840        writer.write_normal(0.0, 1.0, 1.1).unwrap();
841        writer
842            .write_face(&[
843                (1, Some(1), Some(1)),
844                (2, Some(2), Some(2)),
845                (3, Some(3), Some(3)),
846            ])
847            .unwrap();
848
849        let output = String::from_utf8(buffer).unwrap();
850        let expected_output = "# This is a test OBJ file
851o TestObject
852v 1 2 3
853vt 0.5 0.5
854vn 0 1 1.1
855f 1/1/1 2/2/2 3/3/3
856";
857        assert_eq!(output, expected_output);
858    }
859
860    // Tests for f32 support
861
862    #[derive(Default)]
863    struct TestObjReader32 {
864        vertices: Vec<(f32, f32, f32, Option<f32>)>,
865        texture_coordinates: Vec<(f32, Option<f32>, Option<f32>)>,
866        normals: Vec<(f32, f32, f32)>,
867    }
868
869    impl ObjReader<f32> for TestObjReader32 {
870        fn read_comment(&mut self, _comment: &str) {}
871        fn read_object_name(&mut self, _name: &str) {}
872
873        fn read_vertex(&mut self, x: f32, y: f32, z: f32, w: Option<f32>) {
874            self.vertices.push((x, y, z, w));
875        }
876
877        fn read_texture_coordinate(&mut self, u: f32, v: Option<f32>, w: Option<f32>) {
878            self.texture_coordinates.push((u, v, w));
879        }
880
881        fn read_normal(&mut self, nx: f32, ny: f32, nz: f32) {
882            self.normals.push((nx, ny, nz));
883        }
884
885        fn read_face(&mut self, _vertex_indices: &[(usize, Option<usize>, Option<usize>)]) {}
886    }
887
888    #[test]
889    fn test_obj_f32_reading() {
890        let input = "o TestObject
891v 1.5 2.5 3.5
892vt 0.25 0.75
893vn 0.0 1.0 0.0
894";
895
896        let reader = Cursor::new(input);
897        let mut test_reader = TestObjReader32::default();
898        read_obj_file(reader, &mut test_reader).unwrap();
899
900        assert_eq!(test_reader.vertices, vec![(1.5f32, 2.5f32, 3.5f32, None)]);
901        assert_eq!(
902            test_reader.texture_coordinates,
903            vec![(0.25f32, Some(0.75f32), None)]
904        );
905        assert_eq!(test_reader.normals, vec![(0.0f32, 1.0f32, 0.0f32)]);
906    }
907
908    #[test]
909    fn test_obj_f32_writing() {
910        let mut buffer = Vec::new();
911        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
912
913        writer.write_comment("f32 test").unwrap();
914        writer.write_object_name("F32Object").unwrap();
915        writer.write_vertex(1.5f32, 2.5f32, 3.5f32, None).unwrap();
916        writer
917            .write_texture_coordinate(0.25f32, Some(0.75f32), None)
918            .unwrap();
919        writer.write_normal(0.0f32, 1.0f32, 0.0f32).unwrap();
920
921        let output = String::from_utf8(buffer).unwrap();
922        let expected = "# f32 test
923o F32Object
924v 1.5 2.5 3.5
925vt 0.25 0.75
926vn 0 1 0
927";
928        assert_eq!(output, expected);
929    }
930
931    #[test]
932    fn test_obj_f32_round_trip() {
933        // Test that f32 values round-trip correctly
934        let input = "o Test
935v 0.123456789 0.987654321 1.5
936vn 0.577 0.577 0.577
937";
938
939        let reader = Cursor::new(input);
940        let mut test_reader = TestObjReader32::default();
941        read_obj_file(reader, &mut test_reader).unwrap();
942
943        // Write back using f32 writer
944        let mut buffer = Vec::new();
945        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
946        writer.write_object_name("Test").unwrap();
947        for (x, y, z, w) in &test_reader.vertices {
948            writer.write_vertex(*x, *y, *z, *w).unwrap();
949        }
950        for (nx, ny, nz) in &test_reader.normals {
951            writer.write_normal(*nx, *ny, *nz).unwrap();
952        }
953
954        let output = String::from_utf8(buffer).unwrap();
955
956        // The values should be f32-precision
957        assert!(output.contains("o Test"));
958        assert!(output.contains("v "));
959        assert!(output.contains("vn "));
960    }
961
962    #[test]
963    fn test_printf_f_formatting() {
964        let mut buffer = Vec::new();
965        let mut writer: IoObjWriter<_, f64> = IoObjWriter::new_with_printf_f_format(&mut buffer);
966
967        writer.write_object_name("PrintfFTest").unwrap();
968        writer
969            .write_vertex(754.4214477539063, 1753.2353515625, -91.72238159179688, None)
970            .unwrap();
971        writer
972            .write_texture_coordinate(0.123456789, Some(0.987654321), None)
973            .unwrap();
974        writer
975            .write_normal(0.5773502691896257, 0.5773502691896257, 0.5773502691896257)
976            .unwrap();
977
978        let output = String::from_utf8(buffer).unwrap();
979
980        // C `printf %f` defaults to 6 decimal places.
981        let expected = "o PrintfFTest
982v 754.421448 1753.235352 -91.722382
983vt 0.123457 0.987654
984vn 0.577350 0.577350 0.577350
985";
986        assert_eq!(output, expected);
987    }
988
989    #[test]
990    fn test_printf_f_flag_toggle() {
991        // Test that we can toggle the flag
992        let mut buffer = Vec::new();
993        let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
994
995        // Start with default (full precision)
996        writer
997            .write_vertex(1.2345678_f32, 2.345678_f32, 3.45678_f32, None)
998            .unwrap();
999
1000        // Enable printf %f formatting
1001        writer.set_printf_f_format(true);
1002        writer
1003            .write_vertex(1.2345678_f32, 2.3456789_f32, 3.456789_f32, None)
1004            .unwrap();
1005
1006        let output = String::from_utf8(buffer).unwrap();
1007
1008        // First line should have full f32 precision, second should have 6 decimals
1009        let lines: Vec<&str> = output.lines().collect();
1010        assert_eq!(lines.len(), 2);
1011
1012        // Full precision output (f32 Display)
1013        assert_eq!(lines[0], "v 1.2345678 2.345678 3.45678");
1014
1015        // printf %f output (6 decimal places)
1016        assert_eq!(lines[1], "v 1.234568 2.345679 3.456789");
1017    }
1018
1019    // Tests for the standard auxiliary directives:
1020    //   mtllib, usemtl, g, s, l, p
1021
1022    /// Captures every directive seen, for round-trip and equality testing.
1023    #[derive(Default, Debug, PartialEq)]
1024    struct ExtendedReader32 {
1025        material_libs: Vec<Vec<String>>,
1026        use_materials: Vec<String>,
1027        groups: Vec<Vec<String>>,
1028        smoothing_groups: Vec<SmoothingGroup>,
1029        line_elements: Vec<Vec<(usize, Option<usize>)>>,
1030        point_elements: Vec<Vec<usize>>,
1031    }
1032
1033    impl ObjReader<f32> for ExtendedReader32 {
1034        fn read_comment(&mut self, _: &str) {}
1035        fn read_object_name(&mut self, _: &str) {}
1036        fn read_vertex(&mut self, _: f32, _: f32, _: f32, _: Option<f32>) {}
1037        fn read_texture_coordinate(&mut self, _: f32, _: Option<f32>, _: Option<f32>) {}
1038        fn read_normal(&mut self, _: f32, _: f32, _: f32) {}
1039        fn read_face(&mut self, _: &[(usize, Option<usize>, Option<usize>)]) {}
1040
1041        fn read_material_lib(&mut self, names: &[&str]) {
1042            self.material_libs
1043                .push(names.iter().map(|s| s.to_string()).collect());
1044        }
1045        fn read_use_material(&mut self, name: &str) {
1046            self.use_materials.push(name.to_string());
1047        }
1048        fn read_group(&mut self, names: &[&str]) {
1049            self.groups
1050                .push(names.iter().map(|s| s.to_string()).collect());
1051        }
1052        fn read_smoothing_group(&mut self, group: SmoothingGroup) {
1053            self.smoothing_groups.push(group);
1054        }
1055        fn read_line_element(&mut self, indices: &[(usize, Option<usize>)]) {
1056            self.line_elements.push(indices.to_vec());
1057        }
1058        fn read_point_element(&mut self, indices: &[usize]) {
1059            self.point_elements.push(indices.to_vec());
1060        }
1061    }
1062
1063    #[test]
1064    fn read_mtllib_and_usemtl() {
1065        let input = "mtllib first.mtl second.mtl
1066usemtl SomeMaterial
1067";
1068        let mut reader = ExtendedReader32::default();
1069        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1070        assert_eq!(
1071            reader.material_libs,
1072            vec![vec!["first.mtl".to_string(), "second.mtl".to_string()]]
1073        );
1074        assert_eq!(reader.use_materials, vec!["SomeMaterial".to_string()]);
1075    }
1076
1077    #[test]
1078    fn read_group_with_multiple_names() {
1079        let input = "g cube top
1080g default
1081";
1082        let mut reader = ExtendedReader32::default();
1083        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1084        assert_eq!(
1085            reader.groups,
1086            vec![
1087                vec!["cube".to_string(), "top".to_string()],
1088                vec!["default".to_string()],
1089            ]
1090        );
1091    }
1092
1093    #[test]
1094    fn read_smoothing_group_off_zero_and_named() {
1095        let input = "s off
1096s 0
1097s 1
1098s 42
1099";
1100        let mut reader = ExtendedReader32::default();
1101        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1102        assert_eq!(
1103            reader.smoothing_groups,
1104            vec![
1105                SmoothingGroup::Off,
1106                SmoothingGroup::Off,
1107                SmoothingGroup::Group(1),
1108                SmoothingGroup::Group(42),
1109            ]
1110        );
1111    }
1112
1113    #[test]
1114    fn read_smoothing_group_invalid_value_errors() {
1115        let input = "s notanumber\n";
1116        let mut reader = ExtendedReader32::default();
1117        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1118        assert!(
1119            err.to_string().contains("invalid smoothing group"),
1120            "got: {}",
1121            err
1122        );
1123    }
1124
1125    #[test]
1126    fn read_line_and_point_elements() {
1127        let input = "l 1 2 3
1128l 4/1 5/2 6/3
1129p 1 2 3 4
1130";
1131        let mut reader = ExtendedReader32::default();
1132        read_obj_file(Cursor::new(input), &mut reader).unwrap();
1133        assert_eq!(
1134            reader.line_elements,
1135            vec![
1136                vec![(1, None), (2, None), (3, None)],
1137                vec![(4, Some(1)), (5, Some(2)), (6, Some(3))],
1138            ]
1139        );
1140        assert_eq!(reader.point_elements, vec![vec![1, 2, 3, 4]]);
1141    }
1142
1143    #[test]
1144    fn read_line_element_with_one_vertex_errors() {
1145        let mut reader = ExtendedReader32::default();
1146        let err = read_obj_file(Cursor::new("l 1\n"), &mut reader).unwrap_err();
1147        assert!(
1148            err.to_string().contains("at least 2 vertices"),
1149            "got: {}",
1150            err
1151        );
1152    }
1153
1154    #[test]
1155    fn write_directive_round_trip() {
1156        // Write every new directive, parse the output back, compare.
1157        let mut buffer = Vec::new();
1158        {
1159            let mut writer: IoObjWriter<_, f32> = IoObjWriter::new(&mut buffer);
1160            writer
1161                .write_material_lib(&["lib1.mtl", "lib2.mtl"])
1162                .unwrap();
1163            writer.write_use_material("Wood").unwrap();
1164            writer.write_group(&["cube", "top"]).unwrap();
1165            writer.write_smoothing_group(SmoothingGroup::Off).unwrap();
1166            writer
1167                .write_smoothing_group(SmoothingGroup::Group(7))
1168                .unwrap();
1169            writer
1170                .write_line_element(&[(1, None), (2, Some(2)), (3, None)])
1171                .unwrap();
1172            writer.write_point_element(&[1, 2, 3]).unwrap();
1173        }
1174        let text = String::from_utf8(buffer.clone()).unwrap();
1175        let expected = "mtllib lib1.mtl lib2.mtl
1176usemtl Wood
1177g cube top
1178s off
1179s 7
1180l 1 2/2 3
1181p 1 2 3
1182";
1183        assert_eq!(text, expected);
1184
1185        let mut reader = ExtendedReader32::default();
1186        read_obj_file(Cursor::new(buffer), &mut reader).unwrap();
1187        assert_eq!(
1188            reader.material_libs,
1189            vec![vec!["lib1.mtl".to_string(), "lib2.mtl".to_string()]]
1190        );
1191        assert_eq!(reader.use_materials, vec!["Wood".to_string()]);
1192        assert_eq!(
1193            reader.groups,
1194            vec![vec!["cube".to_string(), "top".to_string()]]
1195        );
1196        assert_eq!(
1197            reader.smoothing_groups,
1198            vec![SmoothingGroup::Off, SmoothingGroup::Group(7)]
1199        );
1200        assert_eq!(
1201            reader.line_elements,
1202            vec![vec![(1, None), (2, Some(2)), (3, None)]]
1203        );
1204        assert_eq!(reader.point_elements, vec![vec![1, 2, 3]]);
1205    }
1206
1207    #[test]
1208    fn unknown_prefix_still_errors_by_default() {
1209        // Sanity-check that adding the typed directives didn't accidentally
1210        // make the strict reader lenient for prefixes outside the new set.
1211        let input = "vp 0.1 0.2 0.3\n";
1212        let mut reader = ExtendedReader32::default();
1213        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1214        assert!(
1215            err.to_string().contains("Unknown line prefix: vp"),
1216            "got: {}",
1217            err
1218        );
1219    }
1220
1221    // Typed-error tests: callers can pattern-match on `ObjError` and
1222    // `ParseErrorKind` instead of string-matching on the Display.
1223
1224    #[test]
1225    fn typed_error_unknown_prefix_carries_prefix_and_line() {
1226        let input = "v 0 0 0\nvp 0.1 0.2\n";
1227        let mut reader = ExtendedReader32::default();
1228        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1229        match err {
1230            ObjError::Parse {
1231                line,
1232                kind: ParseErrorKind::UnknownPrefix(prefix),
1233            } => {
1234                assert_eq!(line, 2);
1235                assert_eq!(prefix, "vp");
1236            }
1237            other => panic!("wrong error variant: {:?}", other),
1238        }
1239    }
1240
1241    #[test]
1242    fn typed_error_invalid_number_carries_field_and_value() {
1243        let input = "v 1.0 nope 3.0\n";
1244        let mut reader = ExtendedReader32::default();
1245        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1246        match err {
1247            ObjError::Parse {
1248                line: 1,
1249                kind: ParseErrorKind::InvalidNumber { field, value },
1250            } => {
1251                assert_eq!(field, "vertex y");
1252                assert_eq!(value, "nope");
1253            }
1254            other => panic!("wrong error variant: {:?}", other),
1255        }
1256    }
1257
1258    #[test]
1259    fn typed_error_invalid_index_for_face() {
1260        // Index 0 is illegal in OBJ.
1261        let input = "v 0 0 0\nf 0/0/0 0/0/0 0/0/0\n";
1262        let mut reader = ExtendedReader32::default();
1263        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1264        match err {
1265            ObjError::Parse {
1266                line: 2,
1267                kind: ParseErrorKind::InvalidIndex { kind, value },
1268            } => {
1269                assert_eq!(kind, "vertex");
1270                assert_eq!(value, "0");
1271            }
1272            other => panic!("wrong error variant: {:?}", other),
1273        }
1274    }
1275
1276    #[test]
1277    fn typed_error_invalid_smoothing_group() {
1278        let input = "s notanumber\n";
1279        let mut reader = ExtendedReader32::default();
1280        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1281        match err {
1282            ObjError::Parse {
1283                line: 1,
1284                kind: ParseErrorKind::InvalidSmoothingGroup(value),
1285            } => assert_eq!(value, "notanumber"),
1286            other => panic!("wrong error variant: {:?}", other),
1287        }
1288    }
1289
1290    #[test]
1291    fn typed_error_missing_field() {
1292        let input = "v 1.0 2.0\n";
1293        let mut reader = ExtendedReader32::default();
1294        let err = read_obj_file(Cursor::new(input), &mut reader).unwrap_err();
1295        match err {
1296            ObjError::Parse {
1297                line: 1,
1298                kind: ParseErrorKind::MissingField(field),
1299            } => assert_eq!(field, "vertex z"),
1300            other => panic!("wrong error variant: {:?}", other),
1301        }
1302    }
1303
1304    #[test]
1305    fn typed_error_converts_to_io_error() {
1306        // `read_obj_file` returns `ObjError`; callers that work in
1307        // `io::Result` get a free conversion via `From<ObjError>`.
1308        fn legacy_caller<R: io::Read>(input: R) -> io::Result<()> {
1309            let mut reader = ExtendedReader32::default();
1310            read_obj_file(input, &mut reader)?;
1311            Ok(())
1312        }
1313
1314        let err = legacy_caller(Cursor::new("vp 0.1 0.2\n")).unwrap_err();
1315        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1316        assert!(err.to_string().contains("Unknown line prefix: vp"));
1317    }
1318
1319    #[test]
1320    fn typed_error_io_failure_propagates() {
1321        // Force a Read error mid-stream.
1322        struct FailingRead;
1323        impl io::Read for FailingRead {
1324            fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1325                Err(io::Error::other("disk on fire"))
1326            }
1327        }
1328
1329        let mut reader = ExtendedReader32::default();
1330        let err = read_obj_file(FailingRead, &mut reader).unwrap_err();
1331        match err {
1332            ObjError::Io(inner) => {
1333                assert_eq!(inner.to_string(), "disk on fire");
1334            }
1335            other => panic!("wrong error variant: {:?}", other),
1336        }
1337    }
1338
1339    #[test]
1340    fn custom_read_unknown_can_return_objerror() {
1341        // A reader that surfaces unknown prefixes as Custom errors.
1342        struct StrictCustom;
1343        impl ObjReader<f32> for StrictCustom {
1344            fn read_comment(&mut self, _: &str) {}
1345            fn read_object_name(&mut self, _: &str) {}
1346            fn read_vertex(&mut self, _: f32, _: f32, _: f32, _: Option<f32>) {}
1347            fn read_texture_coordinate(&mut self, _: f32, _: Option<f32>, _: Option<f32>) {}
1348            fn read_normal(&mut self, _: f32, _: f32, _: f32) {}
1349            fn read_face(&mut self, _: &[(usize, Option<usize>, Option<usize>)]) {}
1350            fn read_unknown(&mut self, prefix: &str, _: &str, line: usize) -> Result<(), ObjError> {
1351                Err(ObjError::Parse {
1352                    line,
1353                    kind: ParseErrorKind::Custom(format!("nope: {prefix}")),
1354                })
1355            }
1356        }
1357
1358        let err = read_obj_file(Cursor::new("vp 0 0\n"), &mut StrictCustom).unwrap_err();
1359        match err {
1360            ObjError::Parse {
1361                line: 1,
1362                kind: ParseErrorKind::Custom(msg),
1363            } => assert_eq!(msg, "nope: vp"),
1364            other => panic!("wrong error variant: {:?}", other),
1365        }
1366    }
1367}