cranpose-render-common 0.22.2

Scene, renderer and hit-test contracts for Cranpose backends
Documentation
use cranpose_ui_graphics::Color;

use super::*;

fn assert_close(actual: f32, expected: f32, what: &str) {
    assert!(
        (actual - expected).abs() < 1e-4,
        "{what}: {actual} is not {expected}"
    );
}

fn assert_rect(actual: Rect, expected: [f32; 4], what: &str) {
    assert_close(actual.x, expected[0], &format!("{what} x"));
    assert_close(actual.y, expected[1], &format!("{what} y"));
    assert_close(actual.width, expected[2], &format!("{what} width"));
    assert_close(actual.height, expected[3], &format!("{what} height"));
}

/// A light over a 200-unit-wide window, 500 units up, in pixel units.
const LIGHT: ShadowLight = ShadowLight {
    x: 100.0,
    y: 0.0,
    z: 500.0,
    radius: 800.0,
    pixels_per_unit: 1.0,
};

#[test]
fn a_layer_without_elevation_casts_no_shadow() {
    let geometry = layer_shadow_geometry(
        &GraphicsLayer::default(),
        Rect::from_size(Default::default()),
        None,
        LIGHT,
    );
    assert!(geometry.ambient.is_none());
    assert!(geometry.spot.is_none());
}

#[test]
fn the_light_stands_where_android_puts_it() {
    // A 360 × 748 dp phone at density 3, in pixels: the light is over the
    // middle of the screen, 500 dp up lowered for the 360 dp width.
    let light = ShadowLight::for_window(1080.0, 2244.0, 3.0, 1.0);
    assert_close(light.x, 540.0, "x");
    assert_close(light.y, 0.0, "y");
    assert_close(light.z, 1400.0, "z: 500 dp × (360 / 450 + 2) / 3 × 3");
    assert_close(light.radius, 2400.0, "radius");
}

fn elevated(elevation: f32, alpha: f32) -> GraphicsLayer {
    GraphicsLayer {
        shadow_elevation: elevation,
        alpha,
        ambient_shadow_color: Color(0.2, 0.3, 0.4, 0.8),
        spot_shadow_color: Color(0.7, 0.6, 0.5, 0.8),
        ..GraphicsLayer::default()
    }
}

const CASTER: Rect = Rect {
    x: 20.0,
    y: 30.0,
    width: 40.0,
    height: 12.0,
};

#[test]
fn a_shadow_takes_skias_ambient_and_spot_geometry() {
    let geometry = layer_shadow_geometry(&elevated(10.0, 1.0), CASTER, None, LIGHT);

    // Ambient: reaches 5 past the caster, its corners rounded by the reach,
    // over a ramp 5 × (1 + 10/128) wide that ends at the umbra; the opaque
    // caster covers the middle from the umbra in. Android keeps 9 of the
    // alpha's 255 and truncates: 204 × 9 / 255.
    let (ambient, ambient_color) = geometry.ambient.expect("ambient pass");
    assert_rect(ambient.bounds, [15.0, 25.0, 50.0, 22.0], "ambient");
    assert_eq!(ambient.radii, [5.0; 4], "ambient corners");
    assert_close(ambient.umbra_inset, 5.390625, "ambient umbra");
    assert_close(ambient.distance_correction, 1.0, "ambient ramp end");
    let hole = ambient.hole.expect("an opaque caster's hole");
    assert_close(hole.inset, 5.390625, "ambient hole");
    assert_close(ambient_color.a(), 7.0 / 255.0, "ambient alpha");

    // Spot: 10 / 490 of the way further from the light, scaled by 1 + 10/490,
    // grown by a blur of 800 × 10/490 over a ramp twice that. Its inset
    // reaches past half its height, so Skia fills it.
    let (spot, spot_color) = geometry.spot.expect("spot pass");
    assert_rect(
        spot.bounds,
        [2.040816, 14.285714, 73.46939, 44.897957],
        "spot",
    );
    assert_close(spot.radii[0], 16.32653, "spot corners");
    assert_close(spot.umbra_inset, 22.448978, "spot umbra: half the height");
    assert_close(spot.distance_correction, 0.6875, "spot ramp end");
    assert!(spot.hole.is_none(), "a spot whose inset fills it");
    assert_close(spot_color.a(), 38.0 / 255.0, "spot alpha: 204 × 48 / 255");
}

