refactor(render): move tests_*.rs to tests/<topic>.rs

This commit is contained in:
aiongg
2026-10-26 18:26:59 +00:00
parent 8ded395d49
commit c21beabf97
27 changed files with 53 additions and 75 deletions
+122
View File
@@ -0,0 +1,122 @@
//! Live colour adjustments (Effect → Color Adjustments) as drawn: on paths, type, embedded images,
//! groups and symbol instances, alone and with other effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_geom::{Point, shapes};
use crate::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// Hue +120°: red becomes green, green blue.
fn hue() -> Effect {
fx("adjust.hueSaturation", json!({"hue": 120}))
}
fn doc_with(nodes: Vec<Node>) -> Document {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
for mut n in nodes {
n.id = d.alloc_id();
d.insert(Some(l), 0, n).unwrap();
}
d
}
fn render(d: &Document) -> Rendered {
let mut r = Renderer::new();
r.threads = 0;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn red_square(x: f64, y: f64) -> Node {
Node::path(
NodeId(0),
shapes::rectangle(Rect::new(x, y, x + 30.0, y + 30.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
)
}
fn red_image() -> (String, ImageBlob) {
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
("red".into(), ImageBlob::new("image/png", png))
}
#[test]
fn a_path_takes_its_adjusted_colour_and_keeps_its_other_effects() {
let mut n = red_square(10.0, 10.0);
n.appearance.effects = vec![hue(), fx("distort.transform", json!({"moveH": 40}))];
let img = render(&doc_with(vec![n]));
assert_eq!(img.pixel(65, 25), [0, 255, 0, 255], "green, and moved by the geometry effect");
assert_eq!(img.pixel(25, 25), [255, 255, 255, 255]);
// Hidden, it changes nothing.
let mut n = red_square(10.0, 10.0);
n.appearance.effects = vec![Effect { visible: false, ..hue() }];
assert_eq!(render(&doc_with(vec![n])).pixel(25, 25), [255, 0, 0, 255]);
}
#[test]
fn a_group_recolours_its_members_and_type() {
let style = CharStyle { size: 60.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), ..Default::default() };
let text = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(50.0, 90.0), "I", style))));
let mut g = Node::group(NodeId(0), vec![Arc::new(red_square(5.0, 5.0)), Arc::new(text)]);
g.appearance.effects.push(hue());
let d = doc_with(vec![g]);
let img = render(&d);
assert_eq!(img.pixel(20, 20), [0, 255, 0, 255]);
let inked: Vec<[u8; 4]> =
(50..70).flat_map(|x| (50..90).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[0] < 128 || p[2] < 128).collect();
assert!(inked.contains(&[0, 255, 0, 255]), "the type turns green too");
assert!(inked.iter().all(|p| p[1] == 255 && p[0] == p[2]), "no red left: {:?}", inked.iter().find(|p| p[1] != 255 || p[0] != p[2]));
}
#[test]
fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() {
let mut d = doc_with(vec![]);
let (key, blob) = red_image();
d.images.insert(key.clone(), blob.clone());
let mut n = Node::new(
NodeId(0),
NodeKind::Image(Box::new(ImageObject {
key,
width: 30,
height: 30,
xf: Affine::translate((20.0, 20.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
n.appearance.effects.push(hue());
d = {
let l = d.layers[0].id;
n.id = d.alloc_id();
d.insert(Some(l), 0, n).unwrap();
d
};
let img = render(&d);
assert_eq!(img.pixel(35, 35), [0, 255, 0, 255]);
assert_eq!(d.images.get("red"), Some(&blob), "non-destructive");
}
#[test]
fn a_symbol_instance_recolours_its_art() {
let mut d = Document::new(100.0, 100.0);
let art = red_square(-15.0, -15.0);
d.symbols.push(Symbol { name: "Red".into(), art: Arc::new(art) });
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Red".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance.effects.push(hue());
let l = d.layers[0].id;
n.id = d.alloc_id();
d.insert(Some(l), 0, n).unwrap();
assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]);
}
+192
View File
@@ -0,0 +1,192 @@
//! Preferences › Type › Highlight Alternate Glyphs (#394): OpenType substitutions get a pink
//! cell behind them on screen when the preference is on. Preferences › Type › Highlight
//! Substituted Fonts does the same for text drawn in a substitute for a missing font.
use crate::*;
/// Highlight Substituted Fonts follows the preference passed in [`RenderOptions`] for every
/// document, whatever a document's own setup stored: on, type in a missing font has pink behind
/// it; off, it does not.
#[test]
fn highlight_substituted_fonts_follows_the_preference_in_every_document() {
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
let on = RenderOptions { background: Some([255, 255, 255, 255]), highlight_substitutions: true, ..Default::default() };
assert!(on.highlight_substituted_fonts, "on by default");
let off = RenderOptions { highlight_substituted_fonts: false, ..on.clone() };
let pinkish = |px: &[u8]| px.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
// A document saved with the old Document Setup value on, and one with it off.
for stored in [true, false] {
let mut d = Document::new(200.0, 100.0);
d.setup.highlight_substituted_fonts = stored;
let style = CharStyle { size: 30.0, font_family: "No Such Family".into(), ..Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Hamburg", style))));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let with = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &on);
let without = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &off);
assert!(pinkish(&with.pixels) > 50, "stored {stored}: the preference on highlights the text");
assert_eq!(pinkish(&without.pixels), 0, "stored {stored}: the preference off leaves no highlight");
}
}
#[test]
fn is_opentype_alternate_marks_ligatures_and_forms() {
assert!(is_opentype_alternate(100, 100, 2), "ligature cluster");
assert!(is_opentype_alternate(50, 99, 1), "contextual / stylistic form");
assert!(!is_opentype_alternate(50, 50, 1), "nominal cmap glyph");
assert!(!is_opentype_alternate(50, 0, 2), "missing glyph");
}
#[test]
fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
// Source Serif 4 ships with standard ligatures for "fi".
let mut d = Document::new(200.0, 100.0);
let style = CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 70.0), "fi", style))));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let off = RenderOptions {
background: Some([255, 255, 255, 255]),
highlight_substitutions: true,
highlight_alternate_glyphs: false,
..Default::default()
};
let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() };
let mut r = Renderer::new();
let without = r.render(&d, 200, 100, Affine::IDENTITY, &off);
let with = r.render(&d, 200, 100, Affine::IDENTITY, &on);
assert_ne!(without.pixels, with.pixels, "the preference changes the frame");
// Pink SUBSTITUTED = (255, 120, 190) at alpha 110, blended over white / ink.
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
}
/// The number of glyph boxes Highlight Alternate Glyphs paints for `t`.
fn alternates(t: &vectorcraft_doc::text::TextObject) -> usize {
text_geom(t).alternate_glyphs(t).elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count()
}
/// An installed font that shapes Arabic with OpenType (as `vectorcraft-text`'s bidi tests pick
/// one): Arial or Geeza Pro on macOS and Windows, Noto Sans Arabic on Linux. No font is kept in
/// the repository, so the tests that need one say so and stop without it.
fn arabic_style(test: &str) -> Option<vectorcraft_doc::text::CharStyle> {
let db = vectorcraft_text::FontDb::global();
let face = ["Arial", "Noto Sans Arabic", "Geeza Pro"]
.into_iter()
.filter_map(|name| db.face(name, "Regular"))
.find(|f| ['خ', 'ل', 'ا', 'م', 'ي'].iter().all(|c| f.covers(*c)));
match face {
Some(f) => {
eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", f.family, f.style);
Some(vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() })
}
None => {
eprintln!("SKIPPED: {test}; no installed font covers Arabic");
None
}
}
}
fn text(s: &str, style: vectorcraft_doc::text::CharStyle) -> vectorcraft_doc::text::TextObject {
vectorcraft_doc::text::TextObject::point(vectorcraft_geom::Point::ZERO, s, style)
}
/// The glyphs of `t` by cluster, as the layout shapes them.
fn glyphs(t: &vectorcraft_doc::text::TextObject) -> Vec<(usize, usize, u32)> {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| (g.byte, g.len, g.gid)).collect()
}
/// #394: Arabic letters take their joining forms (`init`, `medi`, `fina`, `isol`) in every word:
/// required shaping, not alternates, so ordinary Arabic text shows no pink.
#[test]
fn arabic_joining_forms_are_not_alternates() {
// Looking up an installed font loads it, which changes the fonts.
let _adding = crate::tests_fontchange::adding_fonts();
let Some(style) = arabic_style("arabic_joining_forms_are_not_alternates") else { return };
let t = text("خلطة مبتكرة من اللومي الحساوي", style);
// The words are shaped: most glyphs are not their letter's cmap glyph.
assert!(!text_geom(&t).alternate_candidates.is_empty(), "the shaper joins the letters");
assert_eq!(alternates(&t), 0);
}
/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either.
#[test]
fn lam_alef_is_not_an_alternate() {
// Looking up an installed font loads it, which changes the fonts.
let _adding = crate::tests_fontchange::adding_fonts();
let Some(style) = arabic_style("lam_alef_is_not_an_alternate") else { return };
for s in ["لا", "سلام", "الاسم"] {
assert_eq!(alternates(&text(s, style.clone())), 0, "{s}");
}
}
/// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or
/// discretionary alternate…) is highlighted on the glyphs it changes, and only on those.
#[test]
fn optional_features_on_arabic_flag_only_the_glyphs_they_change() {
// Looking up an installed font loads it, which changes the fonts.
let _adding = crate::tests_fontchange::adding_fonts();
let db = vectorcraft_text::FontDb::global();
let s = "خلطة مبتكرة من اللومي الحساوي";
// Arabic fonts that may have optional features; the first installed one with a feature that
// changes a glyph of the text.
let fonts = ["Amiri", "El Messiri", "Noto Naskh Arabic", "Scheherazade New", "Arial", "Noto Sans Arabic", "Geeza Pro"];
let tags = ["swsh", "salt", "dlig", "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10"];
let found = fonts.into_iter().filter_map(|name| db.face(name, "Regular")).filter(|f| f.covers('خ') && f.covers('ل')).find_map(|f| {
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: f.family.clone(), font_style: f.style.clone(), ..Default::default() };
let plain = glyphs(&text(s, style.clone()));
tags.into_iter().find_map(|tag| {
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
(changed > 0).then(|| (f.family.clone(), tag, t, changed))
})
});
let Some((family, tag, t, changed)) = found else {
eprintln!(
"SKIPPED: optional_features_on_arabic_flag_only_the_glyphs_they_change; no installed Arabic font has an optional feature for this text"
);
return;
};
eprintln!("ASSERTIONS RUN: optional_features_on_arabic_flag_only_the_glyphs_they_change; {family} with {tag}, {changed} glyphs changed");
assert_eq!(alternates(&t), changed, "{family} {tag}: the glyphs it changed");
}
/// Latin keeps its highlight: the `ffi` of "office" is a standard ligature (`liga`).
#[test]
fn a_latin_standard_ligature_is_still_an_alternate() {
let style = vectorcraft_doc::text::CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
let t = text("office", style.clone());
let shaped = glyphs(&t);
assert!(shaped.iter().any(|(_, len, _)| *len > 1), "Source Serif 4 ligates ffi: {shaped:?}");
assert!(alternates(&t) >= 1);
// Without ligatures nothing is substituted.
assert_eq!(alternates(&text("office", vectorcraft_doc::text::CharStyle { features: vec!["-liga".into()], ..style })), 0);
}
/// #394: in a font the app ships (Source Serif 4), an optional feature turned on for the
/// characters is highlighted on exactly the glyphs it changes, the others staying clear.
#[test]
fn an_optional_feature_flags_only_the_glyphs_it_changes() {
let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: "Source Serif 4".into(), ..Default::default() };
// No ligature in it, so the default features substitute nothing.
let s = "Quay 1234 agy";
assert_eq!(alternates(&text(s, style.clone())), 0);
let plain = glyphs(&text(s, style.clone()));
let tags = ["onum", "smcp", "salt", "swsh", "ss01", "ss02", "ss03", "ss04", "ordn", "sups"];
let found = tags.into_iter().find_map(|tag| {
let t = text(s, vectorcraft_doc::text::CharStyle { features: vec![tag.into()], ..style.clone() });
let changed = glyphs(&t).iter().filter(|g| !plain.contains(g)).count();
(changed > 0 && changed < plain.len()).then_some((tag, t, changed))
});
let (tag, t, changed) = found.expect("Source Serif 4 has an optional feature that changes some of these glyphs");
assert_eq!(alternates(&t), changed, "{tag}: the glyphs it changed");
}
+84
View File
@@ -0,0 +1,84 @@
//! Colour bitmap glyphs (emoji): drawn as their images, which the type's fill doesn't paint.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, colour_bitmap_family, colour_bitmap_font};
use crate::*;
/// 100 pt [`COLOUR_CHAR`] in the colour bitmap test font (`sbix` or `CBDT`), filled blue, its
/// baseline at (20, 120), on a white 160×160 page.
fn render(sbix: bool, opts: RenderOptions) -> Rendered {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle {
font_family: colour_bitmap_family(sbix).into(),
size: 100.0,
fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)),
..Default::default()
};
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st))));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts })
}
#[test]
fn a_bitmap_glyph_draws_its_largest_strike_not_the_fill() {
for sbix in [true, false] {
let img = render(sbix, RenderOptions::default());
// Inside the em square the image covers (the CBDT one from 75 above the baseline to 25
// below, the sbix one from 87.2 above to 12.8 below), away from its edges.
assert_eq!(img.pixel(70, 80), COLOUR_LARGE, "sbix {sbix}");
let blue = (0..160).flat_map(|y| (0..160).map(move |x| (x, y))).any(|(x, y)| img.pixel(x, y)[2] > 200 && img.pixel(x, y)[0] < 50);
assert!(!blue, "the fill doesn't paint the glyph (sbix {sbix})");
}
}
#[test]
fn outline_mode_draws_a_bitmap_glyph_as_its_box() {
let img = render(false, RenderOptions { outline: true, ..Default::default() });
assert_eq!(img.pixel(70, 80), [255, 255, 255, 255], "no pixels inside");
// The box's left edge at x 32.5 (the bearing of 2 px of 6.25 pt), from y 45 to 145.
assert!((31..=34).any(|x| img.pixel(x, 100)[0] < 128), "the box's edge shows");
}
/// 100 pt [`COLOUR_CHAR`] in the `CBDT` test font with `effects`, on a white 160×160 page.
fn render_with(effects: Vec<vectorcraft_doc::Effect>) -> Rendered {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 100.0, ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st))));
let mut appearance = (*n.appearance).clone();
appearance.effects = effects;
n.appearance = appearance.into();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect { id: id.into(), params, visible: true }
}
/// Type with a geometry effect is drawn as its outlines: its emoji go with them, as images that
/// hold their own pixels (here moved 40 pt by a Transform effect).
#[test]
fn bitmap_glyphs_go_with_type_that_a_geometry_effect_reshapes() {
let img = render_with(vec![effect("distort.transform", serde_json::json!({"moveH": 40}))]);
assert_eq!(img.pixel(110, 80), COLOUR_LARGE, "moved with the type");
assert_eq!(img.pixel(50, 80), [255, 255, 255, 255], "not where it was");
}
/// A colour adjustment leaves the emoji of reshaped type in their colours, as it does those of
/// type, and as exports and Expand Appearance draw them.
#[test]
fn a_colour_adjustment_leaves_the_emoji_of_reshaped_type_as_they_are() {
let moved = effect("distort.transform", serde_json::json!({"moveH": 40}));
let img = render_with(vec![moved, effect("adjust.hueSaturation", serde_json::json!({"hue": 120}))]);
assert_eq!(img.pixel(110, 80), COLOUR_LARGE);
}
+109
View File
@@ -0,0 +1,109 @@
//! Blend-mode accuracy: every mode matches the reference formulas ([`blend_rgb`]) within 1/255
//! (see [`tolerance`]), whether it is an object's blend mode, a fill's or a group's.
use crate::*;
use vectorcraft_color::blend::blend_rgb;
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
/// Backdrop / source pairs (8-bit, so the expected values carry no input rounding) that take every
/// branch of the formulas: dark and light backdrops, sources on both sides of ½, greys, extremes.
const PAIRS: [([u8; 3], [u8; 3]); 6] = [
([51, 128, 230], [153, 64, 128]),
([230, 26, 102], [77, 204, 179]),
([0, 255, 128], [255, 0, 127]),
([200, 180, 40], [20, 60, 250]),
([128, 128, 128], [240, 30, 90]),
([10, 90, 160], [128, 128, 128]),
];
fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0))
}
/// Where the blend is set: on the object, on its fill, or on a group around it.
#[derive(Clone, Copy, Debug)]
enum Level {
Object,
Fill,
Group,
}
/// An opaque backdrop with the source on top of it, blended with `mode` at `level`.
fn doc(mode: Blend, level: Level, backdrop: [u8; 3], source: [u8; 3]) -> Document {
let mut d = Document::new(20.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), backdrop);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), source);
match level {
Level::Object => top.blend = mode,
Level::Fill => {
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = top.appearance.items.first_mut() {
f.blend = mode;
}
}
Level::Group => {}
}
let top = match level {
Level::Group => {
let mut g = Node::group(d.alloc_id(), vec![Arc::new(top)]);
g.blend = mode;
g
}
_ => top,
};
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, below).unwrap();
d.insert(Some(l), usize::MAX, top).unwrap();
d
}
pub(crate) fn expected(mode: Blend, backdrop: [u8; 3], source: [u8; 3]) -> [u8; 3] {
let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0);
blend_rgb(mode, f(backdrop), f(source)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// Whether `a` is within `tol`/255 of `b` in every channel.
pub(crate) fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
/// Tolerance of `mode` on screen: 1/255, except Exclusion, which the rasteriser's 8-bit pipeline
/// rounds twice (b + s − 2bs).
fn tolerance(mode: Blend) -> i32 {
if mode == Blend::Exclusion { 2 } else { 1 }
}
#[test]
fn every_mode_matches_the_reference_formulas() {
let mut r = Renderer::new();
for mode in Blend::ALL {
for level in [Level::Object, Level::Fill, Level::Group] {
for (b, s) in PAIRS {
let px = r.render(&doc(mode, level, b, s), 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
let want = expected(mode, b, s);
assert!(
within([px[0], px[1], px[2]], want, tolerance(mode)) && px[3] == 255,
"{mode:?} at {level:?}, {b:?} under {s:?}: {px:?}, want {want:?}"
);
}
}
}
}
#[test]
fn a_translucent_source_mixes_the_blend_by_its_opacity() {
let mut r = Renderer::new();
for mode in Blend::ALL {
let (b, s) = PAIRS[0];
let mut d = doc(mode, Level::Object, b, s);
let top = d.layers[0].children().unwrap()[1].id;
d.node_mut(top).unwrap().opacity = 0.6;
let px = r.render(&d, 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
let f = |c: [u8; 3], a: f32| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, a];
let o = vectorcraft_color::blend::composite(mode, f(b, 1.0), f(s, 0.6));
let want = [o[0], o[1], o[2]].map(|v| (v * 255.0).round() as u8);
// The opacity is one more 8-bit step.
assert!(within([px[0], px[1], px[2]], want, tolerance(mode) + 1), "{mode:?}: {px:?}, want {want:?}");
}
}
+260
View File
@@ -0,0 +1,260 @@
//! Drawing again only what an edit changed gives the pixels of drawing it all again.
use crate::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Point, shapes};
const W: u32 = 160;
const H: u32 = 120;
/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased.
fn view() -> Affine {
Affine::translate((3.3, 2.7)) * Affine::scale(1.37)
}
fn rect(d: &mut Document, r: Rect, fill: Color) -> Node {
let id = d.alloc_id();
Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0))
}
fn shadow() -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}),
visible: true,
}
}
/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a
/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one.
fn document() -> (Document, [NodeId; 5]) {
let mut d = Document::new(110.0, 80.0);
let l = d.layers[0].id;
let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0));
let loose = d.insert(Some(l), 0, loose).unwrap();
let (a, b) = (
rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)),
rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)),
);
let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false });
group.opacity = 0.5;
let member = group.children().unwrap()[1].id;
d.insert(Some(l), 1, group).unwrap();
let (mask, art) =
(rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0)));
let clipped = art.id;
let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true });
d.insert(Some(l), 2, clip).unwrap();
let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2));
shadowed.appearance.effects.push(shadow());
let shadowed = d.insert(Some(l), 3, shadowed).unwrap();
let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5));
let other = d.insert(Some(l), 4, other).unwrap();
(d, [loose, member, clipped, shadowed, other])
}
/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that
/// this is `new` drawn whole; returns what changed.
fn redraw(old: &Document, new: &Document) -> Changed {
let mut r = Renderer::new();
let opts = RenderOptions::default();
let before = r.render(old, W, H, view(), &opts);
let after = r.render(new, W, H, view(), &opts);
let change = changed(old, new);
let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() };
match change {
Changed::Everything => patched.pixels = after.pixels.clone(),
Changed::Nothing => {}
Changed::Area(area) => {
if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) {
let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts);
for y in 0..y1 - y0 {
let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize);
let n = ((x1 - x0) * 4) as usize;
patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]);
}
}
}
}
assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all");
change
}
fn area(c: Changed) -> Rect {
match c {
Changed::Area(a) => a,
other => panic!("an area: {other:?}"),
}
}
#[test]
fn moving_an_object_changes_where_it_was_and_is() {
let (old, [loose, ..]) = document();
let mut new = old.clone();
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
let a = area(redraw(&old, &new));
assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}");
assert!(a.x1 < 35.0, "not the objects beside it: {a:?}");
}
#[test]
fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() {
let (old, [_, member, ..]) = document();
let mut new = old.clone();
new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0));
let a = area(redraw(&old, &new));
assert!(a.x0 > 35.0, "not the group's other member: {a:?}");
}
#[test]
fn moving_clipped_art_changes_its_whole_clip_group() {
let (old, [_, _, clipped, ..]) = document();
let mut new = old.clone();
new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false);
redraw(&old, &new);
}
#[test]
fn moving_a_shadowed_object_changes_where_its_shadow_falls() {
let (old, [.., shadowed, _]) = document();
let mut new = old.clone();
new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false);
let a = area(redraw(&old, &new));
assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}");
}
#[test]
fn adding_removing_and_restacking_change_those_objects() {
let (old, [loose, _, _, _, other]) = document();
let mut added = old.clone();
let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK);
let l = added.layers[0].id;
added.insert(Some(l), 5, n).unwrap();
redraw(&old, &added);
let mut removed = old.clone();
removed.remove(loose).unwrap();
redraw(&old, &removed);
let mut restacked = old.clone();
let n = restacked.remove(other).unwrap();
restacked.insert(Some(l), 0, (*n).clone()).unwrap();
redraw(&old, &restacked);
}
/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn
/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the
/// drag's lower resolution.
#[test]
fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() {
let (mut d, _) = document();
let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9));
back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, back).unwrap();
let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0));
let mut r = Renderer::new();
let whole = r.render(&d, W, H, view, &RenderOptions::default());
let [x0, y0, x1, y1] = [40, 30, 100, 80];
let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() };
let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging);
assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster");
for y in 0..y1 - y0 {
for x in 0..x1 - x0 {
assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}");
}
}
}
/// Type on a path whose path a script reshapes: its letters stand above the path and run past
/// where it ends, and they change where they were and where they are, outside the path's box.
#[test]
fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() {
let mut old = Document::new(110.0, 80.0);
let l = old.layers[0].id;
let on = |y: f64| {
let mut t = vectorcraft_doc::text::TextObject::point(
Point::ZERO,
"Text along a path",
vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() },
);
let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1);
t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
// The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]).
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
t
};
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap();
let mut new = old.clone();
if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) {
**t = on(25.0);
}
redraw(&old, &new);
}
/// Stroked type with sharp corners: the miter spikes of its characters' strokes reach past half
/// the weight, and change where they were and where they are.
#[test]
fn moving_type_with_mitred_strokes_changes_where_its_spikes_reach() {
let mut old = Document::new(110.0, 80.0);
let l = old.layers[0].id;
let style = vectorcraft_doc::text::CharStyle {
size: 30.0,
stroke: Paint::solid(Color::BLACK),
stroke_width: 10.0,
stroke_join: vectorcraft_doc::LineJoin::Miter,
stroke_miter_limit: 10.0,
..Default::default()
};
let mut t = vectorcraft_doc::text::TextObject::point(Point::new(10.0, 50.0), "VAWV", style);
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap();
let mut new = old.clone();
new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false);
redraw(&old, &new);
}
#[test]
fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() {
let (old, _) = document();
assert_eq!(redraw(&old, &old.clone()), Changed::Nothing);
let mut new = old.clone();
let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK));
new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art });
assert_eq!(redraw(&old, &new), Changed::Everything);
}
/// While a pattern is edited its art also draws in the copies round it: an edit of it changes
/// everything.
#[test]
fn an_edit_while_a_pattern_is_edited_changes_everything() {
let (mut old, [loose, ..]) = document();
let l = old.layers[0].id;
old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None });
let mut new = old.clone();
new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false);
assert_eq!(changed(&old, &new), Changed::Everything);
}
/// Type stretched more on one axis than its area grows: its characters' strokes stretch with it.
#[test]
fn moving_stretched_type_changes_where_its_stretched_strokes_reach() {
let mut old = Document::new(110.0, 80.0);
let l = old.layers[0].id;
let style = vectorcraft_doc::text::CharStyle {
size: 4.0,
stroke: Paint::solid(Color::BLACK),
stroke_width: 1.0,
stroke_join: vectorcraft_doc::LineJoin::Miter,
stroke_miter_limit: 10.0,
..Default::default()
};
let mut t = vectorcraft_doc::text::TextObject::point(Point::ZERO, "VAWV", style);
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
// Its area stays, so its scale factor is 1, while it grows ten times as wide.
t.xf = Affine::translate((10.0, 40.0)) * Affine::scale_non_uniform(10.0, 0.1);
let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap();
let mut new = old.clone();
new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false);
area(redraw(&old, &new));
}
+189
View File
@@ -0,0 +1,189 @@
//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type
//! take every stroke option, as path strokes do.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind};
use vectorcraft_geom::Point;
use crate::*;
use crate::tests_fontchange::fonts_steady;
const SIZE: u32 = 200;
/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page.
fn render_text(text: &str, style: CharStyle, items: Vec<AppearanceItem>) -> Rendered {
let mut d = Document::new(SIZE as f64, SIZE as f64);
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style }))));
n.appearance.items = items;
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn dark(img: &Rendered, x: u32, y: u32) -> bool {
let p = img.pixel(x, y);
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 384
}
/// The box of the dark pixels: (x0, y0, x1, y1).
fn ink(img: &Rendered) -> (u32, u32, u32, u32) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
for y in 0..SIZE {
for x in 0..SIZE {
if dark(img, x, y) {
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
}
}
}
b
}
fn stroked(join: LineJoin) -> CharStyle {
CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() }
}
#[test]
fn round_joined_character_strokes_have_no_miter_spikes() {
// The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it,
// the default miter join spikes out further at the sharp corners of the A.
let glyph = ink(&render_text("A", CharStyle::default(), vec![]));
let round = ink(&render_text("A", stroked(LineJoin::Round), vec![]));
let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![]));
let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3);
assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}");
assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}");
}
#[test]
fn character_and_object_strokes_on_type_are_dashed() {
// An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge.
let none = CharStyle { fill: Paint::None, ..Default::default() };
let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() });
let transitions = |img: &Rendered| {
let (x0, y0, _, y1) = ink(img);
let col = x0 + 1;
(y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count()
};
let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() };
assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous");
let chars = CharStyle { stroke_dash: dash.clone(), ..solid };
assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes");
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.dash = dash;
let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]);
assert!(transitions(&object) >= 6, "object dashes");
// And its opacity.
st.opacity = 0.5;
st.dash = None;
let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]);
let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![]));
let p = half.pixel(x0, (y0 + y1) / 2);
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
}
/// Align Stroke on live type: an outside character stroke paints its whole weight around the
/// glyphs and leaves them unpainted, an inside one stays within them, as on paths.
#[test]
fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() {
use vectorcraft_doc::StrokeAlign;
let glyph = ink(&render_text("I", CharStyle::default(), vec![]));
let reach = |b: (u32, u32, u32, u32)| {
let d = |a: u32, b: u32| a as i64 - b as i64;
d(glyph.0, b.0).max(d(glyph.1, b.1)).max(d(b.2, glyph.2)).max(d(b.3, glyph.3))
};
let style = |align| CharStyle { fill: Paint::None, stroke_align: align, ..stroked(LineJoin::Round) };
let centre = render_text("I", style(StrokeAlign::Center), vec![]);
let outside = render_text("I", style(StrokeAlign::Outside), vec![]);
let inside = render_text("I", style(StrokeAlign::Inside), vec![]);
assert!((7..=9).contains(&reach(ink(&centre))), "centred: half the weight past the glyph, {:?}", ink(&centre));
assert!((15..=17).contains(&reach(ink(&outside))), "outside: the whole weight past the glyph, {:?}", ink(&outside));
assert!(reach(ink(&inside)) <= 0, "inside: within the glyph, {:?}", ink(&inside));
let mid = ((glyph.0 + glyph.2) / 2, (glyph.1 + glyph.3) / 2);
assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted");
assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph");
}
/// A document with 60 pt `text` without a fill and with a 1 pt character stroke, and the type's id.
fn stroked_text_doc(text: &str) -> (Document, NodeId) {
let mut d = Document::new(SIZE as f64, SIZE as f64);
let style = CharStyle { size: 60.0, fill: Paint::None, stroke: Paint::solid(Color::BLACK), stroke_width: 1.0, ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), text, style))));
n.id = d.alloc_id();
let id = n.id;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
(d, id)
}
/// The character stroke outlines that `r` keeps and that its last frame made or used.
fn character_outlines(r: &Renderer) -> Vec<Arc<BezPath>> {
r.char_strokes.used_in(r.stamp).map(|(_, outline)| outline.clone()).collect()
}
/// The outlines of type that changes in every frame (an animation) go within a few frames: the
/// renderer keeps no more than the last few frames' strokes, however long it runs.
#[test]
fn the_character_strokes_of_type_that_changes_in_every_frame_go() {
let _fonts = fonts_steady();
let (mut d, id) = stroked_text_doc("Type");
let opts = RenderOptions::default();
let mut r = Renderer::new();
for i in 0..40 {
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
t.xf = Affine::translate((f64::from(i), 0.0));
}
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
}
assert!(r.char_strokes.len() <= 4, "{} outlines kept", r.char_strokes.len());
}
/// A character stroke is expanded once: the next frame of the same view draws the outline the
/// first made. A change of the zoom level or of the text expands it again.
#[test]
fn character_stroke_outlines_are_kept_between_frames() {
let _fonts = fonts_steady();
let (mut d, id) = stroked_text_doc("Type");
let opts = RenderOptions::default();
let mut r = Renderer::new();
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let first = character_outlines(&r);
assert_eq!(first.len(), 1, "the outline of the one run's stroke");
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let again = character_outlines(&r);
assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &first[0]), "the second frame draws the outline the first made");
r.render(&d, SIZE, SIZE, Affine::translate((-40.0, -300.0)) * Affine::scale(4.0), &opts);
let zoomed = character_outlines(&r);
assert!(zoomed.len() == 1 && !Arc::ptr_eq(&zoomed[0], &first[0]), "zoomed in 4×, it is expanded again");
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
t.runs[0].text = "Typo".into();
}
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let changed = character_outlines(&r);
assert!(changed.len() == 1 && !Arc::ptr_eq(&changed[0], &first[0]), "the changed text is expanded again");
}
/// Frames that draw character strokes from the cache draw the same pixels, and what a renderer
/// draws whose glyph cache has no room, so that it expands them in every frame: exactly at 100%,
/// where both take a tolerance of 1/4 device pixel, and within antialiasing at 105% and 150%,
/// where the cache takes 1/8 device pixel and the frames without it about 1/4 and 1/6.
#[test]
fn cached_character_strokes_draw_what_uncached_ones_draw() {
let _fonts = fonts_steady();
let (d, _) = stroked_text_doc("Type");
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
for (zoom, most) in [(1.0, 0), (1.05, 24), (1.5, 24)] {
let view = Affine::scale(zoom);
let mut r = Renderer::new();
let first = r.render(&d, SIZE, SIZE, view, &opts).pixels;
assert!(r.render(&d, SIZE, SIZE, view, &opts).pixels == first, "{zoom}×: the cached frames match");
let mut uncached = Renderer::new();
uncached.texts = FrameCache::new(0, 0);
let plain = uncached.render(&d, SIZE, SIZE, view, &opts).pixels;
assert!(character_outlines(&uncached).is_empty());
let diff = first.iter().zip(&plain).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(diff <= most, "{zoom}×: cached and uncached frames differ by {diff}");
assert!(plain.iter().any(|&c| c < 128), "{zoom}×: the strokes show");
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Clip groups clip by any clipping path (M3.26): compound holes, even-odd fills, the union of a
//! group's members, text outlines; with nothing to clip by the clipped art is hidden.
use crate::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle};
use vectorcraft_geom::{PathData, Point, SubPath, shapes};
const BLACK: [u8; 4] = [0, 0, 0, 255];
const CLEAR: [u8; 4] = [0, 0, 0, 0];
fn path(d: &mut Document, p: PathData, rule: FillRule) -> Arc<Node> {
let mut n = Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = rule;
}
Arc::new(n)
}
fn rect(r: Rect) -> PathData {
shapes::rectangle(r)
}
/// `clip` clipping a black square over the whole 100×100 canvas, rendered transparent.
fn clipped(mut d: Document, clip: Arc<Node>) -> Rendered {
let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default())
}
#[test]
fn compound_clip_keeps_its_hole() {
let mut d = Document::new(100.0, 100.0);
let (outer, inner) = (rect(Rect::new(10.0, 10.0, 90.0, 90.0)), rect(Rect::new(35.0, 35.0, 65.0, 65.0)));
let children = vec![path(&mut d, outer, FillRule::NonZero), path(&mut d, inner, FillRule::NonZero)];
let donut = Arc::new(Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd }));
let r = clipped(d, donut);
assert_eq!(r.pixel(20, 50), BLACK, "the ring");
assert_eq!(r.pixel(50, 50), CLEAR, "the hole stays transparent");
assert_eq!(r.pixel(5, 5), CLEAR);
}
#[test]
fn even_odd_star_clips_with_an_empty_centre() {
let mut d = Document::new(100.0, 100.0);
// A pentagram drawn in one stroke: its centre pentagon is outside under even-odd.
let pts: Vec<Point> = (0..5)
.map(|k| {
let a = (k * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2;
Point::new(50.0 + 48.0 * a.cos(), 52.0 + 48.0 * a.sin())
})
.collect();
let star = path(&mut d, PathData::single(SubPath::polyline(&pts, true)), FillRule::EvenOdd);
let r = clipped(d, star);
assert_eq!(r.pixel(50, 52), CLEAR, "the centre is empty");
assert_eq!(r.pixel(50, 12), BLACK, "a point");
}
#[test]
fn group_clip_is_the_union_of_its_members() {
let mut d = Document::new(100.0, 100.0);
// Opposite windings: a plain concatenation would cancel out where they overlap.
let mut b = rect(Rect::new(40.0, 0.0, 100.0, 50.0));
b.reverse();
let members = vec![path(&mut d, rect(Rect::new(0.0, 0.0, 60.0, 50.0)), FillRule::NonZero), path(&mut d, b, FillRule::NonZero)];
let g = Arc::new(Node::group(d.alloc_id(), members));
let r = clipped(d, g);
for x in [20, 50, 80] {
assert_eq!(r.pixel(x, 25), BLACK, "x = {x}");
}
assert_eq!(r.pixel(50, 75), CLEAR);
}
#[test]
fn text_clips_by_its_glyph_outlines() {
let mut d = Document::new(100.0, 100.0);
let t = vectorcraft_doc::TextObject::point(Point::new(5.0, 80.0), "O", CharStyle { size: 90.0, ..CharStyle::default() });
let text = Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))));
let b = text.geometric_bounds().unwrap();
let r = clipped(d, text);
let cy = ((b.y0 + b.y1) / 2.0) as u32;
// Across the middle of the "O": clear, ring, the counter (clear), ring, clear.
let row: Vec<bool> = (0..100).map(|x| r.pixel(x, cy)[3] > 128).collect();
let runs = row.windows(2).filter(|w| w[0] != w[1]).count();
assert_eq!(runs, 4, "two ring crossings with the counter between: {row:?}");
}
#[test]
fn nothing_to_clip_by_hides_the_art() {
let mut d = Document::new(100.0, 100.0);
let empty = Arc::new(Node::group(d.alloc_id(), vec![]));
let r = clipped(d, empty);
assert_eq!(r.pixel(50, 50), CLEAR);
}
#[test]
fn clip_regions_are_cached_per_clipping_path() {
let mut d = Document::new(100.0, 100.0);
let clip = path(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 100.0)), FillRule::NonZero);
let mut r = Renderer::new();
let first = r.clip_of(&clip).unwrap();
assert!(Arc::ptr_eq(&first, &r.clip_of(&clip).unwrap()));
assert_eq!(first.1, FillRule::NonZero);
}
+283
View File
@@ -0,0 +1,283 @@
//! Blending in CMYK documents ([`crate::ink`]): blend modes and opacity composite inks.
use crate::*;
use vectorcraft_color::blend::MASK_LUM;
use vectorcraft_color::{BlendMode as Blend, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, ColorMode, ImageBlob, ImageObject, Node, OpacityMask};
use vectorcraft_geom::shapes;
fn rect(d: &mut Document, r: Rect, c: Color) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
}
fn add(d: &mut Document, n: Node) {
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
/// `below` over the whole 20 pt page and `top` (10 pt in the middle) with `mode` and `opacity`.
fn doc(mode: ColorMode, below: Option<Color>, top: Color, blend: Blend, opacity: f32) -> Document {
let mut d = Document::new_with_mode(20.0, 20.0, mode);
if let Some(b) = below {
let n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b);
add(&mut d, n);
}
let mut n = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), top);
(n.blend, n.opacity) = (blend, opacity);
add(&mut d, n);
d
}
/// The centre pixel, rendered on white.
fn centre(d: &Document) -> [u8; 4] {
Renderer::new().render_region(d, d.artboards[0].rect, 1.0, true).pixel(10, 10)
}
/// Screen colour of inks `cmyk`.
fn shown(cmyk: [f32; 4]) -> [u8; 4] {
Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0)
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 };
#[test]
fn cyan_multiplied_over_magenta_prints_both_inks() {
let got = centre(&doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Multiply, 1.0));
let want = shown([1.0, 1.0, 0.0, 0.0]);
assert!(near(got, want, 1), "{got:?} ≠ C100 M100 {want:?}");
// Multiplying the screen colours instead gives a visibly different blue.
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
let screen = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as i32);
assert!((0..3).any(|i| (screen[i] - want[i] as i32).abs() > 10), "{screen:?} vs {want:?}");
}
#[test]
fn opacity_mixes_inks() {
let got = centre(&doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5));
assert!(near(got, shown([0.5, 0.0, 0.0, 0.0]), 2), "{got:?}");
}
#[test]
fn black_blends_on_its_own_plane() {
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
// Multiply adds black ink: K50 over K50 leaves 25% of the paper.
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), k(0.5), Blend::Multiply, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.75]), 2), "{got:?}");
// Luminosity takes the source's black, Color keeps the backdrop's.
let got = centre(&doc(ColorMode::Cmyk, Some(CYAN), k(0.6), Blend::Luminosity, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.6]), 3), "{got:?}");
// (Over a backdrop of black ink alone the C, M, Y plane is paper: its luminance takes the
// source's colour out.)
let got = centre(&doc(ColorMode::Cmyk, Some(k(0.5)), CYAN, Blend::Color, 1.0));
assert!(near(got, shown([0.0, 0.0, 0.0, 0.5]), 2), "{got:?}");
}
#[test]
fn rgb_documents_and_opaque_cmyk_documents_keep_screen_colours() {
let rgb = |c: &Color| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
};
// RGB documents multiply screen colours.
let got = centre(&doc(ColorMode::Rgb, Some(rgb(&MAGENTA)), rgb(&CYAN), Blend::Multiply, 1.0));
let (c, m) = (CYAN.to_rgb(), MAGENTA.to_rgb());
let want = [0, 1, 2].map(|i| (c[i] * m[i] * 255.0).round() as u8);
assert!(near(got, [want[0], want[1], want[2], 255], 1), "{got:?} ≠ {want:?}");
// A CMYK document without transparency paints each colour as it is.
let d = doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Normal, 1.0);
assert!(!Renderer::new().blends_in_cmyk(&d, &RenderOptions::default()));
assert_eq!(centre(&d), CYAN.to_rgba8(1.0));
}
/// Whether a document shows transparency is found once for its layers, symbol art and pattern
/// art, and kept while the document holds the same art: an edit, or a new symbol, finds it again.
#[test]
fn the_transparency_test_is_kept_while_the_art_stays_the_same() {
let frame = |r: &mut Renderer, d: &Document| r.render_region(d, d.artboards[0].rect, 1.0, true).pixels;
let kept = |r: &Renderer| r.transparency.as_ref().map(|(art, shows)| (art.as_ptr(), *shows));
// The ink planes show a Gray fill lighter than screen colors do, so a frame drawn the other
// way differs.
let d = doc(ColorMode::Cmyk, Some(Color::gray(0.9)), CYAN, Blend::Normal, 0.5);
let mut r = Renderer::new();
let first = frame(&mut r, &d);
let found = kept(&r);
assert_eq!(found.map(|(_, shows)| shows), Some(true));
assert!(frame(&mut r, &d) == first);
assert_eq!(kept(&r), found, "the same art was tested again");
// The top object made opaque: the frame paints screen colors.
let mut e = d.clone();
let top = e.layers[0].children().unwrap()[1].id;
e.node_mut(top).unwrap().opacity = 1.0;
assert!(frame(&mut r, &e) == frame(&mut Renderer::new(), &e));
assert_eq!(kept(&r).map(|(_, shows)| shows), Some(false));
// A symbol whose art shows transparency, placed nowhere, makes the page draw by ink planes again.
let mut art = rect(&mut e, Rect::new(0.0, 0.0, 5.0, 5.0), CYAN);
art.opacity = 0.5;
e.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
assert!(frame(&mut r, &e) == frame(&mut Renderer::new(), &e));
assert_eq!(kept(&r).map(|(_, shows)| shows), Some(true));
}
/// A 200 × 150 pt CMYK page drawn by ink planes: a Gray background, translucent ellipses in each
/// blend mode, an object with an opacity mask, one with a drop shadow, a gradient with a
/// translucent stop, an overprinting fill and objects across the page's edges.
fn busy_page() -> Document {
let mut d = Document::new_with_mode(200.0, 150.0, ColorMode::Cmyk);
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 150.0), Color::gray(0.3));
add(&mut d, back);
for i in 0..240 {
let (x, y) = ((i * 37 % 200) as f64, (i * 53 % 150) as f64);
let c = Color::cmyk((i % 5) as f32 / 4.0, (i % 7) as f32 / 6.0, (i % 3) as f32 / 2.0, (i % 4) as f32 / 8.0);
let mut n = Node::path(
d.alloc_id(),
shapes::ellipse(Rect::new(x - 9.0, y - 6.0, x + 9.0, y + 6.0)),
Appearance::basic(Paint::solid(c), Paint::solid(MAGENTA), 0.6),
);
(n.blend, n.opacity) = (Blend::ALL[i % Blend::ALL.len()], 0.4 + (i % 6) as f32 / 10.0);
add(&mut d, n);
}
let mut masked = rect(&mut d, Rect::new(20.0, 20.0, 90.0, 70.0), CYAN);
let mask = Node::path(
d.alloc_id(),
shapes::ellipse(Rect::new(30.0, 25.0, 80.0, 65.0)),
Appearance::basic(Paint::solid(Color::gray(0.4)), Paint::None, 0.0),
);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true)));
add(&mut d, masked);
let mut shadowed = rect(&mut d, Rect::new(110.0, 20.0, 170.0, 60.0), Color::cmyk(0.0, 0.2, 0.9, 0.0));
let shadow = serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0, "mode": "multiply"});
shadowed.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
add(&mut d, shadowed);
let stops = vec![GradientStop::new(0.0, CYAN), GradientStop { opacity: 0.3, ..GradientStop::new(1.0, MAGENTA) }];
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 90.0, 120.0, 130.0)), Appearance::basic(ramp, Paint::None, 0.0));
add(&mut d, n);
let mut over = rect(&mut d, Rect::new(100.0, 80.0, 160.0, 120.0), Color::cmyk(0.0, 0.0, 1.0, 0.0));
if let Some(AppearanceItem::Fill(f)) = over.appearance.items.first_mut() {
f.overprint = true;
}
add(&mut d, over);
for r in [Rect::new(-15.0, -10.0, 25.0, 20.0), Rect::new(180.0, 130.0, 230.0, 170.0), Rect::new(-30.0, 60.0, 260.0, 75.0)] {
let mut n = rect(&mut d, r, Color::cmyk(0.7, 0.0, 0.3, 0.1));
n.opacity = 0.7;
add(&mut d, n);
}
d
}
/// The renderer's threads each compose a band of a frame's two ink planes, and the frame has the
/// pixels that one thread composes, with and without a background.
#[test]
fn ink_planes_compose_on_the_renderer_threads() {
let d = busy_page();
let canvas = RenderOptions { background: Some([224, 224, 224, 255]), artboards: true, overprint_preview: true, ..Default::default() };
for opts in [RenderOptions::default(), canvas] {
let frame = |threads: u16| {
let mut r = Renderer::new();
r.threads = threads;
let pixels = r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels;
(pixels, r.stats.ink_threads)
};
let (one, threads) = frame(0);
assert_eq!(threads, 1);
for n in [3, 8] {
let (pixels, threads) = frame(n);
assert_eq!(threads, usize::from(n), "composed on {threads} threads, not {n}");
assert!(pixels == one, "{n} threads changed pixels");
}
}
}
#[test]
fn opacity_masks_take_screen_luminance() {
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
let mut n = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), CYAN);
n.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true)));
add(&mut d, n);
let [r, g, b] = CYAN.to_rgb();
let cover = MASK_LUM[0] * r + MASK_LUM[1] * g + MASK_LUM[2] * b;
let got = centre(&d);
assert!(near(got, shown([0.0, 0.0, 0.0, cover]), 3), "{got:?} ≠ K{cover}");
}
#[test]
fn placed_images_are_separated_not_blackened() {
let mut d = doc(ColorMode::Cmyk, None, CYAN, Blend::Normal, 0.5);
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let n = Node::new(
d.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key: "red".into(),
width: 4,
height: 4,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
})),
);
add(&mut d, n);
let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1);
let cms = vectorcraft_color::cms::active();
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
}
/// Appearance of Black's Rich Black (`rich_black`, #394): pure CMYK black (black ink alone at
/// 100%) shows as RGB black, in an opaque CMYK document (screen colours) and in one drawn by ink
/// planes (Multiply); a rich black, a black tint and placed images show as they do without it.
#[test]
fn rich_black_shows_black_ink_alone_as_rgb_black() {
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
let centre_with = |d: &Document, rich_black| {
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, rich_black, ..Default::default() };
Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)
};
for blend in [Blend::Normal, Blend::Multiply] {
let black = doc(ColorMode::Cmyk, None, k(1.0), blend, 1.0);
let accurate = centre_with(&black, false);
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0]), 1) && accurate[0] > 10, "{blend:?}: accurate {accurate:?}");
assert_eq!(centre_with(&black, true), [0, 0, 0, 255], "{blend:?}: rich");
for c in [Color::cmyk(0.6, 0.4, 0.4, 1.0), k(0.5)] {
let d = doc(ColorMode::Cmyk, None, c, blend, 1.0);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "{blend:?}: {c:?}");
}
}
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
add(&mut d, n);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
}
/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint
/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour.
#[test]
fn overprint_of_the_same_colour_shows_no_darker_band() {
for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] {
let mut d = Document::new_with_mode(40.0, 40.0, mode);
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0));
for it in &mut n.appearance.items {
*it.overprint_mut() = true;
}
add(&mut d, n);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() };
let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts);
// (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill.
assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20));
}
}
+123
View File
@@ -0,0 +1,123 @@
//! COLR colour glyphs: drawn as their layers (or, for paints documents have no form for, their
//! image), which the type's fill doesn't paint, except in the layers in the foreground colour.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_family, colr_font};
use crate::*;
/// 100 pt `text` in the COLR test font (v1 or v0), filled magenta, its baseline at (20, 120)
/// turned by `turn` about there, on a white 160×160 page.
fn render(v1: bool, text: char, turn: f64, opts: RenderOptions) -> Rendered {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle { font_family: colr_family(v1).into(), size: 100.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 1.0)), ..Default::default() };
let mut t = TextObject::point(Point::new(20.0, 120.0), &text.to_string(), st);
t.xf = Affine::rotate_about(turn, (20.0, 120.0)) * t.xf;
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts })
}
/// The box of the pixels `like` says yes to.
fn ink(img: &Rendered, like: impl Fn([u8; 4]) -> bool) -> Option<(u32, u32, u32, u32)> {
let mut b: Option<(u32, u32, u32, u32)> = None;
for y in 0..img.height {
for x in 0..img.width {
if like(img.pixel(x, y)) {
b = Some(b.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y))));
}
}
}
b
}
fn magenta(p: [u8; 4]) -> bool {
p[0] > 200 && p[2] > 200 && p[1] < 60
}
#[test]
fn colr_layers_draw_their_colours_not_the_fill() {
let img = render(true, COLOUR_CHAR, 0.0, RenderOptions::default());
// The `O` with its linear gradient red → blue from x 0 to 500 units (20 to 70 pt).
let reddish = ink(&img, |p| p[0] > 180 && p[2] < 90 && p[1] < 60).expect("red at the gradient's start");
let bluish = ink(&img, |p| p[2] > 180 && p[0] < 90 && p[1] < 60).expect("blue at its end");
assert!(reddish.0 < bluish.0, "red left of blue: {reddish:?} {bluish:?}");
assert!(ink(&img, magenta).is_none(), "the fill doesn't paint the glyph");
}
#[test]
fn the_foreground_layer_takes_the_fill() {
let img = render(false, COLOUR_CHAR, 0.0, RenderOptions::default());
assert!(ink(&img, magenta).is_some(), "the `I` layer is in the foreground colour");
assert!(ink(&img, |p| p == [255, 0, 0, 255]).is_some(), "the `O` layer in red");
}
#[test]
fn turned_type_turns_its_colour_layers() {
let size = |turn| {
let (x0, y0, x1, y1) = ink(&render(false, COLOUR_CHAR, turn, RenderOptions::default()), magenta).unwrap();
(x1 - x0, y1 - y0)
};
let (w, h) = size(0.0);
assert!(h > w * 3, "the `I` stands: {w}×{h}");
let (w, h) = size(std::f64::consts::FRAC_PI_2);
assert!(w > h * 3, "turned, it lies: {w}×{h}");
}
#[test]
fn outline_mode_shows_the_layers_outlines() {
let filled = render(false, COLOUR_CHAR, 0.0, RenderOptions::default());
let outlined = render(false, COLOUR_CHAR, 0.0, RenderOptions { outline: true, ..Default::default() });
let (x0, y0, x1, y1) = ink(&filled, |p| p == [255, 0, 0, 255]).unwrap();
let dark = |p: [u8; 4]| p[0] < 128 && p[1] < 128 && p[2] < 128;
assert!(ink(&outlined, |p| p == [255, 0, 0, 255]).is_none(), "no colours in outline mode");
let (ox0, oy0, ox1, oy1) = ink(&outlined, dark).expect("the outlines show");
assert!(ox0 <= x0 + 2 && oy0 <= y0 + 2 && ox1 + 2 >= x1 && oy1 + 2 >= y1, "around the layers: {:?} {:?}", (ox0, oy0, ox1, oy1), (x0, y0, x1, y1));
}
#[test]
fn a_sweep_glyph_draws_its_image() {
let img = render(true, COLR_SWEEP_CHAR, 0.0, RenderOptions::default());
assert!(ink(&img, |p| p[0] > 180 && p[1] < 60 && p[2] < 120).is_some(), "red where the sweep starts");
assert!(ink(&img, magenta).is_none());
}
/// Type with a geometry effect is drawn reshaped as its outlines: its colour glyphs go with them,
/// as their art (here moved 40 pt by a Transform effect).
#[test]
fn colour_glyphs_go_with_type_that_a_geometry_effect_reshapes() {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st))));
let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true };
let mut appearance = (*n.appearance).clone();
appearance.effects.push(moved);
n.appearance = appearance.into();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let img = Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let red = ink(&img, |p| p == [255, 0, 0, 255]).expect("the `O` layer, in red");
assert!(red.0 >= 58, "moved right with the type: {red:?}");
}
/// Type's outlines stack as the canvas paints type: a run's colour glyphs under its fill, so a
/// plain character set over a colour one stays on top.
#[test]
fn outlined_colour_glyphs_stay_under_their_runs_fill() {
let _adding = crate::tests_fontchange::adding_fonts();
vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap());
let mut d = Document::new(160.0, 160.0);
let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &format!("{COLOUR_CHAR}F"), st))));
let outlined = vectorcraft_effects::outline_text(&n).unwrap();
let kinds: Vec<bool> = outlined.children().unwrap().iter().map(|c| matches!(c.kind, NodeKind::Group { .. })).collect();
assert_eq!(kinds, [true, false], "the colour glyph's group, then the run's path");
}
+358
View File
@@ -0,0 +1,358 @@
//! The appearance of groups, layers and type: a container's own fills and strokes paint its
//! members' geometry below or above them (the contents slot), its effects apply to the composite
//! (one combined shadow) and reshape its members as one piece; type's own fills and strokes paint
//! under or over its characters, reshaped by their geometry effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Knockout, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{Point, shapes};
use crate::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn solid(r: f32, g: f32, b: f32) -> Paint {
Paint::solid(Color::rgb(r, g, b))
}
/// A rectangle filled with `paint` and no stroke.
fn rect(d: &mut Document, r: Rect, paint: Paint) -> Arc<Node> {
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0)))
}
/// A 100×100 document holding a group of a red square (10..40) and a blue one (50..80), on a row
/// from y 10 to 40, given its own appearance by `style`.
fn group_doc(style: impl FnOnce(&mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), solid(1.0, 0.0, 0.0));
let b = rect(&mut d, Rect::new(50.0, 10.0, 80.0, 40.0), solid(0.0, 0.0, 1.0));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
style(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
}
fn render_threads(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn render(d: &Document) -> Rendered {
render_threads(d, 0)
}
const GREEN: [u8; 4] = [0, 255, 0, 255];
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
const WHITE: [u8; 4] = [255, 255, 255, 255];
fn green_fill(g: &mut Node) {
g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
}
#[test]
fn a_group_fill_paints_every_member_above_or_below_them() {
// Above the contents (the default): both members turn green; the gap between them stays white.
let img = render(&group_doc(green_fill));
assert_eq!((img.pixel(25, 25), img.pixel(65, 25), img.pixel(45, 25)), (GREEN, GREEN, WHITE));
// Below the contents the members' own fills cover it.
let img = render(&group_doc(|g| {
green_fill(g);
g.appearance.set_contents_at(1);
}));
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (RED, BLUE));
// A group stroke strokes each member's outline.
let img = render(&group_doc(|g| {
g.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(solid(0.0, 1.0, 0.0), 4.0)));
}));
assert_eq!((img.pixel(10, 25), img.pixel(80, 25), img.pixel(25, 25)), (GREEN, GREEN, RED));
// Hidden fills paint nothing.
let img = render(&group_doc(|g| {
green_fill(g);
if let AppearanceItem::Fill(f) = &mut g.appearance.items[0] {
f.visible = false;
}
}));
assert_eq!(img.pixel(25, 25), RED);
}
#[test]
fn a_group_fill_paints_the_underline_of_its_type() {
// The group holds underlined black "IIII" at 40 pt with its baseline at y 60, and has a green fill.
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
let t = Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style)))));
let mut g = Node::group(d.alloc_id(), vec![t]);
green_fill(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
// The glyphs have no ink below the baseline: the bar's pixels there are green, none black.
let img = render(&d);
let bar: Vec<[u8; 4]> = (61..70).flat_map(|y| (0..100).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[0] < 160).collect();
assert!(bar.len() > 50 && bar.iter().all(|p| p[1] > p[0] + 20 && p[1] > p[2] + 20), "{bar:?}");
}
#[test]
fn a_group_fill_with_opacity_composites_as_one_layer() {
// Two overlapping members under a 50 % group fill: the overlap is as light as the rest.
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 60.0, 40.0), Paint::None);
let b = rect(&mut d, Rect::new(40.0, 10.0, 90.0, 40.0), Paint::None);
let mut g = Node::group(d.alloc_id(), vec![a, b]);
let mut f = FillLayer::new(Paint::solid(Color::BLACK));
f.opacity = 0.5;
g.appearance.items.push(AppearanceItem::Fill(f));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let img = render(&d);
let (single, both) = (img.pixel(20, 25), img.pixel(50, 25));
assert!(single[0].abs_diff(128) <= 2 && single == both, "{single:?} vs {both:?}");
}
#[test]
fn a_group_shadow_is_one_combined_shadow() {
// The members' shadows (offset 30 pt right) overlap at x 65 but not at x 80; one combined
// shadow is equally dark at both, two separate ones would double up at 65.
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Paint::solid(Color::BLACK));
let b = rect(&mut d, Rect::new(30.0, 10.0, 60.0, 40.0), Paint::solid(Color::BLACK));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 50, "mode": "normal"})));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
for threads in [0, 3] {
let img = render_threads(&d, threads);
let (overlap, single) = (img.pixel(65, 25), img.pixel(80, 25));
assert!(single[0] < 160 && single[0] > 100, "{single:?}");
assert!(overlap[0].abs_diff(single[0]) <= 2, "one shadow, not two: {overlap:?} vs {single:?}");
assert_eq!(img.pixel(95, 25), WHITE);
}
}
#[test]
fn group_geometry_effects_move_the_members_as_one_piece() {
let img = render(&group_doc(|g| g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})))));
assert_eq!((img.pixel(25, 25), img.pixel(25, 75), img.pixel(65, 75)), (WHITE, RED, BLUE));
// The group's own fill paints the moved members, and the shadow follows them.
let img = render(&group_doc(|g| {
g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})));
green_fill(g);
}));
assert_eq!((img.pixel(25, 75), img.pixel(65, 75)), (GREEN, GREEN));
}
/// #2023: a Transform effect on a group moves and copies its images as it does its paths (the
/// image's placement takes the map, and every copy is drawn at its own place).
#[test]
fn group_geometry_effects_move_and_copy_images_too() {
let mut d = Document::new(100.0, 100.0);
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let b = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), solid(0.0, 0.0, 1.0));
let im = ImageObject {
key: "red".into(),
width: 30,
height: 30,
xf: Affine::translate((10.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))));
let mut g = Node::group(d.alloc_id(), vec![b, image]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 50, "copies": 1})));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let img = render(&d);
assert_eq!(img.pixel(25, 45), RED, "the image itself");
assert_eq!(img.pixel(75, 45), RED, "its copy, 50 pt to the right");
assert_eq!(img.pixel(60, 10), BLUE, "the path's copy");
assert_eq!(img.pixel(5, 10), BLUE, "the path itself");
}
/// A 100×100 document holding a group of a red square (10..90) and black 40 pt type "IIII" over
/// it, the type changed by `ty` and the group changed by `group`.
fn type_group_doc(ty: impl FnOnce(&mut Node), group: impl FnOnce(&mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), solid(1.0, 0.0, 0.0));
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
ty(&mut t);
let mut g = Node::group(d.alloc_id(), vec![a, Arc::new(t)]);
group(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
}
#[test]
fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geometry_effects() {
// Offset Path 0 leaves every path as it is, so the group is drawn the same with it as without
// it, for type at 50 %, hidden type, and type at 50 % defining its knockout shape in a
// knockout group.
let half: fn(&mut Node) = |t| t.opacity = 0.5;
let hidden: fn(&mut Node) = |t| t.visible = false;
let shape: fn(&mut Node) = |t| (t.opacity, t.knockout_shape) = (0.5, true);
let plain: fn(&mut Node) = |_| {};
let knockout: fn(&mut Node) = |g| g.knockout = Knockout::On;
for (case, ty, group) in [("50 %", half, plain), ("hidden", hidden, plain), ("knockout shape", shape, knockout)] {
let without = render(&type_group_doc(ty, group));
let with = render(&type_group_doc(ty, |g| {
group(g);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
}));
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "{case}: max channel difference {worst}");
}
}
#[test]
fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() {
// The red square is clipped by type with a Transform of its own, which the clip leaves out,
// then by a group of that type and a small square, with the type shown and hidden. Offset
// Path 0 on the clip group leaves every path as it is.
let doc = |effect: bool, grouped: bool, hidden: bool| {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
t.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
t.visible = !hidden;
let mut clip = Arc::new(t);
if grouped {
let small = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Paint::None);
clip = Arc::new(Node::group(d.alloc_id(), vec![clip, small]));
}
let a = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), solid(1.0, 0.0, 0.0));
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, a], clip: true });
if effect {
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
}
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
d
};
for (grouped, hidden) in [(false, false), (true, false), (true, true)] {
let (without, with) = (render(&doc(false, grouped, hidden)), render(&doc(true, grouped, hidden)));
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "grouped {grouped}, hidden {hidden}: max channel difference {worst}");
}
}
#[test]
fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() {
let mut d = group_doc(|_| {});
let l = Arc::make_mut(&mut d.layers[0]);
l.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
let img = render(&d);
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (GREEN, GREEN));
// A knockout group with a drop shadow: its half-transparent members still knock each other
// out (the overlap shows the top one over the backdrop), and it casts its shadow.
let mut d = Document::new(100.0, 100.0);
let half = |d: &mut Document, r: Rect, p: Paint| {
let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0));
n.opacity = 0.5;
Arc::new(n)
};
let a = half(&mut d, Rect::new(10.0, 10.0, 50.0, 40.0), solid(1.0, 0.0, 0.0));
let b = half(&mut d, Rect::new(30.0, 10.0, 70.0, 40.0), solid(0.0, 0.0, 1.0));
let mut g = Node::group(d.alloc_id(), vec![a, b]);
g.knockout = Knockout::On;
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 0, "y": 40, "blur": 0, "opacity": 100, "mode": "normal"})));
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let img = render(&d);
let overlap = img.pixel(40, 25);
assert!(overlap[0] > 120 && overlap[2] > 250, "the blue knocks the red out: {overlap:?}");
// The shadow of the composite: as opaque as the knocked-out art (half), the overlap too.
assert!(img.pixel(40, 65)[0].abs_diff(127) <= 2 && img.pixel(20, 65) == img.pixel(40, 65), "the shadow: {:?}", img.pixel(40, 65));
}
/// "IIII" in black at 40 pt with its own red fill; `below`: under the characters.
fn type_doc(below: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(1.0, 0.0, 0.0))));
if below {
n.appearance.set_contents_at(1);
}
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn red_pixels(img: &Rendered) -> usize {
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
}
#[test]
fn a_fill_below_the_characters_is_hidden_by_the_glyphs() {
assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs");
assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it");
}
/// "IIII" in black at 40 pt with its own red 2 pt stroke under the characters. The stroke carries
/// the effects `row`, and the type carries the effects `object`.
fn stroked_type_doc(row: &[Effect], object: &[Effect]) -> Document {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
let mut stroke = StrokeLayer::new(solid(1.0, 0.0, 0.0), 2.0);
stroke.effects = row.to_vec();
n.appearance.items.push(AppearanceItem::Stroke(stroke));
n.appearance.set_contents_at(1);
n.appearance.effects = object.to_vec();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The red pixels more than 2 px outside the box of the black ones.
fn red_past_the_glyphs(img: &Rendered) -> usize {
let all = || (0..100).flat_map(|y| (0..100).map(move |x| (x, y)));
let black: Vec<(u32, u32)> = all().filter(|&(x, y)| img.pixel(x, y)[..3].iter().all(|c| *c < 60)).collect();
let (x0, x1) = (black.iter().map(|p| p.0).min().unwrap(), black.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (black.iter().map(|p| p.1).min().unwrap(), black.iter().map(|p| p.1).max().unwrap());
let past = |x: u32, y: u32| x + 2 < x0 || x > x1 + 2 || y + 2 < y0 || y > y1 + 2;
all().filter(|&(x, y)| past(x, y) && matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
}
#[test]
fn an_offset_path_on_the_types_own_stroke_draws_the_offset_outline() {
assert_eq!(red_past_the_glyphs(&render(&stroked_type_doc(&[], &[]))), 0, "the stroke without the effect stays at the glyphs");
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &[]))) > 50, "the stroke runs 4 pt outside the glyphs");
}
#[test]
fn a_shadow_on_the_type_keeps_the_offset_outline_of_its_own_stroke() {
let offset = [fx("path.offsetPath", json!({"offset": 4}))];
let shadow = [fx("stylize.dropShadow", json!({"x": 0, "y": 30, "blur": 0, "opacity": 50, "mode": "normal"}))];
assert!(red_past_the_glyphs(&render(&stroked_type_doc(&offset, &shadow))) > 50);
}
#[test]
fn the_offset_outline_of_the_types_own_stroke_is_drawn_when_only_it_is_in_view() {
// The view's right edge is 5 pt left of the glyphs, so only the stroke 8 pt outside them
// reaches into it. The second case also gives the type object Offset Path 0, which leaves its
// outlines as they are.
let offset = [fx("path.offsetPath", json!({"offset": 8}))];
for object in [vec![], vec![fx("path.offsetPath", json!({"offset": 0}))]] {
let d = stroked_type_doc(&offset, &object);
let glyphs = effects::outline_text(&d.layers[0].children().unwrap()[0]).unwrap().geometric_bounds().unwrap();
let view = Affine::translate((105.0 - glyphs.x0, 0.0));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, view, &opts);
assert!(red_pixels(&img) > 20, "{} object effects: {} red pixels", object.len(), red_pixels(&img));
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Dithered gradient fills: fine noise instead of bands, the same colours on average, the same
//! pixels on every render, and nothing changed without dither or on strokes.
use crate::*;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
const W: u32 = 400;
const H: u32 = 40;
/// A shallow grey ramp, #404040 → #4a4a4a: ten levels over 400 pixels, bands 40 pixels wide.
fn shallow(kind: GradientKind, dither: bool) -> Paint {
let mut g =
Gradient::new(kind, vec![GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)), GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a))]);
g.dither = dither;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// A W × H document with a rectangle over it painted `fill`, stroked `stroke` 10 wide.
fn doc(fill: Paint, stroke: Paint) -> Document {
let mut d = Document::new(W as f64, H as f64);
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, W as f64, H as f64)), Appearance::basic(fill, stroke, 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document, xf: Affine) -> Rendered {
Renderer::new().render(d, W, H, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The longest run of identical pixels along row `y` (the stroke's 5 pixels at each end left out).
fn longest_run(r: &Rendered, y: u32) -> usize {
let row: Vec<[u8; 4]> = (5..W - 5).map(|x| r.pixel(x, y)).collect();
row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap_or(0)
}
#[test]
fn a_dithered_shallow_gradient_has_no_bands_where_the_plain_one_does() {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), Affine::IDENTITY);
for y in [3, 20, 36] {
assert!(longest_run(&plain, y) >= 30, "the plain ramp bands: row {y}, longest run {}", longest_run(&plain, y));
assert!(longest_run(&dithered, y) <= 12, "dithered: row {y}, longest run {}", longest_run(&dithered, y));
}
// The noise moves no colour: each 20-pixel stretch averages what the plain ramp does.
for x0 in (0..W).step_by(20) {
let mean = |r: &Rendered| {
(x0..x0 + 20).flat_map(|x| (0..H).map(move |y| (x, y))).map(|(x, y)| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(20 * H)
};
assert!((mean(&plain) - mean(&dithered)).abs() < 0.35, "x {x0}: {} vs {}", mean(&plain), mean(&dithered));
}
// Noise of at most a level or so either way.
for (x, y) in [(10, 10), (200, 20), (390, 30)] {
let (a, b) = (i32::from(plain.pixel(x, y)[0]), i32::from(dithered.pixel(x, y)[0]));
assert!((a - b).abs() <= 2, "({x}, {y}): {a} vs {b}");
}
}
#[test]
fn dithered_renders_are_the_same_every_time() {
let d = doc(shallow(GradientKind::Radial, true), Paint::None);
let (a, b) = (render(&d, Affine::IDENTITY), render(&d, Affine::IDENTITY));
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))));
// And with the grid drawn afresh on another renderer, panned by a whole pixel.
let moved = Renderer::new().render(
&d,
W,
H,
Affine::translate((1.0, 0.0)),
&RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() },
);
assert!((0..H).all(|y| (1..W).all(|x| moved.pixel(x, y) == a.pixel(x - 1, y))), "the noise moves with the art");
}
#[test]
fn dither_leaves_strokes_and_undithered_fills_as_they_were() {
// Dither applies to fills only: a stroke draws its gradient as before.
let fill = Paint::solid(Color::WHITE);
let a = render(&doc(fill.clone(), shallow(GradientKind::Linear, false)), Affine::IDENTITY);
let b = render(&doc(fill, shallow(GradientKind::Linear, true)), Affine::IDENTITY);
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))), "a dithered gradient on a stroke draws as an undithered one");
// Off: the vector gradient, its bands where they always were.
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
assert_eq!(plain.pixel(0, 20)[0], 0x40);
assert_eq!(plain.pixel(W - 1, 20)[0], 0x4a);
}
#[test]
fn a_zoomed_or_rotated_dithered_gradient_keeps_its_colours() {
for xf in [Affine::scale(3.0), Affine::rotate_about(0.3, vectorcraft_geom::Point::new(200.0, 20.0)), Affine::scale(40.0)] {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), xf);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), xf);
for (x, y) in [(50, 10), (150, 20), (250, 30)] {
let (a, b) = (plain.pixel(x, y), dithered.pixel(x, y));
assert!(a.iter().zip(b).all(|(p, q)| (i32::from(*p) - i32::from(q)).abs() <= 2), "{xf:?} ({x}, {y}): {a:?} vs {b:?}");
}
}
}
#[test]
fn a_large_dithered_box_mostly_off_screen_samples_only_what_shows() {
// A 2000-unit square, the shallow ramp across it: a 2048-cell grid a side, 16 MiB.
const S: u32 = 2000;
let mut d = Document::new(f64::from(S), f64::from(S));
let id = d.alloc_id();
let n = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, f64::from(S), f64::from(S))),
Appearance::basic(shallow(GradientKind::Linear, true), Paint::None, 0.0),
);
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let whole = Renderer::new().render(&d, S, S, Affine::IDENTITY, &opts);
// A 64-pixel view of its middle draws the pixels the whole render has there...
let mut part = None;
let bytes = dither::grid_bytes_after(|| part = Some(Renderer::new().render(&d, 64, 64, Affine::translate((-1000.0, -1000.0)), &opts)));
let part = part.unwrap();
assert!((0..64).all(|y| (0..64).all(|x| part.pixel(x, y) == whole.pixel(1000 + x, 1000 + y))), "the view draws what the whole render does");
// ...from the cells around it only, not the whole grid.
assert!(bytes > 0 && bytes <= 512 * 512 * 4, "{bytes} bytes of grid for a 64-pixel view");
}
#[test]
fn a_dithered_gradient_without_stops_draws_nothing_and_samples_nothing() {
let mut g = Gradient::new(GradientKind::Linear, Vec::new());
g.dither = true;
let d = doc(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None);
let mut r = None;
let bytes = dither::grid_bytes_after(|| r = Some(render(&d, Affine::IDENTITY)));
assert_eq!(bytes, 0, "no grid is sampled");
let r = r.unwrap();
assert!((0..H).all(|y| (0..W).all(|x| r.pixel(x, y) == [255, 255, 255, 255])), "nothing is drawn");
}
/// Blue to yellow across the document, mixed `method`'s way, dithered or not.
fn blue_yellow(method: vectorcraft_color::GradientMethod, dither: bool) -> Paint {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
(g.method, g.dither) = (method, dither);
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
#[test]
fn a_dithered_perceptual_gradient_is_the_perceptual_mix_with_noise() {
use vectorcraft_color::GradientMethod::{Classic, Perceptual};
// Classic first: the two share stops and box, and must not share a dithered grid.
let classic = render(&doc(blue_yellow(Classic, true), Paint::None), Affine::IDENTITY);
let dithered = render(&doc(blue_yellow(Perceptual, true), Paint::None), Affine::IDENTITY);
// Each pixel within a level or so of the perceptual mix across it (its grid cell's centre lies
// in it; the noise, rounding, and the mix's stops within half a level), and dither's noise
// moving many off the rounded mix at its centre.
let Paint::Gradient(want) = blue_yellow(Perceptual, false) else { panic!("a gradient") };
let mix = |x: f32| want.gradient.sample(x / W as f32).0.to_rgb().map(|v| v.clamp(0.0, 1.0) * 255.0);
let mut noisy = 0;
for x in 5..W - 5 {
let p = dithered.pixel(x, 20);
let (a, m, b) = (mix(x as f32), mix(x as f32 + 0.5), mix(x as f32 + 1.0));
let near = |k: usize| (a[k].min(b[k]) - 2.0..=a[k].max(b[k]) + 2.0).contains(&f32::from(p[k]));
assert!((0..3).all(near), "({x}, 20): {p:?} vs {a:?} to {b:?}");
noisy += usize::from((0..3).any(|k| p[k] != m[k].round() as u8));
}
assert!(noisy > 100, "dither's noise: {noisy} pixels off the rounded mix");
// The middle: a blue-grey (OKLab), where Classic+Dither gives the display RGB mid grey.
let [r, g, b, _] = dithered.pixel(W / 2, 20).map(i32::from);
assert!(b > r + 20 && b > g + 10, "perceptual: {:?}", [r, g, b]);
let [r, g, b, _] = classic.pixel(W / 2, 20).map(i32::from);
assert!((r - b).abs() <= 4 && (g - b).abs() <= 4, "classic: {:?}", [r, g, b]);
}
+305
View File
@@ -0,0 +1,305 @@
//! Type redraws in a font that becomes available while it is on screen (fonts added, Refresh Font
//! List).
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use crate::*;
/// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what
/// they keep between frames hold it shared ([`fonts_steady`]) and the tests that change the fonts
/// hold it alone ([`adding_fonts`]).
static FONTS: RwLock<()> = RwLock::new(());
/// No fonts are added by these tests until the guard is dropped.
pub(crate) fn fonts_steady() -> RwLockReadGuard<'static, ()> {
// A test that failed holding it leaves nothing to undo.
FONTS.read().unwrap_or_else(PoisonError::into_inner)
}
/// No test holding [`fonts_steady`] runs until the guard is dropped.
pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
FONTS.write().unwrap_or_else(PoisonError::into_inner)
}
/// The bundled Source Serif 4 renamed `family` (as long as the original name).
fn serif_as(family: &str) -> Vec<u8> {
let mut data = include_bytes!("../../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Serif 4"), utf16(family)), (b"Source Serif 4".to_vec(), family.as_bytes().to_vec())] {
assert_eq!(from.len(), to.len());
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
#[test]
fn type_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
const FAMILY: &str = "Arrival Serif4";
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with raster effects redraws in a font that becomes available: its drop shadow and its
/// blurred content are made again.
#[test]
fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
const FAMILY: &str = "Raster Serif 4";
let mut d = Document::new(300.0, 150.0);
let l = d.layers[0].id;
let effects =
[("stylize.dropShadow", serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100})), ("blur.gaussian", serde_json::json!({"radius": 1}))];
for (i, (id, params)) in effects.into_iter().enumerate() {
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0 + 70.0 * i as f64), "Hamburg", style))));
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.into(), params, visible: true });
d.insert(Some(l), i, n).unwrap();
}
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with Variable Font settings is drawn in a face of their own, which is not a substitute:
/// Document Setup's highlight of substituted glyphs leaves it alone, and so does Highlight
/// Alternate Glyphs.
#[test]
fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
let _adding = adding_fonts();
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let font_axes = [("wght".to_string(), 650.0)].into_iter().collect();
let style = CharStyle { size: 40.0, font_family: TWO_AXIS_FAMILY.into(), font_axes, ..Default::default() };
let t = TextObject::point(Point::new(10.0, 60.0), "lIn", style);
let g = text_geom(&t);
assert!(!g.all.elements().is_empty());
assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty());
assert!(g.alternate_glyphs(&t).elements().is_empty());
}
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
/// available: the glyph geometry kept for it is laid out again.
#[test]
fn translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
const FAMILY: &str = "Blended Serif4";
let mut d = Document::new(300.0, 150.0);
let l = d.layers[0].id;
for i in 0..3 {
let style = CharStyle { size: 36.0, font_family: FAMILY.into(), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 40.0 + 45.0 * i as f64), "Hamburg", style))));
match i {
0 => n.opacity = 0.8,
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
_ => n.appearance.effects.push(vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}),
visible: true,
}),
}
d.insert(Some(l), i, n).unwrap();
}
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_eq!(r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, before, "drawn again from the kept glyph geometry");
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
/// kept for the instance is placed again when the fonts change.
#[test]
fn symbol_type_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
const FAMILY: &str = "Placed Serif 4";
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-140.0, 25.0), "Hamburg", style))));
t.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true });
let art = Node::group(d.alloc_id(), vec![Arc::new(t)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) })).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type that a geometry effect outlines redraws in a font that becomes available: the art drawn for
/// the effects of the type, of a group that holds it and of a symbol instance whose art holds it
/// is made again when the fonts change.
#[test]
fn type_with_geometry_effects_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
let cases = [
("Moving Serif 4", "the type with a Transform effect"),
("Shadow Serif 5", "the type with a Transform effect and a drop shadow"),
("Massed Serif 4", "the group with a Transform effect"),
("Stamps Serif 4", "the symbol instance with a Transform effect"),
("Darker Serif 4", "the type with a Transform effect in Multiply"),
];
for (i, (family, what)) in cases.into_iter().enumerate() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let at = if i == 3 { Point::new(-140.0, 20.0) } else { Point::new(10.0, 80.0) };
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style))));
match i {
0 => object.appearance.effects.push(moved()),
1 => {
object.appearance.effects.push(moved());
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) });
object.appearance.effects.push(moved());
}
_ => {
object.appearance.effects.push(moved());
object.blend = vectorcraft_color::BlendMode::Multiply;
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "{what}: drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
}
}
/// Type that an envelope bends redraws in a font that becomes available, in the envelope and in
/// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the
/// fonts change.
#[test]
fn enveloped_type_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
const FAMILY: &str = "Warped Serif 4";
let mut d = Document::new(300.0, 150.0);
let word = |d: &mut Document, at: Point| {
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style))))
};
let typed = word(&mut d, Point::new(10.0, 60.0));
let art = word(&mut d, Point::new(-140.0, 20.0));
let art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) });
let l = d.layers[0].id;
for (i, content) in [typed, shown].into_iter().enumerate() {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
let id = d.alloc_id();
d.insert(Some(l), i, Node::new(id, envelope)).unwrap();
}
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type that an envelope bends redraws in a font that becomes available when the art drawn for
/// effects is made from the envelope's evaluation: the envelope with a drop shadow, and the
/// envelope in a group with a geometry effect or with a fill of its own.
#[test]
fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Effect, FillLayer};
let cases = [
("Shadow Serif 4", "the envelope with a drop shadow"),
("Effect Serif 4", "the group with a geometry effect"),
("Filled Serif 4", "the group with a fill"),
];
for (i, (family, what)) in cases.into_iter().enumerate() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style))));
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(typed)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
let mut object = Node::new(d.alloc_id(), envelope);
match i {
0 => {
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
1 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true });
}
_ => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))));
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "{what}: drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Freeform gradients: the rendered colour grid against the colour field.
use crate::*;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::{Point, shapes};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Freeform {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
Freeform {
points: vec![
p(20.0, 20.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(80.0, 80.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(80.0, 20.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
}
}
fn paint(f: Option<Freeform>) -> Paint {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = f;
Paint::Gradient(Box::new(g))
}
/// A 100 × 100 document with `shape` (on 0..100) painted `fill`, at `opacity`.
fn doc(shape: vectorcraft_geom::PathData, fill: Paint, opacity: f32) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shape, Appearance::basic(fill, Paint::None, 0.0));
n.opacity = opacity;
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The field's colour at `p` composited over white (8-bit).
fn expected(f: &Freeform, p: Point, opacity: f32) -> [u8; 4] {
let ([r, g, b], a) = f.field(spread_scale(Rect::new(0.0, 0.0, 100.0, 100.0))).sample(p);
let a = a * opacity;
let q = |v: f32| ((v * a + (1.0 - a)) * 255.0).round() as u8;
[q(r), q(g), q(b), 255]
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 1.0));
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the red point's colour");
assert_eq!(r.pixel(80, 80), [0, 0, 255, 255], "the blue point's colour");
for (x, y) in [(50, 50), (35, 60), (65, 30), (10, 90), (95, 5), (50, 20)] {
let want = expected(&f, Point::new(x as f64 + 0.5, y as f64 + 0.5), 1.0);
assert!(near(r.pixel(x, y), want, 6), "({x}, {y}): {:?} vs {want:?}", r.pixel(x, y));
}
}
#[test]
fn the_grid_is_clipped_to_the_path_and_takes_the_object_opacity() {
let f = freeform();
let r = render(&doc(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 0.5));
assert_eq!(r.pixel(2, 2), [255, 255, 255, 255], "outside the ellipse");
let want = expected(&f, Point::new(50.5, 50.5), 0.5);
assert!(near(r.pixel(50, 50), want, 6), "{:?} vs {want:?}", r.pixel(50, 50));
}
#[test]
fn unplaced_freeform_gradients_seed_points_on_the_box() {
// Without points: the automatic ones, coloured white → black along the default stops.
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(None), 1.0));
let auto = Freeform::auto(Rect::new(0.0, 0.0, 100.0, 100.0), &Gradient::default(), &|_| true);
let at = |i: usize| auto.points[i].at;
// (Pixel centres sit half a pixel off the points.)
assert!(near(r.pixel(at(0).x as u32, at(0).y as u32), [255, 255, 255, 255], 3));
assert!(near(r.pixel(at(3).x as u32, at(3).y as u32), [0, 0, 0, 255], 3));
}
#[test]
fn strokes_take_freeform_paint() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let line = shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0));
let n = Node::path(id, line, Appearance::basic(Paint::None, paint(Some(freeform())), 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the stroke's corner sits on the red point");
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255], "unfilled inside");
}
+142
View File
@@ -0,0 +1,142 @@
//! Raster effects stay where they are, and look the same, at every zoom and pan (#755).
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, Node, NodeKind};
use vectorcraft_geom::{Point, shapes};
use crate::*;
/// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle
/// that overlays at half opacity; without the type when `radius` is `None`.
fn scene(radius: Option<f64>, feather: bool) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::None, 0.0),
);
back.blend = BlendMode::Overlay;
back.opacity = 0.5;
d.insert(Some(l), 0, back).unwrap();
if let Some(radius) = radius {
let style = CharStyle { size: 36.0, ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(30.0, 60.0), "Hello", style))));
let id = if feather { "stylize.feather" } else { "blur.gaussian" };
t.appearance.effects.push(Effect { id: id.into(), params: json!({"radius": radius}), visible: true });
d.insert(Some(l), 1, t).unwrap();
}
d
}
/// `d` with its layer's art in one group: the overlay and the type grouped, as in #755's report.
fn grouped(mut d: Document) -> Document {
let l = d.layers[0].id;
let id = d.alloc_id();
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
d.insert(Some(l), 0, Node::new(id, NodeKind::Group { children: members, clip: false })).unwrap();
d
}
/// The canvas's options: art on nothing, no artboards.
fn screen() -> RenderOptions {
RenderOptions { background: None, artboards: false, ..Default::default() }
}
fn render(d: &Document, threads: u16, view: Affine, (w, h): (u32, u32)) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, w, h, view, &screen())
}
/// Where the type's ink is on the document (its centroid), rendered `threads`-threaded through
/// `view` on a `w` × `h` canvas.
fn ink_centre(radius: f64, threads: u16, view: Affine, size: (u32, u32)) -> Point {
let with = render(&scene(Some(radius), false), threads, view, size);
let without = render(&scene(None, false), threads, view, size);
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for (i, (p, q)) in with.pixels.chunks(4).zip(without.pixels.chunks(4)).enumerate() {
let d: f64 = (0..4).map(|c| f64::from(p[c].abs_diff(q[c]))).sum();
let (x, y) = ((i % size.0 as usize) as f64 + 0.5, (i / size.0 as usize) as f64 + 0.5);
sx += d * x;
sy += d * y;
sw += d;
}
assert!(sw > 0.0, "the type shows");
view.inverse() * Point::new(sx / sw, sy / sw)
}
#[test]
fn blurred_type_stays_in_place_at_every_zoom_and_pan() {
for (radius, threads) in [(3.0, 0), (3.0, 4), (12.0, 0), (12.0, 4)] {
let base = ink_centre(radius, threads, Affine::IDENTITY, (200, 100));
// The whole document in view, at other zooms and moved by parts of a pixel.
for (zoom, pan) in
[(1.0, (13.3, 7.6)), (2.0, (41.7, 20.2)), (3.3, (15.25, 9.5)), (0.5, (10.4, 5.1)), (6.0, (30.6, 25.3)), (1.37, (0.5, 0.25))]
{
let view = Affine::translate(pan) * Affine::scale(zoom);
let size = ((200.0 * zoom + 2.0 * pan.0).ceil() as u32, (100.0 * zoom + 2.0 * pan.1).ceil() as u32);
let at = ink_centre(radius, threads, view, size);
assert!(
(at.x - base.x).abs() < 1.0 && (at.y - base.y).abs() < 1.0,
"radius {radius}, threads {threads}, zoom {zoom}, pan {pan:?}: {at:?} vs {base:?}"
);
}
}
}
/// Scrolling moves the blurred type with the page and nothing else: two views a whole number of
/// pixels apart draw the same pixels where they overlap, whether the type fits on the canvas or
/// runs off it.
#[test]
fn scrolling_moves_blurred_type_without_changing_it() {
for (feather, group) in [(false, false), (true, false), (false, true)] {
let d = scene(Some(12.0), feather);
let d = if group { grouped(d) } else { d };
for threads in [0, 4] {
for (zoom, (w, h)) in [(1.0, (240, 140)), (4.0, (500, 300)), (8.0, (400, 300))] {
let at = |dx: f64, dy: f64| render(&d, threads, Affine::translate((-dx, -dy)) * Affine::scale(zoom), (w, h));
let a = at(20.0, 10.0);
for (sx, sy) in [(1u32, 0u32), (3, 2), (37, 23), (101, 57)] {
let b = at(20.0 + f64::from(sx), 10.0 + f64::from(sy));
let mut worst = 0;
for y in sy..h {
for x in sx..w {
let i = ((y * w + x) * 4) as usize;
let j = (((y - sy) * w + x - sx) * 4) as usize;
for c in 0..4 {
worst = worst.max(a.pixels[i + c].abs_diff(b.pixels[j + c]));
}
}
}
assert!(
worst <= 3,
"feather {feather}, grouped {group}, threads {threads}, zoom {zoom}, scrolled by ({sx}, {sy}): a pixel changed by {worst}"
);
}
}
}
}
}
/// Scrolling by parts of a pixel (a trackpad, a zoom about the pointer) moves heavily blurred type
/// by just that: its ink stays put on the page within a tenth of a pixel, at any zoom.
#[test]
fn blurred_type_doesnt_wobble_as_the_view_moves_by_parts_of_a_pixel() {
for threads in [0, 4] {
for zoom in [1.0, 4.0] {
let at = |p: f64| {
ink_centre(30.0, threads, Affine::translate((400.0 - 75.0 * zoom + p, 330.0 - 48.0 * zoom + p)) * Affine::scale(zoom), (800, 660))
};
let base = at(0.0);
for k in 1..10 {
let c = at(f64::from(k) * 0.1);
// How far it moved on the screen, besides the view's own move.
let (dx, dy) = ((c.x - base.x) * zoom, (c.y - base.y) * zoom);
assert!(dx.abs() < 0.1 && dy.abs() < 0.1, "threads {threads}, zoom {zoom}, moved by {}: off by ({dx}, {dy}) px", f64::from(k) * 0.1);
}
}
}
}
+442
View File
@@ -0,0 +1,442 @@
//! Non-isolated transparency groups: blending inside a group with opacity or a blend mode reaches
//! the art below the group unless Isolate Blending is on (checked against the reference formulas).
use crate::*;
use vectorcraft_color::blend::{blend_rgb, composite};
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, Knockout, Node};
use vectorcraft_geom::shapes;
/// The backdrop colour (a full-page rectangle) and the blended child's colour.
const B: [f32; 3] = [0.8, 0.6, 0.2];
const S: [f32; 3] = [0.5, 0.5, 1.0];
fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0))
}
/// An unpainted clipping path, as Make Clipping Mask leaves it (a painted one paints too).
fn clip_path(d: &mut Document, r: Rect) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0))
}
/// A Multiply rectangle at 20..80 (the group's content).
fn multiply(d: &mut Document) -> Node {
let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
n.blend = Blend::Multiply;
n
}
/// A Multiply rectangle of color `c` at `r`.
fn multiply_in(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
let mut n = rect(d, r, c);
n.blend = Blend::Multiply;
n
}
/// The backdrop, then `top` (built by `make`), in a 100 × 100 document.
fn doc(make: impl FnOnce(&mut Document) -> Node) -> Document {
let mut d = Document::new(100.0, 100.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), B);
let top = make(&mut d);
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, below).unwrap();
d.insert(Some(l), usize::MAX, top).unwrap();
d
}
/// A group of `children` with `opacity`, `blend` and isolation.
fn group(d: &mut Document, children: Vec<Node>, opacity: f32, blend: Blend, isolate: bool) -> Node {
let mut g = Node::group(d.alloc_id(), children.into_iter().map(Arc::new).collect());
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
g
}
fn render_with(r: &mut Renderer, d: &Document, opts: &RenderOptions) -> Rendered {
r.render(d, 100, 100, Affine::IDENTITY, opts)
}
fn render(d: &Document) -> Rendered {
render_with(&mut Renderer::new(), d, &RenderOptions::default())
}
fn rgb(p: [u8; 4]) -> [u8; 3] {
[p[0], p[1], p[2]]
}
fn to8(c: [f32; 3]) -> [u8; 3] {
c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t)
}
fn assert_near(got: [u8; 4], want: [f32; 3], what: &str) {
let w = to8(want);
assert!(rgb(got).iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 2) && got[3] == 255, "{what}: {got:?}, want {w:?}");
}
#[test]
fn a_multiply_child_of_a_half_opaque_group_multiplies_with_the_backdrop() {
let multiplied = blend_rgb(Blend::Multiply, B, S);
for isolate in [false, true] {
let d = doc(|d| {
let m = multiply(d);
group(d, vec![m], 0.5, Blend::Normal, isolate)
});
let r = render(&d);
// Non-isolated: the child multiplies with the backdrop, then the group is half opaque.
// Isolated: the child multiplies with nothing, so the group shows its plain colour.
let inside = if isolate { lerp(B, S, 0.5) } else { lerp(B, multiplied, 0.5) };
assert_near(r.pixel(50, 50), inside, &format!("isolate {isolate}"));
assert_near(r.pixel(10, 10), B, "outside the group");
}
}
#[test]
fn a_group_blend_mode_applies_again_to_the_non_isolated_result() {
// The child (opaque) multiplies with the backdrop; the group then screens that result onto the
// backdrop at 60%.
let d = doc(|d| {
let m = multiply(d);
group(d, vec![m], 0.6, Blend::Screen, false)
});
let g = blend_rgb(Blend::Multiply, B, S);
let o = composite(Blend::Screen, [B[0], B[1], B[2], 1.0], [g[0], g[1], g[2], 0.6]);
assert_near(render(&d).pixel(50, 50), [o[0], o[1], o[2]], "screened multiply");
}
#[test]
fn a_clip_group_does_not_isolate_its_children() {
let d = doc(|d| {
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
let m = multiply(d);
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true })
});
let r = render(&d);
assert_near(r.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the clip");
assert_near(r.pixel(25, 25), B, "clipped away");
}
#[test]
fn a_non_isolated_group_inside_a_clip_group_stays_clipped() {
// The half-opaque group copies its backdrop while the clip is in force: the clip still applies
// to it and to what follows.
let d = doc(|d| {
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
let m = multiply(d);
let g = group(d, vec![m], 0.5, Blend::Normal, false);
let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S);
let children = [clip, g, after].into_iter().map(Arc::new).collect();
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
});
let r = render(&d);
assert_near(r.pixel(50, 40), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside the clip");
assert_near(r.pixel(25, 50), B, "group clipped away");
assert_near(r.pixel(50, 65), S, "later art inside the clip");
assert_near(r.pixel(50, 85), B, "later art clipped away");
}
#[test]
fn a_masked_object_blends_with_the_backdrop() {
let d = doc(|d| {
let m = multiply(d);
let mut g = group(d, vec![m], 1.0, Blend::Normal, false);
// A 50% grey mask over the whole group.
let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]);
g.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
g
});
assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply");
}
#[test]
fn a_non_isolated_group_inside_an_isolated_one_blends_with_its_siblings_below() {
// An isolated group holding a green square, then a 50% group with the Multiply child: the
// child multiplies with the green (the isolated group's own art), not with the backdrop.
const G: [f32; 3] = [0.2, 0.9, 0.4];
let d = doc(|d| {
let green = rect(d, Rect::new(10.0, 10.0, 90.0, 90.0), G);
let m = multiply(d);
let inner = group(d, vec![m], 0.5, Blend::Normal, false);
group(d, vec![green, inner], 1.0, Blend::Normal, true)
});
assert_near(render(&d).pixel(50, 50), lerp(G, blend_rgb(Blend::Multiply, G, S), 0.5), "multiplied with the sibling");
}
#[test]
fn a_non_isolated_knockout_group_composites_its_children_against_the_backdrop() {
// Two Multiply squares overlapping at 40..60 in a knockout group: in the overlap the top one
// multiplies with the backdrop only (it knocks out the one below).
const T: [f32; 3] = [1.0, 0.4, 0.6];
for isolate in [false, true] {
let d = doc(|d| {
let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S);
let mut upper = rect(d, Rect::new(40.0, 10.0, 90.0, 90.0), T);
(lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply);
let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate);
g.knockout = Knockout::On;
g
});
let r = render(&d);
let (overlap, alone) = if isolate { (T, S) } else { (blend_rgb(Blend::Multiply, B, T), blend_rgb(Blend::Multiply, B, S)) };
assert_near(r.pixel(50, 50), overlap, &format!("overlap, isolate {isolate}"));
assert_near(r.pixel(20, 50), alone, &format!("lower alone, isolate {isolate}"));
}
}
#[test]
fn a_knockout_group_weights_a_partly_covered_pixel_by_the_coverage_once() {
// The upper Multiply square's left edge covers half of pixel column 40. There the upper square
// takes half of the pixel with its color composited against the backdrop, and the lower one
// keeps the other half (ISO 32000-1 §11.4.6). Right of x = 70 the backdrop is a stripe of
// another color, which the upper square multiplies with.
const T: [f32; 3] = [1.0, 0.4, 0.6];
const STRIPE: [f32; 3] = [0.3, 0.7, 0.9];
for isolate in [false, true] {
let mut d = doc(|d| {
let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S);
let mut upper = rect(d, Rect::new(40.5, 10.0, 90.0, 90.0), T);
(lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply);
let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate);
g.knockout = Knockout::On;
g
});
let stripe = rect(&mut d, Rect::new(70.0, 0.0, 100.0, 100.0), STRIPE);
let l = d.layers[0].id;
d.insert(Some(l), 1, stripe).unwrap();
let r = render(&d);
// Isolated, each square multiplies with nothing and shows its own color.
let multiplied = |b: [f32; 3], c: [f32; 3]| if isolate { c } else { blend_rgb(Blend::Multiply, b, c) };
assert_near(r.pixel(40, 50), lerp(multiplied(B, S), multiplied(B, T), 0.5), &format!("half-covered edge, isolate {isolate}"));
assert_near(r.pixel(80, 50), multiplied(STRIPE, T), &format!("over the stripe, isolate {isolate}"));
}
}
#[test]
fn trim_view_page_group_and_threads_keep_the_non_isolated_look() {
let d = doc(|d| {
let m = multiply(d);
group(d, vec![m], 0.5, Blend::Normal, false)
});
let want = lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5);
let trim = RenderOptions { trim: true, ..Default::default() };
assert_near(render_with(&mut Renderer::new(), &d, &trim).pixel(50, 50), want, "trim view");
let mut paged = d.clone();
paged.page_isolate = true;
assert_near(render(&paged).pixel(50, 50), want, "isolated page group");
let mut threaded = Renderer::new();
threaded.threads = 2;
let single = render(&d);
let multi = render_with(&mut threaded, &d, &RenderOptions::default());
assert_eq!(single.pixels, multi.pixels, "multithreaded rendering copies the backdrop the same way");
}
#[test]
fn copying_the_backdrop_changes_nothing_without_blending() {
// A plain 50% group renders the same with or without an invisible blending group drawn first
// (which makes the renderer start again from a picture of the page and mix it back in).
let plain = doc(|d| {
let c = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
group(d, vec![c], 0.5, Blend::Normal, false)
});
let mut flattened = plain.clone();
let m = multiply(&mut flattened);
let g = group(&mut flattened, vec![m], 0.5, Blend::Normal, false);
let l = flattened.layers[0].id;
// Behind everything but the backdrop, and fully transparent: only the copy happens.
let mut g = g;
g.children_mut().unwrap().iter_mut().for_each(|c| Arc::make_mut(c).opacity = 0.0);
flattened.insert(Some(l), 1, g).unwrap();
let (a, b) = (render(&plain).pixels, render(&flattened).pixels);
let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap();
assert!(worst <= 1, "differs by {worst}/255");
}
/// A 300 × 300 page: the backdrop with a 60% stripe over its right half, then nine objects that
/// do not overlap. Each object copies its backdrop or is drawn between objects that do. Object `i`
/// is on the page when `with(i)` is true.
fn objects_apart(with: impl Fn(usize) -> bool) -> Document {
let mut d = Document::new(300.0, 300.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 300.0, 300.0), B);
let mut stripe = rect(&mut d, Rect::new(150.0, 0.0, 300.0, 300.0), [0.1, 0.3, 0.9]);
stripe.opacity = 0.6;
let cell = |x: f64, y: f64| Rect::new(x, y, x + 50.0, y + 50.0);
let mut objects = vec![];
// Objects 0 and 1 are a 50% group and a 70% Screen group, each holding a Multiply child.
let m = multiply_in(&mut d, cell(10.0, 10.0), S);
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
let m = multiply_in(&mut d, cell(110.0, 10.0), S);
objects.push(group(&mut d, vec![m], 0.7, Blend::Screen, false));
// Object 2 is a masked group holding a Multiply child. Its mask art holds a 50% group with a
// Multiply child.
let m = multiply_in(&mut d, cell(210.0, 10.0), S);
let mut masked = group(&mut d, vec![m], 1.0, Blend::Normal, false);
let white = rect(&mut d, cell(205.0, 5.0), [1.0; 3]);
let gray = multiply_in(&mut d, Rect::new(220.0, 20.0, 250.0, 50.0), [0.5; 3]);
let half = group(&mut d, vec![gray], 0.5, Blend::Normal, false);
let art = group(&mut d, vec![white, half], 1.0, Blend::Normal, false);
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
objects.push(masked);
// Object 3 is a knockout group of two Multiply rectangles that overlap.
let lower = multiply_in(&mut d, Rect::new(10.0, 110.0, 45.0, 160.0), S);
let upper = multiply_in(&mut d, Rect::new(25.0, 110.0, 60.0, 160.0), [1.0, 0.4, 0.6]);
let mut knockout = group(&mut d, vec![lower, upper], 1.0, Blend::Normal, false);
knockout.knockout = Knockout::On;
objects.push(knockout);
// Object 4 is an isolated group that holds a 50% group with a Multiply child.
let green = rect(&mut d, cell(110.0, 110.0), [0.2, 0.9, 0.4]);
let m = multiply_in(&mut d, Rect::new(120.0, 120.0, 150.0, 150.0), S);
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
objects.push(group(&mut d, vec![green, half], 1.0, Blend::Normal, true));
// Object 5 is a clip group that holds a 50% group with a Multiply child and a rectangle after
// the group.
let clip = clip_path(&mut d, Rect::new(215.0, 115.0, 255.0, 155.0));
let m = multiply_in(&mut d, cell(210.0, 110.0), S);
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
let after = rect(&mut d, Rect::new(210.0, 140.0, 260.0, 160.0), [0.9, 0.9, 0.1]);
let children = [clip, half, after].into_iter().map(Arc::new).collect();
objects.push(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }));
// Object 6 is a 50% rectangle that is not in a group.
let mut plain = rect(&mut d, cell(10.0, 210.0), [0.3, 0.2, 0.8]);
plain.opacity = 0.5;
objects.push(plain);
// Object 7 is a Screen group at 100% holding a Multiply child.
let m = multiply_in(&mut d, cell(110.0, 210.0), S);
objects.push(group(&mut d, vec![m], 1.0, Blend::Screen, false));
// Object 8 is a 50% group whose Multiply child reaches past the page's corner.
let m = multiply_in(&mut d, Rect::new(260.0, 260.0, 330.0, 330.0), S);
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
let l = d.layers[0].id;
let kept = objects.into_iter().enumerate().filter(|(i, _)| with(*i)).map(|(_, n)| n);
for n in [below, stripe].into_iter().chain(kept) {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
/// The renderer copies the backdrop of several objects in one frame, each from the picture of what
/// it drew before that object. The frame equals the page drawn with each object alone, pixel for
/// pixel, when it is drawn on one thread and on two, turned, composited in floating point, inside
/// an isolated page group, zoomed in until the first group fills the frame, and as the renderer's
/// next frame.
#[test]
fn objects_that_copy_their_backdrop_draw_as_they_do_alone() {
let (all, none) = (objects_apart(|_| true), objects_apart(|_| false));
let alone: Vec<Document> = (0..9).map(|i| objects_apart(|j| j == i)).collect();
let (plain, precise) = (RenderOptions::default(), RenderOptions { precise: true, ..Default::default() });
let turned = Affine::rotate_about(0.35, vectorcraft_geom::Point::new(150.0, 150.0));
let zoomed = Affine::translate((150.0, 150.0)) * Affine::scale(6.0) * Affine::translate((-35.0, -35.0));
for (what, threads, view, opts, isolate) in [
("one thread", 0, Affine::IDENTITY, &plain, false),
("two threads", 2, Affine::IDENTITY, &plain, false),
("turned", 0, turned, &plain, false),
("floating point", 0, Affine::IDENTITY, &precise, false),
("isolated page", 0, Affine::IDENTITY, &plain, true),
("zoomed", 0, zoomed, &plain, false),
] {
let mut r = Renderer::new();
r.threads = threads;
let mut draw = |d: &Document| {
let mut d = d.clone();
d.page_isolate = isolate;
r.render(&d, 300, 300, view, opts).pixels
};
let below = draw(&none);
let mut want = below.clone();
for d in &alone {
let one = draw(d);
for ((w, o), b) in want.as_chunks_mut::<4>().0.iter_mut().zip(one.as_chunks::<4>().0).zip(below.as_chunks::<4>().0) {
if o != b {
assert_eq!(*w, *b, "{what}: two objects draw on one pixel");
*w = *o;
}
}
}
for frame in 1..=2 {
let got = draw(&all);
let differ: Vec<usize> = (0..300 * 300).filter(|i| got[i * 4..i * 4 + 4] != want[i * 4..i * 4 + 4]).collect();
let at = differ.first().map(|i| (i % 300, i / 300));
assert!(differ.is_empty(), "{what}, frame {frame}: {} pixels differ, the first at {at:?}", differ.len());
}
}
}
/// The renderer copies the backdrop of twenty 50% groups with Multiply children on a 400 × 400
/// page. The images it draws in that frame add up to fewer pixels than the page: it draws no
/// picture of the page back after a copy.
#[test]
fn backdrop_copies_draw_no_picture_of_the_page_back() {
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 400.0, 400.0), B);
d.insert(Some(l), usize::MAX, below).unwrap();
for i in 0..20u32 {
let (x, y) = (10.0 + f64::from(i % 5) * 80.0, 10.0 + f64::from(i / 5) * 80.0);
let m = multiply_in(&mut d, Rect::new(x, y, x + 20.0, y + 20.0), S);
let g = group(&mut d, vec![m], 0.5, Blend::Normal, false);
d.insert(Some(l), usize::MAX, g).unwrap();
}
let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get);
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let before = drawn();
let frame = r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default());
let images = drawn() - before;
assert!(images < 400 * 400, "threads {threads}: {images} pixels drawn as images");
assert_near(frame.pixel(20, 20), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside a group");
}
}
/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as
/// well as below; the group's own art as usual.
#[test]
fn isolation_mode_dims_the_art_around_the_isolated_group() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]);
let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]);
let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]);
let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false);
let gid = g.id;
for n in [below, g, above] {
d.insert(Some(l), usize::MAX, n).unwrap();
}
let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3];
let plain = render(&d);
let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() });
let dimmed = isolated(gid);
assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art");
for (x, y) in [(20, 20), (80, 20)] {
assert_eq!(alpha(&plain, x, y), 255);
assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y));
}
// An object that isn't in the document dims nothing.
assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels);
}
/// Deeply nested isolated blending groups (#3581): each level is drawn offscreen only while the
/// offscreen pixels alive stay within a few frames' worth; deeper levels are drawn as layers.
#[test]
fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() {
let d = doc(|d| {
let mut n = multiply(d);
for _ in 0..30 {
n = group(d, vec![n], 1.0, Blend::Multiply, true);
}
n
});
let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get);
let before = drawn();
let frame = render(&d);
let images = drawn() - before;
// Each level's offscreen region is the content's bounds (20..80) plus `region`'s 2 px
// antialiasing margin on each side: 64 × 64. A budget of 4 frames fits whole levels only.
let level = 64 * 64;
let budget = 4 * 100 * 100 / level * level;
assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}");
assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups");
assert_near(frame.pixel(10, 10), B, "outside the groups");
}
+186
View File
@@ -0,0 +1,186 @@
//! Knockout groups, the knockout shape and the page group (Transparency panel).
use crate::*;
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, Knockout, Node};
use vectorcraft_geom::shapes;
/// A 50% opaque rectangle: red at 10..60, blue at 40..90 (they overlap at 40..60).
fn half(d: &mut Document, x: f64, rgb: (f32, f32, f32)) -> Node {
let id = d.alloc_id();
let mut n = Node::path(
id,
shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 90.0)),
Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0),
);
n.opacity = 0.5;
n
}
/// A document whose layer holds a group (`knockout`) of a 50% red and a 50% blue rectangle,
/// after `tweak` adjusts the group.
fn doc(knockout: Knockout, tweak: impl FnOnce(&mut Document, &mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
let red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
let blue = half(&mut d, 40.0, (0.0, 0.0, 1.0));
let id = d.alloc_id();
let mut g = Node::group(id, vec![Arc::new(red), Arc::new(blue)]);
g.knockout = knockout;
tweak(&mut d, &mut g);
d.insert(Some(d.layers[0].id), 0, g).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn close(a: [u8; 4], b: [u8; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= 3)
}
const BLUE_OVER_WHITE: [u8; 4] = [128, 128, 255, 255];
/// 50% blue over 50% red over white.
const BLUE_OVER_RED: [u8; 4] = [128, 64, 191, 255];
/// The overlap with half of the red knocked out by an opaque blue: 0.5 · (50% red over white) +
/// 0.5 · blue (for Normal blending, the same as 50% blue over the red).
const HALF_KNOCKED: [u8; 4] = [128, 64, 191, 255];
#[test]
fn knockout_group_shows_the_top_object_over_the_backdrop() {
let on = render(&doc(Knockout::On, |_, _| {}));
assert!(close(on.pixel(50, 50), BLUE_OVER_WHITE), "overlap: {:?}", on.pixel(50, 50));
assert!(close(on.pixel(20, 50), [255, 128, 128, 255]), "red alone: {:?}", on.pixel(20, 50));
for k in [Knockout::Off, Knockout::Neutral] {
let r = render(&doc(k, |_, _| {}));
assert!(close(r.pixel(50, 50), BLUE_OVER_RED), "{k:?}: {:?}", r.pixel(50, 50));
}
}
#[test]
fn opacity_defines_the_knockout_shape() {
// The blue object's 50% opacity knocks out only half of the red below it.
let r = render(&doc(Knockout::On, |_, g| {
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
blue.knockout_shape = true;
}));
assert!(close(r.pixel(50, 50), HALF_KNOCKED), "{:?}", r.pixel(50, 50));
// Its opacity mask counts too when it defines the shape, and is ignored otherwise.
for shape in [false, true] {
let r = render(&doc(Knockout::On, |d, g| {
let id = d.alloc_id();
let white = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
);
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
blue.opacity = 1.0;
blue.knockout_shape = shape;
blue.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true)));
}));
let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE };
// A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack.
let px = r.pixel(50, 50);
assert!(px.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 6), "shape {shape}: {px:?}");
}
}
#[test]
fn neutral_groups_pass_knockout_through() {
// Red and blue in a neutral group inside a knockout group knock each other out…
let nested = |inner: Knockout| {
doc(Knockout::On, |d, g| {
let children = std::mem::take(g.children_mut().unwrap());
let mut inner_group = Node::group(d.alloc_id(), children);
inner_group.knockout = inner;
*g.children_mut().unwrap() = vec![Arc::new(inner_group)];
})
};
assert!(close(render(&nested(Knockout::Neutral)).pixel(50, 50), BLUE_OVER_WHITE));
// …an Off group's do not…
assert!(close(render(&nested(Knockout::Off)).pixel(50, 50), BLUE_OVER_RED));
// …and with no knockout group around it, a neutral group knocks out nothing.
let mut plain = nested(Knockout::Neutral);
Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral;
assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED));
}
#[test]
fn page_knockout_and_isolated_blending() {
// Page Knockout Group: the neutral layer passes its objects through to the page.
let mut d = doc(Knockout::Neutral, |_, _| {});
d.page_knockout = true;
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50));
// A layer set to Off keeps its objects from knocking each other out.
Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off;
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED));
// Page Isolated Blending: a Difference object no longer blends with the white under the page.
let mut d = Document::new(100.0, 100.0);
let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
red.opacity = 1.0;
red.blend = Blend::Difference;
d.insert(Some(d.layers[0].id), 0, red).unwrap();
assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50));
d.page_isolate = true;
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50));
}
#[test]
fn page_knockout_looks_the_same_when_another_object_blends() {
// With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page
// over a copy of what is below it (white, or nothing). Four objects knock out part of the
// orange page: a blue square whose left edge covers half of pixel column 10, a 50% blue square
// whose opacity defines its knockout shape, a 50% blue square, and an isolated group of a green
// square and a 50% group holding a Multiply square, which draws its middle half transparent.
// Each looks the same with and without the Multiply square.
let rect = |d: &mut Document, r: Rect, (red, green, blue): (f32, f32, f32)| {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(red, green, blue)), Paint::None, 0.0))
};
let page = |blending: bool| {
let mut d = Document::new(100.0, 100.0);
d.page_knockout = true;
let orange = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2));
let edge = rect(&mut d, Rect::new(10.5, 10.0, 30.5, 30.0), (0.1, 0.3, 0.9));
let mut shaped = rect(&mut d, Rect::new(40.0, 10.0, 60.0, 30.0), (0.1, 0.3, 0.9));
(shaped.opacity, shaped.knockout_shape) = (0.5, true);
let mut translucent = rect(&mut d, Rect::new(70.0, 10.0, 90.0, 30.0), (0.1, 0.3, 0.9));
translucent.opacity = 0.5;
let green = rect(&mut d, Rect::new(10.0, 40.0, 60.0, 90.0), (0.2, 0.9, 0.4));
let mut multiply = rect(&mut d, Rect::new(20.0, 50.0, 50.0, 80.0), (0.5, 0.5, 1.0));
multiply.blend = Blend::Multiply;
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(multiply)]);
inner.opacity = 0.5;
let mut isolated = Node::group(d.alloc_id(), vec![Arc::new(green), Arc::new(inner)]);
isolated.isolate = true;
let mut objects = vec![orange, edge, shaped, translucent, isolated];
if blending {
let mut other = rect(&mut d, Rect::new(70.0, 70.0, 90.0, 90.0), (0.5, 0.5, 1.0));
other.blend = Blend::Multiply;
objects.push(other);
}
let l = d.layers[0].id;
for n in objects {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
};
// Half of the orange stays and blue takes the other half: 0.5 · orange + 0.5 · blue. The 50%
// square knocks out all of the orange and shows over what is below the page: 0.5 · white +
// 0.5 · blue, or blue at 50% over nothing.
let half_blue = [115, 115, 140, 255];
for (below, half_square) in [(Some([255, 255, 255, 255]), [140, 166, 242, 255]), (None, [26, 77, 230, 128])] {
let opts = RenderOptions { background: below, ..Default::default() };
let draw = |d: &Document| Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts);
let (plain, blending) = (draw(&page(false)), draw(&page(true)));
let cases = [("half-covered edge", 10, 20, half_blue), ("knockout shape", 50, 20, half_blue), ("50%", 80, 20, half_square)];
for (what, x, y, want) in cases {
for r in [&plain, &blending] {
assert!(close(r.pixel(x, y), want), "{what} over {below:?}: {:?}", r.pixel(x, y));
}
}
let (a, b) = (plain.pixel(35, 65), blending.pixel(35, 65));
assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square");
}
}
+237
View File
@@ -0,0 +1,237 @@
//! Layer Options on screen: Preview off draws a layer in outline, Dim Images fades its images;
//! exports (which leave templates out) draw both as they are.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::shapes;
use crate::*;
/// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`.
fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let l = d.layers[0].id;
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red);
d.insert(Some(l), 0, sq).unwrap();
let mut im = Node::new(
NodeId(0),
NodeKind::Image(Box::new(ImageObject {
key: "red".into(),
width: 30,
height: 30,
xf: Affine::translate((50.0, 10.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
im.id = d.alloc_id();
d.insert(Some(l), 9, im).unwrap();
layer(&mut d.node_mut(l).unwrap().kind);
d
}
fn render(d: &Document, export: bool) -> Rendered {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), skip_templates: export, ..Default::default() };
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
}
#[test]
fn preview_off_draws_the_layer_in_outline_on_screen_only() {
let d = doc(|k| {
if let NodeKind::Layer { preview, .. } = k {
*preview = false;
}
});
assert_eq!(render(&d, false).pixel(25, 25), [255, 255, 255, 255], "no fill in outline");
assert_eq!(render(&d, true).pixel(25, 25), [255, 0, 0, 255], "exports draw it as it is");
}
#[test]
fn dim_images_fades_only_the_images() {
let plain = render(&doc(|_| {}), false);
assert_eq!(plain.pixel(65, 25), [255, 0, 0, 255]);
let d = doc(|k| {
if let NodeKind::Layer { dim_images, .. } = k {
*dim_images = Some(50);
}
});
let img = render(&d, false);
let [r, g, b, _] = img.pixel(65, 25);
assert!(r == 255 && (120..=135).contains(&g) && g == b, "half way to white: {:?}", img.pixel(65, 25));
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
}
/// #1683: a template layer dims its images by its Dim Images percentage and nothing else: with
/// the option off they show at full strength, and the layer's paths are never faded.
#[test]
fn a_template_layer_dims_its_images_by_dim_images() {
let template = |dim: Option<u8>| {
let d = doc(|k| {
if let NodeKind::Layer { template, dim_images, .. } = k {
(*template, *dim_images) = (true, dim);
}
});
let img = render(&d, false);
(img.pixel(65, 25), img.pixel(25, 25))
};
let red = [255, 0, 0, 255];
assert_eq!(template(None), (red, red), "Dim Images off: as it is");
let (image, path) = template(Some(50));
assert!(image[0] == 255 && (120..=135).contains(&image[1]) && image[1] == image[2], "half way to white: {image:?}");
assert_eq!(path, red, "vector art stays");
let (image, _) = template(Some(20));
assert!((198..=210).contains(&image[1]) && image[1] == image[2], "a fifth of its strength: {image:?}");
assert_eq!(template(Some(100)), (red, red));
}
/// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced;
/// the layer above it draws in outline (#1486).
#[test]
fn a_template_layer_stays_in_preview_in_outline_view() {
let mut d = doc(|k| {
if let NodeKind::Layer { template, preview, dim_images, .. } = k {
// As Layer Options makes a template: its images dimmed to 50 %.
(*template, *dim_images) = (true, Some(50));
// Preview off doesn't put a template in outline either.
*preview = false;
}
});
let top = Node::layer(d.alloc_id(), "Drawing", vectorcraft_doc::LayerColor::Preset(0));
let top_id = d.insert(None, 9, top).unwrap();
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 60.0, 40.0, 90.0)), red);
d.insert(Some(top_id), 0, sq).unwrap();
let draw = |outline: bool| {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), outline, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
};
let (preview, outline) = (draw(false), draw(true));
let [r, g, b, _] = outline.pixel(65, 25);
assert!(r == 255 && (120..=135).contains(&g) && g == b, "the template's image, dimmed: {:?}", outline.pixel(65, 25));
assert_eq!(outline.pixel(65, 25), preview.pixel(65, 25), "as in Preview");
assert_eq!(outline.pixel(25, 75), [255, 255, 255, 255], "the drawing layer has no fill in outline");
assert_eq!(preview.pixel(25, 75), [255, 0, 0, 255]);
}
/// View › Hide Template (#2000) leaves template layers out of the screen view and nothing else:
/// a guide path on another layer still draws, unlike in an export.
#[test]
fn hide_template_leaves_only_the_template_layers_out() {
let mut d = doc(|k| {
if let NodeKind::Layer { template, .. } = k {
*template = true;
}
});
let top = d.add_layer(Some("Guides"));
let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
d.insert(Some(top), 0, g).unwrap();
let render = |hide: bool| {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), hide_templates: hide, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
};
let guide_drawn = |r: &Rendered| (60..100).flat_map(|y| (60..100).map(move |x| (x, y))).any(|(x, y)| r.pixel(x, y) != [255, 255, 255, 255]);
let shown = render(false);
assert_ne!(shown.pixel(25, 25), [255, 255, 255, 255], "the template layer's square shows (dimmed)");
assert!(guide_drawn(&shown));
let hidden = render(true);
assert_eq!(hidden.pixel(25, 25), [255, 255, 255, 255], "Hide Template: the template layer is left out");
assert_eq!(hidden.pixel(65, 25), [255, 255, 255, 255], "its image too");
assert!(guide_drawn(&hidden), "the guide on the other layer still draws");
}
/// A guide path draws on screen only: exports leave it out, as they leave templates out (and as
/// the SVG, PSD and PDF exports and printing already do).
#[test]
fn guides_draw_on_screen_only() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
d.insert(Some(l), 0, g).unwrap();
let drawn = |export: bool| {
let r = render(&d, export);
(60..100).flat_map(|y| (60..100).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y) != [255, 255, 255, 255]).count()
};
assert!(drawn(false) > 0, "on screen the guide shows");
assert_eq!(drawn(true), 0, "exports leave it out");
}
/// #3219: View > Hide Guides hides paths made into guides without using the export/template
/// filtering switch; Show Guides (the default) draws them again.
#[test]
fn hide_guides_hides_guide_paths_on_screen() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
d.insert(Some(l), 0, g).unwrap();
let pixels = |hide_guides: bool| {
let opts = RenderOptions { background: Some([255; 4]), hide_guides, ..Default::default() };
let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
(60..100).flat_map(|y| (60..100).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y) != [255; 4]).count()
};
assert!(pixels(false) > 0, "Show Guides draws the guide path");
assert_eq!(pixels(true), 0, "Hide Guides leaves the guide path out");
}
/// #3219: hiding guides omits guide paths, including those inside cached group effects, without
/// hiding ordinary artwork. The same renderer must show them again when the view is toggled back.
#[test]
fn hidden_guide_paths_leave_the_artwork_and_refresh_cached_effects() {
for effect in [false, true] {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
let mut guide =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.5, 20.5, 60.5, 60.5)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
let id = guide.id;
let art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(80.0, 80.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0),
);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(guide), Arc::new(art)]);
if effect {
group.appearance.effects.push(vectorcraft_doc::Effect {
id: "blur.gaussian".into(),
params: serde_json::json!({"radius":2}),
visible: true,
});
}
d.insert(Some(layer), 0, group).unwrap();
let mut without_guide = d.clone();
without_guide.remove(id).unwrap();
let mut renderer = Renderer::new();
for outline in [false, true] {
let opts = RenderOptions { outline, background: Some([255; 4]), ..Default::default() };
let shown = renderer.render(&d, 120, 120, Affine::IDENTITY, &opts);
let hidden = RenderOptions { hide_guides: true, ..opts.clone() };
let expected = Renderer::new().render(&without_guide, 120, 120, Affine::IDENTITY, &hidden);
assert_ne!(shown.pixels, expected.pixels, "the guide is initially visible, effect={effect}, outline={outline}");
assert!(
renderer.render(&d, 120, 120, Affine::IDENTITY, &hidden).pixels == expected.pixels,
"only guides hide, effect={effect}, outline={outline}"
);
assert!(
renderer.render(&d, 120, 120, Affine::IDENTITY, &opts).pixels == shown.pixels,
"guides show again, effect={effect}, outline={outline}"
);
}
}
}
File diff suppressed because it is too large Load Diff
+780
View File
@@ -0,0 +1,780 @@
//! Effects on every object kind: raster effects on type, images and symbol instances through an
//! offscreen layer of their art, geometry effects through their outlines, and one fill or
//! stroke's own raster effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, StrokeLayer, Symbol};
use vectorcraft_geom::{Point, shapes};
use crate::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100×100 document with `n` (its id allocated) on the first layer.
fn doc_with(mut d: Document, mut n: Node) -> Document {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render_threads(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn render(d: &Document) -> Rendered {
render_threads(d, 0)
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
#[test]
fn revolve_caps_cover_the_opening_and_match_export_and_visible_only_expansion() {
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0));
for (angle, opacity) in [(360.0, 1.0), (90.0, 1.0), (360.0, 0.5)] {
n.opacity = opacity;
n.appearance.effects = vec![fx(
"threeD.revolve",
json!({"offset":20,"rotationX":-35,"lightAzimuth":0,
"angle":angle,"segments":32,"cap":false,"expandVisibleOnly":true}),
)];
let open = render(&doc_with(Document::new(100.0, 100.0), n.clone()));
n.appearance.effects[0].params["cap"] = json!(true);
let d = doc_with(Document::new(100.0, 100.0), n.clone());
let live = render(&d);
assert_ne!(live.pixels, open.pixels, "caps change the opening: {angle}, {opacity}");
if angle == 360.0 && opacity == 1.0 {
let pixel = (34 * 100 + 50) * 4;
assert!(live.pixels[pixel] as u16 > open.pixels[pixel] as u16 + 30, "the lit cap covers the visible interior");
}
assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
let mut expanded = d.clone();
let id = expanded.layers[0].children().unwrap()[0].id;
let source = expanded.node(id).unwrap().clone();
let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap();
*expanded.node_mut(id).unwrap() = art;
let flat = render(&expanded);
let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum();
assert!(difference as f64 / (live.pixels.len() as f64) < 1.0, "visible-only expansion preserves cap appearance");
if opacity < 1.0 {
assert_eq!(live.pixels, flat.pixels);
}
}
}
#[test]
fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((50.0, 15.0));
bp.line_to((75.0, 30.0));
bp.line_to((75.0, 75.0));
bp.line_to((50.0, 90.0));
let mut n = Node::path(
NodeId(0),
vectorcraft_geom::PathData::from_bezpath(&bp),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
);
n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0})));
let d = doc_with(Document::new(100.0, 100.0), n);
let live = render(&d);
let baked = vectorcraft_effects::bake_document(&d).unwrap();
let exported = render(&baked);
assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree");
let reds: Vec<_> =
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
assert!(reds.len() > 1500, "a filled surface, rather than the open source path");
assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface");
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
}
/// Extrude & Bevel, Inflate (also twisted, tapered and hollow) and Rotate draw on the canvas exactly
/// as they export, filling more than the flat square with faces of differing shades (Rotate: one
/// shade, narrower).
#[test]
fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
for (effect, params) in [
("threeD.extrude", json!({"depth": 30, "bevel": "rounded"})),
("threeD.inflate", json!({"depth": 10})),
("threeD.inflate", json!({"depth": 10, "twist": 90, "taper": 60, "cap": false, "rotationX": -20, "rotationY": 35})),
("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})),
] {
let mut n = Node::path(
NodeId(0),
shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
);
n.appearance.effects.push(fx(effect, params));
let d = doc_with(Document::new(100.0, 100.0), n);
let live = render(&d);
let exported = render(&vectorcraft_effects::bake_document(&d).unwrap());
assert_eq!(live.pixels, exported.pixels, "{effect}: canvas and baked/exported faces agree");
assert_eq!(live.pixels, render_threads(&d, 4).pixels, "{effect}");
let reds: Vec<_> =
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
let shades = reds.iter().max().unwrap() - reds.iter().min().unwrap();
if effect == "threeD.rotate" {
assert!(reds.len() < 40 * 40 && reds.len() > 600, "{effect}: the square foreshortened, {} px", reds.len());
} else {
assert!(reds.len() > 40 * 40, "{effect}: a solid larger than its front, {} px", reds.len());
assert!(shades > 60, "{effect}: lighting varies across the faces ({shades})");
}
}
}
#[test]
fn smooth_revolve_reduces_banding_without_changing_radial_resolution() {
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 20 L70 80").unwrap());
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
n.appearance
.effects
.push(fx("threeD.revolve", json!({"offset":30,"segments":16,"lightAzimuth":0,"lightElevation":0,"ambient":10,"lightIntensity":80})));
let d = doc_with(Document::new(100.0, 100.0), n);
let smooth = render(&d);
let levels = |r: &Rendered| (14..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2]).collect::<std::collections::BTreeSet<_>>().len();
// More colour levels than radial facets proves interpolation inside the faces.
assert!(levels(&smooth) > 16, "default rendering interpolates lighting: {} blue levels", levels(&smooth));
let jump = |r: &Rendered| (15..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2].abs_diff(r.pixels[(50 * 100 + x - 1) * 4 + 2])).max().unwrap();
assert!(jump(&smooth) < 20, "default lighting has no abrupt changes between face colours");
assert_eq!(smooth.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
assert_eq!(smooth.pixels, render_threads(&d, 4).pixels);
}
#[test]
fn revolve_rim_has_no_spikes_at_high_zoom_and_keeps_seams_covered() {
use vectorcraft_doc::AppearanceItem;
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":-18,"segments":24,"cap":true})));
let d = doc_with(Document::new(100.0, 100.0), n);
let baked = vectorcraft_effects::bake_document(&d).unwrap();
let mut fills = baked.clone();
fn remove_edges(n: &mut Node) {
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
for child in n.children_mut().into_iter().flatten() {
remove_edges(Arc::make_mut(child));
}
}
for layer in &mut fills.layers {
remove_edges(Arc::make_mut(layer));
}
let bounds = fills.layers[0].geometric_bounds().unwrap();
let zoom = 24.0;
let xf = Affine::translate((600.0 - 50.0 * zoom, 50.0 - bounds.y0 * zoom)) * Affine::scale(zoom);
let draw =
|doc: &Document| Renderer::new().render(doc, 1200, 400, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let reference = draw(&fills);
let live = draw(&d);
// The seam stroke may extend half its width, plus antialiasing, past the fill
// silhouette. Miter spikes previously reached several times that distance.
let reach = (0.375 * zoom) as u32 + 2;
let top = |img: &Rendered| (0..1200).map(|x| (0..400).find(|y| lum(img.pixel(x, *y)) < 600).unwrap_or(400)).collect::<Vec<_>>();
let fill_top = top(&reference);
for (x, y) in top(&live).into_iter().enumerate().filter(|(_, y)| *y < 400) {
let near = *fill_top[x.saturating_sub(reach as usize)..=(x + reach as usize).min(1199)].iter().min().unwrap();
assert!(y + reach >= near, "rim spike at x {x}: ink starts at {y}, nearest fill at {near}");
}
assert!((200..1000).all(|x| lum(live.pixel(x, 200)) < 600), "seam coverage leaves no white gaps across the cap");
assert_eq!(live.pixels, draw(&baked).pixels, "live and exported geometry agree at high zoom");
}
#[test]
fn continuous_revolve_caps_keep_one_colour_along_the_curved_rim_at_high_zoom() {
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
for rotation in [-18.0f64, 18.0] {
for shade in [false, true] {
let mut n = Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":rotation,"segments":8,"cap":true,"shade":shade})));
let d = doc_with(Document::new(100.0, 100.0), n);
let center_y = 50.0 + if rotation < 0.0 { -20.0 } else { 20.0 } * rotation.to_radians().cos();
let zoom = 24.0;
let xf = Affine::translate((600.0 - 50.0 * zoom, 220.0 - center_y * zoom)) * Affine::scale(zoom);
let draw = |doc: &Document| {
Renderer::new().render(doc, 1200, 440, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
};
let live = draw(&d);
let color = live.pixel(600, 220);
assert!(lum(color) < 600, "the visible cap is filled");
// Sample 0.15 pt inside the projected ellipse. A triangle fan with
// seam strokes lets body colours intrude here between mesh corners.
for step in 1..80 {
let theta = std::f64::consts::PI * step as f64 / 80.0;
let p = xf * Point::new(50.0 + 19.85 * theta.cos(), center_y + 19.85 * rotation.to_radians().abs().sin() * theta.sin());
let pixel = live.pixel(p.x.round() as u32, p.y.round() as u32);
assert!(
pixel.iter().zip(color).all(|(a, b)| a.abs_diff(b) <= 1),
"cap rim is continuous: rotation {rotation}, shade {shade}, theta {theta}, {pixel:?} vs {color:?}"
);
}
assert_eq!(live.pixels, draw(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
}
}
}
#[test]
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((50.0, 15.0));
bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0));
let mut n = Node::path(
NodeId(0),
vectorcraft_geom::PathData::from_bezpath(&bp),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0),
);
for (angle, rotation, perspective, opacity, shade) in [
(360.0, 0.0, 0.0, 1.0, false),
(360.0, -35.0, 55.0, 1.0, false),
(150.0, -28.0, 40.0, 1.0, false),
(360.0, -35.0, 55.0, 0.5, false),
(360.0, -35.0, 55.0, 1.0, true),
(150.0, -28.0, 40.0, 1.0, true),
(360.0, -35.0, 55.0, 0.5, true),
] {
n.opacity = opacity;
n.appearance.effects = vec![fx(
"threeD.revolve",
json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true,"shade":shade}),
)];
let d = doc_with(Document::new(100.0, 100.0), n.clone());
let live = render(&d);
let mut expanded = d.clone();
let id = expanded.layers[0].children().unwrap()[0].id;
let source = expanded.node(id).unwrap().clone();
let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap();
*expanded.node_mut(id).unwrap() = art;
let flat = render(&expanded);
let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum();
let average = difference as f64 / live.pixels.len() as f64;
assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}");
if opacity < 1.0 {
assert_eq!(live.pixels, flat.pixels);
}
// Export baking keeps every face, even when the expansion checkbox is checked.
assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
}
}
/// A 30×30 red image placed at (20, 20).
fn image_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let mut n = Node::new(
NodeId(0),
NodeKind::Image(Box::new(ImageObject {
key: "red".into(),
width: 30,
height: 30,
xf: Affine::translate((20.0, 20.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
n.appearance.effects = effects;
doc_with(d, n)
}
/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin.
fn symbol_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) });
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance.effects = effects;
doc_with(d, n)
}
/// Black 40 pt "IIII" with its baseline at (5, 60).
fn text_doc(effects: Vec<Effect>) -> Document {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.effects = effects;
doc_with(Document::new(100.0, 100.0), n)
}
/// Columns (x) holding any dark pixel.
fn inked_columns(img: &Rendered) -> Vec<u32> {
(0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect()
}
/// "IIII" in `style` with its baseline at (5, 60), its text space scaled or turned there by `xf`.
fn styled_text_doc(style: CharStyle, xf: Affine) -> Document {
let mut t = TextObject::point(Point::new(5.0, 60.0), "IIII", style);
t.xf *= xf;
doc_with(Document::new(100.0, 100.0), Node::new(NodeId(0), NodeKind::Text(Box::new(t))))
}
/// Pixels whose color differs by more than 8 levels in a channel between `a` and `b`.
fn differing(a: &Rendered, b: &Rendered) -> usize {
a.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8))
.count()
}
/// Pixels that change by more than 8 levels in a channel when the first object of `d` gets Offset
/// Path at 0 pt, a geometry effect that returns its input: the object is then drawn through its
/// outlines.
fn changed_by_outlining(d: &Document) -> usize {
let mut outlined = d.clone();
let id = outlined.layers[0].children().unwrap()[0].id;
outlined.node_mut(id).unwrap().appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
differing(&render(d), &render(&outlined))
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_underline() {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
let d = styled_text_doc(style, Affine::IDENTITY);
// "IIII" has no ink below its baseline: the bar is all that is drawn there.
let plain = render(&d);
let bar = (61..70).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| lum(plain.pixel(x, y)) < 300).count();
assert!(bar > 50, "{bar} pixels of the bar");
let changed = changed_by_outlining(&d);
assert!(changed < 10, "{changed} pixels changed");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_stroke_weight_when_scaled() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let style = CharStyle { size: 20.0, fill: Paint::solid(Color::BLACK), stroke: red, stroke_width: 2.0, ..Default::default() };
// The stroke is 2 pt wide in text space and 4 pt wide in the document.
let changed = changed_by_outlining(&styled_text_doc(style, Affine::scale(2.0)));
assert!(changed < 10, "{changed} pixels changed");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_gradients_in_place() {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
g.angle = 90.0;
let ramp = Paint::Gradient(Box::new(g));
// On the characters, the gradient fits the layout box in text space and turns with the type.
let characters = |xf| styled_text_doc(CharStyle { size: 40.0, fill: ramp.clone(), ..Default::default() }, xf);
// On a fill of the type's own, it fits the bounding box of the layout box in the document.
let mut own = styled_text_doc(CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }, Affine::IDENTITY);
let id = own.layers[0].children().unwrap()[0].id;
own.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(FillLayer::new(ramp.clone())));
let changed: Vec<usize> = [characters(Affine::IDENTITY), characters(Affine::rotate(0.3)), own].iter().map(changed_by_outlining).collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
/// "IIII" at 40 pt with its baseline at (5, 60), its characters filled with a red-to-blue gradient
/// that runs left to right (0°) or bottom to top (90°).
fn gradient_text_doc(angle: f64) -> Document {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
g.angle = angle;
styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY)
}
/// The Transform effects the gradient tests apply, each with the transform that does the same to
/// type `t`: 30 pt right, 20 pt down, and 50 % about the middle of its outlines.
fn transforms_of(t: &Node) -> [(Value, Affine); 3] {
let middle = vectorcraft_effects::outline_text(t).unwrap().geometric_bounds().unwrap().center().to_vec2();
[
(json!({"moveH": 30}), Affine::translate((30.0, 0.0))),
(json!({"moveV": 20}), Affine::translate((0.0, 20.0))),
(json!({"scaleH": 50, "scaleV": 50}), Affine::translate(middle) * Affine::scale(0.5) * Affine::translate(-middle)),
]
}
/// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no
/// bend), with Distort Appearance on or off.
fn envelope_doc(content: Node, distort_appearance: bool) -> Document {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options,
frame: Box::new(Affine::IDENTITY),
};
doc_with(Document::new(100.0, 100.0), Node::new(NodeId(0), envelope))
}
#[test]
fn a_transform_effect_moves_and_scales_the_gradients_of_type_with_its_outlines() {
let mut changed = vec![];
for angle in [0.0, 90.0] {
let d = gradient_text_doc(angle);
let id = d.layers[0].children().unwrap()[0].id;
// The type's own transform does the same as the Transform effect in the document it is
// compared with.
for (params, xf) in transforms_of(d.node(id).unwrap()) {
let (mut effect, mut own) = (d.clone(), d.clone());
effect.node_mut(id).unwrap().appearance.effects.push(fx("distort.transform", params));
own.node_mut(id).unwrap().transform(xf, false);
changed.push(differing(&render(&effect), &render(&own)));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_transform_effect_that_scales_type_out_of_proportion_scales_its_radial_and_diagonal_gradients() {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut changed = vec![];
// A radial gradient turns into an ellipse, and a linear one at 45° turns its isolines.
for (kind, angle) in [(GradientKind::Radial, 0.0), (GradientKind::Linear, 45.0)] {
let mut g = GradientPaint::new(Gradient::new(kind, stops.clone()));
g.angle = angle;
let d = styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY);
let id = d.layers[0].children().unwrap()[0].id;
let middle = vectorcraft_effects::outline_text(d.node(id).unwrap()).unwrap().geometric_bounds().unwrap().center().to_vec2();
// 50 % across and 150 % up and down, about the middle of the outlines.
let xf = Affine::translate(middle) * Affine::scale_non_uniform(0.5, 1.5) * Affine::translate(-middle);
let (mut effect, mut own) = (d.clone(), d.clone());
effect.node_mut(id).unwrap().appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 150})));
own.node_mut(id).unwrap().transform(xf, false);
changed.push(differing(&render(&effect), &render(&own)));
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_transform_effect_on_a_group_symbol_or_envelope_moves_and_scales_the_gradients_of_its_type() {
// The gradient runs left to right, so a move to the right goes along it and a move down
// across it.
let d = gradient_text_doc(0.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut t = d.node(id).unwrap().clone();
t.id = NodeId(500);
// The type in a group, as the art of a symbol, and in an envelope that leaves it as it is.
let mut symbols = Document::new(100.0, 100.0);
symbols.symbols.push(Symbol { name: "Type".into(), art: Arc::new(t.clone()) });
let holders = [
doc_with(Document::new(100.0, 100.0), Node::group(NodeId(0), vec![Arc::new(t.clone())])),
doc_with(symbols, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Type".into(), xf: Affine::IDENTITY })),
envelope_doc(t.clone(), false),
];
let mut changed = vec![];
// The Transform effect is on the group, the symbol instance or the envelope. The type's own
// transform does the same in the document it is compared with.
for (params, xf) in transforms_of(&t) {
let mut own = d.clone();
own.node_mut(id).unwrap().transform(xf, false);
let reference = render(&own);
for h in &holders {
let mut effect = h.clone();
let holder = effect.layers[0].children().unwrap()[0].id;
effect.node_mut(holder).unwrap().appearance.effects.push(fx("distort.transform", params.clone()));
changed.push(differing(&render(&effect), &reference));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn type_in_an_envelope_moves_and_scales_its_gradients_with_its_own_transform_effect() {
let d = gradient_text_doc(0.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut changed = vec![];
for (params, xf) in transforms_of(d.node(id).unwrap()) {
let mut own = d.clone();
own.node_mut(id).unwrap().transform(xf, false);
let reference = render(&own);
let mut t = d.node(id).unwrap().clone();
t.id = NodeId(500);
t.appearance.effects.push(fx("distort.transform", params));
// The envelope leaves the type as it is, with Distort Appearance off and on.
for distort in [false, true] {
changed.push(differing(&render(&envelope_doc(t.clone(), distort)), &reference));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_pattern_tiles_on_the_type() {
// Pattern `Checks` is a red 2 × 2 square in a 4 × 4 tile.
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut def = PatternDef::new("Checks", vec![Arc::new(Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 2.0, 2.0)), red))]);
def.tile = Rect::new(0.0, 0.0, 4.0, 4.0);
// The type is 40 pt in the document: 20 pt type scaled 2×, or 40 pt type turned.
let changed: Vec<usize> = [Affine::scale(2.0), Affine::rotate(0.3)]
.into_iter()
.map(|xf| {
let mut d = styled_text_doc(CharStyle { size: 40.0 / xf.determinant().sqrt(), fill: pattern_paint("Checks"), ..Default::default() }, xf);
d.patterns.push(def.clone());
changed_by_outlining(&d)
})
.collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_shadow_on_an_image_paints_offset_pixels() {
let plain = render(&image_doc(vec![]));
assert_eq!(lum(plain.pixel(55, 55)), WHITE);
let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]);
let img = render(&d);
assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55));
assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top");
assert_eq!(lum(img.pixel(15, 15)), WHITE);
// The multithreaded pipeline filters offscreen and matches.
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn blur_on_a_symbol_instance_softens_its_edges() {
let plain = render(&symbol_doc(vec![]));
assert_eq!(lum(plain.pixel(38, 50)), WHITE);
assert_eq!(lum(plain.pixel(50, 50)), 0);
let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))]));
let edge = lum(img.pixel(38, 50));
assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}");
assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark");
}
#[test]
fn type_in_a_symbols_art_keeps_its_opacity_under_the_instances_effects() {
// The symbol's art is 50 % black type. Offset Path 0 and a shadow at 0 % opacity on the
// instance paint nothing of their own, so the instance is drawn the same with them as without
// them.
let doc = |effects| {
let mut d = symbol_doc(effects);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(-45.0, 10.0), "IIII", style))));
t.opacity = 0.5;
d.symbols[0].art = Arc::new(t);
d
};
let plain = render(&doc(vec![]));
let offset = fx("path.offsetPath", json!({"offset": 0}));
let shadow = fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 0, "mode": "normal"}));
for effect in [offset, shadow] {
let img = render(&doc(vec![effect.clone()]));
let worst = plain.pixels.iter().zip(&img.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "{}: max channel difference {worst}", effect.id);
}
}
#[test]
fn geometry_effects_reshape_type_through_its_outlines() {
let plain = inked_columns(&render(&text_doc(vec![])));
assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}");
// Transform moves the outlines as one piece.
let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))])));
assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30));
// Roughen redraws the glyphs as rough outlines.
let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]);
let rough = render(&d);
let smooth = render(&text_doc(vec![]));
let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 100, "{changed} pixels changed");
// The reshaped art is plain paths painted with the text's fill.
let n = &d.layers[0].children().unwrap()[0];
let art = vectorcraft_effects::reshape(None, n, None).unwrap();
let (mut text, mut black) = (0, 0);
art.walk(&mut |m| {
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
});
assert!(text == 0 && black > 0, "{art:?}");
}
/// Pixels that change by more than 8 levels in a channel when the first object of `d` gets
/// Outline Object.
fn changed_by_outline_object(d: &Document) -> usize {
let mut outlined = d.clone();
let id = outlined.layers[0].children().unwrap()[0].id;
outlined.node_mut(id).unwrap().appearance.effects.push(fx("path.outlineObject", json!({})));
differing(&render(d), &render(&outlined))
}
#[test]
fn outline_object_draws_type_as_its_outlines_and_leaves_its_look() {
// The type is drawn through its outlines: plain paths painted with the text's fill.
let d = text_doc(vec![fx("path.outlineObject", json!({}))]);
let n = &d.layers[0].children().unwrap()[0];
let art = vectorcraft_effects::reshape(None, n, None).unwrap();
let (mut text, mut black) = (0, 0);
art.walk(&mut |m| {
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
});
assert!(text == 0 && black > 0, "{art:?}");
// Plain, underlined and scaled stroked type, and 50 % type in a group that carries the effect,
// look the same with it.
let underlined = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let stroked = CharStyle { size: 20.0, fill: Paint::solid(Color::BLACK), stroke: red, stroke_width: 2.0, ..Default::default() };
let mut t = text_doc(vec![]).layers[0].children().unwrap()[0].as_ref().clone();
t.id = NodeId(500);
t.opacity = 0.5;
let docs = [
text_doc(vec![]),
styled_text_doc(underlined, Affine::IDENTITY),
styled_text_doc(stroked, Affine::scale(2.0)),
doc_with(Document::new(100.0, 100.0), Node::group(NodeId(0), vec![Arc::new(t)])),
];
let changed: Vec<usize> = docs.iter().map(changed_by_outline_object).collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn outline_object_leaves_paths_and_images_as_they_are() {
let ap = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::BLACK), 4.0);
let path = doc_with(Document::new(100.0, 100.0), Node::path(NodeId(0), shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 70.0)), ap));
let changed: Vec<usize> = [path, image_doc(vec![])].iter().map(changed_by_outline_object).collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
// The image is drawn through its frame, as under any geometry effect.
let d = image_doc(vec![fx("path.outlineObject", json!({}))]);
assert!(vectorcraft_effects::reshape(None, &d.layers[0].children().unwrap()[0], None).is_some());
}
#[test]
fn a_rectangle_on_a_fill_below_type_with_outline_object_fits_the_glyphs() {
// "IIII" with a red fill of its own below the characters that carries Convert to Shape ›
// Rectangle (the default 18 pt extra width and height).
let plain = render(&text_doc(vec![]));
let inked = inked_columns(&plain);
let gap = (inked[0]..*inked.last().unwrap()).find(|x| !inked.contains(x)).unwrap();
let mut d = text_doc(vec![fx("path.outlineObject", json!({}))]);
let id = d.layers[0].children().unwrap()[0].id;
let mut fill = FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(fx("convertToShape.rectangle", json!({})));
let ap = &mut d.node_mut(id).unwrap().appearance;
ap.items.push(AppearanceItem::Fill(fill));
ap.set_contents_at(1);
let img = render(&d);
// The rectangle is under the glyphs and fills the gap between two of them.
assert_eq!(img.pixel(gap, 50), [255, 0, 0, 255], "column {gap}");
assert_eq!(lum(img.pixel(inked[0] + 1, 50)), 0);
// It reaches 9 pt past the glyph outlines on each side.
let glyphs = vectorcraft_effects::outline_text(d.node(id).unwrap()).unwrap().geometric_bounds().unwrap();
let (x, y) = (glyphs.center().x as u32, glyphs.center().y as u32);
assert_eq!(img.pixel(glyphs.x1 as u32 + 6, y), [255, 0, 0, 255]);
assert_eq!(lum(img.pixel(glyphs.x1 as u32 + 12, y)), WHITE);
assert_eq!(img.pixel(x, glyphs.y1 as u32 + 6), [255, 0, 0, 255]);
assert_eq!(lum(img.pixel(x, glyphs.y1 as u32 + 12)), WHITE);
}
/// [`image_doc`] with a black 4 pt stroke of its own that carries the effects `row`, below the
/// image when `below`.
fn stroked_image_doc(row: Vec<Effect>, below: bool) -> Document {
let mut d = image_doc(vec![]);
let id = d.layers[0].children().unwrap()[0].id;
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
stroke.effects = row;
let ap = &mut d.node_mut(id).unwrap().appearance;
ap.items.push(AppearanceItem::Stroke(stroke));
ap.set_contents_at(usize::from(below));
d
}
#[test]
fn an_images_stroke_draws_only_the_shape_convert_to_shape_or_outline_object_gives_it() {
// The image's frame runs from 20 to 50. Without an effect the stroke draws nothing.
for below in [false, true] {
let img = render(&stroked_image_doc(vec![], below));
assert_eq!((lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, [255, 0, 0, 255]));
}
let rectangle = fx("convertToShape.rectangle", json!({"relative": true, "extraW": 0, "extraH": 0}));
let outline = fx("path.outlineObject", json!({}));
for effect in [rectangle, outline] {
// Below the image only the stroke's outer half shows, above it both halves.
let img = render(&stroked_image_doc(vec![effect.clone()], true));
assert_eq!((lum(img.pixel(17, 35)), lum(img.pixel(18, 35)), img.pixel(21, 35)), (WHITE, 0, [255, 0, 0, 255]), "{}", effect.id);
let img = render(&stroked_image_doc(vec![effect.clone()], false));
assert_eq!((lum(img.pixel(18, 35)), lum(img.pixel(21, 35)), img.pixel(23, 35)), (0, 0, [255, 0, 0, 255]), "{}", effect.id);
// An effect before it is left out.
let first = fx("path.offsetPath", json!({"offset": 10}));
let img = render(&stroked_image_doc(vec![first, effect.clone()], true));
assert_eq!((lum(img.pixel(10, 35)), lum(img.pixel(18, 35))), (WHITE, 0), "{}", effect.id);
}
// An Offset Path of 2 pt after Outline Object moves the stroke 2 pt out.
let img = render(&stroked_image_doc(vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 2}))], true));
assert_eq!((lum(img.pixel(15, 35)), lum(img.pixel(16, 35)), lum(img.pixel(19, 35))), (WHITE, 0, 0));
}
#[test]
fn an_images_stroke_is_drawn_when_only_it_is_in_view() {
// The stroke runs 8 pt outside the frame, from 10 to 14; the view shows x 15 and less.
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
let d = stroked_image_doc(row, true);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::translate((85.0, 0.0)), &opts);
assert_eq!((lum(img.pixel(96, 35)), lum(img.pixel(99, 35))), (0, WHITE));
}
#[test]
fn rasterize_on_an_image_takes_in_its_strokes_shape() {
// The stroke runs 8 pt outside the frame, from 10 to 14; Rasterize at 72 ppi keeps it.
let row = vec![fx("path.outlineObject", json!({})), fx("path.offsetPath", json!({"offset": 8}))];
let mut d = stroked_image_doc(row, true);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().appearance.effects.push(fx("rasterize", json!({"useDocumentResolution": false, "resolution": 72})));
let img = render(&d);
assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255]));
}
#[test]
fn a_strokes_own_glow_paints_around_that_stroke_only() {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6})));
d = doc_with(d, n);
let img = render(&d);
let g = img.pixel(27, 50);
assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}");
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow");
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::GradientGeom;
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None };
// Fitted to the box on each render, and placed on the object.
for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] {
let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0));
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0));
n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))];
let img = render(&doc_with(Document::new(100.0, 100.0), n));
let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default();
// The left edge of each copy is red, its right edge blue.
for (left, right) in [(1, 18), (41, 58)] {
assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left));
assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right));
}
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Placed documents on screen: drawn from bitmaps made in the background (a placeholder until
//! then), the same as drawn exactly; exports draw them exactly at once.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument};
use vectorcraft_geom::shapes;
use crate::*;
/// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also
/// places itself (the same key) in its middle, and one whose bytes start with `bad` can't be read.
fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
if bytes.starts_with(b"bad") {
return None;
}
let mut d = Document::new(40.0, 20.0);
d.layers.clear();
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))];
if bytes.starts_with(b"loop") {
d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec()));
let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() };
children.push(Arc::new(Node::new(NodeId(2), NodeKind::PlacedDocument(Box::new(me)))));
}
Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0)))
}
/// A 100×100 document showing one placed file (`bytes` under `key`) in the box (10, 10)–(90, 50).
fn doc_with(key: &str, bytes: &[u8]) -> Document {
vectorcraft_doc::placed_document::set_loader(blue_page);
let mut d = Document::new(100.0, 100.0);
d.images.insert(key.into(), ImageBlob::new("application/json", bytes.to_vec()));
let l = d.layers[0].id;
d.insert(Some(l), 0, placed_node(50, key, 40.0, 20.0, Affine::translate((10.0, 10.0)) * Affine::scale(2.0))).unwrap();
d
}
/// Object `id` showing file `key` in a `width`×`height` box placed by `xf`.
fn placed_node(id: u64, key: &str, width: f64, height: f64, xf: Affine) -> Node {
let p = PlacedDocument {
link: LinkInfo::new("/art/card.vectorcraft"),
key: key.into(),
bounding: false,
page_box: Default::default(),
width,
height,
xf,
placement: Default::default(),
};
Node::new(NodeId(id), NodeKind::PlacedDocument(Box::new(p)))
}
/// Held by the tests that draw placed documents from bitmaps, which share the bitmaps, their
/// generation and the jobs being made: one test's bitmaps don't push out another's, and
/// [`settle`] waits for its own.
fn serial() -> std::sync::MutexGuard<'static, ()> {
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn doc(key: &str) -> Document {
doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes())
}
fn render(d: &Document, progressive: bool) -> Rendered {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), progressive_placed: progressive, ..Default::default() };
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
}
/// Wait for the background bitmaps (at most 10 s).
fn settle() {
let t = std::time::Instant::now();
while placed_document::busy() && t.elapsed().as_secs() < 10 {
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!placed_document::busy(), "bitmaps still coming");
}
#[test]
fn a_placed_document_draws_exactly_with_its_own_art() {
let d = doc("exact-1");
let r = render(&d, false);
assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box");
assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside");
}
#[test]
fn an_enveloped_placed_document_shows_its_file_once_read() {
let mut d = doc("enveloped-1");
let placed = d.remove(NodeId(50)).unwrap();
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") };
let p = (**p).clone();
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let envelope = NodeKind::Envelope {
content: vec![placed],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(NodeId(60), envelope)).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
// Exporting the document as PDF, SVG or EPS reads the file.
d.with_placed_art();
assert!(vectorcraft_doc::placed_document::read_cached(&p).is_some(), "the file is read");
let after = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
assert!(after != before, "the file's art shows");
assert!(after == Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
#[test]
fn a_placed_document_with_a_drop_shadow_shows_its_file_once_read() {
let mut d = doc("shadowed-1");
let n = d.node_mut(NodeId(50)).unwrap();
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
n.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params, visible: true });
let NodeKind::PlacedDocument(p) = &n.kind else { panic!("a placed document") };
let p = (**p).clone();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
// Exporting the document as PDF, SVG or EPS reads the file.
d.with_placed_art();
assert!(vectorcraft_doc::placed_document::read_cached(&p).is_some(), "the file is read");
let after = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
assert!(after != before, "the file's art shows");
assert!(after == Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
#[test]
fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() {
let _serial = serial();
let d = doc("progressive-1");
let generation = placed_document::generation();
let first = render(&d, true);
let shown = &first.pixel(50, 30)[..3];
assert!(shown == [0, 0, 255] || shown != [255, 255, 255], "a placeholder or the art: {shown:?}");
settle();
assert!(placed_document::generation() > generation || shown == [0, 0, 255], "a bitmap was made");
let later = render(&d, true);
let exact = render(&d, false);
assert_eq!(&later.pixel(50, 30)[..3], &[0, 0, 255]);
for (x, y) in [(12, 12), (50, 30), (88, 48), (95, 95), (5, 5)] {
let (a, b) = (later.pixel(x, y), exact.pixel(x, y));
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 24), "at ({x}, {y}): {a:?} vs {b:?}");
}
}
#[test]
fn copies_of_one_placed_document_share_its_bitmap() {
let _serial = serial();
let mut d = doc("shared-1");
let n = d.node(NodeId(50)).unwrap().clone();
let l = d.layers[0].id;
let mut copy = n.clone();
copy.id = NodeId(51);
copy.transform(Affine::translate((0.0, 45.0)), false);
d.insert(Some(l), 1, copy).unwrap();
render(&d, true);
settle();
let r = render(&d, true);
assert!(!placed_document::pending_for("shared-1"), "nothing more to make");
assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]);
assert_eq!(&r.pixel(50, 75)[..3], &[0, 0, 255]);
}
/// Nine files on screen, each drawn from a 4096 × 4096 bitmap (576 MiB together, past the 512 MiB
/// the bitmaps keep): once made, the next frames draw all nine and ask for none again.
#[test]
fn nine_large_bitmaps_on_screen_are_not_made_again() {
let _serial = serial();
vectorcraft_doc::placed_document::set_loader(blue_page);
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let keys: Vec<String> = (0..9).map(|i| format!("nine-{i}")).collect();
for (i, key) in keys.iter().enumerate() {
d.images.insert(key.clone(), ImageBlob::new("application/json", format!("{{\"doc\": \"{key}\"}}").into_bytes()));
// A 1 × 1 point box at 4096 pixels a point: a 4096-pixel bitmap.
d.insert(Some(l), i, placed_node(100 + i as u64, key, 1.0, 1.0, Affine::translate((i as f64 * 0.001, 0.0)))).unwrap();
}
let (view, opts) = (Affine::scale(4096.0), RenderOptions { progressive_placed: true, ..Default::default() });
let mut r = Renderer::new();
r.render(&d, 32, 32, view, &opts);
settle();
let asked = || keys.iter().map(|k| placed_document::scheduled(k)).collect::<Vec<_>>();
assert_eq!(asked(), vec![1; 9], "each asked for once");
for frame in 1..=2 {
let shown = r.render(&d, 32, 32, view, &opts);
assert_eq!(asked(), vec![1; 9], "frame {frame} asks for none again");
assert_eq!(&shown.pixel(16, 16)[..3], &[0, 0, 255], "frame {frame} draws the art");
}
}
/// A bitmap that was not kept (it would have taken the bitmaps past the most they may hold) is
/// not asked for again: its file draws exactly at that zoom step, at once.
#[test]
fn a_bitmap_not_kept_draws_exactly_without_being_asked_for() {
let _serial = serial();
let d = doc("unkept-1");
// The box is drawn at 2 pixels a point: zoom step 2.
placed_document::mark_unkept("unkept-1", 2.0);
let shown = render(&d, true);
assert_eq!(&shown.pixel(50, 30)[..3], &[0, 0, 255], "the art, drawn exactly");
assert_eq!(placed_document::scheduled("unkept-1"), 0, "not asked for");
}
/// A file that can't be read, without a preview: its bitmap is asked for once, the screen is
/// not told to draw again, and later frames draw the placeholder without asking again.
#[test]
fn a_bitmap_that_cant_be_made_is_not_asked_for_again() {
let _serial = serial();
let d = doc_with("unreadable-1", b"bad");
let generation = placed_document::generation();
render(&d, true);
settle();
assert_eq!(placed_document::generation(), generation, "nothing new to draw");
let later = render(&d, true);
assert_eq!(placed_document::scheduled("unreadable-1"), 1, "asked for once");
let shown = &later.pixel(50, 30)[..3];
assert!(shown != [255, 255, 255] && shown != [0, 0, 255], "the placeholder: {shown:?}");
}
/// Once bitmaps are ready, drawing again where [`placed_area`] says gives what drawing the whole
/// frame again gives. The area of a file covers every object showing it (a group's member and
/// an object with a drop shadow too) and no object showing another file; art of a brush or a
/// symbol showing it makes it everything.
#[test]
fn a_ready_bitmap_draws_again_where_its_file_shows() {
let _serial = serial();
vectorcraft_doc::placed_document::set_loader(blue_page);
let mut d = Document::new(100.0, 100.0);
for key in ["area-a", "area-b"] {
d.images.insert(key.into(), ImageBlob::new("application/json", format!("{{\"doc\": \"{key}\"}}").into_bytes()));
}
let l = d.layers[0].id;
// 20 × 10 point boxes: A loose at (5, 5), with a shadow at (30, 40) and in a group at (5, 80);
// B at (75, 5). Only the shadow reaches past x = 50, and only the group's member past y = 50.
let at = |x: f64, y: f64| Affine::translate((x, y)) * Affine::scale(0.5);
d.insert(Some(l), 0, placed_node(60, "area-a", 40.0, 20.0, at(5.0, 5.0))).unwrap();
let group = NodeKind::Group { children: vec![Arc::new(placed_node(61, "area-a", 40.0, 20.0, at(5.0, 80.0)))], clip: false };
d.insert(Some(l), 1, Node::new(NodeId(62), group)).unwrap();
let mut shadowed = placed_node(63, "area-a", 40.0, 20.0, at(30.0, 40.0));
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
shadowed.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params, visible: true });
d.insert(Some(l), 2, shadowed).unwrap();
d.insert(Some(l), 3, placed_node(64, "area-b", 40.0, 20.0, at(75.0, 5.0))).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), progressive_placed: true, ..Default::default() };
let mut r = Renderer::new();
let generation = placed_document::generation();
let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
settle();
let made = placed_document::made_between(generation, placed_document::generation()).expect("known");
let files = |keys: &[&str]| keys.iter().map(|k| k.to_string()).collect::<std::collections::HashSet<_>>();
assert_eq!(made, files(&["area-a", "area-b"]), "both files' bitmaps");
let only_a = files(&["area-a"]);
let Changed::Area(a) = placed_area(&d, &only_a) else { panic!("an area") };
for (x, y) in [(6.0, 6.0), (24.0, 14.0), (31.0, 41.0), (53.0, 53.0), (6.0, 81.0), (24.0, 89.0)] {
assert!(a.contains(vectorcraft_geom::Point::new(x, y)), "({x}, {y}) in {a:?}");
}
assert!(a.x1 < 75.0, "not B: {a:?}");
// Drawn again only there, it is what the whole frame drawn again shows.
let Changed::Area(area) = placed_area(&d, &made) else { panic!("an area") };
let after = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
let [x0, y0, x1, y1] = view_pixels(area, Affine::IDENTITY, 100, 100).expect("on screen");
let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))), &opts);
let mut patched = before.pixels.clone();
for y in 0..y1 - y0 {
let (from, to, n) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * 100 + x0) * 4) as usize, ((x1 - x0) * 4) as usize);
patched[to..to + n].copy_from_slice(&part.pixels[from..from + n]);
}
assert!(patched == after.pixels, "drawn again in {area:?}, it differs from drawing it all");
assert!(before.pixels != after.pixels, "the bitmaps show");
// Other files change nothing; art of a brush or a symbol showing the file changes everything.
assert_eq!(placed_area(&d, &files(&["area-z"])), Changed::Nothing);
let mut brushed = d.clone();
let scatter = vectorcraft_brush::Scatter { art: placed_node(66, "area-a", 40.0, 20.0, Affine::IDENTITY), ..Default::default() };
let brush = vectorcraft_brush::Brush { name: "Cards".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) };
vectorcraft_brush::store(&mut brushed, &[brush]);
assert_eq!(placed_area(&brushed, &only_a), Changed::Everything);
d.symbols.push(vectorcraft_doc::Symbol { name: "Card".into(), art: Arc::new(placed_node(65, "area-a", 40.0, 20.0, Affine::IDENTITY)) });
assert_eq!(placed_area(&d, &only_a), Changed::Everything);
}
#[test]
fn a_placed_document_kept_as_its_preview_draws_the_preview() {
let _serial = serial();
let mut d = doc("preview-1");
// Only the preview (a 20×10 green PNG) is there, and the file can't be read here.
let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([0, 200, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut blob = ImageBlob::new("image/png", png);
blob.proxy = Some(blob.bytes.clone());
d.images.insert("preview-1".into(), blob);
let exact = render(&d, false);
assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box");
assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]);
// On screen past the preview's resolution (it has 0.5 px a point; this is 2): still the preview.
render(&d, true);
settle();
let shown = render(&d, true);
assert_eq!(&shown.pixel(50, 30)[..3], &[0, 200, 0]);
}
#[test]
fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() {
let _serial = serial();
let d = doc_with("loop-1", b"loop");
let t = std::time::Instant::now();
let r = render(&d, false);
assert!(t.elapsed().as_secs() < 10);
assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]);
// On screen too.
render(&d, true);
settle();
assert_eq!(&render(&d, true).pixel(50, 30)[..3], &[0, 0, 255]);
}
+423
View File
@@ -0,0 +1,423 @@
//! Effect › Rasterize on the canvas: the art drawn as square pixels of the effect's resolution
//! over its bounds and the room around them, on white or on nothing, its paths left as they are.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Effect, Node};
use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes};
use crate::*;
/// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose
/// raster effects resolution is `ppi`.
fn doc(path: PathData, fx: &[(&str, Value)], ppi: f64) -> Document {
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = ppi;
let l = d.layers[0].id;
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
n.appearance.effects = fx.iter().map(|(id, params)| Effect { id: (*id).into(), params: params.clone(), visible: true }).collect();
d.insert(Some(l), 0, n).unwrap();
d
}
/// A right triangle over 10..30 pt whose long side runs diagonally.
fn triangle() -> PathData {
let corner = |x, y| Anchor::corner(Point::new(x, y));
PathData::single(SubPath::new(vec![corner(10.0, 10.0), corner(30.0, 10.0), corner(10.0, 30.0)], true))
}
/// `d` drawn at `zoom` device pixels a point on a transparent canvas `size` pixels square.
fn draw(d: &Document, zoom: f64, size: u32) -> Rendered {
Renderer::new().render(d, size, size, Affine::scale(zoom), &RenderOptions { background: None, artboards: false, ..Default::default() })
}
fn pixel(r: &Rendered, x: usize, y: usize) -> [u8; 4] {
let i = (y * r.width as usize + x) * 4;
[r.pixels[i], r.pixels[i + 1], r.pixels[i + 2], r.pixels[i + 3]]
}
/// Whether each of the `n` × `n` squares `side` device pixels wide from (`at`, `at`) is one color.
fn squares_are_flat(r: &Rendered, at: usize, side: usize, n: usize) -> bool {
(0..n * n).all(|k| {
let (x0, y0) = (at + (k % n) * side, at + (k / n) * side);
let first = pixel(r, x0, y0);
(0..side * side).all(|i| pixel(r, x0 + i % side, y0 + i / side) == first)
})
}
#[test]
fn rasterize_draws_the_art_in_pixels_of_its_resolution() {
// (params, the document's resolution, device pixels a point, device pixels an image pixel)
let cases = [
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 10.0, 10),
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 2.0, 2),
(json!({"useDocumentResolution": false, "resolution": 300}), 72.0, 25.0, 6),
(json!({}), 150.0, 25.0, 12),
];
for (params, ppi, zoom, side) in cases {
let at = (10.0 * zoom) as usize;
// The whole image pixels inside the 20 pt triangle's bounds.
let n = (20.0 * zoom) as usize / side;
let canvas = (40.0 * zoom) as u32;
let rasterized = draw(&doc(triangle(), &[("rasterize", params.clone())], ppi), zoom, canvas);
assert!(squares_are_flat(&rasterized, at, side, n), "{params}");
// Along the diagonal an image pixel takes the mean of the art it covers.
let partly = (0..n).map(|k| pixel(&rasterized, at + k * side, at + (n - 1 - k) * side)[3]).filter(|a| *a > 0 && *a < 255).count();
assert!(partly > n / 2, "{params}: {partly} of {n}");
// Without the effect the diagonal edge runs through those squares.
assert!(!squares_are_flat(&draw(&doc(triangle(), &[], ppi), zoom, canvas), at, side, n));
}
}
#[test]
fn rasterize_is_drawn_where_only_its_image_reaches_the_view() {
// At 1 ppi a pixel of the image is 72 pt wide: the 20 pt square's image runs to 82 pt.
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 1.0);
// A view of 40..80 pt across, where only the image's white reaches.
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-40.0, 0.0)), &opts);
assert_eq!(pixel(&r, 20, 20)[3], 255);
// A blur of 4 pt (6 pt of spread) and 10 pt of white around it: the white reaches 46 pt.
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"background": "white", "addAround": 10}))];
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0);
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-45.0, 0.0)), &opts);
assert_eq!(pixel(&r, 0, 20), [255; 4]);
// A fill's own Rasterize with 100 pt of white around: the white reaches 130 pt.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect {
id: "rasterize".into(),
params: json!({"background": "white", "addAround": 100}),
visible: true,
});
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-115.0, 0.0)), &opts);
assert_eq!(pixel(&r, 5, 20), [255; 4]);
}
#[test]
fn art_that_paints_nothing_takes_no_room_in_the_image() {
// A second fill, hidden, under a Gaussian Blur that would spread 15 pt.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
let mut hidden = n.appearance.items[0].clone();
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
f.visible = false;
f.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 10}), visible: true });
}
n.appearance.items.push(hidden.clone());
// A third, hidden too, offset 5 pt outward.
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
f.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 5}), visible: true }];
}
n.appearance.items.push(hidden);
let r = draw(&d, 10.0, 400);
assert_eq!((pixel(&r, 105, 105), pixel(&r, 95, 200)[3]), ([255, 0, 0, 255], 0));
// Nor does a hidden member of a group.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let mut away = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), Appearance::default());
away.visible = false;
let l = d.layers[0].id;
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
members.push(Arc::new(away));
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
// Nor does a Shaper group's source art, its first member, which paints nothing.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let none = Appearance::basic(Paint::None, Paint::None, 0.0);
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), none);
let l = d.layers[0].id;
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
members.insert(0, Arc::new(source));
let mut group = Node::group(d.alloc_id(), members);
group.extras_mut().shaper = Some(Box::new(vectorcraft_doc::shaper::ShaperSpec { faces: vec![], edges: vec![] }));
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
// Nor does the path where its only fill's own Transform effect moves the fill away from it.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
f.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40}), visible: true });
}
let r = draw(&d, 10.0, 800);
assert_eq!((pixel(&r, 600, 200), pixel(&r, 200, 200)[3]), ([255, 0, 0, 255], 0));
}
#[test]
fn rasterize_covers_the_share_of_a_screen_pixel_its_image_covers() {
// A 1 pt square from x = 10.25 at 72 ppi and 100%: its image pixel covers a quarter of
// screen column 11, on white and on nothing.
let square = || shapes::rectangle(Rect::new(10.25, 10.0, 11.25, 11.0));
for (params, want) in [(json!({}), [64, 0, 0, 64]), (json!({"background": "white"}), [64, 0, 0, 64])] {
let r = draw(&doc(square(), &[("rasterize", params.clone())], 72.0), 1.0, 20);
assert_eq!(pixel(&r, 11, 10), want, "{params}");
assert_eq!(pixel(&r, 12, 10)[3], 0, "{params}");
}
}
#[test]
fn objects_wider_than_a_raster_can_be_are_drawn_with_their_pixel_effects() {
// 65,530 pt across at 100%: wider than the rasters the effects are made in can safely be.
let bar = || shapes::rectangle(Rect::new(0.0, 10.0, 65_530.0, 11.0));
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
for fx in [("rasterize", json!({"background": "white"})), ("blur.gaussian", json!({"radius": 1}))] {
let r = Renderer::new().render(&doc(bar(), std::slice::from_ref(&fx), 72.0), 40, 20, Affine::translate((-65_500.0, 0.0)), &opts);
assert!(pixel(&r, 10, 10)[3] > 0, "{}", fx.0);
}
}
#[test]
fn a_stack_of_ten_thousand_rasterize_effects_is_drawn() {
let fx = vec![("rasterize", json!({"background": "white"})); 10_000];
let r = draw(&doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0), 1.0, 40);
assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]);
}
#[test]
fn a_stack_of_rasterize_effects_with_shadows_draws_the_content_of_each_once() {
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"}));
let glow = ("stylize.outerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#00ff00"}));
// Five Rasterize effects, each followed by a drop shadow and an outer glow: on a 20 pt square
// at 72 ppi, and on a 300 pt square at 2,400 ppi, whose images are too large for the canvas to
// draw on their own grid.
for (ppi, side, zoom) in [(72.0, 20.0, 1.0), (2400.0, 300.0, 0.1)] {
let rasterize = ("rasterize", json!({"useDocumentResolution": false, "resolution": ppi}));
let fx: Vec<_> = (0..5).flat_map(|_| [rasterize.clone(), shadow.clone(), glow.clone()]).collect();
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side)), &fx, 72.0);
let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get);
let before = drawn();
let r = draw(&d, zoom, 40);
assert_eq!(drawn() - before, 5, "{ppi} ppi");
assert_eq!(pixel(&r, 15, 15), [255, 0, 0, 255], "{ppi} ppi");
}
}
#[test]
fn a_new_document_resolution_redraws_a_rasterized_member_of_a_blurred_group() {
let mut d = doc(triangle(), &[("rasterize", json!({}))], 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 0.5}), visible: true });
d.insert(Some(l), 0, group).unwrap();
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
let mut r = Renderer::new();
r.render(&d, 400, 400, Affine::scale(10.0), &opts);
d.raster_effects_ppi = 18.0;
let again = r.render(&d, 400, 400, Affine::scale(10.0), &opts);
assert_eq!(again.pixels, Renderer::new().render(&d, 400, 400, Affine::scale(10.0), &opts).pixels);
}
#[test]
fn an_export_draws_the_image_with_the_effects_own_anti_aliasing() {
// A supersampled Art Optimized export draws its art with hard edges and then averages them;
// the effect's image keeps its smooth diagonal.
let d = doc(triangle(), &[("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))], 72.0);
let opts = RenderOptions { background: None, artboards: false, anti_alias: AntiAlias::Art, supersample: true, ..Default::default() };
let r = Renderer::new().render_region_with(&d, Rect::new(0.0, 0.0, 40.0, 40.0), 10.0, &opts);
let partly = (0..20).map(|k| pixel(&r, 105 + k * 10, 105 + (19 - k) * 10)[3]).filter(|a| *a > 0 && *a < 255).count();
assert!(partly > 10, "{partly} of 20");
}
#[test]
fn rasterize_without_anti_aliasing_has_hard_edges_at_any_zoom() {
let fx = [("rasterize", json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}))];
for zoom in [10.0, 1.0] {
let r = draw(&doc(triangle(), &fx, 72.0), zoom, (40.0 * zoom) as u32);
let alphas: Vec<u8> = r.pixels.chunks(4).map(|p| p[3]).collect();
assert!(alphas.iter().all(|a| *a == 0 || *a == 255), "zoom {zoom}");
assert!(alphas.contains(&255));
}
// A bar 0.6 pt wide covers the center of the image pixel it starts, which it then fills, and
// which at 50% covers half a screen pixel: it shows there, partly covering it.
let bar = || shapes::rectangle(Rect::new(10.3, 10.0, 10.9, 30.0));
assert_eq!(pixel(&draw(&doc(bar(), &fx, 72.0), 10.0, 400), 105, 200)[3], 255);
let half = pixel(&draw(&doc(bar(), &fx, 72.0), 0.5, 20), 5, 10)[3];
assert!(half > 0 && half < 255, "{half}");
// At 100% that image pixel, 10.3..11.3 pt, covers 70% of screen column 10 and 30% of column 11.
let full = draw(&doc(bar(), &fx, 72.0), 1.0, 40);
assert_eq!((pixel(&full, 10, 20)[3], pixel(&full, 11, 20)[3]), (178, 77));
}
#[test]
fn rasterize_keeps_what_an_earlier_effect_spreads_beyond_the_bounds() {
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))];
let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400);
// 1.5 pt left of the triangle's bounds, the blur still shows.
assert!(pixel(&r, 85, 150)[3] > 0);
// So does a blur of the fill's own.
let mut d = doc(triangle(), &fx[1..], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 4}), visible: true });
assert!(pixel(&draw(&d, 10.0, 400), 85, 150)[3] > 0);
// A fill's own Add Around that stays inside a 40 pt stroke takes no room past the stroke.
let mut d = doc(shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect { id: "rasterize".into(), params: json!({"addAround": 10}), visible: true });
n.appearance.items[1] = vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 40.0));
let r = draw(&d, 2.0, 200);
assert_eq!((pixel(&r, 156, 100)[3], pixel(&r, 170, 100)[3]), (255, 0));
}
#[test]
fn rasterize_and_shadows_paint_in_stack_order() {
let square = || shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0));
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 5, "y": 5, "blur": 0, "color": "#0000ff"}));
let gray = ("rasterize", json!({"colorModel": "grayscale"}));
// Below the square's corner, only the shadow paints.
let first = draw(&doc(square(), &[shadow.clone(), gray.clone()], 72.0), 10.0, 400);
let [r, g, b, a] = pixel(&first, 330, 330);
assert!(a == 255 && r == g && g == b, "the shadow is rasterized in grayscale: {:?}", [r, g, b, a]);
let after = draw(&doc(square(), &[gray.clone(), shadow.clone()], 72.0), 10.0, 400);
assert_eq!(pixel(&after, 330, 330), [0, 0, 255, 255], "a shadow after Rasterize keeps its color");
// A shadow after Rasterize is the image's: a circle's square image on white casts a square shadow.
let circle = shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
let cast = draw(&doc(circle, &[("rasterize", json!({"background": "white"})), shadow.clone()], 72.0), 10.0, 400);
assert_eq!(pixel(&cast, 320, 160), [0, 0, 255, 255]);
// Between two Rasterize effects, the second one takes it in.
let between = draw(&doc(square(), &[("rasterize", json!({})), shadow.clone(), gray], 72.0), 10.0, 400);
let [r, g, b, _] = pixel(&between, 330, 330);
assert!(r == g && g == b && b > 0, "{:?}", [r, g, b]);
// A member's shadow is part of a group's art.
let mut d = doc(square(), std::slice::from_ref(&shadow), 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
// So are a member's Crop Marks: the top right mark runs from x 39 to 57 at y 10.
let mut d = doc(square(), &[("cropMarks", json!({}))], 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
d.insert(Some(l), 0, group).unwrap();
let r = draw(&d, 10.0, 400);
assert!((95..105).any(|y| pixel(&r, 395, y)[3] > 0));
// So is the shadow in a symbol's art, for its instance.
let mut d = Document::new(100.0, 100.0);
let mut art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true });
d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) });
instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, instance).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
}
#[test]
fn rasterize_takes_in_the_art_of_a_3d_effect() {
let square = || shapes::rectangle(Rect::new(30.0, 30.0, 50.0, 50.0));
let extrude = ("threeD.extrude", json!({}));
let solid = draw(&doc(square(), std::slice::from_ref(&extrude), 72.0), 10.0, 800);
// Pixels the extruded art covers outside the square.
let outside: Vec<(usize, usize)> = (0..800 * 800)
.map(|i| (i % 800, i / 800))
.filter(|(x, y)| !((300..500).contains(x) && (300..500).contains(y)) && pixel(&solid, *x, *y)[3] == 255)
.collect();
assert!(!outside.is_empty());
let rasterized = draw(&doc(square(), &[extrude, ("rasterize", json!({}))], 72.0), 10.0, 800);
assert!(outside.iter().all(|(x, y)| pixel(&rasterized, *x, *y)[3] > 0));
}
#[test]
fn rasterize_takes_in_brush_art_wider_than_the_stroke() {
// A 1 pt line under the 10 pt. Oval brush, which paints about 2 pt from its center line.
let mut d = Document::new(200.0, 100.0);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("10 pt. Oval".into());
let mut n = Node::path(d.alloc_id(), shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap);
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() });
assert!(pixel(&r, 100, 52)[3] > 128, "{:?}", pixel(&r, 100, 52));
}
#[test]
fn rasterize_takes_in_type_that_its_own_fills_effects_move() {
// "IIII" is set at 40 pt from x 5 pt with a red fill of its own under the characters, and a
// Transform effect on that fill moves it 100 pt to the right.
let typed = |fx: &[Effect]| {
let mut d = Document::new(200.0, 100.0);
let style = vectorcraft_doc::text::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::text::TextObject::point(Point::new(5.0, 60.0), "IIII", style);
let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Text(Box::new(text)));
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 100}), visible: true });
n.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(fill));
n.appearance.set_contents_at(1);
n.appearance.effects = fx.to_vec();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() })
};
// The pixels right of x 100 that the moved fill paints.
let moved = |r: &Rendered| (0..200 * 100).filter(|i| i % 200 >= 100 && pixel(r, i % 200, i / 200)[0] > 128).count();
assert!(moved(&typed(&[])) > 50);
let rasterize = Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true };
assert!(moved(&typed(&[rasterize])) > 50);
}
#[test]
fn rasterize_fills_its_area_on_white_and_add_around_grows_it() {
let circle = || shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
let at = |params: Value| draw(&doc(circle(), &[("rasterize", params)], 72.0), 10.0, 400);
let white = at(json!({"background": "white"}));
// Inside the bounds, outside the circle: white; outside the bounds: nothing.
assert_eq!((pixel(&white, 105, 105), pixel(&white, 200, 200)), ([255; 4], [255, 0, 0, 255]));
assert_eq!(pixel(&white, 95, 200)[3], 0);
let clear = at(json!({"background": "transparent"}));
assert_eq!((pixel(&clear, 105, 105)[3], pixel(&clear, 200, 200)), (0, [255, 0, 0, 255]));
// Add Around Object: 2 pt more on every side.
let around = at(json!({"background": "white", "addAround": 2}));
assert_eq!((pixel(&around, 85, 200), pixel(&around, 75, 200)[3]), ([255; 4], 0));
// A second Rasterize makes its image over the first one's whole pixels, no larger.
let twice = draw(&doc(circle(), &[("rasterize", json!({})), ("rasterize", json!({"background": "white"}))], 72.0), 10.0, 400);
assert_eq!((pixel(&twice, 105, 105), pixel(&twice, 95, 200)[3], pixel(&twice, 305, 200)[3]), ([255; 4], 0, 0));
// A Feather or an Inner Glow after a Rasterize on white works on the image's square edge.
let white = ("rasterize", json!({"background": "white"}));
let feathered = draw(&doc(circle(), &[white.clone(), ("stylize.feather", json!({"radius": 1}))], 72.0), 10.0, 400);
assert!(pixel(&feathered, 106, 106)[3] > 0);
let glow = ("stylize.innerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#ff0000", "source": "edge"}));
let glowing = draw(&doc(circle(), &[white.clone(), glow.clone()], 72.0), 10.0, 400);
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
// With a Rasterize on nothing between them, the glow follows the same edge.
let glowing = draw(&doc(circle(), &[white, ("rasterize", json!({})), glow], 72.0), 10.0, 400);
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
// Create Clipping Mask: the white stays inside the circle.
let clipped = at(json!({"background": "white", "clippingMask": true}));
assert_eq!((pixel(&clipped, 105, 105)[3], pixel(&clipped, 200, 200)), (0, [255, 0, 0, 255]));
// Inside the outline the white shows where the art paints nothing: a circle stroked only.
let mut ring = doc(circle(), &[], 72.0);
let l = ring.layers[0].id;
let n = Arc::make_mut(&mut ring.children_mut(Some(l)).unwrap()[0]);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white", "clippingMask": true}), visible: true });
let ring = draw(&ring, 10.0, 400);
assert_eq!((pixel(&ring, 200, 200), pixel(&ring, 105, 105)[3]), ([255; 4], 0));
// A stroke 1 pt wide widens the image by half a point a side, as it widens the visual bounds.
let mut stroked = doc(circle(), &[], 72.0);
let n = Arc::make_mut(&mut stroked.children_mut(Some(l)).unwrap()[0]);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
n.appearance.effects.push(Effect {
id: "rasterize".into(),
params: json!({"background": "white", "resolution": 720, "useDocumentResolution": false}),
visible: true,
});
let stroked = draw(&stroked, 10.0, 400);
assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0));
}
+31
View File
@@ -0,0 +1,31 @@
//! Document Setup options the renderer honours.
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use crate::*;
fn doc_with_red_image() -> Document {
let mut d = Document::new(40.0, 40.0);
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
d
}
#[test]
fn outline_mode_draws_images_in_grey_only_when_the_setup_asks() {
let mut d = doc_with_red_image();
let outline = RenderOptions { outline: true, ..Default::default() };
let centre = |d: &Document| Renderer::new().render(d, 40, 40, Affine::IDENTITY, &outline).pixel(20, 20);
assert_eq!(centre(&d)[3], 0, "outline mode draws only the image frame");
d.setup.outline_images = true;
let [r, g, b, a] = centre(&d);
// Red's luma (0.2126 × 255 ≈ 54) in every channel.
assert_eq!(a, 255);
assert!(r == g && g == b && (50..=58).contains(&r), "{:?}", [r, g, b]);
// Preview mode keeps the colours.
assert_eq!(Renderer::new().render(&d, 40, 40, Affine::IDENTITY, &RenderOptions::default()).pixel(20, 20), [255, 0, 0, 255]);
}
+114
View File
@@ -0,0 +1,114 @@
//! Gradients along and across strokes: positions along the path and across the stroke, no seams
//! between the slices, translucent stops painted once.
use vectorcraft_color::{Gradient, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeGradientMode, StrokeLayer};
use vectorcraft_geom::PathData;
use crate::*;
const SIZE: u32 = 200;
/// `bp` stroked 20 pt with `g` laid `mode`, on a 200×200 page (white, or transparent).
fn render(bp: &BezPath, g: Gradient, mode: StrokeGradientMode, white: bool) -> Rendered {
let mut st = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(g))), 20.0);
st.gradient_mode = mode;
let mut d = Document::new(SIZE as f64, SIZE as f64);
let n = Node::path(d.alloc_id(), PathData::from_bezpath(bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let background = white.then_some([255, 255, 255, 255]);
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background, ..Default::default() })
}
fn line(a: (f64, f64), b: (f64, f64)) -> BezPath {
let mut p = BezPath::new();
p.move_to(a);
p.line_to(b);
p
}
/// Grey level (0..1) at the centre of pixel (x, y).
fn grey(img: &Rendered, x: f64, y: f64) -> f64 {
img.pixel(x as u32, y as u32)[0] as f64 / 255.0
}
#[test]
fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() {
for (a, b) in [((20.0, 100.0), (180.0, 100.0)), ((100.0, 180.0), (100.0, 20.0)), ((30.0, 30.0), (170.0, 170.0))] {
let img = render(&line(a, b), Gradient::default(), StrokeGradientMode::Along, true);
for (f, off) in [(0.25, 0.0), (0.25, 6.0), (0.75, -6.0), (0.5, 0.0)] {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
let (x, y) = (a.0 + dx * f - dy / len * off, a.1 + dy * f + dx / len * off);
let want = 1.0 - f;
assert!((grey(&img, x, y) - want).abs() < 0.03, "{a:?}→{b:?} at {f}: {} vs {want}", grey(&img, x, y));
}
}
}
#[test]
fn across_runs_from_the_left_edge_to_the_right() {
// Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110).
let img = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Across, true);
for (y, want) in [(91.0, 0.925), (95.0, 0.725), (100.0, 0.475), (108.0, 0.075)] {
for x in [40.0, 100.0, 160.0] {
assert!((grey(&img, x, y) - want).abs() < 0.03, "({x}, {y}): {} vs {want}", grey(&img, x, y));
}
}
// Within (the default) the gradient lies on the page: left to right here.
let within = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Within, true);
assert!(grey(&within, 40.0, 91.0) > grey(&within, 160.0, 91.0) + 0.5);
}
#[test]
fn slices_meet_without_seams_and_translucent_stops_paint_once() {
let circle = kurbo::Circle::new((100.0, 100.0), 60.0).to_path(0.01);
let mut half = Gradient::default();
for s in &mut half.stops {
s.opacity = 0.5;
}
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let solid = render(&circle, Gradient::default(), mode, false);
let translucent = render(&circle, half.clone(), mode, false);
for k in 0..720 {
let a = k as f64 * std::f64::consts::TAU / 720.0;
for r in [55.0, 60.0, 65.0] {
let (x, y) = (100.0 + r * a.cos(), 100.0 + r * a.sin());
assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a seam at ({x:.1}, {y:.1})");
let alpha = translucent.pixel(x as u32, y as u32)[3] as i32;
assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x:.1}, {y:.1})");
}
}
}
}
#[test]
fn corners_and_tight_turns_have_no_holes() {
let mut corner = line((40.0, 40.0), (160.0, 40.0));
corner.line_to((160.0, 160.0));
corner.line_to((60.0, 120.0));
let tight = kurbo::Circle::new((100.0, 100.0), 6.0).to_path(0.01);
let mut half = Gradient::default();
for s in &mut half.stops {
s.opacity = 0.5;
}
for mode in [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across] {
for (bp, probes) in [
(
&corner,
vec![(160.0, 40.0), (156.0, 36.0), (165.0, 45.0), (163.0, 37.0), (157.0, 43.0), (160.0, 160.0), (166.0, 158.0), (152.0, 157.0)],
),
// The stroke's inside edge folds over the centre: a 4 pt hole stays there.
(&tight, vec![(105.5, 100.0), (95.0, 103.0), (108.0, 100.0), (100.0, 113.0), (91.0, 92.0), (100.0, 94.0)]),
] {
let solid = render(bp, Gradient::default(), mode, false);
let translucent = render(bp, half.clone(), mode, false);
for (x, y) in probes {
assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a hole at ({x}, {y})");
let alpha = translucent.pixel(x as u32, y as u32)[3] as i32;
assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x}, {y})");
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Pattern editing mode: the tile edge in the Tile Edge Color, and Show Swatch Bounds.
use crate::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::pattern::{PatternDef, PatternEdit, TileType};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
/// A document editing a 20×20 pattern tile of type `tt` (a red 10×10 square in its corner, no
/// preview copies), its tile edge and swatch bounds shown as asked.
fn editing(tt: TileType, edge: bool, bounds: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let red = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
let mut def = PatternDef::new("Checks", vec![Arc::new(red.clone())]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
(def.tile_type, def.copies, def.show_tile_edge, def.show_swatch_bounds) = (tt, 1, edge, bounds);
d.patterns.push(def);
let layer = d.add_layer(Some("Pattern Editing Mode"));
d.insert(Some(layer), 0, red).unwrap();
d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer, original: None });
d
}
/// `d` at 1:1 with the tile's corner at the centre of pixel (50, 50), on white.
fn render(d: &Document, tile_edge: Option<[u8; 3]>) -> Rendered {
let opts = RenderOptions { background: Some([255; 4]), tile_edge: tile_edge.unwrap_or(RenderOptions::default().tile_edge), ..Default::default() };
Renderer::new().render(d, 120, 120, Affine::translate((50.5, 50.5)), &opts)
}
fn is_white(p: [u8; 4]) -> bool {
p[0] > 245 && p[1] > 245 && p[2] > 245
}
#[test]
fn the_tile_edge_takes_the_tile_edge_color() {
let green = render(&editing(TileType::Grid, true, false), Some([0, 200, 0]));
// The left edge below the red square, and the bottom edge.
for (x, y) in [(50, 65), (60, 70), (70, 55)] {
let p = green.pixel(x, y);
assert!(p[1] > 150 && p[0] < 100 && p[2] < 100, "({x},{y}) {p:?}");
}
assert!(is_white(green.pixel(65, 65)), "inside the tile");
// The default is the first layer colour (light blue).
let [r, g, b] = RenderOptions::default().tile_edge;
let p = render(&editing(TileType::Grid, true, false), None).pixel(50, 65);
assert!(p[0].abs_diff(r) < 30 && p[1].abs_diff(g) < 30 && p[2].abs_diff(b) < 30, "{p:?}");
// Hidden: nothing there.
assert!(is_white(render(&editing(TileType::Grid, false, false), Some([0, 200, 0])).pixel(50, 65)));
}
#[test]
fn swatch_bounds_outline_the_period_dashed() {
// Brick by row repeats every two rows: the swatch is 20×40 from the tile's corner.
let tt = TileType::BrickByRow { offset: 0.5 };
let on = render(&editing(tt, false, true), Some([0, 200, 0]));
let green = |p: [u8; 4]| p[1] > 150 && p[0] < 100 && p[2] < 100;
let bottom: Vec<bool> = (51..70).map(|x| green(on.pixel(x, 90))).collect();
assert!(bottom.iter().any(|g| *g) && bottom.iter().any(|g| !*g), "dashed: {bottom:?}");
assert!((51..90).any(|y| green(on.pixel(70, y))), "right side");
assert!(!(51..70).any(|x| green(on.pixel(x, 70))), "the tile edge itself stays hidden");
// Off: no outline.
let off = render(&editing(tt, false, false), Some([0, 200, 0]));
assert!((51..70).all(|x| is_white(off.pixel(x, 90))));
}
/// #3261: exports (which leave templates and guides out) don't draw the tile edge or the swatch
/// bounds, but do draw the tile's art.
#[test]
fn exports_leave_the_tile_edge_and_swatch_bounds_out() {
let d = editing(TileType::BrickByRow { offset: 0.5 }, true, true);
let opts = RenderOptions { background: Some([255; 4]), tile_edge: [0, 200, 0], skip_templates: true, ..Default::default() };
let out = Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts);
assert!(is_white(out.pixel(50, 65)), "tile edge: {:?}", out.pixel(50, 65));
assert!((51..70).all(|x| is_white(out.pixel(x, 90))), "swatch bounds");
let p = out.pixel(55, 55);
assert!(p[0] > 200 && p[1] < 60 && p[2] < 60, "the red square is still drawn: {p:?}");
}
/// #1534: a path made into a guide is drawn in the guide colour and style (Preferences › Guides &
/// Grid), as ruler guides are: a line, or dots with gaps between them.
#[test]
fn guide_paths_take_the_guide_color_and_style() {
let mut d = Document::new(200.0, 200.0);
let (layer, id) = (d.layers[0].id, d.alloc_id());
let mut n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { guide, .. } = &mut n.kind {
*guide = true;
}
d.insert(Some(layer), 0, n).unwrap();
let draw = |guide| {
let opts = RenderOptions { background: Some([255; 4]), guide, ..Default::default() };
Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts)
};
// Along the top edge of the square (y = 50), between its corners: the pixels the red covers.
let inked = |r: &Rendered| (55..105).filter(|x| r.pixel(*x, 50)[1] < 64).count();
let line = draw(([200, 0, 0], false));
let p = line.pixel(80, 50);
assert!(p[0] > 150 && p[1] < 100 && p[2] < 100, "{p:?}");
assert_eq!(inked(&line), 50);
let dotted = draw(([200, 0, 0], true));
let dots = inked(&dotted);
assert!((10..40).contains(&dots), "{dots}: {:?}", (45..70).map(|x| dotted.pixel(x, 50)[1]).collect::<Vec<_>>());
let default = draw(RenderOptions::default().guide);
assert!((55..105).all(|x| !is_white(default.pixel(x, 50))), "a line by default");
}
+365
View File
@@ -0,0 +1,365 @@
//! Effect rasters cropped to the view, or made at a lower resolution, are kept while the view
//! stays as it is: dragging other art over an object whose effects reach far past the view (a wide
//! blur, or any blur zoomed in) draws its raster from the last frame. A cropped raster made again
//! after a pan is kept while later pans stay within the margin it is made with.
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::{RenderOptions, Renderer};
/// An A4 page filled by a blue rectangle under `effects`, and a small orange square over it
/// (returned with the document): the square is the art a drag moves.
fn backdrop(effects: &[(&str, serde_json::Value)]) -> (Document, NodeId) {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)),
Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0),
);
back.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
d.insert(Some(layer), 0, back).unwrap();
let id = d.alloc_id();
let square = Node::path(
id,
shapes::rectangle(Rect::new(280.0, 400.0, 300.0, 420.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0),
);
d.insert(Some(layer), 1, square).unwrap();
(d, id)
}
/// A `w` × `h` view at `zoom`, centered on the page.
fn view(zoom: f64, (w, h): (u32, u32)) -> Affine {
Affine::translate((f64::from(w) / 2.0, f64::from(h) / 2.0)) * Affine::scale(zoom) * Affine::translate((-297.5, -421.0))
}
/// `base` with the square moved by `step` steps of a drag.
fn moved(base: &Document, id: NodeId, step: u32, zoom: f64) -> Document {
let mut d = base.clone();
let by = (f64::from(step) * 3.0 / zoom, f64::from(step) * 2.0 / zoom);
d.node_mut(id).unwrap().transform(Affine::translate(by), false);
d
}
/// A case's name, its effects (id and params) and its zoom.
type Case = (&'static str, Vec<(&'static str, serde_json::Value)>, f64);
/// Every effect whose raster can be cropped to the view or made coarser, with a zoom at which it
/// is: wide blurs at the fit zoom, narrow ones zoomed in, a Photoshop-style filter, a shadow and a
/// glow.
fn cases() -> Vec<Case> {
vec![
("Gaussian Blur 500 pt", vec![("blur.gaussian", json!({"radius": 500}))], 1.6),
("Gaussian Blur 100 pt", vec![("blur.gaussian", json!({"radius": 100}))], 1.6),
("Gaussian Blur 20 pt", vec![("blur.gaussian", json!({"radius": 20}))], 4.0),
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))], 16.0),
("Feather 20 pt", vec![("stylize.feather", json!({"radius": 20}))], 4.0),
("Craquelure", vec![("texture.craquelure", json!({}))], 4.0),
("Drop Shadow blur 100 pt", vec![("stylize.dropShadow", json!({"blur": 100, "x": 20, "y": 20}))], 1.6),
("Drop Shadow blur 10 pt", vec![("stylize.dropShadow", json!({"blur": 10, "x": 5, "y": 5}))], 8.0),
("Outer Glow blur 100 pt", vec![("stylize.outerGlow", json!({"blur": 100}))], 1.6),
]
}
/// A drag of the square over the backdrop draws the backdrop's effects from the rasters of the
/// frame before the drag, at full resolution: no frame of the drag previews them at a drag's lower
/// resolution, and each draws as a fresh renderer draws its still frame.
#[test]
fn a_drag_over_art_with_effects_past_the_view_draws_their_rasters_kept() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, xf, &opts);
for step in 1..4 {
let d = moved(&base, id, step, zoom);
let drag = r.render(&d, size.0, size.1, xf, &live);
assert_eq!(r.stats.previewed, 0, "{name}: drag frame {step} drew the backdrop's effects from the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, xf, &opts);
assert!(drag.pixels == still.pixels, "{name}: drag frame {step} draws as a still frame");
}
}
}
/// A pan by whole pixels or a zoom makes the rasters cropped to the view again: the frame after it draws as a
/// fresh renderer draws it.
#[test]
fn rasters_cropped_to_the_view_follow_a_pan_and_a_zoom() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (d, _) = backdrop(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, view(zoom, size), &opts);
for xf in [Affine::translate((37.0, -21.0)) * view(zoom, size), view(zoom * 1.25, size)] {
let image = r.render(&d, size.0, size.1, xf, &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(image.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "{name}");
}
}
}
/// A 6000 × 4000 pt image (300 × 200 pixels, 20 pt a pixel) under `effects`.
fn large_image(effects: &[(&str, serde_json::Value)]) -> Document {
let img = image::RgbaImage::from_fn(300, 200, |x, y| image::Rgba([(x * 7) as u8, (y * 13) as u8, ((x + y) * 3) as u8, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(6000.0, 4000.0);
d.images.insert("viewraster-pan".into(), ImageBlob::new("image/png", png));
let im = ImageObject {
key: "viewraster-pan".into(),
width: 300,
height: 200,
xf: Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
d
}
/// At 100 %, the Drop Shadow, Gaussian Blur and Feather rasters of a large image are cropped to the view. The pan
/// that makes one again at the same zoom makes it with a margin around the view, and later pans within the margin
/// (by up to 259 pixels from the frame that made them, and back) draw it without making any offscreen raster: each
/// such frame makes as many offscreen pixels as the same view drawn again, and draws as a fresh renderer draws it,
/// within a level a channel: an image sampled into a raster that starts at another pixel rounds by up to a level, as it
/// does in the frames of two fresh renderers whose views differ in size.
#[test]
fn pans_within_a_cropped_rasters_margin_draw_it_kept() {
let size = (640, 360);
let cases: Vec<Case> = vec![
("Drop Shadow", vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))], 1.0),
("Gaussian Blur", vec![("blur.gaussian", json!({"radius": 20}))], 1.0),
("Feather", vec![("stylize.feather", json!({"radius": 20}))], 1.0),
(
"Drop Shadow over a Drop Shadow",
vec![("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": 9}))],
1.0,
),
];
let counted = |r: &mut Renderer, d: &Document, xf: Affine, opts: &RenderOptions| {
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
let image = r.render(d, size.0, size.1, xf, opts);
(image, crate::OFFSCREEN_PIXELS.with(|n| n.get()))
};
for (name, effects, zoom) in cases {
let d = large_image(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::scale(zoom) * Affine::translate((-3000.0, -2000.0));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
// The pan that makes the rasters again, and the same view drawn again.
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
let (_, kept) = counted(&mut r, &d, at(37.0, 0.0), &opts);
for (dx, dy) in [(74.0, 0.0), (74.0, -37.0), (296.0, -37.0), (0.0, 0.0), (-222.0, 37.0)] {
let (image, made) = counted(&mut r, &d, at(dx, dy), &opts);
assert_eq!(made, kept, "{name}: the pan to ({dx}, {dy}) drew the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, at(dx, dy), &opts);
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(worst <= 1, "{name}: ({dx}, {dy}): a channel differs by {worst}");
}
}
}
/// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left
/// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster
/// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every
/// frame.
#[test]
fn fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept() {
let size = (640, 360);
let d = large_image(&[("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7}))]);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64, dy: f64| Affine::translate((320.0 + dx, 180.0 + dy)) * Affine::translate((-3000.0, -2000.0));
let counted = |r: &mut Renderer, xf: Affine| {
crate::OFFSCREEN_PIXELS.with(|n| n.set(0));
r.render(&d, size.0, size.1, xf, &opts);
crate::OFFSCREEN_PIXELS.with(|n| n.get())
};
let scrolls = [(0.37, 1.13), (-2.71, 0.0), (0.0, 0.45), (1.9, -0.83), (0.21, 0.21), (13.6, -4.4), (-0.5, 0.5), (7.77, 2.22)];
for whole in [true, false] {
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0, 0.0), &opts);
// The scroll that makes the raster again with its margin, and the same view drawn again.
r.render(&d, size.0, size.1, at(37.0, 0.0), &opts);
let kept = counted(&mut r, at(37.0, 0.0));
let (mut given, mut moved): ((f64, f64), (f64, f64)) = ((37.0, 0.0), (37.0, 0.0));
let mut made_again = 0;
for (k, (x, y)) in scrolls.into_iter().enumerate() {
given = (given.0 + x, given.1 + y);
moved = if whole { (given.0.round(), given.1.round()) } else { given };
let made = counted(&mut r, at(moved.0, moved.1));
if whole {
assert_eq!(made, kept, "scroll {k} to {moved:?} drew the raster kept");
} else if made > kept {
made_again += 1;
}
}
assert!((moved.0 - given.0).abs() <= 0.5 && (moved.1 - given.1).abs() <= 0.5, "moved {moved:?} for {given:?}");
if !whole {
assert_eq!(made_again, scrolls.len(), "scrolls by amounts that are not whole pixels make the raster again");
}
}
}
/// A group's shadow raster holds the shadows of the members outside the view: a pan that brings a member into view
/// draws its shadow from the raster kept as a fresh renderer draws it, for a raster of the group's whole reach (two
/// 100 pt squares) and for one cropped to the view and the margin a pan adds (two 3000 × 2000 pt rectangles).
#[test]
fn a_groups_shadow_raster_holds_the_members_outside_the_view() {
let size = (640, 360);
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
// The two members, the point the view is centered on, and the pans after the first frame.
let cases = [
("whole", [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)], (220.0, 50.0), vec![-300.0]),
("cropped", [Rect::new(0.0, 0.0, 3000.0, 2000.0), Rect::new(3400.0, 0.0, 6400.0, 2000.0)], (3000.0, 1000.0), vec![-37.0, -237.0]),
];
let mut wrong = vec![];
for (name, members, (cx, cy), pans) in cases {
let mut d = Document::new(6400.0, 2000.0);
let members = members.iter().map(|r| std::sync::Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), blue()))).collect();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, group).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dx: f64| Affine::translate((320.0 + dx, 180.0)) * Affine::translate((-cx, -cy));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0), &opts);
for dx in pans {
let image = r.render(&d, size.0, size.1, at(dx), &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
if image.pixels != fresh.render(&d, size.0, size.1, at(dx), &opts).pixels {
wrong.push(format!("{name}: panned by {dx}"));
}
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
/// While interacting, a group with a member outside the view that only moved draws its shadow from the raster of the
/// frame before, where a fresh renderer draws it: the moved silhouette holds that member too.
#[test]
fn a_moved_group_with_a_member_outside_the_view_draws_its_shadow_kept() {
let size = (640, 360);
let blue = || Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0);
let mut base = Document::new(700.0, 100.0);
let members = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(600.0, 0.0, 700.0, 100.0)]
.iter()
.map(|r| std::sync::Arc::new(Node::path(base.alloc_id(), shapes::rectangle(*r), blue())))
.collect();
let mut group = Node::group(base.alloc_id(), members);
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"blur": 20, "x": 7, "y": 7}), visible: true });
let (id, layer) = (group.id, base.layers[0].id);
base.insert(Some(layer), 0, group).unwrap();
let view = Affine::translate((100.0, 130.0));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, view, &opts);
let mut moved = base.clone();
moved.node_mut(id).unwrap().transform(Affine::translate((3.0, 2.0)), false);
let frame = r.render(&moved, size.0, size.1, view, &live);
assert!(r.stats.moved > 0, "drawn from the raster of the frame before");
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(frame.pixels == fresh.render(&moved, size.0, size.1, view, &opts).pixels);
}
/// A shadow made over an earlier cropped shadow covers only the rect the earlier one was made for. In a 2000 × 1200
/// view of the image's right edge, the pixel budget gives the earlier shadow, which also covers what the later one
/// reads 60 pixels below, a margin of 548 pixels and the later one 643: a pan of 600 pixels past the first makes both
/// again, and the frame draws as a fresh renderer draws it.
#[test]
fn a_shadow_over_a_cropped_shadow_keeps_to_the_earlier_ones_margin() {
let size = (2000, 1200);
let effects = [("stylize.dropShadow", json!({"blur": 20, "x": 7, "y": 7})), ("stylize.dropShadow", json!({"blur": 10, "x": -5, "y": -60}))];
let d = large_image(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let at = |dy: f64| Affine::translate((1000.0, 600.0 + dy)) * Affine::translate((-6000.0, -2000.0));
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, at(0.0), &opts);
r.render(&d, size.0, size.1, at(37.0), &opts);
for dy in [37.0 - 600.0, 37.0 + 600.0] {
let image = r.render(&d, size.0, size.1, at(dy), &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, at(dy), &opts);
let worst = image.pixels.iter().zip(&still.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert_eq!(worst, 0, "panned to {dy}");
}
}
/// Time a drag of the square over each backdrop in a 2560 × 1440 view: the first frame renders as
/// before a drag, the next eight as a drag's frames (`interacting` and `dragging`, as the canvas
/// renders them), then the frame after the drag. The hashes of that frame and of a fresh renderer's
/// frame compare builds.
/// Run with `cargo test --release -p vectorcraft-render backdrop_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn backdrop_drag_perf() {
let hash = |px: &[u8]| {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
px.hash(&mut h);
h.finish()
};
let size = (2560, 1440);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = crate::default_threads();
let start = std::time::Instant::now();
std::hint::black_box(r.render(&base, size.0, size.1, xf, &opts));
let first = start.elapsed().as_secs_f64() * 1000.0;
let (mut ms, mut previewed, mut doc) = (vec![], 0, base.clone());
for step in 1..9 {
doc = moved(&base, id, step, zoom);
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, size.0, size.1, xf, &live));
ms.push(start.elapsed().as_secs_f64() * 1000.0);
previewed += r.stats.previewed;
}
let start = std::time::Instant::now();
let still = r.render(&doc, size.0, size.1, xf, &opts);
let after = start.elapsed().as_secs_f64() * 1000.0;
let fresh = Renderer::new().render(&doc, size.0, size.1, xf, &opts);
ms.sort_by(f64::total_cmp);
println!(
"{name} at {:.0}%: first frame {first:.1} ms; drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
zoom * 100.0,
(ms[3] + ms[4]) * 0.5,
ms[0],
ms[7],
hash(&still.pixels),
hash(&fresh.pixels)
);
}
}