//! 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 { 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 = (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 = (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 = None; }