#[test]
fn a_translucent_caster_casts_a_fainter_filled_shadow() {
    let geometry = layer_shadow_geometry(&elevated(10.0, 0.5), CASTER, None, LIGHT);
    let (ambient, color) = geometry.ambient.expect("ambient");
    assert_close(color.a(), 3.0 / 255.0, "ambient: 204 × 9 / 255 × 0.5");
    assert!(ambient.hole.is_none(), "nothing covers its middle");
    let (_, color) = geometry.spot.expect("spot");
    assert_close(color.a(), 19.0 / 255.0, "spot: 204 × 48 / 255 × 0.5");
}

#[test]
fn a_shadow_drops_away_from_the_light() {
    let layer = elevated(10.0, 1.0);
    let square = |x: f32, y: f32| Rect {
        x,
        y,
        width: 10.0,
        height: 10.0,
    };
    let spot_of = |bounds: Rect| {
        let (spot, _) = layer_shadow_geometry(&layer, bounds, None, LIGHT)
            .spot
            .expect("spot");
        (spot.bounds.x - bounds.x, spot.bounds.y - bounds.y)
    };
    let (left, near) = spot_of(square(0.0, 10.0));
    let (right, far) = spot_of(square(190.0, 400.0));
    assert!(
        left < right,
        "a caster left of the light throws its shadow further left"
    );
    assert!(
        near < far,
        "a caster further below the light throws its shadow further down"
    );
}

#[test]
fn a_round_caster_casts_a_round_shadow() {
    let disc = Rect {
        x: 0.0,
        y: 0.0,
        width: 20.0,
        height: 20.0,
    };
    let caster = RoundedCornerShape::uniform(10.0);
    let geometry = layer_shadow_geometry(&elevated(4.0, 1.0), disc, Some(caster), LIGHT);
    let (ambient, _) = geometry.ambient.expect("ambient");
    assert_close(ambient.umbra_inset, 12.0, "the ramp runs to the centre");
    let hole = ambient.hole.expect("the caster covers a disc");
    assert_close(hole.radius, 10.0, "the caster's own disc");
    // Radially, a point 3 in from the outer edge takes the ramp 3 / blur in.
    let blur = 2.0 * (1.0 + 4.0 / 128.0);
    let at = 12.0 - 11.0 * std::f32::consts::FRAC_1_SQRT_2;
    assert_close(
        ambient.coverage(at - 2.0, at - 2.0),
        shadow_falloff(1.0 / blur),
        "one in from the edge on the diagonal",
    );
}

/// A shadow over (0, 0, 100, 60) with corners of `radius`, a ramp `blur`
/// wide and an inset of `inset`.
fn shadow(radius: f32, blur: f32, inset: f32) -> ShadowRRect {
    ShadowRRect::new(
        Rect {
            x: 0.0,
            y: 0.0,
            width: 100.0,
            height: 60.0,
        },
        [radius; 4],
        blur,
        inset,
    )
    .expect("a shadow")
}

#[test]
fn a_straight_edge_ramps_over_the_blur() {
    let shadow = shadow(5.0, 8.0, 100.0);
    for depth in [0.5, 2.0, 4.0, 7.5] {
        assert_close(
            shadow.coverage(50.0, depth),
            shadow_falloff(depth / 8.0),
            &format!("{depth} in from the top"),
        );
    }
    assert_close(
        shadow.coverage(50.0, 30.0),
        shadow_falloff(1.0),
        "the middle",
    );
}

#[test]
fn a_shadow_draws_nothing_outside_its_bounds_or_in_its_hole() {
    let shadow = shadow(5.0, 8.0, 6.0);
    let hole = shadow.hole.expect("a hole");
    assert_close(hole.inset, 8.0, "the hole starts at the umbra");
    assert_eq!(shadow.coverage(50.0, 30.0), 0.0, "in the hole");
    assert!(shadow.coverage(50.0, 7.5) > 0.0, "just above the hole");
    assert_eq!(shadow.coverage(-0.5, 30.0), 0.0, "left of the bounds");
    assert_eq!(shadow.coverage(50.0, 60.5), 0.0, "below the bounds");
}

#[test]
fn a_corner_as_deep_as_its_radius_is_a_quarter_circle() {
    let shadow = shadow(10.0, 4.0, 100.0);
    for (radius, angle) in [(2.0_f32, 0.3_f32), (6.0, 0.8), (9.5, 1.2)] {
        let x = 10.0 - radius * angle.cos();
        let y = 10.0 - radius * angle.sin();
        assert_close(
            shadow.coverage(x, y),
            shadow_falloff(2.5 * (1.0 - radius / 10.0)),
            &format!("{radius} from the corner's centre"),
        );
    }
}

