534 lines
23 KiB
Rust
534 lines
23 KiB
Rust
//! Pixel-sample "golden" tests on small fixtures: gradients, blend modes, clip groups, stroke
|
||
//! alignment/dashes/arrowheads, effects, text, and opacity folding vs layer compositing.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, Document, Effect, FillLayer, LineCap, Node, StrokeAlign, StrokeLayer};
|
||
use vectorcraft_geom::{Point, Rect, shapes};
|
||
use vectorcraft_testkit::fixtures::{DocBuilder, exec, session_with};
|
||
use vectorcraft_testkit::raster::{Image, assert_rgb, assert_similar, diff, render_view};
|
||
|
||
const W: u32 = 100;
|
||
|
||
fn render(d: &Document) -> Image {
|
||
render_view(d, W, W)
|
||
}
|
||
|
||
fn stops(a: Color, b: Color) -> Vec<GradientStop> {
|
||
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]
|
||
}
|
||
|
||
fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint {
|
||
let mut gp = GradientPaint::new(Gradient { kind, stops: stops(a, b) });
|
||
gp.angle = angle;
|
||
Paint::Gradient(Box::new(gp))
|
||
}
|
||
|
||
fn fill_only(p: Paint) -> Appearance {
|
||
Appearance::basic(p, Paint::None, 0.0)
|
||
}
|
||
|
||
fn stroke_only(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
|
||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||
st.cap = LineCap::Butt;
|
||
f(&mut st);
|
||
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- gradients
|
||
|
||
#[test]
|
||
fn linear_gradient_left_to_right() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(
|
||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||
fill_only(gradient(GradientKind::Linear, Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), 0.0)),
|
||
|_| {},
|
||
);
|
||
let img = render(&b.build());
|
||
assert_rgb(&img, 1, 50, [255, 0, 0], 12);
|
||
assert_rgb(&img, 98, 50, [0, 0, 255], 12);
|
||
let mid = img.over_white(50, 50);
|
||
assert!(mid[0].abs_diff(128) < 20 && mid[2].abs_diff(128) < 20 && mid[1] < 10, "mid {mid:?}");
|
||
// Red decreases monotonically along x; rows are identical (horizontal gradient).
|
||
let reds: Vec<u8> = (0..100).step_by(5).map(|x| img.over_white(x, 50)[0]).collect();
|
||
assert!(reds.windows(2).all(|w| w[1] <= w[0]), "{reds:?}");
|
||
for x in [10, 40, 70] {
|
||
assert_eq!(img.over_white(x, 10), img.over_white(x, 90));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn linear_gradient_angle_90_is_vertical() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), fill_only(gradient(GradientKind::Linear, Color::WHITE, Color::BLACK, 90.0)), |_| {});
|
||
let img = render(&b.build());
|
||
// Constant along rows, varying along columns.
|
||
for y in [10, 50, 90] {
|
||
let a = img.over_white(10, y);
|
||
let c = img.over_white(90, y);
|
||
assert!(a[0].abs_diff(c[0]) <= 2, "row {y}: {a:?} vs {c:?}");
|
||
}
|
||
assert!(img.over_white(50, 5)[0].abs_diff(img.over_white(50, 95)[0]) > 180);
|
||
}
|
||
|
||
#[test]
|
||
fn radial_gradient_centre_to_edge() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(
|
||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||
fill_only(gradient(GradientKind::Radial, Color::rgb(0.0, 1.0, 0.0), Color::BLACK, 0.0)),
|
||
|_| {},
|
||
);
|
||
let img = render(&b.build());
|
||
assert_rgb(&img, 50, 50, [0, 255, 0], 15);
|
||
// Symmetric about the centre and darker towards the edge.
|
||
let g = |x, y| img.over_white(x, y)[1];
|
||
assert!(g(50, 50) > g(70, 50) && g(70, 50) > g(95, 50));
|
||
// Pixel (x) has its centre at x + 0.5, so x=30 mirrors x=69 about the centre 50.
|
||
assert!(g(30, 50).abs_diff(g(69, 50)) <= 2 && g(50, 30).abs_diff(g(50, 69)) <= 2, "{} {} {} {}", g(30, 50), g(69, 50), g(50, 30), g(50, 69));
|
||
assert!(g(30, 50).abs_diff(g(50, 30)) <= 2, "not circular");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- blend modes
|
||
|
||
fn blend_ref(mode: BlendMode, b: f64, s: f64) -> f64 {
|
||
let hard = |b: f64, s: f64| {
|
||
if s <= 0.5 {
|
||
b * 2.0 * s
|
||
} else {
|
||
let t = 2.0 * s - 1.0;
|
||
b + t - b * t
|
||
}
|
||
};
|
||
match mode {
|
||
BlendMode::Normal => s,
|
||
BlendMode::Multiply => b * s,
|
||
BlendMode::Screen => b + s - b * s,
|
||
BlendMode::Darken => b.min(s),
|
||
BlendMode::Lighten => b.max(s),
|
||
BlendMode::Difference => (b - s).abs(),
|
||
BlendMode::Exclusion => b + s - 2.0 * b * s,
|
||
BlendMode::HardLight => hard(b, s),
|
||
BlendMode::Overlay => hard(s, b),
|
||
BlendMode::ColorDodge => {
|
||
if b == 0.0 {
|
||
0.0
|
||
} else if s >= 1.0 {
|
||
1.0
|
||
} else {
|
||
(b / (1.0 - s)).min(1.0)
|
||
}
|
||
}
|
||
BlendMode::ColorBurn => {
|
||
if b >= 1.0 {
|
||
1.0
|
||
} else if s <= 0.0 {
|
||
0.0
|
||
} else {
|
||
1.0 - ((1.0 - b) / s).min(1.0)
|
||
}
|
||
}
|
||
BlendMode::SoftLight => {
|
||
if s <= 0.5 {
|
||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||
} else {
|
||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||
b + (2.0 * s - 1.0) * (d - b)
|
||
}
|
||
}
|
||
other => panic!("unexpected {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn separable_blend_modes_match_formulas() {
|
||
let back = [0.8, 0.4, 0.2];
|
||
let src = [0.3, 0.6, 0.9];
|
||
for mode in [
|
||
BlendMode::Normal,
|
||
BlendMode::Multiply,
|
||
BlendMode::Screen,
|
||
BlendMode::Darken,
|
||
BlendMode::Lighten,
|
||
BlendMode::Difference,
|
||
BlendMode::Exclusion,
|
||
BlendMode::HardLight,
|
||
BlendMode::Overlay,
|
||
BlendMode::ColorDodge,
|
||
BlendMode::ColorBurn,
|
||
BlendMode::SoftLight,
|
||
] {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(back[0] as f32, back[1] as f32, back[2] as f32), |_| {});
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(src[0] as f32, src[1] as f32, src[2] as f32), |n| n.blend = mode);
|
||
let img = render(&b.build());
|
||
let want: Vec<u8> = (0..3).map(|c| (blend_ref(mode, back[c], src[c]) * 255.0).round() as u8).collect();
|
||
assert_rgb(&img, 50, 50, [want[0], want[1], want[2]], 3);
|
||
}
|
||
}
|
||
|
||
fn lum(c: [f64; 3]) -> f64 {
|
||
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
||
}
|
||
fn clip_color(c: [f64; 3]) -> [f64; 3] {
|
||
let l = lum(c);
|
||
let n = c[0].min(c[1]).min(c[2]);
|
||
let x = c[0].max(c[1]).max(c[2]);
|
||
let mut c = c;
|
||
if n < 0.0 {
|
||
c = c.map(|v| l + (v - l) * l / (l - n));
|
||
}
|
||
if x > 1.0 {
|
||
c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||
}
|
||
c
|
||
}
|
||
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
|
||
let d = l - lum(c);
|
||
clip_color(c.map(|v| v + d))
|
||
}
|
||
fn sat(c: [f64; 3]) -> f64 {
|
||
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||
}
|
||
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
|
||
let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||
if mx > mn { c.map(|v| (v - mn) * s / (mx - mn)) } else { [0.0; 3] }
|
||
}
|
||
|
||
#[test]
|
||
fn non_separable_blend_modes_match_formulas() {
|
||
let b = [0.8, 0.4, 0.2];
|
||
let s = [0.3, 0.6, 0.9];
|
||
for (mode, want) in [
|
||
(BlendMode::Hue, set_lum(set_sat(s, sat(b)), lum(b))),
|
||
(BlendMode::Saturation, set_lum(set_sat(b, sat(s)), lum(b))),
|
||
(BlendMode::Color, set_lum(s, lum(b))),
|
||
(BlendMode::Luminosity, set_lum(b, lum(s))),
|
||
] {
|
||
let mut d = DocBuilder::new(100.0, 100.0);
|
||
d.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(b[0] as f32, b[1] as f32, b[2] as f32), |_| {});
|
||
d.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(s[0] as f32, s[1] as f32, s[2] as f32), |n| n.blend = mode);
|
||
let img = render(&d.build());
|
||
let w = want.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||
let got = img.over_white(50, 50);
|
||
assert!((0..3).all(|c| got[c].abs_diff(w[c]) <= 4), "{mode:?}: got {got:?}, want {w:?}");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- clipping
|
||
|
||
#[test]
|
||
fn clip_group_masks_content() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
let clip = b.path_node(shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), fill_only(Paint::None));
|
||
let red = b.rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0));
|
||
b.group(vec![clip, red], true);
|
||
let img = render(&b.build());
|
||
assert_rgb(&img, 50, 50, [255, 0, 0], 2);
|
||
assert_rgb(&img, 25, 25, [255, 255, 255], 2); // inside the rect, outside the ellipse
|
||
assert_rgb(&img, 5, 50, [255, 255, 255], 2);
|
||
// Area of red ≈ circle area (π·30²).
|
||
let red_px = img.count(|p| p[0] > 200 && p[1] < 60 && p[3] > 200);
|
||
let want = std::f64::consts::PI * 900.0;
|
||
assert!((red_px as f64 - want).abs() < want * 0.05, "{red_px} vs {want}");
|
||
}
|
||
|
||
#[test]
|
||
fn unclipped_group_draws_everything() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
let e = b.path_node(shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), fill_only(Paint::None));
|
||
let red = b.rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0));
|
||
b.group(vec![e, red], false);
|
||
let img = render(&b.build());
|
||
assert_rgb(&img, 25, 25, [255, 0, 0], 2);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- strokes
|
||
|
||
#[test]
|
||
fn center_stroke_straddles_the_edge() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), stroke_only(10.0, |s| s.align = StrokeAlign::Center), |_| {});
|
||
let img = render(&b.build());
|
||
assert_rgb(&img, 17, 50, [0, 0, 0], 2);
|
||
assert_rgb(&img, 23, 50, [0, 0, 0], 2);
|
||
assert_rgb(&img, 13, 50, [255, 255, 255], 2);
|
||
assert_rgb(&img, 27, 50, [255, 255, 255], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn dashed_line_alternates() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(
|
||
shapes::line(Point::new(10.0, 50.0), Point::new(90.0, 50.0)),
|
||
stroke_only(4.0, |s| s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false })),
|
||
|_| {},
|
||
);
|
||
let img = render(&b.build());
|
||
for (x, on) in [(15, true), (25, false), (35, true), (45, false), (55, true), (65, false)] {
|
||
let px = img.over_white(x, 50);
|
||
assert_eq!(px[0] < 60, on, "x={x}: {px:?}");
|
||
}
|
||
// Solid line for comparison: all on.
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(shapes::line(Point::new(10.0, 50.0), Point::new(90.0, 50.0)), stroke_only(4.0, |_| {}), |_| {});
|
||
let solid = render(&b.build());
|
||
assert!(solid.ink() > img.ink() * 3 / 2, "solid {} dashed {}", solid.ink(), img.ink());
|
||
}
|
||
|
||
#[test]
|
||
fn arrowheads_add_ink_at_the_ends() {
|
||
let line = || shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0));
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(line(), stroke_only(2.0, |_| {}), |_| {});
|
||
let plain = render(&b.build());
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(Arrowhead::Triangle)), |_| {});
|
||
let end = render(&b.build());
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(line(), stroke_only(2.0, |s| s.start_arrow = Some(Arrowhead::Circle)), |_| {});
|
||
let start = render(&b.build());
|
||
assert!(end.ink() > plain.ink() + 20, "end arrow adds no ink: {} vs {}", end.ink(), plain.ink());
|
||
assert!(start.ink() > plain.ink() + 20);
|
||
// The ink is at the right end for the end arrow and at the left for the start arrow.
|
||
let col_ink =
|
||
|img: &Image, x0: u32, x1: u32| (x0..x1).flat_map(|x| (40..60).map(move |y| (x, y))).filter(|&(x, y)| img.over_white(x, y)[0] < 128).count();
|
||
assert!(col_ink(&end, 60, 100) > col_ink(&plain, 60, 100) + 10);
|
||
assert_eq!(col_ink(&end, 0, 30), col_ink(&plain, 0, 30));
|
||
assert!(col_ink(&start, 0, 30) > col_ink(&plain, 0, 30) + 10);
|
||
// Every arrowhead kind renders.
|
||
for a in Arrowhead::ALL {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {});
|
||
assert!(render(&b.build()).ink() > plain.ink(), "{a:?}");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- effects
|
||
|
||
fn with_effect(id: &str, params: serde_json::Value) -> Document {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(20.0, 20.0, 60.0, 60.0), Color::rgb(0.0, 0.0, 1.0), |n| {
|
||
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
|
||
});
|
||
b.build()
|
||
}
|
||
|
||
#[test]
|
||
fn drop_shadow_darkens_offset_region() {
|
||
let plain = render(&with_effect("stylize.dropShadow", json!({})).clone());
|
||
let mut no = with_effect("stylize.dropShadow", json!({}));
|
||
// Same document without the effect.
|
||
let l = no.layers[0].id;
|
||
let id = no.children(Some(l)).unwrap()[0].id;
|
||
no.node_mut(id).unwrap().appearance.effects.clear();
|
||
let none = render(&no);
|
||
let _ = l;
|
||
// Shadow at +7,+7 with blur: below-right of the rect is darker than white; the rect itself is unchanged.
|
||
let p = plain.over_white(64, 64);
|
||
assert!(p[0] < 230, "no shadow at (64,64): {p:?}");
|
||
assert_eq!(none.over_white(64, 64), [255, 255, 255]);
|
||
assert_rgb(&plain, 40, 40, [0, 0, 255], 2);
|
||
assert_rgb(&plain, 10, 10, [255, 255, 255], 2);
|
||
// A shadow with zero opacity renders like no effect.
|
||
let zero = render(&with_effect("stylize.dropShadow", json!({"opacity": 0})));
|
||
assert_similar(&zero, &none, 2.0, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn roughen_is_deterministic_and_seeded() {
|
||
let p = json!({"size": 10, "detail": 20, "relative": true, "seed": 3});
|
||
let a = render(&with_effect("distort.roughen", p.clone()));
|
||
let b = render(&with_effect("distort.roughen", p));
|
||
assert_eq!(a, b, "roughen is not deterministic");
|
||
let c = render(&with_effect("distort.roughen", json!({"size": 10, "detail": 20, "relative": true, "seed": 4})));
|
||
assert!(diff(&a, &c).max > 100, "seed has no effect");
|
||
let plain = render(&with_effect("distort.roughen", json!({"size": 0, "detail": 20})));
|
||
assert!(diff(&a, &plain).max > 100, "roughen does nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn geometry_effects_render_without_panicking() {
|
||
for (id, p) in [
|
||
("distort.zigZag", json!({"size": 5, "ridges": 4})),
|
||
("distort.puckerBloat", json!({"amount": 50})),
|
||
("distort.twist", json!({"angle": 45})),
|
||
("distort.tweak", json!({"h": 10, "v": 10, "seed": 1})),
|
||
("stylize.roundCorners", json!({"radius": 8})),
|
||
("stylize.innerGlow", json!({})),
|
||
("stylize.outerGlow", json!({})),
|
||
("stylize.feather", json!({"radius": 4})),
|
||
("blur.gaussian", json!({"radius": 3})),
|
||
("path.offsetPath", json!({"offset": 5})),
|
||
("warp.arc", json!({})),
|
||
] {
|
||
let img = render(&with_effect(id, p));
|
||
assert!(img.ink() > 500, "{id} rendered almost nothing ({} px)", img.ink());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_effect_is_ignored() {
|
||
let mut d = with_effect("distort.roughen", json!({"size": 20, "seed": 1}));
|
||
let l = d.layers[0].id;
|
||
let id = d.children(Some(l)).unwrap()[0].id;
|
||
d.node_mut(id).unwrap().appearance.effects[0].visible = false;
|
||
let hidden = render(&d);
|
||
d.node_mut(id).unwrap().appearance.effects.clear();
|
||
assert_eq!(hidden, render(&d));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- text
|
||
|
||
#[test]
|
||
fn text_renders_ink_inside_its_bounds() {
|
||
let mut s = session_with(200.0, 100.0);
|
||
exec(&mut s, "text.create", json!({"x": 10, "y": 60, "text": "Hello", "size": 40}));
|
||
exec(&mut s, "paint.setFill", json!({"color": "#000000"}));
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let img = render_view(&doc, 200, 100);
|
||
let ink = img.ink();
|
||
assert!(ink > 300, "text rendered {ink} px");
|
||
// Nothing far away from the text.
|
||
let far = (150..200).flat_map(|x| (0..100).map(move |y| (x, y))).filter(|&(x, y)| img.over_white(x, y) != [255, 255, 255]).count();
|
||
assert_eq!(far, 0);
|
||
// Outlined text renders the same pixels (within anti-aliasing).
|
||
exec(&mut s, "select.all", json!({}));
|
||
exec(&mut s, "type.createOutlines", json!({}));
|
||
let outlined = render_view(&s.doc().unwrap().doc, 200, 100);
|
||
assert_similar(&img, &outlined, 24.0, 0.01);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- opacity folding
|
||
|
||
/// Opacity on a 1-item appearance (folded into the paint alpha) must match opacity on a 2-item
|
||
/// appearance (which forces a compositing layer) when the second item doesn't change the result.
|
||
#[test]
|
||
fn opacity_folding_equals_layer_compositing() {
|
||
for (opacity, blend) in [(0.5, BlendMode::Normal), (0.25, BlendMode::Normal), (0.8, BlendMode::Multiply), (0.6, BlendMode::Screen)] {
|
||
let red = Color::rgb(0.9, 0.1, 0.2);
|
||
let one = {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.1, 0.5, 0.9), |_| {});
|
||
b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), fill_only(Paint::solid(red)), |n| {
|
||
n.opacity = opacity;
|
||
n.blend = blend;
|
||
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
|
||
});
|
||
render(&b.build())
|
||
};
|
||
let two = {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.1, 0.5, 0.9), |_| {});
|
||
b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), fill_only(Paint::solid(red)), |n| {
|
||
n.opacity = opacity;
|
||
n.blend = blend;
|
||
// An opaque green fill fully covered by the same red on top: the composite is red.
|
||
n.appearance.items = vec![
|
||
AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)))),
|
||
AppearanceItem::Fill(FillLayer::new(Paint::solid(red))),
|
||
];
|
||
});
|
||
render(&b.build())
|
||
};
|
||
let st = diff(&one, &two);
|
||
// Interior pixels within 2 levels; edges get a little slack for AA of two coverage passes.
|
||
for (x, y) in [(50, 50), (30, 50), (50, 20), (70, 70)] {
|
||
let (a, b) = (one.over_white(x, y), two.over_white(x, y));
|
||
assert!((0..3).all(|c| a[c].abs_diff(b[c]) <= 2), "opacity {opacity} {blend:?} at ({x},{y}): {a:?} vs {b:?}");
|
||
}
|
||
assert!(st.mean < 0.5, "opacity {opacity} {blend:?}: {st:?}");
|
||
}
|
||
}
|
||
|
||
/// Group opacity over a single child equals the child's own opacity.
|
||
#[test]
|
||
fn group_opacity_equals_child_opacity() {
|
||
let child_op = {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::WHITE, |_| {});
|
||
b.rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.0, 0.0, 0.0), |n| n.opacity = 0.4);
|
||
render(&b.build())
|
||
};
|
||
let group_op = {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::WHITE, |_| {});
|
||
let r = b.rect_node(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.0, 0.0, 0.0));
|
||
let g = b.group(vec![r], false);
|
||
b.doc.node_mut(g).unwrap().opacity = 0.4;
|
||
render(&b.build())
|
||
};
|
||
assert_similar(&child_op, &group_op, 2.0, 0.0);
|
||
assert_rgb(&group_op, 50, 50, [153, 153, 153], 3);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- misc
|
||
|
||
#[test]
|
||
fn invisible_and_hidden_nodes_are_not_drawn() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK, |n| n.visible = false);
|
||
assert_eq!(render(&b.build()).ink(), 0);
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
let id = b.rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK, |_| {});
|
||
let d = b.build();
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), hidden: vec![id], ..Default::default() };
|
||
let r = vectorcraft_render::Renderer::new().render(&d, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts);
|
||
assert_eq!(Image::from_rendered(&r).ink(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn outline_mode_draws_thin_black_edges_only() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(1.0, 0.0, 0.0), |_| {});
|
||
let d = b.build();
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() };
|
||
let img = Image::from_rendered(&vectorcraft_render::Renderer::new().render(&d, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts));
|
||
assert_rgb(&img, 50, 50, [255, 255, 255], 2);
|
||
assert_eq!(img.count(|p| p[0] > 200 && p[1] < 50), 0, "red paint visible in outline mode");
|
||
assert!(img.ink() > 150 && img.ink() < 600, "outline ink {}", img.ink());
|
||
}
|
||
|
||
#[test]
|
||
fn png_encoding_roundtrips_pixels() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(10.0, 10.0, 60.0, 60.0), Color::rgb(0.2, 0.4, 0.6), |n| n.opacity = 0.5);
|
||
let d = b.build();
|
||
let r = vectorcraft_render::Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, false);
|
||
let decoded = Image::from_png(&r.to_png().unwrap()).unwrap();
|
||
assert_eq!(decoded, Image::from_rendered(&r));
|
||
}
|
||
|
||
#[test]
|
||
fn renderer_reuse_matches_fresh_renderer() {
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.rect(Rect::new(10.0, 10.0, 60.0, 60.0), Color::rgb(0.2, 0.4, 0.6), |_| {});
|
||
let d1 = b.build();
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(shapes::ellipse(Rect::new(30.0, 30.0, 90.0, 90.0)), fill_only(Paint::solid(Color::rgb(0.9, 0.2, 0.1))), |_| {});
|
||
let d2 = b.build();
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
let mut r = vectorcraft_render::Renderer::new();
|
||
let id = vectorcraft_geom::Affine::IDENTITY;
|
||
let _ = r.render(&d1, 100, 100, id, &opts);
|
||
let reused = Image::from_rendered(&r.render(&d2, 100, 100, id, &opts));
|
||
assert_eq!(reused, render(&d2));
|
||
}
|
||
|
||
#[test]
|
||
fn scaled_render_matches_upscaled_geometry() {
|
||
// Rendering at 2 px/pt equals rendering geometry scaled ×2 at 1 px/pt.
|
||
let mut b = DocBuilder::new(50.0, 50.0);
|
||
b.path(shapes::ellipse(Rect::new(5.0, 5.0, 45.0, 40.0)), fill_only(Paint::solid(Color::rgb(0.1, 0.6, 0.3))), |_| {});
|
||
let d = b.build();
|
||
let a = Image::from_rendered(&vectorcraft_render::Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 50.0, 50.0), 2.0, true));
|
||
let mut b = DocBuilder::new(100.0, 100.0);
|
||
b.path(shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 80.0)), fill_only(Paint::solid(Color::rgb(0.1, 0.6, 0.3))), |_| {});
|
||
let c = Image::from_rendered(&vectorcraft_render::Renderer::new().render_region(&b.build(), Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true));
|
||
assert_similar(&a, &c, 2.0, 0.0);
|
||
let _: Option<Node> = None;
|
||
}
|