diff --git a/Cargo.lock b/Cargo.lock index 7c8dfab..0603411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000444226fcff248f2bc4c7625be32c63caccfecc2723a2b9f78a7487a49c407" +[[package]] +name = "anyhow" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d9ff5d688f1c13395289f67db01d4826b46dd694e7580accdc3e8430f2d98e" + [[package]] name = "atomic_refcell" version = "0.1.8" @@ -347,6 +353,7 @@ dependencies = [ name = "crab-tv" version = "0.1.0" dependencies = [ + "anyhow", "criterion", "derive_more", "eframe", diff --git a/Cargo.toml b/Cargo.toml index e89eaac..c150e47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ egui = "0.14.2" serde = { version = "1.0", features = ["derive"] } strum = { version = "0.22", features = ["derive"] } glam = { version = "0.20.0", features = ["serde"] } +anyhow = "1.0" [dev-dependencies] criterion = "0.3" diff --git a/assets/african_head.diffuse.png b/assets/african_head.diffuse.png new file mode 100644 index 0000000..fa32bdb Binary files /dev/null and b/assets/african_head.diffuse.png differ diff --git a/models/african_head.obj b/assets/african_head.obj similarity index 100% rename from models/african_head.obj rename to assets/african_head.obj diff --git a/src/lib.rs b/src/lib.rs index 44b8d82..8df9cef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,4 +9,4 @@ mod model; pub use colors::*; pub use canvas::Canvas; -pub use model::{Face, Model, Vertex}; +pub use model::{Face, Model, ModelInput, Vertex}; diff --git a/src/main.rs b/src/main.rs index 91dea8c..2b40518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,11 @@ mod scenes; mod ui; +use std::path::{Path, PathBuf}; + use crate::scenes::{render_scene, RenderScene}; -use crab_tv::Canvas; +use anyhow::{bail, Context, Result}; +use crab_tv::{Canvas, Model, ModelInput}; use glam::Vec3; use rgb::RGB8; @@ -15,7 +18,7 @@ pub struct RenderConfig { scene: RenderScene, width: usize, height: usize, - model_filename: String, + model: PathBuf, light_dir: Vec3, output_filename: String, display_actual_size: bool, @@ -27,19 +30,28 @@ impl RenderConfig { self.width * self.height } - pub(crate) fn validate(&self) -> Result<(), String> { + pub(crate) fn validate(&self) -> Result { if self.width < 200 { - return Err("Width must be 200 or greater".to_owned()); + bail!("Width must be 200 or greater"); } else if self.width > 5000 { - return Err("Width must be 5000 or less".to_owned()); + bail!("Width must be 5000 or less"); } if self.height < 200 { - return Err("Height must be 200 or greater".to_owned()); + bail!("Height must be 200 or greater"); } else if self.height > 5000 { - return Err("Height must be 5000 or less".to_owned()); + bail!("Height must be 5000 or less"); } - Ok(()) + let model_input = Model::validate(&self.model) + .with_context(|| format!("Failed to load model from {}", self.model.display()))?; + + Ok(RenderInput { + scene: self.scene, + width: self.width, + height: self.height, + model_input, + light_dir: self.light_dir, + }) } } impl RenderConfig { @@ -56,8 +68,8 @@ impl RenderConfig { self } - pub(crate) fn model_filename(&mut self, model_filename: String) -> &mut Self { - self.model_filename = model_filename; + pub(crate) fn model(&mut self, model: &Path) -> &mut Self { + self.model = model.to_owned(); self } pub(crate) fn output_filename(&mut self, output_filename: String) -> &mut Self { @@ -79,7 +91,7 @@ impl Default for RenderConfig { scene: RenderScene::iter().last().unwrap(), width: 400, height: 400, - model_filename: "models/african_head.obj".to_owned(), + model: PathBuf::from("assets/african_head"), light_dir: Vec3::new(0.0, 0.0, -1.0), output_filename: "target/output.png".to_owned(), display_actual_size: true, @@ -88,8 +100,17 @@ impl Default for RenderConfig { } } +#[derive(Clone, Debug)] +pub struct RenderInput { + scene: RenderScene, + width: usize, + height: usize, + model_input: ModelInput, + light_dir: Vec3, +} + enum RenderCommand { - Render { config: RenderConfig }, + Render { input: RenderInput }, } enum RenderResult { @@ -125,22 +146,20 @@ fn run_render_loop( match render_command_rx.recv() { Err(flume::RecvError::Disconnected) => break, // nothing to do, just quit quietly - Ok(RenderCommand::Render { config }) => { + Ok(RenderCommand::Render { input }) => { render_result_tx .send(RenderResult::Reset { - image_height: config.height, - image_width: config.width, + image_height: input.height, + image_width: input.width, }) .ok() .expect("sending Reset should succeed"); - let mut image = Canvas::new(config.width, config.height); - render_scene( - &mut image, - &config.scene, - &config.model_filename, - config.light_dir, - ); + let mut image = Canvas::new(input.width, input.height); + + let model = Model::load_obj_file(&input.model_input).expect("Failed to load model"); + + render_scene(&mut image, &input.scene, &model, input.light_dir).unwrap(); render_result_tx .send(RenderResult::FullImage { diff --git a/src/model.rs b/src/model.rs index 806a52a..98f2058 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,5 +1,9 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; use derive_more::Constructor; use glam::Vec3; +use rgb::RGB8; #[derive(Clone, Copy, Debug, PartialEq, Constructor)] pub struct Vertex { @@ -12,19 +16,87 @@ pub struct Face { pub texture_coords: Vec, } +type TextureInput = PathBuf; + +#[derive(Clone, Debug, Constructor)] +pub struct Texture { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +impl Texture { + fn validate(path: &Path) -> Result { + if !path.exists() { + bail!("Texture file does not exist: {}", path.display()); + } + Ok(path.to_owned()) + } + + fn load_from_file(path: &TextureInput) -> Result { + println!("Loading texture from file: {}", path.display()); + let diffuse_bitmap = lodepng::decode24_file(path) + .with_context(|| format!("Loading texture from '{}' failed", path.display()))?; + Ok(Texture::new( + diffuse_bitmap.width, + diffuse_bitmap.height, + diffuse_bitmap.buffer, + )) + } + + pub fn get_pixel(&self, x: usize, y: usize) -> RGB8 { + debug_assert!(x < self.width); + debug_assert!(y < self.height); + self.data[y * self.width + x] + } +} + +#[derive(Clone, Debug)] +pub struct ModelInput { + model: PathBuf, + diffuse_texture: PathBuf, +} + +impl ModelInput {} + #[derive(Clone, Debug)] pub struct Model { pub vertices: Vec, pub faces: Vec, pub texture_coords: Vec, + pub diffuse_texture: Texture, } impl Model { - pub fn load_from_file>(path: S) -> std::io::Result { + pub fn validate(model: &Path) -> Result { + let model_ext = model + .extension() + .ok_or(anyhow!("Model file '{:?}' must have an extension", model))?; + if model_ext != "obj" { + bail!( + "Model file '{:?}' must be an Obj file that ends in .obj", + model + ); + } + + let diffuse_texture = Texture::validate(model.with_extension("diffuse.png").as_ref()) + .context("Loading diffuse texture failed")?; + + Ok(ModelInput { + model: model.to_owned(), + diffuse_texture, + }) + } + + pub fn load_obj_file(input: &ModelInput) -> Result { use std::io::prelude::*; + println!("Loading model from file: {}", input.model.display()); let mut contents = String::new(); - std::fs::File::open(path.as_ref())?.read_to_string(&mut contents)?; + std::fs::File::open(&input.model) + .with_context(|| "attempting to open model file")? + .read_to_string(&mut contents) + .with_context(|| "attempting to read model file")?; let mut vertices = Vec::new(); let mut faces = Vec::new(); @@ -102,10 +174,14 @@ impl Model { } } + let diffuse_texture = Texture::load_from_file(&input.diffuse_texture) + .context("Loading diffuse texture failed")?; + Ok(Self { vertices, faces, texture_coords, + diffuse_texture, }) } } diff --git a/src/scenes.rs b/src/scenes.rs index fc0f04d..ece27e6 100644 --- a/src/scenes.rs +++ b/src/scenes.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use glam::{IVec2, Vec3}; use crab_tv::{Canvas, Model, BLUE, CYAN, GREEN, RED, WHITE}; @@ -29,9 +30,10 @@ pub enum RenderScene { pub fn render_scene( image: &mut Canvas, scene: &RenderScene, - model_filename: &str, + model: &Model, light_dir: Vec3, -) { +) -> Result<()> { + println!("Rendering scene: {}", scene); match scene { RenderScene::FivePixels => { // pixel in the middle @@ -49,9 +51,6 @@ pub fn render_scene( image.line(IVec2::new(0, 0), IVec2::new(50, 50), GREEN); } RenderScene::ModelWireframe => { - println!("Loading model: {}", model_filename); - let model = Model::load_from_file(model_filename).expect("model filename should exist"); - image.model_wireframe(&model, WHITE); } RenderScene::TriangleLineSweepVerbose => { @@ -91,45 +90,42 @@ pub fn render_scene( image.triangle_barycentric(&t2, GREEN); } RenderScene::ModelColoredTriangles => { - println!("Loading model: {}", model_filename); - let model = Model::load_from_file(model_filename).expect("model filename should exist"); - image.model_colored_triangles(&model); } RenderScene::ModelFlatShaded => { - println!("Loading model: {}", model_filename); - let model = Model::load_from_file(model_filename).expect("model filename should exist"); - image.model_flat_shaded(&model, light_dir, false); } RenderScene::ModelFlatShadedDepthTested => { - println!("Loading model: {}", model_filename); - let model = Model::load_from_file(model_filename).expect("model filename should exist"); - image.model_flat_shaded(&model, light_dir, true); } } image.flip_y(); + + Ok(()) } #[cfg(test)] mod tests { + use std::path::Path; + use strum::IntoEnumIterator; use super::*; #[test] - fn every_scene_should_render_without_errors() { + fn every_scene_should_render_without_errors() -> Result<()> { for scene in RenderScene::iter() { let mut image = Canvas::new(200, 200); println!("Rendering scene: {:?}", scene); render_scene( &mut image, &scene, - "models/african_head.obj", + &Model::load_obj_file(&Model::validate(Path::new("assets/african_head").as_ref())?) + .expect("model load should succeed"), Vec3::new(0.0, 0.0, -1.0), - ); + )?; } + Ok(()) } } diff --git a/src/ui.rs b/src/ui.rs index ec5f912..c5146fb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use eframe::{ egui::{self, TextureId}, epi, @@ -6,7 +8,7 @@ use glam::Vec3; use rgb::RGB8; use strum::IntoEnumIterator; -use crate::{RenderCommand, RenderConfig, RenderResult, RenderScene}; +use crate::{RenderCommand, RenderConfig, RenderInput, RenderResult, RenderScene}; #[derive(Debug, Default)] struct UiData { @@ -106,7 +108,7 @@ impl TemplateApp { } } - fn trigger_render(&self) { + fn trigger_render(&self, input: RenderInput) { println!( "Triggering render of {width}x{height} image (total {count} pixels)", width = self.config.width, @@ -115,9 +117,7 @@ impl TemplateApp { ); self.render_command_tx - .send(RenderCommand::Render { - config: self.config.clone(), - }) + .send(RenderCommand::Render { input }) .ok() .expect("render command send should succeed"); } @@ -140,8 +140,8 @@ impl epi::App for TemplateApp { self.config = epi::get_value(storage, epi::APP_KEY).unwrap_or_default() } - if self.config.validate().is_ok() { - self.trigger_render(); + if let Ok(input) = self.config.validate() { + self.trigger_render(input); } } @@ -232,7 +232,7 @@ impl epi::App for TemplateApp { ui.collapsing("Rendering options", |ui| { ui.horizontal(|ui| { ui.label("Image filename"); - ui.text_edit_singleline(&mut self.config.model_filename); + path_edit_singleline(ui, &mut self.config.model); }); ui.end_row(); @@ -263,20 +263,27 @@ impl epi::App for TemplateApp { ui.checkbox(&mut self.config.auto_rerender, "Re-render on config change"); ui.end_row(); - if let Some(err_msg) = self.config.validate().err() { - ui.colored_label(egui::Color32::RED, format!("Error: {}", err_msg)); - } else { - if self.config.auto_rerender { - if config_before != self.config { - self.trigger_render(); - } - } else { - ui.vertical_centered_justified(|ui| { - let button = egui::widgets::Button::new("Re-render image!"); - if ui.add(button).clicked() { - self.trigger_render(); + match self.config.validate() { + Ok(input) => { + if self.config.auto_rerender { + if config_before != self.config { + println!("Configuration change detected - auto-rerendering!"); + self.trigger_render(input); } - }); + } else { + ui.vertical_centered_justified(|ui| { + let button = egui::widgets::Button::new("Re-render image!"); + if ui.add(button).clicked() { + self.trigger_render(input); + } + }); + } + } + Err(err) => { + ui.colored_label( + egui::Color32::RED, + format!("Error detected:\n{:?}", err), + ); } } ui.end_row(); @@ -306,6 +313,12 @@ impl epi::App for TemplateApp { } } +fn path_edit_singleline(ui: &mut egui::Ui, path_buf: &mut PathBuf) { + let mut temp = path_buf.to_string_lossy().to_string(); + ui.text_edit_singleline(&mut temp); + *path_buf = PathBuf::from(temp); +} + fn vec3_editor(ui: &mut egui::Ui, label: &str, v: &mut Vec3) { let speed = 0.01;