mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Shape Builder: Pick Color From Artwork takes the look where the drag starts, else where it ends
With Pick Color From Artwork, a Shape Builder merge took the look of the front-most object over the first covered region the drag touched, and the current fill when the drag touched only areas no object covers. The reference app's combine page gives the look of the object where the drag starts, else of the object where it ends, else of the topmost selected object. shapeBuilder.merge now follows that rule: the merged shape takes the appearance, opacity and blending mode of the front-most of the objects it is given (the selection by default) under the first point, else of the one under the last point, else of the front-most of those objects. A drag from a hole across a gray bar into a red square now gives a red shape, where it gave the bar's look. A click in an area no selected object covers, such as a hole that gray bars enclose or a triangle that lines enclose, takes the front-most selected object's look instead of the current fill. Pick Color From Color Swatches works as before. The tool already sends the press point first and the release point last. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -261,9 +261,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
|
||||
},
|
||||
};
|
||||
let current_fill = s.paint.fill.clone();
|
||||
let fill = match fill_param(s, p, C)? {
|
||||
None if swatches => Some(current_fill.clone()),
|
||||
None if swatches => Some(s.paint.fill.clone()),
|
||||
f => f,
|
||||
};
|
||||
let roots = root_ids(s, p)?;
|
||||
@@ -289,11 +288,14 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
// area only lines enclose) of the front-most object and the current fill.
|
||||
let covered = picked.iter().find_map(|r| r.top());
|
||||
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
|
||||
// drag's first point, else under its last point, else of the front-most object. Color
|
||||
// Swatches: the current fill, with the rest of the look of the front-most object over the
|
||||
// first region the drag touched that an object covers, else of the front-most object.
|
||||
let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top());
|
||||
let covered =
|
||||
if swatches { picked.iter().find_map(|r| r.top()) } else { pts.first().and_then(top_at).or_else(|| pts.last().and_then(top_at)) };
|
||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
|
||||
let mut new_nodes: Vec<Node> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
@@ -346,7 +348,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||
if let Some(f) = &merged_fill {
|
||||
if let Some(f) = &fill {
|
||||
m.appearance.set_fill(f.clone());
|
||||
}
|
||||
merged_id = Some(m.id);
|
||||
|
||||
@@ -350,11 +350,11 @@ fn shape_builder_fills_an_area_lines_enclose() {
|
||||
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.fill_paint(), s.paint.fill, "the current fill");
|
||||
assert!(!node(&s, merged).appearance.stroke_paint().is_none(), "the lines' stroke");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -467,6 +467,117 @@ fn shape_builder_pick_color_from_swatches_fills_with_the_current_fill() {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user