mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
The art that replaces an outlined or expanded path did not take the path's hidden, locked or Knockout Shape state. Outline Stroke on a hidden path, or Expand on a group with a hidden member, made the new art visible (a filled and stroked path became half visible: the outline shown, the fill copy hidden), and a locked member's art was unlocked. The replacement now takes the path's visibility, lock and Knockout Shape state, and the fill copies and clipping paths inside it are shown and unlocked, so showing or unlocking the new art shows or unlocks all of it. This covers Outline Stroke, and Expand's strokes, gradient fills and pattern fills.
213 lines
9.8 KiB
Rust
213 lines
9.8 KiB
Rust
//! 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.
|
||
|
||
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");
|
||
}
|
||
|
||
#[test]
|
||
fn a_lens_profile_outlines_to_half_the_rectangle() {
|
||
let mut s = session();
|
||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||
let n = outline(&mut s);
|
||
assert!(near(area(&n), 0.5 * 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());
|
||
}
|
||
|
||
#[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);
|
||
}
|