Files
vectorcraft/crates/engine/src/tests/outlinestroke.rs
T
dotCooCoo 043e517ef6 Effects: a group's or object's Transform effect moves the raster effects inside it
A Transform effect on a group reshaped its members' paths but left the
raster effects inside it at their own values: a member's Drop Shadow
kept its 10 pt offset under a 200% Transform effect, and with Scale
Strokes & Effects on, it scaled by the mean scale without turning. The
same held for the raster effects of an object's own fills and strokes
under the object's Transform effect.

- A Transform effect that moves the art once now transforms the raster
  effects of a group's members (their own and their fills' and
  strokes'), of nested groups, of the art of the linked opacity masks
  in it and of an object's fills and strokes, after the members' own
  Transform effects: sizes scale by the mean scale and a Drop Shadow's
  offset turns, reflects and scales. A raster effect that a member's
  own Transform effect with copies or random holds back keeps its value
  through that effect and still transforms with the group's. Scale
  Strokes & Effects does not scale them a second time. A group's or
  object's Transform effect that makes copies or moves at random leaves
  them as before.
- The canvas, the redraw region, export, Expand Appearance, Object ›
  Expand and Outline Stroke read the same values.
- The Transform effect's params text and docs/target-app-parity.md
  describe the rule.
2026-11-12 11:35:03 -08:00

