mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -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.
854 lines
46 KiB
Rust
854 lines
46 KiB
Rust
//! Object → Expand (M3.84): the Object, Fill and Stroke options, gradient fills into solid strips,
|
||
//! concentric ellipses or a gradient mesh inside a clip group, freeform gradients into a mesh,
|
||
//! pattern fills into their tiles inside a clip group, and Create Gradient Mesh taking a gradient
|
||
//! fill's colours.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||
use vectorcraft_geom::Rect;
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
/// A selected rectangle filled with `gradient` (paint.setFill's), with no stroke unless `stroke`.
|
||
fn gradient_rect(s: &mut Session, r: Rect, gradient: Value, stroke: bool) -> NodeId {
|
||
let id = run(s, "shape.rectangle", json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}))["id"].as_u64().unwrap();
|
||
run(s, "paint.setFill", json!({ "gradient": gradient }));
|
||
if stroke {
|
||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||
run(s, "stroke.set", json!({"weight": 4}));
|
||
} else {
|
||
run(s, "paint.setStroke", json!({"none": true}));
|
||
}
|
||
NodeId(id)
|
||
}
|
||
|
||
/// The one selected object after a command.
|
||
fn selected(s: &Session) -> Node {
|
||
let sel = &s.doc().unwrap().selection.objects;
|
||
assert_eq!(sel.len(), 1, "one object selected");
|
||
node(s, sel[0])
|
||
}
|
||
|
||
/// A clip group's clipping path and contents.
|
||
fn clip_parts(n: &Node) -> (&Node, Vec<&Node>) {
|
||
match &n.kind {
|
||
NodeKind::Group { clip: true, children } => (&children[0], children[1..].iter().map(|c| &**c).collect()),
|
||
k => panic!("expected a clip group, got {k:?}"),
|
||
}
|
||
}
|
||
|
||
fn fill_color(n: &Node) -> Color {
|
||
n.appearance.fill_paint().color().unwrap_or_else(|| panic!("solid fill expected on {:?}", n.kind))
|
||
}
|
||
|
||
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, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||
}
|
||
|
||
/// The mean difference of the colour channels of two renders.
|
||
fn mean_diff(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> f64 {
|
||
let sum: u64 = a.pixels.iter().zip(b.pixels.iter()).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||
sum as f64 / a.pixels.len() as f64
|
||
}
|
||
|
||
/// Pixels (of 200×200) whose colour 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()
|
||
}
|
||
|
||
/// Pattern "Dots" (a red 10×10 square with a black 1 pt stroke, centered in a 20×20 tile) and a
|
||
/// selected rectangle filled with it, with a blue 4 pt stroke when `stroke`.
|
||
fn pattern_rect(s: &mut Session, r: Rect, stroke: bool) -> NodeId {
|
||
let dot = run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap();
|
||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(s, "stroke.set", json!({"weight": 1}));
|
||
run(s, "object.pattern.make", json!({"ids": [dot], "name": "Dots", "width": 20, "height": 20, "edit": false}));
|
||
run(s, "select.set", json!({"ids": [dot]}));
|
||
run(s, "edit.clear", json!({}));
|
||
let id = run(s, "shape.rectangle", json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}))["id"].as_u64().unwrap();
|
||
run(s, "paint.setFill", json!({"swatch": "Dots"}));
|
||
if stroke {
|
||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||
run(s, "stroke.set", json!({"weight": 4}));
|
||
} else {
|
||
run(s, "paint.setStroke", json!({"none": true}));
|
||
}
|
||
NodeId(id)
|
||
}
|
||
|
||
/// `object.expand` with every option on, for a selected rectangle with a pattern fill only: the
|
||
/// one selected object is then the clip group of its tiles.
|
||
fn expand_to_tiles(s: &mut Session) {
|
||
run(s, "object.expand", json!({}));
|
||
let g = selected(s);
|
||
assert!(!clip_parts(&g).1.is_empty(), "the pattern fill became tiles");
|
||
}
|
||
|
||
fn white_black() -> Value {
|
||
json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]})
|
||
}
|
||
|
||
#[test]
|
||
fn eight_strips_match_the_gradient_samples() {
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), false);
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
run(&mut s, "object.expand", json!({"steps": 8}));
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
let g = selected(&s);
|
||
let (clip, strips) = clip_parts(&g);
|
||
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }));
|
||
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(20.0, 20.0, 180.0, 120.0));
|
||
assert_eq!(strips.len(), 8);
|
||
for (k, strip) in strips.iter().enumerate() {
|
||
// Band k starts at its eighth of the width (and runs on under the next bands, so no
|
||
// seams show) and shows the gradient at its middle.
|
||
let b = strip.geometric_bounds().unwrap();
|
||
assert!((b.x0 - (20.0 + 20.0 * k as f64)).abs() < 1e-9, "strip {k} starts at {}", b.x0);
|
||
assert!((b.x1 - 180.0).abs() < 1e-9 && (b.y0 - 20.0).abs() < 1e-9 && (b.y1 - 120.0).abs() < 1e-9);
|
||
let gray = 1.0 - (k as f32 + 0.5) / 8.0;
|
||
let [r, g, bl] = fill_color(strip).to_rgb();
|
||
assert!((r - gray).abs() < 1e-4 && (g - gray).abs() < 1e-4 && (bl - gray).abs() < 1e-4, "strip {k}: {r} {g} {bl} vs {gray}");
|
||
}
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_radial_gradient_becomes_concentric_ellipses() {
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), json!({"kind": "radial"}), false);
|
||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||
let g = selected(&s);
|
||
let (_, rings) = clip_parts(&g);
|
||
assert_eq!(rings.len(), 4);
|
||
// Largest first, all centred on the gradient's centre; the largest reaches the box's corners.
|
||
let reach = 50.0 * 2f64.sqrt();
|
||
for (i, ring) in rings.iter().enumerate() {
|
||
let b = ring.geometric_bounds().unwrap();
|
||
assert!((b.center().x - 100.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6);
|
||
let r = reach * (4 - i) as f64 / 4.0;
|
||
assert!((b.width() / 2.0 - r).abs() < 1e-6 && (b.height() / 2.0 - r).abs() < 1e-6, "ring {i}: {b:?} vs radius {r}");
|
||
}
|
||
// The centre disc shows the gradient at the middle of the first band (white → black).
|
||
let t = (reach / 8.0 / 50.0) as f32;
|
||
let [r, ..] = fill_color(rings[3]).to_rgb();
|
||
assert!((r - (1.0 - t)).abs() < 1e-4, "{r} vs {}", 1.0 - t);
|
||
// Translucent stops: rings with holes instead of stacked discs.
|
||
let mut s = session();
|
||
let gr = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]});
|
||
gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), gr, false);
|
||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||
let g = selected(&s);
|
||
let (_, rings) = clip_parts(&g);
|
||
let subpaths = |n: &Node| n.path_data().unwrap().subpaths.len();
|
||
assert_eq!(rings.iter().map(|r| subpaths(r)).collect::<Vec<_>>(), [2, 2, 2, 1]);
|
||
let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = rings[3].appearance.items.first() else { panic!() };
|
||
assert!(f.opacity < 1.0 && f.opacity > 0.5, "the centre band is translucent: {}", f.opacity);
|
||
}
|
||
|
||
#[test]
|
||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||
for gradient in [
|
||
json!({"kind": "linear", "angle": 30, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]}),
|
||
json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}),
|
||
] {
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient.clone(), false);
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({"gradient": "mesh"}));
|
||
let g = selected(&s);
|
||
let (_, content) = clip_parts(&g);
|
||
assert_eq!(content.len(), 1);
|
||
let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) };
|
||
assert!(m.is_valid());
|
||
// The mesh is drawn as small flat pieces, so it is close but not identical.
|
||
let after = render(&s);
|
||
assert_eq!(differing(&before, &after, 48), 0, "{gradient}");
|
||
assert!(mean_diff(&before, &after) < 2.5, "{gradient}: {}", mean_diff(&before, &after));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hard_stop_keeps_its_edge_in_the_mesh() {
|
||
let mut s = session();
|
||
let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#ff0000"}, {"offset": 0.5, "color": "#0000ff"}, {"offset": 1, "color": "#0000ff"}]});
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false);
|
||
run(&mut s, "object.expand", json!({"gradient": "mesh"}));
|
||
let g = selected(&s);
|
||
let (_, content) = clip_parts(&g);
|
||
let NodeKind::Mesh(m) = &content[0].kind else { panic!() };
|
||
// Columns at 0, 0.5 (red), 0.5 (blue), 1: the middle patch has no width.
|
||
assert_eq!(m.cols, 3);
|
||
let (a, b) = (&m.points[m.idx(0, 1)], &m.points[m.idx(0, 2)]);
|
||
assert!(a.p.distance(b.p) < 1e-9);
|
||
assert_eq!((a.color.to_hex(), b.color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_freeform_gradient_expands_to_a_mesh_shaped_like_the_object() {
|
||
let mut s = session();
|
||
let gr = json!({"kind": "freeform", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]});
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false);
|
||
let before = render(&s);
|
||
// Even with Specify … Objects.
|
||
run(&mut s, "object.expand", json!({"gradient": "objects"}));
|
||
let n = selected(&s);
|
||
let NodeKind::Mesh(m) = &n.kind else { panic!("a mesh: {:?}", n.kind) };
|
||
assert!(m.rows > 1 && m.cols > 1);
|
||
let b = m.outline().bounds().unwrap();
|
||
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 180.0).abs() < 1e-6, "{b:?}");
|
||
let after = render(&s);
|
||
assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after));
|
||
}
|
||
|
||
#[test]
|
||
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}));
|
||
let before = render(&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 g = selected(&s);
|
||
let (clip, tiles) = clip_parts(&g);
|
||
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, live: None, .. }));
|
||
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 85.0, 65.0));
|
||
// The 20 pt tile is centered on the square, so the squares sit at 5 + 20k; the tiles that reach
|
||
// the rectangle are 4 across and 3 down.
|
||
assert_eq!(tiles.len(), 12);
|
||
for t in &tiles {
|
||
let b = t.geometric_bounds().unwrap();
|
||
assert!((b.x0 - 5.0) % 20.0 == 0.0 && (b.y0 - 5.0) % 20.0 == 0.0 && b.width() == 10.0 && b.height() == 10.0, "{b:?}");
|
||
assert_eq!(fill_color(t).to_hex(), "#ff0000");
|
||
}
|
||
let mut ids = std::collections::HashSet::new();
|
||
g.walk(&mut |n| assert!(ids.insert(n.id), "fresh ids: {:?}", n.id));
|
||
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}));
|
||
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.
|
||
run(&mut s, "object.expand", json!({"fill": false}));
|
||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Pattern { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn the_tiles_of_a_scaled_pattern_keep_the_stroke_weight_the_canvas_draws() {
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]}));
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({}));
|
||
let g = selected(&s);
|
||
let (_, tiles) = clip_parts(&g);
|
||
assert!(!tiles.is_empty());
|
||
for t in &tiles {
|
||
assert_eq!(t.geometric_bounds().unwrap().width(), 20.0);
|
||
let Some(AppearanceItem::Stroke(st)) = t.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() };
|
||
assert_eq!(st.width, 2.0, "the 1 pt stroke drawn at 200%");
|
||
}
|
||
assert!(mean_diff(&before, &render(&s)) < 1.0, "{}", mean_diff(&before, &render(&s)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_tiles_of_a_stretched_pattern_draw_their_strokes_stretched() {
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
// Twice as wide: the 1 pt stroke draws 2 pt wide on the sides and 1 pt on the top and bottom.
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({}));
|
||
let g = selected(&s);
|
||
let (_, tiles) = clip_parts(&g);
|
||
assert!(!tiles.is_empty());
|
||
let mut strokes = 0;
|
||
for t in &tiles {
|
||
t.walk(&mut |n| strokes += n.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count());
|
||
}
|
||
assert_eq!(strokes, 0, "the strokes became filled outlines");
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
}
|
||
|
||
#[test]
|
||
fn the_rounded_corners_of_a_stretched_pattern_stay_stretched() {
|
||
let mut s = session();
|
||
let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "radius": 6}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Round", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [tile]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Round"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [3, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
// The canvas draws the tile as an image stretched 3×, so the edges differ a little; round
|
||
// corners would differ by far more.
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "corners stretched as the canvas draws them");
|
||
}
|
||
|
||
#[test]
|
||
fn a_gradient_along_a_tile_stroke_follows_the_stroke_in_a_stretched_pattern() {
|
||
let mut s = session();
|
||
let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 14, "height": 14}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "stroke.set", json!({"weight": 4}));
|
||
run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "along"}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Along", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [tile]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Along"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
// The meshes are drawn as small flat pieces and the canvas stretches the tile image, so the
|
||
// pictures are close but not identical; a gradient across the outline's box differs almost
|
||
// everywhere on the strokes.
|
||
let after = render(&s);
|
||
assert!(mean_diff(&before, &after) < 2.0, "{}", mean_diff(&before, &after));
|
||
assert!(differing(&before, &after, 64) < 500, "{}", differing(&before, &after, 64));
|
||
}
|
||
|
||
#[test]
|
||
fn a_brushed_tile_stroke_draws_its_brush_stretched() {
|
||
let mut s = session();
|
||
let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(&mut s, "brush.apply", json!({"name": "6 pt. Flat"}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Brushed", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [tile]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Brushed"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
// The canvas stretches the tile image, so edges differ a little.
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "the brush draws stretched as on the canvas");
|
||
let brushed = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some());
|
||
let mut left = 0;
|
||
selected(&s).walk(&mut |n| left += n.appearance.items.iter().filter(|i| brushed(i)).count());
|
||
assert_eq!(left, 0, "the brush strokes became brush art");
|
||
}
|
||
|
||
#[test]
|
||
fn a_flat_brush_in_a_rotated_pattern_turns_with_it() {
|
||
let mut s = session();
|
||
let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(&mut s, "brush.apply", json!({"name": "6 pt. Flat"}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Flat", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [tile]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Flat"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
// A quarter turn: the nib's thick and thin sides swap with the tile.
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [0, 1, -1, 0, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "the brush turns with the tiles as on the canvas");
|
||
}
|
||
|
||
#[test]
|
||
fn an_unlinked_opacity_mask_in_the_tile_art_moves_with_each_tile() {
|
||
let mut s = session();
|
||
let red = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
// A white mask over the square's left half, unlinked from it.
|
||
let mask = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 10}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ffffff"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "transparency.makeOpacityMask", json!({"ids": [red, mask]}));
|
||
run(&mut s, "transparency.unlinkOpacityMask", json!({"id": red}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [red], "name": "Half", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [red]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}));
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Half"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "each tile keeps its half mask");
|
||
}
|
||
|
||
#[test]
|
||
fn a_rounded_mask_in_a_stretched_pattern_stays_stretched() {
|
||
let mut s = session();
|
||
let red = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
// A white rounded square masks the red one.
|
||
let mask = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "radius": 6}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ffffff"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "transparency.makeOpacityMask", json!({"ids": [red, mask]}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [red], "name": "Masked", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [red]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Masked"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [3, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
// As for rounded tiles: the canvas stretches the tile image, so edges differ a little.
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "the mask's corners stretched as on the canvas");
|
||
}
|
||
|
||
#[test]
|
||
fn a_turned_pattern_whose_tiles_have_effects_it_cannot_bake_stays_live() {
|
||
// A drop shadow on the square, and an Offset Path on a group around it: the canvas draws both
|
||
// turned with each tile.
|
||
for group in [false, true] {
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 0, 0]}));
|
||
let square = s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].clone();
|
||
let art = if group {
|
||
let mut g = Node::group(NodeId(0), vec![square]);
|
||
g.appearance.effects.push(vectorcraft_render::effects::new_effect("path.offsetPath", &json!({"offset": 2})).unwrap());
|
||
g
|
||
} else {
|
||
let mut a = (*square).clone();
|
||
a.appearance.effects.push(vectorcraft_render::effects::new_effect("stylize.dropShadow", &json!({"x": 4, "y": 0})).unwrap());
|
||
a
|
||
};
|
||
s.edit("Effect", |d, _| {
|
||
d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)];
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "group {group}: nothing to expand");
|
||
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. }));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_random_transform_in_the_tile_art_draws_every_tile_alike() {
|
||
// The canvas draws the pattern's art once, so each tile shows the same random move and turn:
|
||
// on the square as the pattern is, then turned a quarter, and on a group around the square,
|
||
// which Expand cannot bake and leaves live.
|
||
for (turned, group) in [(false, false), (true, false), (false, true)] {
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
let square = s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].clone();
|
||
let mut art = if group { Node::group(NodeId(0), vec![square]) } else { (*square).clone() };
|
||
let fx = json!({"moveH": 4, "rotate": 40, "random": true});
|
||
art.appearance.effects.push(vectorcraft_render::effects::new_effect("distort.transform", &fx).unwrap());
|
||
s.edit("Effect", |d, _| {
|
||
d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)];
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
if turned {
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 0, 0]}));
|
||
}
|
||
if group {
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand");
|
||
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. }));
|
||
continue;
|
||
}
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
// The turned squares' edges differ a little.
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "turned {turned}: every tile as on the canvas");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_random_brush_in_the_tile_art_draws_every_tile_alike() {
|
||
// Confetti scatters its art at random from the path's points; the canvas draws the pattern's
|
||
// art once, so each tile shows the same confetti, as the pattern is and turned a quarter.
|
||
for turned in [false, true] {
|
||
let mut s = session();
|
||
let tile = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(&mut s, "brush.apply", json!({"name": "Confetti"}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [tile], "name": "Confetti", "width": 60, "height": 60, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [tile]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Confetti"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
if turned {
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [0, 1, -1, 0, 0, 0]}));
|
||
}
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
assert_eq!(differing(&before, &render(&s), 64), 0, "turned {turned}: every tile as on the canvas");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn roughen_in_the_tile_art_of_a_scaled_pattern_keeps_its_detail() {
|
||
// Roughen's detail is per inch: the canvas roughens the 10 pt square and scales the tile, so
|
||
// the expanded square has as many points as the canvas draws, not twice as many.
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
let mut art = (*s.doc().unwrap().doc.pattern("Dots").unwrap().art[0]).clone();
|
||
let fx = json!({"size": 2, "relative": false, "detail": 20, "points": "corner", "seed": 3});
|
||
art.appearance.effects.push(vectorcraft_render::effects::new_effect("distort.roughen", &fx).unwrap());
|
||
s.edit("Effect", |d, _| {
|
||
d.pattern_mut("Dots").unwrap().art = vec![std::sync::Arc::new(art)];
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [2, 0, 0, 2, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
let mut live = 0;
|
||
selected(&s).walk(&mut |n| live += n.appearance.effects.len());
|
||
assert_eq!(live, 0, "Roughen became filled art");
|
||
// The canvas scales the tile image 2×, so edges differ a little.
|
||
let after = render(&s);
|
||
assert!(mean_diff(&before, &after) < 1.0 && differing(&before, &after, 64) < 100, "every tile as on the canvas");
|
||
}
|
||
|
||
#[test]
|
||
fn a_scaled_pattern_whose_tiles_hold_a_symbol_stays_live() {
|
||
// The canvas draws the tile scaled, the symbol's 1 pt stroke included; a scaled instance keeps
|
||
// its stroke weight. A pattern that only moves expands.
|
||
for scaled in [false, true] {
|
||
let mut s = session();
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(&mut s, "stroke.set", json!({"weight": 1}));
|
||
let inst = run(&mut s, "symbol.new", json!({"name": "Box"}))["id"].as_u64().unwrap();
|
||
run(&mut s, "object.pattern.make", json!({"ids": [inst], "name": "Boxes", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [inst]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Boxes"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
if scaled {
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 2, 0, 0]}));
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand");
|
||
assert!(matches!(node(&s, NodeId(id)).appearance.fill_paint(), Paint::Pattern { .. }));
|
||
continue;
|
||
}
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_pattern_inside_a_stretched_pattern_moves_with_each_tile() {
|
||
let mut s = session();
|
||
// "Inner": a 4 pt red square in an 8 pt tile; "Outer": a 10 pt square filled with Inner, in a
|
||
// 20 pt tile, so each Outer tile starts 4 pt into Inner's period from the one before it.
|
||
let dot = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [dot], "name": "Inner", "width": 8, "height": 8, "edit": false}));
|
||
let sq = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Inner"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [sq], "name": "Outer", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [dot, sq]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let id = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Outer"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "pattern.transform", json!({"ids": [id], "matrix": [2, 0, 0, 1, 0, 0]}));
|
||
let before = render(&s);
|
||
expand_to_tiles(&mut s);
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
}
|
||
|
||
#[test]
|
||
fn a_fill_effect_stays_on_the_expanded_fill() {
|
||
let has_effect = |n: &Node| {
|
||
let mut found = false;
|
||
n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Group { clip: true, .. }) && !c.appearance.effects.is_empty());
|
||
found
|
||
};
|
||
// A pattern fill and a gradient fill, each the object's only item, with a drop shadow.
|
||
let kept = [true, false].map(|pattern| {
|
||
let mut s = session();
|
||
if pattern {
|
||
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false);
|
||
} else {
|
||
gradient_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), white_black(), false);
|
||
}
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": 0}));
|
||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||
has_effect(&selected(&s))
|
||
});
|
||
assert_eq!(kept, [true, true], "the fill's drop shadow is on its clip group");
|
||
}
|
||
|
||
#[test]
|
||
fn a_geometry_effect_shapes_the_clip_of_an_expanded_pattern_and_not_its_tiles() {
|
||
// Offset Path on the pattern fill, then on the object: either grows the area the fill paints by
|
||
// 6 pt on each side, and the canvas lays the tiles over that area unchanged.
|
||
for item in [true, false] {
|
||
let mut s = session();
|
||
pattern_rect(&mut s, Rect::new(40.0, 40.0, 160.0, 160.0), false);
|
||
let mut p = json!({"effect": "path.offsetPath", "params": {"offset": 6}});
|
||
if item {
|
||
p["item"] = json!(0);
|
||
}
|
||
run(&mut s, "effect.apply", p);
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({"object": false, "stroke": false}));
|
||
let g = selected(&s);
|
||
assert!(g.appearance.effects.is_empty(), "item {item}: the offset is not left on the tiles: {:?}", g.appearance.effects);
|
||
let (clip, tiles) = clip_parts(&g);
|
||
let b = clip.geometric_bounds().unwrap();
|
||
assert!((b.x0 - 34.0).abs() < 0.01 && (b.x1 - 166.0).abs() < 0.01, "item {item}: the clip is the offset outline: {b:?}");
|
||
assert!(tiles.iter().all(|t| t.geometric_bounds().is_some_and(|b| (b.width() - 10.0).abs() < 0.01)), "item {item}: tiles keep their size");
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "item {item}: looks the same");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_geometry_effect_shapes_the_clip_of_an_expanded_gradient_and_not_its_strips() {
|
||
// As for patterns: the canvas fits the gradient to the offset outline and fills it.
|
||
for item in [true, false] {
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(40.0, 40.0, 160.0, 160.0), white_black(), false);
|
||
let mut p = json!({"effect": "path.offsetPath", "params": {"offset": 6}});
|
||
if item {
|
||
p["item"] = json!(0);
|
||
}
|
||
run(&mut s, "effect.apply", p);
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({"object": false, "stroke": false}));
|
||
let g = selected(&s);
|
||
assert!(g.appearance.effects.is_empty(), "item {item}: the offset is not left on the strips: {:?}", g.appearance.effects);
|
||
let b = clip_parts(&g).0.geometric_bounds().unwrap();
|
||
assert!((b.x0 - 34.0).abs() < 0.01 && (b.x1 - 166.0).abs() < 0.01, "item {item}: the clip is the offset outline: {b:?}");
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "item {item}: looks the same");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tiles_that_blend_blend_only_with_each_other() {
|
||
let mut s = session();
|
||
// A blue backdrop under a rectangle filled with a pattern whose square multiplies.
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
let dot = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap();
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
run(&mut s, "transparency.set", json!({"blend": "multiply"}));
|
||
run(&mut s, "object.pattern.make", json!({"ids": [dot], "name": "Multiply", "width": 20, "height": 20, "edit": false}));
|
||
run(&mut s, "select.set", json!({"ids": [dot]}));
|
||
run(&mut s, "edit.clear", json!({}));
|
||
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 160, "height": 160}));
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Multiply"}));
|
||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({}));
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
assert!(selected(&s).isolate, "the tiles' clip group isolates their blending");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pattern_fill_that_lays_no_tiles_stays_live() {
|
||
// A 0.1 pt tile over a 160 pt square needs more instances than a pattern lays.
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
run(&mut s, "pattern.options", json!({"name": "Dots", "width": 0.1, "height": 0.1}));
|
||
// Fill alone: nothing to expand, and the live rectangle is left as it was.
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err());
|
||
assert!(matches!(node(&s, id).kind, NodeKind::Path { live: Some(_), .. }));
|
||
run(&mut s, "object.expand", json!({}));
|
||
let n = selected(&s);
|
||
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }), "Object still expands the rectangle");
|
||
assert!(matches!(n.appearance.fill_paint(), Paint::Pattern { .. }), "the fill is kept");
|
||
// A pattern scaled by 1e-20 covers the square with more tile positions than can be counted.
|
||
let mut s = session();
|
||
let id = pattern_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), false);
|
||
run(&mut s, "pattern.transform", json!({"ids": [id.0], "matrix": [1e-20, 0, 0, 1e-20, 0, 0]}));
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err());
|
||
assert!(matches!(node(&s, id).appearance.fill_paint(), Paint::Pattern { .. }), "the fill is kept");
|
||
}
|
||
|
||
#[test]
|
||
fn type_filled_with_a_pattern_expands_to_outlines_holding_the_tiles() {
|
||
let mut s = session();
|
||
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), false);
|
||
run(&mut s, "edit.clear", json!({}));
|
||
let t = run(&mut s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap();
|
||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||
run(&mut s, "paint.setFill", json!({"swatch": "Dots"}));
|
||
assert_eq!(run(&mut s, "object.expand.info", json!({}))["fill"], json!(true));
|
||
run(&mut s, "object.expand", json!({}));
|
||
let (mut clips, mut patterns) = (0, 0);
|
||
for id in s.doc().unwrap().selection.objects.clone() {
|
||
node(&s, id).walk(&mut |n| {
|
||
clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. }));
|
||
patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count();
|
||
});
|
||
}
|
||
assert!(clips > 0 && patterns == 0, "{clips} clip groups, {patterns} pattern paints left");
|
||
}
|
||
|
||
#[test]
|
||
fn an_expanded_pattern_fill_keeps_its_transparency_and_the_stroke_on_top() {
|
||
let mut s = session();
|
||
pattern_rect(&mut s, Rect::new(25.0, 25.0, 85.0, 65.0), true);
|
||
// [Fill (pattern), Stroke, Fill (green, on top)], the pattern fill at 50% in Multiply.
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
run(&mut s, "paint.setFill", json!({"item": 2, "color": "#00ff00"}));
|
||
run(&mut s, "transparency.set", json!({"item": 2, "opacity": 30}));
|
||
run(&mut s, "transparency.set", json!({"item": 0, "opacity": 50, "blend": "multiply"}));
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({"stroke": false}));
|
||
let g = selected(&s);
|
||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) };
|
||
assert_eq!(children.len(), 2);
|
||
assert_eq!((children[0].opacity, children[0].blend), (0.5, BlendMode::Multiply));
|
||
assert_eq!(clip_parts(&children[0]).1.len(), 12);
|
||
// The stroke and the top fill stay on a copy above, in paint order.
|
||
let items = &children[1].appearance.items;
|
||
assert_eq!(items.len(), 2);
|
||
assert_eq!(children[1].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff");
|
||
assert!(matches!(&items[1], AppearanceItem::Fill(f) if f.paint.color().unwrap().to_hex() == "#00ff00"));
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
// Stroke on as well: the stroke becomes a filled outline over the tiles.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "object.expand", json!({}));
|
||
let g = selected(&s);
|
||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) };
|
||
assert_eq!(clip_parts(&children[0]).1.len(), 12);
|
||
assert_eq!(fill_color(&children[1]).to_hex(), "#0000ff", "the stroke became a filled outline");
|
||
}
|
||
|
||
#[test]
|
||
fn a_gradient_that_cannot_be_expanded_leaves_the_object_as_it_was() {
|
||
// A freeform gradient on a path of one anchor has no outline to shape a mesh to.
|
||
let mut s = session();
|
||
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 50, "y": 50}]}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}}));
|
||
let before = node(&s, id);
|
||
assert!(s.execute("object.expand", &json!({"object": false, "stroke": false})).is_err(), "nothing to expand");
|
||
assert_eq!(node(&s, id), before);
|
||
}
|
||
|
||
#[test]
|
||
fn create_gradient_mesh_takes_its_colours_from_the_gradient() {
|
||
let mut s = session();
|
||
let id = gradient_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 50.0), white_black(), false);
|
||
run(&mut s, "object.mesh.create", json!({"rows": 1, "cols": 2}));
|
||
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
|
||
let hex = |r: usize, c: usize| m.points[m.idx(r, c)].color.to_hex();
|
||
for r in 0..=1 {
|
||
assert_eq!(hex(r, 0), "#ffffff", "left column white");
|
||
assert_eq!(hex(r, 2), "#000000", "right column black");
|
||
assert_eq!(hex(r, 1), "#808080", "middle grey");
|
||
}
|
||
// A solid fill still gives one colour, the highlight still lightens.
|
||
let mut s = session();
|
||
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50}))["id"].as_u64().unwrap());
|
||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100}));
|
||
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
|
||
assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#ff0000");
|
||
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
|
||
}
|
||
|
||
#[test]
|
||
fn expanded_colours_keep_the_stops_colour_model() {
|
||
let mut s = session();
|
||
let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 1, "y": 0, "k": 0}}]});
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), gr, false);
|
||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||
let g = selected(&s);
|
||
let (_, strips) = clip_parts(&g);
|
||
let Color::Cmyk { c, m, .. } = fill_color(strips[1]) else { panic!("CMYK strip expected: {:?}", fill_color(strips[1])) };
|
||
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
|
||
}
|
||
|
||
#[test]
|
||
fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
|
||
// Fill unchecked: the stroke is outlined, the gradient fill stays a gradient.
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||
run(&mut s, "object.expand", json!({"fill": false}));
|
||
let g = selected(&s);
|
||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("fill + outlined stroke group: {:?}", g.kind) };
|
||
assert_eq!(children.len(), 2);
|
||
assert!(matches!(children[0].appearance.fill_paint(), Paint::Gradient(_)), "the fill is still live");
|
||
assert_eq!(fill_color(&children[1]).to_hex(), "#0000ff", "the stroke became a filled outline");
|
||
// Stroke unchecked: the gradient is expanded and the stroke stays a stroke on a copy above.
|
||
let mut s = session();
|
||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||
let before = render(&s);
|
||
run(&mut s, "object.expand", json!({"stroke": false, "steps": 255}));
|
||
let g = selected(&s);
|
||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) };
|
||
assert_eq!(clip_parts(&children[0]).1.len(), 255);
|
||
assert_eq!(children[1].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff");
|
||
assert!(children[1].appearance.fill_paint().is_none());
|
||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||
}
|
||
|
||
#[test]
|
||
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}));
|
||
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.
|
||
let g = selected(&s);
|
||
let NodeKind::Group { children, .. } = &g.kind else { panic!("{:?}", g.kind) };
|
||
let (clip, strips) = clip_parts(&children[0]);
|
||
assert!(matches!(clip.kind, NodeKind::Path { live: None, .. }));
|
||
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!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand");
|
||
// Bad options are refused before anything changes.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.execute("object.expand", &json!({"gradient": "rings"})).is_err());
|
||
assert!(s.execute("object.expand", &json!({"steps": 0})).is_err());
|
||
assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. }));
|
||
}
|