mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 05:07:51 -06:00
Fix #3089: a stroked line with an effect expands to a stroked line, then Expand outlines it
Squashed from #4169 onto main.
This commit is contained in:
committed by
bflatastic
parent
3cfdb58b90
commit
b41599f657
@@ -218,7 +218,8 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
|
||||
});
|
||||
}
|
||||
assert_eq!((images, frames), (1, 1));
|
||||
// Expand Appearance leaves the stroke's outline (a filled path) below the image, in one step.
|
||||
// Expand Appearance leaves the frame, stroked 4 pt, below the image, in one step (the stroke
|
||||
// stays a stroke for Object › Expand to outline, #3089).
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
@@ -226,9 +227,11 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram
|
||||
let kids = n.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{n:?}");
|
||||
assert!(matches!(kids[1].kind, NodeKind::Image(_)), "{n:?}");
|
||||
let outline = kids[0].visual_bounds().unwrap();
|
||||
assert!((outline.x0 - 48.0).abs() < 0.01 && (outline.x1 - 82.0).abs() < 0.01, "{outline:?}");
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::BLACK));
|
||||
let frame = kids[0].visual_bounds().unwrap();
|
||||
assert!((frame.x0 - 48.0).abs() < 0.01 && (frame.x1 - 82.0).abs() < 0.01, "{frame:?}");
|
||||
assert_eq!(kids[0].appearance.stroke_paint(), Paint::solid(Color::BLACK));
|
||||
assert_eq!(kids[0].appearance.stroke_width(), 4.0);
|
||||
assert!(kids[0].appearance.fill_paint().is_none());
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
@@ -308,13 +311,13 @@ fn group_appearance_round_trips_and_expands() {
|
||||
let ch = n.children().unwrap();
|
||||
assert_eq!(ch.len(), 4, "fill, two members, stroke");
|
||||
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
|
||||
// stroke's paths are its outlines, filled.
|
||||
// stroke's paths are stroked by it (#3089).
|
||||
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
|
||||
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
|
||||
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
|
||||
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||||
assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||||
let stroked = ch[3].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!((stroked.width() - 41.0).abs() < 0.1, "{stroked:?}");
|
||||
// Moved down 50 pt; the 1 pt stroke reaches half a point beyond.
|
||||
assert!((n.visual_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,9 +69,14 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||||
let ch = n.children().expect("a group of the fills and the stroke");
|
||||
assert_eq!(ch.len(), 3);
|
||||
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
|
||||
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
|
||||
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
|
||||
// In paint order: red fill, the stroke on the path offset 10 pt out (still a 4 pt stroke, which
|
||||
// Object › Expand would outline, #3089), the green fill.
|
||||
assert!(ch.iter().all(|c| c.appearance.items[0].effects().is_empty()));
|
||||
assert!(ch[0].appearance.items[0].is_fill() && !ch[1].appearance.items[0].is_fill() && ch[2].appearance.items[0].is_fill());
|
||||
assert_eq!(ch[1].appearance.stroke_width(), 4.0);
|
||||
let ring = ch[1].geometric_bounds().unwrap();
|
||||
assert!((ring.width() - 120.0).abs() < 0.5, "{ring:?}");
|
||||
let ring = ch[1].visual_bounds().unwrap();
|
||||
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
|
||||
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
|
||||
assert!(ch[2].appearance.is_basic());
|
||||
@@ -149,11 +154,11 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
|
||||
art = (*art.children().unwrap()[0]).clone();
|
||||
assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}");
|
||||
}
|
||||
// No object keeps an effect, and in the copies cases no object keeps a stroke: every copy
|
||||
// is expanded.
|
||||
// No object keeps an effect, and in the copies cases every object has a basic appearance:
|
||||
// every copy is expanded (its stroke a stroked path of its own, #3089).
|
||||
art.walk(&mut |n| {
|
||||
let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none());
|
||||
assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}");
|
||||
let unexpanded = case.ends_with("copies") && !n.appearance.is_basic();
|
||||
assert!(n.appearance.effects.is_empty() && !unexpanded, "{case}: {n:?}");
|
||||
});
|
||||
}
|
||||
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
|
||||
@@ -276,8 +281,13 @@ fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
|
||||
let ch = l.children().unwrap();
|
||||
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
|
||||
assert_eq!(ch[0].id, a);
|
||||
// The layer's stroke is outlined: a filled path.
|
||||
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
|
||||
// The layer's stroke stays a stroke, on a path of its own (#3089).
|
||||
let mut six = 0;
|
||||
ch[1].walk(&mut |n| {
|
||||
assert!(n.appearance.items.iter().all(|i| !i.is_fill()), "{n:?}");
|
||||
six += usize::from(n.appearance.stroke_width() == 6.0);
|
||||
});
|
||||
assert!(six > 0, "the layer's 6 pt stroke: {:?}", ch[1]);
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||||
}
|
||||
@@ -331,3 +341,63 @@ fn a_blurred_layer_keeps_its_image_as_its_only_member() {
|
||||
let ch = l.children().unwrap();
|
||||
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
||||
/// A selected open curve with no fill, stroked in blue with `stroke` (stroke.set's params) and
|
||||
/// given a Zig Zag (#3089).
|
||||
fn zig_zag_curve(s: &mut Session, stroke: Value) -> NodeId {
|
||||
let id = NodeId(run(s, "path.create", json!({"d": "M 30 100 C 70 40 130 160 170 100"}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(s, "stroke.set", stroke);
|
||||
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 8, "ridges": 5, "relative": false}}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_stays_a_stroke_with_its_dashes_arrowheads_alignment_and_width_profile() {
|
||||
for stroke in [
|
||||
json!({"weight": 4, "cap": "round", "join": "round", "dash": [10, 5]}),
|
||||
json!({"weight": 3, "startArrow": "Arrow", "endArrow": "Circle"}),
|
||||
json!({"weight": 8, "profile": "lens"}),
|
||||
json!({"weight": 6, "align": "inside"}),
|
||||
] {
|
||||
let mut s = session();
|
||||
let id = zig_zag_curve(&mut s, stroke.clone());
|
||||
let was = node(&s, id).appearance.stroke().unwrap().clone();
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
// The zig-zag is baked into the path, which keeps the very same stroke and no fill: no outline.
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }), "{stroke}: a path, not a group: {:?}", n.kind);
|
||||
assert!(n.appearance.effects.is_empty() && n.appearance.is_basic(), "{stroke}");
|
||||
assert_eq!(n.appearance.stroke(), Some(&was), "{stroke}: the stroke's attributes stay");
|
||||
assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none()), "{stroke}: no fill");
|
||||
assert!(n.path_data().unwrap().anchors().count() > 4, "{stroke}: the zig-zag is baked");
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 40, "{stroke}: {} pixels differ", differing(&before, &after, 8));
|
||||
assert!(!expandable(&s), "{stroke}: nothing left for Expand Appearance");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_strokes_become_stroked_paths_in_appearance_order() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
// [red fill, 4 pt blue stroke, 12 pt green stroke] with a Zig Zag on the object.
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"item": 2, "weight": 12}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 6, "ridges": 4, "relative": false}}));
|
||||
let items = node(&s, id).appearance.items.clone();
|
||||
let before = render(&s);
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, id);
|
||||
let ch = n.children().expect("a group of the fill and the two strokes");
|
||||
assert_eq!(ch.len(), 3);
|
||||
for (c, item) in ch.iter().zip(&items) {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }) && c.appearance.items.len() == 1, "{:?}", c.kind);
|
||||
assert_eq!(&c.appearance.items[0], item, "each piece keeps its own fill or stroke, in appearance order");
|
||||
}
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 8) < 60, "{} pixels differ", differing(&before, &after, 8));
|
||||
}
|
||||
|
||||
@@ -254,7 +254,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
|
||||
let mut s = session();
|
||||
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false);
|
||||
// Fill expands the pattern; Expand Gradient To has no gradient fill to apply to.
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": false, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": true, "stroke": false, "gradient": false, "appearance": false})
|
||||
);
|
||||
let before = render(&s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
@@ -276,7 +279,10 @@ fn a_pattern_fill_becomes_its_tiles_clipped_by_the_object() {
|
||||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||||
// The tiles are the pattern's art as it is (a stroked live rectangle that Object and Stroke can
|
||||
// expand).
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": false, "stroke": true, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": false, "stroke": true, "gradient": false, "appearance": false})
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots"));
|
||||
// Fill off: the pattern stays live.
|
||||
@@ -905,7 +911,10 @@ fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
|
||||
fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true, "gradient": true}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": true, "fill": true, "stroke": true, "gradient": true, "appearance": false})
|
||||
);
|
||||
let r = run(&mut s, "object.expand", json!({}));
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
// Object, Fill and Stroke on; 255 objects: the live rectangle, its stroke and its gradient.
|
||||
@@ -916,7 +925,10 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
assert_eq!(strips.len(), 255);
|
||||
assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false, "gradient": false}));
|
||||
assert_eq!(
|
||||
run(&mut s, "object.expand.info", json!({})),
|
||||
json!({"object": false, "fill": false, "stroke": false, "gradient": false, "appearance": false})
|
||||
);
|
||||
assert!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand");
|
||||
// Bad options are refused before anything changes.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
@@ -924,3 +936,90 @@ fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
assert!(s.execute("object.expand", &json!({"steps": 0})).is_err());
|
||||
assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. }));
|
||||
}
|
||||
|
||||
/// A selected open line with a 2 pt black stroke, no fill and a Zig Zag effect (#3089).
|
||||
fn zig_zag_line(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(run(s, "path.create", json!({"d": "M 20 100 L 180 100"}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"none": true}));
|
||||
run(s, "paint.setStroke", json!({"color": "#000000"}));
|
||||
run(s, "stroke.set", json!({"weight": 2}));
|
||||
run(s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 10, "ridges": 6, "relative": false}}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_info_says_when_expand_appearance_comes_first() {
|
||||
let mut s = session();
|
||||
zig_zag_line(&mut s);
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "Zig Zag is an appearance attribute");
|
||||
// Expand Appearance bakes the zig-zag into the line, which keeps its stroke and gets no fill.
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let line = selected(&s);
|
||||
assert!(matches!(line.kind, NodeKind::Path { .. }), "a path: {:?}", line.kind);
|
||||
assert!(line.path_data().unwrap().anchors().count() > 6, "the zig-zag is baked");
|
||||
assert!(line.appearance.effects.is_empty());
|
||||
assert!(!line.appearance.stroke_paint().is_none(), "still a stroked line");
|
||||
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()), "no fill");
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false));
|
||||
// Then Expand outlines the stroke into a filled shape.
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let outline = selected(&s);
|
||||
assert!(!outline.appearance.fill_paint().is_none(), "a filled outline");
|
||||
assert!(outline.appearance.stroke_paint().is_none());
|
||||
}
|
||||
|
||||
/// The paths under `n` (itself included): (how many have a stroke, how many are filled black).
|
||||
fn strokes_and_black_fills(n: &Node) -> (usize, usize) {
|
||||
let (mut stroked, mut black) = (0, 0);
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
stroked += usize::from(!c.appearance.stroke_paint().is_none());
|
||||
black += usize::from(c.appearance.fill_paint() == Paint::solid(Color::BLACK));
|
||||
}
|
||||
});
|
||||
(stroked, black)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_expand_outlines_a_stroked_line_with_an_effect_in_one_step() {
|
||||
let mut s = session();
|
||||
zig_zag_line(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let n = selected(&s);
|
||||
assert!(n.appearance.effects.is_empty(), "the zig-zag is baked");
|
||||
assert_eq!(strokes_and_black_fills(&n), (0, 1), "the stroke is outlined: {n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_expand_outlines_the_stroke_a_groups_appearance_expands_to() {
|
||||
// A group of two red squares without strokes; the group has a black stroke and a Transform
|
||||
// effect of its own, so Stroke has nothing to outline until Expand Appearance has run.
|
||||
let mut s = session();
|
||||
let a = NodeId(run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
||||
let b = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "appearance.addStroke", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 2, "color": "#000000"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["stroke"], json!(false), "no stroked path yet");
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let g = selected(&s);
|
||||
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty(), "the appearance is expanded");
|
||||
let (stroked, black) = strokes_and_black_fills(&g);
|
||||
assert_eq!(stroked, 0, "no stroke is left: {g:?}");
|
||||
assert_eq!(black, 2, "each square's copy of the group's stroke is outlined: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_info_counts_two_fills_but_not_a_plain_rectangle() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(false), "a basic appearance");
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], json!(true), "two fills");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user