Shape Builder: a Shift-drag draws a marquee and merges every region it reaches

A Shape Builder drag merged only the regions the pointer passed over,
and the tool did not read Shift. The reference app's Shape Builder page
describes a Shift-drag that draws a rectangular marquee to merge many
paths at once.

With Shift held at the press, the drag now draws a rectangular marquee
from where it started to the pointer. The regions it reaches are shaded
while it is drawn and merge into one shape on release; with Alt also
held at the press, they are deleted. The marquee reaches each region
whose outline crosses it or has an anchor inside it, and the region that
holds the whole marquee. A Shift-click that does not move is a click,
and a drag without Shift works as before. With In Merge Mode, Clicking
Stroke Splits the Path on, a Shift-click on a stroke splits it as a
click does; a marquee splits no stroke, and while it is drawn the
pointer does not show scissors and no stroke is highlighted.

shapeBuilder.merge takes the marquee as rect: [x, y, width, height],
where x and y are where the drag started, and points may then be left
out. For Pick Color From Artwork, the corner where the drag started
counts as the drag's first point, and the opposite corner counts as its
last. With rect, splitStroke does not split a stroke.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-23 06:51:20 +07:00
committed by bflatastic
parent 47f3437e75
commit e4a3adcea0
5 changed files with 422 additions and 13 deletions
+32 -8
View File
@@ -40,7 +40,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; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), 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, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), 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; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), 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, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
has_selection_or_ids,
sb_merge
),
@@ -243,18 +243,37 @@ fn gap_param(p: &Value) -> f64 {
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
}
/// The Shift-drag marquee `rect: [x, y, width, height]`, as the point where the drag started
/// (`x`, `y`) and the opposite corner; the width and height may be negative.
fn marquee_param(p: &Value, cmd: &str) -> Result<Option<(Point, Point)>> {
let Some(v) = p.get("rect").filter(|v| !v.is_null()) else { return Ok(None) };
let corners = finite_numbers(v).map(|[x, y, w, h]| (Point::new(x, y), Point::new(x + w, y + h)));
match corners {
Some((a, b)) if finite(a) && finite(b) => Ok(Some((a, b))),
_ => Err(bad(cmd, "rect must be [x, y, width, height]")),
}
}
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shapeBuilder.merge";
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
let marquee = marquee_param(p, C)?;
let pts = match points_param(p, "points") {
Some(pts) => pts,
// A marquee needs no drag path.
None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![],
None => return Err(bad(C, "missing points [[x,y]…]")),
};
if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) {
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
}
let erase = switch_or(p, "erase", false, C)?;
check_numbers(p, &["tolerance", "gap"], C)?;
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
// In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it.
let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1).then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
// In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a
// marquee does not.
let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none())
.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
let swatches = match p.get("pickColorFrom") {
None | Some(Value::Null) => false,
@@ -309,7 +328,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
touched.add(map.hit(q, Some(tol)));
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched::default();
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, piece_tol));
}
@@ -324,10 +343,15 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// 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.
// first region the drag touched that an object covers, else of the front-most object. A
// marquee's drag starts at its first corner and ends at the opposite one.
let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top());
let (first, last) = match marquee {
Some((a, b)) => (Some(a), Some(b)),
None => (pts.first().copied(), pts.last().copied()),
};
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)) };
if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) };
let style_src = covered.or(map.shapes.len().checked_sub(1));
let mut new_nodes: Vec<Node> = vec![];
let mut merged_id = None;
+155 -1
View File
@@ -5,7 +5,7 @@ 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_geom::{Affine, FillRule, PathData, Point, Rect};
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
@@ -140,6 +140,131 @@ fn shape_builder_miss_is_an_error_and_undo_restores() {
assert!(s.execute("shapeBuilder.merge", &json!({})).is_err());
}
/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), each in its own gray, selected.
fn grid(s: &mut Session) -> Vec<NodeId> {
let ids: Vec<NodeId> = (0..9)
.map(|k| {
let id = rect(s, (k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0, 100.0, 100.0);
let gray = k as f32 / 10.0;
set_fill(s, id, Color::rgb(gray, gray, gray));
id
})
.collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
ids
}
/// `rect`: the regions a marquee reaches merge into one shape, which takes the look of the square
/// where the marquee started, else of the square where it ended.
#[test]
fn shape_builder_rect_merges_every_region_the_marquee_reaches() {
let mut s = session();
let squares = grid(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"rect": [250, 250, -200, -200]})).unwrap();
assert_eq!(r["regions"], 9);
let out = ids(&r);
assert_eq!(out.len(), 1, "{r}");
assert!((area(&s, out[0]) - 90000.0).abs() < 1.0, "{}", area(&s, out[0]));
assert_eq!(outline(&node(&s, out[0])).0.subpaths.len(), 1, "one path, no seams");
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.8, 0.8, 0.8)), "the bottom-right square's look");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"rect": [50, 50, 200, 200]})).unwrap();
assert_eq!(node(&s, ids(&r)[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 0.0)), "the top-left square's look");
s.execute("edit.undo", &json!({})).unwrap();
// Started outside every square: the look of the middle square, where it ended.
s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"rect": [-50, -50, 200, 200]})).unwrap();
assert_eq!(r["regions"], 4);
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(0.4, 0.4, 0.4)), "the middle square's look");
s.execute("edit.undo", &json!({})).unwrap();
// Points as well add the regions along them; the marquee touches the left column only.
s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"rect": [40, 50, 20, 200], "points": [[150, 50], [250, 50]]})).unwrap();
assert_eq!(r["regions"], 5);
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 50000.0).abs() < 1.0, "{}", area(&s, merged));
}
/// `rect` with `erase`: the regions the marquee reaches are deleted.
#[test]
fn shape_builder_rect_with_erase_deletes_the_regions_the_marquee_reaches() {
let mut s = session();
grid(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"rect": [140, 50, 20, 200], "erase": true})).unwrap();
assert_eq!((&r["regions"], &r["merged"]), (&json!(3), &Value::Null));
let out = ids(&r);
assert_eq!(out.len(), 6, "{r}");
assert!(out.iter().all(|i| (area(&s, *i) - 10000.0).abs() < 1.0));
let outside_the_middle = |i: &NodeId| node(&s, *i).geometric_bounds().is_some_and(|b| b.x1 <= 100.0 + 1e-6 || b.x0 >= 200.0 - 1e-6);
assert!(out.iter().all(outside_the_middle), "the middle column is gone");
}
#[test]
fn shape_builder_rect_must_be_four_finite_numbers() {
let mut s = session();
grid(&mut s);
let undo_before = s.doc().unwrap().history.undo.len();
for rect in [json!([0, 0, 10]), json!("big"), json!([0, 0, 10, f64::MAX]), json!([0, 0, "10", 10])] {
let e = s.execute("shapeBuilder.merge", &json!({"rect": rect})).unwrap_err().to_string();
assert!(e.contains("rect must be [x, y, width, height]"), "{rect}: {e}");
}
// Outside every region: nothing to merge.
assert!(s.execute("shapeBuilder.merge", &json!({"rect": [400, 400, 50, 50]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
// A null rect is left out, and points are needed again.
assert!(s.execute("shapeBuilder.merge", &json!({"rect": null})).is_err());
}
/// With `rect`, `splitStroke` splits nothing, even with one point on a stroke: the marquee's
/// regions merge.
#[test]
fn shape_builder_rect_merges_and_does_not_split_a_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 r = s.execute("shapeBuilder.merge", &json!({"rect": [40, -5, 20, 20], "points": [[50, -1]], "splitStroke": true})).unwrap();
assert_eq!((r.get("split"), r["regions"].as_u64(), r["edges"].as_u64()), (None, Some(1), Some(0)), "{r}");
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(shapes_of(&s, &[merged]), vec![(true, 4, 400.0)]);
}
/// Past the segment limit only the filled areas count, so a stroke-only line along y = 150 cuts no
/// square: a marquee above the line merges the three whole squares of the middle row, as a drag
/// above it does. Under the limit the line cuts the middle row, and both merge only the halves
/// above it.
#[test]
fn shape_builder_rect_past_the_segment_limit_merges_whole_regions_as_a_drag_does() {
let merged_area = |segments: usize, params: Value| {
let mut s = session();
let mut ids = grid(&mut s);
let pts: Vec<Point> = (0..=segments).map(|k| Point::new(-10.0 + 320.0 * k as f64 / segments as f64, 150.0)).collect();
let look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
let l = s
.edit("t", |d, _| {
let id = d.alloc_id();
let top = d.children(d.default_layer()).map_or(0, |c| c.len());
d.insert(d.default_layer(), top, Node::path(id, PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)), look))?;
Ok(id)
})
.unwrap();
ids.push(l);
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &params).unwrap();
area(&s, NodeId(r["merged"].as_u64().unwrap()))
};
let past = vectorcraft_pathops::SHAPE_BUILDER_MAX_SEGMENTS;
let marquee = json!({"rect": [20, 120, 260, 20]});
let drag = json!({"points": [[50, 130], [250, 130]]});
for (segments, want) in [(2, 15000.0), (past, 30000.0)] {
for params in [&marquee, &drag] {
let got = merged_area(segments, params.clone());
assert!((got - want).abs() < 1.0, "{segments} segments, {params}: {got}");
}
}
}
#[test]
fn shape_builder_regions_query() {
let mut s = session();
@@ -602,6 +727,35 @@ fn shape_builder_tool_options_reach_the_command() {
);
}
/// A Shift-drag with the tool across the grid merges all nine squares into one shape; a drag along
/// the middle row without Shift merges the three squares it passes over.
#[test]
fn shape_builder_tool_shift_drag_merges_the_grid_into_one_shape() {
let v = ViewInfo::default();
let drag = |s: &mut Session, mods: Mods, pts: [(f64, f64); 3]| {
s.select_tool("shapeBuilder", v).unwrap();
for (kind, (x, y)) in [PointerKind::Down, PointerKind::Drag, PointerKind::Up].into_iter().zip(pts) {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap();
}
s.journal.last().unwrap().clone()
};
let mut s = session();
grid(&mut s);
let (cmd, p) = drag(&mut s, Mods { shift: true, ..Default::default() }, [(50.0, 50.0), (150.0, 150.0), (250.0, 250.0)]);
assert_eq!((cmd.as_str(), &p["rect"]), ("shapeBuilder.merge", &json!([50.0, 50.0, 200.0, 200.0])));
assert_eq!(layer_children(&s), 1);
let only = {
let d = &s.doc().unwrap().doc;
d.children(d.default_layer()).unwrap()[0].id
};
assert!((area(&s, only) - 90000.0).abs() < 1.0, "{}", area(&s, only));
let mut s = session();
grid(&mut s);
let (_, p) = drag(&mut s, Mods::default(), [(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)]);
assert_eq!((p.get("rect"), &p["points"]), (None, &json!([[50.0, 150.0], [150.0, 150.0], [250.0, 150.0]])));
assert_eq!(layer_children(&s), 7, "the middle row merged, six squares left");
}
/// Highlight Fill off: hovering a region in merge mode highlights nothing; erase mode still shows
/// what a click would delete.
#[test]
+217 -4
View File
@@ -304,6 +304,27 @@ impl BuilderMap {
self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0)
}
/// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each
/// region whose outline crosses the rectangle or has an anchor inside it, and the region the
/// whole rectangle lies in. Of those, the region under `from` comes first, then the one under
/// `to`, then the others in map order. A marquee of no size touches the region under `from`.
pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec<usize> {
let r = Rect::from_points(from, to);
if r.width() <= 0.0 && r.height() <= 0.0 {
return self.region_at(from).into_iter().collect();
}
let c = r.center();
let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(&reg.path, r);
let mut out: Vec<usize> = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect();
for p in [to, from] {
if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) {
let i = out.remove(k);
out.insert(0, i);
}
}
out
}
/// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that
/// close, else the region under it.
pub fn hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
@@ -649,7 +670,9 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option<usize>, forward: boo
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
/// (outline pieces between the points where paths meet) it passes over. Its options are
/// (outline pieces between the points where paths meet) it passes over. With Shift held at the
/// press, the drag draws a rectangular marquee and touches the regions it reaches (see
/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Its options are
/// [`ShapeBuilderOptions`].
#[derive(Default)]
pub struct ShapeBuilderTool {
@@ -658,6 +681,8 @@ pub struct ShapeBuilderTool {
points: Vec<Point>,
touched: Touched,
dragging: bool,
/// A Shift-drag's marquee: where the drag started and where the pointer is.
marquee: Option<(Point, Point)>,
/// Erase mode: Alt held while hovering, or when the drag started.
alt: bool,
/// Where the pointer last moved (the cursor swatch preview sits above it).
@@ -724,6 +749,19 @@ impl ShapeBuilderTool {
}
}
/// A Shift-drag: the drag touches the regions the marquee from where it started to `p` reaches;
/// while the pointer is where the drag started, what a click there touches.
fn marquee_to(&mut self, cx: &ToolContext, p: Point) {
let Some((start, _)) = self.marquee else { return };
self.marquee = Some((start, p));
self.touched = Touched::default();
if start == p {
self.touch(cx, None, start);
} else {
self.touched.regions = self.cache.get(cx, self.alt, self.opts.open_filled_as_closed).regions_in_marquee(start, p);
}
}
/// The cursor swatch preview: the swatch before the current fill, the current fill (ringed)
/// and the swatch after it, side by side above the pointer.
fn swatch_chips(&self, cx: &ToolContext) -> Vec<Overlay> {
@@ -769,6 +807,7 @@ impl Tool for ShapeBuilderTool {
}
self.dragging = true;
self.alt = ev.mods.alt;
self.marquee = ev.mods.shift.then_some((p, p));
self.points = vec![p];
self.far = false;
self.touched = Touched::default();
@@ -780,6 +819,10 @@ impl Tool for ShapeBuilderTool {
if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) {
self.far = true;
}
if self.marquee.is_some() {
self.marquee_to(cx, p);
return vec![];
}
if self.opts.straight_line {
self.straight_to(cx, p);
return vec![];
@@ -793,7 +836,9 @@ impl Tool for ShapeBuilderTool {
}
PointerKind::Up if self.dragging => {
self.dragging = false;
if self.opts.straight_line {
if self.marquee.is_some() {
self.marquee_to(cx, p);
} else if self.opts.straight_line {
self.straight_to(cx, p);
} else if self.points.last().is_none_or(|q| *q != p) {
let last = self.points.last().copied();
@@ -803,9 +848,11 @@ impl Tool for ShapeBuilderTool {
let points = std::mem::take(&mut self.points);
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
// A marquee of no size is a click.
let marquee = self.marquee.take().filter(|(a, b)| a != b);
// With In Merge Mode, Clicking Stroke Splits the Path, a click on a stroke splits it
// where the press was.
let press = points.first().copied().filter(|s| !self.far && s.distance(p) <= cx.tol(CLICK_TOL_PX));
// where the press was; a marquee splits nothing.
let press = points.first().copied().filter(|s| marquee.is_none() && !self.far && s.distance(p) <= cx.tol(CLICK_TOL_PX));
let split = press.filter(|q| self.stroke_at(cx, *q, erase).is_some());
if std::mem::take(&mut self.touched).is_empty() && split.is_none() {
return vec![];
@@ -816,6 +863,12 @@ impl Tool for ShapeBuilderTool {
let points = split.map_or(points, |q| vec![q]);
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase, "gap": self.gap() });
if let Some((a, b)) = marquee
&& let Some(o) = params.as_object_mut()
{
o.remove("points");
o.insert("rect".into(), json!([a.x, a.y, b.x - a.x, b.y - a.y]));
}
if let Some(t) = Self::line_tol(cx, erase) {
params["tolerance"] = json!(t);
}
@@ -864,6 +917,11 @@ impl Tool for ShapeBuilderTool {
}
}
}
if self.dragging
&& let Some((a, b)) = self.marquee.filter(|(a, b)| a != b)
{
out.push(Overlay::Marquee(Rect::from_points(a, b)));
}
if self.dragging && self.points.len() > 1 {
let mut bp = BezPath::new();
bp.move_to(self.points[0]);
@@ -1365,6 +1423,161 @@ mod tests {
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
}
/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected.
fn grid() -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for k in 0..9 {
let (x, y) = ((k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0);
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 100.0, y + 100.0)), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
/// The paths of an overlay list (a shaded region draws two: its mesh and its outline), and its
/// marquees.
fn paths_and_marquees(ov: &[Overlay]) -> (usize, Vec<Rect>) {
let paths = ov.iter().filter(|o| matches!(o, Overlay::Path { .. })).count();
(paths, ov.iter().filter_map(|o| if let Overlay::Marquee(r) = o { Some(*r) } else { None }).collect())
}
#[test]
fn shape_builder_shift_drag_highlights_and_merges_every_region_in_its_marquee() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let shift = Mods { shift: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0).with_mods(shift));
// Shift may be let go once the drag is on.
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0));
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
assert_eq!(t.touched.regions.len(), 9, "{:?}", t.touched);
assert_eq!(t.touched.regions[0], t.cache.map.region_at(Point::new(250.0, 250.0)).unwrap(), "where the drag started comes first");
assert_eq!(paths_and_marquees(&t.overlays(&cx)), (18, vec![Rect::new(50.0, 50.0, 250.0, 250.0)]), "nine regions, no trail");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["rect"], &v["erase"]), ("shapeBuilder.merge", &json!([250.0, 250.0, -200.0, -200.0]), &json!(false)));
assert!(v.get("points").is_none(), "{v}");
assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee goes with the drag");
}
#[test]
fn shape_builder_alt_shift_drag_marks_the_regions_in_its_marquee_for_deleting() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
// A narrow marquee down the middle column.
t.pointer(&cx, &ev(PointerKind::Down, 140.0, 50.0).with_mods(alt_shift));
t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 250.0).with_mods(alt_shift));
assert_eq!(t.touched.regions.len(), 3);
let ov = t.overlays(&cx);
assert!(ov.iter().all(|o| matches!(o, Overlay::Marquee(_) | Overlay::Path { color: HIGHLIGHT_RED, .. })), "{ov:?}");
assert_eq!(paths_and_marquees(&ov).0, 6, "three regions");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 250.0).with_mods(alt_shift));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((&v["rect"], &v["erase"]), (&json!([140.0, 50.0, 20.0, 200.0]), &json!(true)));
}
/// Without Shift a drag still touches what the pointer passes over; a Shift-click that does not
/// move is a click.
#[test]
fn shape_builder_drag_without_shift_and_shift_click_send_points() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0));
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
assert_eq!(t.touched.regions.len(), 3, "the diagonal");
let (paths, marquees) = paths_and_marquees(&t.overlays(&cx));
assert_eq!((paths, marquees.len()), (7, 0), "three regions and the trail");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[250.0, 250.0], [150.0, 150.0], [50.0, 50.0]]));
assert!(v.get("rect").is_none(), "{v}");
let shift = Mods { shift: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(t.touched.regions.len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0).with_mods(shift));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((&v["points"], v.get("rect")), (&json!([[150.0, 150.0]]), None));
}
#[test]
fn marquee_regions_touch_crossed_outlines_held_anchors_and_the_region_around_it() {
let (d, s) = grid();
let (map, _) = BuilderMap::new(&d, &sorted_roots(&d, &s.objects), false, true);
let at = |x: f64, y: f64| map.region_at(Point::new(x, y)).unwrap();
// Wholly inside the middle square, then crossing into the one to its right.
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(180.0, 180.0)), [at(150.0, 150.0)]);
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(220.0, 180.0)), [at(150.0, 150.0), at(250.0, 150.0)]);
// Ending on a shared side does not reach the square past it.
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(200.0, 180.0)), [at(150.0, 150.0)]);
// Around the whole grid, from outside it: every square, with no region under either corner.
assert_eq!(map.regions_in_marquee(Point::new(-10.0, -10.0), Point::new(310.0, 310.0)).len(), 9);
assert!(map.regions_in_marquee(Point::new(400.0, 400.0), Point::new(450.0, 450.0)).is_empty());
// Where the drag started first, then where it ends, whichever way it runs.
for (a, b) in [((50.0, 50.0), (250.0, 250.0)), ((250.0, 250.0), (50.0, 50.0)), ((250.0, 50.0), (50.0, 250.0))] {
let got = map.regions_in_marquee(a.into(), b.into());
assert_eq!((got.len(), got[0], got[1]), (9, at(a.0, a.1), at(b.0, b.1)), "{a:?} to {b:?}");
}
}
/// With In Merge Mode, Clicking Stroke Splits the Path on, a Shift-drag that starts on a stroke
/// sends its marquee and no split, however little it moves; a Shift-click there splits the
/// stroke as a click does.
#[test]
fn shape_builder_shift_drag_on_a_stroke_merges_and_a_shift_click_splits() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.set_option("clickStrokeSplitsPath", &json!(true));
let shift = Mods { shift: true, ..Default::default() };
// 1 pt outside A's left side, a move of less than 3 pixels, and one across the art.
for (to, rect) in [((0.5, 26.0), json!([-1.0, 25.0, 1.5, 1.0])), ((75.0, 75.0), json!([-1.0, 25.0, 76.0, 50.0]))] {
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift));
let acts = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1).with_mods(shift));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["rect"], v.get("points"), v.get("splitStroke")), (&rect, None, None), "{v}");
}
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0).with_mods(shift));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["splitStroke"], &v["points"], v.get("rect")), (&json!(true), &json!([[-1.0, 25.0]]), None), "{v}");
}
/// While a Shift-drag that started over a stroke a click would split draws its marquee, the
/// pointer shows the Shape Builder, not the scissors, and the stroke is not highlighted.
#[test]
fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.set_option("clickStrokeSplitsPath", &json!(true));
let shift = Mods { shift: true, ..Default::default() };
let highlighted = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Path { color: STROKE_HIGHLIGHT, width: 3.0, .. }));
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), shift), Cursor::Scissors);
assert!(highlighted(&t.overlays(&cx)));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(shift));
let ov = t.overlays(&cx);
assert!(ov.iter().any(|o| matches!(o, Overlay::Marquee(_))) && !highlighted(&ov), "{ov:?}");
assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder);
}
#[test]
fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() {
let (d, s) = corner_rects();
+17
View File
@@ -4590,6 +4590,23 @@ With `pickColorFrom` `swatches`, the run takes the current fill as its stroke. T
{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"points":[[50,-1]],"splitStroke":true}}}
```
With Shift held at the press, a Shape Builder drag draws a rectangular marquee from where the drag started to the
pointer, and the Selection option does not apply to it. The regions the marquee reaches are shaded while it is drawn
and merge into one shape on release; with Alt also held at the press, they are deleted. The marquee reaches a region
when the region's outline crosses the rectangle or has an anchor inside it, or when the region holds the whole
rectangle. The tool sends the marquee to `shapeBuilder.merge` as `rect: [x, y, width, height]` in place of `points`:
`x` and `y` are where the drag started, and the width and height are negative for a drag up or to the left. For Pick
Color From Artwork, the corner where the drag started counts as the drag's first point, and the opposite corner counts
as its last: the merged shape takes the look of the front-most selected object under the first corner, else of the one
under the opposite corner, else of the front-most selected object. A Shift-click that does not move sends `points`
as a click does, so with `clickStrokeSplitsPath` on, a Shift-click on a stroke splits it. A marquee splits no stroke,
however little the pointer moves: the tool sends no `splitStroke` with `rect`, and `shapeBuilder.merge` does not split
a stroke when it has `rect`. With both `rect` and `points`, the command also touches what the points touch.
```json
{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"rect":[50,50,200,200]}}}
```
## Perspective grid
The grid is document data (`perspective.grid.set` edits the model directly). `perspective.grid.get` reads it:
+1
View File
@@ -659,6 +659,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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).
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`).
With In Merge Mode, Clicking Stroke Splits the Path on in the same dialog, a click on a stroke in merge mode splits its path into the run of stroke under the pointer and the rest of the path, as the [Shape Builder tool options](https://web.archive.org/web/20260105172244/https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html) page describes; the run goes from the anchor or crossing point before the pointer to the one after it, and the pointer shows scissors over it. Highlight Stroke When Editable draws that run in the color chosen in its Color list (Red by default), and with Pick Color From Color Swatches the run takes the current fill as its stroke (`shapeBuilder.merge` `splitStroke`).
A Shift-drag with the Shape Builder tool draws a rectangular marquee for merging many paths at once, as [Combine shapes with the Shape Builder tool](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) describes: the regions it reaches are shaded while it is drawn and merge into one shape on release, or with Alt they are deleted. The marquee reaches each region whose outline crosses it or has an anchor inside it, and the region that holds the whole marquee; this rule is VectorCraft's own. For Pick Color From Artwork, the marquee's starting corner is where the drag starts and the opposite corner is where it ends (`shapeBuilder.merge` `rect`).
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.