mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Blob Brush: Keep selected and Merge only with selection options
A Blob Brush stroke merged with every touching closed path of its color that had no stroke, and the blob it drew or merged into was always left selected. The Blob Brush Tool Options had Size only. The Blob Brush Tool Options now have Keep selected and Merge only with selection, both off by default. A blob that a stroke draws or merges into is no longer left selected unless Keep selected is on, and the rest of the selection stays as it was. While Merge only with selection is on, a stroke merges only with selected blobs, and with nothing selected it draws a new blob. The options persist like the tool's Size, and path.blob takes keepSelected and mergeOnlySelection, both false by default. Keep selected stays on by default for the Pencil and Paintbrush. Docs: docs/target-app-parity.md, docs/control-protocol.md.
This commit is contained in:
@@ -13,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node, insert_look};
|
||||
use super::create::{add_look, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -142,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
@@ -1346,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) {
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
continue;
|
||||
@@ -1361,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ap = Appearance::basic(paint, Paint::None, 1.0);
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
return add_node(s, "Blob Brush", path_kind(region), ap, None);
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
}
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::unite_all(&inputs);
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
if keep_selected {
|
||||
sel.set([keep]);
|
||||
} else {
|
||||
sel.remove(keep);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": keep.0 }))
|
||||
|
||||
@@ -186,6 +186,61 @@ fn paintbrush_uses_stroke_blob_brush_fills() {
|
||||
assert!(area(&ps[1].1) > 1500.0);
|
||||
}
|
||||
|
||||
/// Merge only with selection: a Blob Brush stroke merges with the selected blobs it touches and
|
||||
/// leaves the others. Keep selected, off by default: the blob a stroke draws or merges into is not
|
||||
/// selected afterwards, and the rest of the selection stays; on, it is selected.
|
||||
#[test]
|
||||
fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 600.0, 400.0, 50.0, 50.0);
|
||||
let selected = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
let blob = |s: &mut Session, p: serde_json::Value| NodeId(s.execute("path.blob", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let a = blob(&mut s, json!({"points": [[100, 60], [100, 140]], "size": 20}));
|
||||
assert_eq!(selected(&s), vec![r], "Keep selected is off by default: the new blob is not selected");
|
||||
let b = blob(&mut s, json!({"points": [[200, 60], [200, 140]], "size": 20, "keepSelected": true}));
|
||||
assert_eq!(selected(&s), vec![b], "Keep selected on: the new blob is selected");
|
||||
let n = paths(&s).len();
|
||||
let (area_a, area_b) = (area(&path(&s, a)), area(&path(&s, b)));
|
||||
// Only `b` is selected: the stroke across both merges into `b` alone.
|
||||
let undo = undo_steps(&s);
|
||||
let across = json!({"points": [[50, 100], [250, 100]], "size": 10, "mergeOnlySelection": true, "keepSelected": false});
|
||||
assert_eq!(blob(&mut s, across.clone()), b);
|
||||
assert_eq!(paths(&s).len(), n);
|
||||
assert_eq!(area(&path(&s, a)), area_a, "a is left as it was");
|
||||
assert!(area(&path(&s, b)) > area_b + 500.0, "the stroke joined b");
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
// With nothing selected, the stroke merges with nothing, and Keep selected off leaves the new
|
||||
// blob unselected.
|
||||
let new = blob(&mut s, across);
|
||||
assert!(new != a && new != b && paths(&s).len() == n + 1, "a new blob");
|
||||
assert!(selected(&s).is_empty());
|
||||
// A selection that holds no blob: a new blob, and the selection stays.
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let other = blob(&mut s, json!({"points": [[50, 300], [250, 300]], "size": 10, "mergeOnlySelection": true, "keepSelected": false}));
|
||||
assert_eq!(paths(&s).len(), n + 2);
|
||||
assert_eq!(selected(&s), vec![r]);
|
||||
// Without the options, a stroke merges with every touching blob, selected or not, and the
|
||||
// blob it merges into is not selected.
|
||||
let all = blob(&mut s, json!({"points": [[50, 100], [250, 100]], "size": 10}));
|
||||
assert!([a, b, new].contains(&all) && paths(&s).len() == n, "one blob of a, b and the new one");
|
||||
assert_eq!(selected(&s), vec![r]);
|
||||
// Keep selected on: the blob a stroke merges into is selected.
|
||||
assert_eq!(blob(&mut s, json!({"points": [[150, 80], [150, 120]], "size": 10, "keepSelected": true})), all);
|
||||
assert_eq!(paths(&s).len(), n);
|
||||
assert_eq!(selected(&s), vec![all]);
|
||||
// The tool passes both options; Keep selected is off by default.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
s.select_tool("blobBrush", view()).unwrap();
|
||||
assert_eq!(s.tool_options()["keepSelected"], json!(false));
|
||||
s.set_tool_option("mergeOnlySelection", &json!(true));
|
||||
drag(&mut s, "blobBrush", &[(150.0, 50.0), (150.0, 320.0)], Mods::default());
|
||||
assert_eq!(paths(&s).len(), n, "merged into the selected blob only");
|
||||
assert!(selected(&s).is_empty());
|
||||
assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some());
|
||||
}
|
||||
|
||||
/// Three points already curve in the saved path: both ends carry a handle into the curve.
|
||||
#[test]
|
||||
fn path_curvature_with_three_points_saves_end_handles() {
|
||||
|
||||
Reference in New Issue
Block a user