#[test]
fn a_corner_deeper_than_its_radius_starts_its_ramp_at_skias_mesh_edge() {
    let (umbra, radius) = (12.0, 3.0);
    assert_close(fan_offset(umbra, 0.0, umbra, radius), 1.0, "on the edge");
    assert_close(
        fan_offset(umbra, umbra - radius, umbra, radius),
        1.0,
        "where the curve starts",
    );
    let at_corner = fan_offset(umbra, umbra, umbra, radius);
    assert!(
        at_corner > 1.0,
        "the outer corner lies beyond the curve: {at_corner}"
    );
    for (along, across) in [(7.0, 2.0), (5.0, 5.0)] {
        assert_close(
            fan_offset(along, across, umbra, radius),
            fan_offset(across, along, umbra, radius),
            "both edges of the corner",
        );
    }
}

#[test]
fn the_falloff_reads_skias_table() {
    assert!(
        shadow_falloff(0.0) < 0.5 / 255.0,
        "the outer edge, which Skia's byte table holds as 0"
    );
    assert_close(shadow_falloff(1.0), 0.982, "the umbra: 1 - 0.018");
    assert_close(
        shadow_falloff(0.5),
        (-4.0_f32 * (1.0 - 63.5 / 127.0_f32).powi(2)).exp() - 0.018,
        "half way, read half a texel in",
    );
}

#[test]
fn a_light_seen_from_a_moved_and_scaled_space_keeps_its_place() {
    let local = LIGHT.in_space(40.0, 20.0, 2.0);
    assert_close(local.x, 30.0, "x");
    assert_close(local.y, -10.0, "y");
    assert_close(local.z, 250.0, "z");
    assert_close(local.radius, 400.0, "radius");
    assert_close(local.pixels_per_unit, 2.0, "pixels per unit");
}

fn round_shadow(
    bounds: [f32; 4],
    radii: [f32; 4],
    umbra: f32,
    hole: Option<ShadowHole>,
) -> ShadowRRect {
    ShadowRRect {
        bounds: Rect {
            x: bounds[0],
            y: bounds[1],
            width: bounds[2],
            height: bounds[3],
        },
        radii,
        umbra_inset: umbra,
        distance_correction: umbra / 4.0,
        hole,
    }
}

#[test]
fn rows_and_columns_alike_take_one_coverage() {
    let cases = [
        // A card's round rect with the caster's hole.
        round_shadow(
            [0.3, 0.7, 120.0, 40.0],
            [8.0; 4],
            6.0,
            Some(ShadowHole {
                inset: 6.0,
                radius: 0.0,
            }),
        ),
        // A circle, whose hole rounds.
        round_shadow(
            [1.0, 2.0, 40.0, 40.0],
            [20.0; 4],
            20.0,
            Some(ShadowHole {
                inset: 4.0,
                radius: 16.0,
            }),
        ),
        // Corners of four radii, no hole.
        round_shadow([0.0, 0.0, 90.0, 33.0], [2.0, 12.0, 4.0, 20.0], 6.0, None),
        // An umbra shallower than its corners.
        round_shadow(
            [0.5, 0.25, 64.0, 48.0],
            [10.0; 4],
            3.0,
            Some(ShadowHole {
                inset: 10.0,
                radius: 0.0,
            }),
        ),
    ];
    // A circle has no straight band, so its rows share no key; the others
    // share most of theirs.
    let mut shared = 0;
    for (case, shadow) in cases.iter().enumerate() {
        let rows: Vec<f32> = (-3..60).map(|row| row as f32 + 0.5).collect();
        for &y in &rows {
            let mut row = vec![0.0_f32; 143];
            shadow.row_coverage(-3..140, y, &mut row);
            for (column, coverage) in (-3..140).zip(&row) {
                assert_eq!(
                    coverage.to_bits(),
                    shadow.coverage(column as f32 + 0.5, y).to_bits(),
                    "case {case}: the row at {y} differs at column {column}"
                );
            }
        }
        let coverage_of = |y: f32| -> Vec<u32> {
            (-3..140)
                .map(|column| shadow.coverage(column as f32 + 0.5, y).to_bits())
                .collect()
        };
        for (index, &first) in rows.iter().enumerate() {
            for &second in &rows[index + 1..] {
                if shadow.row_key(first) == shadow.row_key(second) {
                    shared += 1;
                    assert_eq!(
                        coverage_of(first),
                        coverage_of(second),
                        "case {case}: rows {first} and {second} share a key but not their coverage"
                    );
                }
            }
        }
    }
    assert!(shared > 400, "only {shared} pairs of rows share a key");
}