mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Type is drawn as its glyph outlines under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, through Flatten Transparency with Convert All Text to Outlines, and inside an envelope. Those outlines differed from the type the canvas draws in four ways. They had no underline or strikethrough bars. A character stroke on scaled type kept its unscaled weight. An unplaced gradient was fitted to the glyphs it painted instead of the layout box. A run's pattern tiles stayed in document space instead of scaling and turning with the type. A group's or layer's own fills and strokes also painted the glyphs of the type in it without the bars. The outlines now include each run's underline and strikethrough bars, as the canvas has drawn them since #850. Each run's path is made in text space with its unplaced gradients fixed to the layout box, then placed by the type's transform, which also scales its stroke weight and dashes and transforms its pattern tiles. The type's own fills and strokes have their unplaced gradients fixed to the bounding box of the layout box in the document, where the canvas fits them. Geometry effects that move or scale the outlines move and scale the type's gradients with them: the type's own effects, also inside an envelope and after Flatten Transparency has converted the type to outlines, and the effects of a group, symbol instance or envelope that holds the type. outline_text marks its group with a new node field, type_outlines, which is saved with the document. reshape_leaves maps the placed gradients in each marked group through the map that takes the group's bounds before the effects onto its bounds after them. A scale out of proportion stretches a radial gradient into an ellipse and turns the isolines of a diagonal linear gradient, as it does on type scaled by its own transform. Placed gradients outside those groups stay where they are.
623 lines
32 KiB
Rust
623 lines
32 KiB
Rust
//! 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, Symbol};
|
||
use vectorcraft_geom::{Point, shapes};
|
||
|
||
use super::*;
|
||
|
||
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(),
|
||
})),
|
||
);
|
||
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 { kind: 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 { kind: 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 { kind, stops: 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(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:?}");
|
||
}
|
||
|
||
#[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}");
|
||
}
|