use repose_core::{
BlendMode, Brush, Color, PaintDesc, Px, Rect, Scene, SceneNode, Transform, VectorMeshData,
VectorVertex,
};
use repose_render_wgpu::offscreen::OffscreenRenderer;
use std::sync::Arc;
fn quad(x0: f32, y0: f32, x1: f32, y1: f32, color: [f32; 4]) -> Arc<VectorMeshData> {
let vertices: Arc<[VectorVertex]> = [
([x0, y0], [0.0, 0.0]),
([x1, y0], [1.0, 0.0]),
([x1, y1], [1.0, 1.0]),
([x0, y1], [0.0, 1.0]),
]
.iter()
.map(|(pos, uv)| VectorVertex {
pos: *pos,
color,
uv: *uv,
})
.collect();
Arc::new(VectorMeshData {
vertices,
indices: [0u32, 1, 2, 0, 2, 3].into(),
})
}
fn try_offscreen(w: u32, h: u32) -> Option<OffscreenRenderer> {
match OffscreenRenderer::new_blocking(w, h, 1) {
Ok(o) => Some(o),
Err(e) => {
eprintln!("SKIP: no WGPU adapter for mesh offscreen probe ({e:#})");
None
}
}
}
fn covered_of(nodes: Vec<SceneNode>) -> Option<Vec<u8>> {
let Some(mut off) = try_offscreen(64, 64) else {
return None;
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes,
};
Some(off.render_rgba(&scene, None).expect("render"))
}
fn ink_bbox(px: &[u8]) -> Option<(u32, u32, u32, u32)> {
let (mut x0, mut y0, mut x1, mut y1) = (64u32, 64u32, 0u32, 0u32);
for (i, p) in px.as_chunks::<4>().0.iter().enumerate() {
if p[3] > 8 {
let (x, y) = ((i % 64) as u32, (i / 64) as u32);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
(x1 >= x0).then_some((x0, y0, x1, y1))
}
fn white_rect(x: f32, y: f32, w: f32, h: f32) -> SceneNode {
SceneNode::Rect {
rect: Rect { x, y, w, h },
brush: repose_core::Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
}
}
#[test]
fn bisect_layer_coordinate_space() {
for (label, cx, cy, expect) in [
("world-child", 10.0f32, 10.0f32, (20, 20, 39, 39)),
("local-child", 0.0f32, 0.0f32, (10, 10, 29, 29)),
] {
let Some(px) = covered_of(vec![
SceneNode::BeginLayer {
rect: Rect {
x: 10.0,
y: 10.0,
w: 44.0,
h: 44.0,
},
layer_id: 7,
alpha: 1.0,
blur_radius_x: Px(0.0),
blur_radius_y: Px(0.0),
rectangle_edge: true,
},
white_rect(cx, cy, 20.0, 20.0),
SceneNode::EndLayer { layer_id: 7 },
]) else {
return;
};
assert_eq!(ink_bbox(&px), Some(expect), "{label} landed wrong");
}
}
#[test]
fn vector_mesh_covers_pixels_offscreen() {
let Some(mut off) = try_offscreen(64, 64) else {
return;
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes: vec![SceneNode::VectorMesh {
mesh: quad(10.0, 10.0, 54.0, 54.0, [1.0, 1.0, 1.0, 1.0]),
transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
paint: PaintDesc::Solid,
clip: None,
blend: BlendMode::Alpha,
}],
};
let px = off.render_rgba(&scene, None).expect("render");
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 8).count();
assert!(covered > 1000, "mesh must cover pixels, got {covered}");
let c = (32 * 64 + 32) * 4;
assert!(px[c + 3] > 200, "centre alpha {}", px[c + 3]);
}
fn tilt_about_y32() -> Transform {
let (cy, f) = (32.0f32, 200.0f32);
let th = 0.3f32;
let (s, c) = (th.sin(), th.cos());
Transform::from_projective_rows(
[1.0, 0.0, 0.0],
[0.0, c, cy * (1.0 - c)],
[0.0, s / f, 1.0 - cy * s / f],
)
}
fn tilt_scene() -> Vec<SceneNode> {
vec![
SceneNode::PushTransform {
transform: tilt_about_y32(),
},
SceneNode::Rect {
rect: Rect {
x: 10.0,
y: 10.0,
w: 44.0,
h: 44.0,
},
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
]
}
#[test]
fn perspective_flatten_projects_rect() {
let Some(px) = covered_of(tilt_scene()) else {
return;
};
let (x0, y0, x1, y1) = ink_bbox(&px).expect("tilted rect must cover pixels");
assert!(
(8..=12).contains(&x0) && (54..=58).contains(&x1),
"projected x-range wrong: {x0}..{x1}"
);
assert!(
(9..=13).contains(&y0) && (49..=53).contains(&y1),
"projected y-range wrong: {y0}..{y1}"
);
}
#[test]
fn affine_push_transform_is_unaffected() {
let Some(px) = covered_of(vec![
SceneNode::PushTransform {
transform: Transform::translate(5.0, 7.0),
},
SceneNode::Rect {
rect: Rect {
x: 10.0,
y: 10.0,
w: 44.0,
h: 44.0,
},
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
]) else {
return;
};
assert_eq!(
ink_bbox(&px),
Some((15, 17, 58, 60)),
"affine path must stay exact"
);
}
fn clip_scene(op: repose_core::ClipOp) -> Vec<SceneNode> {
vec![
SceneNode::PushVectorClip {
mesh: quad(20.0, 20.0, 44.0, 44.0, [1.0, 1.0, 1.0, 1.0]),
op,
},
white_rect(0.0, 0.0, 64.0, 64.0),
SceneNode::PopVectorClip,
]
}
#[test]
fn vector_clip_intersect_masks_rect() {
let Some(px) = covered_of(clip_scene(repose_core::ClipOp::Intersect)) else {
return;
};
assert_eq!(ink_bbox(&px), Some((20, 20, 43, 43)));
}
#[test]
fn vector_clip_difference_cuts_rect() {
let Some(px) = covered_of(clip_scene(repose_core::ClipOp::Difference)) else {
return;
};
let ink = ink_bbox(&px).expect("inverse clip must leave content");
assert_eq!((ink.0, ink.1), (0, 0));
assert_eq!((ink.2, ink.3), (63, 63));
let centre = (32 * 64 + 32) * 4;
assert_eq!(px[centre + 3], 0, "mask interior must be cut out");
assert!(px[3] > 200, "frame corner must survive");
}
#[test]
fn coverage_tile_composites_tinted() {
let Some(mut off) = try_offscreen(64, 64) else {
return;
};
let data = vec![255u8; 10 * 10];
let h = off.renderer_mut().register_coverage_a8(10, 10, &data);
assert_ne!(h, 0, "registration must return a handle");
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes: vec![SceneNode::Coverage {
rect: Rect {
x: 5.0,
y: 5.0,
w: 10.0,
h: 10.0,
},
handle: h,
color: Color::from_rgba(255, 0, 0, 255),
}],
};
let px = off.render_rgba(&scene, None).expect("render");
let at = |x: usize, y: usize| (y * 64 + x) * 4;
let c = at(10, 10);
assert!(px[c + 3] > 200, "tile interior alpha {}", px[c + 3]);
assert!(px[c] > 200, "tile interior red {}", px[c]);
assert_eq!(px[at(0, 0) + 3], 0, "outside tile must be empty");
assert_eq!(px[at(20, 20) + 3], 0, "outside tile must be empty");
let half = vec![128u8; 8 * 8];
let h2 = off.renderer_mut().register_coverage_a8(8, 8, &half);
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes: vec![SceneNode::Coverage {
rect: Rect {
x: 30.0,
y: 30.0,
w: 8.0,
h: 8.0,
},
handle: h2,
color: Color::from_rgba(255, 255, 255, 255),
}],
};
let px = off.render_rgba(&scene, None).expect("render");
let c = at(34, 34);
assert!(
(100..=160).contains(&px[c + 3]),
"half coverage alpha {}",
px[c + 3]
);
off.renderer_mut().remove_coverage(h2);
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes: vec![SceneNode::Coverage {
rect: Rect {
x: 30.0,
y: 30.0,
w: 8.0,
h: 8.0,
},
handle: h2,
color: Color::from_rgba(255, 255, 255, 255),
}],
};
let px = off.render_rgba(&scene, None).expect("render");
assert_eq!(px[at(34, 34) + 3], 0, "removed tile must not draw");
}
fn covered_of_sized(w: u32, h: u32, nodes: Vec<SceneNode>) -> Option<Vec<u8>> {
let Some(mut off) = try_offscreen(w, h) else {
return None;
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes,
};
Some(off.render_rgba(&scene, None).expect("render"))
}
fn ink_bbox_wh(px: &[u8], w: usize) -> Option<(u32, u32, u32, u32)> {
let (mut x0, mut y0, mut x1, mut y1) = (w as u32, 100000u32, 0u32, 0u32);
for (i, p) in px.as_chunks::<4>().0.iter().enumerate() {
if p[3] > 8 {
let (x, y) = ((i % w) as u32, (i / w) as u32);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
(x1 >= x0).then_some((x0, y0, x1, y1))
}
#[test]
fn flatten_with_tiny_tilt() {
let mut t = Transform::identity();
t.perspective = [0.0, 0.0001, 1.0];
let Some(px) = covered_of_sized(
640,
360,
vec![
SceneNode::PushTransform { transform: t },
SceneNode::Rect {
rect: Rect {
x: 214.0,
y: 142.0,
w: 211.0,
h: 75.0,
},
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
],
) else {
return;
};
let got = ink_bbox_wh(&px, 640);
let (x0, y0, x1, y1) = got.expect("tilted rect must cover pixels");
let expect = (209u32, 140u32, 418u32, 211u32);
let (ex0, ey0, ex1, ey1) = expect;
assert!(
x0.abs_diff(ex0) <= 1
&& y0.abs_diff(ey0) <= 1
&& x1.abs_diff(ex1) <= 1
&& y1.abs_diff(ey1) <= 1,
"tiny-tilt rect at {got:?}, want {expect:?}"
);
}
#[test]
fn flatten_places_content_by_map() {
let mut t = Transform::identity();
t.perspective = [0.0, 1e-9, 1.0];
let Some(px) = covered_of_sized(
640,
360,
vec![
SceneNode::PushTransform { transform: t },
SceneNode::Rect {
rect: Rect {
x: 214.0,
y: 142.0,
w: 211.0,
h: 75.0,
},
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
],
) else {
return;
};
assert_eq!(ink_bbox_wh(&px, 640), Some((214, 142, 424, 216)));
let mut t = Transform::identity();
t.perspective = [0.0, 0.0001, 1.0];
for (rect, expect) in [
(
Rect {
x: 10.0,
y: 10.0,
w: 44.0,
h: 44.0,
},
(10, 10, 53, 53),
),
(
Rect {
x: 500.0,
y: 280.0,
w: 60.0,
h: 40.0,
},
(485, 272, 544, 309),
),
] {
let Some(px) = covered_of_sized(
640,
360,
vec![
SceneNode::PushTransform { transform: t },
SceneNode::Rect {
rect,
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
],
) else {
return;
};
let got = ink_bbox_wh(&px, 640);
let (x0, y0, x1, y1) = got.expect("tilted rect must cover pixels");
let (ex0, ey0, ex1, ey1) = expect;
assert!(
x0.abs_diff(ex0) <= 2
&& y0.abs_diff(ey0) <= 2
&& x1.abs_diff(ex1) <= 2
&& y1.abs_diff(ey1) <= 2,
"tilted rect at {got:?}, want {expect:?}"
);
}
}
#[test]
fn flatten_with_ass_frx45_map() {
let t = Transform {
translate_x: 130.33391,
translate_y: 126.03361,
scale_x: 0.87359774,
scale_y: 0.61428744,
rotate: 0.5082492,
shear_x: -0.79221636,
shear_y: -0.5570625,
origin_x: 0.5,
origin_y: 0.5,
perspective: [0.0, -0.0022627416, 1.4072934],
};
let Some(px) = covered_of_sized(
640,
360,
vec![
SceneNode::PushTransform { transform: t },
SceneNode::Rect {
rect: Rect {
x: 214.0,
y: 142.0,
w: 211.0,
h: 75.0,
},
brush: Brush::Solid(Color::from_rgba(255, 255, 255, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::PopTransform,
],
) else {
return;
};
assert_eq!(ink_bbox_wh(&px, 640), Some((204, 155, 434, 208)));
}
fn center_px(nodes: Vec<SceneNode>) -> Option<[u8; 4]> {
let px = covered_of(nodes)?;
let c = (32 * 64 + 32) * 4;
Some([px[c], px[c + 1], px[c + 2], px[c + 3]])
}
fn blend_quad(blend: BlendMode) -> Vec<SceneNode> {
vec![
SceneNode::Rect {
rect: Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
},
brush: Brush::Solid(Color::from_rgba(255, 0, 0, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::VectorMesh {
mesh: quad(16.0, 16.0, 48.0, 48.0, [0.5, 0.5, 0.5, 1.0]),
transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
paint: PaintDesc::Solid,
clip: None,
blend,
},
]
}
#[test]
fn blend_multiply_darkens_backdrop() {
let Some(c) = center_px(blend_quad(BlendMode::Multiply)) else {
return;
};
assert!(
(175..=200).contains(&c[0]) && c[1] < 30 && c[2] < 30,
"multiply of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_screen_lightens_backdrop() {
let Some(c) = center_px(blend_quad(BlendMode::Screen)) else {
return;
};
assert!(
c[0] > 200 && (175..=200).contains(&c[1]) && (175..=200).contains(&c[2]),
"screen of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_darken_takes_minimum() {
let Some(c) = center_px(blend_quad(BlendMode::Darken)) else {
return;
};
assert!(
(175..=200).contains(&c[0]) && c[1] < 30 && c[2] < 30,
"darken of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_lighten_takes_maximum() {
let Some(c) = center_px(blend_quad(BlendMode::Lighten)) else {
return;
};
assert!(
c[0] > 200 && (175..=200).contains(&c[1]) && (175..=200).contains(&c[2]),
"lighten of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_overlay_matches_screen_on_red() {
let Some(c) = center_px(blend_quad(BlendMode::Overlay)) else {
return;
};
assert!(
c[0] > 200 && c[1] < 40 && c[2] < 40,
"overlay of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_difference_subtracts_backdrop() {
let Some(c) = center_px(blend_quad(BlendMode::Difference)) else {
return;
};
assert!(
(175..=200).contains(&c[0]) && (175..=200).contains(&c[1]) && (175..=200).contains(&c[2]),
"difference of red over grey wrong: {c:?}"
);
}
#[test]
fn blend_overlay_inside_layer_composites() {
let nodes = vec![
SceneNode::BeginLayer {
rect: Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
},
layer_id: 21,
alpha: 1.0,
blur_radius_x: Px(0.0),
blur_radius_y: Px(0.0),
rectangle_edge: true,
},
SceneNode::Rect {
rect: Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
},
brush: Brush::Solid(Color::from_rgba(255, 0, 0, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::VectorMesh {
mesh: quad(16.0, 16.0, 48.0, 48.0, [0.5, 0.5, 0.5, 1.0]),
transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
paint: PaintDesc::Solid,
clip: None,
blend: BlendMode::Overlay,
},
SceneNode::EndLayer { layer_id: 21 },
];
let Some(px) = covered_of(nodes) else {
return;
};
let c = (32 * 64 + 32) * 4;
let (r, g, b) = (px[c], px[c + 1], px[c + 2]);
assert!(
r > 200 && g < 40 && b < 40,
"overlay inside origin layer wrong: [{r}, {g}, {b}, {}]",
px[c + 3]
);
}
#[test]
fn blend_overlay_inside_offset_layer_composites() {
let nodes = vec![
SceneNode::BeginLayer {
rect: Rect {
x: 10.0,
y: 8.0,
w: 64.0,
h: 64.0,
},
layer_id: 22,
alpha: 1.0,
blur_radius_x: Px(0.0),
blur_radius_y: Px(0.0),
rectangle_edge: true,
},
SceneNode::Rect {
rect: Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
},
brush: Brush::Solid(Color::from_rgba(255, 0, 0, 255)),
radius: [Px::ZERO; 4],
},
SceneNode::VectorMesh {
mesh: quad(16.0, 16.0, 48.0, 48.0, [0.5, 0.5, 0.5, 1.0]),
transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
paint: PaintDesc::Solid,
clip: None,
blend: BlendMode::Overlay,
},
SceneNode::EndLayer { layer_id: 22 },
];
let Some(mut off) = try_offscreen(96, 96) else {
return;
};
let scene = Scene {
clear_color: Color::from_rgba(0, 0, 0, 0),
nodes,
};
let px = off.render_rgba(&scene, None).expect("render");
let c = ((8 + 32) * 96 + (10 + 32)) * 4;
let (r, g, b) = (px[c], px[c + 1], px[c + 2]);
assert!(
r > 200 && g < 40 && b < 40,
"overlay inside offset layer wrong: [{r}, {g}, {b}, {}]",
px[c + 3]
);
}