mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Asset Export, File › Export, Save for Web and Object › Rasterize drew each shape with its own smoothed edge, so where two shapes met edge to edge (Divide, Shape Builder, abutting art) both half-covered the pixels on the line and the background showed through as a thin ghost border. With Art Optimized (the default) these now supersample, as the option's name in Illustrator says: the art is drawn with hard edges at four times the size (three or two times for large images, within a 40-megapixel budget and the renderer's 65,535-pixel side) and each block averaged into one pixel. Pixels shared by two shapes are fully owned by one or the other at the finer size, so no seam is left, and the outer edges are still smooth. The canvas keeps shape-by-shape smoothing for speed; None and Type Optimized are unchanged. RenderOptions::supersample turns it on (exports and Rasterize set it). A render test checks two triangles sharing a diagonal: a seam with shape-by-shape smoothing, none supersampled, and the factor's limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
19 KiB
Rust
426 lines
19 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]);
|
||
}
|
||
|
||
#[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.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.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(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);
|
||
}
|