diff --git a/examples/print_mesh.rs b/examples/print_mesh.rs index 808518e..4a523db 100644 --- a/examples/print_mesh.rs +++ b/examples/print_mesh.rs @@ -6,7 +6,8 @@ fn main() { let (models, materials) = tobj::load_obj(obj_file, &tobj::LoadOptions::default()).expect("Failed to OBJ load file"); - // Note: If you don't mind missing the materials, you can generate a default. + // Note: If you don't mind missing the materials, you can generate a + // default. let materials = materials.expect("Failed to load MTL file"); println!("Number of models = {}", models.len()); diff --git a/src/lib.rs b/src/lib.rs index c559dba..c2e7f93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -241,8 +241,10 @@ use std::{ fmt, fs::File, io::{prelude::*, BufReader}, + ops::ControlFlow, path::{Path, PathBuf}, str::{FromStr, SplitWhitespace}, + sync::Arc, }; #[cfg(feature = "use_f64")] @@ -276,6 +278,7 @@ pub const GPU_LOAD_OPTIONS: LoadOptions = LoadOptions { triangulate: true, ignore_points: true, ignore_lines: true, + progress_callback: None, }; /// Typical [`LoadOptions`] for using meshes with an offline rendeder. @@ -293,6 +296,7 @@ pub const OFFLINE_RENDERING_LOAD_OPTIONS: LoadOptions = LoadOptions { triangulate: false, ignore_points: true, ignore_lines: true, + progress_callback: None, }; /// A mesh made up of triangles loaded from some `OBJ` file. @@ -404,6 +408,61 @@ pub struct Mesh { pub material_id: Option, } +/// A snapshot of progress made so far while parsing an `OBJ` buffer in +/// [`load_obj_buf()`]. +/// +/// Passed to a [`LoadProgressCallback`] registered via +/// [`LoadOptions::progress_callback`]. The callback is throttled -- it is not +/// invoked for every line read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoadProgress { + /// Number of lines read from the buffer so far. + pub lines_read: u64, + /// Number of bytes read from the buffer so far. + /// + /// This is a lower bound: line-ending bytes stripped by + /// [`BufRead::lines()`](std::io::BufRead::lines) are not counted, since + /// they are not seen by the parser. + pub bytes_read: u64, +} + +/// A throttled progress-report and cooperative-cancellation callback. +/// +/// Wraps a closure that is invoked periodically while [`load_obj_buf()`] +/// parses a buffer. Returning [`ControlFlow::Break`] from the closure aborts +/// the load and causes [`load_obj_buf()`] to return +/// [`LoadError::Cancelled`]. +/// +/// Register one via [`LoadOptions::progress_callback`]. +#[derive(Clone)] +pub struct LoadProgressCallback(Arc); + +type LoadProgressCallbackFn = dyn Fn(&LoadProgress) -> ControlFlow<()> + Send + Sync; + +impl LoadProgressCallback { + /// Creates a new [`LoadProgressCallback`] from a closure. + pub fn new(f: impl Fn(&LoadProgress) -> ControlFlow<()> + Send + Sync + 'static) -> Self { + Self(Arc::new(f)) + } + + /// Invokes the wrapped closure with the given `progress` snapshot. + fn call(&self, progress: &LoadProgress) -> ControlFlow<()> { + (self.0)(progress) + } +} + +impl fmt::Debug for LoadProgressCallback { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("LoadProgressCallback(..)") + } +} + +impl PartialEq for LoadProgressCallback { + fn eq(&self, _other: &Self) -> bool { + true // Not data. + } +} + /// Options for processing the mesh during loading. /// /// Passed to [`load_obj()`], [`load_obj_buf()`] and [`load_obj_buf_async()`]. @@ -427,7 +486,7 @@ pub struct Mesh { /// * [`OFFLINE_RENDERING_LOAD_OPTIONS`] – if you're rendering meshes with e.g. /// an offline path tracer or the like. #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] pub struct LoadOptions { /// Merge identical positions. /// @@ -529,6 +588,16 @@ pub struct LoadOptions { /// Polygon meshes that contains faces with two vertices only usually do so /// because of bad topology. pub ignore_lines: bool, + /// Optional progress-report and cooperative-cancellation callback. + /// + /// If set, [`load_obj_buf()`] invokes it periodically (throttled; not on + /// every line) while parsing, passing it a [`LoadProgress`] snapshot. + /// Returning [`ControlFlow::Break`] from the callback aborts the load and + /// causes [`load_obj_buf()`] to return [`LoadError::Cancelled`]. + /// + /// Not invoked by [`load_obj_buf_async()`]. + #[cfg_attr(feature = "arbitrary", arbitrary(default))] + pub progress_callback: Option, } impl LoadOptions { @@ -649,6 +718,7 @@ pub enum LoadError { FaceColorOutOfBounds, InvalidLoadOptionConfig, GenericFailure, + Cancelled, } impl fmt::Display for LoadError { @@ -672,6 +742,7 @@ impl fmt::Display for LoadError { LoadError::FaceColorOutOfBounds => "face vertex color index out of bounds", LoadError::InvalidLoadOptionConfig => "mutually exclusive load options", LoadError::GenericFailure => "generic failure", + LoadError::Cancelled => "load cancelled by progress callback", }; f.write_str(msg) @@ -719,8 +790,8 @@ impl VertexIndices { ) -> Option { let mut indices = [MISSING_INDEX; 3]; for i in face_str.split('/').enumerate() { - // Catch case of v//vn where we'll find an empty string in one of our splits - // since there are no texcoords for the mesh. + // Catch case of v//vn where we'll find an empty string in one of + // our splits since there are no texcoords for the mesh. if !i.1.is_empty() { match isize::from_str(i.1) { Ok(x) => { @@ -899,8 +970,8 @@ fn export_faces( let mut is_all_triangles = true; for f in faces { - // Optimized paths for Triangles and Quads, Polygon handles the general case of - // an unknown length triangle fan. + // Optimized paths for Triangles and Quads, Polygon handles the general + // case of an unknown length triangle fan. match *f { Face::Point(ref a) => { if !load_options.ignore_points { @@ -1135,8 +1206,8 @@ fn export_faces_multi_index( let mut is_all_triangles = true; for f in faces { - // Optimized paths for Triangles and Quads, Polygon handles the general case of - // an unknown length triangle fan + // Optimized paths for Triangles and Quads, Polygon handles the general + // case of an unknown length triangle fan match *f { Face::Point(ref a) => { if !load_options.ignore_points { @@ -1785,7 +1856,8 @@ fn parse_obj_line( // for them? Some("o") | Some("g") => { // If we were already parsing an object then a new object name - // signals the end of the current one, so push it onto our list of objects + // signals the end of the current one, so push it onto our list of + // objects if !models.faces.is_empty() { models.pop_model(load_options)?; } @@ -1797,7 +1869,8 @@ fn parse_obj_line( Ok(ParseReturnType::None) } Some("mtllib") => { - // File name can include spaces so we cannot rely on a SplitWhitespace iterator + // File name can include spaces so we cannot rely on a + // SplitWhitespace iterator let mtllib = line.split_once(' ').unwrap_or_default().1.trim(); let mat_file = Path::new(mtllib).to_path_buf(); Ok(ParseReturnType::LoadMaterial(mat_file)) @@ -1807,8 +1880,9 @@ fn parse_obj_line( if !mat_name.is_empty() { let new_mat = materials.mat_map.get(&mat_name).cloned(); - // As materials are returned per-model, a new material within an object - // has to emit a new model with the same name but different material + // As materials are returned per-model, a new material within an + // object has to emit a new model with the same + // name but different material if models.mat_id != new_mat && !models.faces.is_empty() { models.pop_model(load_options)?; } @@ -2037,10 +2111,23 @@ where return Err(LoadError::InvalidLoadOptionConfig); } + // How often (in lines) to invoke `load_options.progress_callback`, if set. + // Kept coarse so the callback's cost stays negligible next to parsing. + const PROGRESS_REPORT_INTERVAL: u64 = 1000; + let mut models = TmpModels::new(); let mut materials = TmpMaterials::new(); + let mut lines_read: u64 = 0; + let mut bytes_read: u64 = 0; + for line in reader.lines() { + lines_read += 1; + // `BufRead::lines()` strips the line terminator, so this + // undercounts by one byte per line. Good enough for progress + // reporting. + bytes_read += line.as_ref().map(|l| l.len() as u64 + 1).unwrap_or(0); + let parse_return = parse_obj_line(line, load_options, &mut models, &materials)?; match parse_return { ParseReturnType::LoadMaterial(mat_file) => { @@ -2048,6 +2135,18 @@ where } ParseReturnType::None => {} } + + if let Some(callback) = &load_options.progress_callback { + if lines_read.is_multiple_of(PROGRESS_REPORT_INTERVAL) { + let progress = LoadProgress { + lines_read, + bytes_read, + }; + if let ControlFlow::Break(()) = callback.call(&progress) { + return Err(LoadError::Cancelled); + } + } + } } // For the last object in the file we won't encounter another object name to @@ -2055,6 +2154,20 @@ where // on the list as well models.pop_model(load_options)?; + // One last, unconditional call so a progress UI driven purely off this + // callback can reach 100% -- `lines_read` only lands on a multiple of + // `PROGRESS_REPORT_INTERVAL` by chance, so the loop above may never + // report the true final count. `ControlFlow::Break` here is not + // honored: the parse has already fully succeeded, so there is nothing + // left to cancel. + if let Some(callback) = &load_options.progress_callback { + let progress = LoadProgress { + lines_read, + bytes_read, + }; + let _ = callback.call(&progress); + } + Ok((models.into_models(), materials.into_materials())) } @@ -2283,9 +2396,9 @@ pub mod futures { } } - // For the last object in the file we won't encounter another object name to - // tell us when it's done, so if we're parsing an object push the last one - // on the list as well + // For the last object in the file we won't encounter another object + // name to tell us when it's done, so if we're parsing an object + // push the last one on the list as well models.pop_model(load_options)?; Ok((models.into_models(), materials.into_materials())) @@ -2351,8 +2464,8 @@ pub mod tokio { } }; load_obj_buf(BufReader::new(file), load_options, |mat_path| { - // This needs to be "copied" into this closure before moving it into the async - // one below + // This needs to be "copied" into this closure before moving it into + // the async one below let file_name: &Path = file_name.as_ref(); let file_name = file_name.to_path_buf(); async move { @@ -2427,9 +2540,9 @@ pub mod tokio { } } - // For the last object in the file we won't encounter another object name to - // tell us when it's done, so if we're parsing an object push the last one - // on the list as well + // For the last object in the file we won't encounter another object + // name to tell us when it's done, so if we're parsing an object + // push the last one on the list as well models.pop_model(load_options)?; Ok((models.into_models(), materials.into_materials())) diff --git a/src/tests.rs b/src/tests.rs index f4a86af..ed37ac0 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -3,9 +3,16 @@ use std::{ env, fs::File, io::{BufReader, Cursor}, + ops::ControlFlow, + path::Path, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, }; use crate as tobj; +use tobj::{load_mtl_buf, load_obj_buf, LoadError, LoadOptions, LoadProgressCallback}; const CORNELL_BOX_OBJ: &str = include_str!("../obj/cornell_box.obj"); const CORNELL_BOX_MTL1: &str = include_str!("../obj/cornell_box.mtl"); @@ -665,6 +672,140 @@ fn test_custom_material_loader_files() { validate_cornell(models, mats); } +#[test] +fn test_progress_callback_noop_matches_no_callback() { + let material_loader = |p: &Path| match p.to_str().unwrap() { + "cornell_box.mtl" => load_mtl_buf(&mut BufReader::new(CORNELL_BOX_MTL1.as_bytes())), + "cornell_box2.mtl" => load_mtl_buf(&mut BufReader::new(CORNELL_BOX_MTL2.as_bytes())), + _ => unreachable!(), + }; + + let without_callback = load_obj_buf( + &mut Cursor::new(CORNELL_BOX_OBJ.as_bytes()), + &LoadOptions { + triangulate: true, + single_index: true, + ..Default::default() + }, + material_loader, + ); + + let with_callback = load_obj_buf( + &mut Cursor::new(CORNELL_BOX_OBJ.as_bytes()), + &LoadOptions { + triangulate: true, + single_index: true, + progress_callback: Some(LoadProgressCallback::new(|_progress| { + ControlFlow::Continue(()) + })), + ..Default::default() + }, + material_loader, + ); + + // A no-op progress callback must not change the parse result in any way. + assert_eq!( + format!("{:?}", without_callback), + format!("{:?}", with_callback) + ); +} + +#[test] +fn test_progress_callback_cancels_load() { + // More lines than the progress-report throttle interval, so the + // callback is guaranteed to fire (and cancel the load) before EOF. + let obj = "v 0.0 0.0 0.0\n".repeat(2500); + + let result = load_obj_buf( + &mut Cursor::new(obj.as_bytes()), + &LoadOptions { + progress_callback: Some(LoadProgressCallback::new( + |_progress| ControlFlow::Break(()), + )), + ..Default::default() + }, + |_| unreachable!("no mtllib in the synthetic buffer"), + ); + + assert_eq!(result.unwrap_err(), LoadError::Cancelled); +} + +#[test] +fn test_progress_callback_is_throttled() { + let line_count = 10_000usize; + let obj = "v 0.0 0.0 0.0\n".repeat(line_count); + + let call_count = Arc::new(AtomicU64::new(0)); + let call_count_clone = call_count.clone(); + let result = load_obj_buf( + &mut Cursor::new(obj.as_bytes()), + &LoadOptions { + progress_callback: Some(LoadProgressCallback::new(move |_progress| { + call_count_clone.fetch_add(1, Ordering::SeqCst); + ControlFlow::Continue(()) + })), + ..Default::default() + }, + |_| unreachable!("no mtllib in the synthetic buffer"), + ); + + assert!(result.is_ok()); + // The callback must be throttled, i.e. called far less often than once + // per line. + let calls = call_count.load(Ordering::SeqCst); + assert!(calls > 0); + assert!((calls as usize) < line_count); +} + +#[test] +fn test_progress_callback_fires_once_more_on_completion_with_the_true_final_count() { + // Not a multiple of the 1000-line throttle interval, so the loop body + // never reports the true final count on its own. + let line_count = 2500u64; + let obj = "v 0.0 0.0 0.0\n".repeat(line_count as usize); + + let last_lines_read = Arc::new(AtomicU64::new(0)); + let last_lines_read_clone = last_lines_read.clone(); + let result = load_obj_buf( + &mut Cursor::new(obj.as_bytes()), + &LoadOptions { + progress_callback: Some(LoadProgressCallback::new(move |progress| { + last_lines_read_clone.store(progress.lines_read, Ordering::SeqCst); + ControlFlow::Continue(()) + })), + ..Default::default() + }, + |_| unreachable!("no mtllib in the synthetic buffer"), + ); + + assert!(result.is_ok()); + assert_eq!(last_lines_read.load(Ordering::SeqCst), line_count); +} + +#[test] +fn test_progress_callback_completion_call_cannot_cancel_an_already_finished_load() { + // Fewer lines than the throttle interval, so the ONLY invocation is the + // unconditional completion call. + let obj = "v 0.0 0.0 0.0\n".repeat(10); + + let result = load_obj_buf( + &mut Cursor::new(obj.as_bytes()), + &LoadOptions { + progress_callback: Some(LoadProgressCallback::new( + |_progress| ControlFlow::Break(()), + )), + ..Default::default() + }, + |_| unreachable!("no mtllib in the synthetic buffer"), + ); + + assert!( + result.is_ok(), + "the parse already fully succeeded by the time the completion call \ + fires, so Break there must not discard it" + ); +} + #[test] fn test_invalid_index() { let m = tobj::load_obj(