From d2a1a372ea66d25f63b9af1653f0f8c26a262390 Mon Sep 17 00:00:00 2001 From: Jasyd Caballero Date: Sat, 7 Nov 2026 12:58:40 -0500 Subject: [PATCH 1/3] test(brushes): keep the Procreate preview comparison harness --- crates/core/src/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rs index 48aaff16..125b1b9b 100644 --- a/crates/core/src/tests.rs +++ b/crates/core/src/tests.rs @@ -887,6 +887,57 @@ fn dump_brush_previews() { std::fs::write(format!("{dir}/names.txt"), names).unwrap(); } +/// Writes each brush's own preview and the engine-drawn one, with their alpha metrics, into the +/// directory in `PROCRAFT_DUMP`: `BRUSH_SET=set.brushset PROCRAFT_DUMP=dir +/// cargo test -p procraft-core dump_brush_comparisons -- --ignored`. +#[test] +#[ignore] +fn dump_brush_comparisons() { + use procraft_engine::brushfile; + let Ok(path) = std::env::var("BRUSH_SET") else { return }; + let Ok(root) = std::env::var("PROCRAFT_DUMP") else { return }; + let Some(s) = session(100, 100) else { return }; + let set = s.import_brushes(std::fs::read(path).unwrap()).unwrap(); + let brushes: Vec<(u32, String)> = + s.brush_library()[set as usize].brushes.iter().map(|b| (b.index, b.name.clone())).collect(); + let mut rows = Vec::new(); + for (index, name) in brushes { + // a brush set's folders are not brushes + if name.starts_with('📂') { + continue; + } + let folder = format!("{root}/{}", name.replace(['/', '\\'], "-")); + std::fs::create_dir_all(&folder).unwrap(); + let file = brushfile::read(&s.brush_as_file(set, index).unwrap()).unwrap(); + let Some((w, h, own)) = file.thumbnail.as_deref().and_then(crate::brush_files::thumbnail_pixels) else { + continue; + }; + assert!(s.select_brush(set, index)); + s.studio_begin(); + // clear the imported thumbnail so the preview is drawn, without changing the brush + let spacing = s.studio_settings(vec!["spacing".to_owned()])[0]; + s.set_studio_setting("spacing".to_owned(), spacing); + let engine = s.brush_preview(set, index, w, h).unwrap(); + s.studio_end(false); + std::fs::write(format!("{folder}/procreate.png"), crate::project::rgba_png(w, h, &own)).unwrap(); + std::fs::write(format!("{folder}/engine.png"), crate::project::rgba_png(w, h, &engine.rgba)).unwrap(); + let (mut mae, mut sq) = (0u64, 0u64); + for (a, b) in own.chunks(4).zip(engine.rgba.chunks(4)) { + let d = i64::from(a[3]) - i64::from(b[3]); + mae += d.unsigned_abs(); + sq += (d * d) as u64; + } + let n = f64::from(w * h); + rows.push((name, mae as f64 / n / 255.0, (sq as f64 / n).sqrt() / 255.0)); + } + rows.sort_by(|a, b| b.2.total_cmp(&a.2)); + let mut summary = String::from("brush\tMAE\tRMSE\n"); + for (name, mae, rmse) in rows { + summary += &format!("{name}\t{mae:.4}\t{rmse:.4}\n"); + } + std::fs::write(format!("{root}/summary.txt"), summary).unwrap(); +} + #[test] fn smudge_tool_moves_paint_and_is_one_undo_step() { let Some(s) = session(100, 100) else { return }; From 512a28ad3d42cfb053d2c97d5ea085207d6cec35 Mon Sep 17 00:00:00 2001 From: Jasyd Caballero Date: Sat, 7 Nov 2026 12:58:40 -0500 Subject: [PATCH 2/3] fix(brushes): texturized grain tiles at half size --- crates/core/src/grain_store.rs | 2 +- crates/engine/src/brush.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/core/src/grain_store.rs b/crates/core/src/grain_store.rs index 3e1106c8..243d29b1 100644 --- a/crates/core/src/grain_store.rs +++ b/crates/core/src/grain_store.rs @@ -594,7 +594,7 @@ mod tests { let s = CanvasSession::new(64, 64); s.save_brush_library_file(dir.join("library.json").to_string_lossy().into_owned()); s.import_brushes(brush_file()).unwrap(); - let tile = 1500.0 * 0.75 / std::f32::consts::SQRT_2; + let tile = 1500.0 * 0.75 / (2.0 * std::f32::consts::SQRT_2); let brought = imported(&s); assert!((brought.grain_tile() - tile).abs() < 0.5, "{}", brought.grain_tile()); let full = brought.grain_file.as_ref().and_then(|f| f.full.clone()).unwrap(); diff --git a/crates/engine/src/brush.rs b/crates/engine/src/brush.rs index 32558ece..696c55fd 100644 --- a/crates/engine/src/brush.rs +++ b/crates/engine/src/brush.rs @@ -762,11 +762,11 @@ impl Brush { } /// The grain's tile on the canvas (pixels): fixed to the canvas, a brush file's image is as - /// wide as the file's own times its scale over root two; else what the grain says. + /// wide as the file's own times its scale over twice root two; else what the grain says. pub fn grain_tile(&self) -> f32 { match self.grain_file_now() { Some(f) if self.grain.mode == GrainMode::Texturized && f.width > 0 => { - f.width as f32 * self.grain.file_scale() / std::f32::consts::SQRT_2 + f.width as f32 * self.grain.file_scale() / (2.0 * std::f32::consts::SQRT_2) } _ => self.grain.scale, } @@ -776,7 +776,7 @@ impl Brush { /// scale to suit. Only a grain fixed to the canvas goes by its image's width. pub fn with_grain_image_width(mut self, width: u32) -> Brush { if self.grain.mode == GrainMode::Texturized && self.grain_file_now().is_some() { - let scale = self.grain_tile() * std::f32::consts::SQRT_2 / width.max(1) as f32; + let scale = self.grain_tile() * (2.0 * std::f32::consts::SQRT_2) / width.max(1) as f32; self.grain.scale = crate::studio::MAX_GRAIN_SCALE * (scale / 16.0).sqrt(); } self @@ -867,7 +867,7 @@ mod tests { // without a file's width: what the grain says assert_eq!(b.grain_tile(), scale); b.grain_file = Some(GrainFile { source: source.id, width: 2048, full: None }); - let tile = 2048.0 * 0.75 / std::f32::consts::SQRT_2; + let tile = 2048.0 * 0.75 / (2.0 * std::f32::consts::SQRT_2); assert!((b.grain_tile() - tile).abs() < 0.5, "{}", b.grain_tile()); // written with a smaller image it comes back the same size let written = b.clone().with_grain_image_width(1024); From efb357d259a60c7b4a681bad4908a6a3d27cbac5 Mon Sep 17 00:00:00 2001 From: Jasyd Caballero Date: Sat, 7 Nov 2026 14:10:47 -0500 Subject: [PATCH 3/3] Revert "fix(brushes): texturized grain tiles at half size" This reverts commit 512a28ad3d42cfb053d2c97d5ea085207d6cec35. --- crates/core/src/grain_store.rs | 2 +- crates/engine/src/brush.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/core/src/grain_store.rs b/crates/core/src/grain_store.rs index 243d29b1..3e1106c8 100644 --- a/crates/core/src/grain_store.rs +++ b/crates/core/src/grain_store.rs @@ -594,7 +594,7 @@ mod tests { let s = CanvasSession::new(64, 64); s.save_brush_library_file(dir.join("library.json").to_string_lossy().into_owned()); s.import_brushes(brush_file()).unwrap(); - let tile = 1500.0 * 0.75 / (2.0 * std::f32::consts::SQRT_2); + let tile = 1500.0 * 0.75 / std::f32::consts::SQRT_2; let brought = imported(&s); assert!((brought.grain_tile() - tile).abs() < 0.5, "{}", brought.grain_tile()); let full = brought.grain_file.as_ref().and_then(|f| f.full.clone()).unwrap(); diff --git a/crates/engine/src/brush.rs b/crates/engine/src/brush.rs index 696c55fd..32558ece 100644 --- a/crates/engine/src/brush.rs +++ b/crates/engine/src/brush.rs @@ -762,11 +762,11 @@ impl Brush { } /// The grain's tile on the canvas (pixels): fixed to the canvas, a brush file's image is as - /// wide as the file's own times its scale over twice root two; else what the grain says. + /// wide as the file's own times its scale over root two; else what the grain says. pub fn grain_tile(&self) -> f32 { match self.grain_file_now() { Some(f) if self.grain.mode == GrainMode::Texturized && f.width > 0 => { - f.width as f32 * self.grain.file_scale() / (2.0 * std::f32::consts::SQRT_2) + f.width as f32 * self.grain.file_scale() / std::f32::consts::SQRT_2 } _ => self.grain.scale, } @@ -776,7 +776,7 @@ impl Brush { /// scale to suit. Only a grain fixed to the canvas goes by its image's width. pub fn with_grain_image_width(mut self, width: u32) -> Brush { if self.grain.mode == GrainMode::Texturized && self.grain_file_now().is_some() { - let scale = self.grain_tile() * (2.0 * std::f32::consts::SQRT_2) / width.max(1) as f32; + let scale = self.grain_tile() * std::f32::consts::SQRT_2 / width.max(1) as f32; self.grain.scale = crate::studio::MAX_GRAIN_SCALE * (scale / 16.0).sqrt(); } self @@ -867,7 +867,7 @@ mod tests { // without a file's width: what the grain says assert_eq!(b.grain_tile(), scale); b.grain_file = Some(GrainFile { source: source.id, width: 2048, full: None }); - let tile = 2048.0 * 0.75 / (2.0 * std::f32::consts::SQRT_2); + let tile = 2048.0 * 0.75 / std::f32::consts::SQRT_2; assert!((b.grain_tile() - tile).abs() < 0.5, "{}", b.grain_tile()); // written with a smaller image it comes back the same size let written = b.clone().with_grain_image_width(1024);