344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
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<Self, String> {
|
|
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::<u64>();
|
|
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],
|
|
}
|
|
);
|
|
}
|
|
}
|