cranpose-ui-graphics 0.1.167

Pure math/data for drawing & units in Cranpose
Documentation
use super::*;

fn mask_at(mask: &[u8], width: usize, x: usize, y: usize) -> u8 {
    mask[y * width + x]
}

#[test]
fn parses_absolute_triangle() {
    let path = VectorPath::parse("M 0 0 L 10 0 L 10 10 Z").expect("valid path");
    assert_eq!(path.subpaths().len(), 1);
    assert_eq!(
        path.subpaths()[0],
        vec![
            Point::new(0.0, 0.0),
            Point::new(10.0, 0.0),
            Point::new(10.0, 10.0)
        ]
    );
    let bounds = path.bounds();
    assert_eq!((bounds.x, bounds.y), (0.0, 0.0));
    assert_eq!((bounds.width, bounds.height), (10.0, 10.0));
}

#[test]
fn parses_relative_commands_and_h_v() {
    let path = VectorPath::parse("m 5 5 l 10 0 v 10 h -10 z").expect("valid path");
    assert_eq!(
        path.subpaths()[0],
        vec![
            Point::new(5.0, 5.0),
            Point::new(15.0, 5.0),
            Point::new(15.0, 15.0),
            Point::new(5.0, 15.0)
        ]
    );
}

#[test]
fn parses_packed_numbers_and_negative_shorthand() {
    let path = VectorPath::parse("M10-5L.5.5Z").expect("valid path");
    assert_eq!(
        path.subpaths()[0],
        vec![Point::new(10.0, -5.0), Point::new(0.5, 0.5)]
    );
}

#[test]
fn implicit_lineto_after_moveto() {
    let path = VectorPath::parse("M 0 0 10 0 10 10").expect("valid path");
    assert_eq!(path.subpaths()[0].len(), 3);
    assert_eq!(path.subpaths()[0][2], Point::new(10.0, 10.0));
}

#[test]
fn cubic_flattening_hits_endpoints() {
    let path = VectorPath::parse("M 0 0 C 0 10 10 10 10 0").expect("valid path");
    let points = &path.subpaths()[0];
    assert_eq!(points[0], Point::new(0.0, 0.0));
    assert_eq!(*points.last().unwrap(), Point::new(10.0, 0.0));
    assert!(points.len() > 4, "curve must be subdivided");
    let mid = points
        .iter()
        .min_by(|a, b| (a.x - 5.0).abs().total_cmp(&(b.x - 5.0).abs()))
        .unwrap();
    assert!(
        (mid.y - 7.5).abs() < 0.2,
        "flattened curve must pass near the true midpoint, got {mid:?}"
    );
}

#[test]
fn smooth_cubic_reflects_control_point() {
    let path = VectorPath::parse("M 0 0 C 0 5 2 5 5 5 S 10 5 10 10").expect("valid path");
    let points = &path.subpaths()[0];
    assert_eq!(*points.last().unwrap(), Point::new(10.0, 10.0));
    assert!(
        points
            .iter()
            .any(|p| (p.x - 5.0).abs() < 0.1 && (p.y - 5.0).abs() < 0.1)
    );
}

#[test]
fn quadratic_and_smooth_quadratic() {
    let path = VectorPath::parse("M 0 0 Q 5 10 10 0 T 20 0").expect("valid path");
    let points = &path.subpaths()[0];
    assert_eq!(*points.last().unwrap(), Point::new(20.0, 0.0));
    assert!(
        points
            .iter()
            .any(|p| (p.x - 5.0).abs() < 0.3 && (p.y - 5.0).abs() < 0.3)
    );
    assert!(
        points
            .iter()
            .any(|p| (p.x - 15.0).abs() < 0.3 && (p.y + 5.0).abs() < 0.3)
    );
}

#[test]
fn arc_travels_through_expected_quadrant() {
    let path = VectorPath::parse("M 0 0 A 5 5 0 0 1 10 0").expect("valid path");
    let points = &path.subpaths()[0];
    assert_eq!(*points.last().unwrap(), Point::new(10.0, 0.0));
    let lowest = points.iter().fold(0.0f32, |acc, p| acc.min(p.y));
    assert!(
        (lowest + 5.0).abs() < 0.1,
        "sweep=1 arc must pass through (5,-5), lowest y = {lowest}"
    );

    let path = VectorPath::parse("M 0 0 A 5 5 0 0 0 10 0").expect("valid path");
    let highest = path.subpaths()[0]
        .iter()
        .fold(0.0f32, |acc, p| acc.max(p.y));
    assert!(
        (highest - 5.0).abs() < 0.1,
        "sweep=0 arc must pass through (5,5), highest y = {highest}"
    );
}

#[test]
fn arc_flags_may_be_packed() {
    let spaced = VectorPath::parse("M 0 0 A 5 5 0 0 1 10 0").expect("valid path");
    let packed = VectorPath::parse("M0 0A5 5 0 0110 0").expect("valid path");
    assert_eq!(
        spaced.subpaths()[0].len(),
        packed.subpaths()[0].len(),
        "packed arc flags must parse identically"
    );
}

