vectorcraft/crates/render/src/tests.rs

875 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
let id = d.alloc_id();
Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w))
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
#[test]
fn fills_red_rect() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let d = {
let mut d2 = d.clone();
d2.insert(Some(d.layers[0].id), 0, n).unwrap();
d2
};
let r = render(&d);
assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]);
assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]);
}
#[test]
fn raster_size_limits() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(raster_size(r, 2.0), Ok((200, 100)));
assert_eq!(raster_size(r, 0.001), Ok((1, 1)));
assert_eq!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64, 1.0), 1.0), Ok((MAX_RASTER_SIDE, 1)));
assert!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64 + 1.0, 1.0), 1.0).is_err());
assert!(raster_size(Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 1.0).is_err(), "400 megapixels");
assert!(raster_size(r, f64::NAN).is_err());
assert!(raster_size(r, f64::INFINITY).is_err());
assert!(raster_size(r, 0.0).is_err());
}
#[test]
fn stroke_draws_outline_only() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
}
/// #1396: a stroke wider than its ellipse is a solid blob, pixel for pixel the disc a renderer
/// with a native stroke (the PDF and SVG exports) draws, at every alignment and zoom.
#[test]
fn a_stroke_wider_than_its_ellipse_paints_solid() {
let doc = |r: f64, fill: Paint, stroke: Paint, w: f64, align: StrokeAlign| {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shapes::ellipse(Rect::new(50.0 - r, 50.0 - r, 50.0 + r, 50.0 + r)), Appearance::basic(fill, stroke, w));
if let Some(st) = n.appearance.stroke_mut() {
st.align = align;
}
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
};
let black = || Paint::solid(Color::BLACK);
let render = |d: &Document, zoom: f64| {
let view = Affine::translate((50.0, 50.0)) * Affine::scale(zoom) * Affine::translate((-50.0, -50.0));
Renderer::new().render(d, 100, 100, view, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
};
// (ellipse radius, weight, alignment, radius of the disc it paints)
for (r, w, align, disc) in [
(10.0, 60.0, StrokeAlign::Center, 40.0),
(6.0, 50.0, StrokeAlign::Center, 31.0),
(15.0, 50.0, StrokeAlign::Center, 40.0),
(10.0, 30.0, StrokeAlign::Outside, 40.0),
(10.0, 30.0, StrokeAlign::Inside, 10.0),
] {
for zoom in [0.5, 1.0, 2.0] {
let got = render(&doc(r, Paint::None, black(), w, align), zoom);
if align == StrokeAlign::Outside {
// The shape itself stays unpainted, and the stroke around it has no gap.
assert_eq!(got.pixel(50, 50), [255, 255, 255, 255], "{r} {w} {align:?} at {zoom}");
assert_eq!(got.pixel(50 + (12.0 * zoom) as u32, 50), [0, 0, 0, 255], "{r} {w} {align:?} at {zoom}");
continue;
}
let want = render(&doc(disc, black(), Paint::None, 0.0, StrokeAlign::Center), zoom);
let off =
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| got.pixel(x, y)[0].abs_diff(want.pixel(x, y)[0]) > 40).count();
assert!(off <= 8, "{r} {w} {align:?} at {zoom}: {off} pixels differ from the disc");
}
}
}
#[test]
fn inside_stroke_stays_inside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
}
#[test]
fn outside_stroke_stays_outside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]);
}
#[test]
fn opacity_blends() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.opacity = 0.5;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let p = render(&d).pixel(50, 50);
assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}");
}
#[test]
fn multiply_blend() {
let mut d = Document::new(100.0, 100.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0);
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0);
b.blend = vectorcraft_color::BlendMode::Multiply;
let l = d.layers[0].id;
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 1, b).unwrap();
assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]);
}
#[test]
fn linear_gradient_goes_white_to_black() {
let mut d = Document::new(100.0, 100.0);
let n =
rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert!(r.pixel(2, 50)[0] > 240);
assert!(r.pixel(97, 50)[0] < 15);
let m = r.pixel(50, 50)[0] as i32;
assert!((m - 128).abs() < 12, "{m}");
}
#[test]
fn clip_group_clips() {
let mut d = Document::new(100.0, 100.0);
let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let gid = d.alloc_id();
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let r = render(&d);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]);
}
#[test]
fn hidden_and_culled() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.visible = false;
let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d.insert(Some(l), 1, far).unwrap();
let mut rr = Renderer::new();
let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
}
/// A document with symbol "Far" holding a red rectangle `art`, and an instance of it moved by
/// (-50, -50): its 20 × 20 square, (-60, -60) to (-40, -40), lies outside a view of the page.
fn far_instance(art: Rect) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = rect_node(&mut d, art, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })).unwrap();
d
}
#[test]
fn an_instance_is_culled_by_its_symbols_art() {
// The art drawn from (350, 350) is out of view.
let away = far_instance(Rect::new(400.0, 400.0, 440.0, 440.0));
let mut rr = Renderer::new();
assert_eq!(rr.render(&away, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
// The symbol's art, from (100, 100), drawn from (50, 50): in view. The instance is the same
// node, and the renderer measures it again.
let mut near = away.clone();
let art = rect_node(&mut near, Rect::new(100.0, 100.0, 140.0, 140.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
near.symbols[0].art = Arc::new(art);
assert_eq!(rr.render(&near, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [255, 0, 0, 255]);
assert_eq!(rr.stats.culled, 0);
}
#[test]
fn an_instance_is_culled_by_the_effects_in_its_art() {
// The art, (-1, -1) to (1, 1), drawn from (-5, 20) lies left of the view, and its drop shadow,
// 8 pt to the right, reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn a_repeat_of_an_instance_is_culled_by_the_effects_in_its_art() {
// The instance of the test above as the one copy of a one-by-one grid repeat.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
let grid = vectorcraft_doc::TileType::Grid;
let kind =
vectorcraft_doc::RepeatKind::Grid { h_spacing: 0.0, v_spacing: 0.0, rows: 1, cols: 1, grid_type: grid, flip_rows: false, flip_cols: false };
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(instance)] }));
let l = d.layers[0].id;
d.insert(Some(l), 0, repeat).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_effects() {
// Symbol `Spot` is a square from (-1, -1) to (1, 1) with a drop shadow 8 pt to the right, and
// symbols `Dot` and `Late` each hold an instance of it. 4,100 instances of `Dot`, each turned by
// another angle, lie far left of the view: one frame measures the effects in the art of at most
// 4,096 placements of symbols that hold instances. An instance of `Late` at (-5, 20) comes
// after them: its square lies left of the view, and the shadow reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Spot".into(), art: Arc::new(art) });
for name in ["Dot", "Late"] {
let spot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Spot".into(), xf: Affine::IDENTITY });
let group = Node::group(d.alloc_id(), vec![Arc::new(spot)]);
d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(group) });
}
let l = d.layers[0].id;
for i in 0..4100 {
let xf = Affine::translate((-1000.0, 50.0)) * Affine::rotate(f64::from(i) * 1e-4);
let id = d.alloc_id();
d.insert(Some(l), i as usize, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
}
let id = d.alloc_id();
d.insert(Some(l), 4100, Node::new(id, NodeKind::SymbolInstance { symbol: "Late".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
/// Symbols 0 to 29 each hold two instances of the next, sheared two ways, so that the 2^30
/// instances drawn from the first are all placed differently. Symbol 30 holds a square with a drop
/// shadow. An instance of symbol 0 lies far left of the view. With `effect`, every instance has
/// that effect of its own. The renderer must cull the instance of symbol 0 within 30 s.
fn nested_instances_are_culled_in_bounded_time(effect: Option<vectorcraft_doc::Effect>) {
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 2, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "S30".into(), art: Arc::new(art) });
let instance = |d: &mut Document, symbol: String, xf: Affine| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol, xf });
n.appearance.effects.extend(effect.clone());
n
};
for i in (0..30).rev() {
let next = format!("S{}", i + 1);
let mut pair = vec![];
for xf in [Affine::new([1.0, 0.0, 0.1, 1.0, 0.0, 0.0]), Affine::new([1.0, 0.1, 0.0, 1.0, 2.0, 0.0])] {
pair.push(Arc::new(instance(&mut d, next.clone(), xf)));
}
let group = Node::group(d.alloc_id(), pair);
d.symbols.push(vectorcraft_doc::Symbol { name: format!("S{i}"), art: Arc::new(group) });
}
let first = instance(&mut d, "S0".into(), Affine::translate((-100_000.0, 0.0)));
let l = d.layers[0].id;
d.insert(Some(l), 0, first).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = Renderer::new();
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
tx.send((img.pixel(50, 50), r.stats.culled)).ok();
});
let (pixel, culled) = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the frame within 30 s");
assert_eq!(pixel, [0, 0, 0, 0]);
assert!(culled > 0);
}
#[test]
fn nested_instances_with_effects_are_culled_in_bounded_time() {
nested_instances_are_culled_in_bounded_time(None);
}
#[test]
fn nested_instances_with_geometry_effects_of_their_own_are_culled_in_bounded_time() {
// Each instance's Transform effect reshapes the art it shows before the effects in that art run.
let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 1}), visible: true };
nested_instances_are_culled_in_bounded_time(Some(moved));
}
#[test]
fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() {
// The art, (-1, -1) to (1, 1), scaled 30 times and centered on (-50, 50): (-80, 20) to
// (-20, 80), out of view. Its Transform effect doubles the placed art: (-110, -10) to
// (10, 110).
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let double = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 200, "scaleV": 200}), visible: true };
art.appearance.effects.push(double);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((-50.0, 50.0)) * Affine::scale(30.0);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(5, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() {
// The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect
// moves it 150 pt right, into view.
let mut d = far_instance(Rect::new(0.0, 0.0, 20.0, 20.0));
let id = d.layers[0].children().unwrap()[0].id;
let inst = d.node_mut(id).unwrap();
inst.kind = NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-100.0, 10.0)) };
let effect = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 150}), visible: true };
inst.appearance.effects.push(effect);
assert_eq!(Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(60, 20), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_the_effects_in_its_reshaped_art() {
// The art, (-1, -1) to (1, 1), and the instance at (-11, 20) each have a Transform effect that
// scales by 4. The instance's effect scales the placed art, (-12, 19) to (-10, 21), to
// (-15, 16) to (-7, 24), and the art's effect then scales that to (-27, 4) to (5, 36), which
// reaches into the view.
let mut d = Document::new(100.0, 100.0);
let scale = serde_json::json!({"scaleH": 400, "scaleV": 400});
let effect = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: scale.clone(), visible: true };
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
art.appearance.effects.push(effect());
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let mut instance = Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-11.0, 20.0)) });
instance.appearance.effects.push(effect());
d.insert(Some(l), 0, instance).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(2, 20), [255, 0, 0, 255]);
}
#[test]
fn view_transform_zooms() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default());
assert_eq!(r.pixel(45, 45)[3], 255);
assert_eq!(r.pixel(55, 55)[3], 0);
}
#[test]
fn outline_mode_no_fill() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r =
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() });
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
assert!(r.pixel(10, 50)[0] < 200);
}
/// A 100 × 100 document with each kind of path Outline view draws, and the ids of its objects by
/// name: a stroked square, a compound path with a hole, a path and a compound path with a live
/// effect, a guide, a clip group, an Image Trace object showing its outlines, and a path in a layer
/// with Preview off. The clip group holds a path reaching past its clipping path, a compound path,
/// a group of two paths, a nested clip group, a path with a live effect, a hidden path and an
/// unstroked path a tenth of a point wide.
fn outline_doc() -> (Document, Vec<(&'static str, NodeId)>) {
let mut d = Document::new(100.0, 100.0);
let mut ids = vec![];
let black = || Paint::solid(Color::BLACK);
let offset = |by: f64| vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({ "offset": by }), visible: true };
let compound = |d: &mut Document, outer: Rect, hole: Rect| {
let children = [outer, hole].map(|r| Arc::new(rect_node(d, r, Paint::None, Paint::None, 0.0))).to_vec();
let mut n = Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
n
};
let clipping = |mut n: Node| {
if let NodeKind::Path { clipping, .. } = &mut n.kind {
*clipping = true;
}
n
};
let mut named = |name: &'static str, n: Node| {
ids.push((name, n.id));
Arc::new(n)
};
let square = named("square", rect_node(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), black(), 2.0));
let holed = named("holed", compound(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Rect::new(35.0, 10.0, 45.0, 20.0)));
let mut fx_path = rect_node(&mut d, Rect::new(55.0, 8.0, 70.0, 22.0), black(), Paint::None, 0.0);
fx_path.appearance.effects.push(offset(2.0));
let fx_path = named("fx path", fx_path);
let mut fx_compound = compound(&mut d, Rect::new(75.0, 5.0, 95.0, 25.0), Rect::new(80.0, 10.0, 90.0, 20.0));
fx_compound.appearance.effects.push(offset(-1.0));
let fx_compound = named("fx compound", fx_compound);
let mut guide = Node::path(d.alloc_id(), shapes::line(Point::new(5.0, 30.0), Point::new(95.0, 30.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
let guide = named("guide", guide);
// The clip group.
let ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(5.0, 35.0, 55.0, 85.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
let clip_path = named("clipping path", clipping(ellipse));
let past = named("past the clip", rect_node(&mut d, Rect::new(0.0, 50.0, 60.0, 60.0), Paint::None, black(), 1.0));
let member_compound = named("member compound", compound(&mut d, Rect::new(10.0, 65.0, 20.0, 75.0), Rect::new(12.0, 67.0, 18.0, 73.0)));
let pair_rect = named("pair rect", rect_node(&mut d, Rect::new(25.0, 65.0, 30.0, 75.0), black(), Paint::None, 0.0));
let pair_ellipse = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(32.0, 65.0, 40.0, 75.0)), Appearance::basic(black(), Paint::None, 0.0));
let pair_ellipse = named("pair ellipse", pair_ellipse);
let pair = named("pair", Node::group(d.alloc_id(), vec![pair_rect, pair_ellipse]));
let nested_clip = named("nested clipping path", clipping(rect_node(&mut d, Rect::new(42.0, 62.0, 54.0, 80.0), Paint::None, Paint::None, 0.0)));
let nested_path = named("nested path", rect_node(&mut d, Rect::new(44.0, 64.0, 70.0, 78.0), black(), Paint::None, 0.0));
let nested = named("nested", Node::new(d.alloc_id(), NodeKind::Group { children: vec![nested_clip, nested_path], clip: true }));
let mut member_fx = rect_node(&mut d, Rect::new(15.0, 40.0, 30.0, 47.0), black(), Paint::None, 0.0);
member_fx.appearance.effects.push(offset(1.5));
let member_fx = named("member fx", member_fx);
let mut hidden = rect_node(&mut d, Rect::new(20.0, 80.0, 40.0, 84.0), Paint::None, black(), 1.0);
hidden.visible = false;
let hidden = named("hidden", hidden);
let tiny = named("tiny", rect_node(&mut d, Rect::new(48.0, 56.0, 48.1, 56.1), black(), Paint::None, 0.0));
let members = vec![clip_path, past, member_compound, pair, nested, member_fx, hidden, tiny];
let clip = named("clip group", Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true }));
// The Image Trace object: its hidden source, then the traced shapes.
let mut source = rect_node(&mut d, Rect::new(60.0, 35.0, 95.0, 95.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
source.visible = false;
let shape_a =
named("trace shape a", rect_node(&mut d, Rect::new(62.0, 40.0, 80.0, 60.0), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), Paint::None, 0.0));
let shape_b = Node::path(
d.alloc_id(),
shapes::ellipse(Rect::new(70.0, 62.0, 92.0, 84.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.5, 0.0)), Paint::None, 0.0),
);
let shape_b = named("trace shape b", shape_b);
let mut traced = Node::group(d.alloc_id(), vec![Arc::new(source), shape_a, shape_b]);
traced.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "outlines" })));
let traced = named("traced", traced);
let unpreviewed =
named("unpreviewed", rect_node(&mut d, Rect::new(5.0, 88.0, 40.0, 97.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
let l = d.layers[0].id;
for n in [square, holed, fx_path, fx_compound, guide, clip, traced] {
d.insert(Some(l), usize::MAX, Arc::unwrap_or_clone(n)).unwrap();
}
let layer = Node::layer(d.alloc_id(), "Preview off", vectorcraft_doc::LayerColor::Preset(1));
let off = d.insert(None, usize::MAX, layer).unwrap();
if let Some(NodeKind::Layer { preview, .. }) = d.node_mut(off).map(|n| &mut n.kind) {
*preview = false;
}
d.insert(Some(off), 0, Arc::unwrap_or_clone(unpreviewed)).unwrap();
(d, ids)
}
/// The id `outline_doc` gave the object `name`.
fn id_of(ids: &[(&'static str, NodeId)], name: &str) -> NodeId {
ids.iter().find(|(n, _)| *n == name).map(|(_, id)| *id).unwrap_or_else(|| panic!("no object {name}"))
}
/// Outline frames drawn from the geometry a renderer keeps between frames are the frames a new
/// renderer draws, at three views each drawn twice, and after a member of the clip group moves
/// (here with dotted guides). In Preview the Image Trace object's outlines and the layer with
/// Preview off are drawn in outline.
#[test]
fn outline_frames_from_kept_geometry_match_a_new_renderer() {
let (d, ids) = outline_doc();
let outline = RenderOptions { outline: true, guide: ([0, 128, 255], true), background: Some([255; 4]), ..Default::default() };
let screen = RenderOptions { trace_views: true, background: Some([255; 4]), ..Default::default() };
let views = [Affine::IDENTITY, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), Affine::translate((6.0, -4.0))];
let mut moved = d.clone();
if let Some(n) = moved.node_mut(id_of(&ids, "past the clip")) {
n.transform(Affine::translate((3.0, 2.0)), false);
}
for opts in [&outline, &screen] {
let mut kept = Renderer::new();
for (d, view) in views.iter().chain(&views).map(|v| (&d, v)).chain([(&moved, &Affine::IDENTITY)]) {
let new = Renderer::new().render(d, 100, 100, *view, opts);
assert_eq!(kept.render(d, 100, 100, *view, opts).pixels, new.pixels, "outline: {}, view {view:?}", opts.outline);
}
}
// Outline view draws the members of the clip group unclipped, the tenth-of-a-point path as a
// dot, and no fills.
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &outline);
assert!(img.pixel(58, 50)[0] < 200, "the member past the clipping path");
assert!(img.pixel(48, 56)[0] < 255, "the tenth-of-a-point path");
assert_eq!(img.pixel(15, 15), [255; 4], "the square's fill");
}
/// Outline view draws paths and compound paths from the geometry the renderer keeps for them,
/// the members of clip groups and the shapes of an Image Trace object showing its outlines
/// included: the next frame draws the same paths. A member of a clip group keeps its live-effect
/// geometry, and the members out of view are culled by bounds the renderer keeps.
#[test]
fn outline_view_draws_paths_from_kept_geometry() {
let (d, ids) = outline_doc();
let kept_paths = |r: &Renderer| -> HashMap<NodeId, Arc<BezPath>> { r.geom.values().filter_map(|e| Some((e.node.id, e.path.clone()?))).collect() };
let outline = RenderOptions { outline: true, ..Default::default() };
let screen = RenderOptions { trace_views: true, ..Default::default() };
let in_outline = [
"square",
"holed",
"fx path",
"fx compound",
"guide",
"clipping path",
"past the clip",
"member compound",
"pair rect",
"pair ellipse",
"nested clipping path",
"nested path",
"member fx",
"tiny",
"trace shape a",
"trace shape b",
"unpreviewed",
];
for (opts, drawn) in [(&outline, &in_outline[..]), (&screen, &["trace shape a", "trace shape b", "unpreviewed"][..])] {
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::IDENTITY, opts);
let first = kept_paths(&r);
r.render(&d, 100, 100, Affine::IDENTITY, opts);
let second = kept_paths(&r);
for name in drawn {
let id = id_of(&ids, name);
let path = first.get(&id).unwrap_or_else(|| panic!("outline: {}: no path kept for {name}", opts.outline));
assert!(second.get(&id).is_some_and(|p| Arc::ptr_eq(p, path)), "outline: {}: {name} drawn again from its kept path", opts.outline);
}
assert!(!first.contains_key(&id_of(&ids, "hidden")));
}
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::IDENTITY, &outline);
let member_fx = d.node(id_of(&ids, "member fx")).map(|n| n as *const Node as usize);
assert!(member_fx.is_some_and(|key| r.fx_geom.contains_key(&key)), "the member's live-effect geometry is kept");
// Zoomed in 4× on part of the clip group.
let mut r = Renderer::new();
r.render(&d, 100, 100, Affine::scale(4.0) * Affine::translate((-40.0, -35.0)), &outline);
for name in ["member compound", "pair", "member fx"] {
let id = id_of(&ids, name);
assert!(r.geom.values().any(|e| e.node.id == id && e.bounds.is_some() && e.path.is_none()), "{name}: culled by kept bounds");
}
}
#[test]
fn artboards_white_on_pasteboard() {
let d = Document::new(50.0, 50.0);
let r = Renderer::new().render(
&d,
100,
100,
Affine::IDENTITY,
&RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() },
);
assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]);
assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]);
}
#[test]
fn region_export_png() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true);
assert_eq!((r.width, r.height), (200, 200));
let png = r.to_png().unwrap();
assert_eq!(&png[1..4], b"PNG");
}
#[test]
fn arrowheads_render() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n =
Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
// The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line.
assert!(r.pixel(78, 48)[0] < 128);
assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip");
}
use vectorcraft_doc::Arrowhead;
use vectorcraft_geom::Point;
/// A red 10..90 square masked by a white square covering only its left half (10..50),
/// plus a mid-grey strip (50..70) — rendered both multi- and single-threaded.
fn masked_doc(clip: bool, invert: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let grey = rect_node(&mut d, Rect::new(50.0, 10.0, 70.0, 90.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
let gid = d.alloc_id();
let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]);
let mut m = vectorcraft_doc::OpacityMask::new(art, clip);
m.invert = invert;
n.extras_mut().mask = Some(Box::new(m));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn opacity_mask_uses_luminance_and_clips() {
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = r.render(&masked_doc(true, false), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "white mask → opaque ({threads} threads)");
let mid = img.pixel(60, 50);
assert!((100..160).contains(&mid[1]), "grey mask → about half opacity, got {mid:?}");
assert_eq!(img.pixel(80, 50), [255, 255, 255, 255], "clip hides outside the mask art");
}
}
#[test]
fn opacity_mask_without_clip_and_inverted() {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "no clip: outside the mask art stays visible");
let img = Renderer::new().render(&masked_doc(true, true), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(30, 50), [255, 255, 255, 255], "inverted white → hidden");
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
}
#[test]
fn disabled_or_outline_mask_draws_unmasked() {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.extras_mut().mask.as_mut().unwrap().disabled = true;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]);
}
#[test]
fn linked_mask_moves_with_object() {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().transform(Affine::translate((5.0, 0.0)), false);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(52, 50), [255, 0, 0, 255], "mask art moved by 5 too");
}
#[test]
fn trim_view_clips_to_artboards() {
let mut d = Document::new(50.0, 50.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), trim: true, ..Default::default() };
let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(r.pixel(25, 25), [255, 0, 0, 255]);
assert_eq!(r.pixel(75, 75), [255, 255, 255, 255]);
assert_eq!(render(&d).pixel(75, 75), [255, 0, 0, 255]);
}
#[test]
fn drop_shadow_profile_and_cache() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 60.0, 60.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
n.appearance.effects.push(vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 10.0, "y": 10.0, "blur": 4.0, "opacity": 75.0}),
visible: true,
});
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
for threads in [0, 4] {
let mut r = Renderer::new();
r.threads = threads;
let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
// Inside the shadow (not under the object): 75% black. At its blurred edge: about half.
let inside = a.pixel(65, 50)[0] as i32;
assert!((inside - 64).abs() <= 6, "inside {inside}");
let edge = a.pixel(70, 50)[0] as i32;
assert!((edge - 160).abs() <= 20, "edge {edge}");
assert_eq!(a.pixel(85, 50), [255, 255, 255, 255]);
// Second frame comes from the cache: identical. A pan moves it rigidly.
let b = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(a.pixels, b.pixels);
let c = r.render(&d, 100, 100, Affine::translate((-5.0, 0.0)), &opts);
assert_eq!(c.pixel(60, 50), a.pixel(65, 50));
assert_eq!(c.pixel(65, 50), a.pixel(70, 50));
}
}
#[test]
fn blend_mode_on_a_single_fill_is_exact_without_a_layer() {
let mut d = Document::new(100.0, 100.0);
let red = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut over = rect_node(&mut d, Rect::new(25.0, 0.0, 75.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 1.0)), Paint::None, 0.0);
over.blend = vectorcraft_color::BlendMode::Multiply;
let l = d.layers[0].id;
d.insert(Some(l), 0, red).unwrap();
d.insert(Some(l), 1, over).unwrap();
for threads in [0, 4] {
let mut r = Renderer::new();
r.threads = threads;
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let [rr, g, b, _] = img.pixel(35, 50);
assert!((rr as i32 - 128).abs() <= 2 && g <= 1 && b <= 1, "multiply over red: {:?}", img.pixel(35, 50));
let [rr, g, b, _] = img.pixel(65, 50);
assert!((rr as i32 - 128).abs() <= 2 && (g as i32 - 128).abs() <= 2 && b >= 253, "multiply over white");
assert_eq!(img.pixel(10, 50), [255, 0, 0, 255]);
}
}
#[test]
fn invalid_dash_pattern_strokes_solid() {
// A negative dash value makes the pattern invalid (PDF `d`, SVG `stroke-dasharray`): the stroke
// is drawn solid. [-5, 3] used to send the dasher into an endless loop.
let line = |dash: Option<Vec<f64>>| {
let mut d = Document::new(100.0, 100.0);
let mut bp = BezPath::new();
bp.move_to((10.0, 50.0));
bp.line_to((90.0, 50.0));
let id = d.alloc_id();
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0));
n.appearance.stroke_mut().unwrap().dash = dash.map(|pattern| vectorcraft_doc::Dash { pattern, ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
};
let solid = render(&line(None));
for pattern in [vec![-5.0, 3.0], vec![3.0, -5.0], vec![4.0, f64::NAN], vec![0.0, 0.0]] {
let d = line(Some(pattern.clone()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(render(&d)).ok());
let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish"));
assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}");
}
}
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
/// Anti-aliased Images off, #394).
#[test]
fn images_take_their_nearest_pixel_without_smoothing() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::RgbaImage::from_fn(2, 1, |x, _| image::Rgba(if x == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(40.0, 20.0);
d.images.insert("bw".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
// How many pixels across the image are neither black nor white.
let greys = |smooth_images| {
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
(0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count()
};
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
assert_eq!(greys(false), 0, "nearest: black, then white");
}
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
/// edges stay smooth.
#[test]
fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
// Two triangles sharing the diagonal of the square (10, 10)–(90, 90).
for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(pts[0]);
b.line_to(pts[1]);
b.line_to(pts[2]);
b.close_path();
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap();
}
let draw = |supersample: bool| {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
};
// The palest pixel on the shared diagonal, away from the corners.
let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap();
assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam");
assert_eq!(palest(&draw(true)), 0, "supersampled: no seam");
// The outer edge is still smoothed: a pixel half covered is between white and red.
let ss = draw(true);
let edge = ss.pixel(50, 9);
assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}");
// The factor fits the size and the renderer's limits.
assert_eq!(supersample_factor(100, 100), Some(4));
assert_eq!(supersample_factor(2000, 2000), Some(3));
assert_eq!(supersample_factor(3000, 3000), Some(2));
assert_eq!(supersample_factor(5000, 5000), None);
assert_eq!(supersample_factor(40_000, 10), None);
}
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
#[test]
fn zero_length_subpaths_draw_dots_with_their_cap() {
let dots = |cap: vectorcraft_doc::LineCap| {
let mut d = Document::new(100.0, 100.0);
let mut bp = BezPath::new();
for at in [(30.0, 50.0), (70.0, 50.0)] {
bp.move_to(at);
bp.line_to(at);
}
let id = d.alloc_id();
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0));
n.appearance.stroke_mut().unwrap().cap = cap;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
render(&d)
};
let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]);
let round = dots(vectorcraft_doc::LineCap::Round);
for x in [30, 70] {
assert_eq!(round.pixel(x, 50), black, "round dot at {x}");
assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}");
assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}");
}
let square = dots(vectorcraft_doc::LineCap::Square);
for x in [30, 70] {
assert_eq!(square.pixel(x, 50), black, "square dot at {x}");
assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}");
assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}");
}
let butt = dots(vectorcraft_doc::LineCap::Butt);
assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot");
}