mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Object > Expand with Fill expanded gradient fills only. A pattern fill stayed a live pattern, and object.expand.info did not report it, so the dialog's Fill option was disabled for it. In the reference app, Expand with Fill turns a pattern fill into the pattern's tiles, and the shape becomes their clipping mask. The Fill step now turns each visible pattern fill of a path or compound path into a clip group shaped like the object, holding the tiles the pattern lays over the object's box with fresh ids. The group isolates the tiles' blending and takes the fill's opacity, blend mode and other effects. Geometry effects of the fill or the object, such as Offset Path, shape the group's outline and the box the tiles cover. A path in the tile art with a random Transform effect is baked with its own id first, and a path with a brush that varies at random (calligraphic or scatter with Random variation, or bristle) is baked where the pattern defines it first, so every tile matches the canvas. The object's other fills and strokes stay on copies of it in paint order, and Expand stays one undo step. Type with a pattern fill expands the same way once Object outlines it, and object.expand.info reports pattern fills under fill. It also reports gradient fills under a new gradient key, and the dialog enables Expand Gradient To only when the selection has one. A pattern fill stays as it is when it lays no tiles over the box, when a group or type in its art has a random Transform effect, when the pattern is scaled, rotated, reflected, stretched or sheared and its art holds a symbol instance, and when the pattern is rotated, reflected, stretched or sheared, or scaled while its art has geometry effects, and its art has raster or 3D effects, or a geometry effect on a group or type. PatternDef gains instances_in_with, which places the tiles with a given Scaling. Expand has stroke weights, effect distances and the tiles' own pattern paints scale with the pattern's transform. When the pattern is rotated, reflected, stretched or sheared, or scaled while its art has geometry effects such as Roughen, the strokes and geometry effects of the tiles' paths become filled art first (bake_appearance, and Outline Stroke's conversion for brushed strokes), and their live shapes become plain paths, in their opacity masks too. Tiles place their opacity masks with them, unlinked ones included (place_art), which also fixes unlinked masks in pattern tiles in PDF, EPS, EMF and WMF export and in Distort Pattern Fills. Faults in the shared code are fixed for every caller. assemble dropped the effects of a sole part, so Expand lost a lone gradient fill's own effects and Outline Stroke lost those of a stroke with a gradient along or across it. Outline Stroke also dropped a brushed stroke's raster effects from its brush art. Expand replaced an object whose gradient fill could not be expanded with a plain copy; it now leaves it as it was. Expand left the geometry effects of a gradient fill, and with Object off those of the object, on the expanded art, where they reshaped every strip; they now shape the clip group's outline, and the gradient fits it, as for pattern fills. bake_appearance dropped a stroke's raster effects from its filled outline and filled the outline of a stroke with a gradient along or across it with a gradient across the outline's box; it now keeps the effects and makes the gradient meshes clipped to the outline, as Outline Stroke does. Envelope Options > Distort Appearance uses it too. PatternDef::offsets_covering overflowed on a region too large to count in integers, as for a pattern scaled by 1e-20; it now gives no offsets. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
183 lines
8.3 KiB
Rust
183 lines
8.3 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 super::*;
|
||
|
||
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 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);
|
||
}
|