587 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Outline Stroke (`object.path.outlineStroke`) and the live Outline Stroke effect produce the
//! stroke as the canvas paints it: dashes, arrowheads, width profiles, alignment, every stroke
//! item in order, with the stroke's opacity and blend mode moved to the new fill. Outline Stroke,
//! Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines apply the
//! object's geometry effects and each stroke's own to the path before outlining that stroke.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A horizontal 100 pt line from (0, 50), black, no fill, with stroke options `opts`.
fn line(s: &mut Session, opts: Value) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("stroke.set", &opts).unwrap();
id
}
pub(super) fn outline(s: &mut Session) -> Node {
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
pub(super) fn path_of(n: &Node) -> PathData {
match &n.kind {
NodeKind::Path { path, .. } => path.clone(),
NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()),
_ => panic!("not a path: {:?}", n.kind),
}
}
fn area(n: &Node) -> f64 {
vectorcraft_pathops::area(&path_of(n), FillRule::NonZero)
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
near(a.x0, b.x0, tol) && near(a.y0, b.y0, tol) && near(a.x1, b.x1, tol) && near(a.y1, b.y1, tol)
}
#[test]
fn a_dashed_line_gives_one_subpath_per_dash() {
let mut s = session();
line(&mut s, json!({"weight": 4, "dash": [10, 10]}));
let n = outline(&mut s);
assert_eq!(path_of(&n).subpaths.len(), 5, "dashes at 0, 20, 40, 60 and 80");
assert!(near(area(&n), 5.0 * 10.0 * 4.0, 0.5), "{}", area(&n));
assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 48.0, 90.0, 52.0), 0.01));
}
#[test]
fn arrowheads_are_part_of_the_outline() {
let mut s = session();
line(&mut s, json!({"weight": 2, "endArrow": "Triangle"}));
let n = outline(&mut s);
// The 2 pt line plus an 8 × 8 triangle whose tip sits past the end point.
let b = n.geometric_bounds().unwrap();
assert!(b.x1 > 103.0 && near(b.height(), 8.0, 0.05), "{b:?}");
assert!(area(&n) > 200.0 + 20.0, "{}", area(&n));
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
}
/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle.
#[test]
fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() {
let mut s = session();
line(&mut s, json!({"weight": 10, "profile": "lens"}));
let n = outline(&mut s);
assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
}
#[test]
fn inside_alignment_keeps_the_outline_inside_the_path() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("stroke.set", &json!({"weight": 4, "align": "inside"})).unwrap();
let g = outline(&mut s);
let ring = &g.children().unwrap()[1];
assert!(close(ring.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
assert!(near(area(ring), 100.0 * 100.0 - 92.0 * 92.0, 1.0), "{}", area(ring));
}
#[test]
fn stroke_opacity_and_blend_move_to_the_new_fill() {
let mut s = session();
line(&mut s, json!({"weight": 4}));
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50, "blend": "multiply"})).unwrap();
let n = outline(&mut s);
let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { panic!("{:?}", n.appearance) };
assert!(near(f.opacity as f64, 0.5, 1e-6) && f.blend == BlendMode::Multiply, "{f:?}");
assert!(n.opacity == 1.0 && n.blend == BlendMode::Normal);
}
#[test]
fn every_stroke_is_outlined_in_paint_order() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("appearance.addStroke", &json!({})).unwrap();
// Items: fill, 1 pt stroke, then the new top stroke at 10 pt red.
s.execute("appearance.setItem", &json!({"index": 2, "weight": 10, "color": "#ff0000"})).unwrap();
let g = outline(&mut s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 3, "the fill, then both outlines");
assert!(ch[0].appearance.items.iter().all(AppearanceItem::is_fill));
assert!(close(ch[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-9));
assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-0.5, -0.5, 100.5, 100.5), 0.01));
assert!(close(ch[2].geometric_bounds().unwrap(), Rect::new(-5.0, -5.0, 105.0, 105.0), 0.01));
assert_eq!(ch[2].appearance.fill().unwrap().paint.color().unwrap().to_hex(), "#ff0000");
// One undo step restores the stroked rectangle.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
}
/// The art that stands for an outlined path keeps the path's hidden, locked and knockout-shape
/// states, so a hidden member of a selected group stays hidden.
#[test]
fn outlined_art_keeps_the_hidden_locked_and_knockout_shape_states() {
let mut s = session();
// A filled and stroked square (a fill copy and an outline) and a stroked line (one outline).
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
let ln = line(&mut s, json!({"weight": 4}));
s.execute("select.set", &json!({"ids": [sq.0, ln.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.edit("t", |d, _| {
d.node_mut(sq).unwrap().visible = false;
let l = d.node_mut(ln).unwrap();
l.locked = true;
l.knockout_shape = true;
Ok(())
})
.unwrap();
let g = outline(&mut s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 2);
let (sq, ln) = (&ch[0], &ch[1]);
assert!(!sq.visible && !sq.locked, "the square's art stays hidden: {sq:?}");
assert_eq!(sq.children().map(|c| c.len()), Some(2), "a fill copy and the outline");
assert!(sq.children().unwrap().iter().all(|c| c.visible && !c.locked), "showing it shows all of it: {sq:?}");
assert!(ln.visible && ln.locked && ln.knockout_shape, "the line's outline stays locked: {ln:?}");
}
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
#[test]
fn the_live_effect_outlines_the_stroke_it_sits_on() {
let mut s = session();
let id = line(&mut s, json!({"weight": 20}));
// Items: the line's empty fill, its 20 pt stroke, then a new 2 pt top stroke.
s.execute("appearance.addStroke", &json!({})).unwrap();
s.execute("appearance.setItem", &json!({"index": 2, "weight": 2})).unwrap();
// The lower (20 pt) stroke gets the effect.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": 1})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
assert!(matches!(&n.appearance.items[1], AppearanceItem::Stroke(st) if st.width == 20.0 && st.effects.len() == 1), "{:?}", n.appearance.items);
let baked = vectorcraft_render::effects::bake_document(doc).unwrap();
let parts = baked.node(id).unwrap().children().unwrap().to_vec();
assert!(near(parts[1].geometric_bounds().unwrap().height(), 20.0, 0.01), "{:?}", parts[1].geometric_bounds());
assert!(near(parts[2].geometric_bounds().unwrap().height(), 0.0, 1e-9), "the top stroke keeps the line");
// On the object, the effect outlines the top stroke.
s.execute("effect.apply", &json!({"effect": "path.outlineStroke", "item": null})).unwrap();
let doc = &s.doc().unwrap().doc;
let n = doc.node(id).unwrap();
let ctx = vectorcraft_render::effects::GeomContext::of(n);
let g = vectorcraft_render::effects::apply_geometry_with(&n.appearance.effects, n.path_data().unwrap(), Rect::new(0.0, 50.0, 100.0, 50.0), &ctx);
assert!(close(g.bounds().unwrap(), Rect::new(0.0, 49.0, 100.0, 51.0), 0.01), "{:?}", g.bounds());
}
fn render(s: &Session) -> vectorcraft_render::Rendered {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts)
}
/// Pixels whose color differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> 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) > tol))
.count()
}
/// Does `n` or anything in it have a geometry effect, its own or a fill's or stroke's?
fn has_geometry_effects(n: &Node) -> bool {
let geometry = |e: &vectorcraft_doc::Effect| vectorcraft_render::effects::is_geometry(&e.id);
let mut found = false;
n.walk(&mut |m| found |= m.appearance.effects.iter().any(geometry) || m.appearance.items.iter().any(|i| i.effects().iter().any(geometry)));
found
}
/// A selected 100 pt square at (150, 100) with a 4 pt black stroke and fill `fill` (paint.setFill's),
/// and `effects` (effect.apply's `effect` and `params`) on appearance item `item` (`null`: on the
/// object).
fn square_with(s: &mut Session, fill: Value, item: Value, effects: &[(&str, Value)]) {
s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &fill).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
for (effect, params) in effects {
s.execute("effect.apply", &json!({"effect": effect, "params": params, "item": item})).unwrap();
}
}
/// The geometry effects the tests put on the stroke or on the object.
fn geometry_effects() -> [(&'static str, Value); 2] {
[("path.offsetPath", json!({"offset": 10})), ("distort.zigZag", json!({"size": 6, "ridges": 3}))]
}
/// The commands that outline strokes: Outline Stroke, Expand with Stroke alone, and Flatten
/// Transparency with Convert All Strokes to Outlines.
fn outlining() -> [(&'static str, Value); 3] {
[
("object.path.outlineStroke", json!({})),
("object.expand", json!({"object": false, "fill": false, "stroke": true})),
("object.flattenTransparency", json!({"strokesToOutlines": true})),
]
}
/// What `command` with `params` leaves in place of the selected object.
fn outlined_by(s: &mut Session, command: &str, params: &Value) -> Node {
let r = s.execute(command, params).unwrap_or_else(|e| panic!("{command}: {e}"));
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// For each command in [`outlining`] and each of [`geometry_effects`] on `item` of a square filled
/// with `fill`: the pixels that differ by more than 64 from the canvas before, and whether a
/// geometry effect is left on the result.
fn outlined_as_drawn(fill: Value, item: Value) -> Vec<(&'static str, &'static str, usize, bool)> {
let mut results = vec![];
for (command, p) in outlining() {
for (effect, params) in geometry_effects() {
let mut s = session();
square_with(&mut s, fill.clone(), item.clone(), &[(effect, params)]);
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
results.push((command, effect, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
}
}
results
}
/// What [`outlined_as_drawn`] gives when every outline looks as the canvas drew the object.
fn as_drawn() -> Vec<(&'static str, &'static str, usize, bool)> {
outlining().iter().flat_map(|(command, _)| geometry_effects().map(|(effect, _)| (*command, effect, 0, false))).collect()
}
/// A stroke's own geometry effects reshape the path it strokes, as on the canvas: the outline is
/// the stroke of the offset or zig-zagged path, not the stroke's outline offset or zig-zagged.
#[test]
fn a_geometry_effect_on_the_stroke_shapes_the_path_it_outlines() {
assert_eq!(outlined_as_drawn(json!({"none": true}), json!(1)), as_drawn());
// The stroke of the square offset by 10: 4 pt wide around a 120 pt square.
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
let n = outline(&mut s);
assert!(close(n.geometric_bounds().unwrap(), Rect::new(138.0, 88.0, 262.0, 212.0), 0.01), "{:?}", n.geometric_bounds());
assert!(near(area(&n), 124.0 * 124.0 - 116.0 * 116.0, 2.0), "{}", area(&n));
}
/// A stroke whose geometry effect leaves no path paints nothing on the canvas, and outlining it
/// replaces the live stroke with an empty group.
#[test]
fn a_stroke_its_effects_empty_is_outlined_to_nothing() {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": -60}))]);
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.is_empty()), "{command}: {n:?}");
let mut stroked = false;
for layer in &s.doc().unwrap().doc.layers {
layer.walk(&mut |m| stroked |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
}
assert!(!stroked, "{command} left a live stroke");
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}");
}
}
/// A drop shadow on a stroke with a geometry effect stays on the outline, which draws as the
/// stroke did.
#[test]
fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10})), ("stylize.dropShadow", json!({}))]);
let before = render(&s);
let n = outline(&mut s);
let kept: Vec<&str> = n.appearance.items.iter().flat_map(|i| i.effects()).map(|e| e.id.as_str()).collect();
assert_eq!(kept, ["stylize.dropShadow"]);
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
}
/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns
/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew.
#[test]
fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
let effects = [
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})),
];
for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] {
// Flatten Transparency draws a brushed stroke's drop shadow differently with or without a
// Transform effect, so the brushed stroke is checked with the other two commands.
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
let mut s = session();
square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects);
if brush {
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}");
}
}
}
/// The object's Transform effect turns and scales the drop shadow of its stroke (brushed or not)
/// on the canvas; outlining the stroke keeps the shadow the canvas drew.
#[test]
fn a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it() {
for brush in [false, true] {
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
let mut s = session();
square_with(
&mut s,
json!({"color": "#ff0000"}),
json!(1),
&[("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100}))],
);
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 100, "rotate": 90}})).unwrap();
if brush {
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, brush {brush}");
}
}
}
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
#[test]
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
let mut s = session();
square_with(&mut s, json!({"none": true}), json!(1), &[("path.offsetPath", json!({"offset": 10}))]);
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
let id = s.doc().unwrap().selection.objects[0];
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned().unwrap();
assert!(st.brush.is_some() && st.effects.len() == 1, "{st:?}");
let before = render(&s);
let n = outline(&mut s);
assert!(!vectorcraft_brush::has_brush(&n) && !has_geometry_effects(&n), "brush art: {n:?}");
assert_eq!(differing(&before, &render(&s), 64), 0, "the brush art draws as the brushed stroke did");
}
/// The object's geometry effects reshape the path its fills paint and its strokes outline, as on
/// the canvas, and do not stay on the result.
#[test]
fn a_geometry_effect_on_the_object_shapes_the_path_it_outlines() {
assert_eq!(outlined_as_drawn(json!({"color": "#33aa55"}), Value::Null), as_drawn());
}
/// The canvas paints a gradient fill or stroke on each copy that the object's Transform effect
/// makes by itself; the outlined result draws the same, with the gradient fitted to each copy.
#[test]
fn each_transform_copy_keeps_its_own_gradient() {
let mut differ = vec![];
for on_stroke in [false, true] {
for (command, p) in outlining() {
let mut s = session();
let fill = if on_stroke { json!({"none": true}) } else { json!({"gradient": {"kind": "linear"}}) };
square_with(&mut s, fill, Value::Null, &[("distort.transform", json!({"moveH": 120, "copies": 1}))]);
if on_stroke {
s.execute("paint.setStroke", &json!({"gradient": {"kind": "linear"}})).unwrap();
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((on_stroke, command, differing(&before, &render(&s), 64)));
}
}
let none_differ: Vec<_> = [false, true].into_iter().flat_map(|on_stroke| outlining().map(|(command, _)| (on_stroke, command, 0))).collect();
assert_eq!(differ, none_differ);
}
/// With Scale Strokes & Effects on, the canvas draws the stroke of a solid square that its
/// Transform effect scales to 200% at 8 pt; the outlined result draws the same.
#[test]
fn a_transform_that_scales_strokes_outlines_the_scaled_stroke() {
let scale = json!({"scaleH": 200, "scaleV": 200, "strokes": true});
let mut differ = vec![];
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.transform", scale.clone())]);
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((command, differing(&before, &render(&s), 64)));
}
let none_differ: Vec<_> = outlining().map(|(command, _)| (command, 0)).into_iter().collect();
assert_eq!(differ, none_differ);
}
/// Random in a Transform effect takes its seed from the object's id. The outline draws as the
/// canvas drew the object with Random in a Transform effect that copies a gradient square, in a
/// Transform effect on the stroke of such a square, and in a Transform effect on the fill of a
/// plain square.
#[test]
fn outlines_keep_the_random_transforms_the_canvas_drew() {
let random = json!({"moveH": 30, "moveV": 30, "rotate": 40, "random": true});
let cases = [
("random copies", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "rotate": 30, "copies": 1, "random": true}), None),
("copies, random stroke", json!({"gradient": {"kind": "linear"}}), json!({"moveH": 120, "copies": 1}), Some(1)),
("random fill", json!({"color": "#33aa55"}), json!({}), Some(0)),
];
let mut differ = vec![];
for (case, fill, object, item) in &cases {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, fill.clone(), Value::Null, &[]);
if object != &json!({}) {
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": object})).unwrap();
}
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
if let Some(item) = item {
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": random, "item": item})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
differ.push((*case, command, differing(&before, &render(&s), 64)));
}
}
let none_differ: Vec<_> = cases.iter().flat_map(|(case, ..)| outlining().map(|(command, _)| (*case, command, 0))).collect();
assert_eq!(differ, none_differ);
}
/// For each command in [`outlining`] on a stroke-only square with `effects` on appearance item
/// `item`: the pixels that differ by more than 64 from the canvas before, whether a geometry
/// effect is left on the result, and (for Outline Stroke) the outline's box and area.
fn outlined_square(item: Value, effects: &[(&str, Value)], hide: bool) -> (Vec<(&'static str, usize, bool)>, Rect, f64) {
let mut results = vec![];
let mut shape = (Rect::ZERO, 0.0);
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"none": true}), item.clone(), effects);
if hide {
for index in 0..effects.len() {
s.execute("effect.setParams", &json!({"index": index, "visible": false, "item": item})).unwrap();
}
}
let before = render(&s);
let n = outlined_by(&mut s, command, &p);
if command == "object.path.outlineStroke" {
shape = (n.geometric_bounds().unwrap(), area(&n));
}
results.push((command, differing(&before, &render(&s), 64), has_geometry_effects(&n)));
}
(results, shape.0, shape.1)
}
/// What [`outlined_square`] gives for each command when the outline draws as the canvas did and
/// keeps no geometry effect.
fn drawn_plain() -> Vec<(&'static str, usize, bool)> {
outlining().map(|(command, _)| (command, 0, false)).into_iter().collect()
}
/// A path with no effects outlines its own stroke: 4 pt wide around the 100 pt square.
#[test]
fn a_path_without_effects_outlines_its_own_stroke() {
let (results, bounds, a) = outlined_square(Value::Null, &[], false);
assert_eq!(results, drawn_plain());
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "{a}");
}
/// Round Corners on the object rounds the path whose stroke is outlined, with the radius in
/// points as the canvas draws it: corners of radius 12 outside and 8 inside.
#[test]
fn round_corners_on_the_object_round_the_stroke_it_outlines() {
let (results, bounds, a) = outlined_square(Value::Null, &[("stylize.roundCorners", json!({"radius": 10}))], false);
assert_eq!(results, drawn_plain());
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "{bounds:?}");
let rounded = 104.0 * 104.0 - 96.0 * 96.0 - (4.0 - std::f64::consts::PI) * (12.0 * 12.0 - 8.0 * 8.0);
assert!(near(a, rounded, 2.0), "{a}, rounded {rounded}");
}
/// A hidden geometry effect, on the object or on the stroke, does not draw on the canvas and does
/// not shape the outline.
#[test]
fn a_hidden_geometry_effect_does_not_shape_the_outline() {
for item in [Value::Null, json!(1)] {
let (results, bounds, a) = outlined_square(item.clone(), &geometry_effects(), true);
let drawn: Vec<_> = results.iter().map(|(command, differ, _)| (*command, *differ)).collect();
assert_eq!(drawn, drawn_plain().into_iter().map(|(command, differ, _)| (command, differ)).collect::<Vec<_>>(), "item {item}");
assert!(close(bounds, Rect::new(148.0, 98.0, 252.0, 202.0), 0.01), "item {item}: {bounds:?}");
assert!(near(a, 104.0 * 104.0 - 96.0 * 96.0, 0.5), "item {item}: {a}");
}
}
/// An effect on the object that is not a geometry effect, such as a Gaussian Blur, stays on the
/// result of Outline Stroke and Expand while the geometry effect shapes the path. Flatten
/// Transparency renders raster effects into an image instead.
#[test]
fn an_effect_that_is_not_geometry_stays_on_the_result() {
for (command, p) in outlining().into_iter().filter(|(command, _)| *command != "object.flattenTransparency") {
let mut s = session();
square_with(
&mut s,
json!({"color": "#33aa55"}),
Value::Null,
&[("distort.zigZag", json!({"size": 6, "ridges": 3})), ("blur.gaussian", json!({}))],
);
let n = outlined_by(&mut s, command, &p);
let kept: Vec<&str> = n.appearance.effects.iter().map(|e| e.id.as_str()).collect();
assert_eq!(kept, ["blur.gaussian"], "{command}");
assert!(!has_geometry_effects(&n), "{command}: {n:?}");
}
}
/// Undo after outlining a path with geometry effects on the object and on its stroke gives back
/// the path as it was, effects included, and its selection.
#[test]
fn undo_gives_back_the_path_with_its_effects() {
for (command, p) in outlining() {
let mut s = session();
square_with(&mut s, json!({"color": "#33aa55"}), Value::Null, &[("distort.zigZag", json!({"size": 6, "ridges": 3}))]);
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})).unwrap();
let before = s.doc().unwrap().doc.layers.clone();
let selected = s.doc().unwrap().selection.objects.clone();
outlined_by(&mut s, command, &p);
assert_ne!(s.doc().unwrap().doc.layers, before, "{command} changed nothing");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, before, "{command}");
assert_eq!(s.doc().unwrap().selection.objects, selected, "{command}");
}
}
#[test]
fn brushed_strokes_outline_to_their_brush_art() {
let mut s = session();
line(&mut s, json!({"weight": 2}));
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
s.execute("appearance.setItem", &json!({"index": 1, "opacity": 50})).unwrap();
let n = outline(&mut s);
let doc = &s.doc().unwrap().doc;
let mut fills = 0;
n.walk(&mut |m| fills += m.appearance.items.iter().filter(|i| i.is_fill()).count());
assert!(fills > 0 && n.appearance.stroke().is_none(), "brush art: {n:?}");
assert!(near(n.opacity as f64, 0.5, 1e-6), "the stroke opacity stays on the art");
assert!(!doc.layers[0].children().unwrap().iter().any(|c| vectorcraft_brush::has_brush(c)));
}
#[test]
fn a_brushed_stroke_keeps_its_effects_on_its_brush_art() {
let mut s = session();
line(&mut s, json!({"weight": 2}));
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": 1})).unwrap();
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 1})).unwrap();
let n = outline(&mut s);
let count = |id: &str| {
let mut k = 0;
n.walk(&mut |m| k += m.appearance.effects.iter().filter(|e| e.id == id).count());
k
};
assert_eq!(count("stylize.dropShadow"), 1, "the drop shadow is on the brush art: {n:?}");
// A geometry effect changes the path the brush follows; it is not applied to the brush art.
assert_eq!(count("path.offsetPath"), 0);
}