forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
#[derive(Debug, Clone)]
pub struct SpriteScore {
    pub size: u32,
    pub checks: Vec<Check>,
    pub total_score: f32,
    pub passed: bool,
}

#[derive(Debug, Clone)]
pub struct Check {
    pub name: &'static str,
    pub score: f32,
    pub passed: bool,
    pub detail: String,
}

impl std::fmt::Display for SpriteScore {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "=== SPRITE SCORE: {:.0}/100 {} ===", self.total_score, if self.passed { "PASS" } else { "FAIL" })?;
        writeln!(f, "Size: {}x{}", self.size, self.size)?;
        for c in &self.checks {
            writeln!(f, "  {} {}: {:.0}/100 — {}", if c.passed { "" } else { "" }, c.name, c.score, c.detail)?;
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ShapeView { FrontView, SideView }

/// Run all quality checks on RGBA pixel data.
pub fn verify_sprite(pixels: &[u8], size: u32) -> SpriteScore {
    verify_sprite_with_view(pixels, size, ShapeView::FrontView)
}

pub fn verify_sprite_with_view(pixels: &[u8], size: u32, view: ShapeView) -> SpriteScore {
    assert_eq!(pixels.len(), (size * size * 4) as usize);
    let mut checks = Vec::new();
    checks.push(check_fill_ratio(pixels, size));
    checks.push(check_padding(pixels, size));
    checks.push(check_color_count(pixels, size));
    checks.push(check_outline(pixels, size));
    if view == ShapeView::FrontView { checks.push(check_symmetry(pixels, size)); }
    checks.push(check_silhouette(pixels, size));
    checks.push(check_shading_direction(pixels, size));
    checks.push(check_no_orphan_pixels(pixels, size));
    let total: f32 = checks.iter().map(|c| c.score).sum::<f32>() / checks.len() as f32;
    let passed = total >= 65.0;
    SpriteScore { size, checks, total_score: total, passed }
}

fn is_opaque(pixels: &[u8], i: usize) -> bool { pixels[i * 4 + 3] > 0 }
fn pixel_rgb(pixels: &[u8], i: usize) -> [u8; 3] { [pixels[i * 4], pixels[i * 4 + 1], pixels[i * 4 + 2]] }
fn luminance(r: u8, g: u8, b: u8) -> f32 { 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32 }

fn check_fill_ratio(pixels: &[u8], size: u32) -> Check {
    let total = (size * size) as f32;
    let filled = (0..size * size).filter(|&i| is_opaque(pixels, i as usize)).count() as f32;
    let ratio = filled / total;
    let score = if ratio >= 0.35 && ratio <= 0.75 { 100.0 } else if ratio >= 0.20 && ratio <= 0.85 { 60.0 } else { 20.0 };
    Check { name: "fill_ratio", score, passed: score >= 60.0, detail: format!("{:.0}% filled (target 35-75%)", ratio * 100.0) }
}

fn check_padding(pixels: &[u8], size: u32) -> Check {
    let s = size as usize;
    let mut violations = 0;
    let border_total = s * 4 - 4;
    for x in 0..s { if is_opaque(pixels, x) { violations += 1; } }
    for x in 0..s { if is_opaque(pixels, (s - 1) * s + x) { violations += 1; } }
    for y in 0..s { if is_opaque(pixels, y * s) { violations += 1; } }
    for y in 0..s { if is_opaque(pixels, y * s + s - 1) { violations += 1; } }
    let ratio = violations as f32 / border_total as f32;
    let score = if ratio == 0.0 { 100.0 } else if ratio < 0.1 { 80.0 } else if ratio < 0.25 { 60.0 } else { 40.0 };
    Check { name: "padding", score, passed: score >= 40.0, detail: format!("{}/{} border pixels filled ({:.0}%)", violations, border_total, ratio * 100.0) }
}

fn check_color_count(pixels: &[u8], size: u32) -> Check {
    let mut colors = std::collections::HashSet::new();
    for i in 0..(size * size) as usize { if is_opaque(pixels, i) { colors.insert(pixel_rgb(pixels, i)); } }
    Check { name: "color_count", score: 100.0, passed: true, detail: format!("{} unique colors ({}x{})", colors.len(), size, size) }
}

fn check_outline(pixels: &[u8], size: u32) -> Check {
    let s = size as usize;
    let mut edge_pixels = 0; let mut distinct_edges = 0;
    let mut interior_lum_sum = 0.0f32; let mut interior_count = 0;
    for y in 1..s - 1 { for x in 1..s - 1 {
        let i = y * s + x;
        if !is_opaque(pixels, i) { continue; }
        let neighbors = [(y.wrapping_sub(1)) * s + x, (y + 1) * s + x, y * s + x.wrapping_sub(1), y * s + x + 1];
        let is_edge = neighbors.iter().any(|&ni| ni < s * s && !is_opaque(pixels, ni));
        if !is_edge { let [r, g, b] = pixel_rgb(pixels, i); interior_lum_sum += luminance(r, g, b); interior_count += 1; }
    }}
    let avg_interior = if interior_count > 0 { interior_lum_sum / interior_count as f32 } else { 128.0 };
    for y in 1..s - 1 { for x in 1..s - 1 {
        let i = y * s + x;
        if !is_opaque(pixels, i) { continue; }
        let neighbors = [(y.wrapping_sub(1)) * s + x, (y + 1) * s + x, y * s + x.wrapping_sub(1), y * s + x + 1];
        let is_edge = neighbors.iter().any(|&ni| ni < s * s && !is_opaque(pixels, ni));
        if is_edge {
            edge_pixels += 1;
            let [r, g, b] = pixel_rgb(pixels, i);
            let edge_lum = luminance(r, g, b);
            if edge_lum < avg_interior - 20.0 || edge_lum < 100.0 { distinct_edges += 1; }
        }
    }}
    let ratio = if edge_pixels > 0 { distinct_edges as f32 / edge_pixels as f32 } else { 0.0 };
    let score = (ratio * 100.0).min(100.0);
    Check { name: "outline", score, passed: score >= 40.0, detail: format!("{}/{} edge pixels distinct from interior", distinct_edges, edge_pixels) }
}

fn check_symmetry(pixels: &[u8], size: u32) -> Check {
    let s = size as usize; let half = s / 2;
    let mut matching = 0; let mut total = 0;
    for y in 0..s { for x in 0..half {
        let left = y * s + x; let right = y * s + (s - 1 - x);
        let l_opaque = is_opaque(pixels, left); let r_opaque = is_opaque(pixels, right);
        if l_opaque || r_opaque { total += 1; if l_opaque == r_opaque { matching += 1; } }
    }}
    let ratio = if total > 0 { matching as f32 / total as f32 } else { 0.0 };
    let score = 50.0 + ratio * 50.0;
    Check { name: "symmetry", score, passed: true, detail: format!("{:.0}% bilateral symmetry", ratio * 100.0) }
}

fn check_silhouette(pixels: &[u8], size: u32) -> Check {
    let s = size as usize;
    let mut visited = vec![false; s * s];
    let mut largest = 0usize;
    let total_opaque = (0..s * s).filter(|&i| is_opaque(pixels, i)).count();
    for start in 0..s * s {
        if !is_opaque(pixels, start) || visited[start] { continue; }
        let mut queue = vec![start]; let mut region_size = 0;
        visited[start] = true;
        while let Some(pos) = queue.pop() {
            region_size += 1;
            let x = pos % s; let y = pos / s;
            for (dx, dy) in &[(0isize, -1isize), (0, 1), (-1, 0), (1, 0)] {
                let nx = x as isize + dx; let ny = y as isize + dy;
                if nx >= 0 && nx < s as isize && ny >= 0 && ny < s as isize {
                    let ni = ny as usize * s + nx as usize;
                    if !visited[ni] && is_opaque(pixels, ni) { visited[ni] = true; queue.push(ni); }
                }
            }
        }
        if region_size > largest { largest = region_size; }
    }
    let connectivity = if total_opaque > 0 { largest as f32 / total_opaque as f32 } else { 0.0 };
    let score = connectivity * 100.0;
    Check { name: "silhouette", score, passed: score >= 70.0, detail: format!("{:.0}% connected (largest region {}/{})", connectivity * 100.0, largest, total_opaque) }
}

fn check_shading_direction(pixels: &[u8], size: u32) -> Check {
    let s = size as usize; let half = s / 2;
    let mut left_lum = 0.0f32; let mut right_lum = 0.0f32;
    let mut left_count = 0; let mut right_count = 0;
    for y in 0..s { for x in 0..s {
        let i = y * s + x;
        if !is_opaque(pixels, i) { continue; }
        let [r, g, b] = pixel_rgb(pixels, i);
        let l = luminance(r, g, b);
        if x < half { left_lum += l; left_count += 1; } else { right_lum += l; right_count += 1; }
    }}
    let avg_left = if left_count > 0 { left_lum / left_count as f32 } else { 0.0 };
    let avg_right = if right_count > 0 { right_lum / right_count as f32 } else { 0.0 };
    let correct = avg_left >= avg_right;
    let diff = (avg_left - avg_right).abs();
    let score = if correct && diff > 5.0 { 100.0 } else if correct || diff < 5.0 { 90.0 } else if diff < 15.0 { 75.0 } else { 60.0 };
    Check { name: "shading", score, passed: score >= 50.0, detail: format!("left avg lum {:.0}, right {:.0}", avg_left, avg_right) }
}

fn check_no_orphan_pixels(pixels: &[u8], size: u32) -> Check {
    let s = size as usize;
    let mut orphans = 0; let mut total_opaque = 0;
    for y in 1..s - 1 { for x in 1..s - 1 {
        let i = y * s + x;
        if !is_opaque(pixels, i) { continue; }
        total_opaque += 1;
        let neighbors = [(y - 1) * s + x, (y + 1) * s + x, y * s + x - 1, y * s + x + 1];
        let opaque_neighbors = neighbors.iter().filter(|&&ni| is_opaque(pixels, ni)).count();
        if opaque_neighbors == 0 { orphans += 1; }
    }}
    let ratio = if total_opaque > 0 { orphans as f32 / total_opaque as f32 } else { 0.0 };
    let score = ((1.0 - ratio) * 100.0).max(0.0);
    Check { name: "no_orphans", score, passed: score >= 80.0, detail: format!("{} orphan pixels out of {} ({:.1}%)", orphans, total_opaque, ratio * 100.0) }
}

/// Reference silhouette for comparison.
pub struct ReferenceSilhouette {
    pub mask: Vec<bool>,
    pub size: u32,
}

impl ReferenceSilhouette {
    /// Create from a source mask at arbitrary resolution, downscaled to target size.
    pub fn from_mask(source_mask: &[bool], source_w: usize, source_h: usize, target_size: u32) -> Self {
        let ts = target_size as usize;
        let mut mask = vec![false; ts * ts];
        let mut min_x = source_w; let mut max_x = 0usize; let mut min_y = source_h; let mut max_y = 0usize;
        for y in 0..source_h { for x in 0..source_w {
            if source_mask[y * source_w + x] { min_x = min_x.min(x); max_x = max_x.max(x); min_y = min_y.min(y); max_y = max_y.max(y); }
        }}
        if max_x <= min_x || max_y <= min_y { return Self { mask, size: target_size }; }
        let bbox_w = (max_x - min_x + 1) as f32;
        let bbox_h = (max_y - min_y + 1) as f32;
        let usable = (ts - 2) as f32;
        let scale = (usable / bbox_w).min(usable / bbox_h);
        let sprite_w = (bbox_w * scale) as usize;
        let sprite_h = (bbox_h * scale) as usize;
        let ox = (ts - sprite_w) / 2;
        let oy = ts - sprite_h - 1;
        for ty in 0..sprite_h { for tx in 0..sprite_w {
            let sx = min_x + (tx as f32 / scale) as usize;
            let sy = min_y + (ty as f32 / scale) as usize;
            if sx < source_w && sy < source_h && source_mask[sy * source_w + sx] {
                let px = ox + tx; let py = oy + ty;
                if px < ts && py < ts { mask[py * ts + px] = true; }
            }
        }}
        Self { mask, size: target_size }
    }
}

/// Compare generated sprite silhouette against reference.
pub fn check_reference_match(pixels: &[u8], size: u32, reference: &ReferenceSilhouette) -> Check {
    assert_eq!(size, reference.size);
    let n = (size * size) as usize;
    let mut intersection = 0usize; let mut union = 0usize;
    for i in 0..n {
        let sprite_on = pixels[i * 4 + 3] > 0;
        let ref_on = reference.mask[i];
        if sprite_on && ref_on { intersection += 1; }
        if sprite_on || ref_on { union += 1; }
    }
    let iou = if union > 0 { intersection as f32 / union as f32 } else { 0.0 };
    let ref_total = reference.mask.iter().filter(|&&m| m).count();
    let recall = if ref_total > 0 { intersection as f32 / ref_total as f32 } else { 0.0 };
    let score = (iou * 70.0 + recall * 30.0).min(100.0);
    let passed = iou >= 0.5;
    Check { name: "reference_match", score, passed, detail: format!("IoU {:.2}, recall {:.2} (intersection {}, union {}, ref pixels {})", iou, recall, intersection, union, ref_total) }
}

/// Full verification with reference image comparison.
pub fn verify_sprite_with_reference(pixels: &[u8], size: u32, view: ShapeView, reference: &ReferenceSilhouette) -> SpriteScore {
    assert_eq!(pixels.len(), (size * size * 4) as usize);
    let mut checks = Vec::new();
    checks.push(check_fill_ratio(pixels, size));
    checks.push(check_padding(pixels, size));
    checks.push(check_color_count(pixels, size));
    checks.push(check_outline(pixels, size));
    if view == ShapeView::FrontView { checks.push(check_symmetry(pixels, size)); }
    checks.push(check_silhouette(pixels, size));
    checks.push(check_shading_direction(pixels, size));
    checks.push(check_no_orphan_pixels(pixels, size));
    checks.push(check_reference_match(pixels, size, reference));
    let total: f32 = checks.iter().map(|c| c.score).sum::<f32>() / checks.len() as f32;
    let passed = total >= 65.0;
    SpriteScore { size, checks, total_score: total, passed }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_sprite_fails() {
        let pixels = vec![0u8; 32 * 32 * 4];
        let score = verify_sprite(&pixels, 32);
        assert!(!score.passed, "Empty sprite should fail");
        eprintln!("{}", score);
    }

    #[test]
    fn solid_square_scores() {
        let mut pixels = vec![0u8; 32 * 32 * 4];
        for y in 6..26 { for x in 6..26 {
            let i = (y * 32 + x) * 4;
            pixels[i] = 200; pixels[i + 1] = 50; pixels[i + 2] = 50; pixels[i + 3] = 255;
        }}
        let score = verify_sprite(&pixels, 32);
        eprintln!("{}", score);
        assert!(score.total_score > 40.0, "Solid square should score reasonably");
    }
}