Files
vectorcraft/crates/engine/src/tests_build.rs
T

2249 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for Shape Builder, Live Paint and Image Trace commands and tools.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn set_fill(s: &mut Session, id: NodeId, c: Color) {
s.edit("t", |d, _| {
d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(c));
Ok(())
})
.unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
fn outline(n: &Node) -> (PathData, FillRule) {
vectorcraft_tools::builder::node_outline(n).expect("path")
}
fn area(s: &Session, id: NodeId) -> f64 {
let (p, r) = outline(&node(s, id));
vectorcraft_pathops::area(&p, r)
}
/// Rect A (0..100) and rect B (50..150), both 100 tall, selected. A red, B blue.
fn two(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 0.0, 100.0, 100.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(a, b)
}
fn layer_children(s: &Session) -> usize {
let d = &s.doc().unwrap().doc;
d.children(d.default_layer()).map(|c| c.len()).unwrap_or(0)
}
// ---------- Shape Builder ----------
#[test]
fn shape_builder_drag_merges_touched_regions() {
let mut s = session();
two(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
assert_eq!(r["regions"], 2);
let out = ids(&r);
assert_eq!(out.len(), 2, "merged shape + remaining part of B");
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 10000.0).abs() < 1.0, "{}", area(&s, merged));
let rest = out.iter().copied().find(|i| *i != merged).unwrap();
assert!((area(&s, rest) - 5000.0).abs() < 1.0);
// Merged shape takes the fill of the object under the drag start (A, red).
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(layer_children(&s), 2);
}
#[test]
fn shape_builder_click_splits_out_one_region() {
let mut s = session();
two(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]]})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 3);
let total: f64 = out.iter().map(|i| area(&s, *i)).sum();
assert!((total - 15000.0).abs() < 1.0, "{total}");
let merged = NodeId(r["merged"].as_u64().unwrap());
// Overlap is covered by B on top → blue.
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
let bb = node(&s, merged).geometric_bounds().unwrap();
assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6);
}
#[test]
fn shape_builder_erase_deletes_regions() {
let mut s = session();
two(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "erase": true})).unwrap();
assert!(r["merged"].is_null());
let out = ids(&r);
assert_eq!(out.len(), 2);
for i in out {
assert!((area(&s, i) - 5000.0).abs() < 1.0);
}
}
#[test]
fn shape_builder_fill_option_and_untouched_shapes_kept() {
let mut s = session();
let (a, b) = two(&mut s);
let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0, c.0], "points": [[25, 50], [125, 50]], "fill": "#00ff00"})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 2, "one merged + the untouched square");
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 15000.0).abs() < 1.0);
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
let sq = out.iter().copied().find(|i| *i != merged).unwrap();
assert_eq!(node(&s, sq).geometric_bounds().unwrap(), Rect::new(300.0, 300.0, 350.0, 350.0));
assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4);
}
#[test]
fn shape_builder_miss_is_an_error_and_undo_restores() {
let mut s = session();
let (a, b) = two(&mut s);
let undo_before = s.doc().unwrap().history.undo.len();
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[500, 500]]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some());
assert!(s.execute("shapeBuilder.merge", &json!({"points": []})).is_err());
assert!(s.execute("shapeBuilder.merge", &json!({})).is_err());
}
#[test]
fn shape_builder_regions_query() {
let mut s = session();
two(&mut s);
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regs = r["regions"].as_array().unwrap();
assert_eq!(regs.len(), 3);
let total: f64 = regs.iter().map(|x| x["area"].as_f64().unwrap()).sum();
assert!((total - 15000.0).abs() < 1.0);
assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2));
}
/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing
/// fills. It is a region, and a drag from a circle into it merges the two.
#[test]
fn shape_builder_merges_the_gap_touching_circles_enclose() {
let mut s = session();
let mut circles = vec![];
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] {
let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap();
circles.push(NodeId(r["id"].as_u64().unwrap()));
}
set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regs = r["regions"].as_array().unwrap();
assert_eq!(regs.len(), 5, "{regs:?}");
assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap();
assert_eq!(r["regions"], 2);
let out = ids(&r);
assert_eq!(out.len(), 4, "the merged shape and the three other circles");
let merged = NodeId(r["merged"].as_u64().unwrap());
// A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1).
assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged));
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on");
assert_eq!(layer_children(&s), 4);
// A click in the diamond alone makes it a shape of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap();
assert_eq!(ids(&r).len(), 5);
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0);
}
#[test]
fn shape_builder_tool_drives_the_command() {
let mut s = session();
two(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
assert_eq!(s.tool_id(), "shapeBuilder");
s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "hover highlight");
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap();
assert_eq!(layer_children(&s), 2);
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
// Alt-click deletes a region.
let alt = Mods { alt: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0).with_mods(alt), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0).with_mods(alt), v).unwrap();
assert_eq!(layer_children(&s), 1);
}
/// The first and last stop colours of a gradient paint.
fn gradient_ends(p: &Paint) -> Option<([f32; 3], [f32; 3])> {
let Paint::Gradient(g) = p else { return None };
Some((g.gradient.stops.first()?.color.to_rgb(), g.gradient.stops.last()?.color.to_rgb()))
}
#[test]
fn shape_builder_cuts_gradient_shapes_from_a_pdf_along_their_outlines() {
// Two overlapping diamonds (x 100–300 and 200–400, y 40–160), each written as gradient-filled
// shapes are in a PDF or a PDF-compatible .ai: the diamond as a clip, then a shading (#1304).
let shading = |x0: u32, c0: &str, c1: &str| {
format!(
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{x0} 0 {} 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [{c0}] /C1 [{c1}] /N 1 >> >>",
x0 + 200
)
};
let diamond = |x0: u32| format!("q {x0} 100 m {} 160 l {} 100 l {} 40 l h W n", x0 + 100, x0 + 200, x0 + 100);
let page = PdfPage {
resources: format!("/Shading << /Sh0 {} /Sh1 {} >>", shading(100, "1 0 0", "1 1 0"), shading(200, "0 0 1", "0 1 1")),
..PdfPage::new(500.0, 200.0, &format!("{} /Sh0 sh Q {} /Sh1 sh Q", diamond(100), diamond(200)))
};
let bytes = pdf(&[page], None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "diamonds.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[250, 100]]})).unwrap();
let mut pieces: Vec<(f64, Rect, Paint)> =
ids(&r).into_iter().map(|i| (area(&s, i), node(&s, i).geometric_bounds().unwrap(), node(&s, i).appearance.fill_paint())).collect();
pieces.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.01);
let [(left, lb, lp), (mid, mb, mp), (right, rb, rp)] = pieces.as_slice() else { panic!("three pieces: {pieces:?}") };
// What is left of each diamond, and the diamond where they overlap: not their bounding boxes.
assert!((left - 9000.0).abs() < 1.0 && (mid - 3000.0).abs() < 1.0 && (right - 9000.0).abs() < 1.0, "{pieces:?}");
assert!(near(*lb, Rect::new(100.0, 40.0, 250.0, 160.0)), "{lb:?}");
assert!(near(*mb, Rect::new(200.0, 70.0, 300.0, 130.0)), "{mb:?}");
assert!(near(*rb, Rect::new(250.0, 40.0, 400.0, 160.0)), "{rb:?}");
// Each keeps its gradient; the overlap takes the front diamond's.
let (first, second) = (Some(([1.0, 0.0, 0.0], [1.0, 1.0, 0.0])), Some(([0.0, 0.0, 1.0], [0.0, 1.0, 1.0])));
assert_eq!((gradient_ends(lp), gradient_ends(mp), gradient_ends(rp)), (first, second, second));
}
/// A red 100 × 100 square crossed by a stroke-only vertical line at x = 50, both selected.
fn square_and_line(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
let l = line(s, (50.0, -20.0), (50.0, 120.0));
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
(a, l)
}
/// The open paths among `ids`, as (anchors, bounds).
fn open_paths(s: &Session, ids: &[NodeId]) -> Vec<(usize, Rect)> {
ids.iter()
.map(|i| node(s, *i))
.filter(|n| !outline(n).0.is_closed())
.map(|n| (outline(&n).0.anchor_count(), n.geometric_bounds().unwrap()))
.collect()
}
#[test]
fn shape_builder_line_splits_a_shape_into_regions() {
let mut s = session();
let (a, l) = square_and_line(&mut s);
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regs = r["regions"].as_array().unwrap();
assert_eq!(regs.len(), 2, "{r}");
for x in regs {
assert!((x["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
assert_eq!(x["sources"], json!([a.0]));
}
let lines = r["lines"].as_array().unwrap();
assert_eq!(lines.len(), 3, "the line cut at both sides of the square");
assert!(lines.iter().all(|x| x["source"] == l.0));
}
#[test]
fn shape_builder_click_on_a_side_of_a_line_splits_the_shape() {
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]]})).unwrap();
assert_eq!(r["regions"], 1);
let out = ids(&r);
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(node(&s, merged).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0));
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let closed: Vec<f64> = out.iter().filter(|i| outline(&node(&s, **i)).0.is_closed()).map(|i| area(&s, *i)).collect();
assert_eq!(closed.len(), 2, "both halves");
assert!(closed.iter().all(|a| (a - 5000.0).abs() < 1.0), "{closed:?}");
// The line only bounds the region: it stays whole.
assert_eq!(open_paths(&s, &out), vec![(2, Rect::new(50.0, -20.0, 50.0, 120.0))]);
}
#[test]
fn shape_builder_merge_across_a_line_removes_the_piece_between() {
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
assert_eq!(r["regions"], 2);
let out = ids(&r);
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 10000.0).abs() < 1.0);
assert_eq!(out.len(), 3, "merged square + the line's two loose ends");
let mut ends = open_paths(&s, &out);
ends.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
assert_eq!(ends, vec![(2, Rect::new(50.0, -20.0, 50.0, 0.0)), (2, Rect::new(50.0, 100.0, 50.0, 120.0))]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 2);
}
#[test]
fn shape_builder_alt_deletes_a_region_or_a_line_piece() {
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "erase": true})).unwrap();
assert!(r["merged"].is_null());
let out = ids(&r);
let left: Vec<NodeId> = out.iter().copied().filter(|i| outline(&node(&s, *i)).0.is_closed()).collect();
assert_eq!(left.len(), 1);
assert_eq!(node(&s, left[0]).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0));
assert_eq!(open_paths(&s, &out).len(), 1, "the line stays whole");
// Alt on the line inside the square deletes that piece only.
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "erase": true, "tolerance": 2})).unwrap();
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64()), (Some(0), Some(1)));
let out = ids(&r);
assert_eq!(out.len(), 3);
let sq: Vec<NodeId> = out.iter().copied().filter(|i| outline(&node(&s, *i)).0.is_closed()).collect();
assert_eq!(sq.len(), 1);
assert!((area(&s, sq[0]) - 10000.0).abs() < 1.0, "the square is untouched");
assert_eq!(open_paths(&s, &out).len(), 2);
}
#[test]
fn shape_builder_fills_an_area_lines_enclose() {
let mut s = session();
let l1 = line(&mut s, (0.0, 0.0), (100.0, 100.0));
let l2 = line(&mut s, (100.0, 0.0), (0.0, 100.0));
let l3 = line(&mut s, (-10.0, 80.0), (110.0, 80.0));
s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["regions"].as_array().unwrap().len(), 1);
assert_eq!(r["regions"][0]["sources"], json!([]));
let top_line = node(&s, l3).appearance.clone();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 900.0).abs() < 1e-6, "{}", area(&s, merged));
assert_eq!(node(&s, merged).appearance, top_line, "no object at the click: the look of the front-most selected line");
assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it");
}
/// Two diagonals and a crossbar that stops about 3 pt short of each: the triangle they bound.
fn lines_with_gaps(s: &mut Session) {
let l1 = line(s, (0.0, 0.0), (100.0, 100.0));
let l2 = line(s, (100.0, 0.0), (0.0, 100.0));
let l3 = line(s, (24.0, 80.0), (76.0, 80.0));
s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap();
}
#[test]
fn shape_builder_gap_detection_closes_small_gaps_between_lines() {
let mut s = session();
lines_with_gaps(&mut s);
let regions = |s: &mut Session, p: Value| s.execute("shapeBuilder.regions", &p).unwrap()["regions"].as_array().unwrap().len();
assert_eq!(regions(&mut s, json!({})), 0, "off by default");
assert_eq!(regions(&mut s, json!({"gap": 2})), 0, "the gap is longer");
assert_eq!(regions(&mut s, json!({"gap": 3})), 1);
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).is_err(), "no gap, no region");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]], "gap": 3})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
let a = area(&s, merged);
assert!(a > 850.0 && a < 900.0, "the triangle and its two bridges: {a}");
assert_eq!(ids(&r).len(), 4, "the bridges are no art: the merged shape and the three lines");
assert_eq!(open_paths(&s, &ids(&r)).len(), 3);
}
#[test]
fn shape_builder_tool_gap_detection_option() {
let mut s = session();
lines_with_gaps(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
assert_eq!((s.tool_options()["gapDetection"].clone(), s.tool_options()["gapLength"].clone()), (json!(false), json!(3.0)));
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 70.0), v).unwrap();
};
click(&mut s);
assert_eq!(layer_children(&s), 3, "Gap Detection is off");
s.set_tool_option_cmd(&json!({"values": {"gapDetection": true, "gapLength": 6.0}})).unwrap();
click(&mut s);
assert_eq!(layer_children(&s), 4, "the triangle");
// The options are kept when another tool is chosen.
s.select_tool("selection", v).unwrap();
assert_eq!(
(s.tool_options_of("shapeBuilder")["gapDetection"].clone(), s.tool_options_of("shapeBuilder")["gapLength"].clone()),
(json!(true), json!(6.0))
);
}
#[test]
fn shape_builder_closes_filled_open_paths() {
let mut s = session();
let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap();
let p = NodeId(r["id"].as_u64().unwrap());
set_fill(&mut s, p, Color::rgb(0.0, 1.0, 0.0));
s.execute("select.set", &json!({"ids": [p.0]})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["regions"].as_array().unwrap().len(), 1, "the fill's area, closed by an invisible edge");
assert!((r["regions"][0]["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
assert_eq!(r["lines"], json!([]));
// Unfilled, it is only an edge.
s.edit("t", |d, _| {
d.node_mut(p).unwrap().appearance.set_fill(Paint::None);
Ok(())
})
.unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["regions"], json!([]));
assert_eq!(r["lines"].as_array().unwrap().len(), 1);
}
/// Consider Open Filled Paths as Closed off: a filled open path is only an edge, as an unfilled
/// one is, so it forms no region and a click inside it merges nothing.
#[test]
fn shape_builder_open_filled_paths_cut_without_open_filled_as_closed() {
let mut s = session();
let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap();
let p = NodeId(r["id"].as_u64().unwrap());
set_fill(&mut s, p, Color::rgb(0.0, 1.0, 0.0));
s.execute("select.set", &json!({"ids": [p.0]})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({"openFilledAsClosed": false})).unwrap();
assert_eq!(r["regions"], json!([]));
assert_eq!(r["lines"].as_array().unwrap().len(), 1);
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]], "openFilledAsClosed": false})).is_err());
// On (the default), the click makes the area the fill closes a shape.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]]})).unwrap();
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6);
}
/// Pick Color From Color Swatches: the merged shape takes the current fill, not the fill of the
/// object under the first point; setting the current fill without `ids` leaves the selection's.
#[test]
fn shape_builder_pick_color_from_swatches_fills_with_the_current_fill() {
let mut s = session();
let (a, _) = two(&mut s);
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap();
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill");
let green = s.paint.fill.clone();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]], "pickColorFrom": "swatches"})).unwrap();
assert_eq!(node(&s, NodeId(r["merged"].as_u64().unwrap())).appearance.fill_paint(), green);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]], "pickColorFrom": "artwork"})).unwrap();
assert_eq!(node(&s, NodeId(r["merged"].as_u64().unwrap())).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "pickColorFrom": "layers"})).is_err());
}
/// Four #888888 bars round a hole (20..80 on both axes), and in front of them a red square
/// (90..140 × 30..70) with a 5 pt stroke that overlaps the right bar. Returns the bars, back to
/// front, and the square.
fn frame_and_red_square(s: &mut Session) -> (Vec<NodeId>, NodeId) {
let gray = Color::from_hex("#888888").unwrap();
let mut bars = vec![];
for (x, y, w, h) in [(0.0, 0.0, 20.0, 100.0), (80.0, 0.0, 20.0, 100.0), (0.0, 0.0, 100.0, 20.0), (0.0, 80.0, 100.0, 20.0)] {
let id = rect(s, x, y, w, h);
set_fill(s, id, gray);
bars.push(id);
}
let red = rect(s, 90.0, 30.0, 50.0, 40.0);
s.edit("t", |d, _| {
let a = &mut d.node_mut(red).unwrap().appearance;
a.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
a.set_stroke(Paint::solid(Color::rgb(0.0, 0.0, 0.0)));
a.stroke_mut().unwrap().width = 5.0;
Ok(())
})
.unwrap();
(bars, red)
}
fn select_ids(s: &mut Session, list: &[NodeId]) {
s.execute("select.set", &json!({"ids": list.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn merged_look(s: &Session, r: &Value) -> vectorcraft_doc::Shared<vectorcraft_doc::Appearance> {
node(s, NodeId(r["merged"].as_u64().unwrap())).appearance
}
/// Pick Color From Artwork: the merged shape takes the look of the object where the drag starts,
/// else of the object where it ends, else of the front-most selected object, as the combine page
/// states. A drag that starts in an object keeps that object's look.
#[test]
fn shape_builder_artwork_takes_the_look_where_the_drag_starts_then_ends_then_the_top_object() {
let mut s = session();
let (bars, red) = frame_and_red_square(&mut s);
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap();
let gray = node(&s, bars[3]).appearance.clone();
let red_look = node(&s, red).appearance.clone();
let all: Vec<NodeId> = bars.iter().copied().chain([red]).collect();
let merge = |s: &mut Session, list: &[NodeId], points: Value| {
select_ids(s, list);
let r = s.execute("shapeBuilder.merge", &json!({ "points": points })).unwrap();
let look = merged_look(s, &r);
s.execute("edit.undo", &json!({})).unwrap();
(r["regions"].as_u64().unwrap(), look)
};
// A click in the hole, where no object is: the front-most selected object's look, not the
// current fill.
assert_eq!(merge(&mut s, &bars, json!([[50, 50]])), (1, gray.clone()), "the front bar's look");
assert_eq!(merge(&mut s, &all, json!([[50, 50]])), (1, red_look.clone()), "the square is in front");
// A drag from the hole across the right bar into the square: the look where it ends, not
// the look of the first object it crosses.
assert_eq!(merge(&mut s, &all, json!([[50, 50], [120, 50]])), (4, red_look.clone()));
// Ending on the right bar, the drag takes the bar's look, though the square is in front.
let right_bar = node(&s, bars[1]).appearance.clone();
assert_eq!(merge(&mut s, &all, json!([[50, 50], [85, 50]])), (2, right_bar.clone()));
// A drag from outside every region: the look where it ends.
assert_eq!(merge(&mut s, &all, json!([[-10, 50], [120, 50]])).1, red_look);
// A drag that starts on the right bar: the bar's look.
assert_eq!(merge(&mut s, &all, json!([[85, 50], [120, 50]])).1, right_bar);
// From the square back into the hole: the square's look.
assert_eq!(merge(&mut s, &all, json!([[120, 50], [50, 50]])).1, red_look);
// A drag from blue into red takes blue, where it starts.
let mut s = session();
let (_, b) = two(&mut s);
let blue = node(&s, b).appearance.clone();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[125, 50], [25, 50]]})).unwrap();
assert_eq!(merged_look(&s, &r), blue);
}
/// With Pick Color From Color Swatches, the merged shape takes the current fill, and the rest of
/// its look from the front-most object over the first region the drag touches that an object
/// covers.
#[test]
fn shape_builder_swatches_keep_the_current_fill_and_the_first_covered_objects_look() {
let mut s = session();
let (bars, red) = frame_and_red_square(&mut s);
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap();
let green = s.paint.fill.clone();
let bar = node(&s, bars[1]).appearance.clone();
select_ids(&mut s, &bars.iter().copied().chain([red]).collect::<Vec<_>>());
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [120, 50]], "pickColorFrom": "swatches"})).unwrap();
let look = merged_look(&s, &r);
assert_eq!(look.fill_paint(), green);
assert_eq!((look.stroke_paint(), look.stroke().map(|k| k.width)), (bar.stroke_paint(), bar.stroke().map(|k| k.width)), "the right bar's stroke");
}
/// The Shape Builder tool sends the point where the drag started first and the point where it
/// ended last, so a drag from the hole into the square takes the square's look.
#[test]
fn shape_builder_tool_drag_from_the_hole_takes_the_look_where_it_ends() {
let mut s = session();
let (bars, red) = frame_and_red_square(&mut s);
let red_look = node(&s, red).appearance.clone();
select_ids(&mut s, &bars.iter().copied().chain([red]).collect::<Vec<_>>());
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 85.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 120.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 120.0, 50.0), v).unwrap();
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
let d = &s.doc().unwrap().doc;
let kids = d.children(d.default_layer()).unwrap();
assert_eq!(kids.iter().filter(|n| n.appearance == red_look).count(), 1, "the merged shape has the square's look");
}
/// The Shape Builder Tool Options reach what the tool does: Straight Line sends the drag's first
/// and last points, and the other options go with them to `shapeBuilder.merge`.
#[test]
fn shape_builder_tool_options_reach_the_command() {
let mut s = session();
two(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
let values = json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "selection": "straightLine"});
s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": values})).unwrap();
// A drag that bends up through (60, 20) on its way.
for (kind, x, y) in
[(PointerKind::Down, 25.0, 50.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Drag, 75.0, 50.0), (PointerKind::Up, 75.0, 50.0)]
{
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!(cmd, "shapeBuilder.merge");
assert_eq!(
(&p["points"], &p["openFilledAsClosed"], &p["pickColorFrom"]),
(&json!([[25.0, 50.0], [75.0, 50.0]]), &json!(false), &json!("swatches"))
);
}
/// Highlight Fill off: hovering a region in merge mode highlights nothing; erase mode still shows
/// what a click would delete.
#[test]
fn shape_builder_highlight_fill_off_leaves_regions_unshaded() {
let mut s = session();
two(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap();
assert!(!s.overlays(v).is_empty());
s.set_tool_option_cmd(&json!({"key": "highlightFill", "value": false})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 76.0, 50.0), v).unwrap();
assert!(s.overlays(v).is_empty());
let alt = Mods { alt: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0).with_mods(alt), v).unwrap();
assert!(!s.overlays(v).is_empty());
}
/// Pick Color From Color Swatches: Left and Right step the current fill through the document's
/// color swatches (the selection keeps its fills), and Cursor Swatch Preview shows the current fill
/// between the swatches before and after it.
#[test]
fn shape_builder_arrow_keys_step_the_current_fill_through_the_swatches() {
let mut s = session();
let (a, _) = two(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
let chips = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Swatch { .. })).count();
assert!(!s.tool_claims_key(ToolKey::Right, v), "Artwork leaves the arrows to nudging");
let values = json!({"pickColorFrom": "swatches", "cursorSwatchPreview": true});
s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": values})).unwrap();
assert!(s.tool_claims_key(ToolKey::Right, v));
let (first, swatches): (String, Vec<Paint>) = {
let list = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc);
(list[0].name.clone(), list.iter().take(3).map(|sw| sw.paint.clone()).collect())
};
assert_eq!(swatches.len(), 3);
s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
assert_eq!(s.paint.fill, swatches[2]);
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
assert_eq!(s.paint.fill, swatches[1]);
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill");
s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap();
assert_eq!(chips(&mut s), 3);
s.set_tool_option_cmd(&json!({"key": "cursorSwatchPreview", "value": false})).unwrap();
assert_eq!(chips(&mut s), 0);
}
/// Two swatches of the same color next to each other: the arrow keys step through both and on to
/// the swatch after them.
#[test]
fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() {
let mut s = session();
two(&mut s);
for name in ["Twin A", "Twin B"] {
s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap();
}
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": {"pickColorFrom": "swatches"}})).unwrap();
let names: Vec<String> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect();
let a = names.iter().position(|n| n == "Twin A").unwrap();
assert_eq!(names[a + 1], "Twin B");
s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap();
let twin = s.paint.fill.clone();
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
assert_eq!(s.paint.fill, twin, "Twin B");
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone();
assert_eq!(s.paint.fill, after, "the swatch after Twin B");
}
#[test]
fn shape_builder_tool_alt_deletes_line_pieces() {
let mut s = session();
square_and_line(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
let alt = Mods { alt: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Move, 51.0, 50.0).with_mods(alt), v).unwrap();
assert_eq!(s.overlays(v).len(), 1, "the line piece is highlighted");
s.pointer(&PointerEvent::new(PointerKind::Down, 51.0, 50.0).with_mods(alt), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 51.0, 50.0).with_mods(alt), v).unwrap();
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
assert_eq!(layer_children(&s), 3, "square + the line's two loose ends");
}
/// Red square A (0..100) and blue square B (50..150 both ways), overlapping at a corner, selected.
fn corner(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 50.0, 100.0, 100.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(s, b, Color::rgb(0.0, 0.0, 1.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(a, b)
}
/// (closed, anchors, length) of each of `ids`.
fn shapes_of(s: &Session, ids: &[NodeId]) -> Vec<(bool, usize, f64)> {
ids.iter()
.map(|i| {
let (p, _) = outline(&node(s, *i));
(p.is_closed(), p.anchor_count(), (p.length() * 1e6).round() / 1e6)
})
.collect()
}
#[test]
fn shape_builder_erase_click_on_an_edge_opens_its_path() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(r["edges"].as_array().unwrap().len(), 4, "each outline cut in two where the other crosses it: {r}");
// A's side inside B (1 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["lines"].as_u64(), r["edges"].as_u64()), (Some(0), Some(0), Some(1)));
let out = ids(&r);
// A opens where B's outline crosses it and keeps its look; B is untouched.
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 300.0), (true, 4, 400.0)]);
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
s.execute("edit.undo", &json!({})).unwrap();
let back: Vec<NodeId> = s.doc().unwrap().doc.children(s.doc().unwrap().doc.default_layer()).unwrap().iter().map(|n| n.id).collect();
assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step");
}
/// An even-odd compound path opened by an erase click on one of its edges keeps its fill rule.
#[test]
fn shape_builder_erase_click_on_an_edge_keeps_the_fill_rule() {
let mut s = session();
let c = even_odd_ring(&mut s);
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [c.0, b.0]})).unwrap();
// A's side inside B (1 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(1)));
let out = ids(&r);
assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 9, 500.0)], "A open where B crosses it, with H");
assert_eq!(outline(&node(&s, out[0])).1, FillRule::EvenOdd);
}
#[test]
fn shape_builder_erase_drag_along_edges_deletes_each() {
let mut s = session();
corner(&mut s);
// Down A's side inside B, round the corner and along B's side inside A.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 75], [100, 50], [75, 50]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(2)));
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0); 2]);
}
#[test]
fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses() {
let mut s = session();
corner(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 25], [75, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(2), Some(0)));
// A is gone; what is left of B stays closed and blue.
let out = ids(&r);
assert_eq!(out.len(), 1);
assert!((area(&s, out[0]) - 7500.0).abs() < 1e-6);
assert!(outline(&node(&s, out[0])).0.is_closed());
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
}
/// In Merge Mode, Clicking Stroke Splits the Path: a click on a lone rectangle's top side, 1 pt
/// outside it, splits the rectangle into that side and the rest. Without the option, the same click
/// is refused as outside every region and leaves the rectangle as it was.
#[test]
fn shape_builder_click_on_a_stroke_splits_its_path() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let undo_before = s.doc().unwrap().history.undo.len();
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]]})).is_err());
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": false})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!((r["merged"].clone(), r["regions"].as_u64(), r["edges"].as_u64()), (Value::Null, Some(0), Some(1)));
// The rest of the outline (open, the other three sides) comes first, then the top side in
// front of it; both are red.
assert_eq!(shapes_of(&s, &out), vec![(false, 4, 300.0), (false, 2, 100.0)]);
let split = NodeId(r["split"].as_u64().unwrap());
assert_eq!(split, out[1]);
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 0.0));
assert!(out.iter().all(|i| node(&s, *i).appearance.fill_paint() == Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
assert_eq!(s.doc().unwrap().selection.objects, out);
// One undo step puts the rectangle back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 1);
assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]);
}
/// A click on a stroke that is the whole of its path (a lone line, or a filled open path of one
/// curve) has nothing to split off: with splitStroke it is refused as outside every region, as it
/// is without the option, and the path keeps its geometry and its stroke.
#[test]
fn shape_builder_click_on_a_whole_open_path_does_not_split_it() {
let mut s = session();
let l = line(&mut s, (0.0, 0.0), (100.0, 0.0));
let r = s.execute("path.create", &json!({"anchors": [{"x": 200, "y": 0, "out": [200, -100]}, {"x": 300, "y": 0, "in": [300, -100]}]})).unwrap();
let arch = NodeId(r["id"].as_u64().unwrap());
set_fill(&mut s, arch, Color::rgb(1.0, 0.0, 0.0));
let strokes = |s: &Session| [l, arch].map(|i| node(s, i).appearance.stroke_paint());
let before = (shapes_of(&s, &[l, arch]), strokes(&s));
let undo_before = s.doc().unwrap().history.undo.len();
// The line 0.5 pt off it, then the top of the arch 1 pt above it.
for (id, at) in [(l, [50.0, 0.5]), (arch, [250.0, -76.0])] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [at], "splitStroke": true, "fill": "#00ff00"}));
let err = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("no region under the pointer"), "{id:?}: {err}");
}
assert_eq!(layer_children(&s), 2);
assert_eq!((shapes_of(&s, &[l, arch]), strokes(&s)), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
}
/// The stroke a click splits ends at the points where other paths cross it as well as at the
/// path's anchors: A's right side inside B, then a line piece inside a square. A click away from
/// every stroke merges and an erase click deletes, as they do without the option.
#[test]
fn shape_builder_stroke_split_stops_at_crossings_and_anchors() {
let mut s = session();
let (a, b) = corner(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 350.0), (false, 2, 50.0), (true, 4, 400.0)]);
assert_eq!(node(&s, out[1]).geometric_bounds().unwrap(), Rect::new(100.0, 50.0, 100.0, 100.0));
assert_eq!(node(&s, out[2]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "B as it was");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// A click inside a region away from the strokes merges it; erase mode deletes the edge.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 75]], "splitStroke": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r.get("split")), (Some(1), None));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true, "splitStroke": true})).unwrap();
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0), (true, 4, 400.0)]);
// A click on the line inside the square splits off the piece between the square's sides and
// leaves the square whole.
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(r["lines"].as_u64(), Some(1));
let split = NodeId(r["split"].as_u64().unwrap());
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 50.0, 100.0));
let lengths: Vec<f64> = out.iter().map(|i| (outline(&node(&s, *i)).0.length() * 1e6).round() / 1e6).collect();
assert_eq!(lengths, vec![400.0, 40.0, 100.0], "the square, the line's two loose ends, the piece");
}
/// A split replaces only the path it splits: the two paths take its place in the stacking order and
/// in its group, and the other selected objects stay as they are. A path in a Live Paint group does
/// not split.
#[test]
fn shape_builder_stroke_split_leaves_the_other_objects_in_place() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 0.0, 200.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let split = NodeId(r["split"].as_u64().unwrap());
let order = |s: &Session| -> Vec<NodeId> {
let d = &s.doc().unwrap().doc;
d.children(d.default_layer()).unwrap().iter().map(|n| n.id).collect()
};
let o = order(&s);
assert_eq!((o.len(), o[1], o[2], o[3]), (4, split, b, c), "the rest and the run where A was, under B and C");
assert_eq!(ids(&r), vec![o[0], split, c]);
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
// In a group, the two paths stay in the group, and the group stays selected.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
assert_eq!(ids(&r), vec![g]);
assert_eq!(node(&s, g).children().unwrap().len(), 3);
assert_eq!(order(&s), vec![b, g]);
// A Live Paint group's edge: the click merges as it does without the option.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let lp = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 1]], "splitStroke": true})).unwrap();
assert_eq!((r.get("split"), r["regions"].as_u64()), (None, Some(1)));
assert!(s.doc().unwrap().doc.node(lp).is_none(), "merged as before");
}
/// A split and an erase click cut a small path with a tolerance from its own size, however far
/// away the other selected objects lie.
#[test]
fn shape_builder_split_and_erase_cut_a_small_path_beside_a_far_object() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 5.0, 5.0, 10.0, 10.0);
let far = rect(&mut s, 1_000_000.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, far.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[5, -0.5]], "splitStroke": true})).unwrap();
let split = NodeId(r["split"].as_u64().unwrap_or_else(|| panic!("no split: {r}")));
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 10.0, 0.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0, far.0]})).unwrap();
// A's side inside B (0.5 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[10.5, 7.5]], "erase": true})).unwrap();
assert_eq!(shapes_of(&s, &ids(&r)[..1]), vec![(false, 5, 30.0)], "A open where B crosses it");
}
/// Pick Color From Color Swatches: the stroke split off takes the current fill as its stroke.
#[test]
fn shape_builder_stroke_split_with_swatches_paints_the_stroke() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = node(&s, a).appearance.stroke_paint();
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap();
let green = s.paint.fill.clone();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true, "pickColorFrom": "swatches"})).unwrap();
let out = ids(&r);
assert_eq!(node(&s, NodeId(r["split"].as_u64().unwrap())).appearance.stroke_paint(), green);
assert_eq!(node(&s, out[0]).appearance.stroke_paint(), before, "the rest keeps its stroke");
}
/// A red even-odd compound path, square A (0..100) with the square H (25..75) inside it, selected.
fn even_odd_ring(s: &mut Session) -> NodeId {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let h = rect(s, 25.0, 25.0, 50.0, 50.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0, h.0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("path.setFillRule", &json!({"rule": "evenOdd"})).unwrap();
assert_eq!(outline(&node(s, c)).1, FillRule::EvenOdd);
c
}
/// A path split off an even-odd compound path, and the rest of it, keep its fill rule.
#[test]
fn shape_builder_stroke_split_keeps_the_fill_rule() {
let mut s = session();
even_odd_ring(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out), vec![(false, 8, 500.0), (false, 2, 100.0)], "A's other three sides with H, then A's top side");
assert_eq!(out.iter().map(|i| outline(&node(&s, *i)).1).collect::<Vec<_>>(), vec![FillRule::EvenOdd; 2]);
}
/// A split, and an Alt-click that opens a path on one of its edges, keep the path's name and opacity
/// mask on what is left of it.
#[test]
fn shape_builder_stroke_split_and_erase_keep_the_name_and_opacity_mask() {
let mut s = session();
let (a, b) = corner(&mut s);
let m = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m.0]})).unwrap();
s.edit("t", |d, _| {
d.node_mut(a).unwrap().name = Some("A".into());
Ok(())
})
.unwrap();
let mask = node(&s, a).extras().mask.clone();
assert!(mask.is_some());
let kept = |s: &Session, ids: &[NodeId]| {
ids.iter().all(|i| {
let n = node(s, *i);
n.name.as_deref() == Some("A") && n.extras().mask == mask
})
};
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 2);
assert!(kept(&s, &out), "the rest and the run");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 5, 300.0)]);
assert!(kept(&s, &out[..1]), "A opened where B crosses it");
}
/// The tool with In Merge Mode, Clicking Stroke Splits the Path: over a stroke, the pointer shows
/// scissors and the run a click splits is drawn in the highlight color (none with Highlight Stroke
/// When Editable off), and a click splits it. Off, the same click leaves the rectangle as it was.
#[test]
fn shape_builder_tool_click_on_a_stroke_splits_with_the_option() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
let o = s.tool_options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f")));
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, -1.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, -1.0), v).unwrap();
};
let journal = s.journal.len();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
assert!(s.overlays(v).is_empty());
click(&mut s);
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "off, the click runs no command");
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true, "highlightColor": "Light Blue"}})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
let ov = s.overlays(v);
let [vectorcraft_tools::Overlay::Path { path, color: [0x4f, 0x80, 0xff], .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(vectorcraft_geom::Shape::bounding_box(path), Rect::new(0.0, 0.0, 100.0, 0.0));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, -1.0), Mods::default(), v), vectorcraft_tools::Cursor::Scissors);
s.set_tool_option_cmd(&json!({"values": {"highlightStroke": false}})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
assert!(s.overlays(v).is_empty());
click(&mut s);
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!((cmd.as_str(), &p["splitStroke"], &p["points"]), ("shapeBuilder.merge", &json!(true), &json!([[50.0, -1.0]])));
assert_eq!(layer_children(&s), 2);
// The options are kept when another tool is chosen.
s.select_tool("selection", v).unwrap();
let o = s.tool_options_of("shapeBuilder");
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(false), &json!("#4f80ff")));
}
/// The tool with In Merge Mode, Clicking Stroke Splits the Path over a lone line, which a click
/// cannot split: the pointer does not show scissors, the line is not highlighted, and a click runs no
/// command.
#[test]
fn shape_builder_tool_does_not_offer_to_split_a_lone_line() {
let mut s = session();
let l = line(&mut s, (0.0, 0.0), (100.0, 0.0));
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap();
let journal = s.journal.len();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 1.0), v).unwrap();
assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, 1.0), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder);
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 1.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 1.0), v).unwrap();
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command");
}
/// The tool over a side shorter than the cut tolerance (0.05 pt for a 100 pt path), which
/// `shapeBuilder.merge` cannot cut out: the pointer does not show scissors, the side is not
/// highlighted, and a click outside the path runs no command.
#[test]
fn shape_builder_tool_does_not_offer_to_split_a_side_too_short_to_cut() {
let mut s = session();
let anchors = [(0.0, 0.0), (100.0, 0.0), (100.0, 0.03), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
let r = s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap();
let journal = s.journal.len();
let at = (101.0, 0.015);
s.pointer(&PointerEvent::new(PointerKind::Move, at.0, at.1), v).unwrap();
assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(at.0, at.1), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder);
s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, at.0, at.1), v).unwrap();
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command");
}
// ---------- Live Paint ----------
fn live(s: &mut Session) -> NodeId {
two(s);
let r = s.execute("livePaint.make", &json!({})).unwrap();
assert_eq!(r["faces"], 3);
assert!(r["edges"].as_u64().unwrap() >= 4);
NodeId(r["id"].as_u64().unwrap())
}
#[test]
fn live_paint_make_builds_group_with_hidden_sources() {
let mut s = session();
let g = live(&mut s);
let n = node(&s, g);
assert!(vectorcraft_tools::builder::is_live_paint(&n));
let src = vectorcraft_tools::builder::sources(&n).unwrap();
assert!(!src.visible);
assert_eq!(src.children().unwrap().len(), 2);
assert_eq!(layer_children(&s), 1);
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
// Faces start with the fill of the top-most covering object.
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
assert_eq!(info["faces"].as_array().unwrap().len(), 3);
assert_eq!(info["sources"], 2);
}
#[test]
fn live_paint_fill_assigns_the_face_under_the_point() {
let mut s = session();
let g = live(&mut s);
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap();
let face = node(&s, NodeId(r["face"].as_u64().unwrap()));
let bb = face.geometric_bounds().unwrap();
assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}");
assert_eq!(face.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
// The other faces are untouched.
let gn = node(&s, g);
let green = vectorcraft_tools::builder::faces(&gn).iter().filter(|f| f.appearance.fill_paint() == face.appearance.fill_paint()).count();
assert_eq!(green, 1);
// Default paint = current fill; a miss is an error.
s.paint.fill = Paint::solid(Color::rgb(1.0, 1.0, 0.0));
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [10, 10]})).unwrap();
assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), s.paint.fill);
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [400, 400]})).is_err());
}
#[test]
fn live_paint_fill_on_plain_paths_makes_the_group() {
let mut s = session();
let (a, b) = two(&mut s);
let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [125, 50], "color": "#123456"})).unwrap();
let g = NodeId(r["group"].as_u64().unwrap());
assert!(vectorcraft_tools::builder::is_live_paint(&node(&s, g)));
assert_eq!(layer_children(&s), 1);
// One undo step removes both the make and the fill.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 2);
}
#[test]
fn live_paint_stroke_edge() {
let mut s = session();
let g = live(&mut s);
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3})).unwrap();
let e = node(&s, NodeId(r["edge"].as_u64().unwrap()));
assert_eq!(e.appearance.stroke_width(), 3.0);
assert!(e.appearance.fill_paint().is_none());
let bb = e.geometric_bounds().unwrap();
assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "the vertical edge at x=100: {bb:?}");
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
}
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
/// and the undo history stay as they were. True, false and null still work.
#[test]
fn shape_builder_refuses_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
two(&mut s);
let merge = |extra: Value| {
let mut p = json!({"points": [[75, 50]]});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let calls = [
not_a_switch("shapeBuilder.merge", merge(json!({"erase": "true"})), "erase"),
not_a_switch("shapeBuilder.merge", merge(json!({"openFilledAsClosed": "false"})), "openFilledAsClosed"),
not_a_switch("shapeBuilder.merge", merge(json!({"splitStroke": "true"})), "splitStroke"),
not_a_number("shapeBuilder.merge", merge(json!({"erase": true, "tolerance": "2"})), "tolerance"),
not_a_number("shapeBuilder.merge", merge(json!({"gap": "5"})), "gap"),
not_a_switch("shapeBuilder.regions", json!({"openFilledAsClosed": 1}), "openFilledAsClosed"),
not_a_number("shapeBuilder.regions", json!({"gap": [5]}), "gap"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
s.execute("shapeBuilder.merge", &merge(json!({"erase": false, "openFilledAsClosed": null}))).unwrap();
}
/// Live Paint and Image Trace refuse an on/off option that is not true or false and a width or
/// tolerance that is not a number, given as text or as another value of the wrong kind, naming the
/// key; the document and the undo history stay as they were. Numbers and null still work.
#[test]
fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() {
use super::tests::{not_a_number, not_a_switch, not_refused};
let mut s = session();
let g = live(&mut s);
let calls = [
not_a_switch("livePaint.gapOptions", json!({"closeGaps": "true"}), "closeGaps"),
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "width": "3"}), "width"),
not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "tolerance": [4]}), "tolerance"),
not_a_switch("imageTrace.make", json!({"enhanced": "true"}), "enhanced"),
];
let wrong = not_refused(&mut s, &calls);
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "width": 3, "tolerance": null})).unwrap();
assert_eq!(node(&s, NodeId(r["edge"].as_u64().unwrap())).appearance.stroke_width(), 3.0);
}
#[test]
fn live_paint_stroke_edge_skips_hidden_edges() {
let mut s = session();
let g = live(&mut s);
// The vertical edge at x=100 is hidden on its own; the group stays visible.
let vertical_at = |s: &Session, x: f64| -> Vec<u64> {
vectorcraft_tools::builder::edges(&node(s, g))
.iter()
.filter(|e| e.geometric_bounds().is_some_and(|bb| (bb.x0 - x).abs() < 1e-6 && (bb.x1 - x).abs() < 1e-6))
.map(|e| e.id.0)
.collect()
};
let hidden = vertical_at(&s, 100.0);
assert!(!hidden.is_empty());
s.execute("object.setProps", &json!({"ids": hidden, "visible": false})).unwrap();
// 1 pt from the hidden edge, the nearest visible edge within the tolerance is the one at x=50.
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50], "tolerance": 60})).unwrap();
assert!(vertical_at(&s, 50.0).contains(&r["edge"].as_u64().unwrap()), "{r}");
// With only the hidden edge within the tolerance, no edge is stroked.
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err());
}
#[test]
fn live_paint_fill_skips_hidden_faces() {
let mut s = session();
let g = live(&mut s);
// The face from x=50 to x=100 is hidden on its own; the group stays visible.
let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(75.0, 50.0)).unwrap().id;
s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let children = node(&s, g).children().unwrap().len();
// livePaint.fill at a point on the hidden face returns an error and fills no face, with `group`
// and with the group in `ids`, and it does not rebuild the group.
for p in [json!({"group": g.0, "point": [75, 50], "color": "#00ff00"}), json!({"ids": [g.0], "point": [75, 50], "color": "#00ff00"})] {
assert!(s.execute("livePaint.fill", &p).is_err(), "{p}");
let face = node(&s, hidden);
assert!(!face.visible && face.appearance.fill_paint() != green, "{p}");
assert_eq!(node(&s, g).children().unwrap().len(), children, "{p}");
}
// Given another Live Paint group with a face at that point as well, that face is filled.
let other = rect(&mut s, 60.0, 40.0, 20.0, 20.0);
let other = NodeId(s.execute("livePaint.make", &json!({"ids": [other.0]})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("livePaint.fill", &json!({"ids": [g.0, other.0], "point": [75, 50], "color": "#00ff00"})).unwrap();
assert_eq!(r["group"], other.0);
assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), green);
assert!(!node(&s, hidden).visible);
}
#[test]
fn live_paint_release_and_expand() {
let mut s = session();
let g = live(&mut s);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap();
let r = s.execute("livePaint.expand", &json!({})).unwrap();
assert_eq!(ids(&r), vec![g]);
let n = node(&s, g);
assert!(!vectorcraft_tools::builder::is_live_paint(&n));
// Unpainted face and the hidden sources are gone: 2 faces + stroked edges.
let ch = n.children().unwrap();
assert!(ch.iter().all(|c| c.visible));
assert_eq!(ch.iter().filter(|c| c.name.as_deref() == Some("Face")).count(), 2);
let mut s = session();
let (a, b) = two(&mut s);
live_group_only(&mut s);
let r = s.execute("livePaint.release", &json!({})).unwrap();
assert_eq!(ids(&r), vec![a, b]);
assert!((area(&s, a) - 10000.0).abs() < 1.0);
}
fn live_group_only(s: &mut Session) -> NodeId {
let r = s.execute("livePaint.make", &json!({})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
#[test]
fn live_paint_merge_keeps_paint_and_adds_faces() {
let mut s = session();
let g = live(&mut s);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap();
let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0);
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
let r = s.execute("livePaint.merge", &json!({})).unwrap();
assert_eq!(NodeId(r["id"].as_u64().unwrap()), g);
assert!(r["faces"].as_u64().unwrap() > 3);
let gn = node(&s, g);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(25.0, 50.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
let src = vectorcraft_tools::builder::sources(&gn).unwrap();
assert_eq!(src.children().unwrap().len(), 3, "the rectangle joined the sources");
assert_eq!(s.doc().unwrap().doc.parent_of(c), Some(src.id));
assert_eq!(layer_children(&s), 1);
}
/// Give Live Paint group `g` a lock, hidden state, appearance, knockout, opacity mask and
/// graphic style link, and return it as it then is.
fn dress_live_group(s: &mut Session, g: NodeId) -> Node {
use vectorcraft_doc::{Knockout, OpacityMask};
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("t", |d, _| {
let n = d.node_mut(g).unwrap();
n.locked = true;
n.visible = false;
n.isolate = true;
n.knockout = Knockout::On;
n.appearance.set_stroke(Paint::solid(Color::rgb(0.0, 0.5, 0.0)));
let mut e = n.extras_mut();
e.mask = Some(Box::new(OpacityMask::new(mask, true)));
e.graphic_style = Some(7);
Ok(())
})
.unwrap();
node(s, g)
}
/// Asserts that `after` is `before` with other children and Gap Options.
fn same_group_but_children(before: &Node, after: &Node) {
assert_eq!((after.locked, after.visible, after.isolate), (true, false, true));
assert_eq!(after.appearance, before.appearance, "the group's own appearance");
assert_eq!(after.extras().mask, before.extras().mask, "the opacity mask");
assert_eq!(after.extras().graphic_style, Some(7), "the graphic style link");
let mut want = before.clone();
*want.children_mut().unwrap() = after.children().unwrap().clone();
want.extras_mut().live_paint_gaps = after.extras().live_paint_gaps.clone();
assert_eq!(after, &want);
}
#[test]
fn live_paint_merge_keeps_the_groups_lock_visibility_mask_and_graphic_style() {
let mut s = session();
let g = live(&mut s);
let before = dress_live_group(&mut s, g);
let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0);
let r = s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
assert_eq!(NodeId(r["id"].as_u64().unwrap()), g);
let after = node(&s, g);
same_group_but_children(&before, &after);
assert_eq!(vectorcraft_tools::builder::sources(&after).unwrap().children().unwrap().len(), 3, "the rectangle joined the sources");
// One undo step brings back the group as it was, and the rectangle beside it.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g), before);
assert_eq!(layer_children(&s), 2);
}
#[test]
fn live_paint_make_and_fill_with_a_live_paint_group_selected_add_the_paths_to_it() {
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let fill_at = |s: &Session, g: NodeId, x: f64, y: f64| {
vectorcraft_tools::builder::face_at(&node(s, g), vectorcraft_geom::Point::new(x, y)).map(|f| f.appearance.fill_paint())
};
// Make, and the Live Paint Bucket on a path beside the group, each keep the group, its
// properties and the paint of its faces.
for (cmd, params) in [("livePaint.make", json!({})), ("livePaint.fill", json!({"point": [170, 30], "color": "#0000ff"}))] {
let mut s = session();
let g = live(&mut s);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap();
let before = dress_live_group(&mut s, g);
let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0);
let mut p = params;
p["ids"] = json!([g.0, c.0]);
let r = s.execute(cmd, &p).unwrap();
let id = r.get("id").or_else(|| r.get("group")).and_then(Value::as_u64);
assert_eq!(id, Some(g.0), "{cmd}");
same_group_but_children(&before, &node(&s, g));
assert_eq!(fill_at(&s, g, 25.0, 50.0), Some(green.clone()), "{cmd}");
assert_eq!(layer_children(&s), 1, "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g), before, "{cmd}");
}
}
/// A Live Paint group of one open path round the square 0..100, its end 2 pt short of its start.
fn live_square_with_a_gap(s: &mut Session) -> NodeId {
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y}));
let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap();
s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap();
live_group_only(s)
}
fn gap_options(s: &Session, g: NodeId) -> vectorcraft_doc::GapOptions {
node(s, g).gap_options()
}
/// The gaps of a `livePaint.*` reply, rounded to 1e-6.
fn gaps_of(r: &Value) -> Vec<Vec<f64>> {
r["gaps"].as_array().unwrap().iter().map(|g| g.as_array().unwrap().iter().map(|v| (v.as_f64().unwrap() * 1e6).round() / 1e6).collect()).collect()
}
#[test]
fn live_paint_gap_detection_stops_paint_at_a_gap() {
let mut s = session();
let g = live_square_with_a_gap(&mut s);
// Paint leaks through the gap: there is no face to fill.
assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty());
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_err());
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
assert_eq!(info["gapOptions"], json!({"detect": false, "size": "small", "points": 3.0, "color": "#ff4f4f"}));
assert_eq!(gaps_of(&info), [[0.0, 0.0, 0.0, 2.0]], "Small Gaps (3 pt) finds the gap");
// Gap Detection with Small Gaps: the square is one face, filled in one undo step.
let depth = s.doc().unwrap().history.undo.len();
let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": "small"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
assert_eq!((ids(&r), &r["faces"], &r["closed"]), (vec![g], &json!(1), &json!(0)));
assert_eq!(gaps_of(&r), [[0.0, 0.0, 0.0, 2.0]]);
let f = s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50], "color": "#00ff00"})).unwrap();
let face = node(&s, NodeId(f["face"].as_u64().unwrap()));
assert!((vectorcraft_pathops::area(face.path_data().unwrap(), FillRule::NonZero).abs() - 10000.0).abs() < 1e-6);
assert_eq!(vectorcraft_tools::builder::edges(&node(&s, g)).len(), 1, "the gap is no edge");
// A color change keeps the face and its paint.
s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": "Green"})).unwrap();
let opts = gap_options(&s, g);
assert_eq!((opts.detect, opts.size, opts.color), (true, vectorcraft_doc::GapSize::Small, [0x4f, 0xff, 0x4f]));
let gn = node(&s, g);
let kept = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 50.0)).unwrap();
assert_eq!(kept.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
// A custom size narrower than the gap lets paint leak again, and so does Gap Detection off.
let r = s.execute("livePaint.gapOptions", &json!({"size": 1.5})).unwrap();
assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 0));
s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap();
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1);
let r = s.execute("livePaint.gapOptions", &json!({"detect": false})).unwrap();
assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 1));
// Bad values change nothing.
for bad in [json!({"size": 0}), json!({"size": 73}), json!({"size": "huge"}), json!({"detect": "yes"}), json!({"color": "#12"})] {
assert!(s.execute("livePaint.gapOptions", &bad).is_err(), "{bad}");
}
assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Custom(12.0));
// Plain paths are no Live Paint group.
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
assert!(s.execute("livePaint.gapOptions", &json!({"id": r.0, "detect": true})).is_err());
// Expand leaves a plain group, without the options.
s.execute("livePaint.expand", &json!({"id": g.0})).unwrap();
assert!(node(&s, g).extras().live_paint_gaps.is_none());
}
#[test]
fn medium_and_large_gaps_stop_paint_at_wider_gaps() {
// Squares whose ends stop 5 pt and 9 pt short of their starts: each size closes the gaps up
// to its width (Small 3 pt, Medium 6 pt, Large 12 pt).
for (gap, closed_by) in [(5, ["medium", "large"]), (9, ["large", "large"])] {
let mut s = session();
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, gap)].map(|(x, y)| json!({"x": x, "y": y}));
let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap();
s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap();
let g = live_group_only(&mut s);
for size in ["small", "medium", "large"] {
let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": size})).unwrap();
let faces = if closed_by.contains(&size) { 1 } else { 0 };
assert_eq!(r["faces"], json!(faces), "a {gap} pt gap with {size}");
}
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
assert_eq!(info["gapOptions"], json!({"detect": true, "size": "large", "points": 12.0, "color": "#ff4f4f"}));
s.execute("livePaint.gapOptions", &json!({"size": "Medium"})).unwrap();
assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Medium);
}
}
#[test]
fn gap_options_keep_the_paint_of_split_faces_and_of_unpainted_faces() {
let mut s = session();
// A red square with a divider that stops 2 pt short of its top edge.
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
let l = line(&mut s, (50.0, 2.0), (50.0, 100.0));
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
let g = live_group_only(&mut s);
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap();
let fill_at = |s: &Session, x: f64| {
let gn = node(s, g);
vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 50.0)).map(|f| f.appearance.fill_paint())
};
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
// Closing the gap splits the face in two, and both halves keep its paint.
let r = s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap();
assert_eq!(r["faces"], 2, "{r}");
assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(green.clone())));
// A face painted with none stays unpainted when the options change, and a face keeps its
// transparency and the rest of its look when the faces are made again.
s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap();
s.execute("livePaint.gapOptions", &json!({"color": "Blue"})).unwrap();
assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(Paint::None)));
let left = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(25.0, 50.0)).unwrap().id;
s.edit("t", |d, _| {
d.node_mut(left).unwrap().opacity = 0.5;
Ok(())
})
.unwrap();
s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap();
let gn = node(&s, g);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(25.0, 50.0)).unwrap();
assert_eq!((f.opacity, f.appearance.fill_paint()), (0.5, green));
assert_eq!(fill_at(&s, 75.0), Some(Paint::None));
}
#[test]
fn a_gradient_face_split_by_a_closed_gap_keeps_one_gradient_across_both_halves() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
s.edit("t", |d, _| {
d.node_mut(r).unwrap().appearance.set_fill(gradient);
Ok(())
})
.unwrap();
let l = line(&mut s, (50.0, 2.0), (50.0, 100.0));
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
let g = live_group_only(&mut s);
s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap();
let gn = node(&s, g);
let geoms: Vec<_> = vectorcraft_tools::builder::faces(&gn)
.iter()
.map(|f| match f.appearance.fill_paint() {
Paint::Gradient(gp) => gp.geom,
other => panic!("{other:?}"),
})
.collect();
assert_eq!(geoms.len(), 2);
assert!(geoms[0].is_some() && geoms[0] == geoms[1], "both halves keep the square's fit: {geoms:?}");
}
#[test]
fn close_gaps_with_paths_adds_an_unpainted_path_across_each_gap() {
let mut s = session();
let g = live_square_with_a_gap(&mut s);
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
assert_eq!((&r["faces"], &r["closed"], gaps_of(&r).len()), (&json!(1), &json!(1), 0), "{r}");
assert!(!gap_options(&s, g).detect, "Gap Detection stays off: the path closes the gap");
let gn = node(&s, g);
let src = vectorcraft_tools::builder::sources(&gn).unwrap();
let added = src.children().unwrap().last().unwrap();
assert!(added.appearance.fill_paint().is_none() && added.appearance.stroke_paint().is_none(), "unpainted");
let bb = added.geometric_bounds().unwrap();
assert!(bb.x0.abs() < 1e-9 && bb.x1.abs() < 1e-9 && bb.y0.abs() < 1e-9 && (bb.y1 - 2.0).abs() < 1e-9, "{bb:?}");
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_ok());
// Release gives the closing path back with the others.
assert_eq!(ids(&s.execute("livePaint.release", &json!({"id": g.0})).unwrap()).len(), 2);
}
#[test]
fn gap_options_survive_save_copy_and_undo() {
let mut s = session();
let g = live_square_with_a_gap(&mut s);
s.execute("livePaint.gapOptions", &json!({"detect": true, "size": 4.5, "color": "#123456"})).unwrap();
let want = vectorcraft_doc::GapOptions { detect: true, size: vectorcraft_doc::GapSize::Custom(4.5), color: [0x12, 0x34, 0x56] };
assert_eq!(gap_options(&s, g), want);
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(g).unwrap().gap_options(), want);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = s.doc().unwrap().selection.objects[0];
assert_ne!(pasted, g);
assert_eq!(gap_options(&s, pasted), want);
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, pasted)).len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(gap_options(&s, g), vectorcraft_doc::GapOptions::default());
assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty());
}
#[test]
fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() {
use vectorcraft_doc::{GapOptions, GapSize};
// Two groups side by side, the left one at the bottom, merged with each Gap Detection setting.
let merged = |bottom: Value, top: Value| {
let mut s = session();
let groups: Vec<NodeId> = [0.0, 200.0]
.into_iter()
.zip([bottom, top])
.map(|(x, opts)| {
let r = rect(&mut s, x, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let g = live_group_only(&mut s);
s.execute("livePaint.gapOptions", &opts).unwrap();
g
})
.collect();
s.execute("select.set", &json!({"ids": [groups[0].0, groups[1].0]})).unwrap();
let r = s.execute("livePaint.merge", &json!({})).unwrap();
assert_eq!(NodeId(r["id"].as_u64().unwrap()), groups[0]);
gap_options(&s, groups[0])
};
let six = json!({"detect": true, "size": 6, "color": "Blue"});
let twelve = json!({"detect": true, "size": 12});
let off = json!({"detect": false, "size": 12, "color": "Blue"});
let blue = [0x4f, 0x4f, 0xff];
// The bottom group's options, with its Gap Detection on.
assert_eq!(merged(six.clone(), twelve), GapOptions { detect: true, size: GapSize::Custom(6.0), color: blue });
// Detection on with Small Gaps when only a group above has it on.
assert_eq!(merged(off.clone(), six), GapOptions { detect: true, size: GapSize::Small, color: blue });
// With detection off in every group, the bottom group's options.
assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue });
}
#[test]
fn live_paint_bucket_tool_fills_face() {
let mut s = session();
let g = live(&mut s);
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "red face highlight");
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
let gn = node(&s, g);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(125.0, 50.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
// Live Paint Selection selects the face.
s.select_tool("livePaintSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose.
#[test]
fn live_paint_bucket_fills_an_area_enclosed_by_lines() {
let mut s = session();
let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))];
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0));
// Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled.
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "red face highlight");
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
let g = s.doc().unwrap().selection.objects[0];
let gn = node(&s, g);
assert!(vectorcraft_tools::builder::is_live_paint(&gn));
assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6);
// The lines are edges, cut where they cross, and keep their stroke.
let edges = vectorcraft_tools::builder::edges(&gn);
assert_eq!(edges.len(), 9);
assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none()));
// Outside the triangle there's nothing to fill.
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err());
}
#[test]
fn a_line_across_a_live_paint_shape_splits_its_face() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
let l = line(&mut s, (40.0, -20.0), (60.0, 70.0));
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
let made = s.execute("livePaint.make", &json!({})).unwrap();
assert_eq!(made["faces"], 2, "{made}");
let g = NodeId(made["id"].as_u64().unwrap());
s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap();
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint();
assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill");
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
}
// ---------- Image Trace ----------
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
fn image_doc(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0))
}
fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> NodeId {
let png = r.encode_png();
let (w, h) = (r.width, r.height);
s.edit("img", |d, sel| {
d.images.insert("img1".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let l = d.default_layer();
d.insert(
l,
0,
Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })),
),
)?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
}
#[test]
fn a_large_image_traces_with_a_warning() {
// The public documentation: images over 1500 px on a side are traced, only more slowly.
let mut s = session();
let art = vectorcraft_trace::Raster::from_fn(
1600,
40,
|x, y| if (100..1500).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] },
);
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
let w = r["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].as_str().unwrap().contains("1600 × 40") && w[0].as_str().unwrap().contains("1500"), "{w:?}");
// Make and Expand says so too.
let r = s.execute("imageTrace.makeAndExpand", &json!({"id": r["id"], "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
// At 1500 px and under, no warning.
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({})).unwrap();
assert_eq!(r["warnings"], json!([]));
let edge = vectorcraft_trace::Raster::from_fn(1500, 8, |x, _| if x < 700 { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &edge, Affine::IDENTITY);
assert_eq!(s.execute("imageTrace.make", &json!({})).unwrap()["warnings"], json!([]));
}
#[test]
fn image_trace_make_places_paths_over_the_image() {
let mut s = session();
let img = image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
let g = NodeId(r["id"].as_u64().unwrap());
let n = node(&s, g);
assert_eq!(n.name.as_deref(), Some("Image Trace"));
let first = &n.children().unwrap()[0];
assert!(matches!(first.kind, NodeKind::Image(_)) && !first.visible && first.id == img);
let paths = traced_paths(&n);
assert_eq!(paths.len(), 1);
let (p, rule) = outline(&paths[0]);
let want = std::f64::consts::PI * 60.0 * 60.0; // r = 30 px × 2
let got = vectorcraft_pathops::area(&p, rule);
assert!((got - want).abs() / want < 0.03, "{got} vs {want}");
let bb = p.bounds().unwrap();
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5, "{bb:?}");
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
}
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
/// circle as its traced path; without the option it is a plain path.
#[test]
fn image_trace_shapes_makes_live_shapes() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "shapes": true}})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
let paths = traced_paths(&n);
assert_eq!(paths.len(), 1);
let NodeKind::Path { path, live: Some(live), .. } = &paths[0].kind else { panic!("not a live shape: {:?}", paths[0].kind) };
let vectorcraft_doc::LiveShape::Ellipse { w, h, .. } = **live else { panic!("{live:?}") };
// A circle of r = 30 px, drawn twice the size.
assert!(w == h && (w - 60.0).abs() < 0.75, "{w} × {h}");
let bb = path.bounds().unwrap();
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5 && (bb.width() - 120.0).abs() < 1.5, "{bb:?}");
assert_eq!(*path, live.to_path());
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
// Off, as documents saved before have it: a path.
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
assert!(matches!(traced_paths(&n)[0].kind, NodeKind::Path { live: None, .. }));
}
/// Auto Grouping: a ring and the dot in its hole are a group (the ring first), the square beside
/// them is not in it; the picture is the same as without, and the group traces again, expands
/// and releases as any other.
#[test]
fn image_trace_auto_grouping_groups_what_is_inside() {
let art = vectorcraft_trace::Raster::from_fn(120, 80, |x, y| {
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 40.0);
if d <= 6.0 || (90..110).contains(&x) && (30..50).contains(&y) {
[200, 0, 0, 255]
} else if (16.0..=30.0).contains(&d) {
[0, 0, 160, 255]
} else {
[255, 255, 255, 255]
}
});
let make = |grouping: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": grouping}})).unwrap();
assert_eq!(r["paths"], 3);
let g = NodeId(r["id"].as_u64().unwrap());
(s, g)
};
let (mut s, g) = make(true);
let items = traced_paths(&node(&s, g));
assert_eq!(items.len(), 2);
let group = items.iter().find(|n| matches!(n.kind, NodeKind::Group { .. })).expect("a group");
let inside = group.children().unwrap();
assert_eq!(inside.len(), 2);
assert_eq!(inside[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 160)));
assert_eq!(inside[1].appearance.fill_paint(), Paint::solid(Color::rgb8(200, 0, 0)));
assert!(matches!(items.iter().find(|n| n.id != group.id).unwrap().kind, NodeKind::Path { .. }));
// The same picture as without.
let (flat, fg) = make(false);
assert_eq!(traced_paths(&node(&flat, fg)).len(), 3);
let shot = |s: &Session| {
let img = render_doc(s, true);
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
};
assert_eq!(shot(&s), shot(&flat));
// In black and white too, where the white inside the ring is a path traced before the ring:
// it stays under the ring in their group (over it, the ring's inner edge would show a seam).
let bw = |grouping: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"params": {"autoGrouping": grouping}})).unwrap();
let groups = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).count();
(s, groups)
};
let (grouped, flat) = (bw(true), bw(false));
assert!(grouped.1 > 0 && flat.1 == 0);
assert_eq!(shot(&grouped.0), shot(&flat.0));
// Traced again without the option, it is flat again; expanded, the groups stay.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": true}})).unwrap();
let r = s.execute("imageTrace.expand", &json!({})).unwrap();
let expanded = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert_eq!(expanded.children().unwrap().len(), 2);
}
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
#[test]
fn image_trace_gradients_fills_with_a_gradient() {
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
let t = (f64::from(x) + 0.5) / 100.0;
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
});
let make = |gradients: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
};
let paths = make(true);
assert_eq!(paths.len(), 1);
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
assert_eq!(g.gradient.stops.len(), 2);
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
let geom = g.geom.as_ref().unwrap();
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
let flat = make(false);
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
}
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
#[test]
fn image_trace_enhanced_presets() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
let stored = n.extras().trace.clone().unwrap();
assert_eq!(stored["preset"], "6 Colors");
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
assert_eq!(shapes.len(), 1);
// Listed with each preset; the legacy preset is as it was.
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
// Flat Logo, a preset of our own, has no enhanced version.
assert!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Flat Logo").unwrap()["enhanced"].is_null());
}
#[test]
fn image_trace_release_and_expand() {
let mut s = session();
let img = image_doc(&mut s);
s.execute("imageTrace.make", &json!({"preset": "Black and White Logo"})).unwrap();
let r = s.execute("imageTrace.release", &json!({})).unwrap();
assert_eq!(ids(&r), vec![img]);
assert!(node(&s, img).visible);
assert_eq!(layer_children(&s), 1);
let r = s.execute("imageTrace.make", &json!({"id": img.0})).unwrap();
let g = NodeId(r["id"].as_u64().unwrap());
// Re-trace an existing Image Trace group with other parameters.
let r2 = s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r2["paths"], 1);
let g = NodeId(r2["id"].as_u64().unwrap());
s.execute("imageTrace.expand", &json!({})).unwrap();
let n = node(&s, g);
assert!(n.name.is_none());
assert!(n.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))));
assert!(s.execute("imageTrace.release", &json!({})).is_err());
}
#[test]
fn image_trace_make_and_expand_and_color_presets() {
let mut s = session();
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, true) => [230, 20, 20, 255],
(false, true) => [20, 200, 30, 255],
(true, false) => [20, 30, 220, 255],
(false, false) => [250, 230, 10, 255],
});
add_image(&mut s, &r, Affine::IDENTITY);
let res = s.execute("imageTrace.makeAndExpand", &json!({"preset": "16 Colors", "params": {"colors": 4}})).unwrap();
assert_eq!(res["colors"], 4);
assert_eq!(res["paths"], 4);
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
assert!(g.name.is_none());
assert_eq!(g.children().unwrap().len(), 4);
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err());
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
}
#[test]
fn image_trace_palettes_and_swatch_libraries() {
let mut s = session();
let quads = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, true) => [230, 20, 20, 255],
(false, true) => [20, 200, 30, 255],
(true, false) => [20, 30, 220, 255],
(false, false) => [250, 230, 10, 255],
});
add_image(&mut s, &quads, Affine::IDENTITY);
let fills = |s: &Session, id: &Value| -> Vec<Color> {
traced_paths(&node(s, NodeId(id.as_u64().unwrap()))).iter().filter_map(|n| n.appearance.fill_paint().color()).collect()
};
// Document Library: every fill is one of the document's swatch colours, as it is.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap();
let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap();
let got = fills(&s, &r["id"]);
assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}");
let t = node(&s, NodeId(r["id"].as_u64().unwrap())).extras().trace.clone().unwrap();
assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document")));
assert!(t["params"].get("swatches").is_none(), "looked up again at every trace");
// A swatch library by id: the web-safe colours.
let r = s
.execute(
"imageTrace.make",
&json!({"id": r["id"], "preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "web-safe-216", "colors": 3}}),
)
.unwrap();
let got = fills(&s, &r["id"]);
assert!((1..=3).contains(&got.len()), "{got:?}");
assert!(got.iter().all(|c| c.to_rgba8(1.0)[..3].iter().all(|v| v % 51 == 0)), "{got:?}");
// Full Tone and Automatic keep the four flat colours.
let mut id = r["id"].clone();
for palette in ["fullTone", "automatic"] {
let r = s.execute("imageTrace.make", &json!({"id": id, "preset": "6 Colors", "params": {"palette": palette, "colorDetail": 50}})).unwrap();
assert_eq!(r["colors"], 4, "{palette}");
id = r["id"].clone();
}
let mut bad = |p: Value| s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": p})).is_err();
assert!(bad(json!({"palette": "documentLibrary", "library": "No Such Library"})));
assert!(bad(json!({"palette": "rainbow"})));
}
#[test]
fn image_trace_create_strokes_makes_stroked_centre_lines() {
let mut s = session();
// A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100).
let r =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0));
let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap();
assert_eq!(res["paths"], 1);
let g = NodeId(res["id"].as_u64().unwrap());
let paths = traced_paths(&node(&s, g));
let line = &paths[0];
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
// (The line is cut square at its ends, and so is its stroke.)
assert_eq!((st.cap, st.join), (LineCap::Square, LineJoin::Round));
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
let sp = &line.path_data().unwrap().subpaths[0];
assert!(!sp.closed);
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}");
let t = node(&s, g).extras().trace.clone().unwrap();
assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0)));
// Expand keeps the stroked line.
s.execute("imageTrace.expand", &json!({})).unwrap();
assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
// Create needs Fills or Strokes, and a stroke width.
s.execute("edit.undo", &json!({})).unwrap();
for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] {
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}");
}
}
#[test]
fn image_trace_presets_query_and_errors() {
let mut s = session();
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names.len(), 15);
assert!(names.contains(&"Auto Color") && names.contains(&"Outline"));
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]);
assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]);
}
/// A soft-edged red disc (r = 30 px at (50.3, 49.6)) on transparency, placed at (50, 50) scaled ×2.
fn soft_logo_doc(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let d = (x as f64 + 0.5 - 50.3).hypot(y as f64 + 0.5 - 49.6);
let cover = (0.5 + (30.0 - d) * 0.7).clamp(0.0, 1.0);
[200, 40, 60, (cover * 255.0).round() as u8]
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0))
}
#[test]
fn image_trace_flat_logo_preset_makes_a_true_circle() {
let mut s = session();
soft_logo_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap();
assert_eq!((r["paths"].as_u64(), r["colors"].as_u64()), (Some(1), Some(1)));
assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}");
let g = NodeId(r["id"].as_u64().unwrap());
let n = node(&s, g);
let t = n.extras().trace.clone().expect("settings stored");
assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo")));
let paths = traced_paths(&n);
let (p, rule) = outline(&paths[0]);
let want = std::f64::consts::PI * 60.0 * 60.0;
let got = vectorcraft_pathops::area(&p, rule);
assert!((got - want).abs() / want < 0.01, "area {got} vs {want}");
let bb = p.bounds().unwrap();
// The disc is at (50.3, 49.6) in a x2 image placed at (50, 50): centre (150.6, 149.2).
assert!((bb.center().x - 150.6).abs() < 0.3 && (bb.center().y - 149.2).abs() < 0.3, "{bb:?}");
}
#[test]
fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
let mut s = session();
let grad = vectorcraft_trace::Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
add_image(&mut s, &grad, Affine::IDENTITY);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap_err().to_string();
assert!(e.contains("flat-colour") && e.contains("Color mode"), "{e}");
let mut s = session();
soft_logo_doc(&mut s);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo", "params": {"logoColors": ["crimson"]}})).unwrap_err().to_string();
assert!(e.contains("#rrggbb"), "{e}");
// The named colours are used as given.
let r = s.execute("imageTrace.makeAndExpand", &json!({"preset": "Flat Logo", "params": {"logoColors": ["#c8283c"]}})).unwrap();
assert_eq!(r["colors"], 1);
}
#[test]
fn image_trace_object_remembers_its_settings() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap();
let g = NodeId(r["id"].as_u64().unwrap());
let t = node(&s, g).extras().trace.clone().expect("settings stored");
assert_eq!(t["preset"], "6 Colors");
assert_eq!(t["params"]["colors"], 6);
// Changing a parameter makes it Custom; the settings survive save/open.
let r = s.execute("imageTrace.make", &json!({"id": g.0, "preset": "6 Colors", "params": {"colors": 9}})).unwrap();
let g = NodeId(r["id"].as_u64().unwrap());
let d = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
let t = back.node(g).unwrap().extras().trace.clone().unwrap();
assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9)));
// Expanded traces are plain groups.
s.execute("imageTrace.expand", &json!({})).unwrap();
let st = s.doc().unwrap();
let id = st.selection.in_paint_order(&st.doc)[0];
assert!(st.doc.node(id).unwrap().extras().trace.is_none());
}
/// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in
/// Black and White with Ignore White, the blue drops out and one disc (r = 60 pt at (150, 150))
/// remains.
fn disc_on_blue(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [170, 210, 255, 255] }
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
NodeId(r["id"].as_u64().unwrap())
}
/// The document rendered as the canvas draws it (`trace_views`) or as exports do.
fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), trace_views, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, Affine::IDENTITY, &opts)
}
#[test]
fn image_trace_view_is_stored_kept_and_validated() {
let mut s = session();
let g = disc_on_blue(&mut s);
assert_eq!(node(&s, g).extras().trace.clone().unwrap()["view"], "tracingResult");
let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap();
assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage")));
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
// Setting the view it has already adds no undo step.
let steps = s.doc().unwrap().history.undo.len();
s.execute("imageTrace.setView", &json!({"id": g.0, "view": "outlinesWithSourceImage"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// Tracing again keeps it, unless another one is asked for; it survives save and open.
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true, "noise": 30}})).unwrap()["id"].as_u64().unwrap());
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "view": "SourceImage"})).unwrap()["id"].as_u64().unwrap());
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(g).unwrap().trace_view(), vectorcraft_doc::TraceView::Source);
// Bad views and other objects are refused.
assert!(s.execute("imageTrace.setView", &json!({"view": "sepia"})).is_err());
assert!(s.execute("imageTrace.setView", &json!({})).is_err());
assert!(s.execute("imageTrace.make", &json!({"view": 3})).is_err());
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.setView", &json!({"id": a.0, "view": "outlines"})).is_err());
}
#[test]
fn image_trace_views_draw_on_screen_only_and_expand_to_the_result() {
let mut s = session();
let g = disc_on_blue(&mut s);
let dark = |p: [u8; 4]| p[0] < 60 && p[1] < 60 && p[2] < 60;
let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245;
let blue = |p: [u8; 4]| p[0] < 200 && p[2] > 245;
let (centre, beside) = ((150, 150), (70, 70));
let at = |img: &vectorcraft_render::Rendered, (x, y): (u32, u32)| img.pixel(x, y);
// The outline is a 1 px line where the disc's right side is (x = 210, anti-aliased).
let outlined = |img: &vectorcraft_render::Rendered| (205..=215).any(|x| img.pixel(x, 150)[0] < 128);
let mut expanded = None;
for view in vectorcraft_doc::TraceView::ALL {
s.execute("imageTrace.setView", &json!({"id": g.0, "view": view.id()})).unwrap();
let screen = render_doc(&s, true);
let (c, b) = (at(&screen, centre), at(&screen, beside));
assert_eq!(dark(c), view.shows_result() || view.shows_source(), "{view:?}: the disc's middle {c:?}");
assert_eq!(blue(b), view.shows_source(), "{view:?}: the image's background {b:?}");
assert!(view.shows_source() || white(b), "{view:?}: nothing beside the disc {b:?}");
if view == vectorcraft_doc::TraceView::Outlines {
assert!(outlined(&screen), "{view:?}: the disc's outline");
}
// Exports draw the tracing result whatever the view.
let export = render_doc(&s, false);
assert!(dark(at(&export, centre)) && white(at(&export, beside)), "{view:?}: export");
// Expand keeps the traced shapes, as traced, whatever the view.
s.execute("imageTrace.expand", &json!({"id": g.0})).unwrap();
let shapes: Vec<_> = node(&s, g).children().unwrap().iter().map(|c| (c.path_data().cloned(), c.appearance.clone())).collect();
assert_eq!(*expanded.get_or_insert_with(|| shapes.clone()), shapes, "{view:?}: expanded");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals
/// overlapping) merges every region the drags touch into one shape whose outline is only the
/// merged area's border: no edge between merged regions stays in, as a stray line or a spur.
/// Petal `k` turns about the center by 45° × `k`, or by each angle `turns` lists for it in turn.
#[track_caller]
fn rosette_rings_merge_cleanly(turns: &[(u32, &[f64])]) {
let mut s = session();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
let c = [400.0, 320.0];
let mut petals = vec![];
for k in 0..8 {
let id =
s.execute("shape.ellipse", &json!({"x": c[0] - 60.0, "y": c[1] - 172.0, "width": 120, "height": 324})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let angles = turns.iter().find(|t| t.0 == k).map_or_else(|| vec![45.0 * f64::from(k)], |t| t.1.to_vec());
for angle in angles {
s.execute("object.rotate", &json!({"angle": angle, "origin": c})).unwrap();
}
petals.push(id);
}
s.execute("select.set", &json!({"ids": petals})).unwrap();
// Drags around the ring of petals, from its inside out; the outer ones miss the petals.
let mut merged = None;
for radius in [110.0, 150.0, 190.0, 230.0] {
let pts: Vec<[f64; 2]> = (0..=120)
.map(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 120.0;
[c[0] + radius * a.cos(), c[1] + radius * a.sin()]
})
.collect();
if let Ok(r) = s.execute("shapeBuilder.merge", &json!({"points": pts})) {
merged = r["merged"].as_u64().map(NodeId);
}
}
let (ring, rule) = outline(&node(&s, merged.expect("the drags merged")));
let inside = |p: kurbo::Point| kurbo::Shape::winding(&ring.to_bezpath(), p) != 0;
for k in 0..8 {
let a = std::f64::consts::FRAC_PI_4 * f64::from(k) - std::f64::consts::FRAC_PI_2;
let tip = kurbo::Point::new(c[0] + 165.0 * a.cos(), c[1] + 165.0 * a.sin());
assert!(inside(tip), "petal {k}'s tip at {tip:?} is in the merged shape");
}
// What is left of the petals stays out of the merged area (a piece left over it is a stray
// outline inside it).
let merged = merged.unwrap();
for id in s.doc().unwrap().selection.objects.clone().into_iter().filter(|id| *id != merged) {
let (p, r) = outline(&node(&s, id));
let over = vectorcraft_pathops::boolean(&p, r, &ring, rule, vectorcraft_pathops::BoolOp::Intersect);
let a = vectorcraft_pathops::area(&over, FillRule::NonZero);
assert!(a < 1.0, "{id:?} overlaps the merged shape by {a} pt²");
}
// No outline is a sliver or carries a spur (2 × area / length: its mean width).
for id in s.doc().unwrap().selection.objects.clone() {
let (p, r) = outline(&node(&s, id));
for sp in &p.subpaths {
let one = PathData::new(vec![sp.clone()]);
let width = 2.0 * vectorcraft_pathops::area(&one, r) / one.length();
assert!(width > 1.0, "{id:?} has an outline {width:.3} pt wide on average, {:.1} pt long", one.length());
}
}
// The ring is one outline around the centre (the regions the drags missed aside): the
// petals' tips are no outlines of their own beside it, apart by a hairline.
let big = ring.subpaths.iter().filter(|sp| vectorcraft_pathops::area(&PathData::new(vec![(*sp).clone()]), rule) > 100.0).count();
assert_eq!(big, 2, "{:?}", ring.subpaths.iter().map(|sp| vectorcraft_pathops::area(&PathData::new(vec![sp.clone()]), rule)).collect::<Vec<_>>());
// An inner edge would be a second outline over the merged area: normalising would drop it.
let clean = vectorcraft_pathops::normalize(&ring, rule);
assert!((ring.length() - clean.length()).abs() < 1.0, "{} pt of outline, {} without inner edges", ring.length(), clean.length());
}
#[test]
fn shape_builder_merges_rosette_rings_cleanly() {
rosette_rings_merge_cleanly(&[]);
}
/// #1770: the rosette merges as cleanly with a petal turned by an equivalent angle (-180° for 180°,
/// -855° for 225°, -45° for 315°) or in two steps, which moves its outline by a rounding error.
#[test]
fn shape_builder_merges_rosette_rings_cleanly_however_a_petal_turns() {
rosette_rings_merge_cleanly(&[(4, &[-180.0])]);
rosette_rings_merge_cleanly(&[(5, &[-855.0])]);
rosette_rings_merge_cleanly(&[(7, &[-45.0])]);
rosette_rings_merge_cleanly(&[(4, &[100.0, 80.0])]);
}