use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GalleryPreset { ContentBrowser1240, ContentBrowser1600, ContentBrowser620, Materials620, Materials420, Renderer760, Renderer480, } impl GalleryPreset { pub const ALL: [Self; 7] = [ Self::ContentBrowser1240, Self::ContentBrowser620, Self::Materials620, Self::Materials420, Self::Renderer760, Self::Renderer480, Self::ContentBrowser1600, ]; pub const fn label(self) -> &'static str { match self { Self::ContentBrowser1240 => "Content Browser · 1240", Self::ContentBrowser1600 => "Content Browser · 1600 expanded", Self::ContentBrowser620 => "Content Browser · 620", Self::Materials620 => "Materials · 620", Self::Materials420 => "Materials · 420", Self::Renderer760 => "Renderer · 760", Self::Renderer480 => "Renderer · 480", } } pub const fn width(self) -> f32 { match self { Self::ContentBrowser1240 => 1240.0, Self::ContentBrowser1600 => 1600.0, Self::ContentBrowser620 => 620.0, Self::Materials620 => 620.0, Self::Materials420 => 420.0, Self::Renderer760 => 760.0, Self::Renderer480 => 480.0, } } pub const fn target_height(self) -> f32 { match self { Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 => 650.0, Self::Materials620 => 539.0, Self::Materials420 => 735.0, Self::Renderer760 => 1233.0, Self::Renderer480 => 828.0, } } pub const fn file_name(self) -> &'static str { match self { Self::ContentBrowser1240 => "content-browser-wide-1240.png", Self::ContentBrowser1600 => "content-browser-expanded-1600.png", Self::ContentBrowser620 => "content-browser-compact-620.png", Self::Materials620 => "materials-wide-620.png", Self::Materials420 => "materials-narrow-420.png", Self::Renderer760 => "static-mesh-renderer-wide-760.png", Self::Renderer480 => "static-mesh-renderer-narrow-480.png", } } pub const fn is_renderer(self) -> bool { matches!(self, Self::Renderer760 | Self::Renderer480) } pub const fn is_content_browser(self) -> bool { matches!( self, Self::ContentBrowser1240 | Self::ContentBrowser1600 | Self::ContentBrowser620 ) } /// Production-content canvas inside the exported Penpot reference. /// /// Material boards include the Inspector border and its 12 px horizontal / 10 px top inset; /// renderer boards are already complete component canvases. pub fn content_rect(self, canvas: egui::Rect) -> egui::Rect { if self.is_content_browser() { canvas } else if self.is_renderer() { egui::Rect::from_min_max( canvas.min + egui::vec2(0.0, 12.0), egui::pos2(canvas.right(), canvas.bottom()), ) } else { egui::Rect::from_min_max( canvas.min + egui::vec2(12.0, 10.0), egui::pos2(canvas.right() - 12.0, canvas.bottom()), ) } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComparisonMode { Live, SideBySide, Overlay, Difference, } impl ComparisonMode { pub const ALL: [Self; 4] = [ Self::Live, Self::SideBySide, Self::Overlay, Self::Difference, ]; pub const fn label(self) -> &'static str { match self { Self::Live => "Live", Self::SideBySide => "Side by side", Self::Overlay => "Overlay", Self::Difference => "Difference", } } } pub struct ReferenceImage { pub source_size: [usize; 2], pub texture: egui::TextureHandle, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ReferenceCrop { origin: [usize; 2], size: [usize; 2], } fn reference_crop(source_size: [usize; 2], preset: GalleryPreset) -> ReferenceCrop { let target_width = preset.width().round().max(1.0) as usize; let target_height = preset.target_height().round().max(1.0) as usize; // Penpot's material exports include the frame's one-pixel outer stroke in their viewBox, // producing 622/422 px PNGs for 620/420 px boards. That stroke is export overscan, not part of // the authored canvas. Cropping it preserves direct 1:1 pixels; scaling it into the canvas // blurs text and introduces a false global geometry delta. let overscan = source_size[0].saturating_sub(target_width); let inset = usize::from(overscan == 2); ReferenceCrop { origin: [inset, inset], size: [ target_width.min(source_size[0].saturating_sub(inset)), target_height.min(source_size[1].saturating_sub(inset)), ], } } impl ReferenceImage { pub fn load(ctx: &egui::Context, root: &Path, preset: GalleryPreset) -> Result { let path = reference_path(root, preset); let bytes = std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; let decoded = image::load_from_memory(&bytes) .map_err(|error| format!("{}: {error}", path.display()))? .to_rgba8(); let source_size = [decoded.width() as usize, decoded.height() as usize]; let pixels = egui::ColorImage::from_rgba_unmultiplied(source_size, decoded.as_raw()); let texture = ctx.load_texture( format!("penpot-reference-{}", preset.label()), pixels.clone(), egui::TextureOptions::NEAREST, ); Ok(Self { source_size, texture, }) } pub fn paint(&self, ui: &mut egui::Ui, preset: GalleryPreset, opacity: f32) -> egui::Response { let size = egui::vec2(preset.width(), preset.target_height()); let crop = reference_crop(self.source_size, preset); let uv_min = egui::pos2( crop.origin[0] as f32 / self.source_size[0] as f32, crop.origin[1] as f32 / self.source_size[1] as f32, ); let uv_max = egui::pos2( (crop.origin[0] + crop.size[0]) as f32 / self.source_size[0] as f32, (crop.origin[1] + crop.size[1]) as f32 / self.source_size[1] as f32, ); ui.add( egui::Image::new((self.texture.id(), size)) .uv(egui::Rect::from_min_max(uv_min, uv_max)) .tint(egui::Color32::from_white_alpha((opacity * 255.0) as u8)), ) } } pub fn difference_image( screenshot: &egui::ColorImage, live_rect: egui::Rect, reference_rect: egui::Rect, pixels_per_point: f32, preset: GalleryPreset, ) -> (egui::ColorImage, DifferenceStats) { let width = preset.width().round().max(1.0) as usize; let height = preset.target_height().round().max(1.0) as usize; let mut output = egui::ColorImage::new([width, height], vec![egui::Color32::BLACK; width * height]); let mut total = 0_u64; let mut max_delta = 0_u8; let mut changed = 0_u64; for y in 0..height { for x in 0..width { let live = sample_screenshot(screenshot, live_rect, pixels_per_point, x, y, width, height); let reference_color = sample_screenshot( screenshot, reference_rect, pixels_per_point, x, y, width, height, ); let delta = [ live.r().abs_diff(reference_color.r()), live.g().abs_diff(reference_color.g()), live.b().abs_diff(reference_color.b()), ]; let peak = *delta.iter().max().unwrap_or(&0); max_delta = max_delta.max(peak); total += delta.into_iter().map(u64::from).sum::(); changed += u64::from(peak > 8); let amplified = |value: u8| value.saturating_mul(4); output[(x, y)] = egui::Color32::from_rgb( amplified(delta[0]), amplified(delta[1]), amplified(delta[2]), ); } } let samples = (width * height * 3).max(1) as f32; let pixels = (width * height).max(1) as f32; ( output, DifferenceStats { mean_channel_delta: total as f32 / samples, max_channel_delta: max_delta, changed_percent: changed as f32 * 100.0 / pixels, }, ) } #[derive(Debug, Clone, Copy, Default)] pub struct DifferenceStats { pub mean_channel_delta: f32, pub max_channel_delta: u8, pub changed_percent: f32, } fn reference_path(root: &Path, preset: GalleryPreset) -> PathBuf { root.join("docs/editor/evaluations/content-workspace-m2/penpot") .join(preset.file_name()) } fn sample_screenshot( image: &egui::ColorImage, rect: egui::Rect, pixels_per_point: f32, x: usize, y: usize, width: usize, height: usize, ) -> egui::Color32 { let px = ((rect.left() + (x as f32 + 0.5) * rect.width() / width as f32) * pixels_per_point) .floor() as isize; let py = ((rect.top() + (y as f32 + 0.5) * rect.height() / height as f32) * pixels_per_point) .floor() as isize; if px < 0 || py < 0 || px as usize >= image.size[0] || py as usize >= image.size[1] { return egui::Color32::BLACK; } image[(px as usize, py as usize)] } #[cfg(test)] mod tests { use super::*; #[test] fn material_presets_supply_exported_slot_widths() { for (preset, expected_width) in [ (GalleryPreset::Materials620, 596.0), (GalleryPreset::Materials420, 396.0), ] { let canvas = egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(preset.width(), preset.target_height()), ); let content = preset.content_rect(canvas); assert_eq!(content.left(), 12.0); assert_eq!(content.top(), 10.0); assert_eq!(content.width(), expected_width); } } #[test] fn renderer_presets_preserve_the_exported_top_inset() { for preset in [GalleryPreset::Renderer760, GalleryPreset::Renderer480] { let canvas = egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(preset.width(), preset.target_height()), ); let content = preset.content_rect(canvas); assert_eq!(content.left(), 0.0); assert_eq!(content.top(), 12.0); assert_eq!(content.width(), preset.width()); } } #[test] fn material_export_overscan_is_cropped_instead_of_scaled() { assert_eq!( reference_crop([622, 661], GalleryPreset::Materials620), ReferenceCrop { origin: [1, 1], size: [620, 539], } ); assert_eq!( reference_crop([422, 857], GalleryPreset::Materials420), ReferenceCrop { origin: [1, 1], size: [420, 735], } ); assert_eq!( reference_crop([760, 1233], GalleryPreset::Renderer760), ReferenceCrop { origin: [0, 0], size: [760, 1233], } ); } }