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:
dotCooCoo
2026-10-22 23:56:01 +07:00
committed by bflatastic
parent 00f557e02e
commit fffc8418d9
5 changed files with 134 additions and 19 deletions
+10 -8
View File
@@ -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);
+113 -2
View File
@@ -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]
+2 -1
View File
@@ -495,7 +495,8 @@ pub struct ShapeBuilderOptions {
/// Pick Color From Color Swatches (`pickColorFrom: "swatches"`; off: `"artwork"`): a merge
/// fills the new shape with the current fill, and the Left and Right arrow keys step the current
/// fill through the document's color swatches. With Artwork, the shape takes the look of the
/// art it is merged from.
/// object where the drag starts, else of the one where it ends, else of the front-most selected
/// object.
pub swatches: bool,
/// Cursor Swatch Preview (`cursorSwatchPreview`, off): with Color Swatches, the current fill
/// and the swatches before and after it show as chips above the pointer while no drag is on.
+8 -8
View File
@@ -4550,14 +4550,14 @@ default name, "Artboard N" for the Nth artboard.
The Shape Builder tool's options are its Shape Builder Tool Options (dialog `shapeBuilderOptions`):
`openFilledAsClosed` (on: a filled open path bounds a region as if an invisible edge closed it; off: it cuts regions
as an unfilled path does), `pickColorFrom` (`artwork`, the default: a merged shape takes the fill of the front-most
object over the first region the drag touches that an object covers, and the current fill when only lines enclose
every region touched; `swatches`: the current fill, and the Left and Right arrow keys step the current fill through the
document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the pointer show the current fill
and the swatches before and after it), `selection` (`freeform`, the default, or `straightLine`: a drag touches what
the line from its first point to the pointer crosses) and `highlightFill` (on by default; when it is off, merge mode
shades no regions). The tool sends `openFilledAsClosed` and `pickColorFrom` with `shapeBuilder.merge`, which takes
both as params, and `shapeBuilder.regions` takes `openFilledAsClosed`.
as an unfilled path does), `pickColorFrom` (`artwork`, the default: a merged shape takes the appearance, opacity and
blending mode of the front-most selected object under the drag's first point, else of the one under its last point,
else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys step the
current fill through the document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the
pointer show the current fill and the swatches before and after it), `selection` (`freeform`, the default, or
`straightLine`: a drag touches what the line from its first point to the pointer crosses) and `highlightFill` (on by
default; when it is off, merge mode shades no regions). The tool sends `openFilledAsClosed` and `pickColorFrom` with
`shapeBuilder.merge`, which takes both as params, and `shapeBuilder.regions` takes `openFilledAsClosed`.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"shapeBuilder","values":{"selection":"straightLine","pickColorFrom":"swatches"}}}}
+1
View File
@@ -635,6 +635,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open.
Keep selected in the Pencil and Paintbrush Tool Options is on by default; while it is off, the path a stroke draws or continues is not selected afterwards and the rest of the selection stays as it was (`path.freehand` `keepSelected`).
Double-clicking the Shape Builder tool opens Shape Builder Tool Options ([Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html)): with Consider Open Filled Paths as Closed off, a filled open path cuts regions as an unfilled one does; Pick Color From Color Swatches fills merged shapes with the current fill, the Left and Right arrow keys step it through the document's color swatches, and Cursor Swatch Preview shows the current fill and the swatches before and after it above the pointer; Selection Straight Line merges what the line from a drag's first point to its last crosses ([Setting Illustrator's Shape Builder Options](https://web.archive.org/web/20260615204453/https://creativepro.com/setting-illustrators-shape-builder-options/)); with Highlight Fill off, merge mode shades no regions (`shapeBuilder.merge` `openFilledAsClosed` and `pickColorFrom`). Gap Detection, "In Merge Mode, Clicking Stroke Splits the Path" and Highlight Stroke When Editable are not built.
With Pick Color From Artwork, a merged shape takes the appearance, opacity and blending mode of the object where the drag starts, else of the object where it ends, else of the front-most selected object, as the [Shape Builder combine page](https://web.archive.org/web/20251006211643/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/combine-shapes-with-shape-builder-tool.html) states ("The art style of the object where the mouse drag was initiated is applied to the merging shapes"). A click in an area that no selected object covers, such as a hole other shapes enclose, takes the front-most selected object's look (`shapeBuilder.merge` `points`: the first point is where the drag starts, and the last point is where it ends).
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`).
- **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.