#[test]
fn multiple_subpaths() {
    let path =
        VectorPath::parse("M 0 0 h 4 v 4 h -4 Z M 10 10 h 4 v 4 h -4 Z").expect("valid path");
    assert_eq!(path.subpaths().len(), 2);
}

#[test]
fn rejects_garbage() {
    assert!(VectorPath::parse("this is not a path").is_err());
    assert!(
        VectorPath::parse("L 10 10").is_err(),
        "must start with moveto"
    );
    assert!(VectorPath::parse("M 10").is_err(), "missing y coordinate");
    assert!(
        VectorPath::parse("M 0 0 A 5 5 0 2 1 10 0").is_err(),
        "bad flag"
    );
    assert_eq!(
        VectorPath::parse("").unwrap_err(),
        SvgPathError::MissingMoveTo
    );
}

#[test]
fn fills_axis_aligned_rectangle() {
    let path = VectorPath::parse("M 2 2 H 8 V 8 H 2 Z").expect("valid path");
    let mask = path.coverage_mask(10, 10, Point::ZERO, 1.0);

    assert_eq!(mask_at(&mask, 10, 5, 5), 255, "interior must be opaque");
    assert_eq!(mask_at(&mask, 10, 4, 2), 255, "top edge row is inside");
    assert_eq!(mask_at(&mask, 10, 0, 0), 0, "outside must stay empty");
    assert_eq!(mask_at(&mask, 10, 9, 9), 0, "outside must stay empty");
}

#[test]
fn triangle_edge_is_antialiased() {
    let path = VectorPath::parse("M 0 0 L 8 0 L 0 8 Z").expect("valid path");
    let mask = path.coverage_mask(8, 8, Point::ZERO, 1.0);

    assert_eq!(mask_at(&mask, 8, 1, 1), 255, "deep interior is opaque");
    assert_eq!(mask_at(&mask, 8, 7, 7), 0, "far corner is empty");
    let diagonal = mask_at(&mask, 8, 4, 3);
    assert!(
        diagonal > 30 && diagonal < 225,
        "diagonal pixel should be partially covered, got {diagonal}"
    );
}

#[test]
fn even_odd_ring_has_a_hole() {
    let d = "M 0 0 H 12 V 12 H 0 Z M 4 4 H 8 V 8 H 4 Z";
    let even_odd = VectorPath::parse_with_fill_rule(d, PathFillRule::EvenOdd).expect("valid path");
    let non_zero = VectorPath::parse(d).expect("valid path");

    let even_odd_mask = even_odd.coverage_mask(12, 12, Point::ZERO, 1.0);
    let non_zero_mask = non_zero.coverage_mask(12, 12, Point::ZERO, 1.0);

    assert_eq!(mask_at(&even_odd_mask, 12, 6, 6), 0, "even-odd hole");
    assert_eq!(mask_at(&even_odd_mask, 12, 2, 6), 255, "even-odd ring");
    assert_eq!(mask_at(&non_zero_mask, 12, 6, 6), 255, "non-zero solid");
}

#[test]
fn non_zero_ring_with_reversed_inner_winding_has_a_hole() {
    let d = "M 0 0 H 12 V 12 H 0 Z M 4 4 V 8 H 8 V 4 Z";
    let path = VectorPath::parse(d).expect("valid path");
    let mask = path.coverage_mask(12, 12, Point::ZERO, 1.0);
    assert_eq!(mask_at(&mask, 12, 6, 6), 0, "reversed winding hole");
    assert_eq!(mask_at(&mask, 12, 2, 6), 255, "ring stays filled");
}

#[test]
fn circle_from_arcs_fills_center_and_respects_radius() {
    let path = VectorPath::parse("M 0 8 A 8 8 0 1 1 16 8 A 8 8 0 1 1 0 8 Z").expect("valid path");
    let mask = path.coverage_mask(16, 16, Point::ZERO, 1.0);

    assert_eq!(mask_at(&mask, 16, 8, 8), 255, "circle center is opaque");
    assert_eq!(mask_at(&mask, 16, 0, 0), 0, "circle corner is empty");
    assert_eq!(mask_at(&mask, 16, 15, 0), 0, "circle corner is empty");
    let area: f32 = mask.iter().map(|&value| value as f32 / 255.0).sum();
    let expected = std::f32::consts::PI * 8.0 * 8.0;
    assert!(
        (area - expected).abs() / expected < 0.05,
        "filled area {area} should be close to {expected}"
    );
}

#[test]
fn scale_and_origin_map_path_units_to_pixels() {
    let path = VectorPath::parse("M 10 10 H 14 V 14 H 10 Z").expect("valid path");
    let mask = path.coverage_mask(8, 8, Point::new(10.0, 10.0), 2.0);
    assert_eq!(mask_at(&mask, 8, 4, 4), 255, "scaled interior");
    let full: usize = mask.iter().filter(|&&value| value == 255).count();
    assert_eq!(full, 64, "the 8x8 pixel mask must be fully covered");
}

#[test]
fn empty_and_degenerate_paths_produce_empty_masks() {
    let path = VectorPath::parse("M 5 5 L 6 6").expect("valid path");
    assert!(path.is_empty());
    let mask = path.coverage_mask(8, 8, Point::ZERO, 1.0);
    assert!(mask.iter().all(|&value| value == 0));
}