Blacksite/crates/ui_gallery/src/reference.rs

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],
}
);
}
}