Blob Brush: a stroke merges with the topmost matching path and its neighbors in its group or layer

A Blob Brush stroke used to merge with every closed path of its fill
color and no stroke that it touched, anywhere in the document, across
layers and groups and past objects of other kinds. It now merges with
the topmost such path it touches, and with the other touched matching
paths in the same group or layer that are next to that path in
stacking order, with no other kind of object between them. Merge only
with selection still limits the candidates to selected paths. The
merged path keeps the topmost path's id and place.
This commit is contained in:
dotCooCoo
2026-10-29 10:10:59 +07:00
committed by bflatastic
parent 16d679fde7
commit 3593dd4b0d
4 changed files with 90 additions and 5 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blob Brush",
[],
None,
"{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}",
"{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 the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
has_doc,
blob
),
+34 -4
View File
@@ -336,6 +336,35 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Blob Brush ----------
/// Whether `n` is a path a Blob Brush stroke of `paint` can merge with: the same fill and no stroke.
fn blob_match(n: &Node, paint: &Paint) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.appearance.fill_paint() == *paint && n.appearance.stroke_paint().is_none()
}
/// The paths of `touched` that a Blob Brush stroke of `paint` merges with: the topmost of them, and
/// the others in the same group or layer that are adjacent to it in stacking order, with no object
/// between them other than a path of the same fill and no stroke.
fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec<NodeId> {
let Some(top) = doc.paint_order(touched.iter().copied()).last().copied() else {
return vec![];
};
let Some((parent, at, _)) = doc.position(top) else {
return vec![top];
};
let Some(siblings) = doc.children(parent) else {
return vec![top];
};
let mut out = vec![top];
for n in siblings.iter().take(at).rev() {
if touched.contains(&n.id) {
out.push(n.id);
} else if !blob_match(n, paint) {
break;
}
}
out
}
pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.blob";
let pts = points_param(p, "points", C)?;
@@ -364,7 +393,7 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
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() {
if !blob_match(n, &paint) {
continue;
}
let (pd, rule) = path_of(&st.doc, id)?;
@@ -372,6 +401,8 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
merge_ids.push((id, pd, rule));
}
}
let joining = adjacent_to_topmost(&st.doc, &merge_ids.iter().map(|m| m.0).collect::<Vec<_>>(), &paint);
merge_ids.retain(|m| joining.contains(&m.0));
}
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
if merge_ids.is_empty() {
@@ -380,11 +411,10 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?;
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
let Some(keep) = st.doc.paint_order(merge_ids.iter().map(|m| m.0)).last().copied() 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();
let others: Vec<NodeId> = merge_ids.iter().map(|m| m.0).filter(|id| *id != keep).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
remove_each(d, &others)?;
+54
View File
@@ -465,6 +465,60 @@ fn blob_brush_merges_only_with_the_selection_and_keep_selected_off_deselects() {
assert!(s.doc().unwrap().doc.node(all).is_some() && s.doc().unwrap().doc.node(other).is_some());
}
/// A Blob Brush stroke merges with the topmost matching path it touches and with the touched
/// matching paths next to it in stacking order in the same group or layer; matching paths in other
/// layers or groups, or below an object of another kind, are left alone.
#[test]
fn blob_brush_merges_with_the_topmost_match_and_its_neighbors() {
let blob = |s: &mut Session, x: f64| {
NodeId(s.execute("path.blob", &json!({"points": [[x, 60], [x, 140]], "size": 20})).unwrap()["id"].as_u64().unwrap())
};
let across = json!({"points": [[50, 100], [450, 100]], "size": 10});
let parent = |s: &Session, id: NodeId| s.doc().unwrap().doc.parent_of(id);
// Two layers: only the path on the upper layer merges.
let mut s = session();
let low = blob(&mut s, 100.0);
let upper = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
let high = blob(&mut s, 200.0);
let area_low = area(&path(&s, low));
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
assert_eq!(id, high);
assert_eq!(parent(&s, high), Some(upper));
assert_eq!(paths(&s).len(), 2);
assert_eq!(area(&path(&s, low)), area_low, "the path on the lower layer is left as it was");
// A path of another color between two matches: only the upper one merges.
let mut s = session();
let below = blob(&mut s, 100.0);
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let red = NodeId(s.execute("path.blob", &json!({"points": [[100, 400], [200, 400]], "size": 10})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
let above = blob(&mut s, 200.0);
let (area_below, area_red) = (area(&path(&s, below)), area(&path(&s, red)));
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
assert_eq!(id, above);
assert_eq!(paths(&s).len(), 3);
assert_eq!(area(&path(&s, below)), area_below, "the path below the red one is left as it was");
assert_eq!(area(&path(&s, red)), area_red);
// Two adjacent matches in a group both merge; a match outside the group is left alone.
let mut s = session();
let outside = blob(&mut s, 100.0);
let a = blob(&mut s, 200.0);
let b = blob(&mut s, 300.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
let area_outside = area(&path(&s, outside));
let id = NodeId(s.execute("path.blob", &across).unwrap()["id"].as_u64().unwrap());
assert_eq!(id, b, "the topmost match keeps its id");
assert!(s.doc().unwrap().doc.node(a).is_none(), "a joined b");
assert_eq!(parent(&s, b), Some(g));
assert_eq!(paths(&s).len(), 2);
assert_eq!(area(&path(&s, outside)), area_outside, "the path outside the group is left as it was");
}
/// 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() {
+1
View File
@@ -702,6 +702,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Shape Builder pieces on dense hatches (lines at exact 0.25 or 0.5 pt steps across another line) come back one per stretch between crossings, with no speck where the sweep cut a line's two passes a rounding error apart: the planar map contracts the stub between such cuts into one vertex, bounded by each cluster's extent so a run of tiny level segments keeps its length (`crates/pathops/src/planar.rs` tests `a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing`, `a_run_of_tiny_level_segments_is_not_contracted_as_stubs`, `a_line_crossed_where_its_two_passes_part_is_cut_once`).
The planar map's edge-source grid stays linear in memory on any input: a segment is filed once per cell it crosses, and the grid holds at most a budget of cell references (a few dozen per item plus a few rows' worth), past which the remaining items go to a short scanned list and are still found, so thousands of coincident segments cannot make the index quadratic (`crates/pathops/src/planar.rs` test `rect_index_holds_a_bounded_number_of_references`).
The Blob Brush Tool Options have Keep selected and Merge only with selection, both off by default: while Keep selected is off, the blob a stroke draws or merges into is not selected afterwards, and while Merge only with selection is on, a stroke merges only with selected blobs of its color that it touches (`path.blob` `keepSelected` and `mergeOnlySelection`).
A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`).
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score.
- **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).