mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
The check added for #1396 dropped the inner contour only when the path itself fell in the hole, which takes a stroke more than twice as wide as the shape. Between one and two times the size (a 30 pt ellipse with a 50 pt stroke), and on rectangles with round or bevel joins, the hole stayed. An offset contour is now left out when no point of it lies half the weight from its subpath: it is then nowhere the edge of the stroke. Subpaths are judged one by one, and only when the stroke is at least as wide as the subpath's bounds, so ordinary strokes take the same single call as before.
470 lines
21 KiB
Rust
470 lines
21 KiB
Rust
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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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);
|
||
}
|